diff --git a/src/server/mcpServer.test.ts b/src/server/mcpServer.test.ts new file mode 100644 index 0000000..1183c6d --- /dev/null +++ b/src/server/mcpServer.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the tools module so the server can be exercised without the vscode API. +vi.mock('../tools', () => ({ + getAllTools: vi.fn(() => [ + { name: 'sampleTool', description: 'A sample tool', inputSchema: { type: 'object', properties: {} } }, + ]), + callTool: vi.fn(async (name: string) => { + if (name === 'explodingTool') { + throw new Error('boom'); + } + return { ok: true, name }; + }), +})); + +import { MCPServer } from './mcpServer'; +import type { MCPServerConfig } from '../config/settings'; + +const config: MCPServerConfig = { + autoStart: false, + port: 0, + bindAddress: '127.0.0.1', +}; + +describe('MCPServer JSON-RPC HTTP compatibility', () => { + let server: MCPServer; + let baseUrl: string; + + beforeEach(async () => { + server = new MCPServer(config); + const port = await server.start(); + baseUrl = `http://127.0.0.1:${port}`; + }); + + afterEach(async () => { + await server.stop(); + }); + + const post = (body: unknown) => + fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + describe('GET /mcp', () => { + it('is reachable for probing clients and returns transport metadata', async () => { + const res = await fetch(`${baseUrl}/mcp`); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toMatchObject({ status: 'ok', transport: 'jsonrpc-http', endpoint: '/mcp' }); + }); + }); + + describe('GET /health', () => { + it('reports server status', async () => { + const res = await fetch(`${baseUrl}/health`); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.status).toBe('ok'); + }); + }); + + describe('requests with an id', () => { + it('echoes the id on initialize', async () => { + const res = await post({ jsonrpc: '2.0', id: 1, method: 'initialize' }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.id).toBe(1); + expect(json.result.serverInfo.name).toBe('vscode-mcp'); + }); + + it('preserves an id of 0', async () => { + const res = await post({ jsonrpc: '2.0', id: 0, method: 'tools/list' }); + const json = await res.json(); + expect(json.id).toBe(0); + expect(json.result.tools).toHaveLength(1); + }); + + it('returns tool results for tools/call', async () => { + const res = await post({ + jsonrpc: '2.0', + id: 'abc', + method: 'tools/call', + params: { name: 'sampleTool', arguments: {} }, + }); + const json = await res.json(); + expect(json.id).toBe('abc'); + const payload = JSON.parse(json.result.content[0].text); + expect(payload).toEqual({ ok: true, name: 'sampleTool' }); + }); + + it('preserves the id on method-not-found errors', async () => { + const res = await post({ jsonrpc: '2.0', id: 7, method: 'does/notExist' }); + const json = await res.json(); + expect(json.id).toBe(7); + expect(json.error.code).toBe(-32601); + }); + + it('preserves the id when a tool throws', async () => { + const res = await post({ + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { name: 'explodingTool', arguments: {} }, + }); + const json = await res.json(); + expect(json.id).toBe(42); + expect(json.error.code).toBe(-32603); + expect(json.error.message).toBe('boom'); + }); + }); + + describe('notifications (no id)', () => { + it('returns 202 with an empty body for a known notification method', async () => { + const res = await post({ jsonrpc: '2.0', method: 'notifications/initialized' }); + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + }); + + it('returns 202 with an empty body for initialize without an id', async () => { + const res = await post({ jsonrpc: '2.0', method: 'initialize' }); + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + }); + + it('returns 202 (no error body) for an unknown notification', async () => { + const res = await post({ jsonrpc: '2.0', method: 'unknown/method' }); + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + }); + }); +}); diff --git a/src/server/mcpServer.ts b/src/server/mcpServer.ts index 4f6303e..6e8f00a 100644 --- a/src/server/mcpServer.ts +++ b/src/server/mcpServer.ts @@ -108,22 +108,42 @@ export class MCPServer { }); } + // A JSON-RPC request is a notification when it omits the `id` member. + // Notifications must not receive a JSON-RPC response body. + private static isNotification(body: unknown): boolean { + return ( + !!body && + typeof body === 'object' && + !Array.isArray(body) && + !('id' in body) + ); + } + private setupRoutes(): void { // Health check endpoint this.app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', port: this.actualPort }); }); + // Keep GET /mcp reachable for clients that probe transport + // capabilities before issuing a POST (e.g. JetBrains Rider). + this.app.get('/mcp', (req: Request, res: Response) => { + res.json({ status: 'ok', transport: 'jsonrpc-http', endpoint: '/mcp' }); + }); + // MCP endpoint - simplified JSON-RPC over HTTP this.app.post('/mcp', async (req: Request, res: Response) => { + const notification = MCPServer.isNotification(req.body); + // Preserve the request `id` (including 0) for responses/errors. + const id = req.body?.id ?? null; + try { - const { method, params, id } = req.body; + const { method, params } = req.body ?? {}; + let result: unknown; - if (method === 'initialize') { - res.json({ - jsonrpc: '2.0', - id, - result: { + switch (method) { + case 'initialize': + result = { protocolVersion: '2024-11-05', capabilities: { tools: {}, @@ -132,52 +152,57 @@ export class MCPServer { name: 'vscode-mcp', version: '0.1.0', }, - }, - }); - return; - } + }; + break; - if (method === 'tools/list') { - const tools = getAllTools(); - res.json({ - jsonrpc: '2.0', - id, - result: { tools }, - }); - return; - } + case 'tools/list': + result = { tools: getAllTools() }; + break; - if (method === 'tools/call') { - const { name, arguments: args } = params; - const result = await callTool(name, args || {}); - res.json({ - jsonrpc: '2.0', - id, - result: { + case 'tools/call': { + const { name, arguments: args } = params ?? {}; + const toolResult = await callTool(name, args ?? {}); + result = { content: [ { type: 'text', - text: JSON.stringify(result, null, 2), + text: JSON.stringify(toolResult, null, 2), }, ], - }, - }); - return; + }; + break; + } + + default: + if (notification) { + res.status(202).end(); + return; + } + res.json({ + jsonrpc: '2.0', + id, + error: { + code: -32601, + message: `Method not found: ${method}`, + }, + }); + return; } - res.json({ - jsonrpc: '2.0', - id, - error: { - code: -32601, - message: `Method not found: ${method}`, - }, - }); + if (notification) { + res.status(202).end(); + return; + } + res.json({ jsonrpc: '2.0', id, result }); } catch (error) { + if (notification) { + res.status(202).end(); + return; + } const errorMessage = error instanceof Error ? error.message : String(error); res.json({ jsonrpc: '2.0', - id: req.body?.id, + id, error: { code: -32603, message: errorMessage,