Compare commits

...

10 Commits

Author SHA1 Message Date
a87c4faf21 Merge pull request 'test(mcplocal): smoke asserts the wire-form tool-name contract' (#126) from test/wire-safe-smoke into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m23s
CI/CD / lint (push) Successful in 2m49s
CI/CD / test (push) Successful in 1m28s
CI/CD / smoke (push) Failing after 2m0s
CI/CD / build (push) Successful in 4m32s
CI/CD / publish (push) Has been skipped
2026-08-25 21:06:29 +00:00
Michal
b4ad95ca66 test(mcplocal): smoke asserts the wire-form tool-name contract
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m21s
CI/CD / lint (pull_request) Successful in 2m43s
CI/CD / test (pull_request) Successful in 1m29s
CI/CD / build (pull_request) Successful in 2m41s
CI/CD / smoke (pull_request) Failing after 3m27s
CI/CD / publish (pull_request) Has been skipped
The smoke suite talks to the live mcplocal through the HTTP boundary, which
serves wire names since #124 — assertions expecting `favourite/`, `all/` and
`smoke-aws-docs/` prefixes now check the underscore wire form (config pins
stay canonical). These two files were the release-gate failures after the
bbd3188 rollout; 166/166 green against the live fleet after the update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 22:06:18 +01:00
bbd31883e8 Merge pull request 'fix(mcplocal): wire-form tool names in gate and favourite-index prose' (#125) from fix/wire-safe-prose into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m25s
CI/CD / lint (push) Successful in 2m40s
CI/CD / test (push) Successful in 1m30s
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-08-25 20:59:00 +00:00
Michal
c014fcdd82 fix(mcplocal): name tools in wire form in gate and favourite-index prose
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m24s
CI/CD / lint (pull_request) Successful in 2m34s
CI/CD / test (pull_request) Successful in 1m44s
CI/CD / smoke (pull_request) Failing after 3m18s
CI/CD / build (pull_request) Successful in 2m30s
CI/CD / publish (pull_request) Has been skipped
Follow-up to #124: the boundary WireNameCodec serves underscore-joined
names, but the gate plugin's tool inventories (initialize instructions and
begin_session response) still listed canonical `server/tool`, and the
favourite-index instruction told the model to prefer `favourite/<tool>` —
prose naming functions the model cannot call. Inventories now run through
sanitizeWireName and the instruction describes the favourite_/all_ prefixes.

Cosmetic for routing (slash names still pass through the codec) but
load-bearing for tool selection: models copy names out of prose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 21:58:52 +01:00
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
Michal
c16d7964c9 fix(gitea): pin the rebuilt image by digest and match upstream's CMD form
Some checks failed
CI/CD / lint (pull_request) Successful in 1m17s
CI/CD / typecheck (pull_request) Successful in 2m39s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / build (pull_request) Successful in 2m28s
CI/CD / smoke (pull_request) Failing after 3m4s
CI/CD / publish (pull_request) Has been skipped
Two corrections to the shell-bearing rebuild.

**Pin by digest.** It copied from `:latest`, so a rebuild silently ships
whatever upstream has moved to. When a probe started failing right after a
rebuild I could not tell a version change from a broken build, and burned
time on the wrong one — the binary's own `--version` prints 1.1.0 while
the image label says 1.6.0, so that was a red herring too. Now pinned to
sha256:dda8d56e…, which IS the running 1.6.0.

**CMD, not ENTRYPOINT.** Upstream sets `Cmd: ["/app/gitea-mcp"]` with no
entrypoint. mcpd maps a server's `command` to k8s `args`, which REPLACES
Cmd but only APPENDS to an ENTRYPOINT — so the ENTRYPOINT form would have
changed how the binary is invoked for any server that sets a command.
Matching upstream's shape keeps the non-injected path byte-identical.

gitea also needs `entrypoint` on its server row: its `command` is [], so
there is nothing for the injector wrapper to wrap without it. Set to
["/usr/local/bin/gitea-mcp"] via apply -f (patch cannot express an array).

Verified live: gitea RUNNING/healthy on secretDelivery: injector, with
vault-agent-init present and the command wrapped as
  ["/bin/sh","-c",". /vault/secrets/gitea-creds; exec \"$0\" \"$@\"",
   "/usr/local/bin/gitea-mcp"]
`get_me` — which needs read:user, the scope that started this whole
session — returns the real account. Plaintext credentials across all
mcpctl server pod specs: 4 -> 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 17:07:23 +01: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
12 changed files with 649 additions and 61 deletions

View File

@@ -26,11 +26,18 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \ && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=docker.gitea.com/gitea-mcp-server:latest /app/gitea-mcp /usr/local/bin/gitea-mcp # Pinned by DIGEST, not :latest. `latest` moves, and a rebuild that silently
# ships a different server version is indistinguishable from a broken rebuild —
# chased exactly that here when a probe started failing after a rebuild.
# This digest is gitea-mcp-server 1.6.0 (label org.opencontainers.image.version),
# the build that was running when this image was introduced.
# To bump: skopeo inspect docker://docker.gitea.com/gitea-mcp-server:latest
COPY --from=docker.gitea.com/gitea-mcp-server@sha256:dda8d56e6a91fa89cad186becc27c7aa83d74acdd5dc69f89af840d7bb78a631 /app/gitea-mcp /usr/local/bin/gitea-mcp
WORKDIR /app WORKDIR /app
# Kept as ENTRYPOINT so the plain (non-injected) path behaves exactly like # CMD, not ENTRYPOINT — matching upstream, which sets Cmd ["/app/gitea-mcp"] and
# upstream. mcpd REPLACES this with the sourcing wrapper when the server opts # no entrypoint. mcpd maps a server's `command` to k8s `args`, which REPLACES
# into injected delivery — that is why a shell has to exist in the image. # Cmd but only appends to an ENTRYPOINT; keeping the same form means the plain
ENTRYPOINT ["/usr/local/bin/gitea-mcp"] # (non-injected) path behaves byte-identically to upstream.
CMD ["/usr/local/bin/gitea-mcp"]

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

@@ -26,12 +26,21 @@ import type { ToolDefinition } from '../types.js';
/** Per-session state key holding the presented-name → canonical-name map. */ /** Per-session state key holding the presented-name → canonical-name map. */
const RESOLVER_KEY = 'favourite-index:resolver'; const RESOLVER_KEY = 'favourite-index:resolver';
/** The load-bearing "prefer favourite/" instruction (see module doc). */ /**
* The load-bearing "prefer favourite" instruction (see module doc).
*
* Phrased in the WIRE form (underscore-joined): the HTTP boundary's
* WireNameCodec rewrites the presented `favourite/<tool>` and
* `all/<server>/<tool>` names to `favourite_<tool>` / `all_<server>_<tool>`
* before any client sees them, so prose naming the slash form would tell the
* model to call names that are not in its function list.
*/
export const FAVOURITE_INDEX_INSTRUCTION = export const FAVOURITE_INDEX_INSTRUCTION =
'Tools are indexed in two namespaces. PREFER favourite/<tool> — a short ' + 'Tools are indexed in two namespaces. PREFER the favourite_-prefixed tools ' +
'curated list of the common tools that covers most tasks; reach for these ' + '— a short curated list of the common tools that covers most tasks; reach ' +
'first. Use the full catalog under all/<server>/<tool> only if nothing in ' + 'for these first. Use the full catalog under the all_-prefixed names only ' +
"favourites fits. (read_prompts gives this project's own guidance.)"; "if nothing in favourites fits. (read_prompts gives this project's own " +
'guidance.)';
export interface FavouriteIndexConfig { export interface FavouriteIndexConfig {
/** Canonical `server/tool` names to surface as favourites, in display order. */ /** Canonical `server/tool` names to surface as favourites, in display order. */

View File

@@ -16,6 +16,7 @@ import type { TagMatchResult } from '../../gate/tag-matcher.js';
import { LlmPromptSelector, pickCompletionText, type ServerInfer } from '../../gate/llm-selector.js'; import { LlmPromptSelector, pickCompletionText, type ServerInfer } from '../../gate/llm-selector.js';
import type { ProviderRegistry } from '../../providers/registry.js'; import type { ProviderRegistry } from '../../providers/registry.js';
import { withTimeout, TimeoutError } from '../../util/with-timeout.js'; import { withTimeout, TimeoutError } from '../../util/with-timeout.js';
import { sanitizeWireName } from '../../util/wire-names.js';
/** Cap on the gate's LLM prompt-selection. A slow/thinking LLM must never block /** Cap on the gate's LLM prompt-selection. A slow/thinking LLM must never block
* begin_session — on timeout we fall back to deterministic tag matching. */ * begin_session — on timeout we fall back to deterministic tag matching. */
@@ -115,13 +116,15 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
); );
parts.push(`\n${gateInstructions}`); parts.push(`\n${gateInstructions}`);
// Append tool inventory (names only) // Append tool inventory (names only). Sanitized to the wire charset:
// the boundary WireNameCodec serves `server_tool`, so prose listing the
// canonical `server/tool` would name functions the model cannot call.
try { try {
const tools = await ctx.discoverTools(); const tools = await ctx.discoverTools();
if (tools.length > 0) { if (tools.length > 0) {
parts.push('\nAvailable MCP server tools (accessible after begin_session):'); parts.push('\nAvailable MCP server tools (accessible after begin_session):');
for (const t of tools) { for (const t of tools) {
parts.push(` ${t.name}`); parts.push(` ${sanitizeWireName(t.name)}`);
} }
} }
} catch { } catch {
@@ -424,13 +427,14 @@ async function handleBeginSession(
); );
responseParts.push(encouragement); responseParts.push(encouragement);
// Append tool inventory (names only) // Append tool inventory (names only) — wire charset, see the initialize
// inventory note.
try { try {
const tools = await ctx.discoverTools(); const tools = await ctx.discoverTools();
if (tools.length > 0) { if (tools.length > 0) {
responseParts.push('\nAvailable MCP server tools:'); responseParts.push('\nAvailable MCP server tools:');
for (const t of tools) { for (const t of tools) {
responseParts.push(` ${t.name}`); responseParts.push(` ${sanitizeWireName(t.name)}`);
} }
} }
} catch { } catch {

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

@@ -525,8 +525,11 @@ describe('McpRouter gating', () => {
); );
const result = res.result as { instructions: string }; const result = res.result as { instructions: string };
expect(result.instructions).toContain('ha/get_entities'); // Wire form: prose must name what the client can actually call — the
expect(result.instructions).toContain('node-red/get_flows'); // boundary WireNameCodec serves underscore-joined names.
expect(result.instructions).toContain('ha_get_entities');
expect(result.instructions).toContain('node-red_get_flows');
expect(result.instructions).not.toContain('ha/get_entities');
expect(result.instructions).toContain('after begin_session'); expect(result.instructions).toContain('after begin_session');
// Descriptions should NOT be in init instructions (names only) // Descriptions should NOT be in init instructions (names only)
expect(result.instructions).not.toContain('Get all entities'); expect(result.instructions).not.toContain('Get all entities');
@@ -544,7 +547,9 @@ describe('McpRouter gating', () => {
); );
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text; const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
expect(text).toContain('ha/get_entities'); // Wire form in the inventory (see the initialize-instructions test).
expect(text).toContain('ha_get_entities');
expect(text).not.toContain('ha/get_entities');
expect(text).not.toContain('Get all entities'); expect(text).not.toContain('Get all entities');
}); });

