On this page
Remote MCPImplementation

How we built a remote MCP server for Ethics of AI

Why remote MCP, how DCR with Clerk works, why we wrap our existing API instead of going pure-MCP, and why API Gateway plus Lambda plus FastMCP is the right surface for us.

This doc walks through the design choices behind the Ethics of AI remote MCP server: why remote rather than local, how the OAuth handshake works against Clerk, what we expose before and after sign-in, why the server wraps the existing API instead of standing on its own, and the API Gateway plus Lambda plus FastMCP shape that runs in production. The companion piece, Meeting users where they are, covers the user-engagement problem this surface solves.

Why remote MCP

MCP is a small protocol for exposing tools and resources to an AI client. Local MCP servers run as a stdio child process of the client, which works for developer tools but is the wrong shape for a hosted product: every user has to install something, every Claude surface speaks to its own copy, and updates ship through the client. Remote MCP swaps the stdio pipe for Streamable HTTP over a public URL, so the server lives on our infrastructure and updates ship by deploying.

For a SaaS product with an existing backend and identity provider, remote MCP is the obvious choice. Two consequences shape everything below: the server has to authenticate users against Clerk without a separate sign-in, and it has to survive Lambda fan-out across concurrent instances.

OAuth with Clerk and Dynamic Client Registration

Authentication runs as OAuth 2.1 with Clerk as the Authorisation Server and the MCP server as the Resource Server. Anthropic's backend is the OAuth client, and registers itself with Clerk on the fly via Dynamic Client Registration (DCR), so we never mint static client credentials per Claude surface. Once the user signs in, Anthropic exchanges the auth code for a self-contained JWT and sends it as a Bearer on every POST /mcp. The MCP server verifies it offline against Clerk's published JWKS (no per-request round trip to Clerk) and gets signature, issuer, and expiry checks for free.

Two Clerk-specific points came out of the Phase A spike, and both are load-bearing:

  • No aud claim. Clerk does not emit aud on OAuth access tokens, so JWTVerifier is configured with audience=None on purpose. Adding an audience check would silently reject every token.
  • scopes_supported cannot be empty. Claude Desktop's OAuth backend silently refuses to complete the handshake when the field is empty, so we publish a non-empty list even though the access-token grants are coarser than per-scope authorisation today.
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from pydantic import AnyHttpUrl

SCOPES_SUPPORTED = ["profile", "email", "offline_access"]

verifier = JWTVerifier(
    jwks_uri=str(settings.clerk_jwks_uri),
    issuer=str(settings.clerk_oauth_issuer).rstrip("/"),
    audience=None,
)

auth = RemoteAuthProvider(
    token_verifier=verifier,
    authorization_servers=[AnyHttpUrl(str(settings.clerk_oauth_issuer))],
    base_url=str(settings.mcp_base_url),
    scopes_supported=SCOPES_SUPPORTED,
)

Discovery routes

Three .well-known routes sit in front of the OAuth handshake:

  • /.well-known/oauth-protected-resource/mcp: FastMCP's RemoteAuthProvider auto-serves the RFC 9728 per-resource metadata.
  • /.well-known/oauth-protected-resource: manual mirror, added after watching Claude Desktop hit it and stall on a 404 because the client strips the resource path.
  • /.well-known/oauth-authorization-server: proxy to Clerk's AS-discovery document, so older MCP-spec clients can find the issuer without an extra hop.

The Clerk proxy holds responses in a single-slot in-memory cache for sixty seconds, so a flood of client probes against our route does not amplify into a flood against Clerk.

What clients see before and after auth

Pre-auth, the server answers the discovery surface and nothing else. A tools/list without a valid bearer token returns a 401 with a challenge that points at the protected-resource metadata:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.ethicsofai.io/.well-known/oauth-protected-resource"

Claude reads the header, runs the OAuth flow against Clerk, and retries with a token. After verification the full toolset is exposed: whoami, plus the assessment tools that read and submit answers against an Ethics of AI assessment.

We do not gate per-tool today. Once authenticated, every registered tool is visible. The Users API already authorises every backend call against the user's Clerk identity, so the policy that matters lives at the API layer; adding a second tier at the MCP layer would duplicate it. The trade-off is that tools/list cannot be tailored per user without writing extra gating, which we have not needed yet.

Wrapping the API instead of going pure-MCP

Two ways to design an MCP server. A pure server owns its data and policy directly: database, authorisation, end-to-end behaviour. A wrapped server is a thin adapter over an existing API; each tool maps to one or more endpoints and the API stays the source of truth.

We chose to wrap. The Users API already enforces authorisation and owns the assessment data model. Reinventing the rules at the MCP layer would mean keeping two copies in sync. Wrapping also means new MCP tools usually light up by writing a thin adapter around an existing endpoint, rather than a new policy implementation.

