Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Prompt: auth-server
You get an OAuth-protected MCP server with a scope-gated whoami tool, RFC 9728 discovery metadata, and a fail-closed token verifier; it teaches the authorization and identity story from authorization and identity and principals.
Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the in-memory suite (tests/server.test.ts) exercises only the legacy path, while the HTTP-level suite (tests/route-auth.test.ts) drives the real withMcpAuth wrapper with a 2026-07-28 server/discover request and sees the modern frames. The prompt’s TESTS section pins the behaviors each harness actually exhibits, so the project it produces is green today.
Copy everything in the block below into your AI coding agent as one message. See the prompt index for what makes these prompts reliable and how they were tested.
GOALBuild me a small TypeScript project called auth-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server that requires an OAuth bearer token on every request, accepts only tokens minted for its own canonical resource URL (RFC 8707 audience binding), and exposes one tool, whoami, which reports the caller's verified identity (client id and scopes) read from the token, never from tool arguments.
STACK (exact, non-negotiable)Next.js App Router (next ^16, react and react-dom ^19), mcp-handler 2.1.1 plus @modelcontextprotocol/server 2.0.0 as regular dependencies (mcp-handler 2.1.1 peer-requires @modelcontextprotocol/server ^2.0.0; do NOT add @modelcontextprotocol/sdk, that is the v1 package), zod ^4.2.0 (HARD FLOOR: the v2 SDK requires zod 4.2.0 or newer; zod ^3 installs cleanly and then fails typecheck and tests, so never downgrade it), devDeps typescript, vitest, @types/node, and @modelcontextprotocol/client 2.0.0 (the client package is for tests only). Node 22 or newer. package.json has "type": "module" and scripts dev (next dev), test (vitest run), typecheck (tsc --noEmit). Include a vercel.json with the $schema key and functions["app/api/mcp/route.ts"].maxDuration set to 30.
LAYOUTapp/api/mcp/route.ts is a thin shell: const handler = createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }), then wrap it as withOriginCheck(withMcpAuth(handler, verifyToken, { required: true, requiredScopes: REQUIRED_SCOPES, resourceMetadataPath: '/.well-known/oauth-protected-resource', resourceUrl: CANONICAL_RESOURCE_ORIGIN }), parseAllowedOrigins(process.env[ALLOWED_ORIGINS_ENV])) (createMcpHandler and withMcpAuth come from mcp-handler; the Origin check is outermost) and export the wrapped handler as GET, POST, DELETE. The resourceUrl passed to withMcpAuth is the ORIGIN of the canonical resource (scheme, host, port): the library appends resourceMetadataPath to it, and passing the full endpoint URL would advertise /api/mcp/.well-known/... instead. v2 has no [transport] directory, no three-argument createMcpHandler (the name survives with the two-argument form above), and no basePath option; the public endpoint is /api/mcp.app/.well-known/oauth-protected-resource/route.ts serves RFC 9728 metadata: protectedResourceHandler({ authServerUrls: ['https://auth.example.com'], resourceUrl: CANONICAL_RESOURCE }) exported as GET, and metadataCorsOptionsRequestHandler() exported as OPTIONS. Here resourceUrl is the FULL canonical resource URL (the endpoint), which becomes the document's "resource" value. Without resourceUrl on either route, mcp-handler derives the URL from the request's x-forwarded-host, x-forwarded-proto, and Forwarded headers (falling back to req.url), so a forged header could point clients at an attacker's discovery document and resource; both routes must pin it.ALL protocol logic lives in src/server.ts exporting configureServer(server); ALL token logic lives in src/auth.ts; the Origin allowlist lives in src/origin.ts. Tests in tests/ import src/ (and, for the HTTP-level suite, mcp-handler) and never import Next.js.
BEHAVIORsrc/auth.ts exports: REQUIRED_SCOPES = ['mcp:read']; WHOAMI_SCOPE = 'mcp:read'; EXPIRED_AT = 1000000000 (a fixed epoch second, so no test depends on the wall clock); RESOURCE_URL_ENV = 'MCP_RESOURCE_URL'; DEFAULT_RESOURCE_URL = 'http://localhost:3000/api/mcp'; resolveCanonicalResource(raw), which returns DEFAULT_RESOURCE_URL when raw is undefined or blank, otherwise parses the trimmed value with new URL, clears the fragment, strips one trailing slash, and returns the string, and THROWS an Error whose message names MCP_RESOURCE_URL when the value does not parse (a misconfigured audience fails loudly at startup, never falls back to localhost); CANONICAL_RESOURCE = resolveCanonicalResource(process.env[RESOURCE_URL_ENV]), resolved once at module load; CANONICAL_RESOURCE_ORIGIN = new URL(CANONICAL_RESOURCE).origin; FOREIGN_RESOURCE = 'https://other-server.example/api/mcp'; TOKEN_TABLE, a ReadonlyMap from raw bearer token string to { clientId, scopes, resource, expiresAt? } (resource is the RFC 8707 resource the token was issued for; a real verifier reads it from the JWT aud claim) with exactly these entries: demo-token-full (client-full, scopes mcp:read and mcp:write, resource CANONICAL_RESOURCE), demo-token-read (client-read, mcp:read, CANONICAL_RESOURCE), demo-token-none (client-none, no scopes, CANONICAL_RESOURCE), demo-token-expired (client-expired, mcp:read, CANONICAL_RESOURCE, expiresAt EXPIRED_AT), demo-token-foreign (client-foreign, mcp:read and mcp:write, resource FOREIGN_RESOURCE, no expiresAt: well formed, unexpired, fully scoped, wrong audience only).verifyToken(req, bearerToken?, nowSeconds = current epoch seconds, expectedResource = CANONICAL_RESOURCE) returns the SDK AuthInfo type (import type { AuthInfo } from '@modelcontextprotocol/server') for a valid token and undefined otherwise; it never throws. Missing or empty token: undefined. Unknown token: undefined. Audience mismatch, meaning resolveCanonicalResource(record.resource) !== expectedResource: undefined, checked before expiry; the request's URL, Host, and x-forwarded-host headers play no part in the comparison, only the expectedResource argument does (withMcpAuth calls verifyToken with two arguments, so the defaults apply in production; the third and fourth parameters exist so tests can pin the clock and the audience). Expired token: undefined even though it sits in the table, and exactly at expiresAt counts as expired (the boundary fails closed). The returned AuthInfo is { token, clientId, scopes, resource: new URL(record.resource), and expiresAt only when the record has one }; return a defensive copy of scopes so a caller mutating AuthInfo cannot rewrite the table.hasScopes(authInfo, required) is true only when every required scope is granted; an empty requirement list passes, an empty grant list fails any non-empty requirement.src/server.ts exports SERVER_NAME 'auth-server', SERVER_VERSION '0.1.0', an exported auditLog array of { clientId, tool }, and configureServer registering one tool, whoami, whose inputSchema is the full zod object schema z.object({}) (SDK v2 takes the object schema itself, not the raw shape v1 used; identity is never caller-supplied). The handler signature is async (args, ctx) and it reads ctx.http?.authInfo (v2 moved the principal from v1's extra.authInfo to ctx.http.authInfo): if absent, return isError true with text containing 'unauthenticated' (fail closed even without the route wrapper); if hasScopes fails for WHOAMI_SCOPE, return isError true with text mentioning mcp:read; otherwise push { clientId, tool: 'whoami' } to auditLog and return one text block containing JSON.stringify({ clientId, scopes }). Never echo the raw token anywhere. Denied calls must leave auditLog untouched.src/origin.ts is the Origin allowlist (framework-free: Fetch Request in, Response or null out). Export ALLOWED_ORIGINS_ENV = 'MCP_ALLOWED_ORIGINS'; DEFAULT_ALLOWED_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000']; parseAllowedOrigins(raw) (comma separated, each entry normalized to new URL(entry).origin, unparseable entries dropped, duplicates removed, defaults used when raw is undefined or blank); assertAllowedOrigin(request, allowlist) (no Origin header: return null and let it through; Origin present and on the allowlist: null; anything else, including the literal 'null' origin: a 403 text/plain Response with body 'Forbidden: Origin not allowed' that does not echo the allowlist); and withOriginCheck(handler, allowlist), which wraps a Fetch-style handler, preserves any extra parameters, and short-circuits with the refusal.
TESTS (vitest, offline)tests/auth.test.ts drives verifyToken, resolveCanonicalResource, and hasScopes directly with fixed clocks on both sides of EXPIRED_AT: a valid token returns the table's clientId and scopes and an AuthInfo whose resource?.toString() equals CANONICAL_RESOURCE; missing, empty, and unknown tokens return undefined; demo-token-foreign is present in TOKEN_TABLE with both scopes, no expiresAt, and resource FOREIGN_RESOURCE, yet verifyToken returns undefined, while the same token verifies (clientId client-foreign) when expectedResource is FOREIGN_RESOURCE (the check is a comparison, not a denylist); a Request whose URL is FOREIGN_RESOURCE and whose x-forwarded-host is the foreign host still rejects demo-token-foreign and still accepts demo-token-read (the audience is configuration, never the request); the expired token is present in the table yet rejected, including exactly at EXPIRED_AT; the same token verifies before expiry; mutating a returned scopes array does not affect the next call; resolveCanonicalResource returns DEFAULT_RESOURCE_URL for undefined and blank input, CANONICAL_RESOURCE equals DEFAULT_RESOURCE_URL and CANONICAL_RESOURCE_ORIGIN equals 'http://localhost:3000' in the test process (the variable is unset there), a trailing slash and a #fragment are stripped, surrounding whitespace is trimmed, an explicit port and the path are kept, and 'not a url' throws an error matching /MCP_RESOURCE_URL/; scope gating passes and denies per the rules above.tests/server.test.ts builds a fresh McpServer (from @modelcontextprotocol/server), calls configureServer on it, and connects a real Client from @modelcontextprotocol/client over InMemoryTransport.createLinkedPair() (InMemoryTransport also comes from @modelcontextprotocol/server); connect server and client with Promise.all. To simulate an authenticated connection, wrap clientTransport.send so every message is sent with { ...options, authInfo } (InMemoryTransport.send still accepts an authInfo option in SDK v2, and the server surfaces it to handlers as ctx.http.authInfo, the same path withMcpAuth uses in production). Build that AuthInfo by calling the real verifyToken with a pinned clock. Assert: listTools shows exactly one tool named whoami with no properties; calling it returns the clientId and scopes as JSON, the raw token string appears nowhere in the result text, and auditLog gained exactly one entry; with no authInfo injected the call returns isError true containing 'unauthenticated' and auditLog stays empty; demo-token-none's AuthInfo gets isError true mentioning mcp:read and no audit entry; calling an unknown tool name REJECTS with a ProtocolError whose message matches /not found/i (this CHANGED from v1, which returned isError results for unknown tools; v2 matches the spec's protocol-error semantics, while denials INSIDE a known tool, like the scope gate, remain isError tool results). Do not assert resultType on results or ttlMs/cacheScope on list results, and do not expect server/discover on this path: connect() still performs the legacy initialize handshake at protocol version 2025-11-25 over InMemoryTransport.tests/route-auth.test.ts exercises the real HTTP wrapper end to end without Next.js. Build mcpHandler exactly as the route does (withOriginCheck around withMcpAuth around createMcpHandler, imported from mcp-handler and src/, with DEFAULT_ALLOWED_ORIGINS as the allowlist and resourceUrl CANONICAL_RESOURCE_ORIGIN), plus metadataHandler = protectedResourceHandler({ authServerUrls: ['https://auth.example.com'], resourceUrl: CANONICAL_RESOURCE }) and metadataCorsOptionsRequestHandler(), and drive them with plain Fetch Request objects; reset auditLog in beforeEach. A request helper POSTs to CANONICAL_RESOURCE with headers content-type application/json, accept 'application/json, text/event-stream', mcp-protocol-version '2026-07-28', mcp-method (plus mcp-name for tools/call), and an optional Authorization: Bearer; the body is a JSON-RPC server/discover (or tools/call of whoami with empty arguments) whose params._meta carries the 2026-07-28 envelope { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientCapabilities': {} } (without that envelope mcp-handler classifies the request as legacy and answers -32601 over SSE, so the modern shape is what proves the handler ran). Parse WWW-Authenticate as one Bearer challenge with quoted auth-params. Assert: no token, an unknown token, demo-token-expired, demo-token-foreign (sent together with x-forwarded-host and x-forwarded-proto claiming the foreign host, which must change nothing), and a Basic Authorization scheme each get 401 with error="invalid_token", scope="mcp:read", resource_metadata equal to CANONICAL_RESOURCE_ORIGIN + '/.well-known/oauth-protected-resource', a JSON body whose error is invalid_token, and neither the body nor the header containing the presented credential; with x-forwarded-host: evil.example plus x-forwarded-proto https, with an RFC 7239 Forwarded header (host="evil.example";proto=https), or with the request URL itself set to https://evil.example/api/mcp, the 401's resource_metadata is still the canonical URL and the header never mentions evil.example; demo-token-none gets 403 with error="insufficient_scope", the same scope hint and resource_metadata, a JSON body with error insufficient_scope, no token echo, and an empty auditLog; demo-token-read gets 200 on server/discover with no WWW-Authenticate header, an application/json content-type, and a body whose result has supportedVersions ['2026-07-28'], capabilities including tools, and resultType 'complete'; a tools/call of whoami with demo-token-read gets 200 with result text JSON { clientId: 'client-read', scopes: ['mcp:read'] }, no token echo, and exactly one auditLog entry; demo-token-read with Origin https://evil.example gets 403 with no WWW-Authenticate header and no audit entry (the Origin check runs outside the auth gate, so a valid token does not bypass it); the metadata handler, called at https://evil.example/.well-known/oauth-protected-resource with forged x-forwarded headers, returns 200 with content-type application/json, resource CANONICAL_RESOURCE, authorization_servers ['https://auth.example.com'], and no evil.example anywhere in the document; fetching the resource_metadata URL taken from a real 401 through the metadata handler yields the same resource, and the challenge URL's origin equals the resource's origin; the CORS handler returns 200 with access-control-allow-origin '*'.tests/origin.test.ts calls src/origin.ts directly with plain Fetch Request objects, no server: no Origin header passes; an allowlisted origin passes even when the allowlist entry carried a trailing slash or different case; a non-allowlisted origin, the literal 'null' origin, and an unparseable origin each get a 403 whose body does not contain the allowlist; scheme, host, and port all count (http versus https, an extra port, and a superstring host are refused); an empty allowlist refuses every Origin but still passes requests without one; withOriginCheck never invokes the wrapped handler on refusal and forwards extra arguments on success; parseAllowedOrigins falls back to the defaults for undefined and blank input, drops unparseable and duplicate entries, and returns an empty list (not the defaults) for an all-junk value.tests/vercel-config.test.ts reads vercel.json as data and fails unless functions['app/api/mcp/route.ts'].maxDuration is a positive integer.
DEFINITION OF DONEnpm install, npm run typecheck, npm test all green. Then npm run dev, check curl -i -X POST http://localhost:3000/api/mcp returns 401 with a WWW-Authenticate header whose resource_metadata is http://localhost:3000/.well-known/oauth-protected-resource, and curl http://localhost:3000/.well-known/oauth-protected-resource returns the metadata JSON with resource http://localhost:3000/api/mcp. Connect MCP Inspector (npx @modelcontextprotocol/inspector) with Streamable HTTP to http://localhost:3000/api/mcp, add header 'Authorization: Bearer demo-token-full', and call whoami by hand; switching to demo-token-none should get 403 and demo-token-foreign 401. Optionally vercel deploy: before deploying, set MCP_RESOURCE_URL to https://<deployment>/api/mcp in the project's environment variables (and MCP_ALLOWED_ORIGINS, comma separated browser origins, only if a browser-based client will connect), so both the 401 challenge and the metadata document advertise the deployed resource rather than the localhost default.
SOURCEShttps://modelcontextprotocol.io/specification/2026-07-28/basic/authorization and https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices for the auth model; https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http for the Origin validation MUST; https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel and https://github.com/vercel/mcp-handler for the hosting pieces; the reference implementation at examples/auth-server/ in this repository (a path relative to the repo root; the GitHub repository is private), compare against it if you get stuck.
GUARDRAILSTests must pass with no network access, no live identity provider, and no Vercel account (the test process leaves MCP_RESOURCE_URL unset, so the localhost default is the canonical resource under test). No dependencies beyond the stack list. Keep it small: three src files (server, auth, origin), one tool, five test files (auth, server, route-auth, origin, vercel-config).Where to look now
- Prompt index - all thirteen prompts and the reliability notes.
examples/auth-server(in the repository) - the reference implementation this prompt rebuilds.- Examples index - what each example demonstrates.
Bibliography
- Placeholder authorization server issuer - https://auth.example.com
- Placeholder foreign resource (audience-binding stub) - https://other-server.example/api/mcp
- Model Context Protocol Specification, Streamable HTTP transport (Origin validation) - https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
- localhost:3000 - http://localhost:3000/api/mcp
- localhost:3000 - http://localhost:3000/.well-known/oauth-protected-resource
- Model Context Protocol Specification - https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- Model Context Protocol Security Best Practices - https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices
- Vercel Documentation - https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel
- vercel/mcp-handler, source code - https://github.com/vercel/mcp-handler
- Reference implementation, source code (this repository) -
examples/auth-server(in the repository)