Building an MCP server is easy; making sure only the people who paid for it can call it is the part that turns a demo into a product. In this tutorial you will build a FastMCP server that authenticates every request against a license store: paying customers hold a license key, the server verifies it, and unknown or expired keys are rejected with 401 Unauthorized. Premium tools are further gated so that only the Pro plan can call them.

This builds on Create and Deploy a FastMCP Server on macOS, reusing the same Mac-native stack (uv, make, and a launchd service); the focus here is authentication. The approach follows FastMCP’s server authentication guide: we implement a custom TokenVerifier and attach it with FastMCP(auth=...).

Important: MCP authentication applies only to FastMCP’s HTTP-based transports. The stdio transport inherits the security of its local process and is not authenticated — which is exactly why a deployed, network-reachable server must run over HTTP.

What you will build

  • A license store (licenses.json) mapping each key to a customer, a plan, and an expiry date.
  • A LicenseVerifier (a custom FastMCP TokenVerifier) that rejects unknown and expired keys and grants scopes based on the customer’s plan.
  • A server exposing a whoami tool, a word_count tool for any licensed customer, and a premium_report tool gated to the Pro plan.
  • A pytest suite that tests the verification logic directly, and a client that authenticates with a bearer token over HTTP.
  • A launchd deployment and a make control panel, including a smoke target that proves the gate with different keys.

Prerequisites

  • macOS 13 (Ventura) or newer, with Homebrew (brew.sh).
  • uv 0.5 or newerbrew install uv, then uv --version. uv manages the Python interpreter, so you do not install Python separately.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • Comfort with Python type hints and async/await. FastMCP’s verifier and client APIs are asynchronous.
  • Optional but recommended: read Create and Deploy a FastMCP Server on macOS first for the launchd details, which this article uses but does not re-explain in depth.

We use uv (not pip) for every dependency and run step, and make for every task. All commands assume you are in the project directory unless noted.

Step 1: Scaffold the project and lock down hygiene

Create the workspace and a .gitignore before any other files. Crucially, this .gitignore excludes licenses.json — that file holds real license keys, which are credentials and must never be committed.

Create the files

mkdir -p license-gated-fastmcp-server-macos
cd license-gated-fastmcp-server-macos
touch .gitignore

Add the code: .gitignore

# Python
__pycache__/
*.py[cod]
.venv/

# uv
.uv/

# Tooling caches
.pytest_cache/
.ruff_cache/
.mypy_cache/

# Runtime / deploy artifacts
logs/
*.log
*.pid

# Secrets / license store (never commit real license keys)
licenses.json

# OS / editor noise
.DS_Store

Detailed breakdown

  • licenses.json is the live license store — a list of valid keys and who they belong to. Treat it like a secrets file: it is git-ignored here, and in Step 3 you commit only a non-secret example instead.
  • .venv/ is uv’s virtual environment, reproducible from the lockfile.
  • logs/ holds the output of the deployed service.
  • .DS_Store is Finder’s per-directory metadata on macOS.
  • Creating this first guarantees the very next commands cannot leak keys or build artifacts into a commit.

Step 2: Initialize the project with uv

Turn the folder into a managed uv project, add FastMCP, and add the test tools.

Create the files

uv init --name licensedmcp --no-workspace
rm -f main.py hello.py          # remove the sample file uv scaffolds
uv add fastmcp
uv add --dev pytest pytest-asyncio

Add the code: pyproject.toml

After the commands above, pyproject.toml looks like this (versions may be newer):

[project]
name = "licensedmcp"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.3",
]

[dependency-groups]
dev = [
    "pytest>=9.1.1",
    "pytest-asyncio>=1.4.0",
]

Detailed breakdown

  • fastmcp provides both the server (FastMCP) and the auth building blocks (TokenVerifier, AccessToken) used below.
  • pytest-asyncio lets pytest run the async verify_token method directly.
  • --no-workspace keeps this standalone even inside a larger repo.

Step 3: Define the license store

The license store maps each key to a customer, a plan, and an expiry date. You commit a non-secret example; developers copy it to the real licenses.json (which is git-ignored). In production this would be a database populated by your billing system when a customer pays.

