From c014fcdd823be0e40e7e41c4f4135c010d6cd018 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 25 Aug 2026 21:58:52 +0100 Subject: [PATCH] fix(mcplocal): name tools in wire form in gate and favourite-index prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #124: the boundary WireNameCodec serves underscore-joined names, but the gate plugin's tool inventories (initialize instructions and begin_session response) still listed canonical `server/tool`, and the favourite-index instruction told the model to prefer `favourite/` — prose naming functions the model cannot call. Inventories now run through sanitizeWireName and the instruction describes the favourite_/all_ prefixes. Cosmetic for routing (slash names still pass through the codec) but load-bearing for tool selection: models copy names out of prose. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JaFvfHrQyUKCGv6o3N2Wir --- .../src/proxymodel/plugins/favourite-index.ts | 19 ++++++++++++++----- src/mcplocal/src/proxymodel/plugins/gate.ts | 12 ++++++++---- src/mcplocal/tests/router-gate.test.ts | 11 ++++++++--- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/mcplocal/src/proxymodel/plugins/favourite-index.ts b/src/mcplocal/src/proxymodel/plugins/favourite-index.ts index dd854d7..2fe154a 100644 --- a/src/mcplocal/src/proxymodel/plugins/favourite-index.ts +++ b/src/mcplocal/src/proxymodel/plugins/favourite-index.ts @@ -26,12 +26,21 @@ import type { ToolDefinition } from '../types.js'; /** Per-session state key holding the presented-name → canonical-name map. */ 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/` and + * `all//` names to `favourite_` / `all__` + * 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 = - 'Tools are indexed in two namespaces. PREFER favourite/ — a short ' + - 'curated list of the common tools that covers most tasks; reach for these ' + - 'first. Use the full catalog under all// only if nothing in ' + - "favourites fits. (read_prompts gives this project's own guidance.)"; + 'Tools are indexed in two namespaces. PREFER the favourite_-prefixed tools ' + + '— a short curated list of the common tools that covers most tasks; reach ' + + 'for these first. Use the full catalog under the all_-prefixed names only ' + + "if nothing in favourites fits. (read_prompts gives this project's own " + + 'guidance.)'; export interface FavouriteIndexConfig { /** Canonical `server/tool` names to surface as favourites, in display order. */ diff --git a/src/mcplocal/src/proxymodel/plugins/gate.ts b/src/mcplocal/src/proxymodel/plugins/gate.ts index eafd54e..7dfdd53 100644 --- a/src/mcplocal/src/proxymodel/plugins/gate.ts +++ b/src/mcplocal/src/proxymodel/plugins/gate.ts @@ -16,6 +16,7 @@ import type { TagMatchResult } from '../../gate/tag-matcher.js'; import { LlmPromptSelector, pickCompletionText, type ServerInfer } from '../../gate/llm-selector.js'; import type { ProviderRegistry } from '../../providers/registry.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 * 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}`); - // 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 { const tools = await ctx.discoverTools(); if (tools.length > 0) { parts.push('\nAvailable MCP server tools (accessible after begin_session):'); for (const t of tools) { - parts.push(` ${t.name}`); + parts.push(` ${sanitizeWireName(t.name)}`); } } } catch { @@ -424,13 +427,14 @@ async function handleBeginSession( ); responseParts.push(encouragement); - // Append tool inventory (names only) + // Append tool inventory (names only) — wire charset, see the initialize + // inventory note. try { const tools = await ctx.discoverTools(); if (tools.length > 0) { responseParts.push('\nAvailable MCP server tools:'); for (const t of tools) { - responseParts.push(` ${t.name}`); + responseParts.push(` ${sanitizeWireName(t.name)}`); } } } catch { diff --git a/src/mcplocal/tests/router-gate.test.ts b/src/mcplocal/tests/router-gate.test.ts index a5b0ffd..3db6192 100644 --- a/src/mcplocal/tests/router-gate.test.ts +++ b/src/mcplocal/tests/router-gate.test.ts @@ -525,8 +525,11 @@ describe('McpRouter gating', () => { ); const result = res.result as { instructions: string }; - expect(result.instructions).toContain('ha/get_entities'); - expect(result.instructions).toContain('node-red/get_flows'); + // Wire form: prose must name what the client can actually call — the + // 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'); // Descriptions should NOT be in init instructions (names only) 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; - 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'); });