Compare commits

...

9 Commits

Author SHA1 Message Date
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
Michal
f097c0f4d5 feat(gitea): rebuild gitea-mcp on a shell-bearing base
Some checks failed
CI/CD / lint (pull_request) Successful in 1m20s
CI/CD / test (pull_request) Successful in 1m30s
CI/CD / typecheck (pull_request) Successful in 2m52s
CI/CD / smoke (pull_request) Failing after 2m1s
CI/CD / build (pull_request) Successful in 2m23s
CI/CD / publish (pull_request) Has been skipped
Upstream docker.gitea.com/gitea-mcp-server is distroless — `Cmd` is
["/app/gitea-mcp"] and there is no /bin/sh at any path (verified by
exec'ing every candidate against the running pod).

That is fine until the server wants secretDelivery: injector. The OpenBao
agent renders secrets to a FILE, so mcpd wraps the container command as
`sh -c '. /vault/secrets/<name>; exec "$0" "$@"'`, which needs a shell.
gitea was the only server in the fleet blocked on this, and so the only
one whose token had to stay inline in its pod spec.

Copying one static Go binary onto debian:stable-slim is cheaper than
building and maintaining a static envexec shim, and follows the precedent
in deploy/Dockerfile.docmost-mcp — this repo already rebuilds third-party
MCP servers when it needs to change how they run.

ca-certificates is required rather than incidental: the binary talks HTTPS
to mysources.co.uk and the distroless base shipped a trust store we are
leaving behind. Verified in the built image: shell present, binary runs,
ca-certificates.crt present.

ENTRYPOINT is kept so the plain (non-injected) path behaves exactly like
upstream; mcpd replaces it with the sourcing wrapper only when the server
opts in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 11:16:23 +01:00
c79bdab51b Merge pull request 'fix(secrets): wrap an image server's own command, not just entrypoint' (#121) from fix/injector-image-server-argv into main
Some checks failed
CI/CD / lint (push) Has been cancelled
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-08-21 10:14:42 +00:00
11 changed files with 674 additions and 14 deletions

View File

@@ -0,0 +1,43 @@
# gitea-mcp-server, rebuilt on a shell-bearing base.
#
# WHY THIS EXISTS
# ---------------
# Upstream `docker.gitea.com/gitea-mcp-server` is distroless: `Cmd` is
# ["/app/gitea-mcp"] and there is no /bin/sh at any path (verified by exec'ing
# every candidate against the running pod).
#
# That is fine until the server needs `secretDelivery: injector`. The OpenBao
# agent renders secrets to a FILE, so mcpd wraps the container command as
# `sh -c '. /vault/secrets/<name>; exec "$0" "$@"'` — which needs a shell. With
# no shell the pod cannot source its own credentials, and gitea was the single
# server in the fleet blocked on this.
#
# Copying one static Go binary onto debian:stable-slim is cheaper than building
# and maintaining a static "envexec" shim, and follows the precedent already set
# by deploy/Dockerfile.docmost-mcp — this repo already rebuilds third-party MCP
# servers when it needs to change how they run.
#
# ca-certificates is required, not incidental: the binary talks HTTPS to
# https://mysources.co.uk and a distroless base ships its own trust store which
# we are leaving behind.
FROM debian:stable-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 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
# CMD, not ENTRYPOINT — matching upstream, which sets Cmd ["/app/gitea-mcp"] and
# no entrypoint. mcpd maps a server's `command` to k8s `args`, which REPLACES
# Cmd but only appends to an ENTRYPOINT; keeping the same form means the plain
# (non-injected) path behaves byte-identically to upstream.
CMD ["/usr/local/bin/gitea-mcp"]

36
scripts/build-gitea-mcp.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Build gitea-mcp Docker image and push to Gitea container registry
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env for GITEA_TOKEN
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Push directly to internal address (external proxy has body size limit)
REGISTRY="10.0.0.194:3012"
IMAGE="gitea-mcp"
TAG="${1:-latest}"
echo "==> Building gitea-mcp image..."
podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.gitea-mcp .
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
echo "==> Logging in to $REGISTRY..."
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
# Ensure package is linked to the repository
source "$SCRIPT_DIR/link-package.sh"
link_package "container" "$IMAGE"
echo "==> Done!"
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"

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

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