Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/respect-capability-negotiation.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Respect capability negotiation in list methods by returning empty lists when server lacks capability

The Client now returns empty lists instead of sending requests to servers that don't advertise the corresponding capability:

- `listPrompts()` returns `{ prompts: [] }` if server lacks prompts capability
- `listResources()` returns `{ resources: [] }` if server lacks resources capability
- `listResourceTemplates()` returns `{ resourceTemplates: [] }` if server lacks resources capability
Expand Down
2 changes: 1 addition & 1 deletion examples/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"@modelcontextprotocol/server": "workspace:^",
"@modelcontextprotocol/express": "workspace:^",
"@modelcontextprotocol/hono": "workspace:^",
"better-auth": "^1.4.7",
"better-auth": "^1.4.17",
"cors": "catalog:runtimeServerOnly",
"express": "catalog:runtimeServerOnly",
"hono": "catalog:runtimeServerOnly",
Expand Down
4 changes: 3 additions & 1 deletion examples/server/src/elicitationUrlExample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,8 @@ setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: t

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

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

// Handle server shutdown
Expand Down
20 changes: 18 additions & 2 deletions examples/server/src/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isInitializeRequest,
McpServer
} from '@modelcontextprotocol/server';
import cors from 'cors';
import type { Request, Response } from 'express';
import * as z from 'zod/v4';

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

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

const app = createMcpExpressApp();

// Enable CORS for browser-based clients (demo only)
// This allows cross-origin requests and exposes WWW-Authenticate header for OAuth
// WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself.
app.use(
cors({
exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'],
origin: '*' // WARNING: This allows all origins to access the MCP server. In production, you should restrict this to specific origins.
})
);

// Set up OAuth if enabled
let authMiddleware = null;
if (useOAuth) {
// Create auth middleware for MCP endpoints
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);

setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: true });
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: true, dangerousLoggingEnabled });

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

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

