feat(express): restore Resource-Server auth glue (requireBearerAuth, mcpAuthMetadataRouter) (#1907)
This commit is contained in:
parent
2a7611d46b
commit
7cccc2aca8
8 changed files with 693 additions and 184 deletions
|
|
@ -29,6 +29,7 @@ pnpm tsx src/simpleStreamableHttp.ts
|
|||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) |
|
||||
| Resource-Server-only auth | Minimal OAuth RS using SDK's `mcpAuthMetadataRouter` + `requireBearerAuth` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) |
|
||||
| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) |
|
||||
| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) |
|
||||
| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) |
|
||||
|
|
@ -43,7 +44,6 @@ pnpm tsx src/simpleStreamableHttp.ts
|
|||
|
||||
```bash
|
||||
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth
|
||||
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth --oauth-strict
|
||||
```
|
||||
|
||||
## URL elicitation example (server + client)
|
||||
|
|
|
|||
|
|
@ -9,13 +9,8 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createProtectedResourceMetadataRouter,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
requireBearerAuth,
|
||||
setupAuthServer
|
||||
} from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server';
|
||||
import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server';
|
||||
|
|
@ -235,7 +230,7 @@ let authMiddleware = null;
|
|||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: true });
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true });
|
||||
|
||||
// Add protected resource metadata route to the MCP server
|
||||
// This allows clients to discover the auth server
|
||||
|
|
@ -243,10 +238,9 @@ setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: t
|
|||
app.use(createProtectedResourceMetadataRouter('/mcp'));
|
||||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: demoTokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
strictResource: true,
|
||||
expectedResource: mcpServerUrl
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
|||
87
examples/server/src/resourceServerOnly.ts
Normal file
87
examples/server/src/resourceServerOnly.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Minimal Resource-Server-only auth using the SDK's RS helpers
|
||||
* (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`).
|
||||
*
|
||||
* No better-auth. The Authorization Server is external; this example points
|
||||
* its metadata at a placeholder issuer. For a full AS+RS setup with a real
|
||||
* demo Authorization Server, see {@link ./simpleStreamableHttp.ts}.
|
||||
*
|
||||
* Run: pnpm tsx src/resourceServerOnly.ts
|
||||
* Probe: curl http://localhost:3000/.well-known/oauth-protected-resource/mcp
|
||||
* curl -H 'Authorization: Bearer demo-token' -X POST http://localhost:3000/mcp ...
|
||||
*/
|
||||
|
||||
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
|
||||
import {
|
||||
createMcpExpressApp,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
mcpAuthMetadataRouter,
|
||||
requireBearerAuth
|
||||
} from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { AuthInfo, CallToolResult, OAuthMetadata } from '@modelcontextprotocol/server';
|
||||
import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const PORT = 3000;
|
||||
const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`);
|
||||
|
||||
// In a real deployment this is your external Authorization Server's metadata
|
||||
// (RFC 8414). The SDK router serves it verbatim at
|
||||
// /.well-known/oauth-authorization-server so clients probing the RS origin
|
||||
// can still discover the AS.
|
||||
const oauthMetadata: OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com',
|
||||
authorization_endpoint: 'https://auth.example.com/authorize',
|
||||
token_endpoint: 'https://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
};
|
||||
|
||||
// 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 server = new McpServer({ name: 'rs-only', version: '1.0.0' }, { capabilities: {} });
|
||||
server.registerTool(
|
||||
'whoami',
|
||||
{ description: 'Returns the authenticated subject.', inputSchema: z.object({}) },
|
||||
async (_args, ctx): Promise<CallToolResult> => ({
|
||||
content: [{ type: 'text', text: `client=${ctx.http?.authInfo?.clientId ?? 'anon'}` }]
|
||||
})
|
||||
);
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
resourceName: 'RS-only example'
|
||||
})
|
||||
);
|
||||
|
||||
const auth = requireBearerAuth({
|
||||
verifier: staticTokenVerifier,
|
||||
requiredScopes: ['mcp'],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
|
||||
app.post('/mcp', auth, async (req: Request, res: Response) => {
|
||||
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
res.on('close', () => void transport.close());
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`RS-only MCP server on http://localhost:${PORT}/mcp`);
|
||||
console.log(` PRM: ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`);
|
||||
console.log(` AS metadata mirror: http://localhost:${PORT}/.well-known/oauth-authorization-server`);
|
||||
});
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
createProtectedResourceMetadataRouter,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
requireBearerAuth,
|
||||
setupAuthServer
|
||||
} from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
|
|
@ -25,7 +20,6 @@ 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
|
||||
|
|
@ -624,7 +618,7 @@ if (useOAuth) {
|
|||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: true, dangerousLoggingEnabled });
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, dangerousLoggingEnabled });
|
||||
|
||||
// Add protected resource metadata route to the MCP server
|
||||
// This allows clients to discover the auth server
|
||||
|
|
@ -632,10 +626,9 @@ if (useOAuth) {
|
|||
app.use(createProtectedResourceMetadataRouter('/mcp'));
|
||||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: demoTokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
strictResource: strictOAuth,
|
||||
expectedResource: mcpServerUrl
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -651,8 +644,8 @@ const mcpPostHandler = async (req: Request, res: Response) => {
|
|||
console.log('Request body:', req.body);
|
||||
}
|
||||
|
||||
if (useOAuth && req.app.locals.auth) {
|
||||
console.log('Authenticated user:', req.app.locals.auth);
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated user:', req.auth);
|
||||
}
|
||||
try {
|
||||
let transport: NodeStreamableHTTPServerTransport;
|
||||
|
|
@ -742,8 +735,8 @@ const mcpGetHandler = async (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (useOAuth && req.app.locals.auth) {
|
||||
console.log('Authenticated SSE connection from user:', req.app.locals.auth);
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated SSE connection from user:', req.auth);
|
||||
}
|
||||
|
||||
// Check for Last-Event-ID header for resumability
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
/**
|
||||
* Auth Middleware for MCP Demo Servers
|
||||
*
|
||||
* 🚨 DEMO ONLY - NOT FOR PRODUCTION
|
||||
*
|
||||
* This provides bearer auth middleware for MCP servers.
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { verifyAccessToken } from './authServer.js';
|
||||
|
||||
export interface RequireBearerAuthOptions {
|
||||
requiredScopes?: string[];
|
||||
resourceMetadataUrl?: URL;
|
||||
strictResource?: boolean;
|
||||
expectedResource?: URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware that requires a valid Bearer token.
|
||||
* Sets `req.app.locals.auth` on success.
|
||||
*/
|
||||
export function requireBearerAuth(
|
||||
options: RequireBearerAuthOptions = {}
|
||||
): (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 ')) {
|
||||
res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header'));
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Missing Authorization header'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7); // Remove 'Bearer ' prefix
|
||||
|
||||
try {
|
||||
const authInfo = await verifyAccessToken(token, {
|
||||
strictResource,
|
||||
expectedResource
|
||||
});
|
||||
|
||||
// Check required scopes
|
||||
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(', ')}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
req.app.locals.auth = authInfo;
|
||||
next();
|
||||
} catch (error) {
|
||||
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: message
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get the protected resource metadata URL from a server URL.
|
||||
*/
|
||||
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): URL {
|
||||
const metadataUrl = new URL(serverUrl);
|
||||
// Insert well-known between host and path per RFC 9728 Section 3
|
||||
metadataUrl.pathname = `/.well-known/oauth-protected-resource${serverUrl.pathname}`;
|
||||
return metadataUrl;
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@
|
|||
* See: https://www.better-auth.com/docs/plugins/mcp
|
||||
*/
|
||||
|
||||
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
|
||||
import type { AuthInfo } from '@modelcontextprotocol/server';
|
||||
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import { toNodeHandler } from 'better-auth/node';
|
||||
import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins';
|
||||
import cors from 'cors';
|
||||
|
|
@ -21,7 +24,6 @@ import { createDemoAuth, DEMO_USER_CREDENTIALS } from './auth.js';
|
|||
export interface SetupAuthServerOptions {
|
||||
authServerUrl: URL;
|
||||
mcpServerUrl: URL;
|
||||
strictResource?: boolean;
|
||||
/**
|
||||
* Examples should be used for **demo** only and not for production purposes, however this mode disables some logging and other features.
|
||||
*/
|
||||
|
|
@ -284,60 +286,29 @@ export function createProtectedResourceMetadataRouter(resourcePath = '/mcp'): Ro
|
|||
}
|
||||
|
||||
/**
|
||||
* Verifies an access token using better-auth's getMcpSession.
|
||||
* This can be used by MCP servers to validate tokens.
|
||||
* Demo {@link OAuthTokenVerifier} backed by better-auth's `getMcpSession`.
|
||||
* Pass this to `requireBearerAuth({ verifier: demoTokenVerifier, ... })` from
|
||||
* `@modelcontextprotocol/express` to validate Bearer tokens against the demo
|
||||
* Authorization Server started by `setupAuthServer`.
|
||||
*/
|
||||
export async function verifyAccessToken(
|
||||
token: string,
|
||||
options?: { strictResource?: boolean; expectedResource?: URL }
|
||||
): Promise<{
|
||||
token: string;
|
||||
clientId: string;
|
||||
scopes: string[];
|
||||
expiresAt: number;
|
||||
}> {
|
||||
const auth = getAuth();
|
||||
export const demoTokenVerifier: OAuthTokenVerifier = {
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const auth = getAuth();
|
||||
|
||||
try {
|
||||
// Create a mock request with the Authorization header
|
||||
const headers = new Headers();
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// Use better-auth's getMcpSession API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = await (auth.api as any).getMcpSession({
|
||||
headers
|
||||
});
|
||||
|
||||
const session = await (auth.api as any).getMcpSession({ headers });
|
||||
if (!session) {
|
||||
throw new Error('Invalid token');
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid token');
|
||||
}
|
||||
|
||||
// OAuthAccessToken has:
|
||||
// - accessToken, refreshToken: string
|
||||
// - accessTokenExpiresAt, refreshTokenExpiresAt: Date
|
||||
// - clientId, userId: string
|
||||
// - scopes: string (space-separated)
|
||||
const scopes = typeof session.scopes === 'string' ? session.scopes.split(' ') : ['openid'];
|
||||
const expiresAt = session.accessTokenExpiresAt
|
||||
? Math.floor(new Date(session.accessTokenExpiresAt).getTime() / 1000)
|
||||
: Math.floor(Date.now() / 1000) + 3600;
|
||||
|
||||
// Note: better-auth's OAuthAccessToken doesn't have a resource field
|
||||
// Resource validation would need to be done at a different layer
|
||||
if (options?.strictResource && options.expectedResource) {
|
||||
// For now, we skip resource validation as it's not in the session
|
||||
// In production, you'd store and validate this separately
|
||||
console.warn('[Auth] Resource validation requested but not available in better-auth session');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
clientId: session.clientId,
|
||||
scopes,
|
||||
expiresAt
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Token verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return { token, clientId: session.clientId, scopes, expiresAt };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,10 +2,6 @@
|
|||
export type { CreateDemoAuthOptions, DemoAuth } from './auth.js';
|
||||
export { createDemoAuth } from './auth.js';
|
||||
|
||||
// Auth middleware
|
||||
export type { RequireBearerAuthOptions } from './authMiddleware.js';
|
||||
export { getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from './authMiddleware.js';
|
||||
|
||||
// Auth server setup
|
||||
// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express)
|
||||
export type { SetupAuthServerOptions } from './authServer.js';
|
||||
export { createProtectedResourceMetadataRouter, getAuth, setupAuthServer, verifyAccessToken } from './authServer.js';
|
||||
export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"paths": {
|
||||
"*": ["./*"],
|
||||
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
|
||||
"@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"],
|
||||
"@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"],
|
||||
"@modelcontextprotocol/core": [
|
||||
"./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts"
|
||||
|
|
|
|||
Loading…
Reference in a new issue