diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1167b176a..25cd12862 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,4 +38,6 @@ jobs: run: pnpm run build:all - name: Publish preview packages - run: pnpm dlx pkg-pr-new publish --packageManager=npm --pnpm './packages/server' './packages/client' + run: + pnpm dlx pkg-pr-new publish --packageManager=npm --pnpm './packages/server' './packages/client' + './packages/server-express' './packages/server-hono' diff --git a/.gitignore b/.gitignore index a1b83bc4f..75c943a57 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ dist/ # IDE .idea/ +.cursor/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 2a0b253a6..3caca17b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ mcpServer.tool('tool-name', { param: z.string() }, async ({ param }, extra) => { ```typescript // Server -const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); +const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); await server.connect(transport); // Client diff --git a/README.md b/README.md index dc0116c96..4d5270287 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # MCP TypeScript SDK -> [!IMPORTANT] -> **This is the `main` branch which contains v2 of the SDK (currently in development, pre-alpha).** +> [!IMPORTANT] **This is the `main` branch which contains v2 of the SDK (currently in development, pre-alpha).** > > We anticipate a stable v2 release in Q1 2026. Until then, **v1.x remains the recommended version** for production use. v1.x will continue to receive bug fixes and security updates for at least 6 months after v2 ships to give people time to upgrade. > diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index 321f3f6fc..6ac057c69 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -47,6 +47,7 @@ export default defineConfig( '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], 'simple-import-sort/imports': 'warn', 'simple-import-sort/exports': 'warn', + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], 'import/no-extraneous-dependencies': [ 'error', { diff --git a/docs/faq.md b/docs/faq.md index 1afe1d10b..4e2f6cc35 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -65,6 +65,14 @@ For production use, you can either: The SDK ships several runnable server examples under `examples/server/src`. Start from the server examples index in [`examples/server/README.md`](../examples/server/README.md) and the entry-point quick start in the root [`README.md`](../README.md). +### Why did we remove `server` auth exports? + +Server authentication & authorization is outside of the scope of the SDK, and the recommendation is to use packages that focus on this area specifically (or a full-fledged Authorization Server for those who use such). Example packages provide an example with `better-auth`. + +### Why did we remove `server` SSE transport? + +The SSE transport has been deprecated for a long time, and `v2` will not support it on the server side any more. Client side will keep supporting it in order to be able to connect to legacy SSE servers via the `v2` SDK, but serving SSE from `v2` will not be possible. Servers wanting to switch to `v2` and using SSE should migrate to Streamable HTTP. + ## v1 (legacy) ### Where do v1 documentation and v1-specific fixes live? diff --git a/docs/server.md b/docs/server.md index 4d5138e84..800d336db 100644 --- a/docs/server.md +++ b/docs/server.md @@ -70,7 +70,7 @@ For more detailed patterns (stateless vs stateful, JSON response mode, CORS, DNS MCP servers running on localhost are vulnerable to DNS rebinding attacks. Use `createMcpExpressApp()` to create an Express app with DNS rebinding protection enabled by default: ```typescript -import { createMcpExpressApp } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; // Protection auto-enabled (default host is 127.0.0.1) const app = createMcpExpressApp(); @@ -85,7 +85,7 @@ const app = createMcpExpressApp({ host: '0.0.0.0' }); When binding to `0.0.0.0` / `::`, provide an allow-list of hosts: ```typescript -import { createMcpExpressApp } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; const app = createMcpExpressApp({ host: '0.0.0.0', diff --git a/examples/server/README.md b/examples/server/README.md index 310113e45..1e7322b1a 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -1,6 +1,9 @@ # MCP TypeScript SDK Examples (Server) -This directory contains runnable MCP **server** examples built with `@modelcontextprotocol/server`. +This directory contains runnable MCP **server** examples built with `@modelcontextprotocol/server` plus framework adapters: + +- `@modelcontextprotocol/server-express` +- `@modelcontextprotocol/server-hono` For client examples, see [`../client/README.md`](../client/README.md). For guided docs, see [`../../docs/server.md`](../../docs/server.md). @@ -68,7 +71,7 @@ When deploying MCP servers in a horizontally scaled environment (multiple server ### Stateless mode -To enable stateless mode, configure the `StreamableHTTPServerTransport` with: +To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with: ```typescript sessionIdGenerator: undefined; diff --git a/examples/server/package.json b/examples/server/package.json index a3a3d14c7..7241f3ab2 100644 --- a/examples/server/package.json +++ b/examples/server/package.json @@ -35,11 +35,14 @@ }, "dependencies": { "@hono/node-server": "catalog:runtimeServerOnly", - "hono": "catalog:runtimeServerOnly", "@modelcontextprotocol/examples-shared": "workspace:^", "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/server-express": "workspace:^", + "@modelcontextprotocol/server-hono": "workspace:^", + "better-auth": "^1.4.7", "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", + "hono": "catalog:runtimeServerOnly", "zod": "catalog:runtimeShared" }, "devDependencies": { diff --git a/examples/server/src/elicitationFormExample.ts b/examples/server/src/elicitationFormExample.ts index f8863c17b..eaeb73c32 100644 --- a/examples/server/src/elicitationFormExample.ts +++ b/examples/server/src/elicitationFormExample.ts @@ -9,8 +9,9 @@ import { randomUUID } from 'node:crypto'; -import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import { type Request, type Response } from 'express'; +import { isInitializeRequest, McpServer, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; +import type { Request, Response } from 'express'; // Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults // The validator supports format validation (email, date, etc.) if ajv-formats is installed @@ -321,7 +322,7 @@ async function main() { const app = createMcpExpressApp(); // Map to store transports by session ID - const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; // MCP POST endpoint const mcpPostHandler = async (req: Request, res: Response) => { @@ -331,13 +332,13 @@ async function main() { } try { - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport for this session transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request - create new transport - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: sessionId => { // Store the transport by session ID when session is initialized diff --git a/examples/server/src/elicitationUrlExample.ts b/examples/server/src/elicitationUrlExample.ts index 99f85d079..b3d433214 100644 --- a/examples/server/src/elicitationUrlExample.ts +++ b/examples/server/src/elicitationUrlExample.ts @@ -9,19 +9,20 @@ import { randomUUID } from 'node:crypto'; -import { setupAuthServer } from '@modelcontextprotocol/examples-shared'; -import type { CallToolResult, ElicitRequestURLParams, ElicitResult, OAuthMetadata } from '@modelcontextprotocol/server'; import { - checkResourceAllowed, - createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, - isInitializeRequest, mcpAuthMetadataRouter, - McpServer, requireBearerAuth, - StreamableHTTPServerTransport, + setupAuthServer +} from '@modelcontextprotocol/examples-shared'; +import type { CallToolResult, ElicitRequestURLParams, ElicitResult, OAuthMetadata } from '@modelcontextprotocol/server'; +import { + isInitializeRequest, + McpServer, + NodeStreamableHTTPServerTransport, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -240,47 +241,6 @@ const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true }); -const tokenVerifier = { - verifyAccessToken: async (token: string) => { - const endpoint = oauthMetadata.introspection_endpoint; - - if (!endpoint) { - throw new Error('No token verification endpoint available in metadata'); - } - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - token: token - }).toString() - }); - - if (!response.ok) { - const text = await response.text().catch(() => null); - throw new Error(`Invalid or expired token: ${text}`); - } - - const data = (await response.json()) as { aud: string; client_id: string; scope: string; exp: number }; - - if (!data.aud) { - throw new Error(`Resource Indicator (RFC8707) missing`); - } - if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { - throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); - } - - // Convert the response to AuthInfo format - return { - token, - clientId: data.client_id, - scopes: data.scope ? data.scope.split(' ') : [], - expiresAt: data.exp - }; - } -}; // Add metadata routes to the main MCP server app.use( mcpAuthMetadataRouter({ @@ -292,9 +252,10 @@ app.use( ); authMiddleware = requireBearerAuth({ - verifier: tokenVerifier, requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), + strictResource: true, + expectedResource: mcpServerUrl }); /** @@ -594,7 +555,7 @@ app.post('/confirm-payment', express.urlencoded(), (req: Request, res: Response) }); // Map to store transports by session ID -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; // Interface for a function that can send an elicitation request type ElicitationSender = (params: ElicitRequestURLParams) => Promise; @@ -613,7 +574,7 @@ const mcpPostHandler = async (req: Request, res: Response) => { console.debug(`Received MCP POST for session: ${sessionId || 'unknown'}`); try { - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; @@ -621,7 +582,7 @@ const mcpPostHandler = async (req: Request, res: Response) => { const server = getServer(); // New initialization request const eventStore = new InMemoryEventStore(); - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, // Enable resumability onsessioninitialized: sessionId => { diff --git a/examples/server/src/jsonResponseStreamableHttp.ts b/examples/server/src/jsonResponseStreamableHttp.ts index 2199ebfbe..5935ad2c2 100644 --- a/examples/server/src/jsonResponseStreamableHttp.ts +++ b/examples/server/src/jsonResponseStreamableHttp.ts @@ -1,7 +1,8 @@ import { randomUUID } from 'node:crypto'; import type { CallToolResult } from '@modelcontextprotocol/server'; -import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; @@ -96,21 +97,21 @@ const getServer = () => { const app = createMcpExpressApp(); // Map to store transports by session ID -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; app.post('/mcp', async (req: Request, res: Response) => { console.log('Received MCP request:', req.body); try { // Check for existing session ID const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request - use JSON response mode - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true, // Enable JSON response mode onsessioninitialized: sessionId => { diff --git a/examples/server/src/simpleSseServer.ts b/examples/server/src/simpleSseServer.ts deleted file mode 100644 index 90561c62f..000000000 --- a/examples/server/src/simpleSseServer.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { createMcpExpressApp, McpServer, SSEServerTransport } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -/** - * This example server demonstrates the deprecated HTTP+SSE transport - * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. - * - * The server exposes two endpoints: - * - /mcp: For establishing the SSE stream (GET) - * - /messages: For receiving client messages (POST) - * - */ - -// Create an MCP server instance -const getServer = () => { - const server = new McpServer( - { - name: 'simple-sse-server', - version: '1.0.0' - }, - { capabilities: { logging: {} } } - ); - - server.registerTool( - 'start-notification-stream', - { - description: 'Starts sending periodic notifications', - inputSchema: { - interval: z.number().describe('Interval in milliseconds between notifications').default(1000), - count: z.number().describe('Number of notifications to send').default(10) - } - }, - async ({ interval, count }, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - // Send the initial notification - await server.sendLoggingMessage( - { - level: 'info', - data: `Starting notification stream with ${count} messages every ${interval}ms` - }, - extra.sessionId - ); - - // Send periodic notifications - while (counter < count) { - counter++; - await sleep(interval); - - try { - await server.sendLoggingMessage( - { - level: 'info', - data: `Notification #${counter} at ${new Date().toISOString()}` - }, - extra.sessionId - ); - } catch (error) { - console.error('Error sending notification:', error); - } - } - - return { - content: [ - { - type: 'text', - text: `Completed sending ${count} notifications every ${interval}ms` - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -// Store transports by session ID -const transports: Record = {}; - -// SSE endpoint for establishing the stream -app.get('/mcp', async (req: Request, res: Response) => { - console.log('Received GET request to /sse (establishing SSE stream)'); - - try { - // Create a new SSE transport for the client - // The endpoint for POST messages is '/messages' - const transport = new SSEServerTransport('/messages', res); - - // Store the transport by session ID - const sessionId = transport.sessionId; - transports[sessionId] = transport; - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - console.log(`SSE transport closed for session ${sessionId}`); - delete transports[sessionId]; - }; - - // Connect the transport to the MCP server - const server = getServer(); - await server.connect(transport); - - console.log(`Established SSE stream with session ID: ${sessionId}`); - } catch (error) { - console.error('Error establishing SSE stream:', error); - if (!res.headersSent) { - res.status(500).send('Error establishing SSE stream'); - } - } -}); - -// Messages endpoint for receiving client JSON-RPC requests -app.post('/messages', async (req: Request, res: Response) => { - console.log('Received POST request to /messages'); - - // Extract session ID from URL query parameter - // In the SSE protocol, this is added by the client based on the endpoint event - const sessionId = req.query.sessionId as string | undefined; - - if (!sessionId) { - console.error('No session ID provided in request URL'); - res.status(400).send('Missing sessionId parameter'); - return; - } - - const transport = transports[sessionId]; - if (!transport) { - console.error(`No active transport found for session ID: ${sessionId}`); - res.status(404).send('Session not found'); - return; - } - - try { - // Handle the POST message with the transport - await transport.handlePostMessage(req, res, req.body); - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) { - res.status(500).send('Error handling request'); - } - } -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - process.exit(1); - } - console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/simpleStatelessStreamableHttp.ts b/examples/server/src/simpleStatelessStreamableHttp.ts index 3aee2c212..70389275c 100644 --- a/examples/server/src/simpleStatelessStreamableHttp.ts +++ b/examples/server/src/simpleStatelessStreamableHttp.ts @@ -1,5 +1,6 @@ import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/server'; -import { createMcpExpressApp, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { McpServer, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; @@ -103,7 +104,7 @@ const app = createMcpExpressApp(); app.post('/mcp', async (req: Request, res: Response) => { const server = getServer(); try { - const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + const transport: NodeStreamableHTTPServerTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); diff --git a/examples/server/src/simpleStreamableHttp.ts b/examples/server/src/simpleStreamableHttp.ts index 7613e3786..b90820266 100644 --- a/examples/server/src/simpleStreamableHttp.ts +++ b/examples/server/src/simpleStreamableHttp.ts @@ -1,6 +1,11 @@ import { randomUUID } from 'node:crypto'; -import { setupAuthServer } from '@modelcontextprotocol/examples-shared'; +import { + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth, + setupAuthServer +} from '@modelcontextprotocol/examples-shared'; import type { CallToolResult, GetPromptResult, @@ -10,18 +15,14 @@ import type { ResourceLink } from '@modelcontextprotocol/server'; import { - checkResourceAllowed, - createMcpExpressApp, ElicitResultSchema, - getOAuthProtectedResourceMetadataUrl, InMemoryTaskMessageQueue, InMemoryTaskStore, isInitializeRequest, - mcpAuthMetadataRouter, McpServer, - requireBearerAuth, - StreamableHTTPServerTransport + NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; @@ -529,49 +530,6 @@ if (useOAuth) { const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth }); - const tokenVerifier = { - verifyAccessToken: async (token: string) => { - const endpoint = oauthMetadata.introspection_endpoint; - - if (!endpoint) { - throw new Error('No token verification endpoint available in metadata'); - } - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - token: token - }).toString() - }); - - if (!response.ok) { - const text = await response.text().catch(() => null); - throw new Error(`Invalid or expired token: ${text}`); - } - - const data = (await response.json()) as { aud: string; client_id: string; scope: string; exp: number }; - - if (strictOAuth) { - if (!data.aud) { - throw new Error(`Resource Indicator (RFC8707) missing`); - } - if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { - throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); - } - } - - // Convert the response to AuthInfo format - return { - token, - clientId: data.client_id, - scopes: data.scope ? data.scope.split(' ') : [], - expiresAt: data.exp - }; - } - }; // Add metadata routes to the main MCP server app.use( mcpAuthMetadataRouter({ @@ -583,14 +541,15 @@ if (useOAuth) { ); authMiddleware = requireBearerAuth({ - verifier: tokenVerifier, requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), + strictResource: strictOAuth, + expectedResource: mcpServerUrl }); } // Map to store transports by session ID -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; // MCP POST endpoint with optional auth const mcpPostHandler = async (req: Request, res: Response) => { @@ -601,18 +560,18 @@ const mcpPostHandler = async (req: Request, res: Response) => { console.log('Request body:', req.body); } - if (useOAuth && req.auth) { - console.log('Authenticated user:', req.auth); + if (useOAuth && req.app.locals.auth) { + console.log('Authenticated user:', req.app.locals.auth); } try { - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request const eventStore = new InMemoryEventStore(); - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, // Enable resumability onsessioninitialized: sessionId => { @@ -685,8 +644,8 @@ const mcpGetHandler = async (req: Request, res: Response) => { return; } - if (useOAuth && req.auth) { - console.log('Authenticated SSE connection from user:', req.auth); + if (useOAuth && req.app.locals.auth) { + console.log('Authenticated SSE connection from user:', req.app.locals.auth); } // Check for Last-Event-ID header for resumability diff --git a/examples/server/src/simpleTaskInteractive.ts b/examples/server/src/simpleTaskInteractive.ts index 956c33f8e..469ecf0c2 100644 --- a/examples/server/src/simpleTaskInteractive.ts +++ b/examples/server/src/simpleTaskInteractive.ts @@ -35,16 +35,16 @@ import type { } from '@modelcontextprotocol/server'; import { CallToolRequestSchema, - createMcpExpressApp, GetTaskPayloadRequestSchema, GetTaskRequestSchema, InMemoryTaskStore, isTerminal, ListToolsRequestSchema, + NodeStreamableHTTPServerTransport, RELATED_TASK_META_KEY, - Server, - StreamableHTTPServerTransport + Server } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; import type { Request, Response } from 'express'; // ============================================================================ @@ -642,7 +642,7 @@ const createServer = (): Server => { const app = createMcpExpressApp(); // Map to store transports by session ID -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; // Helper to check if request is initialize const isInitializeRequest = (body: unknown): boolean => { @@ -654,12 +654,12 @@ app.post('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; try { - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: sid => { console.log(`Session initialized: ${sid}`); diff --git a/examples/server/src/sseAndStreamableHttpCompatibleServer.ts b/examples/server/src/sseAndStreamableHttpCompatibleServer.ts deleted file mode 100644 index 335802d0a..000000000 --- a/examples/server/src/sseAndStreamableHttpCompatibleServer.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import type { CallToolResult } from '@modelcontextprotocol/server'; -import { - createMcpExpressApp, - isInitializeRequest, - McpServer, - SSEServerTransport, - StreamableHTTPServerTransport -} from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import * as z from 'zod/v4'; - -import { InMemoryEventStore } from './inMemoryEventStore.js'; - -/** - * This example server demonstrates backwards compatibility with both: - * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05) - * 2. The Streamable HTTP transport (protocol version 2025-11-25) - * - * It maintains a single MCP server instance but exposes two transport options: - * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) - * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) - * - /messages: The deprecated POST endpoint for older clients (POST to send messages) - */ - -const getServer = () => { - const server = new McpServer( - { - name: 'backwards-compatible-server', - version: '1.0.0' - }, - { capabilities: { logging: {} } } - ); - - // Register a simple tool that sends notifications over time - server.registerTool( - 'start-notification-stream', - { - description: 'Starts sending periodic notifications for testing resumability', - inputSchema: { - interval: z.number().describe('Interval in milliseconds between notifications').default(100), - count: z.number().describe('Number of notifications to send (0 for 100)').default(50) - } - }, - async ({ interval, count }, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await server.sendLoggingMessage( - { - level: 'info', - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - }, - extra.sessionId - ); - } catch (error) { - console.error('Error sending notification:', error); - } - // Wait for the specified interval - await sleep(interval); - } - - return { - content: [ - { - type: 'text', - text: `Started sending periodic notifications every ${interval}ms` - } - ] - }; - } - ); - return server; -}; - -// Create Express application -const app = createMcpExpressApp(); - -// Store transports by session ID -const transports: Record = {}; - -//============================================================================= -// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-11-25) -//============================================================================= - -// Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint -app.all('/mcp', async (req: Request, res: Response) => { - console.log(`Received ${req.method} request to /mcp`); - - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: StreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Check if the transport is of the correct type - const existingTransport = transports[sessionId]; - if (existingTransport instanceof StreamableHTTPServerTransport) { - // Reuse existing transport - transport = existingTransport; - } else { - // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: Session exists but uses a different transport protocol' - }, - id: null - }); - return; - } - } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { - const eventStore = new InMemoryEventStore(); - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - console.log(`StreamableHTTP session initialized with ID: ${sessionId}`); - transports[sessionId] = transport; - } - }); - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - console.log(`Transport closed for session ${sid}, removing from transports map`); - delete transports[sid]; - } - }; - - // Connect the transport to the MCP server - const server = getServer(); - await server.connect(transport); - } else { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided' - }, - id: null - }); - return; - } - - // Handle the request with the transport - await transport.handleRequest(req, res, req.body); - } catch (error) { - console.error('Error handling MCP request:', error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -//============================================================================= -// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) -//============================================================================= - -app.get('/sse', async (req: Request, res: Response) => { - console.log('Received GET request to /sse (deprecated SSE transport)'); - const transport = new SSEServerTransport('/messages', res); - transports[transport.sessionId] = transport; - res.on('close', () => { - delete transports[transport.sessionId]; - }); - const server = getServer(); - await server.connect(transport); -}); - -app.post('/messages', async (req: Request, res: Response) => { - const sessionId = req.query.sessionId as string; - let transport: SSEServerTransport; - const existingTransport = transports[sessionId]; - if (existingTransport instanceof SSEServerTransport) { - // Reuse existing transport - transport = existingTransport; - } else { - // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport) - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: Session exists but uses a different transport protocol' - }, - id: null - }); - return; - } - if (transport) { - await transport.handlePostMessage(req, res, req.body); - } else { - res.status(400).send('No transport found for sessionId'); - } -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - process.exit(1); - } - console.log(`Backwards compatible MCP server listening on port ${PORT}`); - console.log(` -============================================== -SUPPORTED TRANSPORT OPTIONS: - -1. Streamable Http(Protocol version: 2025-11-25) - Endpoint: /mcp - Methods: GET, POST, DELETE - Usage: - - Initialize with POST to /mcp - - Establish SSE stream with GET to /mcp - - Send requests with POST to /mcp - - Terminate session with DELETE to /mcp - -2. Http + SSE (Protocol version: 2024-11-05) - Endpoints: /sse (GET) and /messages (POST) - Usage: - - Establish SSE stream with GET to /sse - - Send requests with POST to /messages?sessionId= -============================================== -`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - - // Close all active transports to properly clean up resources - for (const sessionId in transports) { - try { - console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId]!.close(); - delete transports[sessionId]; - } catch (error) { - console.error(`Error closing transport for session ${sessionId}:`, error); - } - } - console.log('Server shutdown complete'); - process.exit(0); -}); diff --git a/examples/server/src/ssePollingExample.ts b/examples/server/src/ssePollingExample.ts index 4e3d36328..4d0841dee 100644 --- a/examples/server/src/ssePollingExample.ts +++ b/examples/server/src/ssePollingExample.ts @@ -15,7 +15,8 @@ import { randomUUID } from 'node:crypto'; import type { CallToolResult } from '@modelcontextprotocol/server'; -import { createMcpExpressApp, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { McpServer, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; import cors from 'cors'; import type { Request, Response } from 'express'; @@ -111,7 +112,7 @@ app.use(cors()); const eventStore = new InMemoryEventStore(); // Track transports by session ID for session reuse -const transports = new Map(); +const transports = new Map(); // Handle all MCP requests app.all('/mcp', async (req: Request, res: Response) => { @@ -121,7 +122,7 @@ app.all('/mcp', async (req: Request, res: Response) => { let transport = sessionId ? transports.get(sessionId) : undefined; if (!transport) { - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, retryInterval: 2000, // Default retry interval for priming events diff --git a/examples/server/src/standaloneSseWithGetStreamableHttp.ts b/examples/server/src/standaloneSseWithGetStreamableHttp.ts index cceb24299..869d7e859 100644 --- a/examples/server/src/standaloneSseWithGetStreamableHttp.ts +++ b/examples/server/src/standaloneSseWithGetStreamableHttp.ts @@ -1,7 +1,8 @@ import { randomUUID } from 'node:crypto'; import type { ReadResourceResult } from '@modelcontextprotocol/server'; -import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; import type { Request, Response } from 'express'; // Create an MCP server with implementation details @@ -11,7 +12,7 @@ const server = new McpServer({ }); // Store transports by session ID to send notifications -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; const addResource = (name: string, content: string) => { const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; @@ -41,14 +42,14 @@ app.post('/mcp', async (req: Request, res: Response) => { try { // Check for existing session ID const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: sessionId => { // Store the transport by session ID when session is initialized diff --git a/examples/server/tsconfig.json b/examples/server/tsconfig.json index 98d3a5b3f..1f72b0199 100644 --- a/examples/server/tsconfig.json +++ b/examples/server/tsconfig.json @@ -6,6 +6,8 @@ "paths": { "*": ["./*"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server-express": ["./node_modules/@modelcontextprotocol/server-express/src/index.ts"], + "@modelcontextprotocol/server-hono": ["./node_modules/@modelcontextprotocol/server-hono/src/index.ts"], "@modelcontextprotocol/core": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" ], diff --git a/examples/shared/package.json b/examples/shared/package.json index 8287ca552..aad10e3f6 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -34,17 +34,22 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { + "@modelcontextprotocol/core": "workspace:^", "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/server-express": "workspace:^", + "better-auth": "^1.4.7", + "better-sqlite3": "^11.10.0", "express": "catalog:runtimeServerOnly" }, "devDependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^", + "@eslint/js": "catalog:devTools", "@modelcontextprotocol/eslint-config": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@types/better-sqlite3": "^7.6.13", "@types/express": "catalog:devTools", "@typescript/native-preview": "catalog:devTools", - "@eslint/js": "catalog:devTools", "eslint": "catalog:devTools", "eslint-config-prettier": "catalog:devTools", "eslint-plugin-n": "catalog:devTools", diff --git a/examples/shared/src/auth.ts b/examples/shared/src/auth.ts new file mode 100644 index 000000000..8bf9c8c11 --- /dev/null +++ b/examples/shared/src/auth.ts @@ -0,0 +1,82 @@ +/** + * Better Auth configuration for MCP demo servers + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * This configuration uses in-memory SQLite and auto-approves all logins. + * For production use, configure a proper database and authentication flow. + */ + +import type { BetterAuthPlugin } from 'better-auth'; +import { betterAuth } from 'better-auth'; +import { mcp } from 'better-auth/plugins'; +import Database from 'better-sqlite3'; + +// Create the in-memory database once (module-level singleton) +// This avoids the type export issue and ensures the same DB is used +let _db: InstanceType | null = null; + +function getDatabase(): InstanceType { + if (!_db) { + _db = new Database(':memory:'); + } + return _db; +} + +export interface CreateDemoAuthOptions { + baseURL: string; + resource?: string; + loginPage?: string; +} + +/** + * Creates a better-auth instance configured for MCP OAuth demo. + * + * @param options - Configuration options + * @param options.baseURL - The base URL for the auth server (e.g., http://localhost:3001) + * @param options.resource - The MCP resource server URL (for protected resource metadata) + * @param options.loginPage - Path to login page (defaults to /sign-in) + * + * @see https://www.better-auth.com/docs/plugins/mcp + */ +export function createDemoAuth(options: CreateDemoAuthOptions) { + const { baseURL, resource, loginPage = '/sign-in' } = options; + + // Use in-memory SQLite database for demo purposes + // Note: All data is lost on restart - demo only! + const db = getDatabase(); + + // MCP plugin configuration + const mcpPlugin = mcp({ + loginPage, + resource, + oidcConfig: { + loginPage, + codeExpiresIn: 600, // 10 minutes + accessTokenExpiresIn: 3600, // 1 hour + refreshTokenExpiresIn: 604800, // 7 days + defaultScope: 'openid', + scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'] + } + }); + + return betterAuth({ + baseURL, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + database: db as any, // Type cast to avoid exposing better-sqlite3 in exported types + trustedOrigins: ['*'], + // Basic email+password for demo + emailAndPassword: { + enabled: true, + requireEmailVerification: false + }, + plugins: [mcpPlugin as BetterAuthPlugin] + }); +} + +/** + * Type for the auth instance returned by createDemoAuth. + * Note: Due to plugin type inference complexity, we use a generic type. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DemoAuth = ReturnType; diff --git a/examples/shared/src/authMiddleware.ts b/examples/shared/src/authMiddleware.ts new file mode 100644 index 000000000..e46d9e410 --- /dev/null +++ b/examples/shared/src/authMiddleware.ts @@ -0,0 +1,125 @@ +/** + * Auth Middleware for MCP Demo Servers + * + * 🚨 DEMO ONLY - NOT FOR PRODUCTION + * + * This provides bearer auth middleware and metadata routes for MCP servers. + */ + +import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/server'; +import type { NextFunction, Request, Response, Router } from 'express'; +import express 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 { + const { requiredScopes = [], resourceMetadataUrl, strictResource = false, expectedResource } = options; + + return async (req: Request, res: Response, next: NextFunction): Promise => { + 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.status(401).json({ + error: 'unauthorized', + error_description: 'Missing or invalid 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.status(403).json({ + error: 'insufficient_scope', + error_description: `Required scopes: ${requiredScopes.join(', ')}` + }); + return; + } + } + + 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); + res.status(401).json({ + error: 'invalid_token', + error_description: error instanceof Error ? error.message : 'Invalid token' + }); + } + }; +} + +export interface McpAuthMetadataRouterOptions { + oauthMetadata: OAuthMetadata; + resourceServerUrl: URL; + scopesSupported?: string[]; + resourceName?: string; +} + +/** + * Creates an Express router that serves OAuth and Protected Resource metadata. + */ +export function mcpAuthMetadataRouter(options: McpAuthMetadataRouterOptions): Router { + const { oauthMetadata, resourceServerUrl, scopesSupported = ['mcp:tools'], resourceName } = options; + + const router = express.Router(); + + // OAuth Protected Resource Metadata (RFC 9728) + const protectedResourceMetadata: OAuthProtectedResourceMetadata = { + resource: resourceServerUrl.toString(), + authorization_servers: [oauthMetadata.issuer], + scopes_supported: scopesSupported, + resource_name: resourceName + }; + + // Serve protected resource metadata + router.get('/.well-known/oauth-protected-resource', (req: Request, res: Response) => { + res.json(protectedResourceMetadata); + }); + + // Also serve at the MCP-specific path + const mcpPath = new URL(resourceServerUrl.pathname, resourceServerUrl).pathname; + router.get(`${mcpPath}/.well-known/oauth-protected-resource`, (req: Request, res: Response) => { + res.json(protectedResourceMetadata); + }); + + return router; +} + +/** + * Helper to get the protected resource metadata URL from a server URL. + */ +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): URL { + const metadataUrl = new URL(serverUrl); + metadataUrl.pathname = `${serverUrl.pathname}/.well-known/oauth-protected-resource`.replace(/\/+/g, '/'); + return metadataUrl; +} diff --git a/examples/shared/src/authServer.ts b/examples/shared/src/authServer.ts new file mode 100644 index 000000000..9a14e8978 --- /dev/null +++ b/examples/shared/src/authServer.ts @@ -0,0 +1,243 @@ +/** + * Better Auth Server Setup for MCP Demo + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * This creates a standalone OAuth Authorization Server using better-auth + * that MCP clients can use to obtain access tokens. + * + * See: https://www.better-auth.com/docs/plugins/mcp + */ + +import type { OAuthMetadata } from '@modelcontextprotocol/core'; +import { toNodeHandler } from 'better-auth/node'; +import type { Request, Response as ExpressResponse } from 'express'; +import express from 'express'; + +import type { DemoAuth } from './auth.js'; +import { createDemoAuth } from './auth.js'; + +export interface SetupAuthServerOptions { + authServerUrl: URL; + mcpServerUrl: URL; + strictResource?: boolean; +} + +export interface AuthServerResult { + auth: DemoAuth; + oauthMetadata: OAuthMetadata; +} + +// Store auth instance globally so it can be used for token verification +let globalAuth: DemoAuth | null = null; + +/** + * Gets the global auth instance (must call setupAuthServer first) + */ +export function getAuth(): DemoAuth { + if (!globalAuth) { + throw new Error('Auth not initialized. Call setupAuthServer first.'); + } + return globalAuth; +} + +/** + * Sets up and starts the OAuth Authorization Server on a separate port. + * + * @param options - Server configuration + * @returns OAuth metadata for the authorization server + */ +export function setupAuthServer(options: SetupAuthServerOptions): OAuthMetadata { + const { authServerUrl, mcpServerUrl } = options; + + // Create better-auth instance with MCP plugin + const auth = createDemoAuth({ + baseURL: authServerUrl.toString().replace(/\/$/, ''), + resource: mcpServerUrl.toString(), + loginPage: '/sign-in' + }); + + // Store globally for token verification + globalAuth = auth; + + // Create Express app for auth server + const authApp = express(); + authApp.use(express.json()); + authApp.use(express.urlencoded({ extended: true })); + + // Enable CORS for all origins (demo only) + 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(); + }); + + // Auto-login page that immediately creates a session and redirects + // This simulates a user logging in and approving the OAuth request + authApp.get('/sign-in', async (req: Request, res: ExpressResponse) => { + // Get the OAuth authorization parameters from the query string + const queryParams = new URLSearchParams(req.query as Record); + const redirectUri = queryParams.get('redirect_uri'); + const clientId = queryParams.get('client_id'); + + if (!redirectUri || !clientId) { + res.status(400).send(` + + + Demo Login + +

