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:

  1. The MCP client discovers the server needs auth (a 401 with a WWW-Authenticate challenge) and reads the server’s OAuth metadata.
  2. The client dynamically registers itself (/register) and starts an OAuth flow with PKCE.
  3. The user’s browser opens to Clerk’s hosted sign-in to authenticate.
  4. 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), and members_only (allowlisted emails only).
  • A pytest suite that verifies the 401 challenge, the OAuth discovery documents, and the authorization logic — without a real login.
  • A launchd deployment that keeps your Clerk secret out of the repo.

Prerequisites

  • macOS 13+ with Homebrew (brew.sh).
  • uv 0.5+brew install uv; verify uv --version.
  • Xcode Command Line Tools (xcode-select --install) for make.
  • 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

  • .env will hold CLERK_CLIENT_ID and CLERK_CLIENT_SECRET. Ignoring it first means the secret cannot slip into a commit. You will commit a placeholder .env.example instead.
  • ClerkProvider keeps 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

  • fastmcp ships ClerkProvider and 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).

  1. 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.
  2. Go to Configure → OAuth applications → Add OAuth application.
  3. Set the Redirect URI to http://localhost:8000/auth/callback (this must match your BASE_URL and FastMCP’s default callback path).
  4. Enable the email and profile scopes (Clerk always includes openid for OIDC). These let the server read the caller’s email and name.
  5. 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_DOMAIN is your instance domain. ClerkProvider derives every OAuth/OIDC endpoint from it (https://<domain>/oauth/authorize, /oauth/token, /oauth/token_info, /oauth/userinfo), so you never hard-code URLs.
  • BASE_URL must be the public URL of your server; Clerk redirects back to BASE_URL/auth/callback, and the OAuth metadata advertises it. For local development, http://localhost:8000 is fine.
  • ALLOWED_EMAILS is your authorization allowlist — empty means “any authenticated user”; a comma-separated list restricts the privileged tools.
  • Only .env.example is 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_auth constructs the ClerkProvider with your instance domain, the OAuth app’s client_id/client_secret, the server’s base_url, and the scopes to request. required_scopes=["openid", "email", "profile"] is the provider default, listed explicitly so the dependency on email (used by the allowlist) is visible. It fails fast with a helpful message if configuration is missing.
  • ClerkProvider is an OAuth proxy. Passing it as auth makes FastMCP serve /authorize, /token, /register (Dynamic Client Registration), and /auth/callback, and enforce that every tool call carries a token that passes Clerk introspection.
  • identity pulls the caller’s Clerk sub (stable user id), email, and name out of the verified token claims. ClerkProvider populates these from introspection + userinfo. It is defensive so it can be unit-tested with a synthetic token.
  • require_member is the authorization gate: with no allowlist it permits any authenticated user; otherwise it raises ToolError unless 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() (from fastmcp.server.dependencies) returns the current request’s verified token inside a tool; identity turns it into the caller’s email, name, and Clerk subject id.
  • members_only gates on identity. A logged-in but non-allowlisted user reaches the tool (they are authenticated) and is turned away by require_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.py covers identity extraction and the allowlist gate directly: a synthetic AccessToken stands in for a real Clerk token, so no network or login is needed. It also asserts build_auth fails without configuration.
  • test_oauth_endpoints.py boots the real server with dummy credentials (enough to construct the provider) and drives it through TestClient:
    • the unauthenticated call returns 401 with a Bearer … resource_metadata=… challenge — how a client learns where to authenticate;
    • the authorization-server document advertises /authorize, /token, /register (DCR), the email scope, and S256 (PKCE);
    • the protected-resource document names the resource and its authorization server.
  • 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 + export loads your Clerk credentials from .env and makes them available to every recipe — so serve and deploy see them without you exporting anything by hand.
  • serve and deploy guard on CLERK_CLIENT_ID so a missing .env fails with a clear message instead of a confusing provider error.
  • deploy substitutes the credentials into the plist (Step 8). The rendered plist lives in ~/Library/LaunchAgents/ — outside the repo — so secrets are never committed.
  • login runs 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 that make deploy fills in from your .env. Because the rendered plist lives outside the repo, the secret stays uncommitted.
  • client.py uses Client(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 whoami shows no email. The OAuth application is missing the email (and/or profile) scope. Enable them in the Clerk Dashboard; email is 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 at BASE_URL.
  • members_only denies you even though you logged in. Your email is not in ALLOWED_EMAILS. Add it (comma-separated) and restart/redeploy. An empty list allows everyone.
  • Production over plain HTTP. http://localhost is fine for development, but a deployed server must use HTTPS (put it behind a TLS reverse proxy such as Caddy) and set BASE_URL to the public https:// URL, which must also be registered as the OAuth application’s redirect URI.

Recap

You put a FastMCP server behind a full identity platform:

  • ClerkProvider delegates 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 401 challenge and the OAuth discovery documents — plus the authorization logic, without a real login.
  • uv, make, and a launchd plist 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_URL so real clients — and Claude Desktop — can complete the flow.
  • Authorize on Clerk organization or role membership by requesting the public_metadata scope and reading clerk_user_data in 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 ClerkProvider for another OAuthProxy-based provider (WorkOS, Auth0, Azure) to compare identity platforms behind the same FastMCP surface.