Skip to content

Commit b0ef89f

Browse files
KKonstantinovpcarletoncliffhall
authored
v2: fix simpleStreamableHttp auth example for inspector connect flow (modelcontextprotocol#1427)
Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com> Co-authored-by: Cliff Hall <cliff@futurescale.com>
1 parent 00249ce commit b0ef89f

File tree

10 files changed

+173
-101
lines changed

10 files changed

+173
-101
lines changed

.changeset/respect-capability-negotiation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Respect capability negotiation in list methods by returning empty lists when server lacks capability
66

77
The Client now returns empty lists instead of sending requests to servers that don't advertise the corresponding capability:
8+
89
- `listPrompts()` returns `{ prompts: [] }` if server lacks prompts capability
910
- `listResources()` returns `{ resources: [] }` if server lacks resources capability
1011
- `listResourceTemplates()` returns `{ resourceTemplates: [] }` if server lacks resources capability

examples/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"@modelcontextprotocol/server": "workspace:^",
4141
"@modelcontextprotocol/express": "workspace:^",
4242
"@modelcontextprotocol/hono": "workspace:^",
43-
"better-auth": "^1.4.7",
43+
"better-auth": "^1.4.17",
4444
"cors": "catalog:runtimeServerOnly",
4545
"express": "catalog:runtimeServerOnly",
4646
"hono": "catalog:runtimeServerOnly",

examples/server/src/elicitationUrlExample.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@ setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: t
239239

240240
// Add protected resource metadata route to the MCP server
241241
// This allows clients to discover the auth server
242-
app.use(createProtectedResourceMetadataRouter());
242+
// Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp
243+
app.use(createProtectedResourceMetadataRouter('/mcp'));
243244

244245
authMiddleware = requireBearerAuth({
245246
requiredScopes: [],
@@ -709,6 +710,7 @@ app.listen(MCP_PORT, error => {
709710
process.exit(1);
710711
}
711712
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
713+
console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`);
712714
});
713715

714716
// Handle server shutdown

examples/server/src/simpleStreamableHttp.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
isInitializeRequest,
2323
McpServer
2424
} from '@modelcontextprotocol/server';
25+
import cors from 'cors';
2526
import type { Request, Response } from 'express';
2627
import * as z from 'zod/v4';
2728

@@ -30,6 +31,7 @@ import { InMemoryEventStore } from './inMemoryEventStore.js';
3031
// Check for OAuth flag
3132
const useOAuth = process.argv.includes('--oauth');
3233
const strictOAuth = process.argv.includes('--oauth-strict');
34+
const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled');
3335

3436
// Create shared task store for demonstration
3537
const taskStore = new InMemoryTaskStore();
@@ -524,18 +526,29 @@ const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AU
524526

525527
const app = createMcpExpressApp();
526528

529+
// Enable CORS for browser-based clients (demo only)
530+
// This allows cross-origin requests and exposes WWW-Authenticate header for OAuth
531+
// WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself.
532+
app.use(
533+
cors({
534+
exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'],
535+
origin: '*' // WARNING: This allows all origins to access the MCP server. In production, you should restrict this to specific origins.
536+
})
537+
);
538+
527539
// Set up OAuth if enabled
528540
let authMiddleware = null;
529541
if (useOAuth) {
530542
// Create auth middleware for MCP endpoints
531543
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
532544
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
533545

534-
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: true });
546+
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: true, dangerousLoggingEnabled });
535547

536548
// Add protected resource metadata route to the MCP server
537549
// This allows clients to discover the auth server
538-
app.use(createProtectedResourceMetadataRouter());
550+
// Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp
551+
app.use(createProtectedResourceMetadataRouter('/mcp'));
539552

540553
authMiddleware = requireBearerAuth({
541554
requiredScopes: [],
@@ -699,6 +712,9 @@ app.listen(MCP_PORT, error => {
699712
process.exit(1);
700713
}
701714
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
715+
if (useOAuth) {
716+
console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`);
717+
}
702718
});
703719