Demo OAuth Server

+

Missing required OAuth parameters. This page should be accessed via OAuth flow.

+ + + `); + return; + } + + // For demo: auto-approve by redirecting to the authorization endpoint + // with a flag indicating auto-approval + // In better-auth, we need to create a session first, then complete authorization + + // Set a demo session cookie + const authCookieData = { + userId: 'demo_user', + name: 'Demo User', + timestamp: Date.now() + }; + const cookieValue = encodeURIComponent(JSON.stringify(authCookieData)); + res.cookie('demo_session', cookieValue, { + httpOnly: true, + sameSite: 'lax', + maxAge: 24 * 60 * 60 * 1000 // 24 hours + }); + + // Redirect to the actual authorization handler with auto-approve + // Better-auth handles the OAuth flow at /api/auth/authorize + const authorizeUrl = new URL('/api/auth/authorize', authServerUrl); + authorizeUrl.search = queryParams.toString(); + // Add a flag to indicate auto-approval (this would be handled by a custom flow) + authorizeUrl.searchParams.set('auto_approve', 'true'); + + console.log(`[Auth Server] Auto-approved login for client ${clientId}`); + res.redirect(authorizeUrl.toString()); + }); + + // Mount better-auth handler for all /api/auth/* routes + // This handles: authorization, token, client registration, etc. + authApp.all('/api/auth/*', toNodeHandler(auth)); + + // OAuth metadata endpoints at well-known paths + // Some clients may not parse WWW-Authenticate header and need these + authApp.get('/.well-known/oauth-authorization-server', (_req, res) => { + res.json(createOAuthMetadata(authServerUrl)); + }); + + authApp.get('/.well-known/oauth-protected-resource', (_req, res) => { + res.json({ + resource: mcpServerUrl.toString(), + authorization_servers: [authServerUrl.toString().replace(/\/$/, '')], + scopes_supported: ['openid', 'profile', 'email', 'mcp:tools'] + }); + }); + + // Start the auth server + const authPort = parseInt(authServerUrl.port, 10); + authApp.listen(authPort, (error?: Error) => { + if (error) { + console.error('Failed to start auth server:', error); + process.exit(1); + } + console.log(`OAuth Authorization Server listening on port ${authPort}`); + console.log(` Authorization: ${authServerUrl}api/auth/authorize`); + console.log(` Token: ${authServerUrl}api/auth/token`); + console.log(` Metadata: ${authServerUrl}.well-known/oauth-authorization-server`); + }); + + return createOAuthMetadata(authServerUrl); +} + +/** + * Creates OAuth 2.0 Authorization Server Metadata (RFC 8414) + */ +function createOAuthMetadata(issuerUrl: URL): OAuthMetadata { + const issuer = issuerUrl.toString().replace(/\/$/, ''); + const apiAuthBase = `${issuer}/api/auth`; + + return { + issuer, + authorization_endpoint: `${apiAuthBase}/authorize`, + token_endpoint: `${apiAuthBase}/token`, + registration_endpoint: `${apiAuthBase}/register`, + introspection_endpoint: `${apiAuthBase}/introspect`, + scopes_supported: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'], + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'], + code_challenge_methods_supported: ['S256'] + }; +} + +/** + * Verifies an access token using better-auth's getMcpSession. + * This can be used by MCP servers to validate tokens. + */ +export async function verifyAccessToken( + token: string, + options?: { strictResource?: boolean; expectedResource?: URL } +): Promise<{ + token: string; + clientId: string; + scopes: string[]; + expiresAt: number; +}> { + 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 + }); + + if (!session) { + throw new Error('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'}`); + } +} diff --git a/examples/shared/src/demoInMemoryOAuthProvider.ts b/examples/shared/src/demoInMemoryOAuthProvider.ts deleted file mode 100644 index bcf11dd0c..000000000 --- a/examples/shared/src/demoInMemoryOAuthProvider.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import type { - AuthInfo, - AuthorizationParams, - OAuthClientInformationFull, - OAuthMetadata, - OAuthRegisteredClientsStore, - OAuthServerProvider, - OAuthTokens -} from '@modelcontextprotocol/server'; -import { createOAuthMetadata, InvalidRequestError, mcpAuthRouter, resourceUrlFromServerUrl } from '@modelcontextprotocol/server'; -import type { Request, Response } from 'express'; -import express from 'express'; - -export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { - private clients = new Map(); - - async getClient(clientId: string) { - return this.clients.get(clientId); - } - - async registerClient(clientMetadata: OAuthClientInformationFull) { - this.clients.set(clientMetadata.client_id, clientMetadata); - return clientMetadata; - } -} - -/** - * 🚨 DEMO ONLY - NOT FOR PRODUCTION - * - * This example demonstrates MCP OAuth flow but lacks some of the features required for production use, - * for example: - * - Persistent token storage - * - Rate limiting - */ -export class DemoInMemoryAuthProvider implements OAuthServerProvider { - clientsStore = new DemoInMemoryClientsStore(); - private codes = new Map< - string, - { - params: AuthorizationParams; - client: OAuthClientInformationFull; - } - >(); - private tokens = new Map(); - - constructor(private validateResource?: (resource?: URL) => boolean) {} - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - const code = randomUUID(); - - const searchParams = new URLSearchParams({ - code - }); - if (params.state !== undefined) { - searchParams.set('state', params.state); - } - - this.codes.set(code, { - client, - params - }); - - // Simulate a user login - // Set a secure HTTP-only session cookie with authorization info - if (res.cookie) { - const authCookieData = { - userId: 'demo_user', - name: 'Demo User', - timestamp: Date.now() - }; - res.cookie('demo_session', JSON.stringify(authCookieData), { - httpOnly: true, - secure: false, // In production, this should be true - sameSite: 'lax', - maxAge: 24 * 60 * 60 * 1000, // 24 hours - for demo purposes - path: '/' // Available to all routes - }); - } - - if (!client.redirect_uris.includes(params.redirectUri)) { - throw new InvalidRequestError('Unregistered redirect_uri'); - } - const targetUrl = new URL(params.redirectUri); - targetUrl.search = searchParams.toString(); - res.redirect(targetUrl.toString()); - } - - async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { - // Store the challenge with the code data - const codeData = this.codes.get(authorizationCode); - if (!codeData) { - throw new Error('Invalid authorization code'); - } - - return codeData.params.codeChallenge; - } - - async exchangeAuthorizationCode( - client: OAuthClientInformationFull, - authorizationCode: string, - // Note: code verifier is checked in token.ts by default - // it's unused here for that reason. - _codeVerifier?: string - ): Promise { - const codeData = this.codes.get(authorizationCode); - if (!codeData) { - throw new Error('Invalid authorization code'); - } - - if (codeData.client.client_id !== client.client_id) { - throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); - } - - if (this.validateResource && !this.validateResource(codeData.params.resource)) { - throw new Error(`Invalid resource: ${codeData.params.resource}`); - } - - this.codes.delete(authorizationCode); - const token = randomUUID(); - - const tokenData = { - token, - clientId: client.client_id, - scopes: codeData.params.scopes || [], - expiresAt: Date.now() + 3600000, // 1 hour - resource: codeData.params.resource, - type: 'access' - }; - - this.tokens.set(token, tokenData); - - return { - access_token: token, - token_type: 'bearer', - expires_in: 3600, - scope: (codeData.params.scopes || []).join(' ') - }; - } - - async exchangeRefreshToken( - _client: OAuthClientInformationFull, - _refreshToken: string, - _scopes?: string[], - _resource?: URL - ): Promise { - throw new Error('Not implemented for example demo'); - } - - async verifyAccessToken(token: string): Promise { - const tokenData = this.tokens.get(token); - if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { - throw new Error('Invalid or expired token'); - } - - return { - token, - clientId: tokenData.clientId, - scopes: tokenData.scopes, - expiresAt: Math.floor(tokenData.expiresAt / 1000), - resource: tokenData.resource - }; - } -} - -export const setupAuthServer = ({ - authServerUrl, - mcpServerUrl, - strictResource -}: { - authServerUrl: URL; - mcpServerUrl: URL; - strictResource: boolean; -}): OAuthMetadata => { - // Create separate auth server app - // NOTE: This is a separate app on a separate port to illustrate - // how to separate an OAuth Authorization Server from a Resource - // server in the SDK. The SDK is not intended to be provide a standalone - // authorization server. - - const validateResource = strictResource - ? (resource?: URL) => { - if (!resource) return false; - const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); - return resource.toString() === expectedResource.toString(); - } - : undefined; - - const provider = new DemoInMemoryAuthProvider(validateResource); - const authApp = express(); - authApp.use(express.json()); - // For introspection requests - authApp.use(express.urlencoded()); - - // Add OAuth routes to the auth server - // NOTE: this will also add a protected resource metadata route, - // but it won't be used, so leave it. - authApp.use( - mcpAuthRouter({ - provider, - issuerUrl: authServerUrl, - scopesSupported: ['mcp:tools'] - }) - ); - - authApp.post('/introspect', async (req: Request, res: Response) => { - try { - const { token } = req.body; - if (!token) { - res.status(400).json({ error: 'Token is required' }); - return; - } - - const tokenInfo = await provider.verifyAccessToken(token); - res.json({ - active: true, - client_id: tokenInfo.clientId, - scope: tokenInfo.scopes.join(' '), - exp: tokenInfo.expiresAt, - aud: tokenInfo.resource - }); - return; - } catch (error) { - res.status(401).json({ - active: false, - error: 'Unauthorized', - error_description: `Invalid token: ${error}` - }); - } - }); - - const auth_port = authServerUrl.port; - // Start the auth server - authApp.listen(auth_port, error => { - if (error) { - console.error('Failed to start server:', error); - process.exit(1); - } - console.log(`OAuth Authorization Server listening on port ${auth_port}`); - }); - - // Note: we could fetch this from the server, but then we end up - // with some top level async which gets annoying. - const oauthMetadata: OAuthMetadata = createOAuthMetadata({ - provider, - issuerUrl: authServerUrl, - scopesSupported: ['mcp:tools'] - }); - - oauthMetadata.introspection_endpoint = new URL('/introspect', authServerUrl).href; - - return oauthMetadata; -}; diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts index 1c31cf06e..21d189bbc 100644 --- a/examples/shared/src/index.ts +++ b/examples/shared/src/index.ts @@ -1 +1,11 @@ -export * from './demoInMemoryOAuthProvider.js'; +// Auth configuration +export type { CreateDemoAuthOptions, DemoAuth } from './auth.js'; +export { createDemoAuth } from './auth.js'; + +// Auth middleware +export type { McpAuthMetadataRouterOptions, RequireBearerAuthOptions } from './authMiddleware.js'; +export { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth } from './authMiddleware.js'; + +// Auth server setup +export type { AuthServerResult, SetupAuthServerOptions } from './authServer.js'; +export { getAuth, setupAuthServer, verifyAccessToken } from './authServer.js'; diff --git a/examples/shared/test/demoInMemoryOAuthProvider.test.ts b/examples/shared/test/demoInMemoryOAuthProvider.test.ts index 4018dddbe..1c798047f 100644 --- a/examples/shared/test/demoInMemoryOAuthProvider.test.ts +++ b/examples/shared/test/demoInMemoryOAuthProvider.test.ts @@ -1,264 +1,35 @@ -import type { OAuthClientInformationFull } from '@modelcontextprotocol/core'; -import type { AuthorizationParams } from '@modelcontextprotocol/server'; -import { InvalidRequestError } from '@modelcontextprotocol/server'; -import { createExpressResponseMock } from '@modelcontextprotocol/test-helpers'; -import type { Response } from 'express'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../src/demoInMemoryOAuthProvider.js'; - -describe('DemoInMemoryAuthProvider', () => { - let provider: DemoInMemoryAuthProvider; - let mockResponse: Response & { getRedirectUrl: () => string }; - - beforeEach(() => { - provider = new DemoInMemoryAuthProvider(); - mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & { - getRedirectUrl: () => string; - }; - }); - - describe('authorize', () => { - const validClient: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback', 'https://example.com/callback2'], - scope: 'test-scope' - }; - - it('should redirect to the requested redirect_uri when valid', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - - expect(mockResponse.redirect).toHaveBeenCalled(); - expect(mockResponse.getRedirectUrl()).toBeDefined(); - - const url = new URL(mockResponse.getRedirectUrl()); - expect(url.origin + url.pathname).toBe('https://example.com/callback'); - expect(url.searchParams.get('state')).toBe('test-state'); - expect(url.searchParams.has('code')).toBe(true); - }); - - it('should throw InvalidRequestError for unregistered redirect_uri', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://evil.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow(InvalidRequestError); - - await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow('Unregistered redirect_uri'); - - expect(mockResponse.redirect).not.toHaveBeenCalled(); - }); - - it('should generate unique authorization codes for multiple requests', async () => { - const params1: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'state-1', - codeChallenge: 'challenge-1', - scopes: ['test-scope'] - }; - - const params2: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'state-2', - codeChallenge: 'challenge-2', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params1, mockResponse); - const firstRedirectUrl = mockResponse.getRedirectUrl(); - const firstCode = new URL(firstRedirectUrl).searchParams.get('code'); - - // Reset the mock for the second call - mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & { - getRedirectUrl: () => string; - }; - await provider.authorize(validClient, params2, mockResponse); - const secondRedirectUrl = mockResponse.getRedirectUrl(); - const secondCode = new URL(secondRedirectUrl).searchParams.get('code'); - - expect(firstCode).toBeDefined(); - expect(secondCode).toBeDefined(); - expect(firstCode).not.toBe(secondCode); - }); - - it('should handle params without state', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - - expect(mockResponse.redirect).toHaveBeenCalled(); - expect(mockResponse.getRedirectUrl()).toBeDefined(); - - const url = new URL(mockResponse.getRedirectUrl()); - expect(url.searchParams.has('state')).toBe(false); - expect(url.searchParams.has('code')).toBe(true); - }); - }); - - describe('challengeForAuthorizationCode', () => { - const validClient: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'test-scope' - }; - - it('should return the code challenge for a valid authorization code', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge-value', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - const challenge = await provider.challengeForAuthorizationCode(validClient, code); - expect(challenge).toBe('test-challenge-value'); - }); - - it('should throw error for invalid authorization code', async () => { - await expect(provider.challengeForAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code'); - }); +/** + * Tests for the demo OAuth provider using better-auth + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * The demo OAuth provider now uses better-auth with the MCP plugin. + * These tests verify the basic setup works correctly. + */ + +import { describe, expect, it } from 'vitest'; + +import type { CreateDemoAuthOptions } from '../src/auth.js'; +import { createDemoAuth } from '../src/auth.js'; + +describe('createDemoAuth', () => { + const validOptions: CreateDemoAuthOptions = { + baseURL: 'http://localhost:3001', + resource: 'http://localhost:3000/mcp', + loginPage: '/sign-in' + }; + + it('creates a better-auth instance with MCP plugin', () => { + const auth = createDemoAuth(validOptions); + expect(auth).toBeDefined(); + expect(auth.api).toBeDefined(); }); - describe('exchangeAuthorizationCode', () => { - const validClient: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'test-scope' + it('uses default loginPage when not specified', () => { + const options: CreateDemoAuthOptions = { + baseURL: 'http://localhost:3001' }; - - it('should exchange valid authorization code for tokens', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope', 'other-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - const tokens = await provider.exchangeAuthorizationCode(validClient, code); - - expect(tokens).toEqual({ - access_token: expect.any(String), - token_type: 'bearer', - expires_in: 3600, - scope: 'test-scope other-scope' - }); - }); - - it('should throw error for invalid authorization code', async () => { - await expect(provider.exchangeAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code'); - }); - - it('should throw error when client_id does not match', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - const differentClient: OAuthClientInformationFull = { - client_id: 'different-client', - client_secret: 'different-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'test-scope' - }; - - await expect(provider.exchangeAuthorizationCode(differentClient, code)).rejects.toThrow( - 'Authorization code was not issued to this client' - ); - }); - - it('should delete authorization code after successful exchange', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - // First exchange should succeed - await provider.exchangeAuthorizationCode(validClient, code); - - // Second exchange should fail - await expect(provider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow('Invalid authorization code'); - }); - - it('should validate resource when validateResource is provided', async () => { - const validateResource = vi.fn().mockReturnValue(false); - const strictProvider = new DemoInMemoryAuthProvider(validateResource); - - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'], - resource: new URL('https://invalid-resource.com') - }; - - await strictProvider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - await expect(strictProvider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow( - 'Invalid resource: https://invalid-resource.com/' - ); - - expect(validateResource).toHaveBeenCalledWith(params.resource); - }); - }); - - describe('DemoInMemoryClientsStore', () => { - let store: DemoInMemoryClientsStore; - - beforeEach(() => { - store = new DemoInMemoryClientsStore(); - }); - - it('should register and retrieve client', async () => { - const client: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'test-scope' - }; - - await store.registerClient(client); - const retrieved = await store.getClient('test-client'); - - expect(retrieved).toEqual(client); - }); - - it('should return undefined for non-existent client', async () => { - const retrieved = await store.getClient('non-existent'); - expect(retrieved).toBeUndefined(); - }); + const auth = createDemoAuth(options); + expect(auth).toBeDefined(); }); }); diff --git a/examples/shared/tsconfig.json b/examples/shared/tsconfig.json index aa994f939..91e368e7a 100644 --- a/examples/shared/tsconfig.json +++ b/examples/shared/tsconfig.json @@ -6,6 +6,7 @@ "paths": { "*": ["./*"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server-express": ["./node_modules/@modelcontextprotocol/server-express/src/index.ts"], "@modelcontextprotocol/core": [ "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" ], diff --git a/package.json b/package.json index 2633d5ef2..0b25f9f4e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "node": ">=20", "pnpm": ">=10.24.0" }, - "packageManager": "pnpm@10.24.0", + "packageManager": "pnpm@10.26.1", "keywords": [ "modelcontextprotocol", "mcp" diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index bff74986e..4cfa77e17 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,6 +1,7 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders } from '@modelcontextprotocol/core'; -import { type ErrorEvent, EventSource, type EventSourceInit } from 'eventsource'; +import type { ErrorEvent, EventSourceInit } from 'eventsource'; +import { EventSource } from 'eventsource'; import type { AuthResult, OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 9c65015d1..c9242e96d 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -292,14 +292,14 @@ export type RequestHandlerExtra void; /** * Closes the standalone GET SSE stream, triggering client reconnection. - * Only available when using StreamableHTTPServerTransport with eventStore configured. + * Only available when using aStreamableHTTPServerTransport with eventStore configured. * Use this to implement polling behavior for server-initiated notifications. */ closeStandaloneSSEStream?: () => void; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 35b04745d..f3e1b92a8 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -2415,13 +2415,13 @@ export interface MessageExtraInfo { /** * Callback to close the SSE stream for this request, triggering client reconnection. - * Only available when using StreamableHTTPServerTransport with eventStore configured. + * Only available when using NodeStreamableHTTPServerTransport with eventStore configured. */ closeSSEStream?: () => void; /** * Callback to close the standalone GET SSE stream, triggering client reconnection. - * Only available when using StreamableHTTPServerTransport with eventStore configured. + * Only available when using NodeStreamableHTTPServerTransport with eventStore configured. */ closeStandaloneSSEStream?: () => void; } diff --git a/packages/server-express/README.md b/packages/server-express/README.md new file mode 100644 index 000000000..27fb348d7 --- /dev/null +++ b/packages/server-express/README.md @@ -0,0 +1,83 @@ +# `@modelcontextprotocol/server-express` + +Express adapters for the MCP TypeScript server SDK. + +This package is the Express-specific companion to [`@modelcontextprotocol/server`](../server/), which is framework-agnostic and uses Web Standard `Request`/`Response` interfaces. + +## Install + +```bash +npm install @modelcontextprotocol/server @modelcontextprotocol/server-express zod +``` + +## Exports + +- `createMcpExpressApp(options?)` +- `hostHeaderValidation(allowedHosts)` +- `localhostHostValidation()` +- `mcpAuthRouter(options)` +- `mcpAuthMetadataRouter(options)` +- `requireBearerAuth(options)` + +## Usage + +### Create an Express app with localhost DNS rebinding protection + +```ts +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; + +const app = createMcpExpressApp(); // default host is 127.0.0.1; protection enabled +``` + +### Streamable HTTP endpoint (Express) + +```ts +import { McpServer, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; + +const app = createMcpExpressApp(); + +app.post('/mcp', async (req, res) => { + const transport = new NodeStreamableHTTPServerTransport(); + await transport.handleRequest(req, res, req.body); +}); +``` + +### OAuth routes (Express) + +`@modelcontextprotocol/server` provides Web-standard auth handlers; this package wraps them as Express routers. + +```ts +import { mcpAuthRouter } from '@modelcontextprotocol/server-express'; +import type { OAuthServerProvider } from '@modelcontextprotocol/server'; +import express from 'express'; + +const provider: OAuthServerProvider = /* ... */; +const app = express(); +app.use(express.json()); + +// MUST be mounted at the app root +app.use( + mcpAuthRouter({ + provider, + issuerUrl: new URL('https://auth.example.com'), + // Optional rate limiting (implemented via express-rate-limit) + rateLimit: { windowMs: 60_000, max: 60 } + }) +); +``` + +### Bearer auth middleware (Express) + +`requireBearerAuth` validates the `Authorization: Bearer ...` header and sets `req.auth` on success. + +```ts +import { requireBearerAuth } from '@modelcontextprotocol/server-express'; +import type { OAuthTokenVerifier } from '@modelcontextprotocol/server'; + +const verifier: OAuthTokenVerifier = /* ... */; + +app.post('/protected', requireBearerAuth({ verifier }), (req, res) => { + res.json({ clientId: req.auth?.clientId }); +}); +``` diff --git a/packages/server-express/eslint.config.mjs b/packages/server-express/eslint.config.mjs new file mode 100644 index 000000000..03d533134 --- /dev/null +++ b/packages/server-express/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/(server|core)' + } + } +]; diff --git a/packages/server-express/package.json b/packages/server-express/package.json new file mode 100644 index 000000000..bca9ac505 --- /dev/null +++ b/packages/server-express/package.json @@ -0,0 +1,70 @@ +{ + "name": "@modelcontextprotocol/server-express", + "private": false, + "version": "2.0.0-alpha.0", + "description": "Express adapters for the Model Context Protocol TypeScript server SDK", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20", + "pnpm": ">=10.24.0" + }, + "packageManager": "pnpm@10.24.0", + "keywords": [ + "modelcontextprotocol", + "mcp", + "express" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "npm run typecheck && npm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:^", + "express": "catalog:runtimeServerOnly", + "express-rate-limit": "catalog:runtimeServerOnly", + "@remix-run/node-fetch-server": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@types/supertest": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "supertest": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/server/src/server/express.ts b/packages/server-express/src/express.ts similarity index 100% rename from packages/server/src/server/express.ts rename to packages/server-express/src/express.ts diff --git a/packages/server-express/src/index.ts b/packages/server-express/src/index.ts new file mode 100644 index 000000000..2d7d20a64 --- /dev/null +++ b/packages/server-express/src/index.ts @@ -0,0 +1,2 @@ +export * from './express.js'; +export * from './middleware/hostHeaderValidation.js'; diff --git a/packages/server-express/src/middleware/hostHeaderValidation.ts b/packages/server-express/src/middleware/hostHeaderValidation.ts new file mode 100644 index 000000000..00ee74e1f --- /dev/null +++ b/packages/server-express/src/middleware/hostHeaderValidation.ts @@ -0,0 +1,52 @@ +import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +/** + * Express middleware for DNS rebinding protection. + * Validates Host header hostname (port-agnostic) against an allowed list. + * + * This is particularly important for servers without authorization or HTTPS, + * such as localhost servers or development servers. DNS rebinding attacks can + * bypass same-origin policy by manipulating DNS to point a domain to a + * localhost address, allowing malicious websites to access your local server. + * + * @param allowedHostnames - List of allowed hostnames (without ports). + * For IPv6, provide the address with brackets (e.g., '[::1]'). + * @returns Express middleware function + * + * @example + * ```typescript + * const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + * app.use(middleware); + * ``` + */ +export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + const result = validateHostHeader(req.headers.host, allowedHostnames); + if (!result.ok) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: result.message + }, + id: null + }); + return; + } + next(); + }; +} + +/** + * Convenience middleware for localhost DNS rebinding protection. + * Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames. + * + * @example + * ```typescript + * app.use(localhostHostValidation()); + * ``` + */ +export function localhostHostValidation(): RequestHandler { + return hostHeaderValidation(localhostAllowedHostnames()); +} diff --git a/packages/server-express/test/server-express.test.ts b/packages/server-express/test/server-express.test.ts new file mode 100644 index 000000000..9bed5903a --- /dev/null +++ b/packages/server-express/test/server-express.test.ts @@ -0,0 +1,182 @@ +import type { NextFunction, Request, Response } from 'express'; +import { vi } from 'vitest'; + +import { createMcpExpressApp } from '../src/express.js'; +import { hostHeaderValidation, localhostHostValidation } from '../src/middleware/hostHeaderValidation.js'; + +// Helper to create mock Express request/response/next +function createMockReqResNext(host?: string) { + const req = { + headers: { + host + } + } as Request; + + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis() + } as unknown as Response; + + const next = vi.fn() as NextFunction; + + return { req, res, next }; +} + +describe('@modelcontextprotocol/server-express', () => { + describe('hostHeaderValidation', () => { + test('should block invalid Host header', () => { + const middleware = hostHeaderValidation(['localhost']); + const { req, res, next } = createMockReqResNext('evil.com:3000'); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32000 + }), + id: null + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + test('should allow valid Host header', () => { + const middleware = hostHeaderValidation(['localhost']); + const { req, res, next } = createMockReqResNext('localhost:3000'); + + middleware(req, res, next); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('should handle multiple allowed hostnames', () => { + const middleware = hostHeaderValidation(['localhost', '127.0.0.1', 'myapp.local']); + const { req: req1, res: res1, next: next1 } = createMockReqResNext('127.0.0.1:8080'); + const { req: req2, res: res2, next: next2 } = createMockReqResNext('myapp.local'); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + expect(next1).toHaveBeenCalled(); + expect(next2).toHaveBeenCalled(); + }); + }); + + describe('localhostHostValidation', () => { + test('should allow localhost', () => { + const middleware = localhostHostValidation(); + const { req, res, next } = createMockReqResNext('localhost:3000'); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow 127.0.0.1', () => { + const middleware = localhostHostValidation(); + const { req, res, next } = createMockReqResNext('127.0.0.1:3000'); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow [::1] (IPv6 localhost)', () => { + const middleware = localhostHostValidation(); + const { req, res, next } = createMockReqResNext('[::1]:3000'); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should block non-localhost hosts', () => { + const middleware = localhostHostValidation(); + const { req, res, next } = createMockReqResNext('evil.com:3000'); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('createMcpExpressApp', () => { + test('should enable localhost DNS rebinding protection by default', () => { + const app = createMcpExpressApp(); + + // The app should be a valid Express application + expect(app).toBeDefined(); + expect(typeof app.use).toBe('function'); + expect(typeof app.get).toBe('function'); + expect(typeof app.post).toBe('function'); + }); + + test('should apply DNS rebinding protection for localhost host', () => { + const app = createMcpExpressApp({ host: 'localhost' }); + expect(app).toBeDefined(); + }); + + test('should apply DNS rebinding protection for ::1 host', () => { + const app = createMcpExpressApp({ host: '::1' }); + expect(app).toBeDefined(); + }); + + test('should use allowedHosts when provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] }); + warn.mockRestore(); + + expect(app).toBeDefined(); + }); + + test('should warn when binding to 0.0.0.0 without allowedHosts', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createMcpExpressApp({ host: '0.0.0.0' }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Warning: Server is binding to 0.0.0.0 without DNS rebinding protection') + ); + + warn.mockRestore(); + }); + + test('should warn when binding to :: without allowedHosts', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createMcpExpressApp({ host: '::' }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Warning: Server is binding to :: without DNS rebinding protection')); + + warn.mockRestore(); + }); + + test('should not warn for 0.0.0.0 when allowedHosts is provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] }); + + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); + + test('should not apply host validation for non-localhost hosts without allowedHosts', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // For arbitrary hosts (not 0.0.0.0 or ::), no validation is applied and no warning + const app = createMcpExpressApp({ host: '192.168.1.1' }); + + expect(warn).not.toHaveBeenCalled(); + expect(app).toBeDefined(); + + warn.mockRestore(); + }); + }); +}); diff --git a/packages/server-express/tsconfig.json b/packages/server-express/tsconfig.json new file mode 100644 index 000000000..0d7fdd0c0 --- /dev/null +++ b/packages/server-express/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ] + } + } +} diff --git a/packages/server-express/tsdown.config.ts b/packages/server-express/tsdown.config.ts new file mode 100644 index 000000000..c72e7a2c4 --- /dev/null +++ b/packages/server-express/tsdown.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { + resolver: 'tsc', + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/server': ['../server/src/index.ts'], + '@modelcontextprotocol/core': ['../core/src/index.ts'] + } + } + }, + noExternal: ['@modelcontextprotocol/server', '@modelcontextprotocol/core'] +}); diff --git a/packages/server-express/vitest.config.js b/packages/server-express/vitest.config.js new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/server-express/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/server-hono/README.md b/packages/server-hono/README.md new file mode 100644 index 000000000..d2788881a --- /dev/null +++ b/packages/server-hono/README.md @@ -0,0 +1,64 @@ +# `@modelcontextprotocol/server-hono` + +Hono adapters for the MCP TypeScript server SDK. + +This package is the Hono-specific companion to [`@modelcontextprotocol/server`](../server/), which is framework-agnostic and uses Web Standard `Request`/`Response` interfaces. + +## Install + +```bash +npm install @modelcontextprotocol/server @modelcontextprotocol/server-hono hono zod +``` + +## Exports + +- `mcpStreamableHttpHandler(transport)` +- `registerMcpAuthRoutes(app, options)` +- `registerMcpAuthMetadataRoutes(app, options)` +- `hostHeaderValidation(allowedHosts)` +- `localhostHostValidation()` + +## Usage + +### Streamable HTTP endpoint (Hono) + +```ts +import { Hono } from 'hono'; +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { mcpStreamableHttpHandler } from '@modelcontextprotocol/server-hono'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +const transport = new WebStandardStreamableHTTPServerTransport(); +await server.connect(transport); + +const app = new Hono(); +app.all('/mcp', mcpStreamableHttpHandler(transport)); +``` + +### OAuth routes (Hono) + +`@modelcontextprotocol/server` provides Web-standard auth handlers; this package mounts them onto a Hono app. + +```ts +import { Hono } from 'hono'; +import type { OAuthServerProvider } from '@modelcontextprotocol/server'; +import { registerMcpAuthRoutes } from '@modelcontextprotocol/server-hono'; + +const provider: OAuthServerProvider = /* ... */; + +const app = new Hono(); +registerMcpAuthRoutes(app, { + provider, + issuerUrl: new URL('https://auth.example.com') +}); +``` + +### Host header validation (DNS rebinding protection) + +```ts +import { Hono } from 'hono'; +import { localhostHostValidation } from '@modelcontextprotocol/server-hono'; + +const app = new Hono(); +app.use('*', localhostHostValidation()); +``` diff --git a/packages/server-hono/eslint.config.mjs b/packages/server-hono/eslint.config.mjs new file mode 100644 index 000000000..03d533134 --- /dev/null +++ b/packages/server-hono/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/(server|core)' + } + } +]; diff --git a/packages/server-hono/package.json b/packages/server-hono/package.json new file mode 100644 index 000000000..ac5b01a89 --- /dev/null +++ b/packages/server-hono/package.json @@ -0,0 +1,64 @@ +{ + "name": "@modelcontextprotocol/server-hono", + "private": false, + "version": "2.0.0-alpha.0", + "description": "Hono adapters for the Model Context Protocol TypeScript server SDK", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20", + "pnpm": ">=10.24.0" + }, + "packageManager": "pnpm@10.24.0", + "keywords": [ + "modelcontextprotocol", + "mcp", + "hono" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "npm run typecheck && npm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:^", + "hono": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/server-hono/src/hono.ts b/packages/server-hono/src/hono.ts new file mode 100644 index 000000000..accf4ab27 --- /dev/null +++ b/packages/server-hono/src/hono.ts @@ -0,0 +1,90 @@ +import type { Context } from 'hono'; +import { Hono } from 'hono'; + +import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; + +/** + * Options for creating an MCP Hono application. + */ +export interface CreateMcpHonoAppOptions { + /** + * The hostname to bind to. Defaults to '127.0.0.1'. + * When set to '127.0.0.1', 'localhost', or '::1', DNS rebinding protection is automatically enabled. + */ + host?: string; + + /** + * List of allowed hostnames for DNS rebinding protection. + * If provided, host header validation will be applied using this list. + * For IPv6, provide addresses with brackets (e.g., '[::1]'). + * + * This is useful when binding to '0.0.0.0' or '::' but still wanting + * to restrict which hostnames are allowed. + */ + allowedHosts?: string[]; +} + +/** + * Creates a Hono application pre-configured for MCP servers. + * + * When the host is '127.0.0.1', 'localhost', or '::1' (the default is '127.0.0.1'), + * DNS rebinding protection middleware is automatically applied to protect against + * DNS rebinding attacks on localhost servers. + * + * This also installs a small JSON body parsing middleware (similar to `express.json()`) + * that stashes the parsed body into `c.set('parsedBody', ...)` when `Content-Type` includes + * `application/json`. + * + * @param options - Configuration options + * @returns A configured Hono application + */ +export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { + const { host = '127.0.0.1', allowedHosts } = options; + + const app = new Hono(); + + // Similar to `express.json()`: parse JSON bodies and make them available to MCP adapters via `parsedBody`. + app.use('*', async (c: Context, next) => { + // If an upstream middleware already set parsedBody, keep it. + if (c.get('parsedBody') !== undefined) { + return await next(); + } + + const ct = c.req.header('content-type') ?? ''; + if (!ct.includes('application/json')) { + return await next(); + } + + try { + // Parse from a clone so we don't consume the original request stream. + const parsed = await c.req.raw.clone().json(); + c.set('parsedBody', parsed); + } catch { + // Mirror express.json() behavior loosely: reject invalid JSON. + return c.text('Invalid JSON', 400); + } + + return await next(); + }); + + // If allowedHosts is explicitly provided, use that for validation. + if (allowedHosts) { + app.use('*', hostHeaderValidation(allowedHosts)); + } else { + // Apply DNS rebinding protection automatically for localhost hosts. + const localhostHosts = ['127.0.0.1', 'localhost', '::1']; + if (localhostHosts.includes(host)) { + app.use('*', localhostHostValidation()); + } else if (host === '0.0.0.0' || host === '::') { + // Warn when binding to all interfaces without DNS rebinding protection. + // eslint-disable-next-line no-console + console.warn( + `Warning: Server is binding to ${host} without DNS rebinding protection. ` + + 'Consider using the allowedHosts option to restrict allowed hosts, ' + + 'or use authentication to protect your server.' + ); + } + } + + return app; +} diff --git a/packages/server-hono/src/index.ts b/packages/server-hono/src/index.ts new file mode 100644 index 000000000..a8c65a2e9 --- /dev/null +++ b/packages/server-hono/src/index.ts @@ -0,0 +1,2 @@ +export * from './hono.js'; +export * from './middleware/hostHeaderValidation.js'; diff --git a/packages/server-hono/src/middleware/hostHeaderValidation.ts b/packages/server-hono/src/middleware/hostHeaderValidation.ts new file mode 100644 index 000000000..8f7b20e88 --- /dev/null +++ b/packages/server-hono/src/middleware/hostHeaderValidation.ts @@ -0,0 +1,33 @@ +import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server'; +import type { MiddlewareHandler } from 'hono'; + +/** + * Hono middleware for DNS rebinding protection. + * Validates Host header hostname (port-agnostic) against an allowed list. + */ +export function hostHeaderValidation(allowedHostnames: string[]): MiddlewareHandler { + return async (c, next) => { + const result = validateHostHeader(c.req.header('host'), allowedHostnames); + if (!result.ok) { + return c.json( + { + jsonrpc: '2.0', + error: { + code: -32000, + message: result.message + }, + id: null + }, + 403 + ); + } + return await next(); + }; +} + +/** + * Convenience middleware for localhost DNS rebinding protection. + */ +export function localhostHostValidation(): MiddlewareHandler { + return hostHeaderValidation(localhostAllowedHostnames()); +} diff --git a/packages/server-hono/test/server-hono.test.ts b/packages/server-hono/test/server-hono.test.ts new file mode 100644 index 000000000..230a566ba --- /dev/null +++ b/packages/server-hono/test/server-hono.test.ts @@ -0,0 +1,109 @@ +import type { Context } from 'hono'; +import { Hono } from 'hono'; +import { vi } from 'vitest'; + +import { createMcpHonoApp } from '../src/hono.js'; +import { hostHeaderValidation } from '../src/middleware/hostHeaderValidation.js'; + +describe('@modelcontextprotocol/server-hono', () => { + test('hostHeaderValidation blocks invalid Host and allows valid Host', async () => { + const app = new Hono(); + app.use('*', hostHeaderValidation(['localhost'])); + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } }); + expect(bad.status).toBe(403); + expect(await bad.json()).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32000 + }), + id: null + }) + ); + + const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } }); + expect(good.status).toBe(200); + expect(await good.text()).toBe('ok'); + }); + + test('createMcpHonoApp enables localhost DNS rebinding protection by default', async () => { + const app = createMcpHonoApp(); + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } }); + expect(bad.status).toBe(403); + + const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } }); + expect(good.status).toBe(200); + }); + + test('createMcpHonoApp uses allowedHosts when provided (even when binding to 0.0.0.0)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] }); + warn.mockRestore(); + + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } }); + expect(bad.status).toBe(403); + + const good = await app.request('http://localhost/health', { headers: { Host: 'myapp.local:3000' } }); + expect(good.status).toBe(200); + }); + + test('createMcpHonoApp does not apply host validation for 0.0.0.0 without allowedHosts', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpHonoApp({ host: '0.0.0.0' }); + warn.mockRestore(); + + app.get('/health', c => c.text('ok')); + + const res = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } }); + expect(res.status).toBe(200); + }); + + test('createMcpHonoApp parses JSON bodies into parsedBody (express.json()-like)', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.json(c.get('parsedBody'))); + + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'application/json' }, + body: JSON.stringify({ a: 1 }) + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ a: 1 }); + }); + + test('createMcpHonoApp returns 400 on invalid JSON', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.text('ok')); + + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'application/json' }, + body: '{"a":' + }); + expect(res.status).toBe(400); + expect(await res.text()).toBe('Invalid JSON'); + }); + + test('createMcpHonoApp does not override parsedBody if upstream middleware set it', async () => { + const app = createMcpHonoApp(); + app.use('/echo', async (c: Context, next) => { + c.set('parsedBody', { preset: true }); + return await next(); + }); + app.post('/echo', (c: Context) => c.json(c.get('parsedBody'))); + + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'application/json' }, + body: JSON.stringify({ a: 1 }) + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ preset: true }); + }); +}); diff --git a/packages/server-hono/tsconfig.json b/packages/server-hono/tsconfig.json new file mode 100644 index 000000000..0d7fdd0c0 --- /dev/null +++ b/packages/server-hono/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ] + } + } +} diff --git a/packages/server-hono/tsdown.config.ts b/packages/server-hono/tsdown.config.ts new file mode 100644 index 000000000..c72e7a2c4 --- /dev/null +++ b/packages/server-hono/tsdown.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { + resolver: 'tsc', + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/server': ['../server/src/index.ts'], + '@modelcontextprotocol/core': ['../core/src/index.ts'] + } + } + }, + noExternal: ['@modelcontextprotocol/server', '@modelcontextprotocol/core'] +}); diff --git a/packages/server-hono/vitest.config.js b/packages/server-hono/vitest.config.js new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/server-hono/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/server/package.json b/packages/server/package.json index b039751f6..20bd77aae 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -44,14 +44,10 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { - "content-type": "catalog:runtimeServerOnly", - "cors": "catalog:runtimeServerOnly", "@hono/node-server": "catalog:runtimeServerOnly", - "hono": "catalog:runtimeServerOnly", - "express": "catalog:runtimeServerOnly", - "express-rate-limit": "catalog:runtimeServerOnly", - "raw-body": "catalog:runtimeServerOnly", + "content-type": "catalog:runtimeServerOnly", "pkce-challenge": "catalog:runtimeShared", + "raw-body": "catalog:runtimeServerOnly", "zod": "catalog:runtimeShared", "zod-to-json-schema": "catalog:runtimeShared" }, @@ -68,13 +64,13 @@ } }, "devDependencies": { + "@cfworker/json-schema": "catalog:runtimeShared", + "@eslint/js": "catalog:devTools", "@modelcontextprotocol/core": "workspace:^", - "@modelcontextprotocol/tsconfig": "workspace:^", - "@modelcontextprotocol/vitest-config": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/test-helpers": "workspace:^", - "@cfworker/json-schema": "catalog:runtimeShared", - "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", "@types/content-type": "catalog:devTools", "@types/cors": "catalog:devTools", "@types/cross-spawn": "catalog:devTools", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4b0c42053..52ec221e2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,15 +1,12 @@ export * from './server/completable.js'; -export * from './server/express.js'; +export * from './server/helper/body.js'; export * from './server/mcp.js'; +export * from './server/middleware/hostHeaderValidation.js'; export * from './server/server.js'; -export * from './server/sse.js'; export * from './server/stdio.js'; export * from './server/streamableHttp.js'; export * from './server/webStandardStreamableHttp.js'; -// auth exports -export * from './server/auth/index.js'; - // experimental exports export * from './experimental/index.js'; diff --git a/packages/server/src/server/auth/clients.ts b/packages/server/src/server/auth/clients.ts deleted file mode 100644 index f6aca1be9..000000000 --- a/packages/server/src/server/auth/clients.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { OAuthClientInformationFull } from '@modelcontextprotocol/core'; - -/** - * Stores information about registered OAuth clients for this server. - */ -export interface OAuthRegisteredClientsStore { - /** - * Returns information about a registered client, based on its ID. - */ - getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; - - /** - * Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server. - * - * NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well. - * - * If unimplemented, dynamic client registration is unsupported. - */ - registerClient?( - client: Omit - ): OAuthClientInformationFull | Promise; -} diff --git a/packages/server/src/server/auth/handlers/authorize.ts b/packages/server/src/server/auth/handlers/authorize.ts deleted file mode 100644 index 65875529e..000000000 --- a/packages/server/src/server/auth/handlers/authorize.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { InvalidClientError, InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '@modelcontextprotocol/core'; -import type { RequestHandler } from 'express'; -import express from 'express'; -import type { Options as RateLimitOptions } from 'express-rate-limit'; -import { rateLimit } from 'express-rate-limit'; -import * as z from 'zod/v4'; - -import { allowedMethods } from '../middleware/allowedMethods.js'; -import type { OAuthServerProvider } from '../provider.js'; - -export type AuthorizationHandlerOptions = { - provider: OAuthServerProvider; - /** - * Rate limiting configuration for the authorization endpoint. - * Set to false to disable rate limiting for this endpoint. - */ - rateLimit?: Partial | false; -}; - -// Parameters that must be validated in order to issue redirects. -const ClientAuthorizationParamsSchema = z.object({ - client_id: z.string(), - redirect_uri: z - .string() - .optional() - .refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' }) -}); - -// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI. -const RequestAuthorizationParamsSchema = z.object({ - response_type: z.literal('code'), - code_challenge: z.string(), - code_challenge_method: z.literal('S256'), - scope: z.string().optional(), - state: z.string().optional(), - resource: z.string().url().optional() -}); - -export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { - // Create a router to apply middleware - const router = express.Router(); - router.use(allowedMethods(['GET', 'POST'])); - router.use(express.urlencoded({ extended: false })); - - // Apply rate limiting unless explicitly disabled - if (rateLimitConfig !== false) { - router.use( - rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // 100 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(), - ...rateLimitConfig - }) - ); - } - - router.all('/', async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - - // In the authorization flow, errors are split into two categories: - // 1. Pre-redirect errors (direct response with 400) - // 2. Post-redirect errors (redirect with error parameters) - - // Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses. - let client_id, redirect_uri, client; - try { - const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); - if (!result.success) { - throw new InvalidRequestError(result.error.message); - } - - client_id = result.data.client_id; - redirect_uri = result.data.redirect_uri; - - client = await provider.clientsStore.getClient(client_id); - if (!client) { - throw new InvalidClientError('Invalid client_id'); - } - - if (redirect_uri !== undefined) { - if (!client.redirect_uris.includes(redirect_uri)) { - throw new InvalidRequestError('Unregistered redirect_uri'); - } - } else if (client.redirect_uris.length === 1) { - redirect_uri = client.redirect_uris[0]; - } else { - throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs'); - } - } catch (error) { - // Pre-redirect errors - return direct response - // - // These don't need to be JSON encoded, as they'll be displayed in a user - // agent, but OTOH they all represent exceptional situations (arguably, - // "programmer error"), so presenting a nice HTML page doesn't help the - // user anyway. - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - - return; - } - - // Phase 2: Validate other parameters. Any errors here should go into redirect responses. - let state; - try { - // Parse and validate authorization parameters - const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const { scope, code_challenge, resource } = parseResult.data; - state = parseResult.data.state; - - // Validate scopes - let requestedScopes: string[] = []; - if (scope !== undefined) { - requestedScopes = scope.split(' '); - } - - // All validation passed, proceed with authorization - await provider.authorize( - client, - { - state, - scopes: requestedScopes, - redirectUri: redirect_uri!, // TODO: Someone to look at. Strict tsconfig showed this could be undefined, while the return type is string. - codeChallenge: code_challenge, - resource: resource ? new URL(resource) : undefined - }, - res - ); - } catch (error) { - // Post-redirect errors - redirect with error parameters - if (error instanceof OAuthError) { - res.redirect(302, createErrorRedirect(redirect_uri!, error, state)); - } else { - const serverError = new ServerError('Internal Server Error'); - res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state)); - } - } - }); - - return router; -} - -/** - * Helper function to create redirect URL with error parameters - */ -function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string { - const errorUrl = new URL(redirectUri); - errorUrl.searchParams.set('error', error.errorCode); - errorUrl.searchParams.set('error_description', error.message); - if (error.errorUri) { - errorUrl.searchParams.set('error_uri', error.errorUri); - } - if (state) { - errorUrl.searchParams.set('state', state); - } - return errorUrl.href; -} diff --git a/packages/server/src/server/auth/handlers/metadata.ts b/packages/server/src/server/auth/handlers/metadata.ts deleted file mode 100644 index 529a6e57a..000000000 --- a/packages/server/src/server/auth/handlers/metadata.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core'; -import cors from 'cors'; -import type { RequestHandler } from 'express'; -import express from 'express'; - -import { allowedMethods } from '../middleware/allowedMethods.js'; - -export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { - // Nested router so we can configure middleware and restrict HTTP method - const router = express.Router(); - - // Configure CORS to allow any origin, to make accessible to web-based MCP clients - router.use(cors()); - - router.use(allowedMethods(['GET', 'OPTIONS'])); - router.get('/', (req, res) => { - res.status(200).json(metadata); - }); - - return router; -} diff --git a/packages/server/src/server/auth/handlers/register.ts b/packages/server/src/server/auth/handlers/register.ts deleted file mode 100644 index a78154d48..000000000 --- a/packages/server/src/server/auth/handlers/register.ts +++ /dev/null @@ -1,129 +0,0 @@ -import crypto from 'node:crypto'; - -import type { OAuthClientInformationFull } from '@modelcontextprotocol/core'; -import { - InvalidClientMetadataError, - OAuthClientMetadataSchema, - OAuthError, - ServerError, - TooManyRequestsError -} from '@modelcontextprotocol/core'; -import cors from 'cors'; -import type { RequestHandler } from 'express'; -import express from 'express'; -import type { Options as RateLimitOptions } from 'express-rate-limit'; -import { rateLimit } from 'express-rate-limit'; - -import type { OAuthRegisteredClientsStore } from '../clients.js'; -import { allowedMethods } from '../middleware/allowedMethods.js'; - -export type ClientRegistrationHandlerOptions = { - /** - * A store used to save information about dynamically registered OAuth clients. - */ - clientsStore: OAuthRegisteredClientsStore; - - /** - * The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended). - * - * If not set, defaults to 30 days. - */ - clientSecretExpirySeconds?: number; - - /** - * Rate limiting configuration for the client registration endpoint. - * Set to false to disable rate limiting for this endpoint. - * Registration endpoints are particularly sensitive to abuse and should be rate limited. - */ - rateLimit?: Partial | false; - - /** - * Whether to generate a client ID before calling the client registration endpoint. - * - * If not set, defaults to true. - */ - clientIdGeneration?: boolean; -}; - -const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days - -export function clientRegistrationHandler({ - clientsStore, - clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS, - rateLimit: rateLimitConfig, - clientIdGeneration = true -}: ClientRegistrationHandlerOptions): RequestHandler { - if (!clientsStore.registerClient) { - throw new Error('Client registration store does not support registering clients'); - } - - // Nested router so we can configure middleware and restrict HTTP method - const router = express.Router(); - - // Configure CORS to allow any origin, to make accessible to web-based MCP clients - router.use(cors()); - - router.use(allowedMethods(['POST'])); - router.use(express.json()); - - // Apply rate limiting unless explicitly disabled - stricter limits for registration - if (rateLimitConfig !== false) { - router.use( - rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour - max: 20, // 20 requests per hour - stricter as registration is sensitive - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(), - ...rateLimitConfig - }) - ); - } - - router.post('/', async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - - try { - const parseResult = OAuthClientMetadataSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidClientMetadataError(parseResult.error.message); - } - - const clientMetadata = parseResult.data; - const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none'; - - // Generate client credentials - const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex'); - const clientIdIssuedAt = Math.floor(Date.now() / 1000); - - // Calculate client secret expiry time - const clientsDoExpire = clientSecretExpirySeconds > 0; - const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0; - const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime; - - let clientInfo: Omit & { client_id?: string } = { - ...clientMetadata, - client_secret: clientSecret, - client_secret_expires_at: clientSecretExpiresAt - }; - - if (clientIdGeneration) { - clientInfo.client_id = crypto.randomUUID(); - clientInfo.client_id_issued_at = clientIdIssuedAt; - } - - clientInfo = await clientsStore.registerClient!(clientInfo); - res.status(201).json(clientInfo); - } catch (error) { - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }); - - return router; -} diff --git a/packages/server/src/server/auth/handlers/revoke.ts b/packages/server/src/server/auth/handlers/revoke.ts deleted file mode 100644 index c7c9f8a6a..000000000 --- a/packages/server/src/server/auth/handlers/revoke.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - InvalidRequestError, - OAuthError, - OAuthTokenRevocationRequestSchema, - ServerError, - TooManyRequestsError -} from '@modelcontextprotocol/core'; -import cors from 'cors'; -import type { RequestHandler } from 'express'; -import express from 'express'; -import type { Options as RateLimitOptions } from 'express-rate-limit'; -import { rateLimit } from 'express-rate-limit'; - -import { allowedMethods } from '../middleware/allowedMethods.js'; -import { authenticateClient } from '../middleware/clientAuth.js'; -import type { OAuthServerProvider } from '../provider.js'; - -export type RevocationHandlerOptions = { - provider: OAuthServerProvider; - /** - * Rate limiting configuration for the token revocation endpoint. - * Set to false to disable rate limiting for this endpoint. - */ - rateLimit?: Partial | false; -}; - -export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { - if (!provider.revokeToken) { - throw new Error('Auth provider does not support revoking tokens'); - } - - // Nested router so we can configure middleware and restrict HTTP method - const router = express.Router(); - - // Configure CORS to allow any origin, to make accessible to web-based MCP clients - router.use(cors()); - - router.use(allowedMethods(['POST'])); - router.use(express.urlencoded({ extended: false })); - - // Apply rate limiting unless explicitly disabled - if (rateLimitConfig !== false) { - router.use( - rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 50, // 50 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), - ...rateLimitConfig - }) - ); - } - - // Authenticate and extract client details - router.use(authenticateClient({ clientsStore: provider.clientsStore })); - - router.post('/', async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - - try { - const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const client = req.client; - if (!client) { - // This should never happen - throw new ServerError('Internal Server Error'); - } - - await provider.revokeToken!(client, parseResult.data); - res.status(200).json({}); - } catch (error) { - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }); - - return router; -} diff --git a/packages/server/src/server/auth/handlers/token.ts b/packages/server/src/server/auth/handlers/token.ts deleted file mode 100644 index 3b7941294..000000000 --- a/packages/server/src/server/auth/handlers/token.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - InvalidGrantError, - InvalidRequestError, - OAuthError, - ServerError, - TooManyRequestsError, - UnsupportedGrantTypeError -} from '@modelcontextprotocol/core'; -import cors from 'cors'; -import type { RequestHandler } from 'express'; -import express from 'express'; -import type { Options as RateLimitOptions } from 'express-rate-limit'; -import { rateLimit } from 'express-rate-limit'; -import { verifyChallenge } from 'pkce-challenge'; -import * as z from 'zod/v4'; - -import { allowedMethods } from '../middleware/allowedMethods.js'; -import { authenticateClient } from '../middleware/clientAuth.js'; -import type { OAuthServerProvider } from '../provider.js'; - -export type TokenHandlerOptions = { - provider: OAuthServerProvider; - /** - * Rate limiting configuration for the token endpoint. - * Set to false to disable rate limiting for this endpoint. - */ - rateLimit?: Partial | false; -}; - -const TokenRequestSchema = z.object({ - grant_type: z.string() -}); - -const AuthorizationCodeGrantSchema = z.object({ - code: z.string(), - code_verifier: z.string(), - redirect_uri: z.string().optional(), - resource: z.string().url().optional() -}); - -const RefreshTokenGrantSchema = z.object({ - refresh_token: z.string(), - scope: z.string().optional(), - resource: z.string().url().optional() -}); - -export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { - // Nested router so we can configure middleware and restrict HTTP method - const router = express.Router(); - - // Configure CORS to allow any origin, to make accessible to web-based MCP clients - router.use(cors()); - - router.use(allowedMethods(['POST'])); - router.use(express.urlencoded({ extended: false })); - - // Apply rate limiting unless explicitly disabled - if (rateLimitConfig !== false) { - router.use( - rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 50, // 50 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(), - ...rateLimitConfig - }) - ); - } - - // Authenticate and extract client details - router.use(authenticateClient({ clientsStore: provider.clientsStore })); - - router.post('/', async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - - try { - const parseResult = TokenRequestSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const { grant_type } = parseResult.data; - - const client = req.client; - if (!client) { - // This should never happen - throw new ServerError('Internal Server Error'); - } - - switch (grant_type) { - case 'authorization_code': { - const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const { code, code_verifier, redirect_uri, resource } = parseResult.data; - - const skipLocalPkceValidation = provider.skipLocalPkceValidation; - - // Perform local PKCE validation unless explicitly skipped - // (e.g. to validate code_verifier in upstream server) - if (!skipLocalPkceValidation) { - const codeChallenge = await provider.challengeForAuthorizationCode(client, code); - if (!(await verifyChallenge(code_verifier, codeChallenge))) { - throw new InvalidGrantError('code_verifier does not match the challenge'); - } - } - - // Passes the code_verifier to the provider if PKCE validation didn't occur locally - const tokens = await provider.exchangeAuthorizationCode( - client, - code, - skipLocalPkceValidation ? code_verifier : undefined, - redirect_uri, - resource ? new URL(resource) : undefined - ); - res.status(200).json(tokens); - break; - } - - case 'refresh_token': { - const parseResult = RefreshTokenGrantSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const { refresh_token, scope, resource } = parseResult.data; - - const scopes = scope?.split(' '); - const tokens = await provider.exchangeRefreshToken( - client, - refresh_token, - scopes, - resource ? new URL(resource) : undefined - ); - res.status(200).json(tokens); - break; - } - // Additional auth methods will not be added on the server side of the SDK. - case 'client_credentials': - default: - throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.'); - } - } catch (error) { - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }); - - return router; -} diff --git a/packages/server/src/server/auth/index.ts b/packages/server/src/server/auth/index.ts deleted file mode 100644 index 5369224cf..000000000 --- a/packages/server/src/server/auth/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from './clients.js'; -export * from './handlers/authorize.js'; -export * from './handlers/metadata.js'; -export * from './handlers/register.js'; -export * from './handlers/revoke.js'; -export * from './handlers/token.js'; -export * from './middleware/allowedMethods.js'; -export * from './middleware/bearerAuth.js'; -export * from './middleware/clientAuth.js'; -export * from './provider.js'; -export * from './providers/proxyProvider.js'; -export * from './router.js'; diff --git a/packages/server/src/server/auth/middleware/allowedMethods.ts b/packages/server/src/server/auth/middleware/allowedMethods.ts deleted file mode 100644 index 72c076ec4..000000000 --- a/packages/server/src/server/auth/middleware/allowedMethods.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { MethodNotAllowedError } from '@modelcontextprotocol/core'; -import type { RequestHandler } from 'express'; - -/** - * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. - * - * @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST']) - * @returns Express middleware that returns a 405 error if method not in allowed list - */ -export function allowedMethods(allowedMethods: string[]): RequestHandler { - return (req, res, next) => { - if (allowedMethods.includes(req.method)) { - next(); - return; - } - - const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`); - res.status(405).set('Allow', allowedMethods.join(', ')).json(error.toResponseObject()); - }; -} diff --git a/packages/server/src/server/auth/middleware/bearerAuth.ts b/packages/server/src/server/auth/middleware/bearerAuth.ts deleted file mode 100644 index 1a16de1a9..000000000 --- a/packages/server/src/server/auth/middleware/bearerAuth.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { AuthInfo } from '@modelcontextprotocol/core'; -import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '@modelcontextprotocol/core'; -import type { RequestHandler } from 'express'; - -import type { OAuthTokenVerifier } from '../provider.js'; - -export type BearerAuthMiddlewareOptions = { - /** - * A provider used to verify tokens. - */ - verifier: OAuthTokenVerifier; - - /** - * Optional scopes that the token must have. - */ - requiredScopes?: string[]; - - /** - * Optional resource metadata URL to include in WWW-Authenticate header. - */ - resourceMetadataUrl?: string; -}; - -declare module 'express-serve-static-core' { - interface Request { - /** - * Information about the validated access token, if the `requireBearerAuth` middleware was used. - */ - auth?: AuthInfo; - } -} - -/** - * Middleware that requires a valid Bearer token in the Authorization header. - * - * This will validate the token with the auth provider and add the resulting auth info to the request object. - * - * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header - * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec. - */ -export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { - return async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - if (!authHeader) { - throw new InvalidTokenError('Missing Authorization header'); - } - - const [type, token] = authHeader.split(' '); - if (type!.toLowerCase() !== 'bearer' || !token) { - throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); - } - - const authInfo = await verifier.verifyAccessToken(token); - - // Check if token has the required scopes (if any) - if (requiredScopes.length > 0) { - const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope)); - - if (!hasAllScopes) { - throw new InsufficientScopeError('Insufficient scope'); - } - } - - // Check if the token is set to expire or if it is expired - if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) { - throw new InvalidTokenError('Token has no expiration time'); - } else if (authInfo.expiresAt < Date.now() / 1000) { - throw new InvalidTokenError('Token has expired'); - } - - req.auth = authInfo; - next(); - } catch (error) { - // Build WWW-Authenticate header parts - 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}"`; - } - return header; - }; - - if (error instanceof InvalidTokenError) { - res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); - res.status(401).json(error.toResponseObject()); - } else if (error instanceof InsufficientScopeError) { - res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); - res.status(403).json(error.toResponseObject()); - } else if (error instanceof ServerError) { - res.status(500).json(error.toResponseObject()); - } else if (error instanceof OAuthError) { - res.status(400).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }; -} diff --git a/packages/server/src/server/auth/middleware/clientAuth.ts b/packages/server/src/server/auth/middleware/clientAuth.ts deleted file mode 100644 index ac4bc8b79..000000000 --- a/packages/server/src/server/auth/middleware/clientAuth.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { OAuthClientInformationFull } from '@modelcontextprotocol/core'; -import { InvalidClientError, InvalidRequestError, OAuthError, ServerError } from '@modelcontextprotocol/core'; -import type { RequestHandler } from 'express'; -import * as z from 'zod/v4'; - -import type { OAuthRegisteredClientsStore } from '../clients.js'; - -export type ClientAuthenticationMiddlewareOptions = { - /** - * A store used to read information about registered OAuth clients. - */ - clientsStore: OAuthRegisteredClientsStore; -}; - -const ClientAuthenticatedRequestSchema = z.object({ - client_id: z.string(), - client_secret: z.string().optional() -}); - -declare module 'express-serve-static-core' { - interface Request { - /** - * The authenticated client for this request, if the `authenticateClient` middleware was used. - */ - client?: OAuthClientInformationFull; - } -} - -export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler { - return async (req, res, next) => { - try { - const result = ClientAuthenticatedRequestSchema.safeParse(req.body); - if (!result.success) { - throw new InvalidRequestError(String(result.error)); - } - const { client_id, client_secret } = result.data; - const client = await clientsStore.getClient(client_id); - if (!client) { - throw new InvalidClientError('Invalid client_id'); - } - if (client.client_secret) { - if (!client_secret) { - throw new InvalidClientError('Client secret is required'); - } - if (client.client_secret !== client_secret) { - throw new InvalidClientError('Invalid client_secret'); - } - if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { - throw new InvalidClientError('Client secret has expired'); - } - } - - req.client = client; - next(); - } catch (error) { - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }; -} diff --git a/packages/server/src/server/auth/provider.ts b/packages/server/src/server/auth/provider.ts deleted file mode 100644 index 6d27fb792..000000000 --- a/packages/server/src/server/auth/provider.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { AuthInfo, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; -import type { Response } from 'express'; - -import type { OAuthRegisteredClientsStore } from './clients.js'; - -export type AuthorizationParams = { - state?: string; - scopes?: string[]; - codeChallenge: string; - redirectUri: string; - resource?: URL; -}; - -/** - * Implements an end-to-end OAuth server. - */ -export interface OAuthServerProvider { - /** - * A store used to read information about registered OAuth clients. - */ - get clientsStore(): OAuthRegisteredClientsStore; - - /** - * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. - * - * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: - * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. - * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. - */ - authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise; - - /** - * Returns the `codeChallenge` that was used when the indicated authorization began. - */ - challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; - - /** - * Exchanges an authorization code for an access token. - */ - exchangeAuthorizationCode( - client: OAuthClientInformationFull, - authorizationCode: string, - codeVerifier?: string, - redirectUri?: string, - resource?: URL - ): Promise; - - /** - * Exchanges a refresh token for an access token. - */ - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; - - /** - * Verifies an access token and returns information about it. - */ - verifyAccessToken(token: string): Promise; - - /** - * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). - * - * If the given token is invalid or already revoked, this method should do nothing. - */ - revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; - - /** - * Whether to skip local PKCE validation. - * - * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. - * - * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. - */ - skipLocalPkceValidation?: boolean; -} - -/** - * Slim implementation useful for token verification - */ -export interface OAuthTokenVerifier { - /** - * Verifies an access token and returns information about it. - */ - verifyAccessToken(token: string): Promise; -} diff --git a/packages/server/src/server/auth/providers/proxyProvider.ts b/packages/server/src/server/auth/providers/proxyProvider.ts deleted file mode 100644 index 0688754c0..000000000 --- a/packages/server/src/server/auth/providers/proxyProvider.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { AuthInfo, FetchLike, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; -import { OAuthClientInformationFullSchema, OAuthTokensSchema, ServerError } from '@modelcontextprotocol/core'; -import type { Response } from 'express'; - -import type { OAuthRegisteredClientsStore } from '../clients.js'; -import type { AuthorizationParams, OAuthServerProvider } from '../provider.js'; - -export type ProxyEndpoints = { - authorizationUrl: string; - tokenUrl: string; - revocationUrl?: string; - registrationUrl?: string; -}; - -export type ProxyOptions = { - /** - * Individual endpoint URLs for proxying specific OAuth operations - */ - endpoints: ProxyEndpoints; - - /** - * Function to verify access tokens and return auth info - */ - verifyAccessToken: (token: string) => Promise; - - /** - * Function to fetch client information from the upstream server - */ - getClient: (clientId: string) => Promise; - - /** - * Custom fetch implementation used for all network requests. - */ - fetch?: FetchLike; -}; - -/** - * Implements an OAuth server that proxies requests to another OAuth server. - */ -export class ProxyOAuthServerProvider implements OAuthServerProvider { - protected readonly _endpoints: ProxyEndpoints; - protected readonly _verifyAccessToken: (token: string) => Promise; - protected readonly _getClient: (clientId: string) => Promise; - protected readonly _fetch?: FetchLike; - - skipLocalPkceValidation = true; - - revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; - - constructor(options: ProxyOptions) { - this._endpoints = options.endpoints; - this._verifyAccessToken = options.verifyAccessToken; - this._getClient = options.getClient; - this._fetch = options.fetch; - if (options.endpoints?.revocationUrl) { - this.revokeToken = async (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => { - const revocationUrl = this._endpoints.revocationUrl; - - if (!revocationUrl) { - throw new Error('No revocation endpoint configured'); - } - - const params = new URLSearchParams(); - params.set('token', request.token); - params.set('client_id', client.client_id); - if (client.client_secret) { - params.set('client_secret', client.client_secret); - } - if (request.token_type_hint) { - params.set('token_type_hint', request.token_type_hint); - } - - const response = await (this._fetch ?? fetch)(revocationUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: params.toString() - }); - await response.body?.cancel(); - - if (!response.ok) { - throw new ServerError(`Token revocation failed: ${response.status}`); - } - }; - } - } - - get clientsStore(): OAuthRegisteredClientsStore { - const registrationUrl = this._endpoints.registrationUrl; - return { - getClient: this._getClient, - ...(registrationUrl && { - registerClient: async (client: OAuthClientInformationFull) => { - const response = await (this._fetch ?? fetch)(registrationUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(client) - }); - - if (!response.ok) { - await response.body?.cancel(); - throw new ServerError(`Client registration failed: ${response.status}`); - } - - const data = await response.json(); - return OAuthClientInformationFullSchema.parse(data); - } - }) - }; - } - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - // Start with required OAuth parameters - const targetUrl = new URL(this._endpoints.authorizationUrl); - const searchParams = new URLSearchParams({ - client_id: client.client_id, - response_type: 'code', - redirect_uri: params.redirectUri, - code_challenge: params.codeChallenge, - code_challenge_method: 'S256' - }); - - // Add optional standard OAuth parameters - if (params.state) searchParams.set('state', params.state); - if (params.scopes?.length) searchParams.set('scope', params.scopes.join(' ')); - if (params.resource) searchParams.set('resource', params.resource.href); - - targetUrl.search = searchParams.toString(); - res.redirect(targetUrl.toString()); - } - - async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise { - // In a proxy setup, we don't store the code challenge ourselves - // Instead, we proxy the token request and let the upstream server validate it - return ''; - } - - async exchangeAuthorizationCode( - client: OAuthClientInformationFull, - authorizationCode: string, - codeVerifier?: string, - redirectUri?: string, - resource?: URL - ): Promise { - const params = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: client.client_id, - code: authorizationCode - }); - - if (client.client_secret) { - params.append('client_secret', client.client_secret); - } - - if (codeVerifier) { - params.append('code_verifier', codeVerifier); - } - - if (redirectUri) { - params.append('redirect_uri', redirectUri); - } - - if (resource) { - params.append('resource', resource.href); - } - - const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: params.toString() - }); - - if (!response.ok) { - await response.body?.cancel(); - throw new ServerError(`Token exchange failed: ${response.status}`); - } - - const data = await response.json(); - return OAuthTokensSchema.parse(data); - } - - async exchangeRefreshToken( - client: OAuthClientInformationFull, - refreshToken: string, - scopes?: string[], - resource?: URL - ): Promise { - const params = new URLSearchParams({ - grant_type: 'refresh_token', - client_id: client.client_id, - refresh_token: refreshToken - }); - - if (client.client_secret) { - params.set('client_secret', client.client_secret); - } - - if (scopes?.length) { - params.set('scope', scopes.join(' ')); - } - - if (resource) { - params.set('resource', resource.href); - } - - const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: params.toString() - }); - - if (!response.ok) { - await response.body?.cancel(); - throw new ServerError(`Token refresh failed: ${response.status}`); - } - - const data = await response.json(); - return OAuthTokensSchema.parse(data); - } - - async verifyAccessToken(token: string): Promise { - return this._verifyAccessToken(token); - } -} diff --git a/packages/server/src/server/auth/router.ts b/packages/server/src/server/auth/router.ts deleted file mode 100644 index ba8b030e0..000000000 --- a/packages/server/src/server/auth/router.ts +++ /dev/null @@ -1,246 +0,0 @@ -import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core'; -import type { RequestHandler } from 'express'; -import express from 'express'; - -import type { AuthorizationHandlerOptions } from './handlers/authorize.js'; -import { authorizationHandler } from './handlers/authorize.js'; -import { metadataHandler } from './handlers/metadata.js'; -import type { ClientRegistrationHandlerOptions } from './handlers/register.js'; -import { clientRegistrationHandler } from './handlers/register.js'; -import type { RevocationHandlerOptions } from './handlers/revoke.js'; -import { revocationHandler } from './handlers/revoke.js'; -import type { TokenHandlerOptions } from './handlers/token.js'; -import { tokenHandler } from './handlers/token.js'; -import type { OAuthServerProvider } from './provider.js'; - -// Check for dev mode flag that allows HTTP issuer URLs (for development/testing only) -const allowInsecureIssuerUrl = - process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === 'true' || process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === '1'; -if (allowInsecureIssuerUrl) { - // eslint-disable-next-line no-console - console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.'); -} - -export type AuthRouterOptions = { - /** - * A provider implementing the actual authorization logic for this router. - */ - provider: OAuthServerProvider; - - /** - * The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. - */ - issuerUrl: URL; - - /** - * The base URL of the authorization server to use for the metadata endpoints. - * - * If not provided, the issuer URL will be used as the base URL. - */ - baseUrl?: URL; - - /** - * An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server. - */ - serviceDocumentationUrl?: URL; - - /** - * An optional list of scopes supported by this authorization server - */ - scopesSupported?: string[]; - - /** - * The resource name to be displayed in protected resource metadata - */ - resourceName?: string; - - /** - * The URL of the protected resource (RS) whose metadata we advertise. - * If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS). - */ - resourceServerUrl?: URL; - - // Individual options per route - authorizationOptions?: Omit; - clientRegistrationOptions?: Omit; - revocationOptions?: Omit; - tokenOptions?: Omit; -}; - -const checkIssuerUrl = (issuer: URL): void => { - // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing - if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) { - throw new Error('Issuer URL must be HTTPS'); - } - if (issuer.hash) { - throw new Error(`Issuer URL must not have a fragment: ${issuer}`); - } - if (issuer.search) { - throw new Error(`Issuer URL must not have a query string: ${issuer}`); - } -}; - -export const createOAuthMetadata = (options: { - provider: OAuthServerProvider; - issuerUrl: URL; - baseUrl?: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; -}): OAuthMetadata => { - const issuer = options.issuerUrl; - const baseUrl = options.baseUrl; - - checkIssuerUrl(issuer); - - const authorization_endpoint = '/authorize'; - const token_endpoint = '/token'; - const registration_endpoint = options.provider.clientsStore.registerClient ? '/register' : undefined; - const revocation_endpoint = options.provider.revokeToken ? '/revoke' : undefined; - - const metadata: OAuthMetadata = { - issuer: issuer.href, - service_documentation: options.serviceDocumentationUrl?.href, - - authorization_endpoint: new URL(authorization_endpoint, baseUrl || issuer).href, - response_types_supported: ['code'], - code_challenge_methods_supported: ['S256'], - - token_endpoint: new URL(token_endpoint, baseUrl || issuer).href, - token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], - grant_types_supported: ['authorization_code', 'refresh_token'], - - scopes_supported: options.scopesSupported, - - revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined, - revocation_endpoint_auth_methods_supported: revocation_endpoint ? ['client_secret_post'] : undefined, - - registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined - }; - - return metadata; -}; - -/** - * Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported). - * Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. - * Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead. - * - * By default, rate limiting is applied to all endpoints to prevent abuse. - * - * This router MUST be installed at the application root, like so: - * - * const app = express(); - * app.use(mcpAuthRouter(...)); - */ -export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { - const oauthMetadata = createOAuthMetadata(options); - - const router = express.Router(); - - router.use( - new URL(oauthMetadata.authorization_endpoint).pathname, - authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) - ); - - router.use(new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions })); - - router.use( - mcpAuthMetadataRouter({ - oauthMetadata, - // Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat) - resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer), - serviceDocumentationUrl: options.serviceDocumentationUrl, - scopesSupported: options.scopesSupported, - resourceName: options.resourceName - }) - ); - - if (oauthMetadata.registration_endpoint) { - router.use( - new URL(oauthMetadata.registration_endpoint).pathname, - clientRegistrationHandler({ - clientsStore: options.provider.clientsStore, - ...options.clientRegistrationOptions - }) - ); - } - - if (oauthMetadata.revocation_endpoint) { - router.use( - new URL(oauthMetadata.revocation_endpoint).pathname, - revocationHandler({ provider: options.provider, ...options.revocationOptions }) - ); - } - - return router; -} - -export type AuthMetadataOptions = { - /** - * OAuth Metadata as would be returned from the authorization server - * this MCP server relies on - */ - oauthMetadata: OAuthMetadata; - - /** - * The url of the MCP server, for use in protected resource metadata - */ - resourceServerUrl: URL; - - /** - * The url for documentation for the MCP server - */ - serviceDocumentationUrl?: URL; - - /** - * An optional list of scopes supported by this MCP server - */ - scopesSupported?: string[]; - - /** - * An optional resource name to display in resource metadata - */ - resourceName?: string; -}; - -export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router { - checkIssuerUrl(new URL(options.oauthMetadata.issuer)); - - const router = express.Router(); - - const protectedResourceMetadata: OAuthProtectedResourceMetadata = { - resource: options.resourceServerUrl.href, - - authorization_servers: [options.oauthMetadata.issuer], - - scopes_supported: options.scopesSupported, - resource_name: options.resourceName, - resource_documentation: options.serviceDocumentationUrl?.href - }; - - // Serve PRM at the path-specific URL per RFC 9728 - const rsPath = new URL(options.resourceServerUrl.href).pathname; - router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata)); - - // Always add this for OAuth Authorization Server metadata per RFC 8414 - router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata)); - - return router; -} - -/** - * Helper function to construct the OAuth 2.0 Protected Resource Metadata URL - * from a given server URL. This replaces the path with the standard metadata endpoint. - * - * @param serverUrl - The base URL of the protected resource server - * @returns The URL for the OAuth protected resource metadata endpoint - * - * @example - * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) - * // Returns: 'https://api.example.com/.well-known/oauth-protected-resource/mcp' - */ -export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { - const u = new URL(serverUrl.href); - const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : ''; - return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href; -} diff --git a/packages/server/src/server/helper/body.ts b/packages/server/src/server/helper/body.ts new file mode 100644 index 000000000..7e08aaae8 --- /dev/null +++ b/packages/server/src/server/helper/body.ts @@ -0,0 +1,26 @@ +export async function getParsedBody(req: Request): Promise { + const ct = req.headers.get('content-type') ?? ''; + + if (ct.includes('application/json')) { + return await req.json(); + } + + if (ct.includes('application/x-www-form-urlencoded')) { + const text = await req.text(); + return objectFromUrlEncoded(text); + } + + // Empty bodies are treated as empty objects. + const text = await req.text(); + if (!text) return {}; + + // If content-type is missing/unknown, fall back to treating it as urlencoded-like. + return objectFromUrlEncoded(text); +} + +export function objectFromUrlEncoded(body: string): Record { + const params = new URLSearchParams(body); + const out: Record = {}; + for (const [k, v] of params.entries()) out[k] = v; + return out; +} diff --git a/packages/server/src/server/middleware/hostHeaderValidation.ts b/packages/server/src/server/middleware/hostHeaderValidation.ts index f46178db3..e4d13ecf5 100644 --- a/packages/server/src/server/middleware/hostHeaderValidation.ts +++ b/packages/server/src/server/middleware/hostHeaderValidation.ts @@ -1,79 +1,67 @@ -import type { NextFunction, Request, RequestHandler, Response } from 'express'; +export type HostHeaderValidationResult = + | { ok: true; hostname: string } + | { + ok: false; + errorCode: 'missing_host' | 'invalid_host_header' | 'invalid_host'; + message: string; + hostHeader?: string; + hostname?: string; + }; /** - * Express middleware for DNS rebinding protection. - * Validates Host header hostname (port-agnostic) against an allowed list. + * Parse and validate a Host header against an allowlist of hostnames (port-agnostic). * - * This is particularly important for servers without authorization or HTTPS, - * such as localhost servers or development servers. DNS rebinding attacks can - * bypass same-origin policy by manipulating DNS to point a domain to a - * localhost address, allowing malicious websites to access your local server. - * - * @param allowedHostnames - List of allowed hostnames (without ports). - * For IPv6, provide the address with brackets (e.g., '[::1]'). - * @returns Express middleware function - * - * @example - * ```typescript - * const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); - * app.use(middleware); - * ``` + * - Input host header may include a port (e.g. `localhost:3000`) or IPv6 brackets (e.g. `[::1]:3000`). + * - Allowlist items should be hostnames only (no ports). For IPv6, include brackets (e.g. `[::1]`). */ -export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler { - return (req: Request, res: Response, next: NextFunction) => { - const hostHeader = req.headers.host; - if (!hostHeader) { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Missing Host header' - }, - id: null - }); - return; - } +export function validateHostHeader(hostHeader: string | null | undefined, allowedHostnames: string[]): HostHeaderValidationResult { + if (!hostHeader) { + return { ok: false, errorCode: 'missing_host', message: 'Missing Host header' }; + } - // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) - let hostname: string; - try { - hostname = new URL(`http://${hostHeader}`).hostname; - } catch { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: `Invalid Host header: ${hostHeader}` - }, - id: null - }); - return; - } + // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + return { ok: false, errorCode: 'invalid_host_header', message: `Invalid Host header: ${hostHeader}`, hostHeader }; + } - if (!allowedHostnames.includes(hostname)) { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: `Invalid Host: ${hostname}` - }, - id: null - }); - return; - } - next(); - }; + if (!allowedHostnames.includes(hostname)) { + return { ok: false, errorCode: 'invalid_host', message: `Invalid Host: ${hostname}`, hostHeader, hostname }; + } + + return { ok: true, hostname }; } /** - * Convenience middleware for localhost DNS rebinding protection. - * Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames. - * + * Convenience allowlist for localhost DNS rebinding protection. + */ +export function localhostAllowedHostnames(): string[] { + return ['localhost', '127.0.0.1', '[::1]']; +} + +/** + * Web-standard Request helper for DNS rebinding protection. * @example - * ```typescript - * app.use(localhostHostValidation()); - * ``` + * const result = validateHostHeader(req.headers.get('host'), ['localhost']) */ -export function localhostHostValidation(): RequestHandler { - return hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); +export function hostHeaderValidationResponse(req: Request, allowedHostnames: string[]): Response | undefined { + const result = validateHostHeader(req.headers.get('host'), allowedHostnames); + if (result.ok) return undefined; + + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: result.message + }, + id: null + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' } + } + ); } diff --git a/packages/server/src/server/sse.ts b/packages/server/src/server/sse.ts deleted file mode 100644 index 4fd0fa1d6..000000000 --- a/packages/server/src/server/sse.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { URL } from 'node:url'; - -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestInfo, Transport } from '@modelcontextprotocol/core'; -import { JSONRPCMessageSchema } from '@modelcontextprotocol/core'; -import contentType from 'content-type'; -import getRawBody from 'raw-body'; - -const MAXIMUM_MESSAGE_SIZE = '4mb'; - -/** - * Configuration options for SSEServerTransport. - */ -export interface SSEServerTransportOptions { - /** - * List of allowed host header values for DNS rebinding protection. - * If not specified, host validation is disabled. - * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - allowedHosts?: string[]; - - /** - * List of allowed origin header values for DNS rebinding protection. - * If not specified, origin validation is disabled. - * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - allowedOrigins?: string[]; - - /** - * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). - * Default is false for backwards compatibility. - * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - enableDnsRebindingProtection?: boolean; -} - -/** - * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. - * - * This transport is only available in Node.js environments. - * @deprecated SSEServerTransport is deprecated. Use StreamableHTTPServerTransport instead. - */ -export class SSEServerTransport implements Transport { - private _sseResponse?: ServerResponse; - private _sessionId: string; - private _options: SSEServerTransportOptions; - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; - - /** - * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. - */ - constructor( - private _endpoint: string, - private res: ServerResponse, - options?: SSEServerTransportOptions - ) { - this._sessionId = randomUUID(); - this._options = options || { enableDnsRebindingProtection: false }; - } - - /** - * Validates request headers for DNS rebinding protection. - * @returns Error message if validation fails, undefined if validation passes. - */ - private validateRequestHeaders(req: IncomingMessage): string | undefined { - // Skip validation if protection is not enabled - if (!this._options.enableDnsRebindingProtection) { - return undefined; - } - - // Validate Host header if allowedHosts is configured - if (this._options.allowedHosts && this._options.allowedHosts.length > 0) { - const hostHeader = req.headers.host; - if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) { - return `Invalid Host header: ${hostHeader}`; - } - } - - // Validate Origin header if allowedOrigins is configured - if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) { - const originHeader = req.headers.origin; - if (originHeader && !this._options.allowedOrigins.includes(originHeader)) { - return `Invalid Origin header: ${originHeader}`; - } - } - - return undefined; - } - - /** - * Handles the initial SSE connection request. - * - * This should be called when a GET request is made to establish the SSE stream. - */ - async start(): Promise { - if (this._sseResponse) { - throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.'); - } - - this.res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive' - }); - - // Send the endpoint event - // Use a dummy base URL because this._endpoint is relative. - // This allows using URL/URLSearchParams for robust parameter handling. - const dummyBase = 'http://localhost'; // Any valid base works - const endpointUrl = new URL(this._endpoint, dummyBase); - endpointUrl.searchParams.set('sessionId', this._sessionId); - - // Reconstruct the relative URL string (pathname + search + hash) - const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash; - - this.res.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`); - - this._sseResponse = this.res; - this.res.on('close', () => { - this._sseResponse = undefined; - this.onclose?.(); - }); - } - - /** - * Handles incoming POST messages. - * - * This should be called when a POST request is made to send a message to the server. - */ - async handlePostMessage(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { - if (!this._sseResponse) { - const message = 'SSE connection not established'; - res.writeHead(500).end(message); - throw new Error(message); - } - - // Validate request headers for DNS rebinding protection - const validationError = this.validateRequestHeaders(req); - if (validationError) { - res.writeHead(403).end(validationError); - this.onerror?.(new Error(validationError)); - return; - } - - const authInfo: AuthInfo | undefined = req.auth; - const requestInfo: RequestInfo = { headers: req.headers }; - - let body: string | unknown; - try { - const ct = contentType.parse(req.headers['content-type'] ?? ''); - if (ct.type !== 'application/json') { - throw new Error(`Unsupported content-type: ${ct.type}`); - } - - body = - parsedBody ?? - (await getRawBody(req, { - limit: MAXIMUM_MESSAGE_SIZE, - encoding: ct.parameters.charset ?? 'utf-8' - })); - } catch (error) { - res.writeHead(400).end(String(error)); - this.onerror?.(error as Error); - return; - } - - try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo }); - } catch { - res.writeHead(400).end(`Invalid message: ${body}`); - return; - } - - res.writeHead(202).end('Accepted'); - } - - /** - * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. - */ - async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise { - let parsedMessage: JSONRPCMessage; - try { - parsedMessage = JSONRPCMessageSchema.parse(message); - } catch (error) { - this.onerror?.(error as Error); - throw error; - } - - this.onmessage?.(parsedMessage, extra); - } - - async close(): Promise { - this._sseResponse?.end(); - this._sseResponse = undefined; - this.onclose?.(); - } - - async send(message: JSONRPCMessage): Promise { - if (!this._sseResponse) { - throw new Error('Not connected'); - } - - this._sseResponse.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); - } - - /** - * Returns the session ID for this transport. - * - * This can be used to route incoming POST requests. - */ - get sessionId(): string { - return this._sessionId; - } -} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index f9ee07ca8..10a990196 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -59,7 +59,7 @@ export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServ * - No Session ID is included in any responses * - No session validation is performed */ -export class StreamableHTTPServerTransport implements Transport { +export class NodeStreamableHTTPServerTransport implements Transport { private _webStandardTransport: WebStandardStreamableHTTPServerTransport; private _requestListener: ReturnType; // Store auth and parsedBody per request for passing through to handleRequest diff --git a/packages/server/src/server/webStandardStreamableHttp.ts b/packages/server/src/server/webStandardStreamableHttp.ts index 082c904e1..73ab30808 100644 --- a/packages/server/src/server/webStandardStreamableHttp.ts +++ b/packages/server/src/server/webStandardStreamableHttp.ts @@ -4,7 +4,7 @@ * This is the core transport implementation using Web Standard APIs (Request, Response, ReadableStream). * It can run on any runtime that supports Web Standards: Node.js 18+, Cloudflare Workers, Deno, Bun, etc. * - * For Node.js Express/HTTP compatibility, use `StreamableHTTPServerTransport` which wraps this transport. + * For Node.js Express/HTTP compatibility, use `NodeStreamableHTTPServerTransport` which wraps this transport. */ import { TextEncoder } from 'node:util'; diff --git a/packages/server/test/server/auth/handlers/authorize.test.ts b/packages/server/test/server/auth/handlers/authorize.test.ts deleted file mode 100644 index b84de3bc3..000000000 --- a/packages/server/test/server/auth/handlers/authorize.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type { AuthInfo, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/core'; -import { InvalidTokenError } from '@modelcontextprotocol/core'; -import type { Response } from 'express'; -import express from 'express'; -import supertest from 'supertest'; - -import type { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import type { AuthorizationHandlerOptions } from '../../../../src/server/auth/handlers/authorize.js'; -import { authorizationHandler } from '../../../../src/server/auth/handlers/authorize.js'; -import type { AuthorizationParams, OAuthServerProvider } from '../../../../src/server/auth/provider.js'; - -describe('Authorization Handler', () => { - // Mock client data - const validClient: OAuthClientInformationFull = { - client_id: 'valid-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'profile email' - }; - - const multiRedirectClient: OAuthClientInformationFull = { - client_id: 'multi-redirect-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback1', 'https://example.com/callback2'], - scope: 'profile email' - }; - - // Mock client store - const mockClientStore: OAuthRegisteredClientsStore = { - async getClient(clientId: string): Promise { - if (clientId === 'valid-client') { - return validClient; - } else if (clientId === 'multi-redirect-client') { - return multiRedirectClient; - } - return undefined; - } - }; - - // Mock provider - const mockProvider: OAuthServerProvider = { - clientsStore: mockClientStore, - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - // Mock implementation - redirects to redirectUri with code and state - const redirectUrl = new URL(params.redirectUri); - redirectUrl.searchParams.set('code', 'mock_auth_code'); - if (params.state) { - redirectUrl.searchParams.set('state', params.state); - } - res.redirect(302, redirectUrl.toString()); - }, - - async challengeForAuthorizationCode(): Promise { - return 'mock_challenge'; - }, - - async exchangeAuthorizationCode(): Promise { - return { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }; - }, - - async exchangeRefreshToken(): Promise { - return { - access_token: 'new_mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'new_mock_refresh_token' - }; - }, - - async verifyAccessToken(token: string): Promise { - if (token === 'valid_token') { - return { - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }; - } - throw new InvalidTokenError('Token is invalid or expired'); - }, - - async revokeToken(): Promise { - // Do nothing in mock - } - }; - - // Setup express app with handler - let app: express.Express; - let options: AuthorizationHandlerOptions; - - beforeEach(() => { - app = express(); - options = { provider: mockProvider }; - const handler = authorizationHandler(options); - app.use('/authorize', handler); - }); - - describe('HTTP method validation', () => { - it('rejects non-GET/POST methods', async () => { - const response = await supertest(app).put('/authorize').query({ client_id: 'valid-client' }); - - expect(response.status).toBe(405); // Method not allowed response from handler - }); - }); - - describe('Client validation', () => { - it('requires client_id parameter', async () => { - const response = await supertest(app).get('/authorize'); - - expect(response.status).toBe(400); - expect(response.text).toContain('client_id'); - }); - - it('validates that client exists', async () => { - const response = await supertest(app).get('/authorize').query({ client_id: 'nonexistent-client' }); - - expect(response.status).toBe(400); - }); - }); - - describe('Redirect URI validation', () => { - it('uses the only redirect_uri if client has just one and none provided', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.origin + location.pathname).toBe('https://example.com/callback'); - }); - - it('requires redirect_uri if client has multiple', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'multi-redirect-client', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(400); - }); - - it('validates redirect_uri against client registered URIs', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - redirect_uri: 'https://malicious.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(400); - }); - - it('accepts valid redirect_uri that client registered with', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.origin + location.pathname).toBe('https://example.com/callback'); - }); - }); - - describe('Authorization request validation', () => { - it('requires response_type=code', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'token', // invalid - we only support code flow - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.searchParams.get('error')).toBe('invalid_request'); - }); - - it('requires code_challenge parameter', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge_method: 'S256' - // Missing code_challenge - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.searchParams.get('error')).toBe('invalid_request'); - }); - - it('requires code_challenge_method=S256', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'plain' // Only S256 is supported - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.searchParams.get('error')).toBe('invalid_request'); - }); - }); - - describe('Resource parameter validation', () => { - it('propagates resource parameter', async () => { - const mockProviderWithResource = vi.spyOn(mockProvider, 'authorize'); - - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(302); - expect(mockProviderWithResource).toHaveBeenCalledWith( - validClient, - expect.objectContaining({ - resource: new URL('https://api.example.com/resource'), - redirectUri: 'https://example.com/callback', - codeChallenge: 'challenge123' - }), - expect.any(Object) - ); - }); - }); - - describe('Successful authorization', () => { - it('handles successful authorization with all parameters', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - scope: 'profile email', - state: 'xyz789' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.origin + location.pathname).toBe('https://example.com/callback'); - expect(location.searchParams.get('code')).toBe('mock_auth_code'); - expect(location.searchParams.get('state')).toBe('xyz789'); - }); - - it('preserves state parameter in response', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - state: 'state-value-123' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.searchParams.get('state')).toBe('state-value-123'); - }); - - it('handles POST requests the same as GET', async () => { - const response = await supertest(app).post('/authorize').type('form').send({ - client_id: 'valid-client', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.searchParams.has('code')).toBe(true); - }); - }); -}); diff --git a/packages/server/test/server/auth/handlers/metadata.test.ts b/packages/server/test/server/auth/handlers/metadata.test.ts deleted file mode 100644 index 0dc51e51d..000000000 --- a/packages/server/test/server/auth/handlers/metadata.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { OAuthMetadata } from '@modelcontextprotocol/core'; -import express from 'express'; -import supertest from 'supertest'; - -import { metadataHandler } from '../../../../src/server/auth/handlers/metadata.js'; - -describe('Metadata Handler', () => { - const exampleMetadata: OAuthMetadata = { - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - registration_endpoint: 'https://auth.example.com/register', - revocation_endpoint: 'https://auth.example.com/revoke', - scopes_supported: ['profile', 'email'], - response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], - token_endpoint_auth_methods_supported: ['client_secret_basic'], - code_challenge_methods_supported: ['S256'] - }; - - let app: express.Express; - - beforeEach(() => { - // Setup express app with metadata handler - app = express(); - app.use('/.well-known/oauth-authorization-server', metadataHandler(exampleMetadata)); - }); - - it('requires GET method', async () => { - const response = await supertest(app).post('/.well-known/oauth-authorization-server').send({}); - - expect(response.status).toBe(405); - expect(response.headers.allow).toBe('GET, OPTIONS'); - expect(response.body).toEqual({ - error: 'method_not_allowed', - error_description: 'The method POST is not allowed for this endpoint' - }); - }); - - it('returns the metadata object', async () => { - const response = await supertest(app).get('/.well-known/oauth-authorization-server'); - - expect(response.status).toBe(200); - expect(response.body).toEqual(exampleMetadata); - }); - - it('includes CORS headers in response', async () => { - const response = await supertest(app).get('/.well-known/oauth-authorization-server').set('Origin', 'https://example.com'); - - expect(response.header['access-control-allow-origin']).toBe('*'); - }); - - it('supports OPTIONS preflight requests', async () => { - const response = await supertest(app) - .options('/.well-known/oauth-authorization-server') - .set('Origin', 'https://example.com') - .set('Access-Control-Request-Method', 'GET'); - - expect(response.status).toBe(204); - expect(response.header['access-control-allow-origin']).toBe('*'); - }); - - it('works with minimal metadata', async () => { - // Setup a new express app with minimal metadata - const minimalApp = express(); - const minimalMetadata: OAuthMetadata = { - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - response_types_supported: ['code'] - }; - minimalApp.use('/.well-known/oauth-authorization-server', metadataHandler(minimalMetadata)); - - const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server'); - - expect(response.status).toBe(200); - expect(response.body).toEqual(minimalMetadata); - }); -}); diff --git a/packages/server/test/server/auth/handlers/register.test.ts b/packages/server/test/server/auth/handlers/register.test.ts deleted file mode 100644 index b10e048ed..000000000 --- a/packages/server/test/server/auth/handlers/register.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { OAuthClientInformationFull, OAuthClientMetadata } from '@modelcontextprotocol/core'; -import express from 'express'; -import supertest from 'supertest'; -import type { MockInstance } from 'vitest'; - -import type { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import type { ClientRegistrationHandlerOptions } from '../../../../src/server/auth/handlers/register.js'; -import { clientRegistrationHandler } from '../../../../src/server/auth/handlers/register.js'; - -describe('Client Registration Handler', () => { - // Mock client store with registration support - const mockClientStoreWithRegistration: OAuthRegisteredClientsStore = { - async getClient(_clientId: string): Promise { - return undefined; - }, - - async registerClient(client: OAuthClientInformationFull): Promise { - // Return the client info as-is in the mock - return client; - } - }; - - // Mock client store without registration support - const mockClientStoreWithoutRegistration: OAuthRegisteredClientsStore = { - async getClient(_clientId: string): Promise { - return undefined; - } - // No registerClient method - }; - - describe('Handler creation', () => { - it('throws error if client store does not support registration', () => { - const options: ClientRegistrationHandlerOptions = { - clientsStore: mockClientStoreWithoutRegistration - }; - - expect(() => clientRegistrationHandler(options)).toThrow('does not support registering clients'); - }); - - it('creates handler if client store supports registration', () => { - const options: ClientRegistrationHandlerOptions = { - clientsStore: mockClientStoreWithRegistration - }; - - expect(() => clientRegistrationHandler(options)).not.toThrow(); - }); - }); - - describe('Request handling', () => { - let app: express.Express; - let spyRegisterClient: MockInstance; - - beforeEach(() => { - // Setup express app with registration handler - app = express(); - const options: ClientRegistrationHandlerOptions = { - clientsStore: mockClientStoreWithRegistration, - clientSecretExpirySeconds: 86400 // 1 day for testing - }; - - app.use('/register', clientRegistrationHandler(options)); - - // Spy on the registerClient method - spyRegisterClient = vi.spyOn(mockClientStoreWithRegistration, 'registerClient'); - }); - - afterEach(() => { - spyRegisterClient.mockRestore(); - }); - - it('requires POST method', async () => { - const response = await supertest(app) - .get('/register') - .send({ - redirect_uris: ['https://example.com/callback'] - }); - - expect(response.status).toBe(405); - expect(response.headers.allow).toBe('POST'); - expect(response.body).toEqual({ - error: 'method_not_allowed', - error_description: 'The method GET is not allowed for this endpoint' - }); - expect(spyRegisterClient).not.toHaveBeenCalled(); - }); - - it('validates required client metadata', async () => { - const response = await supertest(app).post('/register').send({ - // Missing redirect_uris (required) - client_name: 'Test Client' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_client_metadata'); - expect(spyRegisterClient).not.toHaveBeenCalled(); - }); - - it('validates redirect URIs format', async () => { - const response = await supertest(app) - .post('/register') - .send({ - redirect_uris: ['invalid-url'] // Invalid URL format - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_client_metadata'); - expect(response.body.error_description).toContain('redirect_uris'); - expect(spyRegisterClient).not.toHaveBeenCalled(); - }); - - it('successfully registers client with minimal metadata', async () => { - const clientMetadata: OAuthClientMetadata = { - redirect_uris: ['https://example.com/callback'] - }; - - const response = await supertest(app).post('/register').send(clientMetadata); - - expect(response.status).toBe(201); - - // Verify the generated client information - expect(response.body.client_id).toBeDefined(); - expect(response.body.client_secret).toBeDefined(); - expect(response.body.client_id_issued_at).toBeDefined(); - expect(response.body.client_secret_expires_at).toBeDefined(); - expect(response.body.redirect_uris).toEqual(['https://example.com/callback']); - - // Verify client was registered - expect(spyRegisterClient).toHaveBeenCalledTimes(1); - }); - - it('sets client_secret to undefined for token_endpoint_auth_method=none', async () => { - const clientMetadata: OAuthClientMetadata = { - redirect_uris: ['https://example.com/callback'], - token_endpoint_auth_method: 'none' - }; - - const response = await supertest(app).post('/register').send(clientMetadata); - - expect(response.status).toBe(201); - expect(response.body.client_secret).toBeUndefined(); - expect(response.body.client_secret_expires_at).toBeUndefined(); - }); - - it('sets client_secret_expires_at for public clients only', async () => { - // Test for public client (token_endpoint_auth_method not 'none') - const publicClientMetadata: OAuthClientMetadata = { - redirect_uris: ['https://example.com/callback'], - token_endpoint_auth_method: 'client_secret_basic' - }; - - const publicResponse = await supertest(app).post('/register').send(publicClientMetadata); - - expect(publicResponse.status).toBe(201); - expect(publicResponse.body.client_secret).toBeDefined(); - expect(publicResponse.body.client_secret_expires_at).toBeDefined(); - - // Test for non-public client (token_endpoint_auth_method is 'none') - const nonPublicClientMetadata: OAuthClientMetadata = { - redirect_uris: ['https://example.com/callback'], - token_endpoint_auth_method: 'none' - }; - - const nonPublicResponse = await supertest(app).post('/register').send(nonPublicClientMetadata); - - expect(nonPublicResponse.status).toBe(201); - expect(nonPublicResponse.body.client_secret).toBeUndefined(); - expect(nonPublicResponse.body.client_secret_expires_at).toBeUndefined(); - }); - - it('sets expiry based on clientSecretExpirySeconds', async () => { - // Create handler with custom expiry time - const customApp = express(); - const options: ClientRegistrationHandlerOptions = { - clientsStore: mockClientStoreWithRegistration, - clientSecretExpirySeconds: 3600 // 1 hour - }; - - customApp.use('/register', clientRegistrationHandler(options)); - - const response = await supertest(customApp) - .post('/register') - .send({ - redirect_uris: ['https://example.com/callback'] - }); - - expect(response.status).toBe(201); - - // Verify the expiration time (~1 hour from now) - const issuedAt = response.body.client_id_issued_at; - const expiresAt = response.body.client_secret_expires_at; - expect(expiresAt - issuedAt).toBe(3600); - }); - - it('sets no expiry when clientSecretExpirySeconds=0', async () => { - // Create handler with no expiry - const customApp = express(); - const options: ClientRegistrationHandlerOptions = { - clientsStore: mockClientStoreWithRegistration, - clientSecretExpirySeconds: 0 // No expiry - }; - - customApp.use('/register', clientRegistrationHandler(options)); - - const response = await supertest(customApp) - .post('/register') - .send({ - redirect_uris: ['https://example.com/callback'] - }); - - expect(response.status).toBe(201); - expect(response.body.client_secret_expires_at).toBe(0); - }); - - it('sets no client_id when clientIdGeneration=false', async () => { - // Create handler with no expiry - const customApp = express(); - const options: ClientRegistrationHandlerOptions = { - clientsStore: mockClientStoreWithRegistration, - clientIdGeneration: false - }; - - customApp.use('/register', clientRegistrationHandler(options)); - - const response = await supertest(customApp) - .post('/register') - .send({ - redirect_uris: ['https://example.com/callback'] - }); - - expect(response.status).toBe(201); - expect(response.body.client_id).toBeUndefined(); - expect(response.body.client_id_issued_at).toBeUndefined(); - }); - - it('handles client with all metadata fields', async () => { - const fullClientMetadata: OAuthClientMetadata = { - redirect_uris: ['https://example.com/callback'], - token_endpoint_auth_method: 'client_secret_basic', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - client_name: 'Test Client', - client_uri: 'https://example.com', - logo_uri: 'https://example.com/logo.png', - scope: 'profile email', - contacts: ['dev@example.com'], - tos_uri: 'https://example.com/tos', - policy_uri: 'https://example.com/privacy', - jwks_uri: 'https://example.com/jwks', - software_id: 'test-software', - software_version: '1.0.0' - }; - - const response = await supertest(app).post('/register').send(fullClientMetadata); - - expect(response.status).toBe(201); - - // Verify all metadata was preserved - Object.entries(fullClientMetadata).forEach(([key, value]) => { - expect(response.body[key]).toEqual(value); - }); - }); - - it('includes CORS headers in response', async () => { - const response = await supertest(app) - .post('/register') - .set('Origin', 'https://example.com') - .send({ - redirect_uris: ['https://example.com/callback'] - }); - - expect(response.header['access-control-allow-origin']).toBe('*'); - }); - }); -}); diff --git a/packages/server/test/server/auth/handlers/revoke.test.ts b/packages/server/test/server/auth/handlers/revoke.test.ts deleted file mode 100644 index 61ff51b24..000000000 --- a/packages/server/test/server/auth/handlers/revoke.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { AuthInfo, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; -import { InvalidTokenError } from '@modelcontextprotocol/core'; -import type { Response } from 'express'; -import express from 'express'; -import supertest from 'supertest'; -import type { MockInstance } from 'vitest'; - -import type { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import type { RevocationHandlerOptions } from '../../../../src/server/auth/handlers/revoke.js'; -import { revocationHandler } from '../../../../src/server/auth/handlers/revoke.js'; -import type { AuthorizationParams, OAuthServerProvider } from '../../../../src/server/auth/provider.js'; - -describe('Revocation Handler', () => { - // Mock client data - const validClient: OAuthClientInformationFull = { - client_id: 'valid-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback'] - }; - - // Mock client store - const mockClientStore: OAuthRegisteredClientsStore = { - async getClient(clientId: string): Promise { - if (clientId === 'valid-client') { - return validClient; - } - return undefined; - } - }; - - // Mock provider with revocation capability - const mockProviderWithRevocation: OAuthServerProvider = { - clientsStore: mockClientStore, - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - res.redirect('https://example.com/callback?code=mock_auth_code'); - }, - - async challengeForAuthorizationCode(): Promise { - return 'mock_challenge'; - }, - - async exchangeAuthorizationCode(): Promise { - return { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }; - }, - - async exchangeRefreshToken(): Promise { - return { - access_token: 'new_mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'new_mock_refresh_token' - }; - }, - - async verifyAccessToken(token: string): Promise { - if (token === 'valid_token') { - return { - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }; - } - throw new InvalidTokenError('Token is invalid or expired'); - }, - - async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { - // Success - do nothing in mock - } - }; - - // Mock provider without revocation capability - const mockProviderWithoutRevocation: OAuthServerProvider = { - clientsStore: mockClientStore, - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - res.redirect('https://example.com/callback?code=mock_auth_code'); - }, - - async challengeForAuthorizationCode(): Promise { - return 'mock_challenge'; - }, - - async exchangeAuthorizationCode(): Promise { - return { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }; - }, - - async exchangeRefreshToken(): Promise { - return { - access_token: 'new_mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'new_mock_refresh_token' - }; - }, - - async verifyAccessToken(token: string): Promise { - if (token === 'valid_token') { - return { - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }; - } - throw new InvalidTokenError('Token is invalid or expired'); - } - - // No revokeToken method - }; - - describe('Handler creation', () => { - it('throws error if provider does not support token revocation', () => { - const options: RevocationHandlerOptions = { provider: mockProviderWithoutRevocation }; - expect(() => revocationHandler(options)).toThrow('does not support revoking tokens'); - }); - - it('creates handler if provider supports token revocation', () => { - const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation }; - expect(() => revocationHandler(options)).not.toThrow(); - }); - }); - - describe('Request handling', () => { - let app: express.Express; - let spyRevokeToken: MockInstance; - - beforeEach(() => { - // Setup express app with revocation handler - app = express(); - const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation }; - app.use('/revoke', revocationHandler(options)); - - // Spy on the revokeToken method - spyRevokeToken = vi.spyOn(mockProviderWithRevocation, 'revokeToken'); - }); - - afterEach(() => { - spyRevokeToken.mockRestore(); - }); - - it('requires POST method', async () => { - const response = await supertest(app).get('/revoke').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - token: 'token_to_revoke' - }); - - expect(response.status).toBe(405); - expect(response.headers.allow).toBe('POST'); - expect(response.body).toEqual({ - error: 'method_not_allowed', - error_description: 'The method GET is not allowed for this endpoint' - }); - expect(spyRevokeToken).not.toHaveBeenCalled(); - }); - - it('requires token parameter', async () => { - const response = await supertest(app).post('/revoke').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret' - // Missing token - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(spyRevokeToken).not.toHaveBeenCalled(); - }); - - it('authenticates client before revoking token', async () => { - const response = await supertest(app).post('/revoke').type('form').send({ - client_id: 'invalid-client', - client_secret: 'wrong-secret', - token: 'token_to_revoke' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_client'); - expect(spyRevokeToken).not.toHaveBeenCalled(); - }); - - it('successfully revokes token', async () => { - const response = await supertest(app).post('/revoke').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - token: 'token_to_revoke' - }); - - expect(response.status).toBe(200); - expect(response.body).toEqual({}); // Empty response on success - expect(spyRevokeToken).toHaveBeenCalledTimes(1); - expect(spyRevokeToken).toHaveBeenCalledWith(validClient, { - token: 'token_to_revoke' - }); - }); - - it('accepts optional token_type_hint', async () => { - const response = await supertest(app).post('/revoke').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - token: 'token_to_revoke', - token_type_hint: 'refresh_token' - }); - - expect(response.status).toBe(200); - expect(spyRevokeToken).toHaveBeenCalledWith(validClient, { - token: 'token_to_revoke', - token_type_hint: 'refresh_token' - }); - }); - - it('includes CORS headers in response', async () => { - const response = await supertest(app).post('/revoke').type('form').set('Origin', 'https://example.com').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - token: 'token_to_revoke' - }); - - expect(response.header['access-control-allow-origin']).toBe('*'); - }); - }); -}); diff --git a/packages/server/test/server/auth/handlers/token.test.ts b/packages/server/test/server/auth/handlers/token.test.ts deleted file mode 100644 index 02eab891f..000000000 --- a/packages/server/test/server/auth/handlers/token.test.ts +++ /dev/null @@ -1,481 +0,0 @@ -import type { AuthInfo, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; -import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/core'; -import type { Response } from 'express'; -import express from 'express'; -import * as pkceChallenge from 'pkce-challenge'; -import supertest from 'supertest'; -import { type Mock } from 'vitest'; - -import type { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import type { TokenHandlerOptions } from '../../../../src/server/auth/handlers/token.js'; -import { tokenHandler } from '../../../../src/server/auth/handlers/token.js'; -import type { AuthorizationParams, OAuthServerProvider } from '../../../../src/server/auth/provider.js'; -import { ProxyOAuthServerProvider } from '../../../../src/server/auth/providers/proxyProvider.js'; - -// Mock pkce-challenge -vi.mock('pkce-challenge', () => ({ - verifyChallenge: vi.fn().mockImplementation(async (verifier, challenge) => { - return verifier === 'valid_verifier' && challenge === 'mock_challenge'; - }) -})); - -const mockTokens = { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' -}; - -const mockTokensWithIdToken = { - ...mockTokens, - id_token: 'mock_id_token' -}; - -describe('Token Handler', () => { - // Mock client data - const validClient: OAuthClientInformationFull = { - client_id: 'valid-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback'] - }; - - // Mock client store - const mockClientStore: OAuthRegisteredClientsStore = { - async getClient(clientId: string): Promise { - if (clientId === 'valid-client') { - return validClient; - } - return undefined; - } - }; - - // Mock provider - let mockProvider: OAuthServerProvider; - let app: express.Express; - - beforeEach(() => { - // Create fresh mocks for each test - mockProvider = { - clientsStore: mockClientStore, - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - res.redirect('https://example.com/callback?code=mock_auth_code'); - }, - - async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { - if (authorizationCode === 'valid_code') { - return 'mock_challenge'; - } else if (authorizationCode === 'expired_code') { - throw new InvalidGrantError('The authorization code has expired'); - } - throw new InvalidGrantError('The authorization code is invalid'); - }, - - async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { - if (authorizationCode === 'valid_code') { - return mockTokens; - } - throw new InvalidGrantError('The authorization code is invalid or has expired'); - }, - - async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise { - if (refreshToken === 'valid_refresh_token') { - const response: OAuthTokens = { - access_token: 'new_mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'new_mock_refresh_token' - }; - - if (scopes) { - response.scope = scopes.join(' '); - } - - return response; - } - throw new InvalidGrantError('The refresh token is invalid or has expired'); - }, - - async verifyAccessToken(token: string): Promise { - if (token === 'valid_token') { - return { - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }; - } - throw new InvalidTokenError('Token is invalid or expired'); - }, - - async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { - // Do nothing in mock - } - }; - - // Mock PKCE verification - (pkceChallenge.verifyChallenge as Mock).mockImplementation(async (verifier: string, challenge: string) => { - return verifier === 'valid_verifier' && challenge === 'mock_challenge'; - }); - - // Setup express app with token handler - app = express(); - const options: TokenHandlerOptions = { provider: mockProvider }; - app.use('/token', tokenHandler(options)); - }); - - describe('Basic request validation', () => { - it('requires POST method', async () => { - const response = await supertest(app).get('/token').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code' - }); - - expect(response.status).toBe(405); - expect(response.headers.allow).toBe('POST'); - expect(response.body).toEqual({ - error: 'method_not_allowed', - error_description: 'The method GET is not allowed for this endpoint' - }); - }); - - it('requires grant_type parameter', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret' - // Missing grant_type - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - }); - - it('rejects unsupported grant types', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'password' // Unsupported grant type - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('unsupported_grant_type'); - }); - }); - - describe('Client authentication', () => { - it('requires valid client credentials', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'invalid-client', - client_secret: 'wrong-secret', - grant_type: 'authorization_code' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_client'); - }); - - it('accepts valid client credentials', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier' - }); - - expect(response.status).toBe(200); - }); - }); - - describe('Authorization code grant', () => { - it('requires code parameter', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - // Missing code - code_verifier: 'valid_verifier' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - }); - - it('requires code_verifier parameter', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code' - // Missing code_verifier - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - }); - - it('verifies code_verifier against challenge', async () => { - // Setup invalid verifier - (pkceChallenge.verifyChallenge as Mock).mockResolvedValueOnce(false); - - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'invalid_verifier' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_grant'); - expect(response.body.error_description).toContain('code_verifier'); - }); - - it('rejects expired or invalid authorization codes', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'expired_code', - code_verifier: 'valid_verifier' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_grant'); - }); - - it('returns tokens for valid code exchange', async () => { - const mockExchangeCode = vi.spyOn(mockProvider, 'exchangeAuthorizationCode'); - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - resource: 'https://api.example.com/resource', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier' - }); - - expect(response.status).toBe(200); - expect(response.body.access_token).toBe('mock_access_token'); - expect(response.body.token_type).toBe('bearer'); - expect(response.body.expires_in).toBe(3600); - expect(response.body.refresh_token).toBe('mock_refresh_token'); - expect(mockExchangeCode).toHaveBeenCalledWith( - validClient, - 'valid_code', - undefined, // code_verifier is undefined after PKCE validation - undefined, // redirect_uri - new URL('https://api.example.com/resource') // resource parameter - ); - }); - - it('returns id token in code exchange if provided', async () => { - mockProvider.exchangeAuthorizationCode = async ( - client: OAuthClientInformationFull, - authorizationCode: string - ): Promise => { - if (authorizationCode === 'valid_code') { - return mockTokensWithIdToken; - } - throw new InvalidGrantError('The authorization code is invalid or has expired'); - }; - - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier' - }); - - expect(response.status).toBe(200); - expect(response.body.id_token).toBe('mock_id_token'); - }); - - it('passes through code verifier when using proxy provider', async () => { - const originalFetch = global.fetch; - - try { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockTokens) - }); - - const proxyProvider = new ProxyOAuthServerProvider({ - endpoints: { - authorizationUrl: 'https://example.com/authorize', - tokenUrl: 'https://example.com/token' - }, - verifyAccessToken: async token => ({ - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }), - getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined) - }); - - const proxyApp = express(); - const options: TokenHandlerOptions = { provider: proxyProvider }; - proxyApp.use('/token', tokenHandler(options)); - - const response = await supertest(proxyApp).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'any_verifier', - redirect_uri: 'https://example.com/callback' - }); - - expect(response.status).toBe(200); - expect(response.body.access_token).toBe('mock_access_token'); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://example.com/token', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining('code_verifier=any_verifier') - }) - ); - } finally { - global.fetch = originalFetch; - } - }); - - it('passes through redirect_uri when using proxy provider', async () => { - const originalFetch = global.fetch; - - try { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockTokens) - }); - - const proxyProvider = new ProxyOAuthServerProvider({ - endpoints: { - authorizationUrl: 'https://example.com/authorize', - tokenUrl: 'https://example.com/token' - }, - verifyAccessToken: async token => ({ - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }), - getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined) - }); - - const proxyApp = express(); - const options: TokenHandlerOptions = { provider: proxyProvider }; - proxyApp.use('/token', tokenHandler(options)); - - const redirectUri = 'https://example.com/callback'; - const response = await supertest(proxyApp).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'any_verifier', - redirect_uri: redirectUri - }); - - expect(response.status).toBe(200); - expect(response.body.access_token).toBe('mock_access_token'); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://example.com/token', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`) - }) - ); - } finally { - global.fetch = originalFetch; - } - }); - }); - - describe('Refresh token grant', () => { - it('requires refresh_token parameter', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token' - // Missing refresh_token - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - }); - - it('rejects invalid refresh tokens', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'invalid_refresh_token' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_grant'); - }); - - it('returns new tokens for valid refresh token', async () => { - const mockExchangeRefresh = vi.spyOn(mockProvider, 'exchangeRefreshToken'); - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - resource: 'https://api.example.com/resource', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token' - }); - - expect(response.status).toBe(200); - expect(response.body.access_token).toBe('new_mock_access_token'); - expect(response.body.token_type).toBe('bearer'); - expect(response.body.expires_in).toBe(3600); - expect(response.body.refresh_token).toBe('new_mock_refresh_token'); - expect(mockExchangeRefresh).toHaveBeenCalledWith( - validClient, - 'valid_refresh_token', - undefined, // scopes - new URL('https://api.example.com/resource') // resource parameter - ); - }); - - it('respects requested scopes on refresh', async () => { - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token', - scope: 'profile email' - }); - - expect(response.status).toBe(200); - expect(response.body.scope).toBe('profile email'); - }); - }); - - describe('CORS support', () => { - it('includes CORS headers in response', async () => { - const response = await supertest(app).post('/token').type('form').set('Origin', 'https://example.com').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier' - }); - - expect(response.header['access-control-allow-origin']).toBe('*'); - }); - }); -}); diff --git a/packages/server/test/server/auth/middleware/allowedMethods.test.ts b/packages/server/test/server/auth/middleware/allowedMethods.test.ts deleted file mode 100644 index 40e9c3b1f..000000000 --- a/packages/server/test/server/auth/middleware/allowedMethods.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Request, Response } from 'express'; -import express from 'express'; -import request from 'supertest'; - -import { allowedMethods } from '../../../../src/server/auth/middleware/allowedMethods.js'; - -describe('allowedMethods', () => { - let app: express.Express; - - beforeEach(() => { - app = express(); - - // Set up a test router with a GET handler and 405 middleware - const router = express.Router(); - - router.get('/test', (req, res) => { - res.status(200).send('GET success'); - }); - - // Add method not allowed middleware for all other methods - router.all('/test', allowedMethods(['GET'])); - - app.use(router); - }); - - test('allows specified HTTP method', async () => { - const response = await request(app).get('/test'); - expect(response.status).toBe(200); - expect(response.text).toBe('GET success'); - }); - - test('returns 405 for unspecified HTTP methods', async () => { - const methods = ['post', 'put', 'delete', 'patch']; - - for (const method of methods) { - // @ts-expect-error - dynamic method call - const response = await request(app)[method]('/test'); - expect(response.status).toBe(405); - expect(response.body).toEqual({ - error: 'method_not_allowed', - error_description: `The method ${method.toUpperCase()} is not allowed for this endpoint` - }); - } - }); - - test('includes Allow header with specified methods', async () => { - const response = await request(app).post('/test'); - expect(response.headers.allow).toBe('GET'); - }); - - test('works with multiple allowed methods', async () => { - const multiMethodApp = express(); - const router = express.Router(); - - router.get('/multi', (req: Request, res: Response) => { - res.status(200).send('GET'); - }); - router.post('/multi', (req: Request, res: Response) => { - res.status(200).send('POST'); - }); - router.all('/multi', allowedMethods(['GET', 'POST'])); - - multiMethodApp.use(router); - - // Allowed methods should work - const getResponse = await request(multiMethodApp).get('/multi'); - expect(getResponse.status).toBe(200); - - const postResponse = await request(multiMethodApp).post('/multi'); - expect(postResponse.status).toBe(200); - - // Unallowed methods should return 405 - const putResponse = await request(multiMethodApp).put('/multi'); - expect(putResponse.status).toBe(405); - expect(putResponse.headers.allow).toBe('GET, POST'); - }); -}); diff --git a/packages/server/test/server/auth/middleware/bearerAuth.test.ts b/packages/server/test/server/auth/middleware/bearerAuth.test.ts deleted file mode 100644 index 7b464bbff..000000000 --- a/packages/server/test/server/auth/middleware/bearerAuth.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import type { AuthInfo } from '@modelcontextprotocol/core'; -import { CustomOAuthError, InsufficientScopeError, InvalidTokenError, ServerError } from '@modelcontextprotocol/core'; -import { createExpressResponseMock } from '@modelcontextprotocol/test-helpers'; -import type { Request, Response } from 'express'; -import type { Mock } from 'vitest'; - -import { requireBearerAuth } from '../../../../src/server/auth/middleware/bearerAuth.js'; -import type { OAuthTokenVerifier } from '../../../../src/server/auth/provider.js'; - -// Mock verifier -const mockVerifyAccessToken = vi.fn(); -const mockVerifier: OAuthTokenVerifier = { - verifyAccessToken: mockVerifyAccessToken -}; - -describe('requireBearerAuth middleware', () => { - let mockRequest: Partial; - let mockResponse: Partial; - let nextFunction: Mock; - - beforeEach(() => { - mockRequest = { - headers: {} - }; - mockResponse = createExpressResponseMock(); - nextFunction = vi.fn(); - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should call next when token is valid', async () => { - const validAuthInfo: AuthInfo = { - token: 'valid-token', - clientId: 'client-123', - scopes: ['read', 'write'], - expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour - }; - mockVerifyAccessToken.mockResolvedValue(validAuthInfo); - - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); - expect(mockRequest.auth).toEqual(validAuthInfo); - expect(nextFunction).toHaveBeenCalled(); - expect(mockResponse.status).not.toHaveBeenCalled(); - expect(mockResponse.json).not.toHaveBeenCalled(); - }); - - it.each([ - [100], // Token expired 100 seconds ago - [0] // Token expires at the same time as now - ])('should reject expired tokens (expired %s seconds ago)', async (expiredSecondsAgo: number) => { - const expiresAt = Math.floor(Date.now() / 1000) - expiredSecondsAgo; - const expiredAuthInfo: AuthInfo = { - token: 'expired-token', - clientId: 'client-123', - scopes: ['read', 'write'], - expiresAt - }; - mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo); - - mockRequest.headers = { - authorization: 'Bearer expired-token' - }; - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token'); - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'invalid_token', error_description: 'Token has expired' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it.each([ - [undefined], // Token has no expiration time - [NaN] // Token has no expiration time - ])('should reject tokens with no expiration time (expiresAt: %s)', async (expiresAt: number | undefined) => { - const noExpirationAuthInfo: AuthInfo = { - token: 'no-expiration-token', - clientId: 'client-123', - scopes: ['read', 'write'], - expiresAt - }; - mockVerifyAccessToken.mockResolvedValue(noExpirationAuthInfo); - - mockRequest.headers = { - authorization: 'Bearer expired-token' - }; - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token'); - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'invalid_token', error_description: 'Token has no expiration time' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should accept non-expired tokens', async () => { - const nonExpiredAuthInfo: AuthInfo = { - token: 'valid-token', - clientId: 'client-123', - scopes: ['read', 'write'], - expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour - }; - mockVerifyAccessToken.mockResolvedValue(nonExpiredAuthInfo); - - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); - expect(mockRequest.auth).toEqual(nonExpiredAuthInfo); - expect(nextFunction).toHaveBeenCalled(); - expect(mockResponse.status).not.toHaveBeenCalled(); - expect(mockResponse.json).not.toHaveBeenCalled(); - }); - - it('should require specific scopes when configured', async () => { - const authInfo: AuthInfo = { - token: 'valid-token', - clientId: 'client-123', - scopes: ['read'] - }; - mockVerifyAccessToken.mockResolvedValue(authInfo); - - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - const middleware = requireBearerAuth({ - verifier: mockVerifier, - requiredScopes: ['read', 'write'] - }); - - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); - expect(mockResponse.status).toHaveBeenCalledWith(403); - expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"')); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'insufficient_scope', error_description: 'Insufficient scope' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should accept token with all required scopes', async () => { - const authInfo: AuthInfo = { - token: 'valid-token', - clientId: 'client-123', - scopes: ['read', 'write', 'admin'], - expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour - }; - mockVerifyAccessToken.mockResolvedValue(authInfo); - - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - const middleware = requireBearerAuth({ - verifier: mockVerifier, - requiredScopes: ['read', 'write'] - }); - - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); - expect(mockRequest.auth).toEqual(authInfo); - expect(nextFunction).toHaveBeenCalled(); - expect(mockResponse.status).not.toHaveBeenCalled(); - expect(mockResponse.json).not.toHaveBeenCalled(); - }); - - it('should return 401 when no Authorization header is present', async () => { - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).not.toHaveBeenCalled(); - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'invalid_token', error_description: 'Missing Authorization header' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should return 401 when Authorization header format is invalid', async () => { - mockRequest.headers = { - authorization: 'InvalidFormat' - }; - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).not.toHaveBeenCalled(); - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - error: 'invalid_token', - error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'" - }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should return 401 when token verification fails with InvalidTokenError', async () => { - mockRequest.headers = { - authorization: 'Bearer invalid-token' - }; - - mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired')); - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('invalid-token'); - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"')); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'invalid_token', error_description: 'Token expired' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should return 403 when access token has insufficient scopes', async () => { - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: read, write')); - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); - expect(mockResponse.status).toHaveBeenCalledWith(403); - expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"')); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'insufficient_scope', error_description: 'Required scopes: read, write' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should return 500 when a ServerError occurs', async () => { - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue')); - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); - expect(mockResponse.status).toHaveBeenCalledWith(500); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'server_error', error_description: 'Internal server issue' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should return 400 for generic OAuthError', async () => { - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - mockVerifyAccessToken.mockRejectedValue(new CustomOAuthError('custom_error', 'Some OAuth error')); - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); - expect(mockResponse.status).toHaveBeenCalledWith(400); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'custom_error', error_description: 'Some OAuth error' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should return 500 when unexpected error occurs', async () => { - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - mockVerifyAccessToken.mockRejectedValue(new Error('Unexpected error')); - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); - expect(mockResponse.status).toHaveBeenCalledWith(500); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'server_error', error_description: 'Internal Server Error' }) - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - describe('with requiredScopes in WWW-Authenticate header', () => { - it('should include scope in WWW-Authenticate header for 401 responses when requiredScopes is provided', async () => { - mockRequest.headers = {}; - - const middleware = requireBearerAuth({ - verifier: mockVerifier, - requiredScopes: ['read', 'write'] - }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith( - 'WWW-Authenticate', - 'Bearer error="invalid_token", error_description="Missing Authorization header", scope="read write"' - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should include scope in WWW-Authenticate header for 403 insufficient scope responses', async () => { - const authInfo: AuthInfo = { - token: 'valid-token', - clientId: 'client-123', - scopes: ['read'] - }; - mockVerifyAccessToken.mockResolvedValue(authInfo); - - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - const middleware = requireBearerAuth({ - verifier: mockVerifier, - requiredScopes: ['read', 'write'] - }); - - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(403); - expect(mockResponse.set).toHaveBeenCalledWith( - 'WWW-Authenticate', - 'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write"' - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should include both scope and resource_metadata in WWW-Authenticate header when both are provided', async () => { - mockRequest.headers = {}; - - const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource'; - const middleware = requireBearerAuth({ - verifier: mockVerifier, - requiredScopes: ['admin'], - resourceMetadataUrl - }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith( - 'WWW-Authenticate', - `Bearer error="invalid_token", error_description="Missing Authorization header", scope="admin", resource_metadata="${resourceMetadataUrl}"` - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - }); - - describe('with resourceMetadataUrl', () => { - const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource'; - - it('should include resource_metadata in WWW-Authenticate header for 401 responses', async () => { - mockRequest.headers = {}; - - const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith( - 'WWW-Authenticate', - `Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"` - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should include resource_metadata in WWW-Authenticate header when token verification fails', async () => { - mockRequest.headers = { - authorization: 'Bearer invalid-token' - }; - - mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired')); - - const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith( - 'WWW-Authenticate', - `Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"` - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should include resource_metadata in WWW-Authenticate header for insufficient scope errors', async () => { - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: admin')); - - const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(403); - expect(mockResponse.set).toHaveBeenCalledWith( - 'WWW-Authenticate', - `Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"` - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should include resource_metadata when token is expired', async () => { - const expiredAuthInfo: AuthInfo = { - token: 'expired-token', - clientId: 'client-123', - scopes: ['read', 'write'], - expiresAt: Math.floor(Date.now() / 1000) - 100 - }; - mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo); - - mockRequest.headers = { - authorization: 'Bearer expired-token' - }; - - const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.set).toHaveBeenCalledWith( - 'WWW-Authenticate', - `Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"` - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should include resource_metadata when scope check fails', async () => { - const authInfo: AuthInfo = { - token: 'valid-token', - clientId: 'client-123', - scopes: ['read'] - }; - mockVerifyAccessToken.mockResolvedValue(authInfo); - - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - const middleware = requireBearerAuth({ - verifier: mockVerifier, - requiredScopes: ['read', 'write'], - resourceMetadataUrl - }); - - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(403); - expect(mockResponse.set).toHaveBeenCalledWith( - 'WWW-Authenticate', - `Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write", resource_metadata="${resourceMetadataUrl}"` - ); - expect(nextFunction).not.toHaveBeenCalled(); - }); - - it('should not affect server errors (no WWW-Authenticate header)', async () => { - mockRequest.headers = { - authorization: 'Bearer valid-token' - }; - - mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue')); - - const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockResponse.status).toHaveBeenCalledWith(500); - expect(mockResponse.set).not.toHaveBeenCalledWith('WWW-Authenticate', expect.anything()); - expect(nextFunction).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/server/test/server/auth/middleware/clientAuth.test.ts b/packages/server/test/server/auth/middleware/clientAuth.test.ts deleted file mode 100644 index 55a00f0c2..000000000 --- a/packages/server/test/server/auth/middleware/clientAuth.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { OAuthClientInformationFull } from '@modelcontextprotocol/core'; -import express from 'express'; -import supertest from 'supertest'; - -import type { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import type { ClientAuthenticationMiddlewareOptions } from '../../../../src/server/auth/middleware/clientAuth.js'; -import { authenticateClient } from '../../../../src/server/auth/middleware/clientAuth.js'; - -describe('clientAuth middleware', () => { - // Mock client store - const mockClientStore: OAuthRegisteredClientsStore = { - async getClient(clientId: string): Promise { - if (clientId === 'valid-client') { - return { - client_id: 'valid-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback'] - }; - } else if (clientId === 'expired-client') { - // Client with no secret - return { - client_id: 'expired-client', - redirect_uris: ['https://example.com/callback'] - }; - } else if (clientId === 'client-with-expired-secret') { - // Client with an expired secret - return { - client_id: 'client-with-expired-secret', - client_secret: 'expired-secret', - client_secret_expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago - redirect_uris: ['https://example.com/callback'] - }; - } - return undefined; - } - }; - - // Setup Express app with middleware - let app: express.Express; - let options: ClientAuthenticationMiddlewareOptions; - - beforeEach(() => { - app = express(); - app.use(express.json()); - - options = { - clientsStore: mockClientStore - }; - - // Setup route with client auth - app.post('/protected', authenticateClient(options), (req, res) => { - res.status(200).json({ success: true, client: req.client }); - }); - }); - - it('authenticates valid client credentials', async () => { - const response = await supertest(app).post('/protected').send({ - client_id: 'valid-client', - client_secret: 'valid-secret' - }); - - expect(response.status).toBe(200); - expect(response.body.success).toBe(true); - expect(response.body.client.client_id).toBe('valid-client'); - }); - - it('rejects invalid client_id', async () => { - const response = await supertest(app).post('/protected').send({ - client_id: 'non-existent-client', - client_secret: 'some-secret' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_client'); - expect(response.body.error_description).toBe('Invalid client_id'); - }); - - it('rejects invalid client_secret', async () => { - const response = await supertest(app).post('/protected').send({ - client_id: 'valid-client', - client_secret: 'wrong-secret' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_client'); - expect(response.body.error_description).toBe('Invalid client_secret'); - }); - - it('rejects missing client_id', async () => { - const response = await supertest(app).post('/protected').send({ - client_secret: 'valid-secret' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - }); - - it('allows missing client_secret if client has none', async () => { - const response = await supertest(app).post('/protected').send({ - client_id: 'expired-client' - }); - - // Since the client has no secret, this should pass without providing one - expect(response.status).toBe(200); - }); - - it('rejects request when client secret has expired', async () => { - const response = await supertest(app).post('/protected').send({ - client_id: 'client-with-expired-secret', - client_secret: 'expired-secret' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_client'); - expect(response.body.error_description).toBe('Client secret has expired'); - }); - - it('handles malformed request body', async () => { - const response = await supertest(app).post('/protected').send('not-json-format'); - - expect(response.status).toBe(400); - }); - - // Testing request with extra fields to ensure they're ignored - it('ignores extra fields in request', async () => { - const response = await supertest(app).post('/protected').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - extra_field: 'should be ignored' - }); - - expect(response.status).toBe(200); - }); -}); diff --git a/packages/server/test/server/auth/providers/proxyProvider.test.ts b/packages/server/test/server/auth/providers/proxyProvider.test.ts deleted file mode 100644 index 375179e5b..000000000 --- a/packages/server/test/server/auth/providers/proxyProvider.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import type { AuthInfo, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/core'; -import { InsufficientScopeError, InvalidTokenError, ServerError } from '@modelcontextprotocol/core'; -import type { Response } from 'express'; -import { type Mock } from 'vitest'; - -import type { ProxyOptions } from '../../../../src/server/auth/providers/proxyProvider.js'; -import { ProxyOAuthServerProvider } from '../../../../src/server/auth/providers/proxyProvider.js'; - -describe('Proxy OAuth Server Provider', () => { - // Mock client data - const validClient: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback'] - }; - - // Mock response object - const mockResponse = { - redirect: vi.fn() - } as unknown as Response; - - // Mock provider functions - const mockVerifyToken = vi.fn(); - const mockGetClient = vi.fn(); - - // Base provider options - const baseOptions: ProxyOptions = { - endpoints: { - authorizationUrl: 'https://auth.example.com/authorize', - tokenUrl: 'https://auth.example.com/token', - revocationUrl: 'https://auth.example.com/revoke', - registrationUrl: 'https://auth.example.com/register' - }, - verifyAccessToken: mockVerifyToken, - getClient: mockGetClient - }; - - let provider: ProxyOAuthServerProvider; - let originalFetch: typeof global.fetch; - - beforeEach(() => { - provider = new ProxyOAuthServerProvider(baseOptions); - originalFetch = global.fetch; - global.fetch = vi.fn(); - - // Setup mock implementations - mockVerifyToken.mockImplementation(async (token: string) => { - if (token === 'valid-token') { - return { - token, - clientId: 'test-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - } as AuthInfo; - } - throw new InvalidTokenError('Invalid token'); - }); - - mockGetClient.mockImplementation(async (clientId: string) => { - if (clientId === 'test-client') { - return validClient; - } - return undefined; - }); - }); - - // Add helper function for failed responses - const mockFailedResponse = () => { - (global.fetch as Mock).mockImplementation(() => - Promise.resolve({ - ok: false, - status: 400 - }) - ); - }; - - afterEach(() => { - global.fetch = originalFetch; - vi.clearAllMocks(); - }); - - describe('authorization', () => { - it('redirects to authorization endpoint with correct parameters', async () => { - await provider.authorize( - validClient, - { - redirectUri: 'https://example.com/callback', - codeChallenge: 'test-challenge', - state: 'test-state', - scopes: ['read', 'write'], - resource: new URL('https://api.example.com/resource') - }, - mockResponse - ); - - const expectedUrl = new URL('https://auth.example.com/authorize'); - expectedUrl.searchParams.set('client_id', 'test-client'); - expectedUrl.searchParams.set('response_type', 'code'); - expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback'); - expectedUrl.searchParams.set('code_challenge', 'test-challenge'); - expectedUrl.searchParams.set('code_challenge_method', 'S256'); - expectedUrl.searchParams.set('state', 'test-state'); - expectedUrl.searchParams.set('scope', 'read write'); - expectedUrl.searchParams.set('resource', 'https://api.example.com/resource'); - - expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); - }); - }); - - describe('token exchange', () => { - const mockTokenResponse: OAuthTokens = { - access_token: 'new-access-token', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'new-refresh-token' - }; - - beforeEach(() => { - (global.fetch as Mock).mockImplementation(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockTokenResponse) - }) - ); - }); - - it('exchanges authorization code for tokens', async () => { - const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier'); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://auth.example.com/token', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining('grant_type=authorization_code') - }) - ); - expect(tokens).toEqual(mockTokenResponse); - }); - - it('includes redirect_uri in token request when provided', async () => { - const redirectUri = 'https://example.com/callback'; - const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier', redirectUri); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://auth.example.com/token', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`) - }) - ); - expect(tokens).toEqual(mockTokenResponse); - }); - - it('includes resource parameter in authorization code exchange', async () => { - const tokens = await provider.exchangeAuthorizationCode( - validClient, - 'test-code', - 'test-verifier', - 'https://example.com/callback', - new URL('https://api.example.com/resource') - ); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://auth.example.com/token', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) - }) - ); - expect(tokens).toEqual(mockTokenResponse); - }); - - it('handles authorization code exchange without resource parameter', async () => { - const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier'); - - const fetchCall = (global.fetch as Mock).mock.calls[0]; - const body = fetchCall![1].body as string; - expect(body).not.toContain('resource='); - expect(tokens).toEqual(mockTokenResponse); - }); - - it('exchanges refresh token for new tokens', async () => { - const tokens = await provider.exchangeRefreshToken(validClient, 'test-refresh-token', ['read', 'write']); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://auth.example.com/token', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining('grant_type=refresh_token') - }) - ); - expect(tokens).toEqual(mockTokenResponse); - }); - - it('includes resource parameter in refresh token exchange', async () => { - const tokens = await provider.exchangeRefreshToken( - validClient, - 'test-refresh-token', - ['read', 'write'], - new URL('https://api.example.com/resource') - ); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://auth.example.com/token', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) - }) - ); - expect(tokens).toEqual(mockTokenResponse); - }); - }); - - describe('client registration', () => { - it('registers new client', async () => { - const newClient: OAuthClientInformationFull = { - client_id: 'new-client', - redirect_uris: ['https://new-client.com/callback'] - }; - - (global.fetch as Mock).mockImplementation(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(newClient) - }) - ); - - const result = await provider.clientsStore.registerClient!(newClient); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://auth.example.com/register', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(newClient) - }) - ); - expect(result).toEqual(newClient); - }); - - it('handles registration failure', async () => { - mockFailedResponse(); - const newClient: OAuthClientInformationFull = { - client_id: 'new-client', - redirect_uris: ['https://new-client.com/callback'] - }; - - await expect(provider.clientsStore.registerClient!(newClient)).rejects.toThrow(ServerError); - }); - }); - - describe('token revocation', () => { - it('revokes token', async () => { - (global.fetch as Mock).mockImplementation(() => - Promise.resolve({ - ok: true - }) - ); - - await provider.revokeToken!(validClient, { - token: 'token-to-revoke', - token_type_hint: 'access_token' - }); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://auth.example.com/revoke', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining('token=token-to-revoke') - }) - ); - }); - - it('handles revocation failure', async () => { - mockFailedResponse(); - await expect( - provider.revokeToken!(validClient, { - token: 'invalid-token' - }) - ).rejects.toThrow(ServerError); - }); - }); - - describe('token verification', () => { - it('verifies valid token', async () => { - const validAuthInfo: AuthInfo = { - token: 'valid-token', - clientId: 'test-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }; - mockVerifyToken.mockResolvedValue(validAuthInfo); - - const authInfo = await provider.verifyAccessToken('valid-token'); - expect(authInfo).toEqual(validAuthInfo); - expect(mockVerifyToken).toHaveBeenCalledWith('valid-token'); - }); - - it('passes through InvalidTokenError', async () => { - const error = new InvalidTokenError('Token expired'); - mockVerifyToken.mockRejectedValue(error); - - await expect(provider.verifyAccessToken('invalid-token')).rejects.toBe(error); - expect(mockVerifyToken).toHaveBeenCalledWith('invalid-token'); - }); - - it('passes through InsufficientScopeError', async () => { - const error = new InsufficientScopeError('Required scopes: read, write'); - mockVerifyToken.mockRejectedValue(error); - - await expect(provider.verifyAccessToken('token-with-insufficient-scope')).rejects.toBe(error); - expect(mockVerifyToken).toHaveBeenCalledWith('token-with-insufficient-scope'); - }); - - it('passes through unexpected errors', async () => { - const error = new Error('Unexpected error'); - mockVerifyToken.mockRejectedValue(error); - - await expect(provider.verifyAccessToken('valid-token')).rejects.toBe(error); - expect(mockVerifyToken).toHaveBeenCalledWith('valid-token'); - }); - }); -}); diff --git a/packages/server/test/server/auth/router.test.ts b/packages/server/test/server/auth/router.test.ts deleted file mode 100644 index 250fca4c4..000000000 --- a/packages/server/test/server/auth/router.test.ts +++ /dev/null @@ -1,466 +0,0 @@ -import type { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core'; -import type { AuthInfo } from '@modelcontextprotocol/core'; -import { InvalidTokenError } from '@modelcontextprotocol/core'; -import type { Response } from 'express'; -import express from 'express'; -import supertest from 'supertest'; - -import type { OAuthRegisteredClientsStore } from '../../../src/server/auth/clients.js'; -import type { AuthorizationParams, OAuthServerProvider } from '../../../src/server/auth/provider.js'; -import type { AuthMetadataOptions, AuthRouterOptions } from '../../../src/server/auth/router.js'; -import { mcpAuthMetadataRouter, mcpAuthRouter } from '../../../src/server/auth/router.js'; - -describe('MCP Auth Router', () => { - // Setup mock provider with full capabilities - const mockClientStore: OAuthRegisteredClientsStore = { - async getClient(clientId: string): Promise { - if (clientId === 'valid-client') { - return { - client_id: 'valid-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback'] - }; - } - return undefined; - }, - - async registerClient(client: OAuthClientInformationFull): Promise { - return client; - } - }; - - const mockProvider: OAuthServerProvider = { - clientsStore: mockClientStore, - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - const redirectUrl = new URL(params.redirectUri); - redirectUrl.searchParams.set('code', 'mock_auth_code'); - if (params.state) { - redirectUrl.searchParams.set('state', params.state); - } - res.redirect(302, redirectUrl.toString()); - }, - - async challengeForAuthorizationCode(): Promise { - return 'mock_challenge'; - }, - - async exchangeAuthorizationCode(): Promise { - return { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }; - }, - - async exchangeRefreshToken(): Promise { - return { - access_token: 'new_mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'new_mock_refresh_token' - }; - }, - - async verifyAccessToken(token: string): Promise { - if (token === 'valid_token') { - return { - token, - clientId: 'valid-client', - scopes: ['read', 'write'], - expiresAt: Date.now() / 1000 + 3600 - }; - } - throw new InvalidTokenError('Token is invalid or expired'); - }, - - async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { - // Success - do nothing in mock - } - }; - - // Provider without registration and revocation - const mockProviderMinimal: OAuthServerProvider = { - clientsStore: { - async getClient(clientId: string): Promise { - if (clientId === 'valid-client') { - return { - client_id: 'valid-client', - client_secret: 'valid-secret', - redirect_uris: ['https://example.com/callback'] - }; - } - return undefined; - } - }, - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - const redirectUrl = new URL(params.redirectUri); - redirectUrl.searchParams.set('code', 'mock_auth_code'); - if (params.state) { - redirectUrl.searchParams.set('state', params.state); - } - res.redirect(302, redirectUrl.toString()); - }, - - async challengeForAuthorizationCode(): Promise { - return 'mock_challenge'; - }, - - async exchangeAuthorizationCode(): Promise { - return { - access_token: 'mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'mock_refresh_token' - }; - }, - - async exchangeRefreshToken(): Promise { - return { - access_token: 'new_mock_access_token', - token_type: 'bearer', - expires_in: 3600, - refresh_token: 'new_mock_refresh_token' - }; - }, - - async verifyAccessToken(token: string): Promise { - if (token === 'valid_token') { - return { - token, - clientId: 'valid-client', - scopes: ['read'], - expiresAt: Date.now() / 1000 + 3600 - }; - } - throw new InvalidTokenError('Token is invalid or expired'); - } - }; - - describe('Router creation', () => { - it('throws error for non-HTTPS issuer URL', () => { - const options: AuthRouterOptions = { - provider: mockProvider, - issuerUrl: new URL('http://auth.example.com') - }; - - expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must be HTTPS'); - }); - - it('allows localhost HTTP for development', () => { - const options: AuthRouterOptions = { - provider: mockProvider, - issuerUrl: new URL('http://localhost:3000') - }; - - expect(() => mcpAuthRouter(options)).not.toThrow(); - }); - - it('throws error for issuer URL with fragment', () => { - const options: AuthRouterOptions = { - provider: mockProvider, - issuerUrl: new URL('https://auth.example.com#fragment') - }; - - expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must not have a fragment'); - }); - - it('throws error for issuer URL with query string', () => { - const options: AuthRouterOptions = { - provider: mockProvider, - issuerUrl: new URL('https://auth.example.com?param=value') - }; - - expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must not have a query string'); - }); - - it('successfully creates router with valid options', () => { - const options: AuthRouterOptions = { - provider: mockProvider, - issuerUrl: new URL('https://auth.example.com') - }; - - expect(() => mcpAuthRouter(options)).not.toThrow(); - }); - }); - - describe('Metadata endpoint', () => { - let app: express.Express; - - beforeEach(() => { - // Setup full-featured router - app = express(); - const options: AuthRouterOptions = { - provider: mockProvider, - issuerUrl: new URL('https://auth.example.com'), - serviceDocumentationUrl: new URL('https://docs.example.com') - }; - app.use(mcpAuthRouter(options)); - }); - - it('returns complete metadata for full-featured router', async () => { - const response = await supertest(app).get('/.well-known/oauth-authorization-server'); - - expect(response.status).toBe(200); - - // Verify essential fields - expect(response.body.issuer).toBe('https://auth.example.com/'); - expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); - expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); - expect(response.body.registration_endpoint).toBe('https://auth.example.com/register'); - expect(response.body.revocation_endpoint).toBe('https://auth.example.com/revoke'); - - // Verify supported features - expect(response.body.response_types_supported).toEqual(['code']); - expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); - expect(response.body.code_challenge_methods_supported).toEqual(['S256']); - expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post', 'none']); - expect(response.body.revocation_endpoint_auth_methods_supported).toEqual(['client_secret_post']); - - // Verify optional fields - expect(response.body.service_documentation).toBe('https://docs.example.com/'); - }); - - it('returns minimal metadata for minimal router', async () => { - // Setup minimal router - const minimalApp = express(); - const options: AuthRouterOptions = { - provider: mockProviderMinimal, - issuerUrl: new URL('https://auth.example.com') - }; - minimalApp.use(mcpAuthRouter(options)); - - const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server'); - - expect(response.status).toBe(200); - - // Verify essential endpoints - expect(response.body.issuer).toBe('https://auth.example.com/'); - expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); - expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); - - // Verify missing optional endpoints - expect(response.body.registration_endpoint).toBeUndefined(); - expect(response.body.revocation_endpoint).toBeUndefined(); - expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined(); - expect(response.body.service_documentation).toBeUndefined(); - }); - - it('provides protected resource metadata', async () => { - // Setup router with draft protocol version - const draftApp = express(); - const options: AuthRouterOptions = { - provider: mockProvider, - issuerUrl: new URL('https://mcp.example.com'), - scopesSupported: ['read', 'write'], - resourceName: 'Test API' - }; - draftApp.use(mcpAuthRouter(options)); - - const response = await supertest(draftApp).get('/.well-known/oauth-protected-resource'); - - expect(response.status).toBe(200); - - // Verify protected resource metadata - expect(response.body.resource).toBe('https://mcp.example.com/'); - expect(response.body.authorization_servers).toContain('https://mcp.example.com/'); - expect(response.body.scopes_supported).toEqual(['read', 'write']); - expect(response.body.resource_name).toBe('Test API'); - }); - }); - - describe('Endpoint routing', () => { - let app: express.Express; - - beforeEach(() => { - // Setup full-featured router - app = express(); - const options: AuthRouterOptions = { - provider: mockProvider, - issuerUrl: new URL('https://auth.example.com') - }; - app.use(mcpAuthRouter(options)); - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('routes to authorization endpoint', async () => { - const response = await supertest(app).get('/authorize').query({ - client_id: 'valid-client', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location!); - expect(location.searchParams.has('code')).toBe(true); - }); - - it('routes to token endpoint', async () => { - // Setup verifyChallenge mock for token handler - vi.mock('pkce-challenge', () => ({ - verifyChallenge: vi.fn().mockResolvedValue(true) - })); - - const response = await supertest(app).post('/token').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier' - }); - - // The request will fail in testing due to mocking limitations, - // but we can verify the route was matched - expect(response.status).not.toBe(404); - }); - - it('routes to registration endpoint', async () => { - const response = await supertest(app) - .post('/register') - .send({ - redirect_uris: ['https://example.com/callback'] - }); - - // The request will fail in testing due to mocking limitations, - // but we can verify the route was matched - expect(response.status).not.toBe(404); - }); - - it('routes to revocation endpoint', async () => { - const response = await supertest(app).post('/revoke').type('form').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - token: 'token_to_revoke' - }); - - // The request will fail in testing due to mocking limitations, - // but we can verify the route was matched - expect(response.status).not.toBe(404); - }); - - it('excludes endpoints for unsupported features', async () => { - // Setup minimal router - const minimalApp = express(); - const options: AuthRouterOptions = { - provider: mockProviderMinimal, - issuerUrl: new URL('https://auth.example.com') - }; - minimalApp.use(mcpAuthRouter(options)); - - // Registration should not be available - const regResponse = await supertest(minimalApp) - .post('/register') - .send({ - redirect_uris: ['https://example.com/callback'] - }); - expect(regResponse.status).toBe(404); - - // Revocation should not be available - const revokeResponse = await supertest(minimalApp).post('/revoke').send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - token: 'token_to_revoke' - }); - expect(revokeResponse.status).toBe(404); - }); - }); -}); - -describe('MCP Auth Metadata Router', () => { - const mockOAuthMetadata: OAuthMetadata = { - issuer: 'https://auth.example.com/', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - response_types_supported: ['code'], - grant_types_supported: ['authorization_code', 'refresh_token'], - code_challenge_methods_supported: ['S256'], - token_endpoint_auth_methods_supported: ['client_secret_post'] - }; - - describe('Router creation', () => { - it('successfully creates router with valid options', () => { - const options: AuthMetadataOptions = { - oauthMetadata: mockOAuthMetadata, - resourceServerUrl: new URL('https://api.example.com') - }; - - expect(() => mcpAuthMetadataRouter(options)).not.toThrow(); - }); - }); - - describe('Metadata endpoints', () => { - let app: express.Express; - - beforeEach(() => { - app = express(); - const options: AuthMetadataOptions = { - oauthMetadata: mockOAuthMetadata, - resourceServerUrl: new URL('https://api.example.com'), - serviceDocumentationUrl: new URL('https://docs.example.com'), - scopesSupported: ['read', 'write'], - resourceName: 'Test API' - }; - app.use(mcpAuthMetadataRouter(options)); - }); - - it('returns OAuth authorization server metadata', async () => { - const response = await supertest(app).get('/.well-known/oauth-authorization-server'); - - expect(response.status).toBe(200); - - // Verify metadata points to authorization server - expect(response.body.issuer).toBe('https://auth.example.com/'); - expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); - expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); - expect(response.body.response_types_supported).toEqual(['code']); - expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); - expect(response.body.code_challenge_methods_supported).toEqual(['S256']); - expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']); - }); - - it('returns OAuth protected resource metadata', async () => { - const response = await supertest(app).get('/.well-known/oauth-protected-resource'); - - expect(response.status).toBe(200); - - // Verify protected resource metadata - expect(response.body.resource).toBe('https://api.example.com/'); - expect(response.body.authorization_servers).toEqual(['https://auth.example.com/']); - expect(response.body.scopes_supported).toEqual(['read', 'write']); - expect(response.body.resource_name).toBe('Test API'); - expect(response.body.resource_documentation).toBe('https://docs.example.com/'); - }); - - it('works with minimal configuration', async () => { - const minimalApp = express(); - const options: AuthMetadataOptions = { - oauthMetadata: mockOAuthMetadata, - resourceServerUrl: new URL('https://api.example.com') - }; - minimalApp.use(mcpAuthMetadataRouter(options)); - - const authResponse = await supertest(minimalApp).get('/.well-known/oauth-authorization-server'); - - expect(authResponse.status).toBe(200); - expect(authResponse.body.issuer).toBe('https://auth.example.com/'); - expect(authResponse.body.service_documentation).toBeUndefined(); - expect(authResponse.body.scopes_supported).toBeUndefined(); - - const resourceResponse = await supertest(minimalApp).get('/.well-known/oauth-protected-resource'); - - expect(resourceResponse.status).toBe(200); - expect(resourceResponse.body.resource).toBe('https://api.example.com/'); - expect(resourceResponse.body.authorization_servers).toEqual(['https://auth.example.com/']); - expect(resourceResponse.body.scopes_supported).toBeUndefined(); - expect(resourceResponse.body.resource_name).toBeUndefined(); - expect(resourceResponse.body.resource_documentation).toBeUndefined(); - }); - }); -}); diff --git a/packages/server/test/server/sse.test.ts b/packages/server/test/server/sse.test.ts deleted file mode 100644 index 0fc9eebc8..000000000 --- a/packages/server/test/server/sse.test.ts +++ /dev/null @@ -1,733 +0,0 @@ -import type http from 'node:http'; -import { createServer, type Server } from 'node:http'; - -import type { CallToolResult, JSONRPCMessage } from '@modelcontextprotocol/core'; -import type { ZodMatrixEntry } from '@modelcontextprotocol/test-helpers'; -import { listenOnRandomPort, zodTestMatrix } from '@modelcontextprotocol/test-helpers'; -import { type Mocked } from 'vitest'; - -import { McpServer } from '../../src/server/mcp.js'; -import { SSEServerTransport } from '../../src/server/sse.js'; - -const createMockResponse = () => { - const res = { - writeHead: vi.fn().mockReturnThis(), - write: vi.fn().mockReturnThis(), - on: vi.fn().mockReturnThis(), - end: vi.fn().mockReturnThis() - }; - - return res as unknown as Mocked; -}; - -const createMockRequest = ({ headers = {}, body }: { headers?: Record; body?: string } = {}) => { - const mockReq = { - headers, - body: body ? body : undefined, - auth: { - token: 'test-token' - }, - on: vi.fn().mockImplementation((event, listener) => { - const mockListener = listener as unknown as (...args: unknown[]) => void; - if (event === 'data') { - mockListener(Buffer.from(body || '') as unknown as Error); - } - if (event === 'error') { - mockListener(new Error('test')); - } - if (event === 'end') { - mockListener(); - } - if (event === 'close') { - setTimeout(listener, 100); - } - return mockReq; - }), - listeners: vi.fn(), - removeListener: vi.fn() - } as unknown as http.IncomingMessage; - - return mockReq; -}; - -async function readAllSSEEvents(response: Response): Promise { - const reader = response.body?.getReader(); - if (!reader) throw new Error('No readable stream'); - - const events: string[] = []; - const decoder = new TextDecoder(); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - if (value) { - events.push(decoder.decode(value)); - } - } - } finally { - reader.releaseLock(); - } - - return events; -} - -/** - * Helper to send JSON-RPC request - */ -async function sendSsePostRequest( - baseUrl: URL, - message: JSONRPCMessage | JSONRPCMessage[], - sessionId?: string, - extraHeaders?: Record -): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - ...extraHeaders - }; - - if (sessionId) { - baseUrl.searchParams.set('sessionId', sessionId); - } - - return fetch(baseUrl, { - method: 'POST', - headers, - body: JSON.stringify(message) - }); -} - -describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { - const { z } = entry; - - /** - * Helper to create and start test HTTP server with MCP setup - */ - async function createTestServerWithSse(args: { mockRes: http.ServerResponse }): Promise<{ - server: Server; - transport: SSEServerTransport; - mcpServer: McpServer; - baseUrl: URL; - sessionId: string; - serverPort: number; - }> { - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - - mcpServer.tool( - 'greet', - 'A simple greeting tool', - { name: z.string().describe('Name to greet') }, - async ({ name }): Promise => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; - } - ); - - const endpoint = '/messages'; - - const transport = new SSEServerTransport(endpoint, args.mockRes); - const sessionId = transport.sessionId; - - await mcpServer.connect(transport); - - const server = createServer(async (req, res) => { - try { - await transport.handlePostMessage(req, res); - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }); - - const baseUrl = await listenOnRandomPort(server); - - const addr = server.address(); - const port = typeof addr === 'string' ? new URL(baseUrl).port : (addr as unknown as { port: number }).port; - - return { server, transport, mcpServer, baseUrl, sessionId, serverPort: Number(port) }; - } - - describe('SSEServerTransport', () => { - async function initializeServer(baseUrl: URL): Promise { - const response = await sendSsePostRequest(baseUrl, { - jsonrpc: '2.0', - method: 'initialize', - params: { - clientInfo: { name: 'test-client', version: '1.0' }, - protocolVersion: '2025-03-26', - capabilities: {} - }, - - id: 'init-1' - } as JSONRPCMessage); - - expect(response.status).toBe(202); - - const text = await readAllSSEEvents(response); - - expect(text).toHaveLength(1); - expect(text[0]).toBe('Accepted'); - } - - describe('start method', () => { - it('should correctly append sessionId to a simple relative endpoint', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; - - await transport.start(); - - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}\n\n`); - }); - - it('should correctly append sessionId to an endpoint with existing query parameters', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages?foo=bar&baz=qux'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; - - await transport.start(); - - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith( - `event: endpoint\ndata: /messages?foo=bar&baz=qux&sessionId=${expectedSessionId}\n\n` - ); - }); - - it('should correctly append sessionId to an endpoint with a hash fragment', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages#section1'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; - - await transport.start(); - - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}#section1\n\n`); - }); - - it('should correctly append sessionId to an endpoint with query parameters and a hash fragment', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages?key=value#section2'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; - - await transport.start(); - - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith( - `event: endpoint\ndata: /messages?key=value&sessionId=${expectedSessionId}#section2\n\n` - ); - }); - - it('should correctly handle the root path endpoint "/"', async () => { - const mockRes = createMockResponse(); - const endpoint = '/'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; - - await transport.start(); - - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`); - }); - - it('should correctly handle an empty string endpoint ""', async () => { - const mockRes = createMockResponse(); - const endpoint = ''; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; - - await transport.start(); - - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`); - }); - - /** - * Test: Tool With Request Info - */ - it('should pass request info to tool callback', async () => { - const mockRes = createMockResponse(); - const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes }); - await initializeServer(baseUrl); - - mcpServer.tool( - 'test-request-info', - 'A simple test tool with request info', - { name: z.string().describe('Name to greet') }, - async ({ name }, { requestInfo }): Promise => { - return { - content: [ - { type: 'text', text: `Hello, ${name}!` }, - { type: 'text', text: `${JSON.stringify(requestInfo)}` } - ] - }; - } - ); - - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'test-request-info', - arguments: { - name: 'Test User' - } - }, - id: 'call-1' - }; - - const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); - - expect(response.status).toBe(202); - - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`); - - const expectedMessage = { - result: { - content: [ - { - type: 'text', - text: 'Hello, Test User!' - }, - { - type: 'text', - text: JSON.stringify({ - headers: { - host: `127.0.0.1:${serverPort}`, - connection: 'keep-alive', - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - 'accept-language': '*', - 'sec-fetch-mode': 'cors', - 'user-agent': 'node', - 'accept-encoding': 'gzip, deflate', - 'content-length': '124' - } - }) - } - ] - }, - jsonrpc: '2.0', - id: 'call-1' - }; - expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`); - }); - }); - - describe('handlePostMessage method', () => { - it('should return 500 if server has not started', async () => { - const mockReq = createMockRequest(); - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - - const error = 'SSE connection not established'; - await expect(transport.handlePostMessage(mockReq, mockRes)).rejects.toThrow(error); - expect(mockRes.writeHead).toHaveBeenCalledWith(500); - expect(mockRes.end).toHaveBeenCalledWith(error); - }); - - it('should return 400 if content-type is not application/json', async () => { - const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } }); - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - - transport.onerror = vi.fn(); - const error = 'Unsupported content-type: text/plain'; - await expect(transport.handlePostMessage(mockReq, mockRes)).resolves.toBe(undefined); - expect(mockRes.writeHead).toHaveBeenCalledWith(400); - expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error)); - expect(transport.onerror).toHaveBeenCalledWith(new Error(error)); - }); - - it('should return 400 if message has not a valid schema', async () => { - const invalidMessage = JSON.stringify({ - // missing jsonrpc field - method: 'call', - params: [1, 2, 3], - id: 1 - }); - const mockReq = createMockRequest({ - headers: { 'content-type': 'application/json' }, - body: invalidMessage - }); - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - - transport.onmessage = vi.fn(); - await transport.handlePostMessage(mockReq, mockRes); - expect(mockRes.writeHead).toHaveBeenCalledWith(400); - expect(transport.onmessage).not.toHaveBeenCalled(); - expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`); - }); - - it('should return 202 if message has a valid schema', async () => { - const validMessage = JSON.stringify({ - jsonrpc: '2.0', - method: 'call', - params: { - a: 1, - b: 2, - c: 3 - }, - id: 1 - }); - const mockReq = createMockRequest({ - headers: { 'content-type': 'application/json' }, - body: validMessage - }); - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - - transport.onmessage = vi.fn(); - await transport.handlePostMessage(mockReq, mockRes); - expect(mockRes.writeHead).toHaveBeenCalledWith(202); - expect(mockRes.end).toHaveBeenCalledWith('Accepted'); - expect(transport.onmessage).toHaveBeenCalledWith( - { - jsonrpc: '2.0', - method: 'call', - params: { - a: 1, - b: 2, - c: 3 - }, - id: 1 - }, - { - authInfo: { - token: 'test-token' - }, - requestInfo: { - headers: { - 'content-type': 'application/json' - } - } - } - ); - }); - }); - - describe('close method', () => { - it('should call onclose', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - transport.onclose = vi.fn(); - await transport.close(); - expect(transport.onclose).toHaveBeenCalled(); - }); - }); - - describe('send method', () => { - it('should call onsend', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining('event: endpoint')); - expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`)); - }); - }); - - describe('DNS rebinding protection', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('Host header validation', () => { - it('should accept requests with allowed host headers', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000', 'example.com'], - enableDnsRebindingProtection: true - }); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - host: 'localhost:3000', - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); - - it('should reject requests with disallowed host headers', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000'], - enableDnsRebindingProtection: true - }); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - host: 'evil.com', - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); - }); - - it('should reject requests without host header when allowedHosts is configured', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000'], - enableDnsRebindingProtection: true - }); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); - }); - }); - - describe('Origin header validation', () => { - it('should accept requests with allowed origin headers', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedOrigins: ['http://localhost:3000', 'https://example.com'], - enableDnsRebindingProtection: true - }); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - origin: 'http://localhost:3000', - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); - - it('should accept requests without origin headers', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedOrigins: ['http://localhost:3000', 'https://example.com'], - enableDnsRebindingProtection: true - }); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); - - it('should reject requests with disallowed origin headers', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedOrigins: ['http://localhost:3000'], - enableDnsRebindingProtection: true - }); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - origin: 'http://evil.com', - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); - }); - }); - - describe('Content-Type validation', () => { - it('should accept requests with application/json content-type', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); - - it('should accept requests with application/json with charset', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json; charset=utf-8' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); - - it('should reject requests with non-application/json content-type when protection is enabled', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - 'content-type': 'text/plain' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); - expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); - }); - }); - - describe('enableDnsRebindingProtection option', () => { - it('should skip all validations when enableDnsRebindingProtection is false', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000'], - allowedOrigins: ['http://localhost:3000'], - enableDnsRebindingProtection: false - }); - await transport.start(); - - const mockReq = createMockRequest({ - headers: { - host: 'evil.com', - origin: 'http://evil.com', - 'content-type': 'text/plain' - } - }); - const mockHandleRes = createMockResponse(); - - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - - // Should pass even with invalid headers because protection is disabled - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); - // The error should be from content-type parsing, not DNS rebinding protection - expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); - }); - }); - - describe('Combined validations', () => { - it('should validate both host and origin when both are configured', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000'], - allowedOrigins: ['http://localhost:3000'], - enableDnsRebindingProtection: true - }); - await transport.start(); - - // Valid host, invalid origin - const mockReq1 = createMockRequest({ - headers: { - host: 'localhost:3000', - origin: 'http://evil.com', - 'content-type': 'application/json' - } - }); - const mockHandleRes1 = createMockResponse(); - - await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); - - // Invalid host, valid origin - const mockReq2 = createMockRequest({ - headers: { - host: 'evil.com', - origin: 'http://localhost:3000', - 'content-type': 'application/json' - } - }); - const mockHandleRes2 = createMockResponse(); - - await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); - - // Both valid - const mockReq3 = createMockRequest({ - headers: { - host: 'localhost:3000', - origin: 'http://localhost:3000', - 'content-type': 'application/json' - } - }); - const mockHandleRes3 = createMockResponse(); - - await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' }); - - expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); - }); - }); - }); - }); -}); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index d8c6388e4..57e47668b 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -15,9 +15,10 @@ import type { import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import { McpServer } from '../../src/server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; +import { NodeStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; import type { EventId, EventStore, StreamId } from '../../src/server/webStandardStreamableHttp.js'; -import { type ZodMatrixEntry, zodTestMatrix } from './__fixtures__/zodTestMatrix.js'; +import type { ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; +import { zodTestMatrix } from './__fixtures__/zodTestMatrix.js'; async function getFreePort() { return new Promise(res => { @@ -34,7 +35,7 @@ async function getFreePort() { } /** - * Test server configuration for StreamableHTTPServerTransport tests + * Test server configuration for NodeStreamableHTTPServerTransport tests */ interface TestServerConfig { sessionIdGenerator: (() => string) | undefined; @@ -49,7 +50,7 @@ interface TestServerConfig { /** * Helper to stop test server */ -async function stopTestServer({ server, transport }: { server: Server; transport: StreamableHTTPServerTransport }): Promise { +async function stopTestServer({ server, transport }: { server: Server; transport: NodeStreamableHTTPServerTransport }): Promise { // First close the transport to ensure all SSE streams are closed await transport.close(); @@ -153,7 +154,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { */ async function createTestServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ server: Server; - transport: StreamableHTTPServerTransport; + transport: NodeStreamableHTTPServerTransport; mcpServer: McpServer; baseUrl: URL; }> { @@ -168,7 +169,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { } ); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, eventStore: config.eventStore, @@ -202,7 +203,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { */ async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ server: Server; - transport: StreamableHTTPServerTransport; + transport: NodeStreamableHTTPServerTransport; mcpServer: McpServer; baseUrl: URL; }> { @@ -217,7 +218,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { } ); - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, enableJsonResponse: config.enableJsonResponse ?? false, eventStore: config.eventStore, @@ -247,10 +248,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { } const { z } = entry; - describe('StreamableHTTPServerTransport', () => { + describe('NodeStreamableHTTPServerTransport', () => { let server: Server; let mcpServer: McpServer; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; @@ -979,9 +980,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); }); - describe('StreamableHTTPServerTransport with AuthInfo', () => { + describe('NodeStreamableHTTPServerTransport with AuthInfo', () => { let server: Server; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; @@ -1079,9 +1080,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test JSON Response Mode - describe('StreamableHTTPServerTransport with JSON Response Mode', () => { + describe('NodeStreamableHTTPServerTransport with JSON Response Mode', () => { let server: Server; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; @@ -1166,9 +1167,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test pre-parsed body handling - describe('StreamableHTTPServerTransport with pre-parsed body', () => { + describe('NodeStreamableHTTPServerTransport with pre-parsed body', () => { let server: Server; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; let parsedBody: unknown = null; @@ -1302,9 +1303,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test resumability support - describe('StreamableHTTPServerTransport with resumability', () => { + describe('NodeStreamableHTTPServerTransport with resumability', () => { let server: Server; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; let mcpServer: McpServer; @@ -1538,9 +1539,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test stateless mode - describe('StreamableHTTPServerTransport in stateless mode', () => { + describe('NodeStreamableHTTPServerTransport in stateless mode', () => { let server: Server; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; let baseUrl: URL; beforeEach(async () => { @@ -1626,9 +1627,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test SSE priming events for POST streams - describe('StreamableHTTPServerTransport POST SSE priming events', () => { + describe('NodeStreamableHTTPServerTransport POST SSE priming events', () => { let server: Server; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; let baseUrl: URL; let sessionId: string; let mcpServer: McpServer; @@ -2327,7 +2328,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test onsessionclosed callback - describe('StreamableHTTPServerTransport onsessionclosed callback', () => { + describe('NodeStreamableHTTPServerTransport onsessionclosed callback', () => { it('should call onsessionclosed callback when session is closed via DELETE', async () => { const mockCallback = vi.fn(); @@ -2486,7 +2487,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test async callbacks for onsessioninitialized and onsessionclosed - describe('StreamableHTTPServerTransport async callbacks', () => { + describe('NodeStreamableHTTPServerTransport async callbacks', () => { it('should support async onsessioninitialized callback', async () => { const initializationOrder: string[] = []; @@ -2693,9 +2694,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); // Test DNS rebinding protection - describe('StreamableHTTPServerTransport DNS rebinding protection', () => { + describe('NodeStreamableHTTPServerTransport DNS rebinding protection', () => { let server: Server; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; let baseUrl: URL; afterEach(async () => { @@ -2931,7 +2932,7 @@ async function createTestServerWithDnsProtection(config: { enableDnsRebindingProtection?: boolean; }): Promise<{ server: Server; - transport: StreamableHTTPServerTransport; + transport: NodeStreamableHTTPServerTransport; mcpServer: McpServer; baseUrl: URL; }> { @@ -2948,7 +2949,7 @@ async function createTestServerWithDnsProtection(config: { }); } - const transport = new StreamableHTTPServerTransport({ + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: config.sessionIdGenerator, allowedHosts: config.allowedHosts, allowedOrigins: config.allowedOrigins, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dbf8253..6c249da48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ catalogs: '@hono/node-server': specifier: ^1.19.7 version: 1.19.7 + '@remix-run/node-fetch-server': + specifier: ^0.13.0 + version: 0.13.0 content-type: specifier: ^1.0.5 version: 1.0.5 @@ -99,8 +102,8 @@ catalogs: specifier: ^5.0.1 version: 5.1.0 express-rate-limit: - specifier: ^7.5.0 - version: 7.5.1 + specifier: ^8.2.1 + version: 8.2.1 hono: specifier: ^4.11.1 version: 4.11.1 @@ -233,7 +236,7 @@ importers: version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.1) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) + version: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) eslint-plugin-n: specifier: catalog:devTools version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) @@ -305,6 +308,15 @@ importers: '@modelcontextprotocol/server': specifier: workspace:^ version: link:../../packages/server + '@modelcontextprotocol/server-express': + specifier: workspace:^ + version: link:../../packages/server-express + '@modelcontextprotocol/server-hono': + specifier: workspace:^ + version: link:../../packages/server-hono + better-auth: + specifier: ^1.4.7 + version: 1.4.7(better-sqlite3@11.10.0)(vitest@4.0.9(@types/node@24.10.3)(tsx@4.20.6)) cors: specifier: catalog:runtimeServerOnly version: 2.8.5 @@ -339,9 +351,21 @@ importers: examples/shared: dependencies: + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../../packages/core '@modelcontextprotocol/server': specifier: workspace:^ version: link:../../packages/server + '@modelcontextprotocol/server-express': + specifier: workspace:^ + version: link:../../packages/server-express + better-auth: + specifier: ^1.4.7 + version: 1.4.7(better-sqlite3@11.10.0)(vitest@4.0.9(@types/node@24.10.3)(tsx@4.20.6)) + better-sqlite3: + specifier: ^11.10.0 + version: 11.10.0 express: specifier: catalog:runtimeServerOnly version: 5.1.0 @@ -361,6 +385,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/express': specifier: catalog:devTools version: 5.0.5 @@ -561,18 +588,6 @@ importers: content-type: specifier: catalog:runtimeServerOnly version: 1.0.5 - cors: - specifier: catalog:runtimeServerOnly - version: 2.8.5 - express: - specifier: catalog:runtimeServerOnly - version: 5.1.0 - express-rate-limit: - specifier: catalog:runtimeServerOnly - version: 7.5.1(express@5.1.0) - hono: - specifier: catalog:runtimeServerOnly - version: 4.11.1 pkce-challenge: specifier: catalog:runtimeShared version: 5.0.0 @@ -662,6 +677,122 @@ importers: specifier: catalog:devTools version: 4.0.9(@types/node@24.10.3)(tsx@4.20.6) + packages/server-express: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../server + '@remix-run/node-fetch-server': + specifier: catalog:runtimeServerOnly + version: 0.13.0 + express: + specifier: catalog:runtimeServerOnly + version: 5.1.0 + express-rate-limit: + specifier: catalog:runtimeServerOnly + version: 8.2.1(express@5.1.0) + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.1 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@types/express': + specifier: catalog:devTools + version: 5.0.5 + '@types/express-serve-static-core': + specifier: catalog:devTools + version: 5.1.0 + '@types/supertest': + specifier: catalog:devTools + version: 6.0.3 + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20251218.3 + eslint: + specifier: catalog:devTools + version: 9.39.1 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.1) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + supertest: + specifier: catalog:devTools + version: 7.1.4 + tsdown: + specifier: catalog:devTools + version: 0.18.0(@typescript/native-preview@7.0.0-dev.20251218.3)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.0.9(@types/node@24.10.3)(tsx@4.20.6) + + packages/server-hono: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../server + hono: + specifier: catalog:runtimeServerOnly + version: 4.11.1 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.1 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20251218.3 + eslint: + specifier: catalog:devTools + version: 9.39.1 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.1) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.0(@typescript/native-preview@7.0.0-dev.20251218.3)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.0.9(@types/node@24.10.3)(tsx@4.20.6) + test/helpers: devDependencies: '@modelcontextprotocol/client': @@ -703,6 +834,9 @@ importers: '@modelcontextprotocol/server': specifier: workspace:^ version: link:../../packages/server + '@modelcontextprotocol/server-express': + specifier: workspace:^ + version: link:../../packages/server-express '@modelcontextprotocol/test-helpers': specifier: workspace:^ version: link:../helpers @@ -749,6 +883,27 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.4.7': + resolution: {integrity: sha512-rNfj8aNFwPwAMYo+ahoWDsqKrV7svD3jhHSC6+A77xxKodbgV0UgH+RO21GMaZ0PPAibEl851nw5e3bsNslW/w==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-call: 1.1.5 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.7': + resolution: {integrity: sha512-k07C/FWnX6m+IxLruNkCweIxuaIwVTB2X40EqwamRVhYNBAhOYZFGLHH+PtQyM+Yf1Z4+8H6MugLOXSreXNAjQ==} + peerDependencies: + '@better-auth/core': 1.4.7 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} @@ -1072,10 +1227,18 @@ packages: '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1097,6 +1260,9 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@remix-run/node-fetch-server@0.13.0': + resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==} + '@rolldown/binding-android-arm64@1.0.0-beta.53': resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1296,6 +1462,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -1702,13 +1871,92 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-auth@1.4.7: + resolution: {integrity: sha512-kVmDQxzqGwP4FFMOYpS5I7oAaoFW3hwooUAAtcbb2DrOYv5EUvRUDJbTMaPoMTj7URjNDQ6vG9gcCS1Q+0aVBw==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.22.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + better-sqlite3: ^12.4.1 + drizzle-kit: ^0.31.4 + drizzle-orm: ^0.41.0 + mongodb: ^6.18.0 + mysql2: ^3.14.4 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.16.3 + prisma: ^5.22.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^4.0.15 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.5: + resolution: {integrity: sha512-nQJ3S87v6wApbDwbZ++FrQiSiVxWvZdjaO+2v6lZJAG2WWggkB2CziUDjPciz3eAt9TqfRursIQMZIcpkBnvlw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + birpc@3.0.0: resolution: {integrity: sha512-by+04pHuxpCEQcucAXqzopqfhyI8TLK5Qg5MST0cB6MP+JhHna9ollrtK9moVh27aq6Q6MEJgebD0cVm//yBkg==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -1723,6 +1971,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1758,6 +2009,9 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -1838,6 +2092,14 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1864,6 +2126,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -1903,6 +2169,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -2101,12 +2370,16 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} - express-rate-limit@7.5.1: - resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2153,6 +2426,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2196,6 +2472,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -2238,6 +2517,9 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2322,6 +2604,9 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2345,10 +2630,17 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -2480,10 +2772,6 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2518,6 +2806,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kysely@0.28.9: + resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==} + engines: {node: '>=20.0.0'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2584,6 +2876,10 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2594,6 +2890,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2606,6 +2905,13 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2618,6 +2924,10 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + engines: {node: '>=10'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2763,6 +3073,11 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2781,6 +3096,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2806,10 +3124,18 @@ packages: resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} engines: {node: '>= 0.10'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2871,6 +3197,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -2913,6 +3242,9 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2959,6 +3291,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -3007,6 +3345,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3015,6 +3356,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3039,6 +3384,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -3133,6 +3485,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3203,6 +3558,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3352,6 +3710,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + snapshots: '@babel/generator@7.28.5': @@ -3377,6 +3738,27 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@better-auth/core@1.4.7(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.5(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.0.0 + better-call: 1.1.5(zod@4.2.1) + jose: 6.1.3 + kysely: 0.28.9 + nanostores: 1.1.0 + zod: 4.2.1 + + '@better-auth/telemetry@1.4.7(@better-auth/core@1.4.7(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.5(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.7(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.5(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.21': {} + '@cfworker/json-schema@4.1.1': {} '@changesets/apply-release-plan@7.0.14': @@ -3663,7 +4045,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 minimatch: 3.1.2 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3744,8 +4126,12 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/ciphers@2.1.1': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3768,6 +4154,8 @@ snapshots: dependencies: quansync: 1.0.0 + '@remix-run/node-fetch-server@0.13.0': {} + '@rolldown/binding-android-arm64@1.0.0-beta.53': optional: true @@ -3886,6 +4274,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 24.10.3 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -4318,12 +4710,56 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + + better-auth@1.4.7(better-sqlite3@11.10.0)(vitest@4.0.9(@types/node@24.10.3)(tsx@4.20.6)): + dependencies: + '@better-auth/core': 1.4.7(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.5(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.7(@better-auth/core@1.4.7(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.5(zod@4.2.1))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.5(zod@4.2.1) + defu: 6.1.4 + jose: 6.1.3 + kysely: 0.28.9 + nanostores: 1.1.0 + zod: 4.2.1 + optionalDependencies: + better-sqlite3: 11.10.0 + vitest: 4.0.9(@types/node@24.10.3)(tsx@4.20.6) + + better-call@1.1.5(zod@4.2.1): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.2.1 + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + birpc@3.0.0: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -4351,6 +4787,11 @@ snapshots: dependencies: fill-range: 7.1.1 + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bytes@3.1.2: {} cac@6.7.14: {} @@ -4383,6 +4824,8 @@ snapshots: chardet@2.1.1: {} + chownr@1.1.4: {} + ci-info@3.9.0: {} color-convert@2.0.1: @@ -4450,6 +4893,12 @@ snapshots: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -4472,6 +4921,8 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@2.1.2: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -4501,6 +4952,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -4663,15 +5118,14 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.9.3) eslint: 9.39.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.1) @@ -4685,7 +5139,7 @@ snapshots: eslint: 9.39.1 eslint-compat-utils: 0.5.1(eslint@9.39.1) - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4696,7 +5150,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -4707,8 +5161,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -4813,11 +5265,14 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + expand-template@2.0.3: {} + expect-type@1.2.2: {} - express-rate-limit@7.5.1(express@5.1.0): + express-rate-limit@8.2.1(express@5.1.0): dependencies: express: 5.1.0 + ip-address: 10.0.1 express@5.1.0: dependencies: @@ -4883,6 +5338,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -4937,6 +5394,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -4995,6 +5454,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -5071,6 +5532,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -5086,12 +5549,16 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.1.0 + ip-address@10.0.1: {} + ipaddr.js@1.9.1: {} is-array-buffer@3.0.5: @@ -5225,10 +5692,6 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -5257,6 +5720,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kysely@0.28.9: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -5307,6 +5772,8 @@ snapshots: mime@2.6.0: {} + mimic-response@3.1.0: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -5317,18 +5784,28 @@ snapshots: minimist@1.2.8: {} + mkdirp-classic@0.5.3: {} + mri@1.2.0: {} ms@2.1.3: {} nanoid@3.3.11: {} + nanostores@1.1.0: {} + + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} negotiator@1.0.0: {} + node-abi@3.85.0: + dependencies: + semver: 7.7.3 + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -5459,6 +5936,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.85.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier@2.8.8: {} @@ -5470,6 +5962,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qs@6.14.0: @@ -5491,6 +5988,13 @@ snapshots: iconv-lite: 0.7.0 unpipe: 1.0.0 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -5498,6 +6002,12 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -5599,6 +6109,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.2 fsevents: 2.3.3 + rou3@0.7.12: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -5665,6 +6177,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5727,6 +6241,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + slash@3.0.0: {} source-map-js@1.2.1: {} @@ -5776,12 +6298,18 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 strip-bom@3.0.0: {} + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} superagent@10.2.3: @@ -5813,6 +6341,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + term-size@2.2.1: {} tinybench@2.9.0: {} @@ -5894,6 +6437,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -6000,6 +6547,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + vary@1.1.2: {} vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.3)(tsx@4.20.6)): @@ -6134,3 +6683,5 @@ snapshots: zod: 3.25.76 zod@3.25.76: {} + + zod@4.2.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 12bae8326..0d86e1799 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,55 +1,60 @@ packages: - - packages/**/* - - common/**/* - - examples/**/* - - test/**/* + - packages/**/* + - common/**/* + - examples/**/* + - test/**/* catalogs: - runtimeShared: - ajv: ^8.17.1 - ajv-formats: ^3.0.1 - json-schema-typed: ^8.0.2 - pkce-challenge: ^5.0.0 - zod: ^3.25 || ^4.0 - zod-to-json-schema: ^3.25.0 - '@cfworker/json-schema': ^4.1.1 - runtimeServerOnly: - '@hono/node-server': ^1.19.7 - hono: ^4.11.1 - content-type: ^1.0.5 - cors: ^2.8.5 - express: ^5.0.1 - express-rate-limit: ^7.5.0 - raw-body: ^3.0.0 - runtimeClientOnly: - jose: ^6.1.1 - cross-spawn: ^7.0.5 - eventsource: ^3.0.2 - eventsource-parser: ^3.0.0 - devTools: - typescript: ^5.9.3 - vitest: ^4.0.8 - eslint: ^9.8.0 - '@eslint/js': ^9.39.1 - eslint-config-prettier: ^10.1.8 - eslint-plugin-n: ^17.23.1 - prettier: 3.6.2 - tsx: ^4.16.5 - 'typescript-eslint': ^8.48.1 - supertest: ^7.0.0 - ws: ^8.18.0 - vite-tsconfig-paths: ^5.1.4 - '@types/content-type': ^1.1.8 - '@types/cors': ^2.8.17 - '@types/cross-spawn': ^6.0.6 - '@types/eventsource': ^1.1.15 - '@types/express': ^5.0.0 - '@types/express-serve-static-core': ^5.1.0 - '@types/supertest': ^6.0.2 - '@types/ws': ^8.5.12 - '@typescript/native-preview': ^7.0.0-dev.20251217.1 - tsdown: ^0.18.0 + devTools: + '@eslint/js': ^9.39.1 + '@types/content-type': ^1.1.8 + '@types/cors': ^2.8.17 + '@types/cross-spawn': ^6.0.6 + '@types/eventsource': ^1.1.15 + '@types/express': ^5.0.0 + '@types/express-serve-static-core': ^5.1.0 + '@types/supertest': ^6.0.2 + '@types/ws': ^8.5.12 + '@typescript/native-preview': ^7.0.0-dev.20251217.1 + eslint: ^9.8.0 + eslint-config-prettier: ^10.1.8 + eslint-plugin-n: ^17.23.1 + prettier: 3.6.2 + supertest: ^7.0.0 + tsdown: ^0.18.0 + tsx: ^4.16.5 + typescript: ^5.9.3 + typescript-eslint: ^8.48.1 + vite-tsconfig-paths: ^5.1.4 + vitest: ^4.0.8 + ws: ^8.18.0 + runtimeClientOnly: + cross-spawn: ^7.0.5 + eventsource: ^3.0.2 + eventsource-parser: ^3.0.0 + jose: ^6.1.1 + runtimeServerOnly: + '@hono/node-server': ^1.19.7 + '@remix-run/node-fetch-server': ^0.13.0 + content-type: ^1.0.5 + cors: ^2.8.5 + express: ^5.0.1 + express-rate-limit: ^8.2.1 + hono: ^4.11.1 + raw-body: ^3.0.0 + runtimeShared: + '@cfworker/json-schema': ^4.1.1 + ajv: ^8.17.1 + ajv-formats: ^3.0.1 + json-schema-typed: ^8.0.2 + pkce-challenge: ^5.0.0 + zod: ^3.25 || ^4.0 + zod-to-json-schema: ^3.25.0 enableGlobalVirtualStore: false linkWorkspacePackages: deep + +onlyBuiltDependencies: + - better-sqlite3 + - esbuild diff --git a/test/integration/package.json b/test/integration/package.json index e709e431a..baa099bab 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -35,6 +35,7 @@ "@modelcontextprotocol/core": "workspace:^", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/server-express": "workspace:^", "zod": "catalog:runtimeShared", "vitest": "catalog:devTools", "supertest": "catalog:devTools", diff --git a/test/integration/test/issues/test_1277_zod_v4_description.test.ts b/test/integration/test/issues/test_1277_zod_v4_description.test.ts index fe58cfcd5..75a61cb36 100644 --- a/test/integration/test/issues/test_1277_zod_v4_description.test.ts +++ b/test/integration/test/issues/test_1277_zod_v4_description.test.ts @@ -9,7 +9,8 @@ import { Client } from '@modelcontextprotocol/client'; import { InMemoryTransport, ListPromptsResultSchema } from '@modelcontextprotocol/core'; import { McpServer } from '@modelcontextprotocol/server'; -import { type ZodMatrixEntry, zodTestMatrix } from '@modelcontextprotocol/test-helpers'; +import type { ZodMatrixEntry } from '@modelcontextprotocol/test-helpers'; +import { zodTestMatrix } from '@modelcontextprotocol/test-helpers'; describe.each(zodTestMatrix)('Issue #1277: $zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts index fcac6cc45..30a2c03c4 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -30,7 +30,9 @@ import { SetLevelRequestSchema, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; -import { createMcpExpressApp, InMemoryTaskMessageQueue, InMemoryTaskStore, McpServer, Server } from '@modelcontextprotocol/server'; +import { InMemoryTaskStore, McpServer, Server } from '@modelcontextprotocol/server'; +import { createMcpExpressApp } from '@modelcontextprotocol/server-express'; +import type { Request, Response } from 'express'; import supertest from 'supertest'; import * as z3 from 'zod/v3'; import * as z4 from 'zod/v4'; @@ -2066,7 +2068,7 @@ describe('createMcpExpressApp', () => { test('should parse JSON bodies', async () => { const app = createMcpExpressApp({ host: '0.0.0.0' }); // Disable host validation for this test - app.post('/test', (req, res) => { + app.post('/test', (req: Request, res: Response) => { res.json({ received: req.body }); }); @@ -2078,7 +2080,7 @@ describe('createMcpExpressApp', () => { test('should reject requests with invalid Host header by default', async () => { const app = createMcpExpressApp(); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); @@ -2097,7 +2099,7 @@ describe('createMcpExpressApp', () => { test('should allow requests with localhost Host header', async () => { const app = createMcpExpressApp(); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); @@ -2109,7 +2111,7 @@ describe('createMcpExpressApp', () => { test('should allow requests with 127.0.0.1 Host header', async () => { const app = createMcpExpressApp(); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); @@ -2121,7 +2123,7 @@ describe('createMcpExpressApp', () => { test('should not apply host validation when host is 0.0.0.0', async () => { const app = createMcpExpressApp({ host: '0.0.0.0' }); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); @@ -2134,7 +2136,7 @@ describe('createMcpExpressApp', () => { test('should apply host validation when host is explicitly localhost', async () => { const app = createMcpExpressApp({ host: 'localhost' }); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); @@ -2146,7 +2148,7 @@ describe('createMcpExpressApp', () => { test('should allow requests with IPv6 localhost Host header', async () => { const app = createMcpExpressApp(); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); @@ -2158,7 +2160,7 @@ describe('createMcpExpressApp', () => { test('should apply host validation when host is ::1 (IPv6 localhost)', async () => { const app = createMcpExpressApp({ host: '::1' }); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); @@ -2185,7 +2187,7 @@ describe('createMcpExpressApp', () => { test('should use custom allowedHosts when provided', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); @@ -2205,7 +2207,7 @@ describe('createMcpExpressApp', () => { test('should override default localhost validation when allowedHosts is provided', async () => { // Even though host is localhost, we're using custom allowedHosts const app = createMcpExpressApp({ host: 'localhost', allowedHosts: ['custom.local'] }); - app.post('/test', (_req, res) => { + app.post('/test', (_req: Request, res: Response) => { res.json({ success: true }); }); diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index f7bcececc..90e7152aa 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1,26 +1,28 @@ import { Client } from '@modelcontextprotocol/client'; -import { getDisplayName, InMemoryTaskStore, InMemoryTransport, UriTemplate } from '@modelcontextprotocol/core'; +import type { CallToolResult, Notification, TextContent } from '@modelcontextprotocol/core'; import { - type CallToolResult, CallToolResultSchema, CompleteResultSchema, ElicitRequestSchema, ErrorCode, + getDisplayName, GetPromptResultSchema, + InMemoryTaskStore, + InMemoryTransport, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, ListToolsResultSchema, LoggingMessageNotificationSchema, - type Notification, ReadResourceResultSchema, - type TextContent, + UriTemplate, UrlElicitationRequiredError } from '@modelcontextprotocol/core'; import { completable } from '../../../../packages/server/src/server/completable.js'; import { McpServer, ResourceTemplate } from '../../../../packages/server/src/server/mcp.js'; -import { type ZodMatrixEntry, zodTestMatrix } from '../../../../packages/server/test/server/__fixtures__/zodTestMatrix.js'; +import type { ZodMatrixEntry } from '../../../../packages/server/test/server/__fixtures__/zodTestMatrix.js'; +import { zodTestMatrix } from '../../../../packages/server/test/server/__fixtures__/zodTestMatrix.js'; function createLatch() { let latch = false; diff --git a/test/integration/test/stateManagementStreamableHttp.test.ts b/test/integration/test/stateManagementStreamableHttp.test.ts index c33100efa..72180b688 100644 --- a/test/integration/test/stateManagementStreamableHttp.test.ts +++ b/test/integration/test/stateManagementStreamableHttp.test.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { createServer, type Server } from 'node:http'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { @@ -9,10 +10,10 @@ import { ListResourcesResultSchema, ListToolsResultSchema, McpServer, - StreamableHTTPServerTransport + NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; -import { type ZodMatrixEntry, zodTestMatrix } from '@modelcontextprotocol/test-helpers'; +import type { ZodMatrixEntry } from '@modelcontextprotocol/test-helpers'; +import { listenOnRandomPort, zodTestMatrix } from '@modelcontextprotocol/test-helpers'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; @@ -68,7 +69,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); // Create transport with or without session management - const serverTransport = new StreamableHTTPServerTransport({ + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: withSessionManagement ? () => randomUUID() // With session management, generate UUID : undefined // Without session management, return undefined @@ -89,7 +90,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { describe('Stateless Mode', () => { let server: Server; let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; + let serverTransport: NodeStreamableHTTPServerTransport; let baseUrl: URL; beforeEach(async () => { @@ -253,7 +254,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { describe('Stateful Mode', () => { let server: Server; let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; + let serverTransport: NodeStreamableHTTPServerTransport; let baseUrl: URL; beforeEach(async () => { diff --git a/test/integration/test/taskLifecycle.test.ts b/test/integration/test/taskLifecycle.test.ts index 216479e93..324da6aa2 100644 --- a/test/integration/test/taskLifecycle.test.ts +++ b/test/integration/test/taskLifecycle.test.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { createServer, type Server } from 'node:http'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import type { TaskRequestOptions } from '@modelcontextprotocol/server'; @@ -13,8 +14,8 @@ import { InMemoryTaskStore, McpError, McpServer, + NodeStreamableHTTPServerTransport, RELATED_TASK_META_KEY, - StreamableHTTPServerTransport, TaskSchema } from '@modelcontextprotocol/server'; import { listenOnRandomPort, waitForTaskStatus } from '@modelcontextprotocol/test-helpers'; @@ -23,7 +24,7 @@ import { z } from 'zod'; describe('Task Lifecycle Integration Tests', () => { let server: Server; let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; + let serverTransport: NodeStreamableHTTPServerTransport; let baseUrl: URL; let taskStore: InMemoryTaskStore; @@ -188,7 +189,7 @@ describe('Task Lifecycle Integration Tests', () => { ); // Create transport - serverTransport = new StreamableHTTPServerTransport({ + serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); diff --git a/test/integration/test/taskResumability.test.ts b/test/integration/test/taskResumability.test.ts index 1e4d8a0fd..db60e2d4e 100644 --- a/test/integration/test/taskResumability.test.ts +++ b/test/integration/test/taskResumability.test.ts @@ -1,14 +1,15 @@ import { randomUUID } from 'node:crypto'; -import { createServer, type Server } from 'node:http'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server'; import { CallToolResultSchema, LoggingMessageNotificationSchema, McpServer, - StreamableHTTPServerTransport + NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server'; import type { ZodMatrixEntry } from '@modelcontextprotocol/test-helpers'; import { listenOnRandomPort, zodTestMatrix } from '@modelcontextprotocol/test-helpers'; @@ -51,7 +52,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { describe('Transport resumability', () => { let server: Server; let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; + let serverTransport: NodeStreamableHTTPServerTransport; let baseUrl: URL; let eventStore: InMemoryEventStore; @@ -117,7 +118,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); // Create a transport with the event store - serverTransport = new StreamableHTTPServerTransport({ + serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore }); diff --git a/test/integration/test/title.test.ts b/test/integration/test/title.test.ts index 4eec82335..97348c117 100644 --- a/test/integration/test/title.test.ts +++ b/test/integration/test/title.test.ts @@ -1,7 +1,8 @@ import { Client } from '@modelcontextprotocol/client'; import { InMemoryTransport } from '@modelcontextprotocol/core'; import { McpServer, ResourceTemplate, Server } from '@modelcontextprotocol/server'; -import { type ZodMatrixEntry, zodTestMatrix } from '@modelcontextprotocol/test-helpers'; +import type { ZodMatrixEntry } from '@modelcontextprotocol/test-helpers'; +import { zodTestMatrix } from '@modelcontextprotocol/test-helpers'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json index f69a602fd..666fc0509 100644 --- a/test/integration/tsconfig.json +++ b/test/integration/tsconfig.json @@ -8,6 +8,7 @@ "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server-express": ["./node_modules/@modelcontextprotocol/server-express/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"] }