Create the file

touch licenses.example.json

Add the code: licenses.example.json

{
  "LIC-PRO-7F3A9C21": {
    "customer": "[email protected]",
    "plan": "pro",
    "expires": "2099-01-01"
  },
  "LIC-BASIC-2B8D14E0": {
    "customer": "[email protected]",
    "plan": "basic",
    "expires": "2099-01-01"
  },
  "LIC-PRO-EXPIRED00": {
    "customer": "[email protected]",
    "plan": "pro",
    "expires": "2020-01-01"
  }
}

Create your working store from the example:

cp licenses.example.json licenses.json

Detailed breakdown

  • Keys (LIC-PRO-7F3A9C21, …) are the bearer tokens customers present. Real keys should be long and random; these are illustrative.
  • plan drives entitlements: basic customers get the standard tools; pro customers additionally get premium tools.
  • expires models a subscription. The LIC-PRO-EXPIRED00 entry is deliberately in the past so you can watch the server reject a lapsed subscription in Step 9.
  • Committing only the example keeps real keys out of git while giving anyone who clones the repo a template to seed from.

Step 4: Write the license verifier

This is the heart of the tutorial. A FastMCP auth provider is a TokenVerifier subclass with one async method, verify_token, that returns an AccessToken for a valid credential or None to reject it. We also add a small require_scope helper for gating individual tools.

Create the file

touch auth.py

Add the code: auth.py

"""License-based authentication for the MCP server.

Paying customers are issued a license key (a bearer token). The server keeps a
license store mapping each key to a customer, a plan, and an expiry date. The
`LicenseVerifier` turns that store into a FastMCP auth provider: unknown or
expired keys are rejected, and a valid key is granted scopes based on its plan.
"""

import json
import os
from datetime import datetime, timezone
from pathlib import Path

from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken, TokenVerifier

# Which scopes each paid plan grants. Every valid license is "licensed"; only
# the Pro plan additionally gets "pro", which premium tools require.
PLAN_SCOPES = {
    "basic": ["licensed"],
    "pro": ["licensed", "pro"],
}

DEFAULT_DB = "licenses.json"


def _db_path() -> Path:
    return Path(os.environ.get("LICENSE_DB", DEFAULT_DB))


def _expiry_ts(expires: str) -> int:
    """Convert a YYYY-MM-DD expiry date to a Unix timestamp (UTC end of day)."""
    day = datetime.strptime(expires, "%Y-%m-%d").replace(tzinfo=timezone.utc)
    return int(day.timestamp())


class LicenseVerifier(TokenVerifier):
    """Validate a license key against the license store."""

    def __init__(self, db_path: Path | None = None):
        super().__init__()
        self._db_path = db_path or _db_path()

    def _load(self) -> dict:
        # Read on every call so revoking a key takes effect without a restart.
        try:
            return json.loads(self._db_path.read_text())
        except FileNotFoundError:
            return {}

    async def verify_token(self, token: str) -> AccessToken | None:
        record = self._load().get(token)
        if record is None:
            return None  # unknown key -> 401

        expires_at = _expiry_ts(record["expires"])
        if expires_at < int(datetime.now(timezone.utc).timestamp()):
            return None  # lapsed subscription -> 401

        scopes = PLAN_SCOPES.get(record["plan"], [])
        if not scopes:
            return None  # unrecognized plan -> deny by default

        return AccessToken(
            token=token,
            client_id=record["customer"],
            scopes=scopes,
            expires_at=expires_at,
            claims={"plan": record["plan"]},
        )


def require_scope(access: AccessToken | None, scope: str) -> None:
    """Raise a ToolError if the caller's token lacks the given scope."""
    if access is None or scope not in access.scopes:
        raise ToolError(f"This tool requires the '{scope}' entitlement. Upgrade your plan.")

