Repository navigation
fix(mcptoolset): refuse MCP server tools that use reserved framework names - #1606
sushant-me wants to merge 6 commits into
Conversation
|
One scoping note on the residual, so the trade-off is on the record. This guard closes the path where a remote MCP server advertises a reserved
So a locally-registered tool (e.g. via I did look at the general fix — registering the in-model name in |
|
Hi, @sushant-me, thank you for bringing it up! I did some tests, and it seems to me that the actual situation is a bit different. "toolConfig": {
"includeServerSideToolInvocations": true
},
"tools": [{
"googleSearch": {}
}, {
"functionDeclarations": [{
"description": "MCP-provided web search.",
"name": "googleSearch",
"parametersJsonSchema": {
"additionalProperties": false,
"properties": {
"query": {
"description": "the web search query",
"type": "string"
}
},
"required": ["query"],
"type": "object"
},
"responseJsonSchema": {
"additionalProperties": false,
"properties": {
"summary": {
"description": "a short summary of the results",
"type": "string"
}
},
"required": ["summary"],
"type": "object"
}
}so, it is a different naming space, there is no collision on this level. What's interesting, gemini is not deterministic in that case - sometimes calls the internal search only, sometimes the functionDeclaration, sometimes even both. So it's not the case that the custom MCP tool "covers" the built-in tool, they co-exists and LLM makes some choices. I have not tested the remaining ones. What do you think? |
|
Thanks for testing this — your payload is decisive, and I think you're right. I'd separated two cases badly, and checking the second one makes your point stronger than you put it. 1. In-model built-ins live in a different namespace (your finding).
2. The framework's function tools are also already safe, which I had missed. I assumed if _, ok := req.Tools[name]; ok {
return fmt.Errorf("duplicate tool: %q", name)
}
Why the distinction is worth keeping in mind when porting: the Python implementation does lose the name. One residual, and I am not asking for a change here: Closing this. Thanks for taking the time to test it and correct me — it's a better outcome than the patch. |
|
Withdrawing: as established above, |
|
One correction to my own comment above, because I over-generalised while conceding. I wrote that the So the accurate description of this PR is: a mitigation for ambiguous routing, not a fix for a shadow — and it is the wrong shape, because refusing a server's tool is a policy/availability trade-off while the underlying question is why a built-in never occupies its own name. That is a maintainer call, not something to force with a deny-list, which is the real reason I'm withdrawing it. The three ports, since I had them tangled:
Your test is what sent me to |
…e of a check adk-go and adk-java were reported as guarding 0 names, on the strength of there being no reserved-name list to cite. That was wrong. A server cannot take a name the framework packs, because the duplicate is refused when the request is built: toolutils.PackTool returns 'duplicate tool: %q' in Go, and LlmRequest.Builder.appendTools throws 'Duplicate tool name' in Java. Both fail closed. adk-go is 10/14 guarded and adk-java 8/13; the names that stay open are the in-model built-ins, which append straight to config.Tools and never pass through either check. The definition of 'guarded' now covers refusal at request-build time as well as at registration, since the question the column answers is whether a server can take the name. This came out of google/adk-go#1606, where a maintainer showed that an in-model built-in and a same-named server tool are advertised in different namespaces and the model was non-deterministic about which it called. The Go PR that would have refused the name is withdrawn - it treated a policy trade-off as a fix - but the measurement contradicted this file and is what corrected it. 63 tests pass (was 62).
|
Reopening, with the claim narrowed to what your test actually supports. You are right that my original wording was wrong. "Dispatched in place of the framework's own" describes displacement, and your payload shows no displacement: the built-in stays in The narrower claim is the one your own measurement establishes, and I do not think it is a different-namespace question at the payload level:
Two tools now answer to one name in the model's view, and one of them is supplied by the server. Which one gets called is not decided by the framework, it is decided by the model, per call. That is a name the framework puts on the wire and does not control — which is a different property from collision-in-an-array, and it is the property this guard addresses. It is not "the server replaced your tool"; it is "a call the developer believes is bound to Google's search may be routed to server-supplied code, and no one chose that." The parity point is the one I would most like your view on. The Java port carries the same fix in google/adk-java#1515 ( What I got wrong was the severity and the word "shadow", not the remedy. If you read the non-determinism as acceptable and out of scope for the Go port, that is a reasonable call and I will close this again and let the tooling record Go as unguarded — I would rather the count be right than the PR be merged. But I did not want to leave it withdrawn on the strength of an argument you had already contradicted. |
|
Noting for reviewers, so the red on the earlier push is not mistaken for this change. The failed runs on 2026-09-16 (Go, API diff, Actions Workflow Security Scan) report "This run likely failed because of a workflow file issue" and create no jobs at all — they are the fork-workflow gate, not a test result. The current push is sitting at Verified locally at
|
|
I have run some additional checks. It seems that if the provided custom "google_search" tool has description "MCP-provided web search." then it is used by gemini often. After changing the description to something else like "The tools checks if the query is searchable" gemini doesn't call this tool. It means that the name is no the only factor here. At this moment I don't see a point in not allowing the google_search as MCP tool name. It gives an option to provide custom search (i.e. filtered by some additional conditions). Tell me your thoughts |
|
Thanks — this is a genuinely useful measurement, and it changes what I'd propose. Let me separate what your test settles from what it leaves open. What it settles. You're right that the name alone isn't decisive, and my original "dispatched in place of the framework's own" wording was wrong. The built-in stays in What it leaves open. The part that still concerns me is the direction your result moves in. You found that changing the description changes whether the tool is called, and that with So I'd frame the question differently now. It isn't "does the name alone decide it" — your test shows it doesn't. It's "should two tools be allowed to answer to one name silently". Your measurement shows the effect isn't uniform, which is why I'd argue for a graduated rule rather than the single hard refusal I opened with. Proposal.
If you'd rather not add a knob, accepting the name but emitting a warning would also address the concern — I'd just prefer the default to be visible rather than silent. I'm happy to rework this PR into that shape: narrow One small correction while I'm here, since it's in the diff: |
|
k |
f381222 to
ba76c09
Compare
|
Rebased onto |
ba76c09 to
622382b
Compare
622382b to
02e637e
Compare
|
A correction I owe you, because I stated something as done that was not. In my earlier reply I wrote that It is done now, in Removed — ADK Go defines no such tool:
Added — this port does define them, and they were missing:
So the set was wrong in five places, not two. Listing names this port never had refuses a server tool for no reason; omitting the two it does define leaves them unguarded.
The open question is unchanged and still yours. Your measurement — that selection follows the description, and that a differently-named tool is not called at all — is the part I cannot argue away, and it cuts both ways: it is the capability you want for a deliberately added custom search, and it is also a string a remote server supplies. My proposal stands: keep the hard refusal for the framework-internal and dispatch names, and put the in-model built-ins ( |
…names In-model built-ins (google_search, google_maps, url_context, vertex_ai_search, code_execution) append only to the request's config tools and never occupy their name in the tool map, so the duplicate-name guard never sees them. A server advertising one of those names was therefore accepted and won dispatch in place of the framework's own tool. A server tool named set_model_response also aborted the whole run. Refuse reserved names when the MCP toolset registers server tools. Fixes google#1605
…ines The set advertised in the PR description as complete was not, and the claim that these two names had already been removed was wrong. Correcting both. Removed, because ADK Go defines no such tool: google_maps - the Java spelling; the Go tool is google_maps_grounding vertex_ai_search - appears nowhere in this repository code_execution - a Python/wire name, not a Go tool Added, because this port does define them: exit_loop google_maps_grounding Listing names this port never had would refuse a server tool for no reason; omitting the two it does define would leave them unguarded. TestReservedToolNames pins both directions: the ten names above are refused, and google_maps, vertex_ai_search, code_execution and web_search are accepted, so a future port cannot quietly re-add a name from another language.
926b5bd to
7be3100
Compare
karolpiotrowicz
left a comment
There was a problem hiding this comment.
One change is needed before merge: a server that lists a reserved name now takes down the whole agent run, not just the offending tool.
Must change before merge:
- A reserved name should cost the server that one tool, not the listing and the run. At set.go L203-L205
Tools()returns an error, and tools_processor.go L40-L43 turns that into a failed step, so every invocation of the agent fails at its first step. This happens even when the agent configures nothing that collides. With a server listingget_weatherandload_memory, and no memory tool on the agent, the run completes on main and fails on this branch withfailed to extract tools from the tool set "mcp_tool_set": mcp toolset: refusing reserved tool name "load_memory" advertised by the server. The same happens forgoogle_searchwhen no built-in search is configured, and forset_model_responseon an agent without an output schema. - A filter cannot work around it. The check runs before
Config.ToolFilter(set.go L212), andtool.FilterToolsetcalls the innerTools()before applying its predicate (tool.go L106-L110). An allowlist ofget_weatherover the same server, which hidesload_memoryfrom the model on main, still fails here. The YAMLMcpToolsetfactory always sets a filter (configurable_utils.go L217-L243), so config-driven agents hit this too.
adk-python skips a reserved tool and logs a warning, with a comment saying one reserved name would otherwise take the server's honest tools down with it (mcp_toolset.py L528-L536). I ran that variant against the same scenarios: every run completes, and a server's set_model_response result no longer becomes the agent's final answer, which is the hijack this PR is right to close. Whatever shape the fix takes, the property to keep is that a listing containing a reserved name yields exactly its other tools, and an agent whose filter already excludes the name behaves as it does on main.
The refusal tests will need to change with it. Each one uses a single-tool server and only checks that Tools() errors, so they currently require the whole listing to fail. A case with a reserved name next to an honest tool, and one with a filter, would pin the behavior that matters.
Also worth fixing, none of it blocking:
- The PR description lists
code_execution,list_skills,load_skillandload_skill_resourceas refused, and none of them are. It says adk-python's set omitsset_model_response, but mcp_tool.py L80-L86 includes it. The issue's second scenario (an output-schema agent whose server listsset_model_response) still aborts, now with a different message, so "Fixes #1605" claims more than the change does. - adk-python also reserves
adk_request_credential,adk_request_confirmationandadk_request_input, and all three are framework names here as well (tool_confirmation.go L46, contents_processor.go L840, request_input.go L36). A server tool namedadk_request_confirmationis accepted on main and on this branch, and its call and response drop out of the model's history on the next turn. This predates the PR, so it is fine to leave for a follow-up.
| // reservedToolNames are names this framework itself puts on the wire, in two classes. | ||
| // | ||
| // Dispatch names are resolved back out of the request by the framework rather than by | ||
| // the model, so a server tool holding one of them changes which implementation runs. |
There was a problem hiding this comment.
This holds for names the framework reads back by name without packing a tool, such as set_model_response on an agent with no output schema. For names it packs first (exit_loop, load_memory, load_artifacts, transfer_to_agent, and set_model_response when an output schema is set), a real collision already fails with duplicate tool from toolutils.PackTool, so nothing is displaced. Narrowing the sentence would stop a later reader from taking it as the reason behind every entry.
| // In-model built-ins (google_search and the rest) are registered through | ||
| // RegisterToolFactory in internal/configurable and append to the request's config | ||
| // tools; they are listed here because a second tool answering to the same name leaves | ||
| // the model, not the framework, choosing between them. |
There was a problem hiding this comment.
Built-ins go on the wire as genai.Tool{GoogleSearch: ...} with no function name, and the framework never dispatches them, so the only case this list covers is a second tool answering to the exact same name. A lookalike such as web_search, which the test below accepts, draws the same calls. Non-blocking, but the comment promises more than a name list can do.
| var adkTools []tool.Tool | ||
| for _, mcpTool := range mcpTools { | ||
| if _, reserved := reservedToolNames[mcpTool.Name]; reserved { | ||
| return nil, fmt.Errorf("mcp toolset: refusing reserved tool name %q advertised by the server", mcpTool.Name) |
There was a problem hiding this comment.
Nit: the other errors in this file use the mcptoolset: prefix (lines 75 and 82).
| } | ||
| } | ||
|
|
||
| func TestReservedToolNameRefused(t *testing.T) { |
There was a problem hiding this comment.
This repeats the refuses/google_search subtest in TestReservedToolNames and could go.
…the run Reviewer feedback on google#1606 (karolpiotrowicz): a server advertising a framework-owned name previously made Tools() return an error, which tools_processor turned into a failed agent run. Drop the offending tool and keep the rest of the server's listing instead. - set.go: skip the reserved tool (continue) rather than failing the whole call - set_test.go: drop TestReservedToolNameRefused, which duplicated the refuses/google_search subtest, and assert the new contract - no error, and the reserved name is not handed to the model
…ol-names # Conflicts: # tool/mcptoolset/set_test.go
The existing table asserts the reserved name is absent from the result, but the server in that case advertises only the reserved name -- so the result is empty and the assertion holds vacuously. It never exercises the case the reviewer described: a server advertising a reserved name alongside ordinary ones. That is the case that regressed. Before the fix, one reserved name failed the whole listing, which failed the step and therefore failed every invocation of the agent, including agents that configure nothing that collides. This subtest advertises `load_memory` and `get_weather` together and asserts that `get_weather` still reaches the agent. Seen to fail with the fix reverted: `continue` replaced by the old error return turns this subtest red, and the package green again when it is restored.
|
@karolpiotrowicz — done, in One thing your scenario caught that the existing table did not. The table asserted the reserved name is absent from the result — but its server advertises only the reserved name, so the result is empty and that assertion holds vacuously. Nothing tested that the ordinary tools survive, which is the half of the bug that took down the run. So I added a subtest ( Also applied your point about diagnostics: the repo's |
…-names # Conflicts: # tool/mcptoolset/set.go # tool/mcptoolset/set_test.go
|
The blocking item is in, and it landed after your review — hence the stale state on this page.
if _, reserved := reservedToolNames[mcpTool.Name]; reserved {
continue // skips one tool; the listing survives
}
...
if s.toolFilter != nil && !s.toolFilter(ctx, t) { continue }
On your filter point: the reserved-name check now runs before the filter, so an allowlist that Description corrections, as you flagged them. You were right on all three, and the first one is
One parity note, stated rather than left implicit. adk-python skips the reserved tool and logs
|
kvmilos (google/adk-java#1515) and karolpiotrowicz (google/adk-go#1606) each state the missing-coverage case in their own words. Their sentences are now in the page, and the adk-go guard item is corrected to reflect that it was fixed.
Fixes #1605.
What changed
mcptoolset.set.Tools()no longer hands out MCP server tools whose name the framework owns.Two classes are affected. In-model built-ins (
google_search,google_maps_grounding,url_context) only append to the request's config tools and never occupy their name in thetool map, so the duplicate-name guard in
toolutils.PackToolnever sees them — a serveradvertising one of those names was accepted and won dispatch in place of the framework's own
tool. Framework dispatch names (
set_model_response,transfer_to_agent,finish_task,task_completed,exit_loop,load_artifacts,load_memory) are resolved back out of therequest by the framework rather than by the model, so a server tool holding one changes which
implementation runs;
set_model_responseadditionally aborted the whole run.Behaviour change for someone on the current release
An MCP server that advertises a reserved name now has that one tool dropped. The rest of its
listing is returned as before, and the run proceeds.
That is the deliberate shape of the change. The first revision of this PR returned an error
instead, which made a reserved name cost the agent its entire tool listing and fail every invocation
at its first step — including for agents that configured nothing colliding, and including agents
whose
ToolFilteralready excluded the name, because the check ran before the filter. A reservedname must cost the server that one tool and nothing else, so the tool is skipped and the others are
returned.
Names this framework does not define are accepted.
google_mapsis the Java spelling (the Gogrounded-maps tool is
google_maps_grounding), andvertex_ai_searchandcode_executionappearin no Go tool, so listing them would refuse names this port never had a problem with. A test pins
both directions.
Parity with adk-python
adk-python skips the reserved tool and logs a warning (
mcp_toolset.pyL528-L536). This skipsit silently. That is not an oversight:
AGENTS.mdforbids library code from writing to aprocess-wide sink and ADK Go has no logger for library code to use, and returning the error is the
bug this change fixes. A warning needs a logger seam first, and is worth a follow-up.
_RESERVED_TOOL_NAMESin adk-python covers a subset of these;set_model_responseis included there(
mcp_tool.pyL80-L86), and this port matches that.Testing Plan
TestReservedToolNamescovers both directions:Tools()does not fail the listing, and does not hand the name out.the earlier single-tool subtests could not express — they asserted that
Tools()errors, so theyrequired the whole listing to fail.
Proven to fail without the fix: with the source change reverted the refusal assertions report the
reserved name was accepted, and the listing assertion reports the whole listing failed. With the fix
both pass. Verified locally with
go test ./tool/mcptoolset/ -count=1. No live model or networkcall is involved.
Note on process
AGENTS.mdasks contributors to check first before changing a high-fan-in package. I opened #1605with the evidence and this proposed fix; this PR implements it. Happy to rework or close it if you
would prefer a different approach (e.g. making the in-model tools register their name so the existing
guard applies, instead of a reserved-name list in the toolset).