Skip to content

Authorize and accept call_tool with nested tool_name - #6150

Open
Tanguille wants to merge 3 commits into
stacklok:mainfrom
Tanguille:fix-call-tool-nested-tool-name
Open

Authorize and accept call_tool with nested tool_name#6150
Tanguille wants to merge 3 commits into
stacklok:mainfrom
Tanguille:fix-call-tool-nested-tool-name

Conversation

@Tanguille

@Tanguille Tanguille commented Jul 30, 2026

Copy link
Copy Markdown

Summary

LLMs calling the optimizer's call_tool frequently nest tool_name inside parameters instead of alongside it. Handling that turned out to require fixing authorization first, so this PR is two commits.

1. Authorize call_tool when tool_name is nested

handleToolsCall read the target name from the top level of the request arguments only. A nested tool_name missed that lookup, and the pass-through branch fell through to "allow through" and reached the backend with no policy check performed at all. Cedar policy denying a backend tool was enforced for the flat form and silently skipped for the nested one.

This is a live gap on main today, though its impact is currently limited: authorization is skipped, then the optimizer rejects the call because it cannot find tool_name either. Making the optimizer accept the nested form (commit 2) would have removed that backstop, so this change was required.

The resolution now goes through optimizer.ResolveCallToolTarget, shared by both readers, so authorization and dispatch cannot disagree about which tool a request names.

2. Accept nested tool_name in call_tool input

CallToolInput.UnmarshalJSON resolves through the same helper, so the nested form dispatches instead of erroring. A top-level tool_name always wins and parameters is left untouched in that case, so a backend tool with its own tool_name argument is unaffected.

The tool_name is required error now states the expected shape and echoes the received parameter keys. Previously a nesting mistake and a genuinely empty call produced identical text, which is why this went undiagnosed on my side for a while.

The advertised tool schema is unchanged. GenerateSchema reflects over struct fields, so clients are still told the strict contract and the leniency is invisible repair.

Fixes #5891

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)

Authorization bypass, via the existing TestMiddlewareOptimizerMetaTools harness and its Cedar policy permitting only allowed_backend:

call_tool arguments Expected Before After
{"tool_name": "forbidden_backend", "parameters": {}} 403 403 403
{"parameters": {"tool_name": "forbidden_backend", ...}} 403 200, backend reached 403
{"parameters": {"tool_name": "allowed_backend", ...}} 200 200 200

The new cases fail against main and pass with commit 1.

Dispatch, same input decoded through schema.Translate[CallToolInput], which is the path both call_tool handlers use:

Input {"parameters": {"tool_name": "searxng_web_search", "query": "..."}}

Decoded tool_name Decoded parameters CallTool result
Before "" {query, tool_name} tool_name is required
After "searxng_web_search" {query} dispatches to the tool

Input {"parameters": {"query": "...", "pageno": 1}}, no name anywhere

CallTool error
Before tool_name is required
After tool_name is required: call_tool expects {"tool_name": "<name from find_tool>", "parameters": {<tool arguments>}}, got parameters keys [pageno query]

End to end. The Tier-1 find→call round-trip in test/e2e/vmcp_optimizer_test.go only exercised the flat payload, so nothing verified the nested form against a real server. It now issues both calls against the same discovered backend tool and asserts each echoes its own input:

$ ginkgo --label-filter="optimizer" ./test/e2e
Ran 3 of 506 Specs in 12.462 seconds
SUCCESS! -- 3 Passed | 0 Failed

That is the optimizer-labelled subset, not the full e2e suite; CI covers the rest.

Both handlers decode via schema.Translate, which round-trips through json.Marshal/json.Unmarshal. There is a regression test asserting the hoist survives that path rather than only a direct json.Unmarshal, since a change to Translate would otherwise silently stop the unmarshaler from running.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Does this introduce a user-facing change?

