Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/server/mcpServer.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
});
103 changes: 64 additions & 39 deletions src/server/mcpServer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as http from 'http';
import express, { Request, Response, NextFunction } from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

Check warning on line 4 in src/server/mcpServer.ts

View workflow job for this annotation

GitHub Actions / build / build

'StdioServerTransport' is defined but never used. Allowed unused vars must match /^_/u
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Expand Down Expand Up @@ -108,22 +108,42 @@
});
}

// 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: {},
Expand All @@ -132,52 +152,57 @@
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,
Expand Down
Loading