Skip to content

mcp: reject unknown tool options at dispatch - #614

Open
pbednarcik wants to merge 1 commit into
zzet:mainfrom
pbednarcik:fix/mcp-tool-arg-guard
Open

mcp: reject unknown tool options at dispatch#614
pbednarcik wants to merge 1 commit into
zzet:mainfrom
pbednarcik:fix/mcp-tool-arg-guard

Conversation

@pbednarcik

Copy link
Copy Markdown
Contributor

Addresses #597.

Problem

Request decoding reads the keys it knows and unknown keys simply vanish. The measured shape from the issue: an agent passed line_range: [120, 160] to read_file three times — a plausible guess for an option that doesn't exist — and got the full file back each time, silently. Maximum token cost on the heaviest tool in the surface, for a call that explicitly asked for a 40-line window, with no signal to self-correct.

Why reject, not the rider I proposed first

The issue ranked a warning rider (option 2) as the natural first step. Running agents against my own index changed my mind: the rider arrives attached to the full-cost response — the tokens are already spent, and the correction only helps the next call. An immediate error costs a few dozen tokens, names the bad key, and the agent fixes the very next call; the expensive wrong answer is never produced. So this PR makes dispatch honor the schema's own contract by default, with the rider and the old behavior both one env var away:

  • default — unknown key on a closed schema → tool error, before the handler runs: read_file does not accept option(s): line_range; valid options: …
  • GORTEX_TOOL_ARG_GUARD=warn — run the handler, append an _ignored_options rider (option 2 as described in the issue)
  • GORTEX_TOOL_ARG_GUARD=off — pre-guard behavior

Change

  • prepareTool closes any structured schema that never took a position (additionalProperties unset → false), so tools/list publishes exactly what dispatch enforces. Tools that opt into open args (AdditionalProperties(true)) keep them — enforcement follows the schema in both directions, raw schemas as authored.
  • wrapToolArgGuard enforces at the same seam as the existing overlay/telemetry middleware, so both the daemon-dispatched path and the in-process HTTP path get it, and lazy-promoted tools inherit it.
  • Facade names are exempt: their compatibility wrapper deliberately accepts legacy call shapes the facade schema doesn't declare, and the facade options envelopes are open by design. Per-operation key enforcement inside those envelopes would be the follow-up — that one's a design conversation, not a patch.
  • tools/list byte ceilings re-based (+~27 bytes per tool for the published stamp), following the constants' own re-base convention.

Tests

Nine pins: rejection happens before the handler runs (the handler-never-ran assertion is the point of the whole change), valid/nil args pass, explicit-open structured and raw schemas stay unenforced, closed raw schemas reject typo'd keys, warn mode runs the handler and names what it ignored, off mode restores old behavior, prepareTool publishes the closed schema — and the issue's exact shape end to end through real MCP frames: read_file(line_range: [120,160]) refuses before the read, the corrected call works. Full suite matches my Windows baseline.

