examples: web-standard twin of the bearer-auth story (#2424)
This commit is contained in:
parent
e8de519d31
commit
7015d2129b
6 changed files with 170 additions and 0 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
18
examples/bearer-auth-web/README.md
Normal file
18
examples/bearer-auth-web/README.md
Normal 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
|
||||
```
|
||||
32
examples/bearer-auth-web/client.ts
Normal file
32
examples/bearer-auth-web/client.ts
Normal 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();
|
||||
27
examples/bearer-auth-web/package.json
Normal file
27
examples/bearer-auth-web/package.json
Normal 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."
|
||||
}
|
||||
}
|
||||
70
examples/bearer-auth-web/server.ts
Normal file
70
examples/bearer-auth-web/server.ts
Normal 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
22
pnpm-lock.yaml
generated
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Reference in a new issue