In Add GitHub OAuth to a FastMCP Server you delegated login to GitHub. That is
a good fit when your users already have GitHub accounts, but it ties your server
to one social provider. This tutorial uses Clerk instead: a
full authentication platform where you own the user directory. Clerk gives
you email/password, magic links, social logins, and MFA behind a single OAuth
authorization server, and FastMCP’s ClerkProvider
plugs that server into your MCP server with a few lines of configuration.
Your server never sees a password. The MCP client runs the OAuth 2.0 flow
(Dynamic Client Registration, browser login, PKCE, token exchange) against Clerk,
and your tools read the authenticated user’s identity from the verified token.
You will then add a thin authorization layer: an allowlist of email
addresses, so only specific users can call your privileged tools. The stack is
Mac-native: uv, make, and a launchd service.
Authentication vs authorization. Clerk tells you who the caller is (authentication). Whether that person may use a tool is your decision (authorization) — here, an allowlist keyed on their verified email.
How the flow works
ClerkProvider runs your server as an OAuth proxy in front of your Clerk
instance. It serves the standard discovery and OAuth endpoints (/authorize,
/token, /register, /auth/callback) and brokers logins to Clerk:
- The MCP client discovers the server needs auth (a
401with aWWW-Authenticatechallenge) and reads the server’s OAuth metadata. - The client dynamically registers itself (
/register) and starts an OAuth flow with PKCE. - The user’s browser opens to Clerk’s hosted sign-in to authenticate.
- Clerk redirects back to
/auth/callback; the server exchanges the code for a token and issues the client a token to call tools with.
On every tool call, ClerkProvider verifies the presented token against Clerk’s
introspection endpoint (RFC 7662) for the security-critical checks (active,
audience, scopes), then enriches it from the userinfo endpoint (email, name).
FastMCP’s client does the login side (steps 1–4) with a single auth="oauth"
argument.
What you will build
- A server authenticated by Clerk via
ClerkProvider. - Tools:
whoami(the caller’s Clerk identity),word_count(any authenticated user), andmembers_only(allowlisted emails only). - A
pytestsuite that verifies the401challenge, the OAuth discovery documents, and the authorization logic — without a real login. - A
launchddeployment that keeps your Clerk secret out of the repo.
Prerequisites
- macOS 13+ with Homebrew (brew.sh).
- uv 0.5+ —
brew install uv; verifyuv --version. - Xcode Command Line Tools (
xcode-select --install) formake. - A Clerk account (clerk.com, free tier is enough), to create an application and an OAuth application in Step 3.
- Familiarity with OAuth concepts is helpful; the moving parts are explained as they appear.
Everything runs through uv and make. OAuth applies to the HTTP transport.
Step 1: Scaffold and lock down hygiene
Create the workspace and a .gitignore first. It ignores .env, which will hold
your Clerk client secret — a credential that must never be committed.
Create the files
mkdir -p clerk-fastmcp-server-macos
cd clerk-fastmcp-server-macos
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
.uv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
logs/
*.log
*.pid
# Secrets — never commit Clerk client credentials
.env
# OS / editor noise
.DS_Store
Detailed breakdown
.envwill holdCLERK_CLIENT_IDandCLERK_CLIENT_SECRET. Ignoring it first means the secret cannot slip into a commit. You will commit a placeholder.env.exampleinstead.ClerkProviderkeeps its own OAuth state (client registrations, encrypted tokens) in an OS data directory outside the repo, so nothing else needs ignoring here.
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 authenticated with Clerk"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastmcp>=3.4.4",
]
[dependency-groups]
dev = [
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
]
Detailed breakdown
fastmcpshipsClerkProviderand the OAuth machinery; there is nothing else to install for auth.
Step 3: Create a Clerk OAuth application and configure the server
Clerk is both your user directory and your OAuth authorization server. You need two things from the Clerk Dashboard: your instance domain and an OAuth application (client ID + secret).
- Create an application at https://dashboard.clerk.com (or use an existing
one). Note its Frontend API / instance domain, which looks like
your-app-name.clerk.accounts.dev. - Go to Configure → OAuth applications → Add OAuth application.
- Set the Redirect URI to
http://localhost:8000/auth/callback(this must match yourBASE_URLand FastMCP’s default callback path). - Enable the
emailandprofilescopes (Clerk always includesopenidfor OIDC). These let the server read the caller’s email and name. - Copy the Client ID and Client secret. Clerk shows the secret once — store it now.
Commit a non-secret template so others know what to fill in.
Create the files
touch .env.example
cp .env.example .env # then edit .env with real values
Add the code: .env.example
# Copy to .env and fill in from your Clerk Dashboard
# (https://dashboard.clerk.com → Configure → OAuth applications).
# .env is gitignored.
CLERK_DOMAIN=your-app-name.clerk.accounts.dev
CLERK_CLIENT_ID=your_client_id
CLERK_CLIENT_SECRET=your_client_secret
BASE_URL=http://localhost:8000
# Optional: comma-separated emails allowed to call members-only tools.
# Leave empty to allow any authenticated user.
ALLOWED_EMAILS=
Detailed breakdown
CLERK_DOMAINis your instance domain.ClerkProviderderives every OAuth/OIDC endpoint from it (https://<domain>/oauth/authorize,/oauth/token,/oauth/token_info,/oauth/userinfo), so you never hard-code URLs.BASE_URLmust be the public URL of your server; Clerk redirects back toBASE_URL/auth/callback, and the OAuth metadata advertises it. For local development,http://localhost:8000is fine.ALLOWED_EMAILSis your authorization allowlist — empty means “any authenticated user”; a comma-separated list restricts the privileged tools.- Only
.env.exampleis committed; the real.env(with your secret) is git-ignored.
Step 4: Configure OAuth and authorization
Isolate auth in one module: build the Clerk provider from the environment, and provide identity extraction plus the allowlist gate.
Create the file
touch auth.py
Add the code: auth.py
"""OAuth configuration and identity-based authorization.
Authentication is delegated to Clerk via FastMCP's ClerkProvider, which acts as
an OAuth proxy: the MCP client performs a standard OAuth 2.0 flow (with Dynamic
Client Registration and PKCE) against this server, which brokers the login to
Clerk. Each incoming token is verified against Clerk's introspection endpoint and
enriched from userinfo, so the caller's identity (subject, email, name) lands in
the access token's claims.
Authorization is separate: an optional allowlist of email addresses gates the
"members only" tools, so you can restrict the server to specific users (for
example, paying customers identified by the email they signed up with).
"""
import os
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
from fastmcp.server.auth.providers.clerk import ClerkProvider
def build_auth() -> ClerkProvider:
"""Construct the Clerk OAuth provider from environment configuration."""
domain = os.environ.get("CLERK_DOMAIN")
client_id = os.environ.get("CLERK_CLIENT_ID")
client_secret = os.environ.get("CLERK_CLIENT_SECRET")
if not domain or not client_id or not client_secret:
raise RuntimeError(
"Set CLERK_DOMAIN, CLERK_CLIENT_ID and CLERK_CLIENT_SECRET "
"(see .env.example). Create an OAuth application in the Clerk "
"Dashboard: https://dashboard.clerk.com."
)
return ClerkProvider(
domain=domain,
client_id=client_id,
client_secret=client_secret,
base_url=os.environ.get("BASE_URL", "http://localhost:8000"),
required_scopes=["openid", "email", "profile"],
)
def allowed_emails() -> set[str]:
"""Emails permitted to call members-only tools (empty = allow all)."""
raw = os.environ.get("ALLOWED_EMAILS", "")
return {addr.strip().lower() for addr in raw.split(",") if addr.strip()}
def identity(access: AccessToken | None) -> dict:
"""Extract a small identity dict from a verified Clerk access token."""
claims = getattr(access, "claims", None) or {}
return {
"sub": claims.get("sub"),
"email": claims.get("email"),
"name": claims.get("name"),
"scopes": getattr(access, "scopes", []),
}
def require_member(access: AccessToken | None) -> None:
"""Raise ToolError unless the caller's email is on the allowlist."""
allow = allowed_emails()
if not allow: # no allowlist configured -> any authenticated user is fine
return
email = (identity(access).get("email") or "").lower()
if email not in allow:
raise ToolError(
f"'{email or 'unknown'}' is not authorized for this tool. "
"Ask an admin to add your email to ALLOWED_EMAILS."
)
Detailed breakdown
build_authconstructs theClerkProviderwith your instancedomain, the OAuth app’sclient_id/client_secret, the server’sbase_url, and the scopes to request.required_scopes=["openid", "email", "profile"]is the provider default, listed explicitly so the dependency onemail(used by the allowlist) is visible. It fails fast with a helpful message if configuration is missing.ClerkProvideris an OAuth proxy. Passing it asauthmakes FastMCP serve/authorize,/token,/register(Dynamic Client Registration), and/auth/callback, and enforce that every tool call carries a token that passes Clerk introspection.identitypulls the caller’s Clerksub(stable user id),email, andnameout of the verified token claims.ClerkProviderpopulates these from introspection + userinfo. It is defensive so it can be unit-tested with a synthetic token.require_memberis the authorization gate: with no allowlist it permits any authenticated user; otherwise it raisesToolErrorunless the caller’s email is listed (case-insensitive). Keeping it a pure function makes it trivial to test.
Step 5: Write the server
The tools read the authenticated identity via get_access_token(). whoami and
word_count accept any Clerk user; members_only calls the allowlist gate.
Create the file
touch server.py
Add the code: server.py
"""A FastMCP server authenticated with Clerk.
Every request must present a token obtained by logging in through Clerk. Tools
read the caller's identity from the verified access token; "members only" tools
additionally require the email to be on an allowlist. OAuth applies to the HTTP
transport, which is what you deploy.
"""
import os
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_access_token
from auth import build_auth, identity, require_member
mcp = FastMCP("macmcp", auth=build_auth())
@mcp.tool
def whoami() -> dict:
"""Return the authenticated caller's Clerk identity."""
return identity(get_access_token())
@mcp.tool
def word_count(text: str) -> int:
"""Count words in text. Available to any authenticated user."""
return len(text.split())
@mcp.tool
def members_only(secret_name: str) -> dict:
"""A tool restricted to allowlisted emails."""
require_member(get_access_token())
who = identity(get_access_token())
return {"granted_to": who["email"], "secret": f"the value of {secret_name}"}
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_auth())wires Clerk OAuth into every HTTP request. Unauthenticated calls are rejected before any tool runs.get_access_token()(fromfastmcp.server.dependencies) returns the current request’s verified token inside a tool;identityturns it into the caller’s email, name, and Clerk subject id.members_onlygates on identity. A logged-in but non-allowlisted user reaches the tool (they are authenticated) and is turned away byrequire_member— authentication and authorization doing distinct jobs.- The transport switch keeps stdio for unauthenticated local dev and HTTP for the deployed, OAuth-protected service.
Step 6: Test the challenge, discovery, and authorization
You can verify almost everything without a real login: the 401 challenge and
the OAuth discovery documents (via Starlette’s TestClient with dummy
credentials), and the authorization logic (as pure functions). The one thing you
cannot automate is the interactive Clerk consent — that is exercised by hand in
Step 8.
Create the files
mkdir -p tests
touch tests/__init__.py tests/test_auth.py tests/test_oauth_endpoints.py pytest.ini
Add the code: pytest.ini
[pytest]
asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
Add the code: tests/test_auth.py
"""Test the identity extraction and allowlist authorization (pure logic)."""
import pytest
from fastmcp.exceptions import ToolError
from fastmcp.server.auth import AccessToken
import auth
def _token(email: str) -> AccessToken:
return AccessToken(
token="t",
client_id="c",
scopes=["openid", "email", "profile"],
claims={
"sub": "user_123",
"email": email,
"name": "Ada Lovelace",
"clerk_user_data": {"name": "Ada Lovelace"},
},
)
def test_identity_extracts_email_and_name():
ident = auth.identity(_token("[email protected]"))
assert ident["email"] == "[email protected]"
assert ident["name"] == "Ada Lovelace"
assert ident["sub"] == "user_123"
def test_identity_handles_missing_token():
assert auth.identity(None)["email"] is None
def test_no_allowlist_allows_anyone(monkeypatch):
monkeypatch.delenv("ALLOWED_EMAILS", raising=False)
auth.require_member(_token("[email protected]")) # should not raise
def test_allowlisted_email_is_permitted(monkeypatch):
monkeypatch.setenv("ALLOWED_EMAILS", "[email protected], [email protected]")
auth.require_member(_token("[email protected]")) # case-insensitive; no raise
def test_non_allowlisted_email_is_denied(monkeypatch):
monkeypatch.setenv("ALLOWED_EMAILS", "[email protected]")
with pytest.raises(ToolError):
auth.require_member(_token("[email protected]"))
def test_build_auth_requires_configuration(monkeypatch):
monkeypatch.delenv("CLERK_DOMAIN", raising=False)
monkeypatch.delenv("CLERK_CLIENT_ID", raising=False)
monkeypatch.delenv("CLERK_CLIENT_SECRET", raising=False)
with pytest.raises(RuntimeError):
auth.build_auth()
Add the code: tests/test_oauth_endpoints.py
"""Test the OAuth server behavior with Starlette's TestClient and dummy creds.
These assert the protocol surface every MCP OAuth client relies on — the 401
challenge and the discovery documents — without performing a real Clerk login.
"""
import importlib
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
monkeypatch.setenv("CLERK_DOMAIN", "example-42.clerk.accounts.dev")
monkeypatch.setenv("CLERK_CLIENT_ID", "dummy-id")
monkeypatch.setenv("CLERK_CLIENT_SECRET", "dummy-secret")
monkeypatch.setenv("BASE_URL", "http://localhost:8000")
import server
importlib.reload(server)
with TestClient(server.mcp.http_app()) as c:
yield c
def test_unauthenticated_call_gets_401_challenge(client):
resp = client.post(
"/mcp/",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
headers={"Accept": "application/json, text/event-stream"},
)
assert resp.status_code == 401
challenge = resp.headers.get("www-authenticate", "")
assert challenge.startswith("Bearer")
assert "resource_metadata=" in challenge
def test_authorization_server_metadata(client):
meta = client.get("/.well-known/oauth-authorization-server").json()
assert meta["authorization_endpoint"].endswith("/authorize")
assert meta["token_endpoint"].endswith("/token")
# Dynamic Client Registration endpoint (clients self-register).
assert meta["registration_endpoint"].endswith("/register")
assert "email" in meta["scopes_supported"]
assert "S256" in meta["code_challenge_methods_supported"] # PKCE
def test_protected_resource_metadata(client):
meta = client.get("/.well-known/oauth-protected-resource/mcp").json()
assert meta["resource"].endswith("/mcp")
assert meta["authorization_servers"]
assert "email" in meta["scopes_supported"]
Detailed breakdown
test_auth.pycovers identity extraction and the allowlist gate directly: a syntheticAccessTokenstands in for a real Clerk token, so no network or login is needed. It also assertsbuild_authfails without configuration.test_oauth_endpoints.pyboots the real server with dummy credentials (enough to construct the provider) and drives it throughTestClient:- the unauthenticated call returns
401with aBearer … resource_metadata=…challenge — how a client learns where to authenticate; - the authorization-server document advertises
/authorize,/token,/register(DCR), theemailscope, andS256(PKCE); - the protected-resource document names the resource and its authorization server.
- the unauthenticated call returns
- These are exactly the protocol guarantees an MCP OAuth client depends on, so passing them means the flow will work for a real client. No Clerk network call happens here because token verification only runs when a token is actually presented.
Run them:
uv run pytest -v
All tests pass, with no Clerk application required.
Step 7: The Makefile
Wrap development and deployment, loading secrets from .env. Plain make prints
help.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
# Load local secrets from .env (gitignored) and export them to recipes.
-include .env
export
# --- Deploy configuration (launchd user agent) --------------------------------
LABEL := com.macmcp.clerk
PLIST := $(HOME)/Library/LaunchAgents/$(LABEL).plist
UV := $(shell command -v uv)
DIR := $(shell pwd)
DOMAIN := gui/$(shell id -u)
BASE_URL ?= http://localhost:8000
.PHONY: help install test serve login 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
test: ## Run the test suite (uses dummy credentials, no real login)
uv run pytest -v
serve: ## Run the HTTP server in the foreground (needs CLERK_* in .env)
@test -n "$(CLERK_CLIENT_ID)" || (echo "Set CLERK_DOMAIN/CLIENT_ID/SECRET in .env (see .env.example)" && exit 1)
MCP_TRANSPORT=http uv run python server.py
login: ## Connect a client via Clerk OAuth (opens a browser)
uv run python scripts/client.py
deploy: ## Render the launchd plist (with your creds) and start the service
@test -n "$(CLERK_CLIENT_ID)" || (echo "Set CLERK_DOMAIN/CLIENT_ID/SECRET in .env first" && exit 1)
@mkdir -p logs
@sed -e 's#@UV@#$(UV)#g' \
-e 's#@DIR@#$(DIR)#g' \
-e 's#@BASE_URL@#$(BASE_URL)#g' \
-e 's#@CLERK_DOMAIN@#$(CLERK_DOMAIN)#g' \
-e 's#@CLERK_CLIENT_ID@#$(CLERK_CLIENT_ID)#g' \
-e 's#@CLERK_CLIENT_SECRET@#$(CLERK_CLIENT_SECRET)#g' \
-e 's#@ALLOWED_EMAILS@#$(ALLOWED_EMAILS)#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). The plist contains secrets and lives outside the repo."
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
rm -rf .pytest_cache logs __pycache__ tests/__pycache__
Detailed breakdown
-include .env+exportloads your Clerk credentials from.envand makes them available to every recipe — soserveanddeploysee them without you exporting anything by hand.serveanddeployguard onCLERK_CLIENT_IDso a missing.envfails with a clear message instead of a confusing provider error.deploysubstitutes the credentials into the plist (Step 8). The rendered plist lives in~/Library/LaunchAgents/— outside the repo — so secrets are never committed.loginruns the interactive client (Step 8).
Step 8: Deploy and log in for real
Add the launchd plist template and the OAuth client, then run the real flow.
Create the files
mkdir -p deploy scripts
touch deploy/com.macmcp.clerk.plist.template scripts/client.py
Add the code: deploy/com.macmcp.clerk.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.clerk</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>@BASE_URL@</string>
<key>CLERK_DOMAIN</key>
<string>@CLERK_DOMAIN@</string>
<key>CLERK_CLIENT_ID</key>
<string>@CLERK_CLIENT_ID@</string>
<key>CLERK_CLIENT_SECRET</key>
<string>@CLERK_CLIENT_SECRET@</string>
<key>ALLOWED_EMAILS</key>
<string>@ALLOWED_EMAILS@</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/client.py
"""Connect to the server with OAuth and call a tool.
Running this opens your browser to log in through Clerk, then calls whoami. It
requires a running server (see `make serve`) and cannot run headless.
"""
import asyncio
import os
from fastmcp import Client
URL = os.environ.get("MCP_URL", "http://127.0.0.1:8000/mcp/")
async def main() -> None:
# auth="oauth" drives the full flow: dynamic client registration, the browser
# login, PKCE, and token exchange — all handled by the FastMCP client.
async with Client(URL, auth="oauth") as client:
me = (await client.call_tool("whoami", {})).data
print("logged in as:", me["email"], f"({me['name']})")
print("scopes:", me["scopes"])
wc = await client.call_tool("word_count", {"text": "authenticated with clerk"})
print("word_count ->", wc.data)
if __name__ == "__main__":
asyncio.run(main())
Detailed breakdown
- The plist injects the OAuth env (
CLERK_DOMAIN,CLERK_CLIENT_ID/SECRET,BASE_URL,ALLOWED_EMAILS) from placeholders thatmake deployfills in from your.env. Because the rendered plist lives outside the repo, the secret stays uncommitted. client.pyusesClient(URL, auth="oauth")— that single argument makes the FastMCP client perform Dynamic Client Registration, open the browser to Clerk, complete PKCE, and attach the resulting token to every call.
Run it end to end:
# Terminal 1 — start the server (foreground, reads .env)
make serve
# Terminal 2 — log in and call tools (opens a browser to Clerk)
make login
# logged in as: [email protected] (Your Name)
# scopes: ['openid', 'email', 'profile']
# word_count -> 3
Or run it supervised under launchd:
make deploy
make status # state = running
make undeploy
To lock the privileged tool to specific people, set ALLOWED_EMAILS in .env
(e.g. [email protected],[email protected]) and redeploy;
members_only then rejects everyone else with a ToolError.
Troubleshooting
Set CLERK_DOMAIN/CLIENT_ID/SECRET in .env. You have not created.env(copy.env.example) or filled in your OAuth application credentials.- Clerk says “redirect URI mismatch”. The redirect URI in your OAuth
application must be exactly
BASE_URL/auth/callback(e.g.http://localhost:8000/auth/callback). - The login succeeds but
whoamishows no email. The OAuth application is missing theemail(and/orprofile) scope. Enable them in the Clerk Dashboard;emailis what the allowlist keys on. - The browser never opens on
make login. The client needs a running server (make serve) and a desktop session; it cannot complete OAuth headless. Confirm the server is up atBASE_URL. members_onlydenies you even though you logged in. Your email is not inALLOWED_EMAILS. Add it (comma-separated) and restart/redeploy. An empty list allows everyone.- Production over plain HTTP.
http://localhostis fine for development, but a deployed server must use HTTPS (put it behind a TLS reverse proxy such as Caddy) and setBASE_URLto the publichttps://URL, which must also be registered as the OAuth application’s redirect URI.
Recap
You put a FastMCP server behind a full identity platform:
ClerkProviderdelegates authentication to Clerk as an OAuth proxy — Dynamic Client Registration, browser login, and PKCE — and verifies every token by introspection, so your server never handles passwords.- An allowlist of emails provides authorization on top of authentication, the identity equivalent of a paid-customer gate.
- The tests verify the protocol surface — the
401challenge and the OAuth discovery documents — plus the authorization logic, without a real login. uv,make, and alaunchdplist deploy it while keeping the client secret out of the repo.
Next improvements
- Put the server behind HTTPS (Caddy/nginx) and set a public
BASE_URLso real clients — and Claude Desktop — can complete the flow. - Authorize on Clerk organization or role membership by requesting the
public_metadatascope and readingclerk_user_datain the gate, instead of a static email allowlist. - Map a Clerk identity to a subscription record to grant plan-based scopes, tying this to the license-gating article.
- Swap
ClerkProviderfor anotherOAuthProxy-based provider (WorkOS, Auth0, Azure) to compare identity platforms behind the same FastMCP surface.