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 { names: ['websearch'], ungatedServers: [] }; }), // 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(); } }); });