Detailed breakdown

  • TokenVerifier / AccessToken come from fastmcp.server.auth. Subclassing TokenVerifier and overriding the async verify_token(self, token) -> AccessToken | None is the documented way to plug custom credential logic into FastMCP.
  • Returning None rejects the request. FastMCP turns that into a 401 Unauthorized before your tools ever run. We return None for three cases: unknown key, expired key, and unrecognized plan (deny-by-default).
  • _load() reads the store on every call. That means revoking a key — a refund, a chargeback, a canceled plan — takes effect immediately, with no server restart. For a large store you would cache with a short TTL or use a database.
  • AccessToken fields. client_id is the authenticated identity (the customer email); scopes are the entitlements derived from the plan; expires_at is a Unix timestamp (an int) — note it is not a datetime — so FastMCP can also enforce expiry itself; claims carries the plan through to the tools.
  • require_scope is a plain helper, not FastMCP magic: it raises a ToolError when the caller lacks an entitlement. Keeping it a pure function makes it trivial to unit test (Step 6) and reusable across tools.
  • LICENSE_DB env var lets the deployed service point at an absolute store path while tests point at a temp file.

Step 5: Write the server and gate the tools

Attach the verifier with FastMCP(auth=...). Once attached, every tool requires a valid license. Inside a tool, get_access_token() returns the caller’s AccessToken, which premium_report uses to enforce the Pro plan.

Create the file

touch server.py

Add the code: server.py

"""A license-gated FastMCP server for macOS.

Only callers presenting a valid, unexpired license key can reach any tool.
Premium tools additionally require the Pro plan. Auth applies to the HTTP
transport, which is what you deploy; stdio is for unauthenticated local dev.
"""

import os

from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token

from auth import LicenseVerifier, require_scope

mcp = FastMCP("licensedmcp", auth=LicenseVerifier())


@mcp.tool
def whoami() -> dict:
    """Return the calling customer's identity and entitlements."""
    token = get_access_token()
    return {
        "customer": token.client_id,
        "plan": token.claims.get("plan"),
        "scopes": token.scopes,
    }


@mcp.tool
def word_count(text: str) -> int:
    """Count words in text. Available to any licensed customer."""
    return len(text.split())


@mcp.tool
def premium_report(text: str) -> dict:
    """Produce a detailed report. Requires a Pro license."""
    require_scope(get_access_token(), "pro")
    words = text.split()
    return {
        "words": len(words),
        "characters": len(text),
        "unique_words": len({w.lower() for w in words}),
    }


def _transport() -> str:
    return os.environ.get("MCP_TRANSPORT", "stdio").lower()


def main() -> None:
    if _transport() == "http":
        mcp.run(
            transport="http",
            host=os.environ.get("MCP_HOST", "127.0.0.1"),
            port=int(os.environ.get("MCP_PORT", "8000")),
        )
    else:
        mcp.run()  # stdio (no auth enforced; local dev only)


if __name__ == "__main__":
    main()

Detailed breakdown

  • FastMCP("licensedmcp", auth=LicenseVerifier()) is the whole integration: passing a verifier as auth makes FastMCP authenticate every HTTP request before dispatching to a tool. No per-tool boilerplate is needed for the basic “must be licensed” gate.
  • get_access_token() (from fastmcp.server.dependencies) returns the AccessToken for the current request inside a tool. whoami uses it to report the customer and plan back to the caller — handy for confirming who a key belongs to.
  • premium_report calls require_scope(get_access_token(), "pro") first. A Basic customer reaches the tool (they are licensed) but is turned away with the ToolError before any work happens. This is plan-level entitlement on top of the server-wide auth gate.
  • The transport switch mirrors the companion deploy article: stdio for local development (unauthenticated by design) and http for the deployed, authenticated service. Setting MCP_TRANSPORT=http is what turns the gate on.

Step 6: Test the verifier and the gate

Because auth is enforced only over HTTP, the fastest way to test the logic is to call verify_token and require_scope directly, against a temporary license store. No server, no network.

Create the files

mkdir -p tests
touch tests/__init__.py tests/test_auth.py pytest.ini

Add the code: pytest.ini

[pytest]
asyncio_mode = auto

Add the code: tests/test_auth.py

"""Test the license verifier and scope gate directly, with no transport.

Auth is only enforced over HTTP, so these tests exercise the verification logic
itself against a temporary license store instead of standing up a server.
"""

