Compare commits

..

5 Commits

Author SHA1 Message Date
7fbb827aa5 Merge pull request 'fix(mcplocal): serve OpenAI-safe tool names on the wire' (#124) from fix/wire-safe-tool-names into main
Some checks failed
CI/CD / lint (push) Successful in 1m20s
CI/CD / typecheck (push) Successful in 1m21s
CI/CD / test (push) Successful in 1m28s
CI/CD / build (push) Successful in 2m28s
CI/CD / smoke (push) Failing after 3m1s
CI/CD / publish (push) Has been skipped
2026-08-25 19:23:19 +00:00
Michal
eb0e97e76e test(mcplocal): end-to-end wire-name coverage on the project endpoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m21s
CI/CD / test (pull_request) Successful in 3m42s
CI/CD / smoke (pull_request) Failing after 3m5s
CI/CD / build (pull_request) Successful in 2m26s
CI/CD / publish (pull_request) Has been skipped
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 20:23:09 +01:00
Michal
065ce02a60 fix(mcplocal): serve OpenAI-safe tool names on the wire
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m40s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / smoke (pull_request) Failing after 2m3s
CI/CD / build (pull_request) Successful in 4m58s
CI/CD / publish (pull_request) Has been skipped
The proxy namespaces tools as `server/tool` (and favourite-index presents
`favourite/<tool>` / `all/<server>/<tool>`). 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 20:17:33 +01:00
9c5d0d1861 Merge pull request 'fix(gitea): pin rebuilt image by digest, match upstream CMD form' (#123) from fix/gitea-mcp-digest-pin into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m18s
CI/CD / lint (push) Successful in 2m30s
CI/CD / test (push) Successful in 1m27s
CI/CD / smoke (push) Failing after 2m1s
CI/CD / build (push) Successful in 4m27s
CI/CD / publish (push) Has been skipped
2026-08-21 16:07:31 +00:00
7716e424f9 Merge pull request 'feat(gitea): rebuild gitea-mcp on a shell-bearing base' (#122) from feat/gitea-mcp-shell-base into main
Some checks failed
CI/CD / lint (push) Successful in 1m14s
CI/CD / test (push) Successful in 1m26s
CI/CD / typecheck (push) Successful in 2m48s
CI/CD / smoke (push) Failing after 3m7s
CI/CD / build (push) Successful in 2m12s
CI/CD / publish (push) Has been skipped
2026-08-21 10:16:28 +00:00
6 changed files with 565 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { McpRouter } from '../router.js'; import type { McpRouter } from '../router.js';
import type { JsonRpcRequest } from '../types.js'; import type { JsonRpcRequest } from '../types.js';
import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js';
interface SessionEntry { interface SessionEntry {
transport: StreamableHTTPServerTransport; transport: StreamableHTTPServerTransport;
@@ -20,6 +21,9 @@ interface SessionEntry {
export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void { export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void {
const sessions = new Map<string, SessionEntry>(); const sessions = new Map<string, SessionEntry>();
// 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.) // POST /mcp — JSON-RPC requests (initialize, tools/call, etc.)
app.post('/mcp', async (request, reply) => { app.post('/mcp', async (request, reply) => {
@@ -52,7 +56,11 @@ export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): vo
transport.onmessage = async (message: JSONRPCMessage) => { transport.onmessage = async (message: JSONRPCMessage) => {
// The transport sends us JSON-RPC messages; route them through McpRouter // The transport sends us JSON-RPC messages; route them through McpRouter
if ('method' in message && 'id' in message) { 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); await transport.send(response as unknown as JSONRPCMessage);
} }
// Notifications (no id) are ignored — router doesn't handle inbound notifications // Notifications (no id) are ignored — router doesn't handle inbound notifications

View File

@@ -27,6 +27,7 @@ import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-inde
import { composePlugins } from '../proxymodel/plugins/compose.js'; import { composePlugins } from '../proxymodel/plugins/compose.js';
import type { ProxyModelPlugin } from '../proxymodel/plugin.js'; import type { ProxyModelPlugin } from '../proxymodel/plugin.js';
import { AuditCollector } from '../audit/collector.js'; import { AuditCollector } from '../audit/collector.js';
import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js';
interface ProjectCacheEntry { interface ProjectCacheEntry {
router: McpRouter; router: McpRouter;
@@ -45,6 +46,10 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
let resolvedUserName: string | null | undefined; // undefined = not yet resolved let resolvedUserName: string | null | undefined; // undefined = not yet resolved
const projectCache = new Map<string, ProjectCacheEntry>(); const projectCache = new Map<string, ProjectCacheEntry>();
const sessions = new Map<string, SessionEntry>(); const sessions = new Map<string, SessionEntry>();
// 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<string, WireNameCodec>();
/** Resolve the mcplocal owner's userName once from /auth/me using mcplocal's own credentials. */ /** Resolve the mcplocal owner's userName once from /auth/me using mcplocal's own credentials. */
async function ensureUserName(): Promise<string | null> { async function ensureUserName(): Promise<string | null> {
@@ -335,7 +340,18 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
}); });
const ctx = transport.sessionId ? { sessionId: transport.sessionId, correlationId } : { correlationId }; 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 // Forward queued notifications BEFORE the response — the response send
// closes the POST SSE stream, so notifications must go first. // closes the POST SSE stream, so notifications must go first.

View File

@@ -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/<tool>`, `all/<server>/<tool>` and
* `agent-<name>/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<string, string>();
/** presented (internal) name → wire name */
private toWire = new Map<string, string>();
/**
* 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<string, unknown>;
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<string, unknown>), 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<JsonRpcResponse>,
request: JsonRpcRequest,
): Promise<JsonRpcResponse> {
const inbound = request.method === 'tools/call' ? codec.decodeToolCall(request) : request;
const response = await route(inbound);
return request.method === 'tools/list' ? codec.encodeToolsList(response) : response;
}

View File

@@ -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<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();
}
});
});

View File

@@ -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<JsonRpcResponse> => {
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<typeof import('../src/http/config.js')>('../src/http/config.js');
return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) };
});
function mockMcpdClient() {
const client: Record<string, unknown> = {
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<typeof vi.fn>).mockReturnValue(client);
(client.withToken as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withTimeout as ReturnType<typeof vi.fn>).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();
}
});
});

View File

@@ -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<JsonRpcResponse> => {
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<JsonRpcResponse> => {
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);
});
});