Skip to content

Commit 7015d21

Browse files
examples: web-standard twin of the bearer-auth story (#2424)
1 parent e8de519 commit 7015d21

6 files changed

Lines changed: 170 additions & 0 deletions

File tree

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`]
4848
| [`parallel-calls/`](./parallel-calls/README.md) | Multiple clients / parallel tool calls, per-client notifications | stdio + http | dual |
4949
| [`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) |
5050
| [`bearer-auth/`](./bearer-auth/README.md) | Resource server with bearer token; `401` + `WWW-Authenticate` | http | dual |
51+
| [`bearer-auth-web/`](./bearer-auth-web/README.md) | Web-standard twin: host/origin guards + `requireBearerAuth` + `createMcpHandler` as one fetch handler | http | dual |
5152
| [`oauth/`](./oauth/README.md) | OAuth `authorization_code`: in-repo AS (auto-consent) + headless redirect-following client | http | dual |
5253
| [`oauth-client-credentials/`](./oauth-client-credentials/README.md) | OAuth `client_credentials` (machine-to-machine): in-repo AS + `ClientCredentialsProvider` | http | dual |
5354
| [`scoped-tools/`](./scoped-tools/README.md) | Per-tool scope on `createMcpHandler` — bearer-verify gate + handler-level `ctx.http?.authInfo` checks | http | modern |

examples/bearer-auth-web/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# bearer-auth-web
2+
3+
The web-standard twin of [`bearer-auth`](../bearer-auth/): the same minimal
4+
Resource-Server-only story built entirely from `@modelcontextprotocol/server`
5+
exports, with no framework.
6+
7+
Host and origin validation plus `requireBearerAuth` gate `createMcpHandler`,
8+
composed as one `fetch(request)` handler. On Cloudflare Workers, Deno, or Bun
9+
that handler is the whole server; `toNodeHandler` bridges it onto `node:http`
10+
so the story runs in this repo's example matrix.
11+
12+
No Authorization Server and no discovery documents here, matching the sibling
13+
— see [`oauth`](../oauth/) for the full RS + AS dance.
14+
15+
```sh
16+
pnpm --filter @mcp-examples/bearer-auth-web server -- --http --port 3000
17+
pnpm --filter @mcp-examples/bearer-auth-web client -- --http http://127.0.0.1:3000/mcp
18+
```

examples/bearer-auth-web/client.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Asserts a bare request is `401` with a `WWW-Authenticate` challenge (parsed
3+
* with the SDK's `extractWWWAuthenticateParams`), and that a request with
4+
* `Authorization: Bearer demo-token` reaches the `whoami` tool with the
5+
* verified `authInfo`.
6+
*/
7+
import { check, parseExampleArgs } from '@mcp-examples/shared';
8+
import { Client, extractWWWAuthenticateParams, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
9+
10+
const { url, era } = parseExampleArgs();
11+
12+
// Unauthenticated → 401 + WWW-Authenticate.
13+
const unauth = await fetch(url, {
14+
method: 'POST',
15+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
16+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' })
17+
});
18+
check.equal(unauth.status, 401);
19+
check.equal(extractWWWAuthenticateParams(unauth).error, 'invalid_token');
20+
21+
// Authenticated → 200 and the tool sees the authInfo. Bearer auth is
22+
// HTTP-layer and era-agnostic; the client honours `--legacy` via `era`.
23+
const client = new Client(
24+
{ name: 'bearer-auth-web-example-client', version: '1.0.0' },
25+
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
26+
);
27+
await client.connect(new StreamableHTTPClientTransport(new URL(url), { authProvider: { token: async () => 'demo-token' } }));
28+
29+
const result = await client.callTool({ name: 'whoami', arguments: {} });
30+
check.equal(result.content?.[0]?.type === 'text' ? result.content[0].text : '', 'client=demo-client');
31+
32+
await client.close();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@mcp-examples/bearer-auth-web",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"server": "tsx server.ts",
7+
"client": "tsx client.ts"
8+
},
9+
"dependencies": {
10+
"@mcp-examples/shared": "workspace:*",
11+
"@modelcontextprotocol/client": "workspace:*",
12+
"@modelcontextprotocol/node": "workspace:*",
13+
"@modelcontextprotocol/server": "workspace:*",
14+
"zod": "catalog:runtimeShared"
15+
},
16+
"devDependencies": {
17+
"tsx": "catalog:devTools"
18+
},
19+
"example": {
20+
"transports": [
21+
"http"
22+
],
23+
"era": "dual",
24+
"path": "/mcp",
25+
"//": "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."
26+
}
27+
}