import json

import pytest
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken

from auth import LicenseVerifier, require_scope

LICENSES = {
    "LIC-PRO-VALID": {"customer": "[email protected]", "plan": "pro", "expires": "2099-01-01"},
    "LIC-BASIC-VALID": {"customer": "[email protected]", "plan": "basic", "expires": "2099-01-01"},
    "LIC-PRO-EXPIRED": {"customer": "[email protected]", "plan": "pro", "expires": "2020-01-01"},
}


@pytest.fixture
def verifier(tmp_path):
    db = tmp_path / "licenses.json"
    db.write_text(json.dumps(LICENSES))
    return LicenseVerifier(db_path=db)


async def test_valid_pro_key_grants_pro_scope(verifier):
    token = await verifier.verify_token("LIC-PRO-VALID")
    assert token is not None
    assert token.client_id == "[email protected]"
    assert set(token.scopes) == {"licensed", "pro"}
    assert token.claims["plan"] == "pro"


async def test_valid_basic_key_has_no_pro_scope(verifier):
    token = await verifier.verify_token("LIC-BASIC-VALID")
    assert token is not None
    assert token.scopes == ["licensed"]
    assert "pro" not in token.scopes


async def test_unknown_key_is_rejected(verifier):
    assert await verifier.verify_token("LIC-DOES-NOT-EXIST") is None


async def test_expired_key_is_rejected(verifier):
    assert await verifier.verify_token("LIC-PRO-EXPIRED") is None


def test_require_scope_allows_holder():
    access = AccessToken(token="t", client_id="c", scopes=["licensed", "pro"])
    require_scope(access, "pro")  # should not raise


def test_require_scope_denies_missing_scope():
    access = AccessToken(token="t", client_id="c", scopes=["licensed"])
    with pytest.raises(ToolError):
        require_scope(access, "pro")


def test_require_scope_denies_anonymous():
    with pytest.raises(ToolError):
        require_scope(None, "pro")

Detailed breakdown

  • The verifier fixture writes a throwaway store under pytest’s tmp_path and points a LicenseVerifier at it, so tests never touch your real licenses.json.
  • The four verify_token tests cover the whole decision table: a valid Pro key gets both scopes, a valid Basic key lacks pro, an unknown key is None, and an expired key is None.
  • The three require_scope tests prove the gate: a Pro holder passes, a Basic holder raises ToolError, and an anonymous (None) caller raises too.
  • asyncio_mode = auto lets the async def tests run without per-test event-loop boilerplate.

Run them:

uv run pytest -v

You should see seven passing tests, with no server running.

Step 7: Write the client (bearer-token auth)

A client authenticates by sending its license key as a bearer token. FastMCP’s Client takes an auth=BearerAuth(key) argument, which sets the Authorization: Bearer <key> header on every request.

Create the file

mkdir -p scripts
touch scripts/smoke.py

Add the code: scripts/smoke.py

"""Connect to the running HTTP server with a license key and call tools.

Usage:
    LICENSE_KEY=LIC-PRO-7F3A9C21 uv run python scripts/smoke.py
"""

import asyncio
import os
import sys

from fastmcp import Client
from fastmcp.client.auth import BearerAuth

URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
KEY = os.environ.get("LICENSE_KEY", "")


async def main() -> int:
    auth = BearerAuth(KEY) if KEY else None
    async with Client(URL, auth=auth) as client:
        print("whoami ->", (await client.call_tool("whoami", {})).data)
        wc = await client.call_tool("word_count", {"text": "licensed users only"})
        print("word_count ->", wc.data)
        try:
            report = await client.call_tool("premium_report", {"text": "pro feature test"})
            print("premium_report ->", report.data)
        except Exception as exc:  # noqa: BLE001 - surface the gate to the user
            print("premium_report DENIED ->", exc)
    return 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))

Detailed breakdown

  • BearerAuth(KEY) (from fastmcp.client.auth) attaches the license key as a bearer token. Passing auth=None (no key) lets you observe the server rejecting an unauthenticated request.
  • whoami and word_count succeed for any valid license. premium_report is wrapped in try/except so that when a Basic key hits the Pro gate, the script prints the denial instead of crashing — you see the entitlement working.
  • LICENSE_KEY / MCP_URL env vars let one script exercise every scenario: swap the key to switch customers, or point at a different host.

