Compare commits

..

14 Commits

Author SHA1 Message Date
7fbb827aa5 Merge pull request 'fix(mcplocal): serve OpenAI-safe tool names on the wire' (#124) from fix/wire-safe-tool-names into main
Some checks failed
CI/CD / lint (push) Successful in 1m20s
CI/CD / typecheck (push) Successful in 1m21s
CI/CD / test (push) Successful in 1m28s
CI/CD / build (push) Successful in 2m28s
CI/CD / smoke (push) Failing after 3m1s
CI/CD / publish (push) Has been skipped
2026-08-25 19:23:19 +00:00
Michal
eb0e97e76e test(mcplocal): end-to-end wire-name coverage on the project endpoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m18s
CI/CD / typecheck (pull_request) Successful in 1m21s
CI/CD / test (pull_request) Successful in 3m42s
CI/CD / smoke (pull_request) Failing after 3m5s
CI/CD / build (pull_request) Successful in 2m26s
CI/CD / publish (pull_request) Has been skipped
Drives /projects/:name/mcp over the real Streamable HTTP transport with a
fake websearch upstream: tools/list must serve `websearch_fetch_content`
(every name matching the OpenAI function-name charset), calling that wire
name must reach the upstream as bare `fetch_content`, and a legacy client
echoing the canonical `websearch/fetch_content` must still route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 20:23:09 +01:00
Michal
065ce02a60 fix(mcplocal): serve OpenAI-safe tool names on the wire
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m22s
CI/CD / lint (pull_request) Successful in 2m40s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / smoke (pull_request) Failing after 2m3s
CI/CD / build (pull_request) Successful in 4m58s
CI/CD / publish (pull_request) Has been skipped
The proxy namespaces tools as `server/tool` (and favourite-index presents
`favourite/<tool>` / `all/<server>/<tool>`). A `/` is not a valid character
in an OpenAI-style function name, so hosts that forward MCP tool names
verbatim as LLM function names depend on the model faithfully echoing an
illegal name. LibreChat did exactly that: deepseek-v4-flash intermittently
dropped the `websearch/` prefix, LibreChat's registry lookup failed, and it
reported "This tool's MCP server is temporarily unavailable" while nothing
was down — the calls never reached mcplocal at all (confirmed against the
AuditEvent table, 2026-08-25). Claude Code and the pi extension only dodge
this because they sanitize names client-side.

Fix at the HTTP boundary only: a WireNameCodec rewrites tools/list responses
to wire-safe names (`/` -> `_`, exact-match reverse map, deterministic
suffix on collision) and maps tools/call names back before routing. Wired
into both /mcp and /projects/:name/mcp. Everything inside the proxy —
routing maps, plugins, favourites config, audit events — keeps canonical
names, and unknown inbound names (legacy clients echoing slash names,
virtual tools) pass through unchanged, so existing clients keep working.

Codecs are keyed per project and outlive the router cache TTL so a client
can call a tool it listed minutes earlier; after a restart the client's
initialize-time tools/list repopulates the map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir
2026-08-25 20:17:33 +01:00
9c5d0d1861 Merge pull request 'fix(gitea): pin rebuilt image by digest, match upstream CMD form' (#123) from fix/gitea-mcp-digest-pin into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m18s
CI/CD / lint (push) Successful in 2m30s
CI/CD / test (push) Successful in 1m27s
CI/CD / smoke (push) Failing after 2m1s
CI/CD / build (push) Successful in 4m27s
CI/CD / publish (push) Has been skipped
2026-08-21 16:07:31 +00:00
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
Michal
913c0fbdc6 fix(secrets): wrap an image server's own command, not just entrypoint
Some checks failed
CI/CD / lint (pull_request) Successful in 1m25s
CI/CD / test (pull_request) Successful in 1m32s
CI/CD / typecheck (pull_request) Successful in 3m0s
CI/CD / smoke (pull_request) Failing after 2m0s
CI/CD / build (pull_request) Successful in 4m34s
CI/CD / publish (pull_request) Has been skipped
Migrating docmost and my-home-assistant, both rendered their secret and
neither picked it up. The pod spec showed why:

  command: (empty)
  args:    ["node","build/index.js"]
  server.entrypoint: (unset)

wrapCommand consulted only `server.entrypoint` for non-package servers, so
with `entrypoint` unset it returned undefined, the wrapper was skipped
entirely, and the container ran its normal command — which never sourced
/vault/secrets/<name>. The failure is silent by construction: the agent
init container succeeds, the file is there, and the server simply starts
with empty credentials.

An explicit `command` on an image server is already a complete command
line — mcpd's exec mode would run exactly it — so it should be wrapped
verbatim. `entrypoint` is only needed when there is no command at all and
the image's own ENTRYPOINT would take over.

Now branches on the three real shapes: package server (prepend the runner
entrypoint mcpd owns), image + command (use verbatim), image only (require
the declared entrypoint).

Seven tests, one per shape plus the two undefined cases. Reintroducing the
old logic fails two of them — checked before keeping.

Both servers were rolled back to secretDelivery: env and are healthy; they
can migrate once this ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 11:14:39 +01:00
beb57baf58 Merge pull request 'fix(secrets): injector-delivered servers must attach, not exec' (#120) from feat/injector-attach-mode into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m19s
CI/CD / lint (push) Successful in 2m24s
CI/CD / test (push) Successful in 1m27s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 4m40s
CI/CD / publish (push) Has been skipped
2026-08-21 01:05:53 +00:00
Michal
0a83f71648 fix(secrets): injector-delivered servers must attach, not exec
Some checks failed
CI/CD / lint (pull_request) Successful in 1m17s
CI/CD / typecheck (pull_request) Successful in 2m41s
CI/CD / test (pull_request) Successful in 1m28s
CI/CD / build (pull_request) Successful in 2m18s
CI/CD / smoke (pull_request) Failing after 3m3s
CI/CD / publish (pull_request) Has been skipped
With injected delivery the credentials exist ONLY in PID 1's environment:
the container command is a shell that sources /vault/secrets/<name> and
execs the real server, so the values live in that process and nowhere
else — not in the pod spec, which is the whole point.

mcpd picks its STDIO mode from the server's shape: an explicit command or
a packageName selects `exec`, a bare dockerImage selects `attach`. `exec`
spawns a NEW process inside the container, which never sourced the file
and therefore starts with empty credentials. The server comes up, answers
tools/list, and fails every authenticated call — precisely the silent
empty-token failure this feature exists to prevent.

Seen as `Readiness check (list_datasources) failed: process exited 1` on a
pod whose PID 1 demonstrably held the token (verified via /proc/1/environ:
GRAFANA_URL set, token 46 chars, nothing in the pod spec).

So secretDelivery: injector now forces attach regardless of server shape.
Safe because wrapCommandForInjector execs rather than forks, so PID 1 IS
the server. It also means no bouncer or HTTP shim is needed — mcpd's
PersistentStdio already holds one long-lived connection; it was simply
pointed at the wrong process.

Note this inverts an assumption I had earlier: image-entrypoint servers
were already on the attach path and would have worked; it is the
package-based ones that were broken. gitea remains the sole exception, and
for the unrelated reason that it is distroless and has no shell to source
the file.

Extracts the decision as a pure `chooseStdioMode()` so it can be tested —
it is subtle and fails silently. Six cases pinned, including that env
delivery still execs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 02:05:51 +01:00
f25616f720 Merge pull request 'fix(secrets): injector wrapper must replace the entrypoint' (#119) from fix/injector-entrypoint into main
Some checks failed
CI/CD / typecheck (push) Successful in 1m21s
CI/CD / lint (push) Successful in 2m35s
CI/CD / test (push) Successful in 1m28s
CI/CD / smoke (push) Failing after 1m59s
CI/CD / build (push) Successful in 4m39s
CI/CD / publish (push) Has been skipped
2026-08-21 00:28:33 +00:00
Michal
ec35e1cc36 fix(secrets): the injector wrapper must replace the entrypoint, not extend it
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 2m35s
CI/CD / test (pull_request) Successful in 1m27s
CI/CD / build (pull_request) Successful in 2m24s
CI/CD / smoke (pull_request) Failing after 3m5s
CI/CD / publish (pull_request) Has been skipped
Caught migrating a real server: the pod crashlooped with
`.: cannot open /vault/secrets/grafana-creds`, and the reason was in the
generated spec:

  args: ["/bin/sh","-c",". /vault/secrets/grafana-creds; exec \"$0\" \"$@\"",
         "@leval/mcp-grafana"]

mcpd deliberately maps ContainerSpec.command -> k8s `args` so a package
server keeps its runner image's ENTRYPOINT (`npx -y`, `uvx`). Putting the
sourcing wrapper there meant the pod actually ran
`npx -y /bin/sh -c '...'` — npx trying to resolve a package called
/bin/sh. The agent had rendered the file correctly; nothing ever sourced it.

Adds `ContainerSpec.entrypoint`, which maps to k8s `command` and so
REPLACES the image entrypoint, and has wrapCommand fold that entrypoint
into the argv it returns (`npx -y` / `uvx` for package servers, the
server's own `entrypoint` field for dockerImage servers — already required
at validation for exactly this reason).

Two tests pin it: a wrapped server emits `command` and no `args`; an
unwrapped one still emits `args` and no `command`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vybEitX4FykeMatKe5Xki
2026-08-21 01:28:21 +01:00
13421008c7 Merge pull request 'fix(servers): persist secretDelivery and entrypoint' (#118) from fix/server-repo-field-mapping into main
Some checks failed
CI/CD / lint (push) Successful in 1m15s
CI/CD / test (push) Successful in 1m32s
CI/CD / typecheck (push) Successful in 2m48s
CI/CD / smoke (push) Failing after 2m0s
CI/CD / build (push) Successful in 3m31s
CI/CD / publish (push) Has been skipped
2026-08-20 22:41:12 +00:00
16 changed files with 897 additions and 39 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

@@ -564,8 +564,13 @@ export class InstanceService {
spec.serviceAccountName = identity;
spec.automountServiceAccountToken = true;
spec.annotations = this.serverIdentity!.annotationsFor(identity, spec.envFromSecret);
// Replaces the image entrypoint (see wrapCommand); the original argv
// is folded in, so spec.command must not also be emitted as args.
const wrapped = this.serverIdentity!.wrapCommand(server, spec.command);
if (wrapped !== undefined) spec.command = wrapped;
if (wrapped !== undefined) {
spec.entrypoint = wrapped;
delete spec.command;
}
} catch (idErr) {
const msg = idErr instanceof Error ? idErr.message : String(idErr);
return this.markInstanceError(instance, `secret identity provisioning failed: ${msg}`);

View File

@@ -259,7 +259,12 @@ function buildContainerSpec(spec: ContainerSpec) {
// In Docker, spec.command maps to Cmd (args to entrypoint).
// In k8s, we use `args` to pass arguments to the image's entrypoint,
// preserving the runner image's entrypoint (uvx, npx -y, etc.)
if (spec.command && spec.command.length > 0) {
//
// `entrypoint` is the exception: it REPLACES the entrypoint (k8s `command`),
// which injected secret delivery needs so the sourcing shell can be PID 1.
if (spec.entrypoint && spec.entrypoint.length > 0) {
container.command = spec.entrypoint;
} else if (spec.command && spec.command.length > 0) {
container.args = spec.command;
}

View File

@@ -62,6 +62,59 @@ function parseStreamableResponse(body: string): McpProxyResponse {
return JSON.parse(body) as McpProxyResponse;
}
/**
* Decide how mcpd opens a STDIO session against a running container.
*
* attach → connect to PID 1's stdin/stdout
* exec → spawn a NEW process inside the container
*
* Pure and exported so the choice is testable: it is subtle, and getting it
* wrong fails silently rather than loudly (see the injector case below).
*/
export function chooseStdioMode(server: {
name: string;
id: string;
secretDelivery?: string | null;
command?: string[] | null;
packageName?: string | null;
dockerImage?: string | null;
runtime?: string | null;
}): StdioMode {
// Injected delivery MUST attach, whatever the server type.
//
// The secrets exist only in PID 1's environment: the container command is a
// shell that sources /vault/secrets/<name> and execs the real server, so the
// values live in that process and nowhere else — not in the pod spec, which
// is the entire point of the feature.
//
// `exec` spawns a NEW process, which never sourced the file and so starts
// with empty credentials. The server comes up, answers tools/list, and fails
// every authenticated call — the exact silent-empty-token failure this
// feature exists to prevent. Observed as
// `Readiness check (list_datasources) failed: process exited 1` on a pod
// whose PID 1 demonstrably held the token.
//
// Safe because wrapCommandForInjector execs rather than forks, so PID 1 IS
// the server process.
if (server.secretDelivery === 'injector') return { kind: 'attach' };
if (server.command !== null && server.command !== undefined && server.command.length > 0) {
return { kind: 'exec', command: server.command };
}
if (server.packageName !== null && server.packageName !== undefined && server.packageName !== '') {
return { kind: 'exec', command: buildRuntimeSpawnCmd(server.runtime ?? 'node', server.packageName) };
}
// Image entrypoint IS the MCP server.
if (server.dockerImage !== null && server.dockerImage !== undefined && server.dockerImage !== '') {
return { kind: 'attach' };
}
throw new InvalidStateError(
`Server '${server.name}' (${server.id}) uses STDIO transport but has no ` +
`packageName, command, or dockerImage. Configure one of these.`,
);
}
export class McpProxyService {
/** Session IDs per server for streamable-http protocol */
private sessions = new Map<string, string>();
@@ -159,20 +212,15 @@ export class McpProxyService {
// - command set → exec the given command in the container.
// - dockerImage only → attach to PID 1 (image entrypoint IS the MCP server).
// - nothing → unreachable, reject.
const runtime = (server.runtime as string | null) ?? 'node';
let mode: StdioMode;
if (command && command.length > 0) {
mode = { kind: 'exec', command };
} else if (packageName) {
mode = { kind: 'exec', command: buildRuntimeSpawnCmd(runtime, packageName) };
} else if (dockerImage) {
mode = { kind: 'attach' };
} else {
throw new InvalidStateError(
`Server '${server.name}' (${server.id}) uses STDIO transport but has no ` +
`packageName, command, or dockerImage. Configure one of these.`,
);
}
const mode = chooseStdioMode({
name: server.name as string,
id: server.id as string,
secretDelivery: server.secretDelivery as string | null,
command,
packageName,
dockerImage,
runtime: server.runtime as string | null,
});
// Try persistent connection first
try {
@@ -181,7 +229,7 @@ export class McpProxyService {
this.removeClient(instance.containerId);
// Fall back to one-shot exec when we have a command to run.
if (mode.kind === 'exec') {
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params, 120_000, command, runtime);
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params, 120_000, command, (server.runtime as string | null) ?? 'node');
}
// Attach mode has no one-shot equivalent, but the failure is usually
// a stale pipe from an in-place container restart — retry once

View File

@@ -62,6 +62,17 @@ export interface ContainerSpec {
serviceAccountName?: string;
/** Injected agents need the projected SA token; plain servers do not. */
automountServiceAccountToken?: boolean;
/**
* REPLACES the image's ENTRYPOINT (k8s `command`), unlike `command`, which is
* appended to it as `args`.
*
* Needed only for injected secret delivery: the container has to run a shell
* that sources the rendered file before exec'ing the real process, and that
* shell must BE the entrypoint. Because it replaces the entrypoint, the argv
* here has to include whatever the image's entrypoint would have contributed
* (`npx -y`, `uvx`, ...).
*/
entrypoint?: string[];
/** Host port to bind (null = auto-assign) */
hostPort?: number | null;
/** Container port to expose */

View File

@@ -96,26 +96,6 @@ export class ServerIdentityService {
});
}
/**
* Rewrite argv so the rendered secrets are sourced before the server runs.
*
* `command` is what mcpd already computed: for package-based servers that is
* the runner image's entrypoint plus the package, which mcpd owns. For a
* dockerImage server it may be absent — the image's own ENTRYPOINT would run,
* and mcpd cannot introspect it, which is why `entrypoint` is required on the
* server row in that case (enforced at validation).
*/
wrapCommand(
server: Pick<McpServer, 'env' | 'entrypoint'>,
command: string[] | undefined,
): string[] | undefined {
const secretNames = this.secretNamesFor(server);
if (secretNames.length === 0) return command;
const argv = command ?? (server.entrypoint as string[] | null) ?? undefined;
if (argv === undefined || argv.length === 0) return command;
return wrapCommandForInjector(argv, secretNames);
}
/** Identity name for a server — also the SA, policy and role name. */
identityNameFor(serverName: string): string {
return `${IDENTITY_PREFIX}${serverName}`;
@@ -135,6 +115,51 @@ export class ServerIdentityService {
return [...names].sort((a, b) => a.localeCompare(b));
}
/**
* Build the container ENTRYPOINT that sources the rendered secrets and then
* execs the real server.
*
* This must REPLACE the image's entrypoint, not extend it. mcpd normally puts
* a package server's argv into k8s `args` so the runner image's `npx -y` /
* `uvx` entrypoint still runs; a wrapper placed there would be executed BY
* npx (`npx -y /bin/sh -c ...`) and fail. So the argv returned here folds in
* whatever the image's entrypoint would have contributed.
*
* Returns undefined when there is nothing to wrap, leaving the pod on the
* normal entrypoint+args path.
*/
wrapCommand(
server: Pick<McpServer, 'env' | 'entrypoint' | 'packageName' | 'runtime'>,
command: string[] | undefined,
): string[] | undefined {
const secretNames = this.secretNamesFor(server);
if (secretNames.length === 0) return undefined;
// Build the COMPLETE argv the container should run. It differs by shape:
//
// package server — mcpd owns the runner image, whose ENTRYPOINT
// (`npx -y` / `uvx`) is lost once we take over
// `command`, so it must be prepended here.
// image + command — `command` is already a full command line; mcpd would
// have run exactly it. Prepending anything breaks it.
// image only — the image's own ENTRYPOINT would run and mcpd cannot
// introspect it, so the row must declare `entrypoint`.
//
// Getting this wrong returns undefined and SILENTLY skips the wrapper: the
// agent still renders the file, nothing sources it, and the server starts
// with empty credentials. Observed on docmost and my-home-assistant, which
// carry a `command` but no `entrypoint`.
const hasPackage = server.packageName !== null && server.packageName !== undefined && server.packageName !== '';
const argv = hasPackage
? [...(server.runtime === 'python' ? ['uvx'] : ['npx', '-y']), ...(command ?? [server.packageName as string])]
: command !== undefined && command.length > 0
? command
: ((server.entrypoint as string[] | null) ?? undefined);
if (argv === undefined || argv.length === 0) return undefined;
return wrapCommandForInjector(argv, secretNames);
}
/**
* Converge the identity for one server. Returns the identity name so the
* caller can stamp it onto the pod spec.

View File

@@ -0,0 +1,58 @@
/**
* Which argv the injector wrapper wraps, by server shape.
*
* Getting this wrong is SILENT: wrapCommand returns undefined, the wrapper is
* skipped, the agent still renders /vault/secrets/<name>, nothing sources it,
* and the server starts with empty credentials. Observed live on docmost and
* my-home-assistant, which carry a `command` but no `entrypoint`.
*/
import { describe, it, expect } from 'vitest';
import { ServerIdentityService } from '../src/services/server-identity.service.js';
import type { SecretBackendService } from '../src/services/secret-backend.service.js';
const svc = new ServerIdentityService(
{} as unknown as SecretBackendService,
{ namespace: 'mcpctl-servers', ensure: async () => undefined, remove: async () => undefined },
);
const withSecret = { env: [{ name: 'T', valueFrom: { secretRef: { name: 'creds', key: 'K' } } }] };
/** The wrapper is `sh -c <script> arg0 arg1...`; argv starts at index 3. */
const argvOf = (r: string[] | undefined): string[] | undefined => r?.slice(3);
describe('wrapCommand argv by server shape', () => {
it('prepends the node runner entrypoint for a package server', () => {
const r = svc.wrapCommand({ ...withSecret, packageName: '@leval/mcp-grafana', runtime: 'node', entrypoint: null } as never, ['@leval/mcp-grafana']);
expect(argvOf(r)).toEqual(['npx', '-y', '@leval/mcp-grafana']);
});
it('prepends uvx for a python package server', () => {
const r = svc.wrapCommand({ ...withSecret, packageName: 'mcp-searxng', runtime: 'python', entrypoint: null } as never, ['mcp-searxng']);
expect(argvOf(r)).toEqual(['uvx', 'mcp-searxng']);
});
it('uses an image server\'s command verbatim — prepending anything breaks it', () => {
// The docmost/home-assistant regression: this used to return undefined.
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, ['node', 'build/index.js']);
expect(argvOf(r)).toEqual(['node', 'build/index.js']);
});
it('falls back to the declared entrypoint for an image server with no command', () => {
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: ['/usr/local/bin/gitea-mcp'] } as never, undefined);
expect(argvOf(r)).toEqual(['/usr/local/bin/gitea-mcp']);
});
it('returns undefined when there is genuinely nothing to run', () => {
expect(svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, undefined)).toBeUndefined();
});
it('returns undefined for a server with no secret refs', () => {
expect(svc.wrapCommand({ env: [], packageName: 'p', runtime: 'node', entrypoint: null } as never, ['p'])).toBeUndefined();
});
it('always sources before exec, whatever the shape', () => {
const r = svc.wrapCommand({ ...withSecret, packageName: null, entrypoint: null } as never, ['node', 'x.js']);
expect(r?.[0]).toBe('/bin/sh');
expect(r?.[2]).toContain('. /vault/secrets/creds');
expect(r?.[2]).toContain('exec "$0" "$@"');
});
});

View File

@@ -136,3 +136,23 @@ describe('shell quoting survives adversarial values', () => {
expect(out).toBe('');
});
});
describe('the wrapper must REPLACE the image entrypoint, not extend it', () => {
// Regression: mcpd maps ContainerSpec.command -> k8s `args` so the runner
// image's `npx -y` / `uvx` entrypoint still runs. Emitting the wrapper there
// meant the pod actually ran `npx -y /bin/sh -c '...'`, which crashlooped.
it('emits container.command (entrypoint) and no args', () => {
const pod = generatePodSpec({
name: 'g', image: 'runner',
entrypoint: ['/bin/sh', '-c', '. /vault/secrets/s; exec "$0" "$@"', 'npx', '-y', '@leval/mcp-grafana'],
} as ContainerSpec, 'mcpctl-servers');
expect(pod.spec.containers[0]?.command?.[0]).toBe('/bin/sh');
expect(pod.spec.containers[0]?.args).toBeUndefined();
});
it('leaves the normal entrypoint+args path alone when not wrapping', () => {
const pod = generatePodSpec({ name: 'g', image: 'runner', command: ['@leval/mcp-grafana'] } as ContainerSpec, 'mcpctl-servers');
expect(pod.spec.containers[0]?.command).toBeUndefined();
expect(pod.spec.containers[0]?.args).toEqual(['@leval/mcp-grafana']);
});
});

View File

@@ -0,0 +1,44 @@
/**
* How mcpd opens a STDIO session: attach to PID 1, or exec a new process.
*
* Subtle and silent when wrong. With injected secret delivery the credentials
* exist ONLY in PID 1's environment (a shell sourced /vault/secrets/<name> and
* exec'd the server), so an `exec` starts a process with empty credentials —
* the server comes up, answers tools/list, and fails every authenticated call.
*/
import { describe, it, expect } from 'vitest';
import { chooseStdioMode } from '../src/services/mcp-proxy-service.js';
const base = { name: 's', id: 'id1' };
describe('chooseStdioMode', () => {
it('attaches for an injector server even though it has a packageName', () => {
// The regression: packageName would otherwise select exec, and exec loses
// the secrets entirely.
expect(chooseStdioMode({ ...base, secretDelivery: 'injector', packageName: '@leval/mcp-grafana' }))
.toEqual({ kind: 'attach' });
});
it('attaches for an injector server even though it has an explicit command', () => {
expect(chooseStdioMode({ ...base, secretDelivery: 'injector', command: ['node', 'x.js'] }))
.toEqual({ kind: 'attach' });
});
it('still execs a package server on the default env delivery', () => {
const m = chooseStdioMode({ ...base, secretDelivery: 'env', packageName: '@leval/mcp-grafana', runtime: 'node' });
expect(m.kind).toBe('exec');
});
it('still prefers an explicit command over packageName on env delivery', () => {
expect(chooseStdioMode({ ...base, secretDelivery: 'env', command: ['node', 'x.js'], packageName: 'p' }))
.toEqual({ kind: 'exec', command: ['node', 'x.js'] });
});
it('attaches for an image-entrypoint server, as before', () => {
expect(chooseStdioMode({ ...base, dockerImage: 'gitea/mcp:latest' })).toEqual({ kind: 'attach' });
});
it('rejects a server with no way to start', () => {
expect(() => chooseStdioMode({ ...base })).toThrow(/packageName, command, or dockerImage/);
});
});

View File

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

View File

@@ -27,6 +27,7 @@ import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-inde
import { composePlugins } from '../proxymodel/plugins/compose.js';
import type { ProxyModelPlugin } from '../proxymodel/plugin.js';
import { AuditCollector } from '../audit/collector.js';
import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js';
interface ProjectCacheEntry {
router: McpRouter;
@@ -45,6 +46,10 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
let resolvedUserName: string | null | undefined; // undefined = not yet resolved
const projectCache = new Map<string, ProjectCacheEntry>();
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. */
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 response = await router.route(message as unknown as JsonRpcRequest, ctx);
// Wire-name translation happens HERE, at the client boundary, so the
// router, plugins and audit all keep canonical `server/tool` names.
let codec = wireCodecs.get(projectName);
if (!codec) {
codec = new WireNameCodec();
wireCodecs.set(projectName, codec);
}
const response = await routeWithWireNames(
codec,
(req) => router.route(req, ctx),
message as unknown as JsonRpcRequest,
);
// Forward queued notifications BEFORE the response — the response send
// closes the POST SSE stream, so notifications must go first.

View File

@@ -0,0 +1,126 @@
/**
* Wire-safe tool-name codec for client-facing MCP endpoints.
*
* Internally the proxy namespaces tools as `server/tool`, and presentation
* plugins add `favourite/<tool>`, `all/<server>/<tool>` and
* `agent-<name>/chat`. A `/` is not a valid character in OpenAI-style
* function names (`^[a-zA-Z0-9_.-]{1,64}$`), so hosts that forward MCP tool
* names verbatim as LLM function names (LibreChat) depend on the model
* faithfully echoing an illegal name. deepseek-v4-flash intermittently drops
* the `server/` prefix; the host's registry lookup then fails and it reports
* the tool's "MCP server is temporarily unavailable" while nothing is down
* (the librechat fetch_content incident, 2026-08-25). Claude Code and the pi
* extension dodge this only because they sanitize names client-side.
*
* The codec translates ONLY at the HTTP boundary:
* - tools/list responses are rewritten to wire-safe names (`/` → `_`),
* - tools/call requests are mapped back to the presented (internal) name
* via an exact-match reverse map.
*
* Everything inside the proxy — routing maps, plugins, favourites config,
* audit events — keeps the canonical names. Inbound names with no map entry
* (legacy clients echoing slash names, virtual tools called before any
* tools/list) pass through unchanged, so existing clients keep working.
*
* The maps live per project router (not per session) so a client that
* reconnects mid-conversation still resolves names listed on its previous
* session, as long as the process is alive. After a restart the first
* tools/list (which every MCP client performs on initialize) repopulates them.
*/
import type { JsonRpcRequest, JsonRpcResponse } from '../types.js';
/** Replace every character that is invalid in an OpenAI-style function name. */
export function sanitizeWireName(name: string): string {
return name.replace(/[^A-Za-z0-9_.-]/g, '_');
}
export class WireNameCodec {
/** wire name → presented (internal) name */
private toPresented = new Map<string, string>();
/** presented (internal) name → wire name */
private toWire = new Map<string, string>();
/**
* Wire name for a presented tool name. Stable for the codec's lifetime.
*
* Collisions (two presented names sanitizing to the same string, or a
* sanitized name shadowing a tool that already uses that exact name) get a
* numeric suffix — first registration wins the plain name. This keeps the
* reverse map unambiguous; order within one tools/list pass is stable, so
* suffixes are deterministic in practice.
*/
encodeName(presented: string): string {
const existing = this.toWire.get(presented);
if (existing !== undefined) return existing;
const base = sanitizeWireName(presented);
let wire = base;
for (let i = 2; this.toPresented.has(wire) && this.toPresented.get(wire) !== presented; i++) {
wire = `${base}_${String(i)}`;
}
if (wire !== base) {
console.warn(`[wire-names] collision: '${presented}' presented as '${wire}' (base '${base}' taken by '${this.toPresented.get(base) ?? '?'}')`);
}
this.toWire.set(presented, wire);
this.toPresented.set(wire, presented);
return wire;
}
/** The presented name a wire name maps to, or the input unchanged if unknown. */
decodeName(wire: string): string {
return this.toPresented.get(wire) ?? wire;
}
/**
* Rewrite a tools/list response's tool names to wire-safe names,
* registering each mapping. Non-list responses and errors pass through.
*/
encodeToolsList(response: JsonRpcResponse): JsonRpcResponse {
if (response.error !== undefined) return response;
if (response.result === null || typeof response.result !== 'object') return response;
const result = response.result as Record<string, unknown>;
const tools: unknown = result['tools'];
if (!Array.isArray(tools)) return response;
let changed = false;
const encoded = (tools as unknown[]).map((tool) => {
if (tool === null || typeof tool !== 'object' || typeof (tool as { name?: unknown }).name !== 'string') return tool;
const presented = (tool as { name: string }).name;
const wire = this.encodeName(presented);
if (wire === presented) return tool;
changed = true;
return { ...(tool as Record<string, unknown>), name: wire };
});
if (!changed) return response;
return { ...response, result: { ...result, tools: encoded } };
}
/**
* Map a tools/call request's wire name back to the presented name.
* Requests for unknown names (or without a name) pass through unchanged.
*/
decodeToolCall(request: JsonRpcRequest): JsonRpcRequest {
const params = request.params;
const name = params?.['name'];
if (typeof name !== 'string') return request;
const presented = this.toPresented.get(name);
if (presented === undefined || presented === name) return request;
return { ...request, params: { ...params, name: presented } };
}
}
/**
* Route one client request through `route` with wire-name translation:
* decode the tool name on the way in (tools/call), encode tool names on the
* way out (tools/list). Every other method is untouched.
*/
export async function routeWithWireNames(
codec: WireNameCodec,
route: (request: JsonRpcRequest) => Promise<JsonRpcResponse>,
request: JsonRpcRequest,
): Promise<JsonRpcResponse> {
const inbound = request.method === 'tools/call' ? codec.decodeToolCall(request) : request;
const response = await route(inbound);
return request.method === 'tools/list' ? codec.encodeToolsList(response) : response;
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi } from 'vitest';
import Fastify from 'fastify';
import { registerMcpEndpoint } from '../src/http/mcp-endpoint.js';
import type { McpRouter } from '../src/router.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
/**
* End-to-end over the Streamable HTTP transport: the /mcp endpoint must serve
* OpenAI-safe tool names on tools/list and map them back to the router's
* canonical `server/tool` names on tools/call (the librechat fetch_content
* incident, 2026-08-25).
*/
function parseSse(body: string): JsonRpcResponse {
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
}
describe('registerMcpEndpoint wire names', () => {
it('lists wire-safe names and routes calls back to canonical names', async () => {
const routed: JsonRpcRequest[] = [];
const fakeRouter = {
route: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
routed.push(req);
switch (req.method) {
case 'initialize':
return {
jsonrpc: '2.0',
id: req.id,
result: {
protocolVersion: '2024-11-05',
serverInfo: { name: 'test', version: '0' },
capabilities: { tools: {} },
},
};
case 'tools/list':
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: [{ name: 'websearch/fetch_content', inputSchema: { type: 'object' } }] },
};
case 'tools/call':
return {
jsonrpc: '2.0',
id: req.id,
result: { content: [{ type: 'text', text: 'ok' }] },
};
default:
return { jsonrpc: '2.0', id: req.id, result: {} };
}
}),
} as unknown as McpRouter;
const app = Fastify();
registerMcpEndpoint(app, fakeRouter);
await app.ready();
try {
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
};
const init = await app.inject({
method: 'POST',
url: '/mcp',
headers,
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
});
expect(init.statusCode).toBe(200);
const sessionId = init.headers['mcp-session-id'] as string;
expect(sessionId).toBeTruthy();
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
const list = await app.inject({
method: 'POST',
url: '/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
});
const listResponse = parseSse(list.body);
const tools = (listResponse.result as { tools: Array<{ name: string }> }).tools;
expect(tools.map((t) => t.name)).toEqual(['websearch_fetch_content']);
const call = await app.inject({
method: 'POST',
url: '/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
});
const callResponse = parseSse(call.body);
expect(callResponse.error).toBeUndefined();
const routedCall = routed.find((r) => r.method === 'tools/call');
expect(routedCall?.params?.['name']).toBe('websearch/fetch_content');
} finally {
await app.close();
}
});
});

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi } from 'vitest';
import Fastify from 'fastify';
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
import type { McpRouter } from '../src/router.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
/**
* End-to-end wire-name test on the endpoint the librechat incident actually
* hit: /projects/:name/mcp. A fake `websearch` upstream serves a tool named
* `fetch_content`; the client must see `websearch_fetch_content` in
* tools/list, and calling that wire name must reach the upstream as plain
* `fetch_content` (router canonical `websearch/fetch_content`, prefix
* stripped on dispatch).
*/
const upstreamRequests: JsonRpcRequest[] = [];
vi.mock('../src/discovery.js', () => ({
refreshProjectUpstreams: vi.fn(async (router: McpRouter) => {
router.addUpstream({
name: 'websearch',
send: async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
upstreamRequests.push(req);
if (req.method === 'tools/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: [{ name: 'fetch_content', description: 'fetch a page', inputSchema: { type: 'object' } }] },
};
}
if (req.method === 'tools/call') {
return {
jsonrpc: '2.0',
id: req.id,
result: { content: [{ type: 'text', text: 'page body' }] },
};
}
return { jsonrpc: '2.0', id: req.id, result: {} };
},
close: async () => {},
isAlive: () => true,
});
return ['websearch'];
}),
// gated: false → no gate plugin, the full catalog is served at initialize
// (the chat-web configuration).
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
}));
vi.mock('../src/http/config.js', async () => {
const actual = await vi.importActual<typeof import('../src/http/config.js')>('../src/http/config.js');
return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) };
});
function mockMcpdClient() {
const client: Record<string, unknown> = {
baseUrl: 'http://test:3100',
token: 'test-token',
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(),
withToken: vi.fn(),
withTimeout: vi.fn(),
};
(client.withHeaders as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withToken as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withTimeout as ReturnType<typeof vi.fn>).mockReturnValue(client);
return client;
}
function parseSse(body: string): JsonRpcResponse {
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
}
describe('registerProjectMcpEndpoint wire names', () => {
it('serves wire-safe names on the project endpoint and dispatches calls upstream', async () => {
upstreamRequests.length = 0;
const app = Fastify();
registerProjectMcpEndpoint(app, mockMcpdClient() as never);
await app.ready();
try {
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
};
const init = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers,
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
});
expect(init.statusCode).toBe(200);
const sessionId = init.headers['mcp-session-id'] as string;
expect(sessionId).toBeTruthy();
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
const list = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
});
const listResponse = parseSse(list.body);
const tools = (listResponse.result as { tools: Array<{ name: string }> }).tools;
const names = tools.map((t) => t.name);
expect(names).toContain('websearch_fetch_content');
// Every served name must be a valid OpenAI-style function name — the
// invariant the librechat incident violated.
for (const name of names) {
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
}
const call = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
});
const callResponse = parseSse(call.body);
expect(callResponse.error).toBeUndefined();
expect((callResponse.result as { content: Array<{ text: string }> }).content[0]?.text).toBe('page body');
// The upstream saw the bare tool name — namespace stripped, not mangled.
const upstreamCall = upstreamRequests.find((r) => r.method === 'tools/call');
expect(upstreamCall?.params?.['name']).toBe('fetch_content');
// Legacy clients echoing the canonical slash name keep working.
const legacy = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'websearch/fetch_content', arguments: { url: 'https://x' } } },
});
expect(parseSse(legacy.body).error).toBeUndefined();
} finally {
await app.close();
}
});
});

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi } from 'vitest';
import { sanitizeWireName, WireNameCodec, routeWithWireNames } from '../src/util/wire-names.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
describe('sanitizeWireName', () => {
it('replaces slashes with underscores', () => {
expect(sanitizeWireName('websearch/fetch_content')).toBe('websearch_fetch_content');
expect(sanitizeWireName('all/websearch/fetch_content')).toBe('all_websearch_fetch_content');
});
it('keeps names that are already OpenAI-safe', () => {
expect(sanitizeWireName('begin_session')).toBe('begin_session');
expect(sanitizeWireName('my-grafana.tool')).toBe('my-grafana.tool');
});
it('replaces every character outside [A-Za-z0-9_.-]', () => {
expect(sanitizeWireName('a b:c/d')).toBe('a_b_c_d');
});
});
describe('WireNameCodec', () => {
it('round-trips a namespaced tool name', () => {
const codec = new WireNameCodec();
const wire = codec.encodeName('websearch/fetch_content');
expect(wire).toBe('websearch_fetch_content');
expect(codec.decodeName(wire)).toBe('websearch/fetch_content');
});
it('is stable across repeated encodes', () => {
const codec = new WireNameCodec();
expect(codec.encodeName('searxng/web_url_read')).toBe('searxng_web_url_read');
expect(codec.encodeName('searxng/web_url_read')).toBe('searxng_web_url_read');
});
it('passes unknown inbound names through unchanged', () => {
const codec = new WireNameCodec();
// Legacy client echoing the slash form, or a virtual tool never listed.
expect(codec.decodeName('websearch/fetch_content')).toBe('websearch/fetch_content');
expect(codec.decodeName('begin_session')).toBe('begin_session');
});
it('suffixes on collision, first registration wins the plain name', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const codec = new WireNameCodec();
expect(codec.encodeName('foo_bar/baz')).toBe('foo_bar_baz');
expect(codec.encodeName('foo/bar_baz')).toBe('foo_bar_baz_2');
// Both decode back to their own presented names.
expect(codec.decodeName('foo_bar_baz')).toBe('foo_bar/baz');
expect(codec.decodeName('foo_bar_baz_2')).toBe('foo/bar_baz');
// And stay stable.
expect(codec.encodeName('foo/bar_baz')).toBe('foo_bar_baz_2');
expect(warn).toHaveBeenCalledOnce();
} finally {
warn.mockRestore();
}
});
it('encodes tools/list responses and leaves other fields intact', () => {
const codec = new WireNameCodec();
const response: JsonRpcResponse = {
jsonrpc: '2.0',
id: 1,
result: {
tools: [
{ name: 'websearch/fetch_content', description: 'fetch', inputSchema: { type: 'object' } },
{ name: 'begin_session', description: 'gate' },
],
},
};
const encoded = codec.encodeToolsList(response);
const tools = (encoded.result as { tools: Array<{ name: string; description?: string }> }).tools;
expect(tools.map((t) => t.name)).toEqual(['websearch_fetch_content', 'begin_session']);
expect(tools[0]?.description).toBe('fetch');
// Original response object is not mutated.
const originalTools = (response.result as { tools: Array<{ name: string }> }).tools;
expect(originalTools[0]?.name).toBe('websearch/fetch_content');
});
it('returns error and non-list responses unchanged', () => {
const codec = new WireNameCodec();
const err: JsonRpcResponse = { jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'boom' } };
expect(codec.encodeToolsList(err)).toBe(err);
const other: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: { content: [] } };
expect(codec.encodeToolsList(other)).toBe(other);
});
it('decodes tools/call requests for known wire names only', () => {
const codec = new WireNameCodec();
codec.encodeName('websearch/fetch_content');
const known: JsonRpcRequest = {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } },
};
const decoded = codec.decodeToolCall(known);
expect(decoded.params?.['name']).toBe('websearch/fetch_content');
expect(decoded.params?.['arguments']).toEqual({ url: 'https://x' });
// Original request object is not mutated.
expect(known.params?.['name']).toBe('websearch_fetch_content');
const unknown: JsonRpcRequest = {
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'not_listed', arguments: {} },
};
expect(codec.decodeToolCall(unknown)).toBe(unknown);
});
});
describe('routeWithWireNames', () => {
const listResponse: JsonRpcResponse = {
jsonrpc: '2.0',
id: 1,
result: { tools: [{ name: 'websearch/fetch_content' }, { name: 'searxng/web_url_read' }] },
};
it('serves wire-safe names on tools/list and maps tools/call back', async () => {
const codec = new WireNameCodec();
const seen: JsonRpcRequest[] = [];
const route = async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
seen.push(req);
if (req.method === 'tools/list') return listResponse;
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'ok' }] } };
};
const listed = await routeWithWireNames(codec, route, { jsonrpc: '2.0', id: 1, method: 'tools/list' });
const names = (listed.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toEqual(['websearch_fetch_content', 'searxng_web_url_read']);
// The exact scenario from the librechat incident: the model echoes the
// wire name; the router must receive the canonical name.
await routeWithWireNames(codec, route, {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } },
});
expect(seen[1]?.params?.['name']).toBe('websearch/fetch_content');
});
it('keeps legacy slash-name calls working', async () => {
const codec = new WireNameCodec();
const seen: JsonRpcRequest[] = [];
const route = async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
seen.push(req);
return { jsonrpc: '2.0', id: req.id, result: {} };
};
await routeWithWireNames(codec, route, {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'websearch/fetch_content', arguments: {} },
});
expect(seen[0]?.params?.['name']).toBe('websearch/fetch_content');
});
it('does not touch other methods', async () => {
const codec = new WireNameCodec();
const init: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} };
const response: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05' } };
const out = await routeWithWireNames(codec, async () => response, init);
expect(out).toBe(response);
});
});