101 lines
3.6 KiB
TypeScript
101 lines
3.6 KiB
TypeScript
|
|
import { describe, it, expect, vi } from 'vitest';
|
||
|
|
import Fastify from 'fastify';
|
||
|
|
import { registerMcpEndpoint } from '../src/http/mcp-endpoint.js';
|
||
|
|
import type { McpRouter } from '../src/router.js';
|
||
|
|
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* End-to-end over the Streamable HTTP transport: the /mcp endpoint must serve
|
||
|
|
* OpenAI-safe tool names on tools/list and map them back to the router's
|
||
|
|
* canonical `server/tool` names on tools/call (the librechat fetch_content
|
||
|
|
* incident, 2026-08-25).
|
||
|
|
*/
|
||
|
|
|
||
|
|
function parseSse(body: string): JsonRpcResponse {
|
||
|
|
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
|
||
|
|
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
|
||
|
|
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('registerMcpEndpoint wire names', () => {
|
||
|
|
it('lists wire-safe names and routes calls back to canonical names', async () => {
|
||
|
|
const routed: JsonRpcRequest[] = [];
|
||
|
|
const fakeRouter = {
|
||
|
|
route: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
|
||
|
|
routed.push(req);
|
||
|
|
switch (req.method) {
|
||
|
|
case 'initialize':
|
||
|
|
return {
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: req.id,
|
||
|
|
result: {
|
||
|
|
protocolVersion: '2024-11-05',
|
||
|
|
serverInfo: { name: 'test', version: '0' },
|
||
|
|
capabilities: { tools: {} },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
case 'tools/list':
|
||
|
|
return {
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: req.id,
|
||
|
|
result: { tools: [{ name: 'websearch/fetch_content', inputSchema: { type: 'object' } }] },
|
||
|
|
};
|
||
|
|
case 'tools/call':
|
||
|
|
return {
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: req.id,
|
||
|
|
result: { content: [{ type: 'text', text: 'ok' }] },
|
||
|
|
};
|
||
|
|
default:
|
||
|
|
return { jsonrpc: '2.0', id: req.id, result: {} };
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
} as unknown as McpRouter;
|
||
|
|
|
||
|
|
const app = Fastify();
|
||
|
|
registerMcpEndpoint(app, fakeRouter);
|
||
|
|
await app.ready();
|
||
|
|
try {
|
||
|
|
const headers = {
|
||
|
|
'content-type': 'application/json',
|
||
|
|
accept: 'application/json, text/event-stream',
|
||
|
|
};
|
||
|
|
|
||
|
|
const init = await app.inject({
|
||
|
|
method: 'POST',
|
||
|
|
url: '/mcp',
|
||
|
|
headers,
|
||
|
|
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
|
||
|
|
});
|
||
|
|
expect(init.statusCode).toBe(200);
|
||
|
|
const sessionId = init.headers['mcp-session-id'] as string;
|
||
|
|
expect(sessionId).toBeTruthy();
|
||
|
|
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
|
||
|
|
|
||
|
|
const list = await app.inject({
|
||
|
|
method: 'POST',
|
||
|
|
url: '/mcp',
|
||
|
|
headers: sessionHeaders,
|
||
|
|
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
|
||
|
|
});
|
||
|
|
const listResponse = parseSse(list.body);
|
||
|
|
const tools = (listResponse.result as { tools: Array<{ name: string }> }).tools;
|
||
|
|
expect(tools.map((t) => t.name)).toEqual(['websearch_fetch_content']);
|
||
|
|
|
||
|
|
const call = await app.inject({
|
||
|
|
method: 'POST',
|
||
|
|
url: '/mcp',
|
||
|
|
headers: sessionHeaders,
|
||
|
|
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
|
||
|
|
});
|
||
|
|
const callResponse = parseSse(call.body);
|
||
|
|
expect(callResponse.error).toBeUndefined();
|
||
|
|
|
||
|
|
const routedCall = routed.find((r) => r.method === 'tools/call');
|
||
|
|
expect(routedCall?.params?.['name']).toBe('websearch/fetch_content');
|
||
|
|
} finally {
|
||
|
|
await app.close();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|