Step 8: Deploy as a launchd service

Deployment mirrors the companion article: a launchd user agent runs the HTTP server, restarts it on crash, and logs to logs/. The one addition is a LICENSE_DB environment variable pointing at the absolute path of the store.

Create the file

mkdir -p deploy
touch deploy/com.licensedmcp.server.plist.template

Add the code: deploy/com.licensedmcp.server.plist.template

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.licensedmcp.server</string>

    <key>ProgramArguments</key>
    <array>
        <string>@UV@</string>
        <string>run</string>
        <string>python</string>
        <string>server.py</string>
    </array>

    <key>WorkingDirectory</key>
    <string>@DIR@</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>MCP_TRANSPORT</key>
        <string>http</string>
        <key>MCP_HOST</key>
        <string>127.0.0.1</string>
        <key>MCP_PORT</key>
        <string>8000</string>
        <key>LICENSE_DB</key>
        <string>@DIR@/licenses.json</string>
        <key>PATH</key>
        <string>@PATH@</string>
    </dict>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>@DIR@/logs/server.out.log</string>

    <key>StandardErrorPath</key>
    <string>@DIR@/logs/server.err.log</string>
</dict>
</plist>

Detailed breakdown

  • MCP_TRANSPORT=http is what makes the deployed server enforce auth — the whole point of deploying over HTTP rather than stdio.
  • LICENSE_DB=@DIR@/licenses.json gives the service an absolute path to the store, because launchd runs with a bare environment and an unknown working directory relative to your shell. The @DIR@ placeholder is filled in by make deploy.
  • @UV@ / @PATH@ are substituted with the absolute uv path and a minimal PATH so launchd can find uv and system tools. RunAtLoad + KeepAlive make it start at login and restart on crash. See the companion deploy article for a deeper walk-through of these keys.

Step 9: The Makefile and the end-to-end proof

The Makefile drives development, deployment, and (most importantly) a smoke target that demonstrates the license gate with different keys. Plain make prints a help screen.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

# --- Deploy configuration (launchd user agent) --------------------------------
LABEL   := com.licensedmcp.server
PLIST   := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV      := $(shell command -v uv)
DIR     := $(shell pwd)
DOMAIN  := gui/$(shell id -u)

# License key used by `make smoke` (override: make smoke KEY=LIC-...)
KEY ?= LIC-PRO-7F3A9C21

.PHONY: help install seed serve test smoke deploy undeploy status logs clean

help:  ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2}'

install:  ## Sync runtime and dev dependencies
	uv sync

seed:  ## Create licenses.json from the example (does not overwrite)
	@test -f licenses.json && echo "licenses.json already exists" \
		|| (cp licenses.example.json licenses.json && echo "Created licenses.json")

serve: seed  ## Run the HTTP server in the foreground (Ctrl-C to stop)
	MCP_TRANSPORT=http uv run python server.py

test:  ## Run the test suite
	uv run pytest -v

smoke:  ## Call tools against the running server with a license key (KEY=...)
	LICENSE_KEY=$(KEY) uv run python scripts/smoke.py

deploy: seed  ## Render the launchd plist and start the service
	@mkdir -p logs
	@sed -e 's#@UV@#$(UV)#g' \
	     -e 's#@DIR@#$(DIR)#g' \
	     -e 's#@PATH@#$(dir $(UV)):/usr/bin:/bin#g' \
	     deploy/$(LABEL).plist.template > $(PLIST)
	launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
	launchctl bootstrap $(DOMAIN) $(PLIST)
	@echo "Deployed $(LABEL). Check: make status"

undeploy:  ## Stop the service and remove the launchd plist
	launchctl bootout $(DOMAIN) $(PLIST) 2>/dev/null || true
	rm -f $(PLIST)
	@echo "Removed $(LABEL)."