The interesting bit is the auth bridge. Clerk OAuth access tokens carry the user's identity, but the Users API expects the ethicsofai JWT template (the same one the website sends from useAuth().getToken({ template: 'ethicsofai' })). The MCP server resolves the active Clerk session, mints a short-lived ethicsofai-template JWT via the Clerk Backend API, caches it for sixty seconds keyed on the session ID, and forwards each backend call with that token. From the Users API's perspective the call is indistinguishable from a website call.

async def call_backend(access_token: AccessToken, path: str) -> httpx.Response:
    api_token = await clerk_token_provider.get_api_token(access_token)
    return await backend_client.request(
        "GET",
        path,
        headers={"Authorization": f"Bearer {api_token}"},
    )

The cache matters in practice. Lambda fans out per request, so a single user clicking through five tools can land on five different instances. The Clerk Backend API is fast but not free; short-lived per-session tokens are the natural cache key, and they expire on their own, so we leave them in memory and let Python garbage-collect the cold instance.

Deployment surface: API Gateway, Lambda, FastMCP

The whole server runs as a single Docker-image Lambda function on ARM64, 1024 MB, 60-second timeout. The container has the AWS Lambda Web Adapter shim baked in, so FastMCP's ASGI app runs unchanged: the shim translates Lambda invocation events into HTTP requests against the local server. The same image runs locally under Uvicorn for development.

FastMCP runs in stateless HTTP plus JSON response mode. Stateless HTTP is load-bearing for Lambda: the default Streamable HTTP transport keeps session state per server instance, so a follow-up POST /mcp that lands on a different cold-starting instance returns 404 Session Not Found. We saw this exact failure during the Phase A smoke test on AWS. JSON response mode disables Server-Sent Events and returns each tool response as a single JSON payload; today's tools have no streaming progress events, so giving up SSE buys survivability for free.

app = create_app()
http_app = app.http_app(stateless_http=True, json_response=True)
uvicorn.run(http_app, host="0.0.0.0", port=8080)

In front of Lambda sits an HTTP API Gateway v2 mounted on the dedicated mcp.ethicsofai.io custom domain. The split away from the broader Users API surface keeps mcp.* scoped to MCP transport and OAuth discovery, which makes routing and CORS simpler to reason about. The API has four routes, all integrated to the same Lambda function:

const mcpRoutes = [
  { path: '/mcp', methods: [HttpMethod.POST, HttpMethod.GET, HttpMethod.DELETE] },
  { path: '/.well-known/oauth-protected-resource', methods: [HttpMethod.GET, HttpMethod.OPTIONS] },
  { path: '/.well-known/oauth-protected-resource/mcp', methods: [HttpMethod.GET, HttpMethod.OPTIONS] },
  { path: '/.well-known/oauth-authorization-server', methods: [HttpMethod.GET, HttpMethod.OPTIONS] },
]

CORS preflight allows GET, POST, DELETE, and OPTIONS, with Content-Type, Authorization, and mcp-protocol-version headers. There is no Gateway-layer authoriser: the MCP server enforces OAuth per request, and the discovery routes are intentionally public.

What we would revisit

Cold-start budget. The first authenticated tool call sits between 2.5 and 10 seconds on 1024 MB ARM64 Python container Lambda: image pull, Python interpreter init, JWKS pre-warm, and FastMCP construction. We pre-warm JWKS during boot so the worst-case auth path is off the critical path of the first request, and 1024 MB roughly halves cold-start versus 512 MB at the cost of a higher per-invocation price. There is more to extract here, but moving off the container image, or onto a different runtime, is not a small change.

No SSE. JSON response mode means no progress events for tools that might want them later. Today nothing needs progress (assessment answers are short, whoami returns immediately). When a longer-running tool arrives, the honest fix is either to move that one tool to a different surface, or to move the whole server off Lambda onto something with stable per-process state (ECS, or an Express-style process). Neither is a blocker right now.

Single-tier auth. No per-tool gating, because the API enforces policy and the user has to authenticate to see any tool at all. There are reasonable designs the MCP spec supports (per-tool scopes, role-aware tools/list) that we have not built. Calling that out as a present-state choice rather than a missing feature is the point of an implementation doc.

Three takeaways for anyone building the same thing

None of the trade-offs above is a blocker, and naming them keeps the doc honest. If you are designing a remote MCP server for your own product, the three things worth taking seriously are:

  1. Do not put SSE on the critical path if your transport is Lambda. Stateless HTTP plus JSON response mode survives fan-out; sticky sessions do not.
  2. Let your existing identity provider issue the access token, and let your existing API enforce the policy. A wrapped MCP server keeps the rules in one place and makes new tools cheap to add.
  3. Treat the discovery routes as a real surface, not boilerplate. Clients depend on them being there, in the shape they expect, including the unsuffixed mirrors that the spec does not strictly require.