examples: web-standard twin of the bearer-auth story (#2424)

This commit is contained in:
Felix Weinberger 2026-07-06 19:58:41 +01:00 • committed by GitHub
commit 7015d2129b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 170 additions and 0 deletions

View file

@ -48,6 +48,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`]
| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual |
| [`legacy-routing/`](./legacy-routing/README.md) | `isLegacyRequest` in front of an existing sessionful 1.x deployment + a strict modern entry on one port | http | dual (in-body) |
| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual |
| [`bearer-auth-web/`](./bearer-auth-web/README.md) | Web-standard twin: host/origin guards + `requireBearerAuth` + `createMcpHandler` as one fetch handler | http | dual |
| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual |
| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual |
| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern |

View file

@ -0,0 +1,18 @@
# bearer-auth-web
The web-standard twin of [`bearer-auth`](../bearer-auth/): the same minimal
Resource-Server-only story built entirely from `@modelcontextprotocol/server`
exports, with no framework.
Host and origin validation plus `requireBearerAuth` gate `createMcpHandler`,
composed as one `fetch(request)` handler. On Cloudflare Workers, Deno, or Bun
that handler is the whole server; `toNodeHandler` bridges it onto `node:http`
so the story runs in this repo's example matrix.
No Authorization Server and no discovery documents here, matching the sibling
— see [`oauth`](../oauth/) for the full RS + AS dance.
```sh
pnpm --filter @mcp-examples/bearer-auth-web server -- --http --port 3000
pnpm --filter @mcp-examples/bearer-auth-web client -- --http http://127.0.0.1:3000/mcp
```

View file

@ -0,0 +1,32 @@
/**
* Asserts a bare request is `401` with a `WWW-Authenticate` challenge (parsed
* with the SDK's `extractWWWAuthenticateParams`), and that a request with
* `Authorization: Bearer demo-token` reaches the `whoami` tool with the
* verified `authInfo`.
*/
import { check, parseExampleArgs } from '@mcp-examples/shared';
import { Client, extractWWWAuthenticateParams, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const { url, era } = parseExampleArgs();
// Unauthenticated → 401 + WWW-Authenticate.
const unauth = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
});
check.equal(unauth.status, 401);
check.equal(extractWWWAuthenticateParams(unauth).error, 'invalid_token');
// Authenticated → 200 and the tool sees the authInfo. Bearer auth is
// HTTP-layer and era-agnostic; the client honours `--legacy` via `era`.
const client = new Client(
{ name: 'bearer-auth-web-example-client', version: '1.0.0' },
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
);
await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: { token: async () => 'demo-token' } }));
const result = await client.callTool({ name: 'whoami', arguments: {} });
check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client');
await client.close();

View file

@ -0,0 +1,27 @@
{
"name": "@mcp-examples/bearer-auth-web",
"private": true,
"type": "module",
"scripts": {
"server": "tsx server.ts",
"client": "tsx client.ts"
},
"dependencies": {
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
"devDependencies": {
"tsx": "catalog:devTools"
},
"example": {
"transports": [
"http"
],
"era": "dual",
"path": "/mcp",
"//": "The web-standard twin of bearer-auth: requireBearerAuth from @modelcontextprotocol/server composed as one fetch handler (toNodeHandler bridges for the matrix); era-agnostic like its sibling."
}
}

View file

@ -0,0 +1,70 @@
/**
* The web-standard counterpart of `examples/bearer-auth`: the same
* Resource-Server-only auth built entirely from `@modelcontextprotocol/server`
* exports — `requireBearerAuth` gating the MCP handler, behind the same
* DNS-rebinding guards the Express sibling gets from `createMcpExpressApp` —
* composed as one `fetch(request)` handler.
*
* On Cloudflare Workers, Deno, or Bun that handler is the whole server
* (`export default { fetch: fetchHandler }`); on Node, `toNodeHandler` bridges
* it onto `node:http`. HTTP-only by definition.
*/
import { createServer } from 'node:http';
import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { AuthInfo, McpServerFactory, OAuthTokenVerifier } from '@modelcontextprotocol/server';
import {
createMcpHandler,
hostHeaderValidationResponse,
localhostAllowedHostnames,
localhostAllowedOrigins,
McpServer,
OAuthError,
OAuthErrorCode,
originValidationResponse,
requireBearerAuth
} from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const buildServer: McpServerFactory = ctx => {
const server = new McpServer({ name: 'bearer-auth-web-example', version: '1.0.0' });
server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({
content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }]
}));
return server;
};
const { port } = parseExampleArgs();
// Replace with JWT verification, RFC 7662 introspection, etc.
const staticTokenVerifier: OAuthTokenVerifier = {
async verifyAccessToken(token): Promise<AuthInfo> {
if (token !== 'demo-token') {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
}
return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
}
};
const gate = requireBearerAuth({ verifier: staticTokenVerifier, requiredScopes: ['mcp'] });
const handler = createMcpHandler(buildServer);
async function fetchHandler(request: Request): Promise<Response> {
const rejected =
hostHeaderValidationResponse(request, localhostAllowedHostnames()) ?? originValidationResponse(request, localhostAllowedOrigins());
if (rejected) {
return rejected;
}
const auth = await gate(request);
if (auth instanceof Response) {
return auth;
}
return handler.fetch(request, { authInfo: auth });
}
// On a web-standard runtime the composition above is the whole server;
// `toNodeHandler` accepts any `{ fetch }` shape and bridges it onto node:http.
createServer(toNodeHandler({ fetch: fetchHandler })).listen(port, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});

22
pnpm-lock.yaml generated
View file

@ -403,6 +403,28 @@ importers:
specifier: catalog:devTools
version: 4.21.0
examples/bearer-auth-web:
dependencies:
'@mcp-examples/shared':
specifier: workspace:*
version: link:../shared
'@modelcontextprotocol/client':
specifier: workspace:*
version: link:../../packages/client
'@modelcontextprotocol/node':
specifier: workspace:*
version: link:../../packages/middleware/node
'@modelcontextprotocol/server':
specifier: workspace:*
version: link:../../packages/server
zod:
specifier: catalog:runtimeShared
version: 4.3.6
devDependencies:
tsx:
specifier: catalog:devTools
version: 4.21.0
examples/caching:
dependencies:
'@mcp-examples/shared':