status:  ## Show whether the service is registered and running
	@launchctl print $(DOMAIN)/$(LABEL) 2>/dev/null \
		| grep -E 'state =|pid =' || echo "$(LABEL) is not loaded."

logs:  ## Tail the service log files
	@tail -n 40 -f logs/server.out.log logs/server.err.log

clean:  ## Remove caches and local logs
	rm -rf .pytest_cache logs __pycache__ tests/__pycache__

Detailed breakdown

  • seed copies the example store into licenses.json if it does not already exist; serve and deploy depend on it so the server always has a store.
  • smoke takes a KEY (default the sample Pro key). make smoke KEY=LIC-BASIC-2B8D14E0 runs the same script as a Basic customer, which is how you see the Pro gate reject premium_report.
  • deploy / undeploy / status / logs are the same launchd lifecycle as the companion article; .DEFAULT_GOAL := help plus the grep/awk line make a bare make self-document.

Now prove the whole thing. Confirm the help screen, run the tests, then deploy:

make            # help screen (11 targets)
make test       # 7 passing tests
make deploy
make status     # state = running, a live pid

A valid Pro key gets everything:

make smoke
# whoami -> {'customer': '[email protected]', 'plan': 'pro', 'scopes': ['licensed', 'pro']}
# word_count -> 3
# premium_report -> {'words': 3, 'characters': 16, 'unique_words': 3}

A valid Basic key is licensed but blocked from the premium tool:

make smoke KEY=LIC-BASIC-2B8D14E0
# whoami -> {'customer': '[email protected]', 'plan': 'basic', 'scopes': ['licensed']}
# word_count -> 3
# premium_report DENIED -> This tool requires the 'pro' entitlement. Upgrade your plan.

An expired key — or no key at all — cannot get in:

make smoke KEY=LIC-PRO-EXPIRED00     # httpx.HTTPStatusError: 401 Unauthorized
LICENSE_KEY= uv run python scripts/smoke.py   # 401 Unauthorized (no credential)

Because the verifier re-reads the store on every request, you can revoke a customer live: delete their key from licenses.json and their very next call returns 401, no restart required. When you are done:

make undeploy
make status     # com.licensedmcp.server is not loaded.

Troubleshooting

  • Everything returns 401, even a key you copied from the example. Make sure the server is reading the same store you edited. The deployed service uses LICENSE_DB (an absolute path); make deploy sets it for you. Confirm licenses.json exists (make seed).
  • whoami works but premium_report is denied. That is the gate working — the key is a Basic plan. Use a pro key (LIC-PRO-7F3A9C21).
  • A tool errors with “get_access_token” returning None. You are calling over stdio, where auth is not enforced and there is no token. Run with MCP_TRANSPORT=http (via make serve/make deploy) to exercise auth.
  • make deploy fails to start; logs show a JSON error. licenses.json is malformed. Validate it with uv run python -m json.tool licenses.json.
  • You accidentally committed licenses.json. It is git-ignored, so this should not happen — but if it did, rotate every key in it. Keys in git history are compromised.

Recap

You built a FastMCP server that only serves paying customers:

  • A custom LicenseVerifier (TokenVerifier) rejects unknown and expired keys with 401, following FastMCP’s authentication guide.
  • FastMCP(auth=...) enforces “must be licensed” on every HTTP request; require_scope + get_access_token() add per-plan entitlements so only Pro customers reach premium tools.
  • Auth is HTTP-only, which is exactly why the deployed, network-reachable server runs over HTTP under launchd.
  • uv, make, and a launchd plist package it into a supervised macOS service, and make smoke KEY=... proves the gate from valid Pro, valid Basic, expired, and anonymous callers.

Next improvements

  • Replace the JSON file with a database, and populate it from your billing provider’s webhook (Stripe, Paddle, etc.) when a subscription starts or ends.
  • Issue signed JWTs instead of opaque keys and switch to FastMCP’s JWTVerifier (jwks_uri / issuer / audience) so you can verify without a central lookup.
  • Add rate limits or per-plan quotas keyed on access_token.client_id.
  • Put the server behind TLS (Caddy or nginx) if customers connect from other machines instead of 127.0.0.1.