704720
// Handle server shutdown

examples/shared/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@
3737
"@modelcontextprotocol/core": "workspace:^",
3838
"@modelcontextprotocol/server": "workspace:^",
3939
"@modelcontextprotocol/express": "workspace:^",
40-
"better-auth": "1.4.7",
41-
"better-sqlite3": "^12.4.1",
40+
"better-auth": "^1.4.17",
41+
"better-sqlite3": "^12.6.2",
42+
"cors": "catalog:runtimeServerOnly",
4243
"express": "catalog:runtimeServerOnly"
4344
},
4445
"devDependencies": {
@@ -48,6 +49,7 @@
4849
"@modelcontextprotocol/tsconfig": "workspace:^",
4950
"@modelcontextprotocol/vitest-config": "workspace:^",
5051
"@types/better-sqlite3": "^7.6.13",
52+
"@types/cors": "catalog:devTools",
5153
"@types/express": "catalog:devTools",
5254
"@typescript/native-preview": "catalog:devTools",
5355
"eslint": "catalog:devTools",

examples/shared/src/authMiddleware.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,26 @@ export function requireBearerAuth(
2626
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
2727
const { requiredScopes = [], resourceMetadataUrl, strictResource = false, expectedResource } = options;
2828

29+
// Build WWW-Authenticate header matching v1.x format
30+
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
31+
let header = `Bearer error="${errorCode}", error_description="${message}"`;
32+
if (requiredScopes.length > 0) {
33+
header += `, scope="${requiredScopes.join(' ')}"`;
34+
}
35+
if (resourceMetadataUrl) {
36+
header += `, resource_metadata="${resourceMetadataUrl.toString()}"`;
37+
}
38+
return header;
39+
};
40+
2941
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
3042
const authHeader = req.headers.authorization;
3143

3244
if (!authHeader || !authHeader.startsWith('Bearer ')) {
33-
const wwwAuthenticate = resourceMetadataUrl ? `Bearer resource_metadata="${resourceMetadataUrl.toString()}"` : 'Bearer';
34-
35-
res.set('WWW-Authenticate', wwwAuthenticate);
45+
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header'));
3646
res.status(401).json({
37-
error: 'unauthorized',
38-
error_description: 'Missing or invalid Authorization header'
47+
error: 'invalid_token',
48+
error_description: 'Missing Authorization header'
3949
});
4050
return;
4151
}
@@ -52,6 +62,7 @@ export function requireBearerAuth(
5262
if (requiredScopes.length > 0) {
5363
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
5464
if (!hasAllScopes) {
65+
res.set('WWW-Authenticate', buildWwwAuthHeader('insufficient_scope', `Required scopes: ${requiredScopes.join(', ')}`));
5566
res.status(403).json({
5667
error: 'insufficient_scope',
5768
error_description: `Required scopes: ${requiredScopes.join(', ')}`
@@ -63,14 +74,11 @@ export function requireBearerAuth(
6374
req.app.locals.auth = authInfo;
6475
next();
6576
} catch (error) {
66-
const wwwAuthenticate = resourceMetadataUrl
67-
? `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl.toString()}"`
68-
: 'Bearer error="invalid_token"';
69-
70-
res.set('WWW-Authenticate', wwwAuthenticate);
77+
const message = error instanceof Error ? error.message : 'Invalid token';
78+
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', message));
7179
res.status(401).json({
7280
error: 'invalid_token',
73-
error_description: error instanceof Error ? error.message : 'Invalid token'
81+
error_description: message
7482
});
7583
}
7684
};

examples/shared/src/authServer.ts

Lines changed: 76 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { toNodeHandler } from 'better-auth/node';
1313
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
14+
import cors from 'cors';
1415
import type { Request, Response as ExpressResponse, Router } from 'express';
1516
import express from 'express';
1617

@@ -25,6 +26,12 @@ export interface SetupAuthServerOptions {
2526
* Examples should be used for **demo** only and not for production purposes, however this mode disables some logging and other features.
2627
*/
2728
demoMode: boolean;
29+
/**
30+
* Enable verbose logging of better-auth requests/responses.
31+
* WARNING: This may log sensitive information like tokens and cookies.
32+
* Only use for debugging purposes.
33+
*/
34+
dangerousLoggingEnabled?: boolean;
2835
}
2936

3037
// Store auth instance globally so it can be used for token verification
@@ -79,7 +86,7 @@ async function ensureDemoUserExists(auth: DemoAuth): Promise<void> {
7986
* @param options - Server configuration
8087
*/
8188
export function setupAuthServer(options: SetupAuthServerOptions): void {
82-
const { authServerUrl, mcpServerUrl, demoMode } = options;
89+
const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false } = options;
8390

8491
// Create better-auth instance with MCP plugin
8592
const auth = createDemoAuth({
@@ -96,54 +103,68 @@ export function setupAuthServer(options: SetupAuthServerOptions): void {
96103
const authApp = express();
97104

98105
// Enable CORS for all origins (demo only) - must be before other middleware
99-
authApp.use((_req, res, next) => {
100-
res.header('Access-Control-Allow-Origin', '*');
101-
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
102-
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
103-
res.header('Access-Control-Expose-Headers', 'WWW-Authenticate');
104-
if (_req.method === 'OPTIONS') {
105-
res.sendStatus(200);
106-
return;
107-
}
108-
next();
109-
});
106+
// WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself.
107+
authApp.use(
108+
cors({
109+
origin: '*' // WARNING: This allows all origins to access the auth server. In production, you should restrict this to specific origins.
110+
})
111+
);
110112

111-
// Request logging middleware for OAuth endpoints
112-
authApp.use('/api/auth', (req, res, next) => {
113-
const timestamp = new Date().toISOString();
114-
console.log(`${timestamp} [Auth Request] ${req.method} ${req.url}`);
115-
if (req.method === 'POST') {
116-
console.log(`${timestamp} [Auth Request] Content-Type: ${req.headers['content-type']}`);
117-
}
113+
// Create better-auth handler
114+
// toNodeHandler bypasses Express methods
115+
const betterAuthHandler = toNodeHandler(auth);
118116

119-
if (demoMode) {
120-
// Log response when it finishes
121-
const originalSend = res.send.bind(res);
122-
res.send = function (body) {
123-
console.log(`${timestamp} [Auth Response] ${res.statusCode} ${req.url}`);
124-
if (res.statusCode >= 400 && body) {
125-
try {
126-
const parsed = typeof body === 'string' ? JSON.parse(body) : body;
127-
console.log(`${timestamp} [Auth Response] Error:`, parsed);
128-
} catch {
129-
// Not JSON, log as-is if short
130-
if (typeof body === 'string' && body.length < 200) {
131-
console.log(`${timestamp} [Auth Response] Body: ${body}`);
132-
}
117+
// Mount better-auth handler BEFORE body parsers
118+
// toNodeHandler reads the raw request body, so Express must not consume it first
119+
if (dangerousLoggingEnabled) {
120+
// Verbose logging mode - intercept at Node.js level to see all requests/responses
121+
// WARNING: This may log sensitive information like tokens and cookies
122+
authApp.all('/api/auth/{*splat}', (req, res) => {
123+
const ts = new Date().toISOString();
124+
console.log(`\n${'='.repeat(60)}`);
125+
console.log(`${ts} [AUTH] ${req.method} ${req.originalUrl}`);
126+
console.log(`${ts} [AUTH] Query:`, JSON.stringify(req.query));
127+
console.log(`${ts} [AUTH] Headers.Cookie:`, req.headers.cookie?.slice(0, 100));
128+
129+
// Intercept writeHead to capture status and headers (including redirects)
130+
const originalWriteHead = res.writeHead.bind(res);
131+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
132+
res.writeHead = function (statusCode: number, ...args: any[]) {
133+
console.log(`${ts} [AUTH] >>> Response Status: ${statusCode}`);
134+
// Headers can be in different positions depending on the overload
135+
const headers = args.find(a => typeof a === 'object' && a !== null);
136+
if (headers) {
137+
if (headers.location || headers.Location) {
138+
console.log(`${ts} [AUTH] >>> Location (redirect): ${headers.location || headers.Location}`);
133139
}
140+
console.log(`${ts} [AUTH] >>> Headers:`, JSON.stringify(headers));
134141
}
135-
return originalSend(body);
142+
return originalWriteHead(statusCode, ...args);
136143
};
137-
}
138-
next();
139-
});
140144

141-
// Mount better-auth handler BEFORE body parsers
142-
// toNodeHandler reads the raw request body, so Express must not consume it first
143-
authApp.all('/api/auth/{*splat}', toNodeHandler(auth));
145+
// Intercept write to capture response body
146+
const originalWrite = res.write.bind(res);
147+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
148+
res.write = function (chunk: any, ...args: any[]) {
149+
if (chunk) {
150+
const bodyPreview = typeof chunk === 'string' ? chunk.slice(0, 500) : chunk.toString().slice(0, 500);
151+
console.log(`${ts} [AUTH] >>> Body: ${bodyPreview}`);
152+
}
153+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
154+
return originalWrite(chunk, ...(args as [any]));
155+
};
156+
157+
return betterAuthHandler(req, res);
158+
});
159+
} else {
160+
// Normal mode - no verbose logging
161+
authApp.all('/api/auth/{*splat}', toNodeHandler(auth));
162+
}
144163

145164
// OAuth metadata endpoints using better-auth's built-in handlers
146-
authApp.get('/.well-known/oauth-authorization-server', toNodeHandler(oAuthDiscoveryMetadata(auth)));
165+
// Add explicit OPTIONS handler for CORS preflight
166+
authApp.options('/.well-known/oauth-authorization-server', cors());
167+
authApp.get('/.well-known/oauth-authorization-server', cors(), toNodeHandler(oAuthDiscoveryMetadata(auth)));
147168

148169
// Body parsers for non-better-auth routes (like /sign-in)
149170
authApp.use(express.json());
@@ -239,14 +260,25 @@ export function setupAuthServer(options: SetupAuthServerOptions): void {
239260
* This is needed because MCP clients discover the auth server by first
240261
* fetching protected resource metadata from the MCP server.
241262
*
263+
* Per RFC 9728 Section 3, the metadata URL includes the resource path.
264+
* E.g., for resource http://localhost:3000/mcp, metadata is at
265+
* http://localhost:3000/.well-known/oauth-protected-resource/mcp
266+
*
242267
* See: https://www.better-auth.com/docs/plugins/mcp#oauth-protected-resource-metadata
268+
*
269+
* @param resourcePath - The path of the MCP resource (e.g., '/mcp'). Defaults to '/mcp'.
243270
*/
244-
export function createProtectedResourceMetadataRouter(): Router {
271+
export function createProtectedResourceMetadataRouter(resourcePath = '/mcp'): Router {
245272
const auth = getAuth();
246273
const router = express.Router();
247274

248-
// Serve at the standard well-known path
249-
router.get('/.well-known/oauth-protected-resource', toNodeHandler(oAuthProtectedResourceMetadata(auth)));
275+
// Construct the metadata path per RFC 9728 Section 3
276+
const metadataPath = `/.well-known/oauth-protected-resource${resourcePath}`;
277+
278+
// Enable CORS for browser-based clients to discover the auth server
279+
// Add explicit OPTIONS handler for CORS preflight
280+
router.options(metadataPath, cors());
281+
router.get(metadataPath, cors(), toNodeHandler(oAuthProtectedResourceMetadata(auth)));
250282

251283
return router;
252284
}

examples/shared/tsconfig.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"include": ["./"],
44
"exclude": ["node_modules", "dist"],
55
"compilerOptions": {
6+
"declaration": false,
7+
"declarationMap": false,
68
"paths": {
79
"*": ["./*"],
810
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],

0 commit comments

Comments
 (0)