Yes.

  • call_tool invocations that nest tool_name inside parameters are now authorized against the tool they actually target, instead of skipping the authorization check.
  • Those invocations now succeed instead of failing.
  • When tool_name is genuinely missing, the error describes the expected shape and lists the parameter keys received.

Special notes for reviewers

ResolveCallToolTarget never modifies the caller's map; it copies only when a nested name is hoisted. parsedRequest.Arguments is shared with downstream consumers, so in-place mutation there would not be safe.

The error message lists parameter keys only, never values, so tool arguments do not reach error strings or logs.

pkg/authz already depended on pkg/vmcp/optimizer transitively through optimizerdec, so importing it directly adds no new dependency edge.

Scope is deliberately limited to the nesting direction reported in #5891. The symmetric malformation, arguments flattened as top-level siblings of tool_name rather than under parameters, is not handled. If it appears it belongs in ResolveCallToolTarget at the same depth. The new error message echoes the received keys, which is the signal needed to decide whether it is worth handling.

Happy to split the authorization fix into its own PR, or to route it through a security process instead, if you would prefer that.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.63%. Comparing base (33e06f4) to head (9bde613).

Files with missing lines Patch % Lines
pkg/vmcp/optimizer/optimizer.go 90.47% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6150      +/-   ##
==========================================
+ Coverage   72.58%   72.63%   +0.04%     
==========================================
  Files         736      736              
  Lines       76331    76353      +22     
==========================================
+ Hits        55408    55457      +49     
+ Misses      17001    16961      -40     
- Partials     3922     3935      +13     

☔ 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.

@aponcedeleonch

aponcedeleonch commented Jul 31, 2026

Copy link
Copy Markdown
Member

Nothing red here is from your diff.

The errors you're seeing come from three unrelated things: six jobs never ran any code, they died in checkout with upload-pack: not our ref d40ce833, which happens when the merge ref gets recomputed while jobs are queued. E2E Test Lifecycle on 1.35.1 fails on backend-optmulti-ida-pro-mcp failed: ContainerTerminated, which is #6159. And E2E Tests Core (proxy) fails because mcp-server-time:2026.7.10 was rebuilt this morning and picked up mcp 2.0.0, which renamed McpError to MCPError, so the container exits on import and the proxy never completes initialize. That last may need its own fix, we'll handle it separately.

Rebase when you get a chance to clear the checkout failures. Thanks for the PR. I will approve when you have rebased.

Authorization read the call_tool target from the top level of the request
arguments only. A caller that nested tool_name inside parameters missed that
lookup and fell through the pass-through branch, reaching the backend with no
policy check performed.

Resolve the target through a shared helper so authorization and dispatch agree
on which tool a request names.

Signed-off-by: Tanguille <91473554+Tanguille@users.noreply.github.com>
LLMs frequently nest tool_name inside parameters rather than alongside it.
These calls were rejected with a bare "tool_name is required", which gave the
model no indication of the expected shape, so retries reproduced the same
payload and the turn was lost.

Decode such calls by resolving the target the same way authorization does, and
report the expected shape and the parameter keys received when the name really
is absent. Keys are listed without values so tool arguments stay out of logs.

Fixes stacklok#5891

Signed-off-by: Tanguille <91473554+Tanguille@users.noreply.github.com>
The find->call round-trip only exercised the flat payload, so nothing verified
that a nested tool_name dispatches through a real vMCP server rather than only
through the decode unit tests.

Signed-off-by: Tanguille <91473554+Tanguille@users.noreply.github.com>
@Tanguille
Tanguille force-pushed the fix-call-tool-nested-tool-name branch from d23c6cd to 572a960 Compare August 1, 2026 10:47
@Tanguille

Copy link
Copy Markdown
Author

Thanks for letting me know @aponcedeleonch.

I have now rebased my changes.

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

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

call_tool is easy for models to malform (nested tool_name / wrong-slot args) and fails with no correction hint, causing retry loops

2 participants