[v2] Decouple server from express and hono - http framework-agnostic MCP server (#1326)
This commit is contained in:
parent
5ce4b5ef08
commit
f495077e08
10 changed files with 8193 additions and 15757 deletions
|
|
@ -9,8 +9,10 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import { type Request, type Response } from 'express';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
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 +323,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 +333,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
|
||||
|
|
|
|||
|
|
@ -9,19 +9,16 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { setupAuthServer } from '@modelcontextprotocol/examples-shared';
|
||||
import type { CallToolResult, ElicitRequestURLParams, ElicitResult, OAuthMetadata } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
checkResourceAllowed,
|
||||
createMcpExpressApp,
|
||||
createProtectedResourceMetadataRouter,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
isInitializeRequest,
|
||||
mcpAuthMetadataRouter,
|
||||
McpServer,
|
||||
requireBearerAuth,
|
||||
StreamableHTTPServerTransport,
|
||||
UrlElicitationRequiredError
|
||||
} from '@modelcontextprotocol/server';
|
||||
setupAuthServer
|
||||
} from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server';
|
||||
import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server';
|
||||
import cors from 'cors';
|
||||
import type { Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
|
|
@ -238,63 +235,17 @@ let authMiddleware = null;
|
|||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true });
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true, demoMode: 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({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
scopesSupported: ['mcp:tools'],
|
||||
resourceName: 'MCP Demo Server'
|
||||
})
|
||||
);
|
||||
// Add protected resource metadata route to the MCP server
|
||||
// This allows clients to discover the auth server
|
||||
app.use(createProtectedResourceMetadataRouter());
|
||||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: tokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
strictResource: true,
|
||||
expectedResource: mcpServerUrl
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -594,7 +545,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<ElicitResult>;
|
||||
|
|
@ -613,7 +564,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 +572,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 => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { CallToolResult } from '@modelcontextprotocol/server';
|
||||
import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
|
|
@ -96,21 +98,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 => {
|
||||
|
|
|
|||
|
|
@ -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<CallToolResult> => {
|
||||
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<string, SSEServerTransport> = {};
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/server';
|
||||
import { createMcpExpressApp, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
|
|
@ -103,7 +105,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);
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { setupAuthServer } from '@modelcontextprotocol/examples-shared';
|
||||
import {
|
||||
createProtectedResourceMetadataRouter,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
requireBearerAuth,
|
||||
setupAuthServer
|
||||
} from '@modelcontextprotocol/examples-shared';
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
GetPromptResult,
|
||||
OAuthMetadata,
|
||||
PrimitiveSchemaDefinition,
|
||||
ReadResourceResult,
|
||||
ResourceLink
|
||||
} from '@modelcontextprotocol/server';
|
||||
import {
|
||||
checkResourceAllowed,
|
||||
createMcpExpressApp,
|
||||
ElicitResultSchema,
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
InMemoryTaskMessageQueue,
|
||||
InMemoryTaskStore,
|
||||
isInitializeRequest,
|
||||
mcpAuthMetadataRouter,
|
||||
McpServer,
|
||||
requireBearerAuth,
|
||||
StreamableHTTPServerTransport
|
||||
McpServer
|
||||
} from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
|
@ -527,70 +527,22 @@ if (useOAuth) {
|
|||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth });
|
||||
setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth, demoMode: 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 (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({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
scopesSupported: ['mcp:tools'],
|
||||
resourceName: 'MCP Demo Server'
|
||||
})
|
||||
);
|
||||
// Add protected resource metadata route to the MCP server
|
||||
// This allows clients to discover the auth server
|
||||
app.use(createProtectedResourceMetadataRouter());
|
||||
|
||||
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 +553,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 +637,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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
CreateMessageRequest,
|
||||
|
|
@ -35,15 +37,13 @@ import type {
|
|||
} from '@modelcontextprotocol/server';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
createMcpExpressApp,
|
||||
GetTaskPayloadRequestSchema,
|
||||
GetTaskRequestSchema,
|
||||
InMemoryTaskStore,
|
||||
isTerminal,
|
||||
ListToolsRequestSchema,
|
||||
RELATED_TASK_META_KEY,
|
||||
Server,
|
||||
StreamableHTTPServerTransport
|
||||
Server
|
||||
} from '@modelcontextprotocol/server';
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -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<CallToolResult> => {
|
||||
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<string, StreamableHTTPServerTransport | SSEServerTransport> = {};
|
||||
|
||||
//=============================================================================
|
||||
// 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=<id>
|
||||
==============================================
|
||||
`);
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
|
@ -14,8 +14,10 @@
|
|||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { CallToolResult } from '@modelcontextprotocol/server';
|
||||
import { createMcpExpressApp, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import cors from 'cors';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
|
|
@ -111,7 +113,7 @@ app.use(cors());
|
|||
const eventStore = new InMemoryEventStore();
|
||||
|
||||
// Track transports by session ID for session reuse
|
||||
const transports = new Map<string, StreamableHTTPServerTransport>();
|
||||
const transports = new Map<string, NodeStreamableHTTPServerTransport>();
|
||||
|
||||
// Handle all MCP requests
|
||||
app.all('/mcp', async (req: Request, res: Response) => {
|
||||
|
|
@ -121,7 +123,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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { ReadResourceResult } from '@modelcontextprotocol/server';
|
||||
import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
|
|
@ -11,7 +13,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 +43,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
|
||||
|
|
|
|||
Loading…
Reference in a new issue