Compare commits
13 Commits
feat/injec
...
test/wire-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4ad95ca66 | ||
| bbd31883e8 | |||
|
|
c014fcdd82 | ||
| 7fbb827aa5 | |||
|
|
eb0e97e76e | ||
|
|
065ce02a60 | ||
| 9c5d0d1861 | |||
|
|
c16d7964c9 | ||
| 7716e424f9 | |||
|
|
f097c0f4d5 | ||
| c79bdab51b | |||
|
|
913c0fbdc6 | ||
| beb57baf58 |
43
deploy/Dockerfile.gitea-mcp
Normal file
43
deploy/Dockerfile.gitea-mcp
Normal 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
36
scripts/build-gitea-mcp.sh
Executable 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"
|
||||||
@@ -135,17 +135,29 @@ export class ServerIdentityService {
|
|||||||
const secretNames = this.secretNamesFor(server);
|
const secretNames = this.secretNamesFor(server);
|
||||||
if (secretNames.length === 0) return undefined;
|
if (secretNames.length === 0) return undefined;
|
||||||
|
|
||||||
// mcpd owns the runner images, so their entrypoints are known. A
|
// Build the COMPLETE argv the container should run. It differs by shape:
|
||||||
// dockerImage server's is not introspectable — hence `entrypoint` being
|
//
|
||||||
// required on the row at validation time.
|
// package server — mcpd owns the runner image, whose ENTRYPOINT
|
||||||
const imageEntrypoint = server.packageName
|
// (`npx -y` / `uvx`) is lost once we take over
|
||||||
? server.runtime === 'python'
|
// `command`, so it must be prepended here.
|
||||||
? ['uvx']
|
// image + command — `command` is already a full command line; mcpd would
|
||||||
: ['npx', '-y']
|
// have run exactly it. Prepending anything breaks it.
|
||||||
: ((server.entrypoint as string[] | null) ?? undefined);
|
// image only — the image's own ENTRYPOINT would run and mcpd cannot
|
||||||
if (imageEntrypoint === undefined || imageEntrypoint.length === 0) return undefined;
|
// 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);
|
||||||
|
|
||||||
return wrapCommandForInjector([...imageEntrypoint, ...(command ?? [])], secretNames);
|
if (argv === undefined || argv.length === 0) return undefined;
|
||||||
|
return wrapCommandForInjector(argv, secretNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
58
src/mcpd/tests/injector-argv.test.ts
Normal file
58
src/mcpd/tests/injector-argv.test.ts
Normal 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" "$@"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
126
src/mcplocal/src/util/wire-names.ts
Normal file
126
src/mcplocal/src/util/wire-names.ts
Normal 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;
|
||||||
|
}
|
||||||
100
src/mcplocal/tests/mcp-endpoint-wire-names.test.ts
Normal file
100
src/mcplocal/tests/mcp-endpoint-wire-names.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
145
src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts
Normal file
145
src/mcplocal/tests/project-mcp-endpoint-wire-names.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Provisions an ungated project with favourite-index enabled + two pinned tools
|
* Provisions an ungated project with favourite-index enabled + two pinned tools
|
||||||
* from the smoke-aws-docs server, then verifies through the LIVE mcplocal proxy:
|
* from the smoke-aws-docs server, then verifies through the LIVE mcplocal proxy:
|
||||||
* - initialize instructions carry the load-bearing "prefer favourite/" line,
|
* - initialize instructions carry the load-bearing "prefer favourite_" line,
|
||||||
* - tools/list presents favourite/<tool> (curated) + all/<server>/<tool> (full),
|
* - tools/list presents favourite_<tool> (curated) + all_<server>_<tool> (full, wire form),
|
||||||
* - a favourite/ call and an all/ call both ROUTE to the real upstream
|
* - a favourite_ call and an all_ call both ROUTE to the real upstream
|
||||||
* (they reach the server's arg validation, not a -32601 "unknown tool").
|
* (they reach the server's arg validation, not a -32601 "unknown tool").
|
||||||
*
|
*
|
||||||
* Run with: pnpm test:smoke
|
* Run with: pnpm test:smoke
|
||||||
@@ -65,40 +65,40 @@ describe('Smoke: favourite-index presentation', () => {
|
|||||||
console.log('\n ━━━ favourite-index smoke complete ━━━\n');
|
console.log('\n ━━━ favourite-index smoke complete ━━━\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('presents favourite/ + all/ namespaces with the prefer-favourite instruction', async () => {
|
it('presents favourite_ + all_ namespaces with the prefer-favourite instruction', async () => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
|
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
|
||||||
chat.section('favourite-index presentation');
|
chat.section('favourite-index presentation');
|
||||||
try {
|
try {
|
||||||
const initResult = (await chat.initialize()) as { instructions?: string };
|
const initResult = (await chat.initialize()) as { instructions?: string };
|
||||||
const instructions = initResult?.instructions ?? '';
|
const instructions = initResult?.instructions ?? '';
|
||||||
chat.check('Instruction mentions favourite/', String(instructions.includes('favourite/')), (v) => v === 'true');
|
chat.check('Instruction mentions favourite_', String(instructions.includes('favourite_')), (v) => v === 'true');
|
||||||
expect(instructions).toContain('favourite/');
|
expect(instructions).toContain('favourite_');
|
||||||
|
|
||||||
const tools = await chat.listTools();
|
const tools = await chat.listTools();
|
||||||
const names = tools.map((t) => t.name);
|
const names = tools.map((t) => t.name);
|
||||||
const favNames = names.filter((n) => n.startsWith('favourite/'));
|
const favNames = names.filter((n) => n.startsWith('favourite_'));
|
||||||
const allNames = names.filter((n) => n.startsWith('all/'));
|
const allNames = names.filter((n) => n.startsWith('all_'));
|
||||||
|
|
||||||
chat.check('Has favourite/ tools', favNames.length, (v) => v >= 1);
|
chat.check('Has favourite_ tools', favNames.length, (v) => v >= 1);
|
||||||
chat.check('Has all/ catalog', allNames.length, (v) => v >= 1);
|
chat.check('Has all_ catalog', allNames.length, (v) => v >= 1);
|
||||||
chat.check('favourite/read_documentation present', String(names.includes('favourite/read_documentation')), (v) => v === 'true');
|
chat.check('favourite_read_documentation present', String(names.includes('favourite_read_documentation')), (v) => v === 'true');
|
||||||
chat.check('all/smoke-aws-docs/read_documentation present', String(names.includes('all/smoke-aws-docs/read_documentation')), (v) => v === 'true');
|
chat.check('all_smoke-aws-docs_read_documentation present', String(names.includes('all_smoke-aws-docs_read_documentation')), (v) => v === 'true');
|
||||||
|
|
||||||
// Favourites are listed before the all/ catalog.
|
// Favourites are listed before the all_ catalog.
|
||||||
const firstFav = names.findIndex((n) => n.startsWith('favourite/'));
|
const firstFav = names.findIndex((n) => n.startsWith('favourite_'));
|
||||||
const firstAll = names.findIndex((n) => n.startsWith('all/'));
|
const firstAll = names.findIndex((n) => n.startsWith('all_'));
|
||||||
chat.check('favourites precede all/', String(firstFav < firstAll), (v) => v === 'true');
|
chat.check('favourites precede all_', String(firstFav < firstAll), (v) => v === 'true');
|
||||||
|
|
||||||
expect(names).toContain('favourite/read_documentation');
|
expect(names).toContain('favourite_read_documentation');
|
||||||
expect(names).toContain('all/smoke-aws-docs/read_documentation');
|
expect(names).toContain('all_smoke-aws-docs_read_documentation');
|
||||||
expect(firstFav).toBeLessThan(firstAll);
|
expect(firstFav).toBeLessThan(firstAll);
|
||||||
} finally {
|
} finally {
|
||||||
await chat.close();
|
await chat.close();
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('routes favourite/ and all/ calls to the real upstream tool', async () => {
|
it('routes favourite_ and all_ calls to the real upstream tool', async () => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
|
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
|
||||||
chat.section('favourite-index routing');
|
chat.section('favourite-index routing');
|
||||||
@@ -108,15 +108,15 @@ describe('Smoke: favourite-index presentation', () => {
|
|||||||
|
|
||||||
// Calling with no args → the UPSTREAM tool's arg validation fires, proving
|
// Calling with no args → the UPSTREAM tool's arg validation fires, proving
|
||||||
// the presented name routed to the real server (not a -32601 unknown tool).
|
// the presented name routed to the real server (not a -32601 unknown tool).
|
||||||
const favRes = await chat.callTool('favourite/read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
|
const favRes = await chat.callTool('favourite_read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
|
||||||
const allRes = await chat.callTool('all/smoke-aws-docs/read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
|
const allRes = await chat.callTool('all_smoke-aws-docs_read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
|
||||||
|
|
||||||
const favStr = JSON.stringify(favRes).toLowerCase();
|
const favStr = JSON.stringify(favRes).toLowerCase();
|
||||||
const allStr = JSON.stringify(allRes).toLowerCase();
|
const allStr = JSON.stringify(allRes).toLowerCase();
|
||||||
// Reached the upstream (arg validation / real response), not "unknown tool".
|
// Reached the upstream (arg validation / real response), not "unknown tool".
|
||||||
const routed = (s: string): boolean => !s.includes('-32601') && !s.includes('unknown') && !s.includes('method not found');
|
const routed = (s: string): boolean => !s.includes('-32601') && !s.includes('unknown') && !s.includes('method not found');
|
||||||
chat.check('favourite/ routed to upstream', String(routed(favStr)), (v) => v === 'true');
|
chat.check('favourite_ routed to upstream', String(routed(favStr)), (v) => v === 'true');
|
||||||
chat.check('all/ routed to upstream', String(routed(allStr)), (v) => v === 'true');
|
chat.check('all_ routed to upstream', String(routed(allStr)), (v) => v === 'true');
|
||||||
expect(routed(favStr)).toBe(true);
|
expect(routed(favStr)).toBe(true);
|
||||||
expect(routed(allStr)).toBe(true);
|
expect(routed(allStr)).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
const ungatedTools = await chat.listTools();
|
const ungatedTools = await chat.listTools();
|
||||||
chat.check('Ungated tools > 1', ungatedTools.length, (v) => v > 1);
|
chat.check('Ungated tools > 1', ungatedTools.length, (v) => v > 1);
|
||||||
|
|
||||||
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs/'));
|
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs_'));
|
||||||
chat.check('Has smoke-aws-docs/* tools', awsTools.length, (v) => v > 0);
|
chat.check('Has smoke-aws-docs_* tools', awsTools.length, (v) => v > 0);
|
||||||
|
|
||||||
expect(ungatedTools.length).toBeGreaterThan(1);
|
expect(ungatedTools.length).toBeGreaterThan(1);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -228,7 +228,7 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
it('has AWS documentation tools after ungating', async () => {
|
it('has AWS documentation tools after ungating', async () => {
|
||||||
if (!serverResponding) return;
|
if (!serverResponding) return;
|
||||||
|
|
||||||
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs/'));
|
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs_'));
|
||||||
chat.check('AWS docs tools available', awsTools.length, (v) => v > 0);
|
chat.check('AWS docs tools available', awsTools.length, (v) => v > 0);
|
||||||
|
|
||||||
if (awsTools.length > 0) {
|
if (awsTools.length > 0) {
|
||||||
@@ -241,9 +241,9 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
it('can call an AWS documentation tool', async () => {
|
it('can call an AWS documentation tool', async () => {
|
||||||
if (!serverResponding) return;
|
if (!serverResponding) return;
|
||||||
|
|
||||||
const searchTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/search_documentation');
|
const searchTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_search_documentation');
|
||||||
const recommendTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/recommend');
|
const recommendTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_recommend');
|
||||||
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation');
|
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
|
||||||
|
|
||||||
// Prefer search_documentation — most reliable (no URL format requirements)
|
// Prefer search_documentation — most reliable (no URL format requirements)
|
||||||
const toolToTest = searchTool ?? recommendTool ?? readTool;
|
const toolToTest = searchTool ?? recommendTool ?? readTool;
|
||||||
@@ -271,13 +271,13 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
it('large tool result gets paginated with _resultId', async () => {
|
it('large tool result gets paginated with _resultId', async () => {
|
||||||
if (!serverResponding) return;
|
if (!serverResponding) return;
|
||||||
|
|
||||||
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation');
|
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
|
||||||
if (!readTool) {
|
if (!readTool) {
|
||||||
chat.skip('read_documentation not available');
|
chat.skip('read_documentation not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await chat.callTool('smoke-aws-docs/read_documentation', {
|
const result = await chat.callTool('smoke-aws-docs_read_documentation', {
|
||||||
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
||||||
});
|
});
|
||||||
const text = result.content[0]?.text ?? '';
|
const text = result.content[0]?.text ?? '';
|
||||||
@@ -296,13 +296,13 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
it('section drill-down via _resultId and _section', async () => {
|
it('section drill-down via _resultId and _section', async () => {
|
||||||
if (!serverResponding) return;
|
if (!serverResponding) return;
|
||||||
|
|
||||||
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation');
|
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
|
||||||
if (!readTool) {
|
if (!readTool) {
|
||||||
chat.skip('read_documentation not available');
|
chat.skip('read_documentation not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await chat.callTool('smoke-aws-docs/read_documentation', {
|
const result = await chat.callTool('smoke-aws-docs_read_documentation', {
|
||||||
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
||||||
});
|
});
|
||||||
const text = result.content[0]?.text ?? '';
|
const text = result.content[0]?.text ?? '';
|
||||||
@@ -315,7 +315,7 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
|
|
||||||
const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, '');
|
const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
|
||||||
const sectionResult = await chat.callTool('smoke-aws-docs/read_documentation', {
|
const sectionResult = await chat.callTool('smoke-aws-docs_read_documentation', {
|
||||||
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
||||||
_resultId: resultId,
|
_resultId: resultId,
|
||||||
_section: 'page-1',
|
_section: 'page-1',
|
||||||
@@ -386,13 +386,13 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
it('subindex model produces structural sections (not flat pages)', async () => {
|
it('subindex model produces structural sections (not flat pages)', async () => {
|
||||||
if (!serverResponding) return;
|
if (!serverResponding) return;
|
||||||
|
|
||||||
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs/read_documentation');
|
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs_read_documentation');
|
||||||
if (!readTool) {
|
if (!readTool) {
|
||||||
chat.skip('read_documentation not available');
|
chat.skip('read_documentation not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await chat.callTool('smoke-aws-docs/read_documentation', {
|
const result = await chat.callTool('smoke-aws-docs_read_documentation', {
|
||||||
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
||||||
});
|
});
|
||||||
const text = result.content[0]?.text ?? '';
|
const text = result.content[0]?.text ?? '';
|
||||||
@@ -409,13 +409,13 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
it('subindex drill-down returns section content', async () => {
|
it('subindex drill-down returns section content', async () => {
|
||||||
if (!serverResponding) return;
|
if (!serverResponding) return;
|
||||||
|
|
||||||
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs/read_documentation');
|
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs_read_documentation');
|
||||||
if (!readTool) {
|
if (!readTool) {
|
||||||
chat.skip('read_documentation not available');
|
chat.skip('read_documentation not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await chat.callTool('smoke-aws-docs/read_documentation', {
|
const result = await chat.callTool('smoke-aws-docs_read_documentation', {
|
||||||
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
||||||
});
|
});
|
||||||
const text = result.content[0]?.text ?? '';
|
const text = result.content[0]?.text ?? '';
|
||||||
@@ -428,7 +428,7 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
|
|
||||||
const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, '');
|
const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
|
||||||
const sectionResult = await chat.callTool('smoke-aws-docs/read_documentation', {
|
const sectionResult = await chat.callTool('smoke-aws-docs_read_documentation', {
|
||||||
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
||||||
_resultId: resultId,
|
_resultId: resultId,
|
||||||
_section: 'section-0',
|
_section: 'section-0',
|
||||||
@@ -482,14 +482,14 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
if (!serverResponding) return;
|
if (!serverResponding) return;
|
||||||
|
|
||||||
const tools = await chat.listTools();
|
const tools = await chat.listTools();
|
||||||
const readTool = tools.find((t) => t.name === 'smoke-aws-docs/read_documentation');
|
const readTool = tools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
|
||||||
if (!readTool) {
|
if (!readTool) {
|
||||||
chat.skip('read_documentation not available');
|
chat.skip('read_documentation not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
chat.info('Call 1: using default model (passthrough + paginate)');
|
chat.info('Call 1: using default model (passthrough + paginate)');
|
||||||
const result1 = await chat.callTool('smoke-aws-docs/read_documentation', {
|
const result1 = await chat.callTool('smoke-aws-docs_read_documentation', {
|
||||||
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
||||||
});
|
});
|
||||||
const text1 = result1.content[0]?.text ?? '';
|
const text1 = result1.content[0]?.text ?? '';
|
||||||
@@ -516,7 +516,7 @@ describe('Smoke: ProxyModel pipeline', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
chat.info('Call 2: using new model (should produce different output)');
|
chat.info('Call 2: using new model (should produce different output)');
|
||||||
const result2 = await chat.callTool('smoke-aws-docs/read_documentation', {
|
const result2 = await chat.callTool('smoke-aws-docs_read_documentation', {
|
||||||
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
|
||||||
});
|
});
|
||||||
const text2 = result2.content[0]?.text ?? '';
|
const text2 = result2.content[0]?.text ?? '';
|
||||||
|
|||||||
168
src/mcplocal/tests/wire-names.test.ts
Normal file
168
src/mcplocal/tests/wire-names.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user