Generic MCP server with OAuth 2.1 PKCE for claude.ai.
Build any MCP service in ~30 lines. Deploy to Cloud Run in one command.
mcp_server/
__init__.py -- Public API
auth.py -- PKCE + TokenStore + AuthProvider (+ challenge hook)
oauth_routes.py -- OAuth 2.1 AS endpoints
app.py -- FastAPI factory (wires everything)
context.py -- Per-request `current_sub` for tool code
examples/
polymarket_server.py -- No-auth example (Polymarket markets)
github_oauth_server.py -- Per-user upstream OAuth (GitHub)
tests/
test_oauth.py -- Full PKCE flow + edge cases
test_github_oauth.py -- GitHub provider + callback logic
uv pip install .# my_service.py
import fastmcp
from mcp_server import create_app
mcp = fastmcp.FastMCP(
"my-service",
instructions="Describe what your server does — shown as the connector card in claude.ai.",
)
@mcp.tool()
def my_tool(query: str) -> str:
return f"Result for: {query}"
app = create_app(mcp=mcp)uvicorn my_service:app --reload --port 8080curl http://localhost:8080/.well-known/oauth-authorization-server | jqchmod +x deploy.sh
./deploy.sh my-service europe-west1Settings → Connectors → Add MCP Server
URL: https://my-service-xxxx.run.app/mcp
Claude.ai will handle the OAuth PKCE flow automatically.
app = create_app(mcp=mcp)
# /authorize issues code immediately -- protect at network levelfrom mcp_server import create_app, StaticPasswordProvider
import os
app = create_app(
mcp=mcp,
provider=StaticPasswordProvider(os.environ["ADMIN_PASSWORD"])
)Set ADMIN_PASSWORD env var on Cloud Run. claude.ai redirects the user's browser to /authorize; the server renders a password form; after submit, the OAuth code is issued and the PKCE flow completes.
Subclass AuthProvider:
from mcp_server.auth import AuthProvider
from starlette.requests import Request
class GoogleAuthProvider(AuthProvider):
def authenticate(self, request: Request, credentials: dict[str, str]) -> str | None:
# `request` gives access to headers/cookies for session-based auth.
# `credentials` merges /authorize query params + POST form fields.
# Return user sub or None.
...Each claude.ai user logs in through an external identity provider (GitHub,
Google, etc.) and subsequent tool calls run with their own upstream
token — not a shared one. The server maintains an allowlist of permitted
users. Override AuthProvider.challenge() to redirect unauthenticated
users to the IdP; your callback route exchanges the code for a token,
sets a session cookie, and bounces the user back into /authorize. Tools
read the caller's identity via mcp_server.get_current_sub() and look up
the per-user token from a session store.
See examples/github_oauth_server.py
for a complete, working GitHub implementation (whoami, list_my_repos,
get_starred — each executes as the caller).
sequenceDiagram
autonumber
participant C as claude.ai / browser
participant S as your MCP server
participant I as upstream IdP — e.g. GitHub
Note over C,S: Discovery
C->>S: GET /.well-known/oauth-authorization-server
S-->>C: AS metadata — issuer, endpoints, PKCE=S256
Note over C,S: PKCE challenge
C->>S: GET /authorize?code_challenge=S256&redirect_uri=…
alt password required — StaticPasswordProvider
S-->>C: 200 HTML login form
C->>S: POST /authorize — password + hidden PKCE fields
S-->>C: 302 redirect_uri?code=X
else upstream OAuth — challenge hook
S-->>C: 302 to IdP authorize URL
C->>I: GET /login/oauth/authorize
Note over C,I: user logs in + approves
I-->>C: 302 /auth/github/callback?code=Y
C->>S: GET /auth/github/callback?code=Y
S->>I: POST /login/oauth/access_token
I-->>S: access_token
S-->>C: 302 /authorize — Set-Cookie mcp_session
C->>S: GET /authorize + Cookie
S-->>C: 302 redirect_uri?code=X
else no password — SingleUserProvider
S-->>C: 302 redirect_uri?code=X
end
Note over C,S: PKCE verify
C->>S: POST /token — code=X + code_verifier
S-->>C: access_token
Note over C,S: Tool calls
loop each tool invocation
C->>S: POST /mcp — Authorization Bearer …
S-->>C: tool result
end
SingleUserProviderwith no password: protect/authorizevia Cloud Run IAM or VPN if not behind a loginStaticPasswordProvider: use a strong random password, rotate via env var- Token store is in-memory -- tokens lost on restart; users re-auth automatically (PKCE flow is fast)
- For production multi-user: replace
TokenStorewith a Redis or SQLite-backed implementation stateparameter is passed through but not validated inSingleUserProvidermode -- add validation for multi-user
uv pip install '.[dev]'
pytest tests/ -v