Skip to content

Commit 351189f

Browse files
committed
docs(examples): add external auth resource server example (closes #658)
New `examples/server/src/externalAuthStreamableHttp.ts` shows the production OAuth pattern where the MCP server is a pure resource server that validates JWT bearer tokens minted by an external Authorization Server (Auth0, Okta, Keycloak, Entra ID, Cognito, in-house IdP, ...) via JWKS. RFC 8707 audience binding and RFC 9728 Protected Resource Metadata are demonstrated. No DIY OAuth server code is added; trust anchors come from environment variables. Also adds a row to `examples/server/README.md` and pulls `jose` from the existing `runtimeClientOnly` catalog into the examples-server package.
1 parent 42cb6b2 commit 351189f

4 files changed

Lines changed: 377 additions & 0 deletions

File tree

examples/server/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts
3838
| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) |
3939
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
4040
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
41+
| External OAuth Authorization Server | Pure OAuth 2.0 resource server: validates JWT bearer tokens minted by an external AS via JWKS. | [`src/externalAuthStreamableHttp.ts`](src/externalAuthStreamableHttp.ts) |
4142

4243
## OAuth demo flags (Streamable HTTP server)
4344

@@ -46,6 +47,31 @@ pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamabl
4647
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth --oauth-strict
4748
```
4849

50+
## External Authorization Server (resource-server pattern)
51+
52+
`simpleStreamableHttp.ts --oauth` co-locates an Authorization Server with the
53+
MCP server for demos. In production, the Authorization Server is usually a
54+
separate system (Auth0, Okta, Keycloak, Entra ID, AWS Cognito, an in-house
55+
IdP, ...) and the MCP server is a pure OAuth 2.0 *resource server* that
56+
validates incoming bearer tokens. `externalAuthStreamableHttp.ts` shows that
57+
pattern.
58+
59+
The example reads its trust anchors from environment variables, validates
60+
JWTs against the AS's published JWKS, enforces the RFC 8707 audience claim,
61+
and serves RFC 9728 Protected Resource Metadata so clients can discover the
62+
AS automatically:
63+
64+
```bash
65+
export MCP_JWKS_URL=https://<tenant>.auth0.com/.well-known/jwks.json
66+
export MCP_ISSUER=https://<tenant>.auth0.com/
67+
export MCP_AUDIENCE=http://localhost:3000/mcp
68+
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthStreamableHttp.ts
69+
```
70+
71+
Tools registered:
72+
- `whoami` — requires `mcp:read`. Echoes the validated subject and scopes.
73+
- `echo` — requires `mcp:write`. Demonstrates per-tool scope enforcement.
74+
4975
## URL elicitation example (server + client)
5076

5177
Run the server:

examples/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"cors": "catalog:runtimeServerOnly",
4545
"express": "catalog:runtimeServerOnly",
4646
"hono": "catalog:runtimeServerOnly",
47+
"jose": "catalog:runtimeClientOnly",
4748
"valibot": "catalog:devTools",
4849
"zod": "catalog:runtimeShared"
4950
},
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
/**
2+
* MCP Streamable HTTP server with an EXTERNAL OAuth Authorization Server.
3+
*
4+
* Demonstrates the production pattern from the MCP authorization spec where
5+
* the MCP server is a pure OAuth 2.0 *resource server* and a separate
6+
* Authorization Server (Auth0, Okta, Keycloak, Entra ID, AWS Cognito, your
7+
* in-house IdP, ...) mints the access tokens. The MCP server does **not**
8+
* know how to issue tokens — it validates incoming bearer tokens against the
9+
* AS's published JWKS, checks the audience (RFC 8707 resource indicator) and
10+
* scopes, and serves the resource.
11+
*
12+
* Contrast with `simpleStreamableHttp.ts --oauth`, which co-locates an AS and
13+
* the resource server in the same process for demos.
14+
*
15+
* Configure via environment variables:
16+
* MCP_JWKS_URL (required) e.g. https://<tenant>.auth0.com/.well-known/jwks.json
17+
* MCP_ISSUER (required) e.g. https://<tenant>.auth0.com/
18+
* MCP_AUDIENCE (required) the resource indicator the AS binds to tokens (RFC 8707).
19+
* Typically the canonical MCP server URL.
20+
* MCP_AUTHORIZATION_SERVERS (optional, comma-separated) advertised in the
21+
* Protected Resource Metadata document
22+
* (RFC 9728). Defaults to MCP_ISSUER.
23+
* MCP_PORT (optional, default 3000)
24+
*
25+
* Quick local sketch with Auth0:
26+
* export MCP_JWKS_URL=https://example.auth0.com/.well-known/jwks.json
27+
* export MCP_ISSUER=https://example.auth0.com/
28+
* export MCP_AUDIENCE=http://localhost:3000/mcp
29+
* pnpm --filter @modelcontextprotocol/examples-server exec tsx src/externalAuthStreamableHttp.ts
30+
*
31+
* Tools registered:
32+
* - `whoami` requires `mcp:read`
33+
* - `echo` requires `mcp:write`
34+
*/
35+
36+
import { randomUUID } from 'node:crypto';
37+
38+
import { createMcpExpressApp } from '@modelcontextprotocol/express';
39+
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
40+
import type { AuthInfo, CallToolResult } from '@modelcontextprotocol/server';
41+
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
42+
import cors from 'cors';
43+
import type { NextFunction, Request, Response } from 'express';
44+
import type { JWTPayload } from 'jose';
45+
import { createRemoteJWKSet, jwtVerify } from 'jose';
46+
import * as z from 'zod/v4';
47+
48+
// --- Config -----------------------------------------------------------------
49+
50+
const JWKS_URL = process.env.MCP_JWKS_URL;
51+
const ISSUER = process.env.MCP_ISSUER;
52+
const AUDIENCE = process.env.MCP_AUDIENCE;
53+
const AUTHORIZATION_SERVERS = (process.env.MCP_AUTHORIZATION_SERVERS ?? ISSUER ?? '')
54+
.split(',')
55+
.map(s => s.trim())
56+
.filter(Boolean);
57+
const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000;
58+
59+
if (!JWKS_URL || !ISSUER || !AUDIENCE) {
60+
console.error('Missing required env: MCP_JWKS_URL, MCP_ISSUER, MCP_AUDIENCE.');
61+
console.error('See the file header comment for an example configuration.');
62+
// eslint-disable-next-line unicorn/no-process-exit
63+
process.exit(1);
64+
}
65+
66+
// RFC 9728 §5.1: the metadata location for resource `https://host/mcp` is
67+
// `https://host/.well-known/oauth-protected-resource/mcp`. We derive both the
68+
// path served on this app and the absolute URL advertised in WWW-Authenticate
69+
// from the configured audience so they line up with whatever the AS actually
70+
// bound the token to.
71+
const AUDIENCE_URL = new URL(AUDIENCE);
72+
const METADATA_PATH = `/.well-known/oauth-protected-resource${AUDIENCE_URL.pathname === '/' ? '' : AUDIENCE_URL.pathname}`;
73+
const RESOURCE_METADATA_URL = new URL(METADATA_PATH, AUDIENCE_URL.origin);
74+
75+
// --- JWKS bearer auth middleware -------------------------------------------
76+
77+
// `createRemoteJWKSet` caches keys and refreshes on `kid` rotation, so this is
78+
// safe to share across requests.
79+
const jwks = createRemoteJWKSet(new URL(JWKS_URL));
80+
81+
function parseScopes(payload: JWTPayload): string[] {
82+
// Common JWT scope claims:
83+
// - `scope` (RFC 8693): space-separated string
84+
// - `scp` (Okta/Entra): array of strings
85+
const raw = (payload as { scope?: unknown; scp?: unknown }).scope ?? (payload as { scp?: unknown }).scp;
86+
if (Array.isArray(raw)) return raw.map(String);
87+
if (typeof raw === 'string') return raw.split(/\s+/).filter(Boolean);
88+
return [];
89+
}
90+
91+
function wwwAuthHeader(error: string, description: string, requiredScopes?: string[]): string {
92+
const parts = [
93+
`Bearer error="${error}"`,
94+
`error_description="${description}"`,
95+
`resource_metadata="${RESOURCE_METADATA_URL.toString()}"`
96+
];
97+
if (requiredScopes && requiredScopes.length > 0) parts.push(`scope="${requiredScopes.join(' ')}"`);
98+
return parts.join(', ');
99+
}
100+
101+
/**
102+
* Express middleware that validates a Bearer token against the configured
103+
* external Authorization Server. On success, attaches an `AuthInfo` to
104+
* `req.auth` so the SDK threads it into `ctx.http?.authInfo` for tool
105+
* handlers. On failure, replies with RFC 6750 401/403 plus a
106+
* `WWW-Authenticate` header that points to the resource metadata.
107+
*/
108+
function requireBearerAuth(requiredScopes: string[] = []) {
109+
return async (
110+
req: Request & { auth?: AuthInfo },
111+
res: Response,
112+
next: NextFunction
113+
): Promise<void> => {
114+
const header = req.headers.authorization;
115+
if (!header || !header.startsWith('Bearer ')) {
116+
res.set('WWW-Authenticate', wwwAuthHeader('invalid_token', 'Missing Bearer token', requiredScopes));
117+
res.status(401).json({ error: 'invalid_token', error_description: 'Missing Bearer token' });
118+
return;
119+
}
120+
const token = header.slice('Bearer '.length).trim();
121+
try {
122+
const { payload } = await jwtVerify(token, jwks, {
123+
issuer: ISSUER,
124+
audience: AUDIENCE
125+
});
126+
const scopes = parseScopes(payload);
127+
128+
// RFC 6750 §3.1: missing scopes -> 403 insufficient_scope.
129+
const missing = requiredScopes.filter(s => !scopes.includes(s));
130+
if (missing.length > 0) {
131+
res.set(
132+
'WWW-Authenticate',
133+
wwwAuthHeader('insufficient_scope', `Missing scopes: ${missing.join(' ')}`, requiredScopes)
134+
);
135+
res.status(403).json({
136+
error: 'insufficient_scope',
137+
error_description: `Missing scopes: ${missing.join(' ')}`
138+
});
139+
return;
140+
}
141+
142+
const authInfo: AuthInfo = {
143+
token,
144+
clientId: typeof payload.client_id === 'string' ? payload.client_id : (payload.azp as string | undefined) ?? '',
145+
scopes,
146+
expiresAt: typeof payload.exp === 'number' ? payload.exp : undefined,
147+
resource: AUDIENCE_URL,
148+
extra: { sub: payload.sub, iss: payload.iss }
149+
};
150+
req.auth = authInfo;
151+
next();
152+
} catch (error) {
153+
const message = error instanceof Error ? error.message : 'Token validation failed';
154+
res.set('WWW-Authenticate', wwwAuthHeader('invalid_token', message, requiredScopes));
155+
res.status(401).json({ error: 'invalid_token', error_description: message });
156+
}
157+
};
158+
}
159+
160+
// --- MCP server -------------------------------------------------------------
161+
162+
const getServer = () => {
163+
const server = new McpServer(
164+
{ name: 'external-auth-streamable-http-server', version: '1.0.0' },
165+
{ capabilities: { logging: {} } }
166+
);
167+
168+
// `whoami` — gated on `mcp:read`. Reads the validated AuthInfo that the
169+
// SDK propagates from `req.auth` into the tool context.
170+
server.registerTool(
171+
'whoami',
172+
{
173+
title: 'Who Am I',
174+
description: 'Returns the authenticated subject and granted scopes (requires mcp:read).',
175+
inputSchema: z.object({})
176+
},
177+
async (_args, ctx): Promise<CallToolResult> => {
178+
const auth = ctx.http?.authInfo;
179+
return {
180+
content: [
181+
{
182+
type: 'text',
183+
text: JSON.stringify(
184+
{
185+
sub: (auth?.extra?.sub as string | undefined) ?? null,
186+
clientId: auth?.clientId ?? null,
187+
scopes: auth?.scopes ?? []
188+
},
189+
null,
190+
2
191+
)
192+
}
193+
]
194+
};
195+
}
196+
);
197+
198+
// `echo` — requires `mcp:write`. The tool itself re-checks the scope so
199+
// it stays correct even if a future maintainer wires it onto a route with
200+
// looser middleware.
201+
server.registerTool(
202+
'echo',
203+
{
204+
title: 'Echo',
205+
description: 'Echoes the supplied message back (requires mcp:write).',
206+
inputSchema: z.object({ message: z.string().describe('Message to echo') })
207+
},
208+
async ({ message }, ctx): Promise<CallToolResult> => {
209+
const scopes = ctx.http?.authInfo?.scopes ?? [];
210+
if (!scopes.includes('mcp:write')) {
211+
return {
212+
isError: true,
213+
content: [{ type: 'text', text: 'Forbidden: mcp:write scope required.' }]
214+
};
215+
}
216+
return { content: [{ type: 'text', text: message }] };
217+
}
218+
);
219+
220+
return server;
221+
};
222+
223+
// --- Express app ------------------------------------------------------------
224+
225+
const app = createMcpExpressApp();
226+
227+
// Demo CORS — restrict in production.
228+
// WARNING: This configuration is for demo purposes only. In production, you
229+
// should restrict origins and configure CORS yourself.
230+
app.use(
231+
cors({
232+
exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'],
233+
origin: '*'
234+
})
235+
);
236+
237+
// RFC 9728 Protected Resource Metadata. Clients fetch this on a 401 to
238+
// discover the authorization server(s) and supported scopes.
239+
app.get(METADATA_PATH, (_req: Request, res: Response) => {
240+
res.json({
241+
resource: AUDIENCE,
242+
authorization_servers: AUTHORIZATION_SERVERS,
243+
bearer_methods_supported: ['header'],
244+
scopes_supported: ['mcp:read', 'mcp:write'],
245+
resource_documentation: 'https://modelcontextprotocol.io'
246+
});
247+
});
248+
249+
// All `/mcp` routes require at least `mcp:read`. The `echo` tool re-checks
250+
// `mcp:write` inline (see above) so the authorization story stays clear.
251+
const authReadOnly = requireBearerAuth(['mcp:read']);
252+
253+
const transports: Record<string, NodeStreamableHTTPServerTransport> = {};
254+
255+
const mcpPostHandler = async (req: Request, res: Response): Promise<void> => {
256+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
257+
try {
258+
let transport: NodeStreamableHTTPServerTransport;
259+
if (sessionId && transports[sessionId]) {
260+
transport = transports[sessionId];
261+
} else if (!sessionId && isInitializeRequest(req.body)) {
262+
transport = new NodeStreamableHTTPServerTransport({
263+
sessionIdGenerator: () => randomUUID(),
264+
onsessioninitialized: sid => {
265+
transports[sid] = transport;
266+
}
267+
});
268+
transport.onclose = () => {
269+
const sid = transport.sessionId;
270+
if (sid && transports[sid]) delete transports[sid];
271+
};
272+
const server = getServer();
273+
await server.connect(transport);
274+
await transport.handleRequest(req, res, req.body);
275+
return;
276+
} else if (sessionId) {
277+
res.status(404).json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null });
278+
return;
279+
} else {
280+
res.status(400).json({
281+
jsonrpc: '2.0',
282+
error: { code: -32_000, message: 'Bad Request: Session ID required' },
283+
id: null
284+
});
285+
return;
286+
}
287+
await transport.handleRequest(req, res, req.body);
288+
} catch (error) {
289+
console.error('Error handling MCP request:', error);
290+
if (!res.headersSent) {
291+
res.status(500).json({
292+
jsonrpc: '2.0',
293+
error: { code: -32_603, message: 'Internal server error' },
294+
id: null
295+
});
296+
}
297+
}
298+
};
299+
300+
const mcpGetHandler = async (req: Request, res: Response): Promise<void> => {
301+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
302+
if (!sessionId || !transports[sessionId]) {
303+
res.status(404).send('Session not found');
304+
return;
305+
}
306+
await transports[sessionId].handleRequest(req, res);
307+
};
308+
309+
const mcpDeleteHandler = async (req: Request, res: Response): Promise<void> => {
310+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
311+
if (!sessionId || !transports[sessionId]) {
312+
res.status(404).send('Session not found');
313+
return;
314+
}
315+
await transports[sessionId].handleRequest(req, res);
316+
};
317+
318+
app.post('/mcp', authReadOnly, mcpPostHandler);
319+
app.get('/mcp', authReadOnly, mcpGetHandler);
320+
app.delete('/mcp', authReadOnly, mcpDeleteHandler);
321+
322+
app.listen(MCP_PORT, error => {
323+
if (error) {
324+
console.error('Failed to start server:', error);
325+
// eslint-disable-next-line unicorn/no-process-exit
326+
process.exit(1);
327+
}
328+
console.log(`MCP (external-auth) Streamable HTTP Server listening on port ${MCP_PORT}`);
329+
console.log(` Issuer: ${ISSUER}`);
330+
console.log(` Audience: ${AUDIENCE}`);
331+
console.log(` JWKS: ${JWKS_URL}`);
332+
console.log(` Protected Resource Metadata: ${RESOURCE_METADATA_URL}`);
333+
});
334+
335+
process.on('SIGINT', async () => {
336+
console.log('Shutting down server...');
337+
for (const sid of Object.keys(transports)) {
338+
try {
339+
await transports[sid]!.close();
340+
delete transports[sid];
341+
} catch (error) {
342+
console.error(`Error closing transport ${sid}:`, error);
343+
}
344+
}
345+
// eslint-disable-next-line unicorn/no-process-exit
346+
process.exit(0);
347+
});

0 commit comments

Comments
 (0)