Skip to content

Reject tools/call for tools hidden from tools/list - #6216

Open
jerm-dro wants to merge 2 commits into
mainfrom
jerm-dro/01KYWE0KFZ800M7E2A70KM54XM
Open

Reject tools/call for tools hidden from tools/list#6216
jerm-dro wants to merge 2 commits into
mainfrom
jerm-dro/01KYWE0KFZ800M7E2A70KM54XM

Conversation

@jerm-dro

@jerm-dro jerm-dro commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

vMCP tool filtering (aggregation.tools, filter, excludeAll, excludeAllTools) is enforced on the Legacy path but not on the Modern one, so a Modern client that knows the name of a filtered-out tool can call it successfully. Filtering is understood as an access-control boundary — #6073 uses a filtered vMCP to constrain unsupervised agents — so the same config silently means something different depending on which revision a client speaks.

The aggregator deliberately puts every backend tool in the routing table — including ones withheld from tools/list — so composite-tool workflow steps can still reach them (#3636, default_aggregator.go:349). Legacy registers one SDK handler per advertised tool, so registration is the gate and a filtered name is rejected as unknown. Modern is stateless with no registration step: tools/call goes from the request to core.CallTool (modern_dispatch.go:297), which resolves the name against the unfiltered table (core_calls.go:64). Nothing narrows it. CallTool's doc comment already promised ErrNotFound for an unadvertised name; nothing enforced it.

  • Hold tools/call to the advertised view in the core, not in the Modern dispatcher, so every consumer inherits the contract (Modern, codemode, ratelimit, any future VMCP decorator) rather than one transport being patched — see the reviewer note below on why this is broader than the issue proposed. The check reuses advertisedTools/findAdvertisedTool against the agg already in hand — no extra aggregation or backend fan-out.
  • Checked after authorizeToolCall, so a denied tool keeps returning ErrAuthorizationFailed. If the order were reversed, ErrNotFound vs. denial would tell an unauthorized caller which hidden tools exist.
  • Classify the refusal as -32602 (HTTP 400) on the Modern path, at the tools/call site. Without this, ErrNotFound launders into -32603/HTTP 200 and the two eras still disagree on the same input — Legacy answers -32602 "unknown tool" (go-sdk server.go:957).

Composite tools are unaffected, with no exemption code. A workflow enters CallTool once under its own (advertised) name; its steps then run composer → router.RouteTool → backendClient.CallTool (workflow_engine.go:428,486) and never re-enter the core. #3636 is preserved exactly.

Fixes #6217

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

task lint-fix reports 4 issues (3 gosec, 1 staticcheck), all pre-existing in files this PR does not touch — verified identical on a clean main checkout.

Both failure directions were verified by deliberately breaking the code, because this invariant has two halves that must move in opposite directions:

Injected mistake Result
Revert the advertised-view guard Modern leg fails — hidden_tool returns fake result, i.e. a successful filtered-tool call (Legacy still passes, reproducing the asymmetry the issue describes)
Prune hidden tools from the routing table instead (the tempting wrong fix) Both legs fail on a composite workflow step must still reach a hidden backend tool (#3636)

That second row is the point: pruning the routing table would satisfy every "filtered tool is rejected" assertion while silently re-breaking #3636. A single-direction test is what let this bug ship in the first place.

Changes

File Change
pkg/vmcp/core/core_calls.go CallTool resolves against the advertised view, after authz; doc comment now states which view "unadvertised" means
pkg/vmcp/server/modern_dispatch.go ErrNotFound on tools/call-32602/HTTP 400 instead of -32603
pkg/vmcp/server/hidden_tool_regression_test.go New: both-eras, both-directions regression guard with a backend-call recorder
pkg/vmcp/server/session_management_integration_test.go serverOptions.hiddenTools (routing-table-only names) and onBackendCall (records forwarded tool names)
pkg/vmcp/core/core_calls_test.go New rejection test; two fixtures that only passed because the contract was unenforced
pkg/vmcp/core/core_checks_test.go New: denial takes precedence over not-found for a filtered and denied tool

Does this introduce a user-facing change?

Yes — two related narrowings, both deliberate:

  1. A tool filtered out of tools/list is no longer directly callable. This is the point of the fix: filtering now holds identically on both revisions. Anyone relying on invoking an excludeAll/filter-hidden tool by name loses that. Because the fix is in the core rather than the Modern dispatcher, it applies to every path (Modern, codemode, ratelimit), not just Modern. Composite tools calling filtered tools continue to work, which is the supported way to reach one.
  2. RouteTool's {workloadID}.{toolName} alias is no longer accepted for a direct call. An alias is by definition not an advertised name, so accepting one would leave a second bypass of exactly the boundary this PR closes. It exists so composite workflow step definitions survive conflict-resolution renames and keeps working there, inside the composer. Nothing in-tree called the core with an alias (codemode binds innerTools; the optimizer and Legacy bind advertised names), so this affects only an external core-library consumer that constructed one by hand.

Special notes for reviewers

Two scrutiny requests:

  1. Why the core and not the Modern dispatcher. Modern path: filtered-out vMCP tools are still callable #6217 proposes refusing to route a Modern call; this fixes it one layer down. A dispatcher-only guard was considered and rejected: it would cost an extra aggregation per tools/call (via LookupTool), leave core.CallTool's documented ErrNotFound contract false for library consumers, and — given the issue frames filtering as an access-control boundary — fix only the transport that happens to expose the gap today, leaving the next stateless consumer to re-open it. The work item that prompted this assumed a core guard would need an explicit composite-step exemption; it does not, because steps never reach core.CallTool. That's the load-bearing fact — if it's wrong, this design is wrong.

  2. -32602 is scoped to tools/call on purpose. It is written at the call site rather than added to writeModernDispatchError, which is shared with resources/read, prompts/get and completion/complete — their not-found classification is a separate decision, out of scope here. Consequence: those verbs still answer -32603 for a not-found, so Modern is briefly inconsistent across verbs. Worth a follow-up, but bundling it would mix scopes.

Deliberate rewrite worth a close look: TestCallTool_ResolvesRenamedTool previously asserted that CallTool("be1.echo") succeeds while only be1_echo is advertised — it pinned the dot alias as callable through the core. It is now split into a resolved-name success leg and an alias-rejection leg. This is the one existing assertion this PR intentionally inverts.

The error message deliberately does not name the requested tool. Echoing it back would turn the not-found/denied pair into a probe for which denied tools exist; TestCallTool_DenialPrecedesNotFound pins that ordering.

No e2e run: the existing e2e coverage (test/e2e/vmcp_cli_features_test.go:248-347) only asserts filtered tools are absent from tools/list and never that they are uncallable — which is precisely the blind spot that let this ship. Adding a Modern callability leg there is worth a follow-up; the new integration test covers both eras in-process in the meantime.

Generated with Claude Code

The aggregator keeps every backend tool in the routing table, including
ones withheld from tools/list by excludeAllTools, per-workload excludeAll,
or filter, so composite-tool workflow steps can still reach them (#3636).
core.CallTool resolved a direct call against that same table, so a hidden
tool was callable by name -- documented as returning ErrNotFound for an
unadvertised name, but never enforced.

On the Modern (2026-07-28) path, where tools/call goes straight to
core.CallTool, this was reachable off the wire whenever no Cedar policy
was configured. Legacy was incidentally protected: the SDK only registers
advertised names on the session.

Hold the call to the advertised view in the core, so every consumer gets
the contract, and classify the refusal as -32602 on the Modern path to
match what Legacy already answers for an unregistered tool.

Composite tools are unaffected: a workflow enters CallTool once under its
own advertised name, and its steps then bypass the core entirely.
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Aug 5, 2026
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.46%. Comparing base (2c623d5) to head (5beec43).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6216      +/-   ##
==========================================
- Coverage   72.51%   72.46%   -0.05%     
==========================================
  Files         739      739              
  Lines       76719    76727       +8     
==========================================
- Hits        55629    55603      -26     
- Misses      17107    17161      +54     
+ Partials     3983     3963      -20     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 5, 2026
@jerm-dro
jerm-dro requested a lite review from Copilot August 5, 2026 20:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR closes an access-control gap in vMCP where Modern (stateless) tools/call could invoke tools that were intentionally hidden by aggregation tool filtering, aligning behavior with the Legacy (session/SDK) path and with core.CallTool’s documented contract.

Changes:

  • Enforce “directly callable == advertised” inside core.CallTool (after authz) so all callers/transports share the same boundary.
  • Map vmcp.ErrNotFound for Modern tools/call to JSON-RPC -32602 / HTTP 400 to match Legacy’s “unknown tool” classification.
  • Add regression/integration/unit tests to assert both halves of the invariant: hidden tools are not directly callable, but remain routable for composite workflow steps.

vMCP Review (anti-pattern scan; touched files under pkg/vmcp/)

Clean

  • No issues found for vMCP anti-patterns #1#10 in the changed code paths (no new context-value coupling, no repeated body read/restore patterns, no new caching/abstraction leakage in core/serve boundaries, etc.).

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pkg/vmcp/core/core_calls.go Enforces advertised-view gating for direct tool calls (after authz), preserving composite-step routing via the routing table.
pkg/vmcp/server/modern_dispatch.go Reclassifies ErrNotFound from tools/call to -32602/HTTP 400 for Modern parity with Legacy.
pkg/vmcp/server/hidden_tool_regression_test.go Adds two-direction regression test covering Modern + Legacy and composite-step counterweight.
pkg/vmcp/server/session_management_integration_test.go Extends integration harness to support routing-table-only hidden tools and backend-call recording.
pkg/vmcp/core/core_calls_test.go Adds core-level guard test + updates rename/alias expectations to match new advertised-only direct-call behavior.
pkg/vmcp/core/core_checks_test.go Adds ordering test to ensure authz denial takes precedence over not-found for hidden+denied tools.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/vmcp/server/hidden_tool_regression_test.go

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed this alongside a second independent review pass, then had the disputed findings adjudicated before writing anything up. The fix itself I'm happy with: holding the call to the advertised view in the core rather than in the Modern dispatcher is the right call, the composite-steps-never-re-enter-CallTool reasoning holds up (I traced it), and the both-directions test is the part I'd want to keep — a single-direction test is exactly what let this ship. -32602 is also right, and not just SDK-faithful: it's the literal example in the 2026-07-28 spec's tools error-handling section.

Everything below is comments, docs, or test coverage. Nothing blocks merge.

Two things I want to flag that aren't inline, because they're on files this diff doesn't touch:

  1. pkg/vmcp/core/admission.go:322findAdvertisedTool's doc comment still ends "...and routing remains the authority on whether the call resolves." That's now false, and it's the helper this fix is built on. Worth the one-line fix while you're here.
  2. docs/arch/10-virtual-mcp-architecture.md:222 — the whole ### Tool Filtering section is one sentence. The invariant this PR establishes (advertised == directly callable, routing table deliberately wider and reachable only from inside a composite) isn't recorded anywhere in docs/arch/. Right now the only place it's written down is the new test comments, which means the next person who "fixes" this by pruning the routing table has nothing telling them not to. Three or four sentences in that section would do it.

Smaller, also not inline:

  • aggregator/default_aggregator.go:635shouldAdvertiseTool's "controls advertising, not routing" now understates things, since this gate is what decides callability. One clause. (Its neighbours are fine — they describe routing-table contents, which genuinely didn't change.)
  • buildTestServerWithOptions never calls srv.Stop, and core.New starts the workflow-state cleanup ticker in its constructor, so every fixture leaks a core. Pre-existing — main already has 7 leaking call sites and this PR adds 2 — so fixing it once in the helper (register srv.Stop before t.Cleanup(ts.Close)) is the smaller diff and gets all 9. Your call whether that belongs here or separately.
  • resources/read still answers -32603 for a nonexistent URI on Modern, which is a spec MUST violation (§Resources mandates -32602), and prompts/get diverges from Legacy the same way. You flagged the inconsistency yourself and scoping it out is right — but it's a real protocol bug rather than a cosmetic nit, so it's worth an issue rather than just a note.

One thing I checked and want to explicitly not ask you to change: the guard uses the pre-admission aggregation view rather than ListTools' post-FilterTools result. That's correct as written. Making it match ListTools would delete the args-aware call-side decision and turn an authorization outcome into -32602, which recreates the enumeration oracle your own TestCallTool_DenialPrecedesNotFound guards against. Only the comment needs narrowing, not the code.

Comment thread pkg/vmcp/core/core_calls.go Outdated
Comment on lines +28 to +31
// "Unadvertised" is enforced against the same view ListTools returns, NOT the
// routing table — which intentionally holds more (see the advertised-view check
// below). A tool hidden from tools/list is therefore not directly callable,
// while composite workflow steps may still reach it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"the same view ListTools returns" isn't quite true, and the second sentence is broader than what the code does. ListTools is admission.FilterTools(advertisedTools(agg)) (core_vmcp.go:281), so it returns a narrower set than the one this guard checks — FilterTools authorizes with nil args (admission.go:159) while AllowToolCall gets the real ones (admission.go:186). So a tool hidden from tools/list by an argument-conditional Cedar policy is still callable when the call carries permitted arguments.

That's fine and deliberate — authorizeToolCall is the enforcement point for that case and it runs first. It just means this comment is promising something wider than the guard delivers. The commit message and PR body both get this right (they scope to excludeAllTools/excludeAll/filter); it's only here that it's unqualified.

Suggested change
// "Unadvertised" is enforced against the same view ListTools returns, NOT the
// routing table — which intentionally holds more (see the advertised-view check
// below). A tool hidden from tools/list is therefore not directly callable,
// while composite workflow steps may still reach it.
// "Unadvertised" here means the AGGREGATION view that ListTools filters — NOT
// the routing table, which intentionally holds more (see the advertised-view
// check below). Admission narrowing on top of that view is enforced separately,
// by authorizeToolCall. So a tool hidden by excludeAllTools, per-workload
// excludeAll, or filter is not directly callable, while composite workflow steps
// may still reach it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, and the distinction matters — thanks. ListTools is FilterTools(advertisedTools(agg)) (core_vmcp.go:281), and FilterTools authorizes with nil args (admission.go:161) while AllowToolCall gets the real ones (admission.go:187), so the argument-conditional case you describe is real: a tool absent from tools/list under a when {} clause is still callable with permitted arguments, via authorizeToolCall.

Applied your wording verbatim in 5beec43.

Comment thread pkg/vmcp/core/core_calls.go Outdated
// (router/session_router.go:105) for a direct call: an alias is never an
// advertised name. That alias exists for workflow step definitions and keeps
// working there, inside the composer.
if findAdvertisedTool(c.advertisedTools(agg), name) == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

authorizeToolCall already did this exact lookup four lines up — core_checks.go:79 is also findAdvertisedTool(c.advertisedTools(agg), name), it just throws away the result and substitutes a bare &vmcp.Tool{Name: name} when it misses. And line 85 then calls accessibleComposites(agg) a third time, which advertisedTools already called internally.

So on a view with composites, every tools/call now runs FilterWorkflowDefsForSession + ValidateNoToolConflicts + ConvertWorkflowDefsToTools three times, allocates the concatenated tool slice twice, and linear-scans it twice.

Cheapest fix is to compute both once at the top of CallTool and thread them through — authorizeToolCall taking the precomputed *vmcp.Tool (or the slice) instead of agg, and line 85 reusing the same composites map. That also removes the possibility of the two lookups ever disagreeing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — three passes, and I'd missed that advertisedTools calls accessibleComposites internally too.

Took the approach you suggested in 5beec43. CallTool now resolves both once:

composites := c.accessibleComposites(agg)
advertised := advertisedToolsWith(agg, composites)
tool := findAdvertisedTool(advertised, name)

then threads them through: authorizeToolCall takes the resolved *vmcp.Tool instead of agg (nil-checks internally and substitutes the bare stub, so its fail-closed behaviour for an absent name is unchanged), the guard becomes if tool == nil, and the composite branch reuses composites[name].

advertisedTools is split into a thin method plus advertisedToolsWith(agg, defs) taking a pre-resolved set, so ListTools/Discover/authorizedBackends keep their existing one-line call and only CallTool passes the shared map.

That collapses it to one pass, one allocation, one scan. And as you note, it also makes the drift you flagged structurally impossible — admission and the guard now read the same tool variable rather than performing independent lookups that could disagree.

CheckToolCall still does its own lookup, which is correct: it has no composite dispatch to share with, and the anti-drift guarantee is that both routes resolve the tool the same way, not that they share a call.

Comment thread pkg/vmcp/core/core_calls_test.go Outdated
Comment on lines +297 to +298
// never enters CallTool (see TestCallTool_CompositeWorkflow, whose step targets
// "be1.echo").

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pointer doesn't hold up, and it's hiding a coverage gap. TestCallTool_CompositeWorkflow doesn't exercise the alias fallback: it registers be1.echo as an exact routing-table key (line 148), so RouteTool's fast-path exact match answers and the dot-convention fallback at session_router.go:110-118 is never reached.

Which means this PR removes the only call in the suite that ever drove that fallback and replaces it with an assertion that it's rejected. The guarantee the alias exists for — a workflow step written as be1.echo still resolving when the routing table is keyed be1_echo after conflict resolution — now has no coverage at all, and a future prune of the fallback would go unnoticed.

Worth a third leg: routing key be1_echo with OriginalCapabilityName: "echo", a workflow step targeting be1.echo, asserting the step reaches backendClient.CallTool. That's the case session_router.go:87-95 says the alias is for.

Suggested change
// never enters CallTool (see TestCallTool_CompositeWorkflow, whose step targets
// "be1.echo").
// never enters CallTool. Note that TestCallTool_CompositeWorkflow does NOT cover
// the alias fallback: it registers "be1.echo" as an exact routing-table key, so
// RouteTool's fast path answers and the fallback never runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, and this was the most valuable comment in the review — I asserted that pointer without checking it. TestCallTool_CompositeWorkflow registers be1.echo at line 148 as an exact key, so RouteTool's fast path (session_router.go:101-103) answers and the fallback never executes. My PR would have removed the suite's only alias-driving call and replaced it with a rejection.

Added your third leg in 5beec43 — routing key be1_echo with OriginalCapabilityName: "echo", workflow step targeting be1.echo, asserting the step reaches backendClient.CallTool.

Verified it actually drives the fallback rather than passing incidentally, by short-circuiting the dot-convention branch:

if dotIdx := strings.Index(toolName, "."); false && dotIdx > 0 { // probe

Only the new leg fails:

--- FAIL: TestCallTool_ResolvesRenamedTool/composite_step_reaches_the_backend_through_the_dot-alias_fallback
    missing call(s) to MockBackendClient.CallTool(..., is equal to be1.echo, ...)

The other two legs pass with the fallback disabled, confirming they only ever used the fast path — exactly as you said.

Also corrected the comment to your suggested text, so it now warns about the fast-path shadowing instead of citing that test as coverage.

Comment thread pkg/vmcp/server/modern_dispatch.go Outdated
Comment on lines +303 to +305
// filter -- matching what the Legacy SDK path already returns for a tool it
// never registered on the session (-32602 "unknown tool", go-sdk
// server.go:957), which writeModernError maps to HTTP 400.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The citation is off. vMCP's Legacy path doesn't reach go-sdk's callTool — it goes through toolhive-core/mcpcompat, which produces the -32602 at mcpcompat/server/request_handler.go:146 and then rewrites the message in translateUnknownToolError (mcpcompat/server/server.go:1165-1179). So what a Legacy client actually sees is tool "X" not found, not unknown tool "X".

Same code either way, so the parity claim survives — but the two eras answer with different text, and this comment reads as though they're identical.

Suggested change
// filter -- matching what the Legacy SDK path already returns for a tool it
// never registered on the session (-32602 "unknown tool", go-sdk
// server.go:957), which writeModernError maps to HTTP 400.
// filter -- matching what the Legacy path already returns for a tool it
// never registered on the session (-32602, via toolhive-core mcpcompat's
// translateUnknownToolError, which rewrites the message to `tool "X" not
// found`), which writeModernError maps to HTTP 400.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 5beec43. I traced it: translateUnknownToolError at mcpcompat/server/server.go:1284 matches on "unknown tool" and rewrites to mcp-go's tool %q not found wording, preserving InvalidParams — called from the dispatch path at server.go:858.

Applied your suggestion, and added a sentence making the residual difference explicit ("Same code either way; the two eras differ only in message text"), since that's the part the original comment obscured.

Related: this is why the Legacy leg of the regression test asserts on the decoded error.code rather than message text — see your comment on hidden_tool_regression_test.go, which I've also applied. Matching on the message would have coupled the test to whichever era's wording it happened to be written against.

Comment thread pkg/vmcp/server/modern_dispatch.go Outdated
Comment on lines +309 to +313
// not-found classification is a separate decision. The message names no
// tool: an authorization denial is classified ahead of this (in
// core.CallTool, which authorizes before checking the advertised view), and
// echoing the name back here would turn the pair of answers into a probe
// for which denied tools exist.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The omission is a reasonable choice, but it doesn't buy what this says it buys. A denial answers 403 + JSONRPCCodeDenied; this answers 400 + -32602. Those are already trivially distinguishable by status and code, whether or not the name is in the message — so withholding it doesn't close a probe. (Legacy echoes the name, and so does the spec's own example for this error.)

Keep the behaviour, just don't claim it's a mitigation:

Suggested change
// not-found classification is a separate decision. The message names no
// tool: an authorization denial is classified ahead of this (in
// core.CallTool, which authorizes before checking the advertised view), and
// echoing the name back here would turn the pair of answers into a probe
// for which denied tools exist.
// not-found classification is a separate decision. The message names no
// tool: an authorization denial is classified ahead of this (in
// core.CallTool, which authorizes before checking the advertised view).
// Omitting the name is a conservative choice rather than a mitigation --
// a denial already answers 403 + JSONRPCCodeDenied against this 400 +
// -32602, so the two are distinguishable either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair — I over-claimed. 403 + JSONRPCCodeDenied vs. 400 + -32602 are trivially distinguishable regardless of the message, so withholding the name closes nothing. Applied your wording in 5beec43; behaviour unchanged, the justification is now honest about being conservatism rather than a mitigation.

Comment thread pkg/vmcp/server/modern_dispatch.go Outdated
// core.CallTool, which authorizes before checking the advertised view), and
// echoing the name back here would turn the pair of answers into a probe
// for which denied tools exist.
if errors.Is(err, vmcp.ErrNotFound) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor hardening, your call. authorizeToolCall wraps with a double %wfmt.Errorf("%w: tool %q: %w", vmcp.ErrAuthorizationFailed, name, err) at core_checks.go:84 — so errors.Is matches everything in both branches of that tree. Testing the derived sentinel before the security-classifying one means an authorizer error that happened to wrap ErrNotFound would answer 400 instead of 403.

Not reachable today: I checked, and pkg/authz has no import of pkg/vmcp at all, so neither the Cedar nor the HTTP/remote-PDP authorizer can produce an error carrying vmcp.ErrNotFound. But ErrNotFound is exported and Admission is a public interface, so a future implementation could trip this without knowing about the coupling. One line to make it order-independent:

Suggested change
if errors.Is(err, vmcp.ErrNotFound) {
if errors.Is(err, vmcp.ErrNotFound) && !errors.Is(err, vmcp.ErrAuthorizationFailed) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken — applied in 5beec43. Confirmed your reachability analysis independently: pkg/authz imports pkg/vmcp/optimizer, pkg/vmcp/schema and pkg/vmcp/session/optimizerdec, but never the pkg/vmcp root where ErrNotFound lives, so no current authorizer can produce an error carrying it.

Worth doing anyway for the reason you give: Admission is a public interface and ErrNotFound is exported, so the coupling is invisible to a future implementer. One && to make a security classification order-independent is the right trade — a mistake here fails open toward 400-instead-of-403, which is the wrong direction to leave load-bearing on statement order.

Documented the reasoning inline so the extra clause doesn't read as redundant to someone who checks the same import graph later and concludes it's dead.

Comment on lines +181 to +182
assert.Contains(t, hiddenBody, "-32602",
"Legacy must also refuse a hidden tool as an unknown tool: %s", hiddenBody)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is weaker than the Modern leg's check for the same invariant — Contains on the raw body passes if -32602 shows up anywhere in it (an unrelated frame, an echoed message), and never verifies it's error.code for request id 3.

The stated reason for staying at string level ("the SDK's streamable transport may answer as SSE") is already contradicted in this package: listToolNames in session_management_integration_test.go:635-660 decodes Legacy tools/list responses just fine. Since this is the assertion keeping the two eras from drifting, it'd be worth decoding and asserting error.code == -32602 the way the Modern leg does at line 143.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right on both points, and the SSE justification was wrong — listToolNames (session_management_integration_test.go:635) decodes Legacy responses with a plain json.NewDecoder, so the transport answers JSON here.

Fixed in 5beec43:

  • tools/list now goes through the existing listToolNames helper and asserts on the returned names, rather than substring-matching the body.
  • tools/call goes through a new legacyCallError that decodes and returns the error object, so the assertion is assert.Equal(t, float64(-32602), hiddenErr["code"]) — the same shape as the Modern leg at line 143.

legacyCall survives only for the composite counterweight, where the assertion is on the backend recorder and the body is irrelevant; its doc comment now says so instead of carrying the bogus SSE claim.

As you say, this is the assertion holding the two eras together, so it needed to be as strong as the Modern one. It's also now robust to the message-text difference between eras that you flagged on modern_dispatch.go — code only, no wording.

Resolve the advertised view and composite set once in CallTool and
thread both through admission, the not-found guard and composite
dispatch. Each of accessibleComposites/advertisedTools re-runs workflow
filtering and conversion, so the previous code did that work three times
per call and allocated the concatenated slice twice.

Add the leg that drives RouteTool's dot-alias fallback: rejecting the
alias for direct calls otherwise left it with no coverage, since
TestCallTool_CompositeWorkflow registers the aliased name as an exact
routing-table key and never reaches the fallback.

Assert the Legacy refusal on the decoded error.code rather than a
substring of the response body, matching the Modern leg.

Correct three comments: the guard checks the aggregation view ListTools
filters (admission narrows further, separately); Legacy's -32602 comes
from mcpcompat's translateUnknownToolError, not go-sdk directly; and
omitting the tool name from the error is conservatism, not a mitigation,
since a denial is already distinguishable by status and code.

Exclude ErrAuthorizationFailed from the not-found branch so the
classification cannot depend on branch order.
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 5, 2026
@jerm-dro
jerm-dro requested a review from jhrozek August 5, 2026 22:40

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-checked all of it against 5beec43. Every inline point is addressed, and the dedupe went further than what I suggested — pulling out advertisedToolsWith and changing authorizeToolCall to take a resolved *vmcp.Tool means the admission lookup and the not-found guard now share one result and can't drift apart, which is better than just hoisting the call. The third leg on TestCallTool_ResolvesRenamedTool is exactly the case I was worried about losing: it's now the only test in the suite that drives RouteTool's dot-convention fallback rather than its exact-key fast path. legacyCallError also brings the Legacy assertion up to the Modern leg's strength.

Approving. Four leftovers, all one-line comment/doc edits on files outside the diff, none blocking:

  • pkg/vmcp/core/admission.go:322findAdvertisedTool's doc still ends "...and routing remains the authority on whether the call resolves." This is the one I'd actually still like fixed: it's now false, and after this refactor it's the helper three consumers route through. Easy to have missed since it couldn't be an inline comment.
  • docs/arch/10-virtual-mcp-architecture.md:222### Tool Filtering is still one sentence; the advertised-vs-routing invariant is only recorded in test comments.
  • pkg/vmcp/aggregator/default_aggregator.go:635shouldAdvertiseTool's "controls advertising, not routing" now understates, since that gate decides callability.
  • The srv.Stop gap in buildTestServerWithOptions — pre-existing and your call, as I said before.

Happy either way on whether those ride along here or go in a follow-up. Note I didn't run tests or lint on this revision.

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

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Modern path: filtered-out vMCP tools are still callable

4 participants