Skip to content

Commit 9b8271f

Browse files
authored
fix: preserve compact MCP tool routing guidance (#1668)
Compact retrieve_tools entries (tool_response_mode: compact, Spec 085) omitted call_with, so agents picked the call_tool_read/write/destructive variant from the tool name and could route a tool such as windows:PowerShell through the wrong variant although the proxy already had the upstream classification. Each compact entry now carries the same call_with route as full mode, including the call_tool_read fallback for unannotated tools, and the tool descriptions, usage instructions and REST docs make that route authoritative over names, descriptions, command text and apparent intent. The old name-keyword decision rules are removed, which also makes every response smaller (about 50 to 220 tokens per retrieve_tools call on the goldens). Review: maintainer read, live-instance check in compact and full mode with annotated, destructive and unannotated tools, opencode Sol 6.1 cross-review; follow-up commit pins the exact golden delta and aligns docs. Co-authored-by: copystring <copystring@users.noreply.github.com>
1 parent 35e91cc commit 9b8271f

25 files changed

Lines changed: 277 additions & 96 deletions

‎docs/api/rest-api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ See [Routing Modes](../features/routing-modes.md) for details on each mode.
758758

759759
The MCP endpoints (not REST) additionally expose progressive-disclosure discovery in the `retrieve_tools` routing mode:
760760

761-
- **`tool_response_mode`** config (`full` default | `compact`, hot-reloadable via `POST /api/v1/config/apply`) controls `retrieve_tools` serialization only. In `compact` mode each entry is `{id, score, sig, desc, lossy}` — a one-line parameter signature (`*` = required, `~` = lossy) plus a first-sentence description — instead of full `inputSchema`, and the response carries one top-level `hint` line. Ranking is identical between modes.
761+
- **`tool_response_mode`** config (`full` default | `compact`, hot-reloadable via `POST /api/v1/config/apply`) controls `retrieve_tools` serialization only. In `compact` mode each entry is `{id, score, sig, desc, lossy, call_with}` — a one-line parameter signature (`*` = required, `~` = lossy), a first-sentence description, and the same dispatch route as full mode — instead of full `inputSchema`, and the response carries one top-level `hint` line. If upstream tier annotations are unavailable, `call_with` preserves the existing read fallback. Ranking is identical between modes.
762762
- **`detail`** — optional per-call `retrieve_tools` parameter (`compact` | `full`) overriding the configured mode for that call.
763763
- **`describe_tool`** — built-in second-stage tool (retrieve_tools mode only): accepts 1–5 `server:tool` ids and returns full definitions (`name`, `description`, `inputSchema`, `server`, `annotations`, `call_with`) with per-id errors for ids that do not resolve. It applies the same visibility pipeline as search (profile scope, agent-token scope, quarantine, tool approval, disabled) and never returns a definition `retrieve_tools` could not. Per-id error codes: `not_found`, `quarantined`, `pending_approval`, `changed`, `disabled` (Spec 099 retired `invisible`: an out-of-scope id now reports `not_found`, indistinguishable from an id that does not exist — see the [breaking-change note](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/CHANGELOG.md)).
764764
- **`describe_tool` check mode (Spec 099)** — the same built-in with `check: true` answers availability instead of definitions: up to 50 ids, an optional `filters` object (`read_only_only`, `exclude_destructive`, `exclude_open_world` — the REST body of `POST /api/v1/preflight` calls the same object `policy`), and a response of `{verdict, checked_at, request_id, results[]}` carrying the same reason codes this endpoint returns. It is the in-band twin of `POST /api/v1/preflight`, evaluated by the same evaluator, and differs deliberately: always the agent-token disclosure tier (never a hash, never `server_not_in_scope`), the session's own scope with no `profile` parameter, no hash pins (`expect_hashes` is a reserved field name and is rejected), and no wait budget. See [Required-Tools Preflight](../features/tools-preflight.md#in-band-describe_tool-check-mode).

‎docs/configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1396,7 +1396,7 @@ Controls only the *serialization* of `retrieve_tools` responses (Spec 085) — n
13961396

13971397
| Field | Type | Default | Description |
13981398
|-------|------|---------|-------------|
1399-
| `tool_response_mode` | string | `"full"` | `full` returns complete `inputSchema` entries (pre-Spec-085 behavior, byte-identical). `compact` returns one-line signatures instead: per entry `id`, `score`, `sig`, first-sentence `desc`, and a `lossy` flag, plus one top-level `hint` line. |
1399+
| `tool_response_mode` | string | `"full"` | `full` returns complete `inputSchema` entries (pre-Spec-085 behavior). `compact` returns one-line signatures instead: per entry `id`, `score`, `sig`, first-sentence `desc`, a `lossy` flag and the same `call_with` route as full mode, plus one top-level `hint` line. |
14001400

14011401
- **Compact signatures**: `*` marks a required parameter (never elided), `~` marks a lossy collapse (nested objects, long enums — call `describe_tool` for the full schema), short enums/defaults are inlined (e.g. `(origin*:str, ttl:int=3600)`).
14021402
- **Per-call override**: the `detail` parameter on `retrieve_tools` (`compact` | `full`) overrides the configured mode for that call only.

‎docs/features/search-discovery.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ the query, the ranking, or which tools are found.
108108

109109
| Option | Type | Default | Values | Description |
110110
|--------|------|---------|--------|-------------|
111-
| `tool_response_mode` | string | `"full"` | `full`, `compact` | `full` returns each result with its complete `inputSchema` (pre-Spec-085 behaviour, byte-identical). `compact` returns a one-line signature instead: per entry `id`, `score`, `sig`, a first-sentence `desc` and a `lossy` flag, plus one top-level `hint`. An empty value means `full`. |
111+
| `tool_response_mode` | string | `"full"` | `full`, `compact` | `full` returns each result with its complete `inputSchema` (pre-Spec-085 behaviour). `compact` returns a one-line signature instead: per entry `id`, `score`, `sig`, a first-sentence `desc`, a `lossy` flag and the same `call_with` route as full mode, plus one top-level `hint`. An empty value means `full`. |
112112

113113
- **Compact signatures**: `*` marks a required parameter (never elided), `~`
114114
marks a lossy collapse (nested objects, long enums), and short enums/defaults

‎internal/contracts/intent.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,11 @@ func (i *IntentDeclaration) ValidateAgainstServerAnnotations(toolVariant, server
197197
}
198198

199199
// DeriveCallWith derives the recommended tool variant from server annotations.
200-
// Defaults to call_tool_read as the safest option when intent is unclear.
201-
// LLMs should analyze the tool description to override this default when appropriate.
200+
// Defaults to call_tool_read when no tier annotation is present. Agents are told
201+
// that call_with is authoritative over a tool's name, description or the requested
202+
// outcome (PR #1668), so this default is what an unannotated tool is routed through;
203+
// the server-side tier gate and the profile `unannotated` policy, not the agent's
204+
// guess, decide whether that call is admitted.
202205
//
203206
// Priority:
204207
// 1. destructiveHint=true → call_tool_destructive

‎internal/server/mcp.go‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,7 +1165,7 @@ func buildCallToolVariantTool(variant string) mcp.Tool {
11651165

11661166
switch variant {
11671167
case contracts.ToolVariantRead:
1168-
description = "Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only."
1168+
description = "Execute a READ-ONLY tool. Use this variant only when retrieve_tools returns call_with=call_tool_read. The upstream annotations and call_with are authoritative over a tool's name, description, command text, or the requested outcome. Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first)."
11691169
title = "Call Tool (Read)"
11701170
nameExample = "github:get_user"
11711171
sensitivityDesc = "Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns."
@@ -1176,7 +1176,7 @@ func buildCallToolVariantTool(variant string) mcp.Tool {
11761176
mcp.WithOpenWorldHintAnnotation(true),
11771177
}
11781178
case contracts.ToolVariantWrite:
1179-
description = "Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state."
1179+
description = "Execute a STATE-MODIFYING tool. Use this variant only when retrieve_tools returns call_with=call_tool_write. The upstream annotations and call_with are authoritative over a tool's name, description, command text, or the requested outcome. Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first)."
11801180
title = "Call Tool (Write)"
11811181
nameExample = "github:create_issue"
11821182
sensitivityDesc = "Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes."
@@ -1187,7 +1187,7 @@ func buildCallToolVariantTool(variant string) mcp.Tool {
11871187
mcp.WithOpenWorldHintAnnotation(true),
11881188
}
11891189
case contracts.ToolVariantDestructive:
1190-
description = "Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations."
1190+
description = "Execute a DESTRUCTIVE tool. Use this variant only when retrieve_tools returns call_with=call_tool_destructive. The upstream annotations and call_with are authoritative over a tool's name, description, command text, or the requested outcome. This variant does not bypass authorization, quarantine, approval, or other dispatch gates. Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first)."
11911191
title = "Call Tool (Destructive)"
11921192
nameExample = "github:delete_repo"
11931193
sensitivityDesc = "Classify data being deleted: public, internal, private, or unknown. Important for tracking destructive operations on sensitive data."
@@ -1343,7 +1343,7 @@ const retrieveToolsDiagnosticsNote = " ANNOTATION FILTERS: read_only_only, exclu
13431343
func (p *MCPProxyServer) registerTools(_ bool) {
13441344
// retrieve_tools - THE PRIMARY TOOL FOR DISCOVERING TOOLS - Enhanced with clear instructions
13451345
retrieveToolsOpts := []mcp.ToolOption{
1346-
mcp.WithDescription("🔍 CALL THIS FIRST to discover relevant tools! This is the primary tool discovery mechanism that searches across ALL upstream MCP servers using intelligent BM25 full-text search. Always use this before attempting to call any specific tools. Use natural language to describe what you want to accomplish (e.g., 'create GitHub repository', 'query database', 'weather forecast'). Results include 'annotations' (tool behavior hints like destructiveHint) and 'call_with' recommendation indicating which tool variant to use (call_tool_read/write/destructive). Then use the recommended variant with an 'intent' parameter. Compact mode returns one-line signatures ('sig': '*'=required, '~'=lossy) with first-sentence 'desc'; call describe_tool for full schemas. NOTE: Quarantined servers are excluded from search results for security. Use 'quarantine_security' tool to examine and manage quarantined servers. TO ADD NEW SERVERS: Use 'list_registries' then 'search_servers' to find and add new MCP servers." + retrieveToolsReachNote + retrieveToolsDiagnosticsNote),
1346+
mcp.WithDescription("🔍 CALL THIS FIRST to discover relevant tools! This is the primary tool discovery mechanism that searches across ALL upstream MCP servers using intelligent BM25 full-text search. Always use this before attempting to call any specific tools. Use natural language to describe what you want to accomplish (e.g., 'create GitHub repository', 'query database', 'weather forecast'). Follow each result's call_with route exactly; upstream annotations determine that route and take precedence over tool names, descriptions, command text, and requested outcome. Compact results preserve the same call_with route as full results, including the existing read fallback when upstream tier annotations are unavailable. Never infer a route from a tool's name or intent. Compact mode returns one-line signatures ('sig': '*'=required, '~'=lossy) with first-sentence 'desc'; call describe_tool for full schemas. NOTE: Quarantined servers are excluded from search results for security. Use 'quarantine_security' tool to examine and manage quarantined servers. TO ADD NEW SERVERS: Use 'list_registries' then 'search_servers' to find and add new MCP servers." + retrieveToolsReachNote + retrieveToolsDiagnosticsNote),
13471347
mcp.WithTitleAnnotation("Retrieve Tools"),
13481348
mcp.WithReadOnlyHintAnnotation(true),
13491349
mcp.WithDestructiveHintAnnotation(false),
@@ -2402,7 +2402,8 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques
24022402
// construction lives in buildToolEntry (Spec 085 entry-builder seam) —
24032403
// full mode is byte-identical to the pre-extraction inline code, guarded
24042404
// by the T011 golden test (FR-006/SC-003); compact mode renders
2405-
// {id, score, sig, desc, lossy} from the shared signature cache. The
2405+
// {id, score, sig, desc, lossy, call_with} from the shared signature cache
2406+
// (call_with added by #1668 so compact routing never falls back to names). The
24062407
// ranked order of `results` is already final here — the mode selects
24072408
// serialization only (FR-007).
24082409
entryOpts := toolEntryOpts{includeStats: includeStats}
@@ -2439,11 +2440,7 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques
24392440
"Do NOT use call_tool_read, call_tool_write, or call_tool_destructive — they are not available in code execution mode."
24402441
default:
24412442
// Default instructions for retrieve_tools mode and backward-compatible callers
2442-
usageInstructions = "TOOL SELECTION GUIDE: Check the 'call_with' field for each tool, then use the matching tool variant. " +
2443-
"DECISION RULES BY TOOL NAME: " +
2444-
"(1) READ (call_tool_read): search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. DEFAULT choice when unsure. " +
2445-
"(2) WRITE (call_tool_write): create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. " +
2446-
"(3) DESTRUCTIVE (call_tool_destructive): delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. " +
2443+
usageInstructions = "TOOL SELECTION GUIDE: Use each result's 'call_with' value as the exact variant to call (call_tool_read, call_tool_write, or call_tool_destructive). The route comes from upstream annotations and overrides tool names, descriptions, command wording, and apparent user intent. When no upstream tier annotations exist, call_with preserves the existing read fallback; do not infer a different route from tool names or intent. " +
24472444
"INTENT TRACKING: Always provide intent_reason (why you're calling this tool) and intent_data_sensitivity (public/internal/private/unknown) to enable activity auditing."
24482445
}
24492446

‎internal/server/mcp_entry_builder.go‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
// "hint") to every compact-mode retrieve_tools response (Spec 085 FR-009).
1616
// It explains the lossy marker and the describe_tool second stage; being part
1717
// of the serialized payload it counts toward every measured response size.
18-
const compactModeHint = "sig legend: name*:type = required param, ~ = collapsed/lossy (details omitted), (~) = schema unavailable. Before calling a tool whose entry is lossy:true, call describe_tool with its id to get the full input schema."
18+
const compactModeHint = "sig legend: name*:type = required param, ~ = collapsed/lossy (details omitted), (~) = schema unavailable. Follow the call_with route exactly; it matches the full tool result, including the existing read fallback when no tier annotations are available. Never infer a route from the name, description, command text, or requested outcome. Before calling a lossy entry, call describe_tool for its full input schema."
1919

2020
// toolEntryOpts carries the per-request options that shape one retrieve_tools
2121
// result entry (Spec 085 entry-builder seam, research.md R5).
@@ -47,8 +47,9 @@ type toolEntryOpts struct {
4747
// built inline before the extraction — byte-identical under
4848
// json.Marshal, guarded by the T011 golden test (FR-006/SC-003).
4949
// - mode compact (US1, FR-002): {id, score, sig, desc, lossy} rendered
50-
// from the shared p.sigCache — no inputSchema, no full description, no
51-
// annotations (those move to describe_tool / self-healing errors).
50+
// from the shared p.sigCache, plus the same call_with route as full mode.
51+
// No inputSchema, full description, or annotations
52+
// (those move to describe_tool / self-healing errors).
5253
//
5354
// Cross-cutting response sections (usage_instructions, disabled/remediation,
5455
// notice, session_risk, debug, usage_summary) intentionally stay in
@@ -69,8 +70,9 @@ func (p *MCPProxyServer) buildToolEntry(result *config.SearchResult, mode string
6970
// here.
7071
//
7172
// include_stats is intentionally ignored in compact mode: the compact entry
72-
// shape is fixed at exactly {id, score, sig, desc, lossy} (data-model §4);
73-
// the top-level usage_summary section still renders in the handler.
73+
// shape is {id, score, sig, desc, lossy, call_with}, with call_with derived
74+
// from the same annotations and fallback as full mode; the top-level
75+
// usage_summary section still renders in the handler.
7476
func (p *MCPProxyServer) buildCompactToolEntry(result *config.SearchResult) map[string]interface{} {
7577
tool := result.Tool
7678

@@ -94,13 +96,34 @@ func (p *MCPProxyServer) buildCompactToolEntry(result *config.SearchResult) map[
9496
}
9597
}
9698

97-
return map[string]interface{}{
99+
entry := map[string]interface{}{
98100
"id": tool.Name,
99101
"score": result.Score,
100102
"sig": sig.Sig,
101103
"desc": sig.Desc,
102104
"lossy": sig.Lossy,
103105
}
106+
entry["call_with"] = p.compactToolCallWith(result)
107+
return entry
108+
}
109+
110+
// compactToolCallWith mirrors buildFullToolEntry's route selection so compact
111+
// entries preserve the existing dispatch classification, including its read
112+
// fallback when no upstream tier hints are available.
113+
func (p *MCPProxyServer) compactToolCallWith(result *config.SearchResult) string {
114+
if result == nil || result.Tool == nil {
115+
return contracts.ToolVariantRead
116+
}
117+
serverName := result.Tool.ServerName
118+
toolName := result.Tool.Name
119+
if serverName == "" {
120+
parts := strings.SplitN(result.Tool.Name, ":", 2)
121+
if len(parts) != 2 {
122+
return contracts.ToolVariantRead
123+
}
124+
serverName, toolName = parts[0], parts[1]
125+
}
126+
return contracts.DeriveCallWith(p.lookupToolAnnotations(serverName, toolName))
104127
}
105128

106129
// buildFullToolEntry reproduces today's full-mode entry exactly

0 commit comments

Comments
 (0)