// Handle server shutdown
Expand Down
6 changes: 4 additions & 2 deletions examples/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@
"@modelcontextprotocol/core": "workspace:^",
"@modelcontextprotocol/server": "workspace:^",
"@modelcontextprotocol/express": "workspace:^",
"better-auth": "1.4.7",
"better-sqlite3": "^12.4.1",
"better-auth": "^1.4.17",
"better-sqlite3": "^12.6.2",
"cors": "catalog:runtimeServerOnly",
"express": "catalog:runtimeServerOnly"
},
"devDependencies": {
Expand All @@ -48,6 +49,7 @@
"@modelcontextprotocol/tsconfig": "workspace:^",
"@modelcontextprotocol/vitest-config": "workspace:^",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "catalog:devTools",
"@types/express": "catalog:devTools",
"@typescript/native-preview": "catalog:devTools",
"eslint": "catalog:devTools",
Expand Down
30 changes: 19 additions & 11 deletions examples/shared/src/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,26 @@ export function requireBearerAuth(
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
const { requiredScopes = [], resourceMetadataUrl, strictResource = false, expectedResource } = options;

// Build WWW-Authenticate header matching v1.x format
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
let header = `Bearer error="${errorCode}", error_description="${message}"`;
if (requiredScopes.length > 0) {
header += `, scope="${requiredScopes.join(' ')}"`;
}
if (resourceMetadataUrl) {
header += `, resource_metadata="${resourceMetadataUrl.toString()}"`;
}
return header;
};

return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
const wwwAuthenticate = resourceMetadataUrl ? `Bearer resource_metadata="${resourceMetadataUrl.toString()}"` : 'Bearer';

res.set('WWW-Authenticate', wwwAuthenticate);
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header'));
res.status(401).json({
error: 'unauthorized',
error_description: 'Missing or invalid Authorization header'
error: 'invalid_token',
error_description: 'Missing Authorization header'
});
return;
}
Expand All @@ -52,6 +62,7 @@ export function requireBearerAuth(
if (requiredScopes.length > 0) {
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
if (!hasAllScopes) {
res.set('WWW-Authenticate', buildWwwAuthHeader('insufficient_scope', `Required scopes: ${requiredScopes.join(', ')}`));
res.status(403).json({
error: 'insufficient_scope',
error_description: `Required scopes: ${requiredScopes.join(', ')}`
Expand All @@ -63,14 +74,11 @@ export function requireBearerAuth(
req.app.locals.auth = authInfo;
next();
} catch (error) {
const wwwAuthenticate = resourceMetadataUrl
? `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl.toString()}"`
: 'Bearer error="invalid_token"';

res.set('WWW-Authenticate', wwwAuthenticate);
const message = error instanceof Error ? error.message : 'Invalid token';
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', message));
res.status(401).json({
error: 'invalid_token',
error_description: error instanceof Error ? error.message : 'Invalid token'
error_description: message
});
}
};
Expand Down
120 changes: 76 additions & 44 deletions examples/shared/src/authServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { toNodeHandler } from 'better-auth/node';
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
import cors from 'cors';
import type { Request, Response as ExpressResponse, Router } from 'express';
import express from 'express';

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

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

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

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

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

if (demoMode) {
// Log response when it finishes
const originalSend = res.send.bind(res);
res.send = function (body) {
console.log(`${timestamp} [Auth Response] ${res.statusCode} ${req.url}`);
if (res.statusCode >= 400 && body) {
try {
const parsed = typeof body === 'string' ? JSON.parse(body) : body;
console.log(`${timestamp} [Auth Response] Error:`, parsed);
} catch {
// Not JSON, log as-is if short
if (typeof body === 'string' && body.length < 200) {
console.log(`${timestamp} [Auth Response] Body: ${body}`);
}
// Mount better-auth handler BEFORE body parsers
// toNodeHandler reads the raw request body, so Express must not consume it first
if (dangerousLoggingEnabled) {
// Verbose logging mode - intercept at Node.js level to see all requests/responses
// WARNING: This may log sensitive information like tokens and cookies
authApp.all('/api/auth/{*splat}', (req, res) => {
const ts = new Date().toISOString();
console.log(`\n${'='.repeat(60)}`);
console.log(`${ts} [AUTH] ${req.method} ${req.originalUrl}`);
console.log(`${ts} [AUTH] Query:`, JSON.stringify(req.query));
console.log(`${ts} [AUTH] Headers.Cookie:`, req.headers.cookie?.slice(0, 100));

// Intercept writeHead to capture status and headers (including redirects)
const originalWriteHead = res.writeHead.bind(res);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res.writeHead = function (statusCode: number, ...args: any[]) {
console.log(`${ts} [AUTH] >>> Response Status: ${statusCode}`);
// Headers can be in different positions depending on the overload
const headers = args.find(a => typeof a === 'object' && a !== null);
if (headers) {
if (headers.location || headers.Location) {
console.log(`${ts} [AUTH] >>> Location (redirect): ${headers.location || headers.Location}`);
}
console.log(`${ts} [AUTH] >>> Headers:`, JSON.stringify(headers));
}
return originalSend(body);
return originalWriteHead(statusCode, ...args);
};
}
next();
});

// Mount better-auth handler BEFORE body parsers
// toNodeHandler reads the raw request body, so Express must not consume it first
authApp.all('/api/auth/{*splat}', toNodeHandler(auth));
// Intercept write to capture response body
const originalWrite = res.write.bind(res);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res.write = function (chunk: any, ...args: any[]) {
if (chunk) {
const bodyPreview = typeof chunk === 'string' ? chunk.slice(0, 500) : chunk.toString().slice(0, 500);
console.log(`${ts} [AUTH] >>> Body: ${bodyPreview}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return originalWrite(chunk, ...(args as [any]));
};

return betterAuthHandler(req, res);
});
} else {
// Normal mode - no verbose logging
authApp.all('/api/auth/{*splat}', toNodeHandler(auth));
}

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

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

// Serve at the standard well-known path
router.get('/.well-known/oauth-protected-resource', toNodeHandler(oAuthProtectedResourceMetadata(auth)));
// Construct the metadata path per RFC 9728 Section 3
const metadataPath = `/.well-known/oauth-protected-resource${resourcePath}`;

// Enable CORS for browser-based clients to discover the auth server
// Add explicit OPTIONS handler for CORS preflight
router.options(metadataPath, cors());
router.get(metadataPath, cors(), toNodeHandler(oAuthProtectedResourceMetadata(auth)));

return router;
}
Expand Down
2 changes: 2 additions & 0 deletions examples/shared/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"include": ["./"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"declaration": false,
"declarationMap": false,
"paths": {
"*": ["./*"],
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
Expand Down
Loading
Loading