Two earlier articles secured a FastMCP server two ways: Gate a FastMCP Server to Paying Customers verified opaque license keys you minted and stored yourself, and Add GitHub OAuth delegated login to GitHub. This one takes a third path that sits between them: you run your own token authority that issues asymmetrically signed JWTs, and the server verifies each token’s signature against a JWKS (JSON Web Key Set) it publishes. No per-request database lookup, no third-party login. The signature and claims are self-contained, and only the holder of the private key can mint a valid token.
You will build a small signing service (mint.py), a server that verifies tokens
with FastMCP’s JWTVerifier via jwks_uri, and scope-based authorization so
privileged tools require an extra scope. The stack is Mac-native: uv, make,
and a launchd service.
Why asymmetric? The authority signs with a private key; the server (and anyone) verifies with the public key. The server never holds a secret capable of minting tokens, so publishing its verification keys as a JWKS is safe and is exactly how identity providers expose theirs.
What you will build
keys.py: generate and persist an RSA key, and derive the public key and JWKS.mint.py: a CLI token authority that signs JWTs (subject, scopes, expiry).- A server that verifies tokens with
JWTVerifier(jwks_uri=…)and publishes/.well-known/jwks.json. - Tools:
whoami,word_count(any valid token), androtate_secrets(requires theadminscope). - A
pytestsuite covering the full verification decision table and alaunchddeployment.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - Familiarity with JWTs helps; the claims and signing are explained as they
appear. FastMCP pulls in PyJWT and
cryptography, so there is nothing extra to install.
Everything runs through uv and make. Token verification applies to the HTTP
transport.
Step 1: Scaffold and lock down hygiene
Create the workspace and a .gitignore first. It ignores the RSA private key,
which is the one secret that can mint tokens.
Create the files
mkdir -p jwt-fastmcp-server-macos
cd jwt-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
# Private signing key — never commit
*.pem
jwtRS256.key
# OS / editor noise
.DS_Store
Detailed breakdown
jwtRS256.keyand*.pemhold the RSA private key. Anyone with it can mint tokens your server will trust, so it must never be committed. The public key and JWKS are derived from it at runtime and are safe to expose.
Step 2: Initialize the project with uv
Create the uv project and add FastMCP plus the test tools.
Create the files
uv init --name macmcp --no-workspace
rm -f main.py hello.py
uv add fastmcp
uv add --dev pytest pytest-asyncio
Add the code: pyproject.toml
[project]
name = "macmcp"
version = "0.1.0"
description = "A FastMCP server that verifies signed JWTs via JWKS"
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
fastmcpprovidesJWTVerifierand theRSAKeyPairtest helper, and it depends on PyJWT andcryptography, which the key and mint modules use. No extra dependency is needed.
Step 3: Shared configuration
The minter and the server must agree on the issuer, audience, and where the JWKS lives. Put those in one module.
Create the file
touch config.py
Add the code: config.py
"""Shared JWT configuration for the minter and the server."""
import os
ISSUER = os.environ.get("JWT_ISSUER", "https://auth.macmcp.local")
AUDIENCE = os.environ.get("JWT_AUDIENCE", "macmcp")
BASE_URL = os.environ.get("BASE_URL", "http://127.0.0.1:8000").rstrip("/")
JWKS_URL = os.environ.get("JWKS_URL", f"{BASE_URL}/.well-known/jwks.json")
# Every valid token must carry this scope; premium tools require more.
REQUIRED_SCOPES = ["access"]
Detailed breakdown
ISSUER/AUDIENCEbecome theissandaudclaims. The verifier rejects any token whoseiss/auddo not match, which stops a token minted for one service from being replayed against another.JWKS_URLis where the server publishes its verification keys and where the verifier fetches them. In this self-contained demo the authority and the server share a host; in production the authority (your login/billing service) hosts the JWKS and the MCP server pointsjwks_uriat it.REQUIRED_SCOPESis the baseline every token must carry to reach any tool.
Step 4: Manage the signing key and publish the JWKS
Generate the RSA key once, persist it, and derive the public key and JWKS from it so they never drift apart.
Create the file
touch keys.py
Add the code: keys.py
"""RSA signing key management and JWKS publication.
The token authority signs JWTs with an RSA private key; the server verifies them
with the matching public key, which it publishes as a JWKS document. The private
key is generated once and persisted to a gitignored file; the public key and the
JWKS are derived from it, so they always stay in sync.
"""
import hashlib
import os
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from fastmcp.server.auth.providers.jwt import RSAKeyPair
from jwt.algorithms import RSAAlgorithm
ALGORITHM = "RS256"
def _key_path() -> Path:
return Path(os.environ.get("JWT_PRIVATE_KEY", "jwtRS256.key"))
def private_pem() -> str:
"""Return the PEM private key, generating and persisting it on first use."""
path = _key_path()
if not path.exists():
keypair = RSAKeyPair.generate()
path.write_text(keypair.private_key.get_secret_value())
path.chmod(0o600)
return path.read_text()
def _public_key():
priv = serialization.load_pem_private_key(private_pem().encode(), password=None)
return priv.public_key()
def public_pem() -> str:
"""Return the PEM public key derived from the private key."""
return _public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
def kid() -> str:
"""A stable key id: the first 16 hex chars of the public key's SHA-256."""
return hashlib.sha256(public_pem().encode()).hexdigest()[:16]
def jwks() -> dict:
"""Build a JWKS document (one key) from the public key."""
jwk = RSAAlgorithm.to_jwk(_public_key(), as_dict=True)
jwk.update({"kid": kid(), "use": "sig", "alg": ALGORITHM})
return {"keys": [jwk]}
Detailed breakdown
private_pemgenerates an RSA keypair with FastMCP’sRSAKeyPairon first call, writes the private key to a0600file, and reuses it afterward. The minter and server both read this one file.public_pemderives the public key from the private key withcryptography, so there is no separate public-key file to keep in sync.kidis a stable key id computed from the public key. Tokens carry it in their header, and the JWKS advertises it, so a verifier can pick the right key. Because it is derived from the key, rotating the key changes thekidautomatically.jwksturns the public key into a JWK with PyJWT’sRSAAlgorithm.to_jwkand tags it with thekid,use, andalg. This dict is what the server serves at/.well-known/jwks.json.
Step 5: The token authority (mint.py)
This CLI signs a JWT for a subject with a set of scopes and an expiry. It stands in for whatever issues credentials to your users: a login service, a billing webhook, or an admin tool.
Create the file
touch mint.py
Add the code: mint.py
"""Token authority: mint a signed JWT for a subject.
This stands in for whatever issues credentials to your users (a login service, a
billing webhook, an admin CLI). It signs a JWT with the RSA private key; the
server verifies it against the published JWKS. Run it with `make token`.
uv run python mint.py alice --scopes access,admin --ttl 3600
"""
import argparse
import time
import jwt
import config
import keys
def mint(subject: str, scopes: list[str], ttl: int) -> str:
now = int(time.time())
payload = {
"sub": subject,
"iss": config.ISSUER,
"aud": config.AUDIENCE,
"scope": " ".join(scopes),
"iat": now,
"exp": now + ttl,
}
return jwt.encode(
payload,
keys.private_pem(),
algorithm=keys.ALGORITHM,
headers={"kid": keys.kid()},
)
def main() -> None:
parser = argparse.ArgumentParser(description="Mint a signed JWT")
parser.add_argument("subject", help="the token subject (e.g. a user id)")
parser.add_argument("--scopes", default="access", help="comma-separated scopes")
parser.add_argument("--ttl", type=int, default=3600, help="lifetime in seconds")
args = parser.parse_args()
scopes = [s.strip() for s in args.scopes.split(",") if s.strip()]
print(mint(args.subject, scopes, args.ttl))
if __name__ == "__main__":
main()
Detailed breakdown
- The payload is a standard JWT claim set.
subis the user,iss/audmust match the server’s config,iat/expbound the lifetime, andscopeis a space-separated list (the convention FastMCP parses intoAccessToken.scopes). jwt.encode(..., algorithm="RS256", headers={"kid": ...})signs the payload with the RSA private key and stamps the key id in the header. The server uses thatkidto select the matching public key from the JWKS.- Because signing needs the private key, only this authority can produce tokens the server will trust. Verification needs only the public key.
Step 6: Verify tokens and gate scopes
Wire the verifier to the JWKS, and add a scope gate for privileged tools.
Create the file
touch auth.py
Add the code: auth.py
"""JWT verification wiring and scope-based authorization."""
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
from fastmcp.server.auth.providers.jwt import JWTVerifier
import config
def build_verifier() -> JWTVerifier:
"""Verify incoming JWTs against the JWKS published by the server.
The verifier fetches the public keys from `jwks_uri` and checks each token's
signature, `iss`, `aud`, expiry, and that it carries the required scopes.
"""
return JWTVerifier(
jwks_uri=config.JWKS_URL,
issuer=config.ISSUER,
audience=config.AUDIENCE,
required_scopes=config.REQUIRED_SCOPES,
)
def require_scope(access: AccessToken | None, scope: str) -> None:
"""Raise ToolError unless the caller's token carries `scope`."""
scopes = getattr(access, "scopes", None) or []
if scope not in scopes:
raise ToolError(f"this tool requires the '{scope}' scope")
Detailed breakdown
JWTVerifier(jwks_uri=…, issuer=…, audience=…, required_scopes=…)does the heavy lifting. It fetches and caches the JWKS from the URL, then for every token verifies the RSA signature, thatiss/audmatch, thatexpis in the future, and that the required scopes are present. Any failure rejects the request with401before a tool runs.required_scopes=["access"]enforces a baseline entitlement at the server level. A token withoutaccessnever reaches a tool.require_scopeadds per-tool authorization on top:rotate_secretscalls it to demand theadminscope, so an ordinaryaccesstoken is turned away.
Step 7: The server
Attach the verifier and publish the JWKS on the same HTTP app, so the keys the verifier trusts are served alongside the tools.
Create the file
touch server.py
Add the code: server.py
"""A FastMCP server that verifies signed JWTs via JWKS.
Callers present a JWT signed by the token authority (mint.py). The server
verifies each token's RSA signature against the public keys it publishes at
`/.well-known/jwks.json`, plus the issuer, audience, expiry, and required
scopes. Verification applies to the HTTP transport.
"""
import os
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token
from starlette.requests import Request
from starlette.responses import JSONResponse
import keys
from auth import build_verifier, require_scope
mcp = FastMCP("macmcp", auth=build_verifier())
@mcp.tool
def whoami() -> dict:
"""Return the subject and scopes from the caller's verified token."""
token = get_access_token()
return {"subject": token.client_id, "scopes": token.scopes}
@mcp.tool
def word_count(text: str) -> int:
"""Count words. Available to any token with the 'access' scope."""
return len(text.split())
@mcp.tool
def rotate_secrets() -> dict:
"""A privileged tool. Requires the 'admin' scope."""
require_scope(get_access_token(), "admin")
return {"status": "secrets rotated"}
@mcp.custom_route("/.well-known/jwks.json", methods=["GET"])
async def jwks(request: Request) -> JSONResponse:
"""Publish the public keys clients' tokens are verified against."""
return JSONResponse(keys.jwks())
def main() -> None:
if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "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 (unauthenticated; local dev only)
if __name__ == "__main__":
main()
Detailed breakdown
FastMCP("macmcp", auth=build_verifier())enforces JWT verification on every HTTP request.get_access_token()inside a tool returns the verified token, sowhoamireports thesuband scopes.rotate_secretsdemonstrates authorization: it reaches for theadminscope and raisesToolErrorif the token lacks it, even though the token is otherwise valid.@mcp.custom_route("/.well-known/jwks.json")serves the JWKS on the same app the verifier reads from, so the deployment is self-contained. In production you would instead pointjwks_uriat your identity provider’s JWKS and drop this route.
Step 8: Test the verification decision table
Verification logic is the security boundary, so test it directly. Generate an
isolated key per test, mint tokens, and verify them with JWTVerifier(public_key=…).
That runs the same checks the server does via JWKS, without an HTTP round-trip.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_jwt.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Add the code: tests/test_jwt.py
"""Test JWT verification directly against a static public key.
A fresh RSA keypair is generated per test session and its private key is written
to a temp file, so mint.py and keys.py operate on an isolated key. Verification
uses JWTVerifier(public_key=...) — the same logic the server runs via JWKS, but
without an HTTP round-trip, so the decision table is hermetic.
"""
import importlib
import time
import jwt as pyjwt
import pytest
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
from fastmcp.server.auth.providers.jwt import JWTVerifier
ISSUER = "https://auth.macmcp.local"
AUDIENCE = "macmcp"
@pytest.fixture()
def kit(tmp_path, monkeypatch):
monkeypatch.setenv("JWT_PRIVATE_KEY", str(tmp_path / "key.pem"))
import config
import keys
import mint
importlib.reload(config)
importlib.reload(keys)
importlib.reload(mint)
verifier = JWTVerifier(
public_key=keys.public_pem(),
issuer=ISSUER,
audience=AUDIENCE,
required_scopes=["access"],
)
return mint, keys, verifier
async def test_valid_token_verifies(kit):
mint, _, verifier = kit
token = mint.mint("alice", ["access", "admin"], ttl=60)
result = await verifier.verify_token(token)
assert result is not None
assert result.client_id == "alice"
assert set(result.scopes) == {"access", "admin"}
async def test_expired_token_rejected(kit):
mint, _, verifier = kit
token = mint.mint("alice", ["access"], ttl=-10) # already expired
assert await verifier.verify_token(token) is None
async def test_missing_required_scope_rejected(kit):
mint, _, verifier = kit
token = mint.mint("alice", ["something-else"], ttl=60) # lacks "access"
assert await verifier.verify_token(token) is None
async def test_wrong_audience_rejected(kit):
mint, keys_mod, verifier = kit
now = int(time.time())
token = pyjwt.encode(
{"sub": "a", "iss": ISSUER, "aud": "some-other-api",
"scope": "access", "iat": now, "exp": now + 60},
keys_mod.private_pem(), algorithm="RS256", headers={"kid": keys_mod.kid()},
)
assert await verifier.verify_token(token) is None
async def test_tampered_signature_rejected(kit):
mint, _, verifier = kit
token = mint.mint("alice", ["access"], ttl=60)
tampered = token[:-4] + ("aaaa" if not token.endswith("aaaa") else "bbbb")
assert await verifier.verify_token(tampered) is None
def test_jwks_document_shape(kit):
_, keys_mod, _ = kit
doc = keys_mod.jwks()
assert doc["keys"] and doc["keys"][0]["kty"] == "RSA"
assert doc["keys"][0]["kid"] == keys_mod.kid()
assert doc["keys"][0]["alg"] == "RS256"
def test_require_scope_gate():
from auth import require_scope
admin = AccessToken(token="t", client_id="a", scopes=["access", "admin"])
require_scope(admin, "admin") # no raise
with pytest.raises(ToolError):
require_scope(AccessToken(token="t", client_id="a", scopes=["access"]), "admin")
Detailed breakdown
- The
kitfixture pointsJWT_PRIVATE_KEYat a temp file and reloads the modules, so each test gets a fresh, isolated key and never touches your realjwtRS256.key. - The verification tests cover the whole decision table: a valid token yields
the right subject and scopes; expired, missing-required-scope, wrong-audience,
and tampered-signature tokens all return
None. ReturningNoneis what the server turns into a401. test_jwks_document_shapeconfirms the published JWKS is a well-formed RSA JWK whosekidmatches the signing key, which is what lets a verifier find the right key.test_require_scope_gateproves the per-tool authorization independently of transport.
Run them:
uv run pytest -v
All tests pass, with no server running.
Step 9: The Makefile
Wrap development, token minting, and the launchd deployment. Plain make prints
help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# --- Deploy configuration (launchd user agent) --------------------------------
LABEL := com.macmcp.jwt
PLIST := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV := $(shell command -v uv)
DIR := $(shell pwd)
DOMAIN := gui/$(shell id -u)
# `make token` / `make smoke` arguments
SUBJECT ?= alice
SCOPES ?= access,admin
.PHONY: help install serve test token 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
serve: ## 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
token: ## Mint a JWT: make token SUBJECT=alice SCOPES=access,admin
@uv run python mint.py $(SUBJECT) --scopes $(SCOPES)
smoke: ## Call tools with a freshly minted token (SUBJECT/SCOPES overridable)
@JWT="$$(uv run python mint.py $(SUBJECT) --scopes $(SCOPES))" uv run python scripts/smoke.py
deploy: ## 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 logs (keeps the signing key)
rm -rf .pytest_cache logs __pycache__ tests/__pycache__
Detailed breakdown
tokenmints a JWT you can paste into any client’sAuthorization: Bearerheader.smokemints one and calls the tools in one step; overrideSCOPESto see theadmingate.cleankeeps the signing key so cleaning caches never invalidates already issued tokens. The launchd lifecycle matches the companion deploy article.
Step 10: Deploy and exercise the flow
Add the launchd plist and a smoke client, then run it end to end.
Create the files
mkdir -p deploy scripts
touch deploy/com.macmcp.jwt.plist.template scripts/smoke.py
Add the code: deploy/com.macmcp.jwt.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.macmcp.jwt</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>BASE_URL</key>
<string>http://127.0.0.1:8000</string>
<key>JWT_PRIVATE_KEY</key>
<string>@DIR@/jwtRS256.key</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>
Add the code: scripts/smoke.py
"""Call the server with a JWT and print the results.
JWT=$(uv run python mint.py alice --scopes access,admin) \
uv run python scripts/smoke.py
"""
import asyncio
import os
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
TOKEN = os.environ.get("JWT", "")
async def main() -> None:
auth = BearerAuth(TOKEN) if TOKEN 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": "signed and verified"})
print("word_count ->", wc.data)
try:
print("rotate_secrets ->", (await client.call_tool("rotate_secrets", {})).data)
except Exception as exc: # noqa: BLE001
print("rotate_secrets DENIED ->", exc)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
- The plist sets
JWT_PRIVATE_KEYto an absolute path so the deployed service reads the same signing key the minter uses.BASE_URLfixes theJWKS_URLthe verifier fetches. smoke.pyattaches the JWT as a bearer token withBearerAuth. With no token it lets you watch the server reject an unauthenticated request.
Deploy and run the flow:
make deploy
make status # state = running, a live pid
Confirm the JWKS is published:
curl -s http://127.0.0.1:8000/.well-known/jwks.json
# {"keys": [{"kty": "RSA", "n": "...", "e": "AQAB", "kid": "...", "use": "sig", "alg": "RS256"}]}
An admin token reaches every tool:
make smoke
# whoami -> {'subject': 'alice', 'scopes': ['access', 'admin']}
# word_count -> 3
# rotate_secrets -> {'status': 'secrets rotated'}
An access-only token is verified but blocked from the privileged tool:
make smoke SUBJECT=bob SCOPES=access
# whoami -> {'subject': 'bob', 'scopes': ['access']}
# word_count -> 3
# rotate_secrets DENIED -> this tool requires the 'admin' scope
An expired token, or no token, cannot get in (both return 401):
JWT=$(make token SUBJECT=carol SCOPES=access) # then wait, or mint with a short ttl
uv run python mint.py carol --ttl -10 > /tmp/expired && \
JWT=$(cat /tmp/expired) uv run python scripts/smoke.py # 401 Unauthorized
Tear down when done:
make undeploy
Troubleshooting
- Every token returns
401. The server cannot fetch or match the JWKS. Confirmcurl $BASE_URL/.well-known/jwks.jsonreturns a key, and that the token’skidheader matches the JWKSkid(it will, as long as the minter and server shareJWT_PRIVATE_KEY). 401right after rotating the key. Old tokens were signed by the previous key and no longer verify. That is the point of rotation. Re-mint tokens after replacingjwtRS256.key.invalid audience/invalid issuer. The token’saud/issdo not match the server’sJWT_AUDIENCE/JWT_ISSUER. Mint with the same config the server runs.rotate_secretsdenied with a valid token. The token lacks theadminscope. Mint with--scopes access,admin.- Production over plain HTTP. Serve JWKS and the MCP endpoint over HTTPS in
production, and set
BASE_URLto the publichttps://URL sojwks_uriis fetched securely.
Recap
You built a server that trusts self-contained, cryptographically signed tokens:
- A token authority (
mint.py) signs JWTs with an RSA private key; the server verifies them with the public key, so it holds no minting secret. JWTVerifier(jwks_uri=…)checks signature,iss,aud, expiry, and required scopes on every request, and the server publishes its JWKS for verification.- Scopes provide both a baseline gate (
required_scopes) and per-tool authorization (require_scope). - The tests cover the full decision table, and
uv,make, andlaunchdpackage and deploy it.
Next improvements
- Move the authority to a separate service and point
jwks_uriat it; keep the private key off the MCP server entirely. - Support key rotation with multiple keys in the JWKS (verifiers pick by
kid), so old and new tokens both validate during a rollover window. - Add short token lifetimes plus a refresh endpoint, instead of long-lived tokens.
- Map scopes to plans and combine with the license article to sell tiered access.