From 93ca3128f603080387cd772dce1e42d3dc8918a4 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Sat, 24 Jan 2026 00:06:35 +0800 Subject: [PATCH] fix: use factory function for MCP server in stateless mode --- docs/tutorials/todo-manager/README.mdx | 292 ++++++++++++------------- 1 file changed, 140 insertions(+), 152 deletions(-) diff --git a/docs/tutorials/todo-manager/README.mdx b/docs/tutorials/todo-manager/README.mdx index 768fcde..3a8df94 100644 --- a/docs/tutorials/todo-manager/README.mdx +++ b/docs/tutorials/todo-manager/README.mdx @@ -338,50 +338,55 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express, { type Request, type Response } from 'express'; -// Create an MCP server -const server = new McpServer({ - name: 'Todo Manager', - version: '0.0.0', -}); +// Factory function to create an MCP server instance +// In stateless mode, each request needs its own server instance +const createMcpServer = () => { + const mcpServer = new McpServer({ + name: 'Todo Manager', + version: '0.0.0', + }); + + mcpServer.registerTool( + 'create-todo', + { + description: 'Create a new todo', + inputSchema: { content: z.string() }, + }, + async ({ content }) => { + return { + content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }], + }; + } + ); -server.registerTool( - 'create-todo', - { - description: 'Create a new todo', - inputSchema: { content: z.string() }, - }, - async ({ content }) => { - return { - content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }], - }; - } -); + mcpServer.registerTool( + 'get-todos', + { + description: 'List all todos', + inputSchema: {}, + }, + async () => { + return { + content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }], + }; + } + ); -server.registerTool( - 'get-todos', - { - description: 'List all todos', - inputSchema: {}, - }, - async () => { - return { - content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }], - }; - } -); + mcpServer.registerTool( + 'delete-todo', + { + description: 'Delete a todo by id', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + return { + content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }], + }; + } + ); -server.registerTool( - 'delete-todo', - { - description: 'Delete a todo by id', - inputSchema: { id: z.string() }, - }, - async ({ id }) => { - return { - content: [{ type: 'text', text: JSON.stringify({ error: 'Not implemented' }) }], - }; - } -); + return mcpServer; +}; // Below is the boilerplate code from MCP SDK documentation const PORT = 3001; @@ -391,18 +396,19 @@ app.post('/', async (request: Request, response: Response) => { // In stateless mode, create a new instance of transport and server for each request // to ensure complete isolation. A single instance would cause request ID collisions // when multiple clients connect concurrently. + const mcpServer = createMcpServer(); try { - const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); - response.on('close', async () => { + await mcpServer.connect(transport); + await transport.handleRequest(request, response, request.body); + response.on('close', () => { console.log('Request closed'); - await transport.close(); - await server.close(); + void transport.close(); + void mcpServer.close(); }); - await server.connect(transport); - await transport.handleRequest(request, response, request.body); } catch (error) { console.error('Error handling MCP request:', error); if (!response.headersSent) { @@ -418,36 +424,6 @@ app.post('/', async (request: Request, response: Response) => { } }); -// SSE notifications not supported in stateless mode -app.get('/', async (request: Request, response: Response) => { - console.log('Received GET MCP request'); - response.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.', - }, - id: null, - }) - ); -}); - -// Session termination not needed in stateless mode -app.delete('/', async (request: Request, response: Response) => { - console.log('Received DELETE MCP request'); - response.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32_000, - message: 'Method not allowed.', - }, - id: null, - }) - ); -}); - app.listen(PORT); ``` @@ -715,104 +691,116 @@ const hasRequiredScopes = (userScopes: string[], requiredScopes: string[]): bool return requiredScopes.every((scope) => userScopes.includes(scope)); }; +// TodoService is a singleton since we need to share state across requests const todoService = new TodoService(); -server.registerTool( - 'create-todo', - { - description: 'Create a new todo', - inputSchema: { content: z.string() }, - }, - ({ content }, { authInfo }) => { - const userId = assertUserId(authInfo); - - /** - * Only users with 'create:todos' scope can create todos - */ - if (!hasRequiredScopes(authInfo?.scopes ?? [], ['create:todos'])) { - throw new MCPAuthBearerAuthError('missing_required_scopes'); - } +// Factory function to create an MCP server instance +// In stateless mode, each request needs its own server instance +const createMcpServer = () => { + const mcpServer = new McpServer({ + name: 'Todo Manager', + version: '0.0.0', + }); - const createdTodo = todoService.createTodo({ content, ownerId: userId }); - - return { - content: [{ type: 'text', text: JSON.stringify(createdTodo) }], - }; - } -); + mcpServer.registerTool( + 'create-todo', + { + description: 'Create a new todo', + inputSchema: { content: z.string() }, + }, + ({ content }, { authInfo }) => { + const userId = assertUserId(authInfo); -server.registerTool( - 'get-todos', - { - description: 'List all todos', - inputSchema: {}, - }, - (_params, { authInfo }) => { - const userId = assertUserId(authInfo); + /** + * Only users with 'create:todos' scope can create todos + */ + if (!hasRequiredScopes(authInfo?.scopes ?? [], ['create:todos'])) { + throw new MCPAuthBearerAuthError('missing_required_scopes'); + } - /** - * If user has 'read:todos' scope, they can access all todos (todoOwnerId = undefined) - * If user doesn't have 'read:todos' scope, they can only access their own todos (todoOwnerId = userId) - */ - const todoOwnerId = hasRequiredScopes(authInfo?.scopes ?? [], ['read:todos']) - ? undefined - : userId; + const createdTodo = todoService.createTodo({ content, ownerId: userId }); - const todos = todoService.getAllTodos(todoOwnerId); + return { + content: [{ type: 'text', text: JSON.stringify(createdTodo) }], + }; + } + ); - return { - content: [{ type: 'text', text: JSON.stringify(todos) }], - }; - } -); + mcpServer.registerTool( + 'get-todos', + { + description: 'List all todos', + inputSchema: {}, + }, + (_params, { authInfo }) => { + const userId = assertUserId(authInfo); -server.registerTool( - 'delete-todo', - { - description: 'Delete a todo by id', - inputSchema: { id: z.string() }, - }, - ({ id }, { authInfo }) => { - const userId = assertUserId(authInfo); + /** + * If user has 'read:todos' scope, they can access all todos (todoOwnerId = undefined) + * If user doesn't have 'read:todos' scope, they can only access their own todos (todoOwnerId = userId) + */ + const todoOwnerId = hasRequiredScopes(authInfo?.scopes ?? [], ['read:todos']) + ? undefined + : userId; - const todo = todoService.getTodoById(id); + const todos = todoService.getAllTodos(todoOwnerId); - if (!todo) { return { - content: [{ type: 'text', text: JSON.stringify({ error: 'Failed to delete todo' }) }], + content: [{ type: 'text', text: JSON.stringify(todos) }], }; } + ); + + mcpServer.registerTool( + 'delete-todo', + { + description: 'Delete a todo by id', + inputSchema: { id: z.string() }, + }, + ({ id }, { authInfo }) => { + const userId = assertUserId(authInfo); + + const todo = todoService.getTodoById(id); + + if (!todo) { + return { + content: [{ type: 'text', text: JSON.stringify({ error: 'Failed to delete todo' }) }], + }; + } + + /** + * Users can only delete their own todos + * Users with 'delete:todos' scope can delete any todo + */ + if (todo.ownerId !== userId && !hasRequiredScopes(authInfo?.scopes ?? [], ['delete:todos'])) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ error: 'Failed to delete todo' }), + }, + ], + }; + } + + const deletedTodo = todoService.deleteTodo(id); - /** - * Users can only delete their own todos - * Users with 'delete:todos' scope can delete any todo - */ - if (todo.ownerId !== userId && !hasRequiredScopes(authInfo?.scopes ?? [], ['delete:todos'])) { return { content: [ { type: 'text', - text: JSON.stringify({ error: 'Failed to delete todo' }), + text: JSON.stringify({ + message: `Todo ${id} deleted`, + details: deletedTodo, + }), }, ], }; } + ); - const deletedTodo = todoService.deleteTodo(id); - - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - message: `Todo ${id} deleted`, - details: deletedTodo, - }), - }, - ], - }; - } -); + return mcpServer; +}; ``` Now, create the "Todo service" used in the above code to implement the related functionality: