← library
promptexample · tested🤖 AI agentsv1 · published 2026-10-15 · updated 2026-11-03

Remote MCP server with OAuth

A stateless MCP server on the 2026-07-28 spec with OAuth 2.1 sign-in, audience checks, and per-tool scopes.

By Uplift Duo

Run it as a prompt

Paste this into any AI agent, or fetch it: curl -s https://uplift.page/api/v1/prompts/remote-mcp-server-oauth/raw

prompt.md
# Remote MCP server with OAuth

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

1. 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.
2. 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.
3. Serve RFC 9728 metadata at `/.well-known/oauth-protected-resource/mcp` (oauthMetadataResponse) naming your authorization server, with only the minimal scopes in scopes_supported.
4. 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.
5. 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.
6. 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.
7. 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).
8. Test with `npx @modelcontextprotocol/inspector` through 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>/mcp` returns 401 with `WWW-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.

The full prompt

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

  1. 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.
  2. 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.
  3. Serve RFC 9728 metadata at /.well-known/oauth-protected-resource/mcp (oauthMetadataResponse) naming your authorization server, with only the minimal scopes in scopes_supported.
  4. 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.
  5. 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.
  6. 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.
  7. 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).
  8. Test with npx @modelcontextprotocol/inspector through 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>/mcp returns 401 with WWW-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.

Served from the uplift.page library and refreshed within 5 minutes of every update.