Skip to content

Commit b1cdd1f

Browse files
committed
docs(examples): add external auth resource server example (closes modelcontextprotocol#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 b1cdd1f

4 files changed

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

0 commit comments

Comments
 (0)