From 065ce02a609c5d178fab75add57e3ec217a6ea8f Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 25 Aug 2026 20:17:33 +0100 Subject: [PATCH 1/2] fix(mcplocal): serve OpenAI-safe tool names on the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy namespaces tools as `server/tool` (and favourite-index presents `favourite/` / `all//`). A `/` is not a valid character in an OpenAI-style function name, so hosts that forward MCP tool names verbatim as LLM function names depend on the model faithfully echoing an illegal name. LibreChat did exactly that: deepseek-v4-flash intermittently dropped the `websearch/` prefix, LibreChat's registry lookup failed, and it reported "This tool's MCP server is temporarily unavailable" while nothing was down — the calls never reached mcplocal at all (confirmed against the AuditEvent table, 2026-08-25). Claude Code and the pi extension only dodge this because they sanitize names client-side. Fix at the HTTP boundary only: a WireNameCodec rewrites tools/list responses to wire-safe names (`/` -> `_`, exact-match reverse map, deterministic suffix on collision) and maps tools/call names back before routing. Wired into both /mcp and /projects/:name/mcp. Everything inside the proxy — routing maps, plugins, favourites config, audit events — keeps canonical names, and unknown inbound names (legacy clients echoing slash names, virtual tools) pass through unchanged, so existing clients keep working. Codecs are keyed per project and outlive the router cache TTL so a client can call a tool it listed minutes earlier; after a restart the client's initialize-time tools/list repopulates the map. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir --- src/mcplocal/src/http/mcp-endpoint.ts | 10 +- src/mcplocal/src/http/project-mcp-endpoint.ts | 18 +- src/mcplocal/src/util/wire-names.ts | 126 +++++++++++++ .../tests/mcp-endpoint-wire-names.test.ts | 100 +++++++++++ src/mcplocal/tests/wire-names.test.ts | 168 ++++++++++++++++++ 5 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 src/mcplocal/src/util/wire-names.ts create mode 100644 src/mcplocal/tests/mcp-endpoint-wire-names.test.ts create mode 100644 src/mcplocal/tests/wire-names.test.ts diff --git a/src/mcplocal/src/http/mcp-endpoint.ts b/src/mcplocal/src/http/mcp-endpoint.ts index 3e42d9f..2da4bac 100644 --- a/src/mcplocal/src/http/mcp-endpoint.ts +++ b/src/mcplocal/src/http/mcp-endpoint.ts @@ -13,6 +13,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; import type { McpRouter } from '../router.js'; import type { JsonRpcRequest } from '../types.js'; +import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js'; interface SessionEntry { transport: StreamableHTTPServerTransport; @@ -20,6 +21,9 @@ interface SessionEntry { export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void { const sessions = new Map(); + // One codec for the shared router: serve OpenAI-safe tool names on the wire, + // map tools/call names back to the internal `server/tool` form. + const wireCodec = new WireNameCodec(); // POST /mcp — JSON-RPC requests (initialize, tools/call, etc.) app.post('/mcp', async (request, reply) => { @@ -52,7 +56,11 @@ export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): vo transport.onmessage = async (message: JSONRPCMessage) => { // The transport sends us JSON-RPC messages; route them through McpRouter if ('method' in message && 'id' in message) { - const response = await router.route(message as unknown as JsonRpcRequest); + const response = await routeWithWireNames( + wireCodec, + (req) => router.route(req), + message as unknown as JsonRpcRequest, + ); await transport.send(response as unknown as JSONRPCMessage); } // Notifications (no id) are ignored — router doesn't handle inbound notifications diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 274b6b7..4f1e884 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -27,6 +27,7 @@ import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-inde import { composePlugins } from '../proxymodel/plugins/compose.js'; import type { ProxyModelPlugin } from '../proxymodel/plugin.js'; import { AuditCollector } from '../audit/collector.js'; +import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js'; interface ProjectCacheEntry { router: McpRouter; @@ -45,6 +46,10 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp let resolvedUserName: string | null | undefined; // undefined = not yet resolved const projectCache = new Map(); const sessions = new Map(); + // Wire-name codecs are keyed per project and OUTLIVE the router cache TTL: + // a client may call a tool it listed minutes ago through a refreshed router, + // and the mapping must still resolve. + const wireCodecs = new Map(); /** Resolve the mcplocal owner's userName once from /auth/me using mcplocal's own credentials. */ async function ensureUserName(): Promise { @@ -331,7 +336,18 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp }); const ctx = transport.sessionId ? { sessionId: transport.sessionId, correlationId } : { correlationId }; - const response = await router.route(message as unknown as JsonRpcRequest, ctx); + // Wire-name translation happens HERE, at the client boundary, so the + // router, plugins and audit all keep canonical `server/tool` names. + let codec = wireCodecs.get(projectName); + if (!codec) { + codec = new WireNameCodec(); + wireCodecs.set(projectName, codec); + } + const response = await routeWithWireNames( + codec, + (req) => router.route(req, ctx), + message as unknown as JsonRpcRequest, + ); // Forward queued notifications BEFORE the response — the response send // closes the POST SSE stream, so notifications must go first. diff --git a/src/mcplocal/src/util/wire-names.ts b/src/mcplocal/src/util/wire-names.ts new file mode 100644 index 0000000..f1e47bc --- /dev/null +++ b/src/mcplocal/src/util/wire-names.ts @@ -0,0 +1,126 @@ +/** + * Wire-safe tool-name codec for client-facing MCP endpoints. + * + * Internally the proxy namespaces tools as `server/tool`, and presentation + * plugins add `favourite/`, `all//` and + * `agent-/chat`. A `/` is not a valid character in OpenAI-style + * function names (`^[a-zA-Z0-9_.-]{1,64}$`), so hosts that forward MCP tool + * names verbatim as LLM function names (LibreChat) depend on the model + * faithfully echoing an illegal name. deepseek-v4-flash intermittently drops + * the `server/` prefix; the host's registry lookup then fails and it reports + * the tool's "MCP server is temporarily unavailable" while nothing is down + * (the librechat fetch_content incident, 2026-08-25). Claude Code and the pi + * extension dodge this only because they sanitize names client-side. + * + * The codec translates ONLY at the HTTP boundary: + * - tools/list responses are rewritten to wire-safe names (`/` → `_`), + * - tools/call requests are mapped back to the presented (internal) name + * via an exact-match reverse map. + * + * Everything inside the proxy — routing maps, plugins, favourites config, + * audit events — keeps the canonical names. Inbound names with no map entry + * (legacy clients echoing slash names, virtual tools called before any + * tools/list) pass through unchanged, so existing clients keep working. + * + * The maps live per project router (not per session) so a client that + * reconnects mid-conversation still resolves names listed on its previous + * session, as long as the process is alive. After a restart the first + * tools/list (which every MCP client performs on initialize) repopulates them. + */ +import type { JsonRpcRequest, JsonRpcResponse } from '../types.js'; + +/** Replace every character that is invalid in an OpenAI-style function name. */ +export function sanitizeWireName(name: string): string { + return name.replace(/[^A-Za-z0-9_.-]/g, '_'); +} + +export class WireNameCodec { + /** wire name → presented (internal) name */ + private toPresented = new Map(); + /** presented (internal) name → wire name */ + private toWire = new Map(); + + /** + * Wire name for a presented tool name. Stable for the codec's lifetime. + * + * Collisions (two presented names sanitizing to the same string, or a + * sanitized name shadowing a tool that already uses that exact name) get a + * numeric suffix — first registration wins the plain name. This keeps the + * reverse map unambiguous; order within one tools/list pass is stable, so + * suffixes are deterministic in practice. + */ + encodeName(presented: string): string { + const existing = this.toWire.get(presented); + if (existing !== undefined) return existing; + + const base = sanitizeWireName(presented); + let wire = base; + for (let i = 2; this.toPresented.has(wire) && this.toPresented.get(wire) !== presented; i++) { + wire = `${base}_${String(i)}`; + } + if (wire !== base) { + console.warn(`[wire-names] collision: '${presented}' presented as '${wire}' (base '${base}' taken by '${this.toPresented.get(base) ?? '?'}')`); + } + this.toWire.set(presented, wire); + this.toPresented.set(wire, presented); + return wire; + } + + /** The presented name a wire name maps to, or the input unchanged if unknown. */ + decodeName(wire: string): string { + return this.toPresented.get(wire) ?? wire; + } + + /** + * Rewrite a tools/list response's tool names to wire-safe names, + * registering each mapping. Non-list responses and errors pass through. + */ + encodeToolsList(response: JsonRpcResponse): JsonRpcResponse { + if (response.error !== undefined) return response; + if (response.result === null || typeof response.result !== 'object') return response; + const result = response.result as Record; + const tools: unknown = result['tools']; + if (!Array.isArray(tools)) return response; + + let changed = false; + const encoded = (tools as unknown[]).map((tool) => { + if (tool === null || typeof tool !== 'object' || typeof (tool as { name?: unknown }).name !== 'string') return tool; + const presented = (tool as { name: string }).name; + const wire = this.encodeName(presented); + if (wire === presented) return tool; + changed = true; + return { ...(tool as Record), name: wire }; + }); + + if (!changed) return response; + return { ...response, result: { ...result, tools: encoded } }; + } + + /** + * Map a tools/call request's wire name back to the presented name. + * Requests for unknown names (or without a name) pass through unchanged. + */ + decodeToolCall(request: JsonRpcRequest): JsonRpcRequest { + const params = request.params; + const name = params?.['name']; + if (typeof name !== 'string') return request; + const presented = this.toPresented.get(name); + if (presented === undefined || presented === name) return request; + return { ...request, params: { ...params, name: presented } }; + } +} + +/** + * Route one client request through `route` with wire-name translation: + * decode the tool name on the way in (tools/call), encode tool names on the + * way out (tools/list). Every other method is untouched. + */ +export async function routeWithWireNames( + codec: WireNameCodec, + route: (request: JsonRpcRequest) => Promise, + request: JsonRpcRequest, +): Promise { + const inbound = request.method === 'tools/call' ? codec.decodeToolCall(request) : request; + const response = await route(inbound); + return request.method === 'tools/list' ? codec.encodeToolsList(response) : response; +} diff --git a/src/mcplocal/tests/mcp-endpoint-wire-names.test.ts b/src/mcplocal/tests/mcp-endpoint-wire-names.test.ts new file mode 100644 index 0000000..a155879 --- /dev/null +++ b/src/mcplocal/tests/mcp-endpoint-wire-names.test.ts @@ -0,0 +1,100 @@ +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 => { + 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(); + } + }); +}); diff --git a/src/mcplocal/tests/wire-names.test.ts b/src/mcplocal/tests/wire-names.test.ts new file mode 100644 index 0000000..b844efc --- /dev/null +++ b/src/mcplocal/tests/wire-names.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi } from 'vitest'; +import { sanitizeWireName, WireNameCodec, routeWithWireNames } from '../src/util/wire-names.js'; +import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js'; + +describe('sanitizeWireName', () => { + it('replaces slashes with underscores', () => { + expect(sanitizeWireName('websearch/fetch_content')).toBe('websearch_fetch_content'); + expect(sanitizeWireName('all/websearch/fetch_content')).toBe('all_websearch_fetch_content'); + }); + + it('keeps names that are already OpenAI-safe', () => { + expect(sanitizeWireName('begin_session')).toBe('begin_session'); + expect(sanitizeWireName('my-grafana.tool')).toBe('my-grafana.tool'); + }); + + it('replaces every character outside [A-Za-z0-9_.-]', () => { + expect(sanitizeWireName('a b:c/d')).toBe('a_b_c_d'); + }); +}); + +describe('WireNameCodec', () => { + it('round-trips a namespaced tool name', () => { + const codec = new WireNameCodec(); + const wire = codec.encodeName('websearch/fetch_content'); + expect(wire).toBe('websearch_fetch_content'); + expect(codec.decodeName(wire)).toBe('websearch/fetch_content'); + }); + + it('is stable across repeated encodes', () => { + const codec = new WireNameCodec(); + expect(codec.encodeName('searxng/web_url_read')).toBe('searxng_web_url_read'); + expect(codec.encodeName('searxng/web_url_read')).toBe('searxng_web_url_read'); + }); + + it('passes unknown inbound names through unchanged', () => { + const codec = new WireNameCodec(); + // Legacy client echoing the slash form, or a virtual tool never listed. + expect(codec.decodeName('websearch/fetch_content')).toBe('websearch/fetch_content'); + expect(codec.decodeName('begin_session')).toBe('begin_session'); + }); + + it('suffixes on collision, first registration wins the plain name', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const codec = new WireNameCodec(); + expect(codec.encodeName('foo_bar/baz')).toBe('foo_bar_baz'); + expect(codec.encodeName('foo/bar_baz')).toBe('foo_bar_baz_2'); + // Both decode back to their own presented names. + expect(codec.decodeName('foo_bar_baz')).toBe('foo_bar/baz'); + expect(codec.decodeName('foo_bar_baz_2')).toBe('foo/bar_baz'); + // And stay stable. + expect(codec.encodeName('foo/bar_baz')).toBe('foo_bar_baz_2'); + expect(warn).toHaveBeenCalledOnce(); + } finally { + warn.mockRestore(); + } + }); + + it('encodes tools/list responses and leaves other fields intact', () => { + const codec = new WireNameCodec(); + const response: JsonRpcResponse = { + jsonrpc: '2.0', + id: 1, + result: { + tools: [ + { name: 'websearch/fetch_content', description: 'fetch', inputSchema: { type: 'object' } }, + { name: 'begin_session', description: 'gate' }, + ], + }, + }; + const encoded = codec.encodeToolsList(response); + const tools = (encoded.result as { tools: Array<{ name: string; description?: string }> }).tools; + expect(tools.map((t) => t.name)).toEqual(['websearch_fetch_content', 'begin_session']); + expect(tools[0]?.description).toBe('fetch'); + // Original response object is not mutated. + const originalTools = (response.result as { tools: Array<{ name: string }> }).tools; + expect(originalTools[0]?.name).toBe('websearch/fetch_content'); + }); + + it('returns error and non-list responses unchanged', () => { + const codec = new WireNameCodec(); + const err: JsonRpcResponse = { jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'boom' } }; + expect(codec.encodeToolsList(err)).toBe(err); + const other: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: { content: [] } }; + expect(codec.encodeToolsList(other)).toBe(other); + }); + + it('decodes tools/call requests for known wire names only', () => { + const codec = new WireNameCodec(); + codec.encodeName('websearch/fetch_content'); + + const known: JsonRpcRequest = { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } }, + }; + const decoded = codec.decodeToolCall(known); + expect(decoded.params?.['name']).toBe('websearch/fetch_content'); + expect(decoded.params?.['arguments']).toEqual({ url: 'https://x' }); + // Original request object is not mutated. + expect(known.params?.['name']).toBe('websearch_fetch_content'); + + const unknown: JsonRpcRequest = { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'not_listed', arguments: {} }, + }; + expect(codec.decodeToolCall(unknown)).toBe(unknown); + }); +}); + +describe('routeWithWireNames', () => { + const listResponse: JsonRpcResponse = { + jsonrpc: '2.0', + id: 1, + result: { tools: [{ name: 'websearch/fetch_content' }, { name: 'searxng/web_url_read' }] }, + }; + + it('serves wire-safe names on tools/list and maps tools/call back', async () => { + const codec = new WireNameCodec(); + const seen: JsonRpcRequest[] = []; + const route = async (req: JsonRpcRequest): Promise => { + seen.push(req); + if (req.method === 'tools/list') return listResponse; + return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'ok' }] } }; + }; + + const listed = await routeWithWireNames(codec, route, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + const names = (listed.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name); + expect(names).toEqual(['websearch_fetch_content', 'searxng_web_url_read']); + + // The exact scenario from the librechat incident: the model echoes the + // wire name; the router must receive the canonical name. + await routeWithWireNames(codec, route, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } }, + }); + expect(seen[1]?.params?.['name']).toBe('websearch/fetch_content'); + }); + + it('keeps legacy slash-name calls working', async () => { + const codec = new WireNameCodec(); + const seen: JsonRpcRequest[] = []; + const route = async (req: JsonRpcRequest): Promise => { + seen.push(req); + return { jsonrpc: '2.0', id: req.id, result: {} }; + }; + await routeWithWireNames(codec, route, { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'websearch/fetch_content', arguments: {} }, + }); + expect(seen[0]?.params?.['name']).toBe('websearch/fetch_content'); + }); + + it('does not touch other methods', async () => { + const codec = new WireNameCodec(); + const init: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }; + const response: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05' } }; + const out = await routeWithWireNames(codec, async () => response, init); + expect(out).toBe(response); + }); +}); -- 2.49.1 From eb0e97e76e7dbd8f6722b27b489c7296251e4b1a Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 25 Aug 2026 20:23:09 +0100 Subject: [PATCH 2/2] 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(); + } + }); +}); -- 2.49.1