View File

@@ -3,9 +3,9 @@
* *
* Provisions an ungated project with favourite-index enabled + two pinned tools * Provisions an ungated project with favourite-index enabled + two pinned tools
* from the smoke-aws-docs server, then verifies through the LIVE mcplocal proxy: * from the smoke-aws-docs server, then verifies through the LIVE mcplocal proxy:
* - initialize instructions carry the load-bearing "prefer favourite/" line, * - initialize instructions carry the load-bearing "prefer favourite_" line,
* - tools/list presents favourite/<tool> (curated) + all/<server>/<tool> (full), * - tools/list presents favourite_<tool> (curated) + all_<server>_<tool> (full, wire form),
* - a favourite/ call and an all/ call both ROUTE to the real upstream * - a favourite_ call and an all_ call both ROUTE to the real upstream
* (they reach the server's arg validation, not a -32601 "unknown tool"). * (they reach the server's arg validation, not a -32601 "unknown tool").
* *
* Run with: pnpm test:smoke * Run with: pnpm test:smoke
@@ -65,40 +65,40 @@ describe('Smoke: favourite-index presentation', () => {
console.log('\n ━━━ favourite-index smoke complete ━━━\n'); console.log('\n ━━━ favourite-index smoke complete ━━━\n');
}); });
it('presents favourite/ + all/ namespaces with the prefer-favourite instruction', async () => { it('presents favourite_ + all_ namespaces with the prefer-favourite instruction', async () => {
if (!ready) return; if (!ready) return;
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME)); const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
chat.section('favourite-index presentation'); chat.section('favourite-index presentation');
try { try {
const initResult = (await chat.initialize()) as { instructions?: string }; const initResult = (await chat.initialize()) as { instructions?: string };
const instructions = initResult?.instructions ?? ''; const instructions = initResult?.instructions ?? '';
chat.check('Instruction mentions favourite/', String(instructions.includes('favourite/')), (v) => v === 'true'); chat.check('Instruction mentions favourite_', String(instructions.includes('favourite_')), (v) => v === 'true');
expect(instructions).toContain('favourite/'); expect(instructions).toContain('favourite_');
const tools = await chat.listTools(); const tools = await chat.listTools();
const names = tools.map((t) => t.name); const names = tools.map((t) => t.name);
const favNames = names.filter((n) => n.startsWith('favourite/')); const favNames = names.filter((n) => n.startsWith('favourite_'));
const allNames = names.filter((n) => n.startsWith('all/')); const allNames = names.filter((n) => n.startsWith('all_'));
chat.check('Has favourite/ tools', favNames.length, (v) => v >= 1); chat.check('Has favourite_ tools', favNames.length, (v) => v >= 1);
chat.check('Has all/ catalog', allNames.length, (v) => v >= 1); chat.check('Has all_ catalog', allNames.length, (v) => v >= 1);
chat.check('favourite/read_documentation present', String(names.includes('favourite/read_documentation')), (v) => v === 'true'); chat.check('favourite_read_documentation present', String(names.includes('favourite_read_documentation')), (v) => v === 'true');
chat.check('all/smoke-aws-docs/read_documentation present', String(names.includes('all/smoke-aws-docs/read_documentation')), (v) => v === 'true'); chat.check('all_smoke-aws-docs_read_documentation present', String(names.includes('all_smoke-aws-docs_read_documentation')), (v) => v === 'true');
// Favourites are listed before the all/ catalog. // Favourites are listed before the all_ catalog.
const firstFav = names.findIndex((n) => n.startsWith('favourite/')); const firstFav = names.findIndex((n) => n.startsWith('favourite_'));
const firstAll = names.findIndex((n) => n.startsWith('all/')); const firstAll = names.findIndex((n) => n.startsWith('all_'));
chat.check('favourites precede all/', String(firstFav < firstAll), (v) => v === 'true'); chat.check('favourites precede all_', String(firstFav < firstAll), (v) => v === 'true');
expect(names).toContain('favourite/read_documentation'); expect(names).toContain('favourite_read_documentation');
expect(names).toContain('all/smoke-aws-docs/read_documentation'); expect(names).toContain('all_smoke-aws-docs_read_documentation');
expect(firstFav).toBeLessThan(firstAll); expect(firstFav).toBeLessThan(firstAll);
} finally { } finally {
await chat.close(); await chat.close();
} }
}, 30_000); }, 30_000);
it('routes favourite/ and all/ calls to the real upstream tool', async () => { it('routes favourite_ and all_ calls to the real upstream tool', async () => {
if (!ready) return; if (!ready) return;
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME)); const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
chat.section('favourite-index routing'); chat.section('favourite-index routing');
@@ -108,15 +108,15 @@ describe('Smoke: favourite-index presentation', () => {
// Calling with no args → the UPSTREAM tool's arg validation fires, proving // Calling with no args → the UPSTREAM tool's arg validation fires, proving
// the presented name routed to the real server (not a -32601 unknown tool). // the presented name routed to the real server (not a -32601 unknown tool).
const favRes = await chat.callTool('favourite/read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message })); const favRes = await chat.callTool('favourite_read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
const allRes = await chat.callTool('all/smoke-aws-docs/read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message })); const allRes = await chat.callTool('all_smoke-aws-docs_read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
const favStr = JSON.stringify(favRes).toLowerCase(); const favStr = JSON.stringify(favRes).toLowerCase();
const allStr = JSON.stringify(allRes).toLowerCase(); const allStr = JSON.stringify(allRes).toLowerCase();
// Reached the upstream (arg validation / real response), not "unknown tool". // Reached the upstream (arg validation / real response), not "unknown tool".
const routed = (s: string): boolean => !s.includes('-32601') && !s.includes('unknown') && !s.includes('method not found'); const routed = (s: string): boolean => !s.includes('-32601') && !s.includes('unknown') && !s.includes('method not found');
chat.check('favourite/ routed to upstream', String(routed(favStr)), (v) => v === 'true'); chat.check('favourite_ routed to upstream', String(routed(favStr)), (v) => v === 'true');
chat.check('all/ routed to upstream', String(routed(allStr)), (v) => v === 'true'); chat.check('all_ routed to upstream', String(routed(allStr)), (v) => v === 'true');
expect(routed(favStr)).toBe(true); expect(routed(favStr)).toBe(true);
expect(routed(allStr)).toBe(true); expect(routed(allStr)).toBe(true);
} finally { } finally {

View File

@@ -158,8 +158,8 @@ describe('Smoke: ProxyModel pipeline', () => {
const ungatedTools = await chat.listTools(); const ungatedTools = await chat.listTools();
chat.check('Ungated tools > 1', ungatedTools.length, (v) => v > 1); chat.check('Ungated tools > 1', ungatedTools.length, (v) => v > 1);
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs/')); const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs_'));
chat.check('Has smoke-aws-docs/* tools', awsTools.length, (v) => v > 0); chat.check('Has smoke-aws-docs_* tools', awsTools.length, (v) => v > 0);
expect(ungatedTools.length).toBeGreaterThan(1); expect(ungatedTools.length).toBeGreaterThan(1);
} finally { } finally {
@@ -228,7 +228,7 @@ describe('Smoke: ProxyModel pipeline', () => {
it('has AWS documentation tools after ungating', async () => { it('has AWS documentation tools after ungating', async () => {
if (!serverResponding) return; if (!serverResponding) return;
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs/')); const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs_'));
chat.check('AWS docs tools available', awsTools.length, (v) => v > 0); chat.check('AWS docs tools available', awsTools.length, (v) => v > 0);
if (awsTools.length > 0) { if (awsTools.length > 0) {
@@ -241,9 +241,9 @@ describe('Smoke: ProxyModel pipeline', () => {
it('can call an AWS documentation tool', async () => { it('can call an AWS documentation tool', async () => {
if (!serverResponding) return; if (!serverResponding) return;
const searchTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/search_documentation'); const searchTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_search_documentation');
const recommendTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/recommend'); const recommendTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_recommend');
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation'); const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
// Prefer search_documentation — most reliable (no URL format requirements) // Prefer search_documentation — most reliable (no URL format requirements)
const toolToTest = searchTool ?? recommendTool ?? readTool; const toolToTest = searchTool ?? recommendTool ?? readTool;
@@ -271,13 +271,13 @@ describe('Smoke: ProxyModel pipeline', () => {
it('large tool result gets paginated with _resultId', async () => { it('large tool result gets paginated with _resultId', async () => {
if (!serverResponding) return; if (!serverResponding) return;
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation'); const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) { if (!readTool) {
chat.skip('read_documentation not available'); chat.skip('read_documentation not available');
return; return;
} }
const result = await chat.callTool('smoke-aws-docs/read_documentation', { const result = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html', url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
}); });
const text = result.content[0]?.text ?? ''; const text = result.content[0]?.text ?? '';
@@ -296,13 +296,13 @@ describe('Smoke: ProxyModel pipeline', () => {
it('section drill-down via _resultId and _section', async () => { it('section drill-down via _resultId and _section', async () => {
if (!serverResponding) return; if (!serverResponding) return;
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation'); const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) { if (!readTool) {
chat.skip('read_documentation not available'); chat.skip('read_documentation not available');
return; return;
} }
const result = await chat.callTool('smoke-aws-docs/read_documentation', { const result = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html', url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
}); });
const text = result.content[0]?.text ?? ''; const text = result.content[0]?.text ?? '';
@@ -315,7 +315,7 @@ describe('Smoke: ProxyModel pipeline', () => {
const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, ''); const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, '');
const sectionResult = await chat.callTool('smoke-aws-docs/read_documentation', { const sectionResult = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html', url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
_resultId: resultId, _resultId: resultId,
_section: 'page-1', _section: 'page-1',
@@ -386,13 +386,13 @@ describe('Smoke: ProxyModel pipeline', () => {
it('subindex model produces structural sections (not flat pages)', async () => { it('subindex model produces structural sections (not flat pages)', async () => {
if (!serverResponding) return; if (!serverResponding) return;
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs/read_documentation'); const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) { if (!readTool) {
chat.skip('read_documentation not available'); chat.skip('read_documentation not available');
return; return;
} }
const result = await chat.callTool('smoke-aws-docs/read_documentation', { const result = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html', url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
}); });
const text = result.content[0]?.text ?? ''; const text = result.content[0]?.text ?? '';
@@ -409,13 +409,13 @@ describe('Smoke: ProxyModel pipeline', () => {
it('subindex drill-down returns section content', async () => { it('subindex drill-down returns section content', async () => {
if (!serverResponding) return; if (!serverResponding) return;
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs/read_documentation'); const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) { if (!readTool) {
chat.skip('read_documentation not available'); chat.skip('read_documentation not available');
return; return;
} }
const result = await chat.callTool('smoke-aws-docs/read_documentation', { const result = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html', url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
}); });
const text = result.content[0]?.text ?? ''; const text = result.content[0]?.text ?? '';
@@ -428,7 +428,7 @@ describe('Smoke: ProxyModel pipeline', () => {
const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, ''); const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, '');
const sectionResult = await chat.callTool('smoke-aws-docs/read_documentation', { const sectionResult = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html', url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
_resultId: resultId, _resultId: resultId,
_section: 'section-0', _section: 'section-0',
@@ -482,14 +482,14 @@ describe('Smoke: ProxyModel pipeline', () => {
if (!serverResponding) return; if (!serverResponding) return;
const tools = await chat.listTools(); const tools = await chat.listTools();
const readTool = tools.find((t) => t.name === 'smoke-aws-docs/read_documentation'); const readTool = tools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) { if (!readTool) {
chat.skip('read_documentation not available'); chat.skip('read_documentation not available');
return; return;
} }
chat.info('Call 1: using default model (passthrough + paginate)'); chat.info('Call 1: using default model (passthrough + paginate)');
const result1 = await chat.callTool('smoke-aws-docs/read_documentation', { const result1 = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html', url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
}); });
const text1 = result1.content[0]?.text ?? ''; const text1 = result1.content[0]?.text ?? '';
@@ -516,7 +516,7 @@ describe('Smoke: ProxyModel pipeline', () => {
); );
chat.info('Call 2: using new model (should produce different output)'); chat.info('Call 2: using new model (should produce different output)');
const result2 = await chat.callTool('smoke-aws-docs/read_documentation', { const result2 = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html', url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
}); });
const text2 = result2.content[0]?.text ?? ''; const text2 = result2.content[0]?.text ?? '';

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