examples/bearer-auth-web/server.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* The web-standard counterpart of `examples/bearer-auth`: the same
3+
* Resource-Server-only auth built entirely from `@modelcontextprotocol/server`
4+
* exports — `requireBearerAuth` gating the MCP handler, behind the same
5+
* DNS-rebinding guards the Express sibling gets from `createMcpExpressApp` —
6+
* composed as one `fetch(request)` handler.
7+
*
8+
* On Cloudflare Workers, Deno, or Bun that handler is the whole server
9+
* (`export default { fetch: fetchHandler }`); on Node, `toNodeHandler` bridges
10+
* it onto `node:http`. HTTP-only by definition.
11+
*/
12+
import { createServer } from 'node:http';
13+
14+
import { parseExampleArgs } from '@mcp-examples/shared';
15+
import { toNodeHandler } from '@modelcontextprotocol/node';
16+
import type { AuthInfo, McpServerFactory, OAuthTokenVerifier } from '@modelcontextprotocol/server';
17+
import {
18+
createMcpHandler,
19+
hostHeaderValidationResponse,
20+
localhostAllowedHostnames,
21+
localhostAllowedOrigins,
22+
McpServer,
23+
OAuthError,
24+
OAuthErrorCode,
25+
originValidationResponse,
26+
requireBearerAuth
27+
} from '@modelcontextprotocol/server';
28+
import * as z from 'zod/v4';
29+
30+
const buildServer: McpServerFactory = ctx => {
31+
const server = new McpServer({ name: 'bearer-auth-web-example', version: '1.0.0' });
32+
server.registerTool('whoami', { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, async () => ({
33+
content: [{ type: 'text', text: `client=${ctx.authInfo?.clientId ?? 'anon'}` }]
34+
}));
35+
return server;
36+
};
37+
38+
const { port } = parseExampleArgs();
39+
40+
// Replace with JWT verification, RFC 7662 introspection, etc.
41+
const staticTokenVerifier: OAuthTokenVerifier = {
42+
async verifyAccessToken(token): Promise<AuthInfo> {
43+
if (token !== 'demo-token') {
44+
throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token');
45+
}
46+
return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
47+
}
48+
};
49+
50+
const gate = requireBearerAuth({ verifier: staticTokenVerifier, requiredScopes: ['mcp'] });
51+
const handler = createMcpHandler(buildServer);
52+
53+
async function fetchHandler(request: Request): Promise<Response> {
54+
const rejected =
55+
hostHeaderValidationResponse(request, localhostAllowedHostnames()) ?? originValidationResponse(request, localhostAllowedOrigins());
56+
if (rejected) {
57+
return rejected;
58+
}
59+
const auth = await gate(request);
60+
if (auth instanceof Response) {
61+
return auth;
62+
}
63+
return handler.fetch(request, { authInfo: auth });
64+
}
65+
66+
// On a web-standard runtime the composition above is the whole server;
67+
// `toNodeHandler` accepts any `{ fetch }` shape and bridges it onto node:http.
68+
createServer(toNodeHandler({ fetch: fetchHandler })).listen(port, () => {
69+
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
70+
});

pnpm-lock.yaml

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)