Expose 3-6 tools of an existing API as a remote MCP server any MCP client can sign in to: unauthenticated calls get a 401 with resource_metadata in under 50 ms, tokens minted for another audience are rejected 100% of the time, and the server adds under 100 ms p95 over the underlying API.
Recommended stack
- MCP TypeScript SDK v2 (@modelcontextprotocol/server 2.x) - MCP server, Streamable HTTP transport, requireBearerAuth and protected-resource metadata helpers. v2 is the stable line and implements the 2026-07-28 spec, which drops the initialize handshake and session so any instance can serve any request.
- Hono 4 + @modelcontextprotocol/hono 2.x - HTTP app and Host-header validation. The official adapter runs the same web-standard handler on Node, Bun, Deno, or Workers.
- An OAuth 2.1 / OIDC provider (e.g. Auth0, WorkOS, Keycloak) - Authorization server that issues tokens. The SDK is only the resource-server half and never issues tokens; a dedicated identity provider handles login, consent, and Client ID Metadata Documents.
- jose 6 - JWT signature, issuer, expiry, and audience verification. Verifies tokens locally against the provider JWKS with no per-request network call.
- MCP Inspector 2.x - Manual test client for the OAuth flow and tools. Walks the full 401 -> metadata -> sign-in -> tool call path before a real client does.
Build steps
- List the tools first: 3-6 verbs mapped to existing API endpoints, each with a zod v4 input schema, a one-sentence "use when" description, a required scope (e.g. notes:read, notes:write), and a read-only or destructive annotation.
- Install
@modelcontextprotocol/server @modelcontextprotocol/hono hono jose zod. Build the McpServer inside a factory that runs per request and register tools there; create DB pools and caches once at module scope. - Serve RFC 9728 metadata at
/.well-known/oauth-protected-resource/mcp(oauthMetadataResponse) naming your authorization server, with only the minimal scopes in scopes_supported. - Write verifyAccessToken: check signature (createRemoteJWKSet), iss, exp, and that aud equals the canonical server URL (e.g. https://mcp.example.com/mcp, no trailing slash). Return AuthInfo with resource and expiresAt set; throw OAuthError(InvalidToken) on reject.
- Gate /mcp with requireBearerAuth({ verifier, resourceMetadataUrl, expectedResource }) and put in requiredScopes only the scope every tool needs. On each write tool set scopeChallenge: requireScopes('notes:write') in registerTool, so a read-only token gets 403 insufficient_scope listing every scope the call needs.
- Configure the provider: enable Client ID Metadata Documents (DCR only for older clients), allow-list redirect URIs, set access tokens to 1 hour or less, and register the canonical URL as the API audience. On Keycloak, CIMD is experimental (--features=cimd) and resource indicators are not supported, so add an audience mapper that sets aud to the canonical URL.
- Never forward the incoming token to downstream APIs; call them with the server's own credentials scoped to the user (token passthrough is forbidden by the spec).
- Test with
npx @modelcontextprotocol/inspectorthrough sign-in, then deploy 2+ instances behind a round-robin load balancer with no sticky sessions.
Watch out for
- Following v1 tutorials: the initialize handshake and Mcp-Session-Id are retired in 2026-07-28. Use the per-request factory and keep no state on the server instance.
- Skipping the audience check: any valid token from the same provider, minted for another app, then works on your server. Set expectedResource and verify aud.
- Canonical URL drift (trailing slash, http vs https, path) between metadata, provider audience, and verifier causes endless 401 loops. Define the URL once in config and reuse it.
- Leaving expiresAt unset in AuthInfo makes every call fail with 401; on localhost, add localhostHostValidation to block DNS rebinding.
Definition of done
curl -i -X POST https://<host>/mcpreturns 401 withWWW-Authenticate: Bearer resource_metadata="...", and that URL returns JSON naming the authorization server.- MCP Inspector completes sign-in and lists every tool; calling a write tool with a read-only token returns 403 insufficient_scope.
- A scripted test using a JWT from the same provider with a different audience gets 401.
- With two instances running, stopping one does not break the client's next tool call.