Dispatch read only the keys it knew, so an unknown option vanished
silently — read_file(line_range: ...) returned the full file at maximum
token cost with no signal to self-correct (zzet#597). Close every structured
schema that never took a position, publish that in tools/list, and
enforce it before the handler runs: unknown keys error immediately,
naming them and the valid options. GORTEX_TOOL_ARG_GUARD=warn appends an
_ignored_options rider instead; =off restores the old behavior. Facade
names stay exempt — their compat wrapper accepts legacy shapes by design.

@zzet zzet left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The blanket additionalProperties:false stamp closes 152 of 173 registered tools and hard-rejects keys gortex's own callers inject. Verified on 277152b against b55b9a0.

1. format injection → refusal on 54/152 closed tools

Three first-party injectors add format to arbitrary tools' args:

  • cmd/gortex/cli_daemon.go:95buildToolCallFrameWithDefault pins format:"json" into every legacy-surface CLI frame (pinJSONDefault = tools != facade-v1; CLI verbs request core).
  • cmd/gortex/call.go:117gortex call <non-facade tool> sets argObj["format"] = callFormat (default json). Only the facade branch is exempt.
  • internal/server/handler.go:449 — merges ?format= / body format into any tool. docs/server.md:32 documents this for "any MCP tool".

Broken:

  • gortex auditaudit_health
  • gortex affectedget_test_targets (affected.go:149)
  • gortex tools list|receipttool_profile (declares only ["tool"]). The receipt reports AdvertisedTools:0 / no_surface_mounted against a healthy daemon, no error surfaced.
  • gortex edit — 9/13 subcommands via edit.go:67: verify_change, get_edit_plan, preview_edit, simulate_chain, batch_edit, edit_file, edit_symbol, rename_symbol, get_test_targets
  • gortex call edit_file --arg path=... — the invocation printed in docs/cli.md:250
  • POST /v1/tools/read_file?format=gcx and 53 other tools
audit_health does not accept option(s): format; valid options: project, repo, scope, workspace

2. Handler-honored keys that no schema declares

  • read_file reads max_chars (tools_fileops.go:1408, honored via capReadFileContent) but doesn't declare it. TestReadFilePhysicalEvidenceMaxCharsRetainsTruncationContract asserts that contract and stays green. Its description also says Composable with format:"gcx" while declaring no format.
  • budget.go:481 effectiveBudget reads max_bytes / max_tokens generically for every tool (budget.go:55: "wired onto every list-shaped tool"); applyFieldsFilter does the same for fields. 104/152 closed tools don't declare max_bytes, 148/152 don't declare fields. search_text{max_bytes} and graph_query{fields} now hard-error.

3. Gortex's own hook self-degrades silently

internal/hooks/subagent.go renderTaskContext calls smart_context{task, compact:true}. smart_context declares no compact. Both hook transports collapse isError to "", so the ### Relevant Symbols block drops out of every subagent briefing with nothing logged.

4. The suite cannot see any of this

Full suite is green on the head (internal/mcp, internal/server, internal/server/hub, cmd/gortex). Nothing crosses the guarded path with a caller-built arg map:

  • internal/mcp/server_test.go:69 findAndCallHandler dispatches through a hand-maintained handler map, bypassing prepareTool.
  • cmd/gortex tests stub the relay seam (edit_test.go:40, tools_cmd_test.go:38, analyze_test.go:31).
  • cli_daemon_test.go:13 only asserts the pin exists, using search_symbols / graph_stats — both declare format.
  • internal/server/handler_test.go:204's ?format=gcx test registers its fixture via bare mcpserver.AddTool.

5. Guard is inert where #597 was observed

docs/mcp.md:114: every connection with a non-empty clientInfo.name defaults to facade-v1 — the 21 tools exempted at server.go:3120. Second cause: s.facades.capture(*tool, handler) at :3128 stores the pre-guard closure facade dispatch invokes. Measured: read(operation:"file", target:{file:"main.go"}, line_range:"1-3") returns the whole file, isError=false, no rider. So the fix doesn't fire where the bug was reported, and does fire on the CLI, which it breaks.

Premise

Schemas do not declare additionalProperties:false on main — this PR's own TestPrepareToolStampsClosedSchema asserts require.Nil(...) before the stamp. The change mints the contract and enforces it in one step rather than honoring an existing one, so "clients that work by accident" understates the break.

Suggested path

Default to warn; make reject opt-in (GORTEX_TOOL_ARG_GUARD=reject). That clears every item above and matches this issue's own ranking of option 2 as the first step.

If reject-by-default is wanted:

  1. Teach the three injectors to consult the published schema, or exempt a fixed response-shaping key set (format, fields, max_bytes, max_tokens, cursor) inside wrapToolArgGuard.
  2. Declare max_chars on read_file and compact on smart_context.
  3. Add an integration test piping buildToolCallFrame(...) output for audit_health / verify_change / get_test_targets through srv.MCPServer().HandleMessage, asserting isError=false. The harness at arg_schema_guard_test.go:161 already exists.
  4. A lint test asserting no shipped tool reads an undeclared key prevents recurrence.

Correct as-is

The middleware seam is the right one: the guard survives lazy promotion (tool stamped before lazy.Register), covers control tools and both dispatch paths. reconcileToolParams (overlay.go:124) runs before the guard and deletes resolved alias keys, so typo recovery still works. checkToolGate runs first, so hidden tools leak no schema. The e2e test is not vacuous — the fixture's main.go really contains func helper(). Byte-ceiling re-base verified against main: agent had 8 bytes of slack (28192/28200) → 28743/29050; loc 20545 → 20922; core/full still under their pre-diet baselines.

Minor: warn appends only to Content, so it is invisible to structuredContent readers, and it also fires on error results. The off-vocabulary {off,0,false,none} diverges from the repo's {0,false,off,no} (parse_gate.go:97). The RawInputSchema branch is unreachable — 0/173 live tools use raw schemas — and its test comment about "the facade envelopes" is wrong; facades use structured schemas.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants