From eb0e97e76e7dbd8f6722b27b489c7296251e4b1a Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 25 Aug 2026 20:23:09 +0100 Subject: [PATCH] test(mcplocal): end-to-end wire-name coverage on the project endpoint Drives /projects/:name/mcp over the real Streamable HTTP transport with a fake websearch upstream: tools/list must serve `websearch_fetch_content` (every name matching the OpenAI function-name charset), calling that wire name must reach the upstream as bare `fetch_content`, and a legacy client echoing the canonical `websearch/fetch_content` must still route. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir --- .../project-mcp-endpoint-wire-names.test.ts | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts diff --git a/src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts b/src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts new file mode 100644 index 0000000..f1f2b3c --- /dev/null +++ b/src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi } from 'vitest'; +import Fastify from 'fastify'; +import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js'; +import type { McpRouter } from '../src/router.js'; +import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js'; + +/** + * End-to-end wire-name test on the endpoint the librechat incident actually + * hit: /projects/:name/mcp. A fake `websearch` upstream serves a tool named + * `fetch_content`; the client must see `websearch_fetch_content` in + * tools/list, and calling that wire name must reach the upstream as plain + * `fetch_content` (router canonical `websearch/fetch_content`, prefix + * stripped on dispatch). + */ + +const upstreamRequests: JsonRpcRequest[] = []; + +vi.mock('../src/discovery.js', () => ({ + refreshProjectUpstreams: vi.fn(async (router: McpRouter) => { + router.addUpstream({ + name: 'websearch', + send: async (req: JsonRpcRequest): Promise => { + upstreamRequests.push(req); + if (req.method === 'tools/list') { + return { + jsonrpc: '2.0', + id: req.id, + result: { tools: [{ name: 'fetch_content', description: 'fetch a page', inputSchema: { type: 'object' } }] }, + }; + } + if (req.method === 'tools/call') { + return { + jsonrpc: '2.0', + id: req.id, + result: { content: [{ type: 'text', text: 'page body' }] }, + }; + } + return { jsonrpc: '2.0', id: req.id, result: {} }; + }, + close: async () => {}, + isAlive: () => true, + }); + return ['websearch']; + }), + // gated: false → no gate plugin, the full catalog is served at initialize + // (the chat-web configuration). + fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })), +})); + +vi.mock('../src/http/config.js', async () => { + const actual = await vi.importActual('../src/http/config.js'); + return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) }; +}); + +function mockMcpdClient() { + const client: Record = { + baseUrl: 'http://test:3100', + token: 'test-token', + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), + put: vi.fn(), + delete: vi.fn(), + forward: vi.fn(async () => ({ status: 200, body: [] })), + withHeaders: vi.fn(), + withToken: vi.fn(), + withTimeout: vi.fn(), + }; + (client.withHeaders as ReturnType).mockReturnValue(client); + (client.withToken as ReturnType).mockReturnValue(client); + (client.withTimeout as ReturnType).mockReturnValue(client); + return client; +} + +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('registerProjectMcpEndpoint wire names', () => { + it('serves wire-safe names on the project endpoint and dispatches calls upstream', async () => { + upstreamRequests.length = 0; + const app = Fastify(); + registerProjectMcpEndpoint(app, mockMcpdClient() as never); + await app.ready(); + try { + const headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }; + + const init = await app.inject({ + method: 'POST', + url: '/projects/chat-web/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: '/projects/chat-web/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; + const names = tools.map((t) => t.name); + expect(names).toContain('websearch_fetch_content'); + // Every served name must be a valid OpenAI-style function name — the + // invariant the librechat incident violated. + for (const name of names) { + expect(name).toMatch(/^[A-Za-z0-9_.-]+$/); + } + + const call = await app.inject({ + method: 'POST', + url: '/projects/chat-web/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(); + expect((callResponse.result as { content: Array<{ text: string }> }).content[0]?.text).toBe('page body'); + + // The upstream saw the bare tool name — namespace stripped, not mangled. + const upstreamCall = upstreamRequests.find((r) => r.method === 'tools/call'); + expect(upstreamCall?.params?.['name']).toBe('fetch_content'); + + // Legacy clients echoing the canonical slash name keep working. + const legacy = await app.inject({ + method: 'POST', + url: '/projects/chat-web/mcp', + headers: sessionHeaders, + payload: { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'websearch/fetch_content', arguments: { url: 'https://x' } } }, + }); + expect(parseSse(legacy.body).error).toBeUndefined(); + } finally { + await app.close(); + } + }); +});