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 FastMCPTokenVerifier) that rejects unknown and expired keys and grants scopes based on the customer’s plan. - A server exposing a
whoamitool, aword_counttool for any licensed customer, and apremium_reporttool gated to the Pro plan. - A
pytestsuite that tests the verification logic directly, and a client that authenticates with a bearer token over HTTP. - A
launchddeployment and amakecontrol panel, including asmoketarget that proves the gate with different keys.
Prerequisites
- macOS 13 (Ventura) or newer, with Homebrew (brew.sh).
- uv 0.5 or newer —
brew install uv, thenuv --version.uvmanages the Python interpreter, so you do not install Python separately. - Xcode Command Line Tools (
xcode-select --install) formake. - 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
launchddetails, 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.jsonis 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/isuv’s virtual environment, reproducible from the lockfile.logs/holds the output of the deployed service..DS_Storeis 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
fastmcpprovides both the server (FastMCP) and the auth building blocks (TokenVerifier,AccessToken) used below.pytest-asyncioletspytestrun the asyncverify_tokenmethod directly.--no-workspacekeeps 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. plandrives entitlements:basiccustomers get the standard tools;procustomers additionally get premium tools.expiresmodels a subscription. TheLIC-PRO-EXPIRED00entry 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/AccessTokencome fromfastmcp.server.auth. SubclassingTokenVerifierand overriding the asyncverify_token(self, token) -> AccessToken | Noneis the documented way to plug custom credential logic into FastMCP.- Returning
Nonerejects the request. FastMCP turns that into a401 Unauthorizedbefore your tools ever run. We returnNonefor 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.AccessTokenfields.client_idis the authenticated identity (the customer email);scopesare the entitlements derived from the plan;expires_atis a Unix timestamp (anint) — note it is not adatetime— so FastMCP can also enforce expiry itself;claimscarries the plan through to the tools.require_scopeis a plain helper, not FastMCP magic: it raises aToolErrorwhen the caller lacks an entitlement. Keeping it a pure function makes it trivial to unit test (Step 6) and reusable across tools.LICENSE_DBenv 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 asauthmakes 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()(fromfastmcp.server.dependencies) returns theAccessTokenfor the current request inside a tool.whoamiuses it to report the customer and plan back to the caller — handy for confirming who a key belongs to.premium_reportcallsrequire_scope(get_access_token(), "pro")first. A Basic customer reaches the tool (they are licensed) but is turned away with theToolErrorbefore any work happens. This is plan-level entitlement on top of the server-wide auth gate.- The transport switch mirrors the companion deploy article:
stdiofor local development (unauthenticated by design) andhttpfor the deployed, authenticated service. SettingMCP_TRANSPORT=httpis 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
verifierfixture writes a throwaway store under pytest’stmp_pathand points aLicenseVerifierat it, so tests never touch your reallicenses.json. - The four
verify_tokentests cover the whole decision table: a valid Pro key gets both scopes, a valid Basic key lackspro, an unknown key isNone, and an expired key isNone. - The three
require_scopetests prove the gate: a Pro holder passes, a Basic holder raisesToolError, and an anonymous (None) caller raises too. asyncio_mode = autolets theasync deftests 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)(fromfastmcp.client.auth) attaches the license key as a bearer token. Passingauth=None(no key) lets you observe the server rejecting an unauthenticated request.whoamiandword_countsucceed for any valid license.premium_reportis wrapped intry/exceptso 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_URLenv 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=httpis what makes the deployed server enforce auth — the whole point of deploying over HTTP rather than stdio.LICENSE_DB=@DIR@/licenses.jsongives the service an absolute path to the store, becauselaunchdruns with a bare environment and an unknown working directory relative to your shell. The@DIR@placeholder is filled in bymake deploy.@UV@/@PATH@are substituted with the absoluteuvpath and a minimalPATHsolaunchdcan finduvand system tools.RunAtLoad+KeepAlivemake 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
seedcopies the example store intolicenses.jsonif it does not already exist;serveanddeploydepend on it so the server always has a store.smoketakes aKEY(default the sample Pro key).make smoke KEY=LIC-BASIC-2B8D14E0runs the same script as a Basic customer, which is how you see the Pro gate rejectpremium_report.deploy/undeploy/status/logsare the same launchd lifecycle as the companion article;.DEFAULT_GOAL := helpplus thegrep/awkline make a baremakeself-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 usesLICENSE_DB(an absolute path);make deploysets it for you. Confirmlicenses.jsonexists (make seed). whoamiworks butpremium_reportis denied. That is the gate working — the key is a Basic plan. Use aprokey (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 withMCP_TRANSPORT=http(viamake serve/make deploy) to exercise auth. make deployfails to start; logs show a JSON error.licenses.jsonis malformed. Validate it withuv 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 with401, 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 alaunchdplist package it into a supervised macOS service, andmake 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.