diff --git a/packages/happy-cli/src/claude/utils/startHappyServer.test.ts b/packages/happy-cli/src/claude/utils/startHappyServer.test.ts new file mode 100644 index 000000000..51b75f551 --- /dev/null +++ b/packages/happy-cli/src/claude/utils/startHappyServer.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for Happy MCP server + * Verifies that the per-request transport pattern works correctly, + * especially with @modelcontextprotocol/sdk >= 1.26.0 which forbids + * reusing a stateless StreamableHTTPServerTransport across requests. + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { startHappyServer } from './startHappyServer' +import http from 'node:http' + +function createMockClient() { + const messages: unknown[] = []; + return { + sessionId: 'test-session-123', + sendClaudeSessionMessage: (msg: unknown) => { + messages.push(msg); + }, + _messages: messages, + } as any; +} + +function postJSON(url: string, body: object): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + }, + }, (res) => { + let body = ''; + res.on('data', (chunk) => body += chunk); + res.on('end', () => resolve({ status: res.statusCode!, body })); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +const MCP_INITIALIZE = { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + }, + id: 1, +}; + +describe('startHappyServer', () => { + let server: Awaited> | null = null; + + afterEach(() => { + server?.stop(); + server = null; + }); + + it('should start and return a url and tool names', async () => { + const client = createMockClient(); + server = await startHappyServer(client); + + expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+/); + expect(server.toolNames).toEqual(['change_title']); + }); + + it('should respond to a single MCP initialize request', async () => { + const client = createMockClient(); + server = await startHappyServer(client); + + const res = await postJSON(server.url, MCP_INITIALIZE); + expect(res.status).toBe(200); + }); + + it('should handle multiple sequential requests without 500 errors', async () => { + const client = createMockClient(); + server = await startHappyServer(client); + + // This is the core regression test: SDK >= 1.26.0 throws + // "Stateless transport cannot be reused across requests" + // if a single transport handles more than one request. + const res1 = await postJSON(server.url, MCP_INITIALIZE); + expect(res1.status).toBe(200); + + const res2 = await postJSON(server.url, MCP_INITIALIZE); + expect(res2.status).toBe(200); + + const res3 = await postJSON(server.url, MCP_INITIALIZE); + expect(res3.status).toBe(200); + }); +}); diff --git a/packages/happy-cli/src/claude/utils/startHappyServer.ts b/packages/happy-cli/src/claude/utils/startHappyServer.ts index 2a6ba79e6..7caae2044 100644 --- a/packages/happy-cli/src/claude/utils/startHappyServer.ts +++ b/packages/happy-cli/src/claude/utils/startHappyServer.ts @@ -33,59 +33,65 @@ export async function startHappyServer(client: ApiSessionClient) { }; // - // Create the MCP server + // Create a per-request MCP server factory + // @modelcontextprotocol/sdk >= 1.26.0 forbids reusing a stateless + // StreamableHTTPServerTransport across requests, so we create a fresh + // McpServer + transport for each incoming request (following the SDK's + // own simpleStatelessStreamableHttp.ts example). // - const mcp = new McpServer({ - name: "Happy MCP", - version: "1.0.0", - }); + function createMcpServer(): McpServer { + const mcp = new McpServer({ + name: "Happy MCP", + version: "1.0.0", + }); - mcp.registerTool('change_title', { - description: 'Change the title of the current chat session', - title: 'Change Chat Title', - inputSchema: { - title: z.string().describe('The new title for the chat session'), - }, - }, async (args) => { - const response = await handler(args.title); - logger.debug('[happyMCP] Response:', response); - - if (response.success) { - return { - content: [ - { - type: 'text', - text: `Successfully changed chat title to: "${args.title}"`, - }, - ], - isError: false, - }; - } else { - return { - content: [ - { - type: 'text', - text: `Failed to change chat title: ${response.error || 'Unknown error'}`, - }, - ], - isError: true, - }; - } - }); + mcp.registerTool('change_title', { + description: 'Change the title of the current chat session', + title: 'Change Chat Title', + inputSchema: { + title: z.string().describe('The new title for the chat session'), + }, + }, async (args) => { + const response = await handler(args.title); + logger.debug('[happyMCP] Response:', response); - const transport = new StreamableHTTPServerTransport({ - // NOTE: Returning session id here will result in claude - // sdk spawn to fail with `Invalid Request: Server already initialized` - sessionIdGenerator: undefined - }); - await mcp.connect(transport); + if (response.success) { + return { + content: [ + { + type: 'text', + text: `Successfully changed chat title to: "${args.title}"`, + }, + ], + isError: false, + }; + } else { + return { + content: [ + { + type: 'text', + text: `Failed to change chat title: ${response.error || 'Unknown error'}`, + }, + ], + isError: true, + }; + } + }); + + return mcp; + } // // Create the HTTP server // const server = createServer(async (req, res) => { + const mcp = createMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await mcp.connect(transport); try { await transport.handleRequest(req, res); } catch (error) { @@ -94,6 +100,10 @@ export async function startHappyServer(client: ApiSessionClient) { res.writeHead(500).end(); } } + res.on("close", () => { + transport.close(); + mcp.close(); + }); }); const baseUrl = await new Promise((resolve) => { @@ -110,7 +120,6 @@ export async function startHappyServer(client: ApiSessionClient) { toolNames: ['change_title'], stop: () => { logger.debug(`[happyMCP] server:stop sessionId=${client.sessionId}`); - mcp.close(); server.close(); } }