Skip to content

fix(mcp): refuse MCP tools that shadow reserved framework names - #1515

Open
sushant-me wants to merge 3 commits into
google:mainfrom
sushant-me:fix/mcp-reserved-tool-names
Open

sushant-me wants to merge 3 commits into
google:mainfrom
sushant-me:fix/mcp-reserved-tool-names

Conversation

@sushant-me

@sushant-me sushant-me commented Sep 16, 2026

Copy link
Copy Markdown

Fixes #1513

Problem

McpToolset accepted any tool name an MCP server advertised. Names that the
ADK framework reserves for its own built-ins were therefore installable by a
remote server, and the duplicate-name guard in
LlmRequest.Builder.appendTools never noticed:

  • In-model built-ins (google_search, google_maps, url_context,
    vertex_ai_search, code_execution) are appended only to the request's
    config.tools and are never placed in the tool map, so they never collide
    with the MCP entry.
  • set_model_response is consumed internally by
    SingleTurnAgentExecutor/LlmFlow; a server tool of that name makes the
    flow abort the whole run where it expects its own response model.

The result is that an MCP server silently wins dispatch for a framework tool
name — a tool-boundary bug, not a naming nit.

Fix

Reserve the names in McpToolset.getTools() and fail loading with
McpToolLoadingException when a server advertises one:

private static final Set<String> RESERVED_TOOL_NAMES = Set.of(
    "set_model_response", "transfer_to_agent", "google_search", "google_maps",
    "url_context", "vertex_ai_search", "code_execution", "load_artifacts",
    "loadMemory", "exit_loop", "list_skills", "load_skill",
    "load_skill_resource");

Correction (later push). An earlier revision of this PR listed the Go set
verbatim, which was wrong in both directions.

  • finish_task and task_completed do not exist anywhere in this port. They
    were refused as collisions against tools this framework does not have.
  • The memory tool is named loadMemory here, not load_memory. This port takes
    a tool's name from the method name when the method carries no
    @Annotations.SchemaLoadMemoryTool#loadMemory annotates only its
    parameter — so the other ports' load_memory spelling did not match, and the
    guard was not watching for the name it actually uses.

Two tests now pin this: getTools_refusesDerivedLoadMemoryName, and
getTools_acceptsNamesOtherPortsDefineButThisOneDoesNot, which asserts that the
three names from the other ports are accepted rather than refused.

McpToolLoadingException is used deliberately: retryWhen treats
IllegalArgumentException as fatal, and a config error of this kind should
surface once rather than be retried as a transient server fault.

Tests

getTools_refusesReservedToolName mocks an McpSyncClient returning
McpSchema.Tool.builder().name("google_search") and asserts the load fails
with McpToolLoadingException. It also verifies the client is called exactly
once, pinning the no-retry behaviour.

Verified in both directions:

  • without the change to McpToolset.java: Tests run: 1, Failures: 1
  • with the change: green

This mirrors the equivalent google/adk-go fix in #1606.

Why a list at all, and why I would rather not keep one

This is the second correction to RESERVED_TOOL_NAMES, and the reason is the
approach rather than either edit. The set was assembled from names reported one
at a time, so each fix addressed the name in front of it and left the rest. Only
after enumerating the tools this framework actually declares — rather than
transcribing a list — did the remaining four appear.

The structural alternative is better and I would rather implement it: give the
in-model tools their name in the request's tool map, so the existing duplicate-name
check in LlmRequest.appendTools refuses a server that takes one. That guard
already exists and already has the right semantics; the in-model tools currently
bypass it by appending only to the config tools. A list needs maintaining on every
tool the framework adds, and its completeness is not checkable by the same
mechanism that uses it.

If you would rather have the list, it is here and tested. If you would rather have
the guard apply at the point where names are already being checked, I am happy to
rework it that way instead.

@hemasekhar-p hemasekhar-p self-assigned this Sep 16, 2026
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 in
LlmRequest.Builder.appendTools 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 McpToolset loads server tools.

Fixes google#1513
@hemasekhar-p
hemasekhar-p force-pushed the fix/mcp-reserved-tool-names branch from ef4d0b0 to 6d3088b Compare September 16, 2026 15:21
@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @sushant-me , thank you for your contribution. We appreciate you taking the time to submit this pull request. Currently this PR is under review by our team, we will keep you posted if any additional information is required. thank you.

@sushant-me

Copy link
Copy Markdown
Author

One scoping note on the residual, so the trade-off is on the record — the same class of note as the one on the Go port, but the reasoning differs here and I did not want to copy it across.

This guard closes the path where a remote MCP server advertises a reserved name. It does not change the underlying property that an in-model tool never occupies its name in the tool map:

  • GoogleSearchTool.processLlmRequest (and the other in-model tools) only append to configBuilder.tools(...); they never put the name into llmRequestBuilder.tools().
  • Nothing else does either — I could not find a duplicate-name check at the tools() map level in the flows (the only duplicate check is for sub-agent names in BaseAgent).

So an in-process tool registered under google_search — a FunctionTool or an AgentTool — is accepted alongside the in-model tool. In this repository that requires the application author to pick the name, so I treated it as a footgun rather than the adversarial case and kept this PR to the server-controlled path.

Where Java differs from Go: in adk-go the equivalent map is map[string]any and consumers type-assert to tool.Tool, so occupying a name with a sentinel breaks them — that is why I left the general fix alone there. Here LlmRequest.tools() is already Map<String, BaseTool> (LlmRequest.java:89), so registering a sentinel or a real holder under the in-model name is type-safe. The open question is only whether a sentinel value is acceptable to advertise, which is a design decision rather than a typing constraint.

Happy to prepare that broader change if you would prefer the invariant enforced in one place instead of at each boundary — it touches high-fan-in classes, so I did not want to fold it in unasked.

Two names in the set were not ADK Java tool names:

  finish_task       0 string literals in any non-test Java source
  task_completed    0 string literals in any non-test Java source

Neither tool exists in this port; both came from the Go list, which was carried
over wholesale. A server advertising either name was refused as a collision
against a tool this framework does not have.

`load_memory` was also wrong, in the other direction. This port names the tool
from the method name: LoadMemoryTool#loadMemory carries no @Annotations.Schema,
only its parameter does, and FunctionTool falls back to func.getName(). So the
wire name here is `loadMemory`, not the `load_memory` the other ports use, and
the guard was not watching for it.

The set is now the nine names verified in this codebase, and the javadoc
records both corrections and the derivation for `loadMemory` so the next person
does not have to re-derive it.

Tests: `getTools_refusesDerivedLoadMemoryName` pins the corrected spelling, and
`getTools_acceptsNamesOtherPortsDefineButThisOneDoesNot` pins the inverse — the
three names from the other ports must be accepted, because refusing them reports
a collision against a tool this framework does not have. Both guards reverted
and re-run:

  - adding "finish_task" back fails the accepted-names test
  - reverting McpToolset.java to origin/main fails both refusal tests with
    `No errors (latch = 0, values = 1, errors = 0, completions = 1)`

25 tests in the class pass, google-java-format 1.27.0 reports 0 non-complying.
Four framework-owned names were missing:

  exit_loop, list_skills, load_skill, load_skill_resource

Each is a tool this framework ships and a caller can add, with the same standing
as `load_artifacts`, which was already listed. All four are present in the Java
sources: `ExitLoopTool` declares `exit_loop`, `ListSkillsTool` and
`LoadSkillTool` pass theirs to `super(...)`, and `LoadSkillResourceTool` is
registered alongside them.

They were missed because the set had been assembled from names reported one at a
time rather than from what the framework declares. The new test iterates all
eight reserved names instead of pinning one, which is the shape that would have
caught this. Reverted and re-run: deleting "exit_loop" fails it with
`No errors (latch = 0, values = 1, errors = 0, completions = 1)`.

Test count 25 -> 26; google-java-format 1.27.0 reports 0 non-complying.

This is the second correction to this list. The reason is the approach rather
than either edit: a hand-maintained enumeration ships a snapshot that the next
tool addition invalidates. Making the in-model tools occupy their name would let
the existing duplicate-name guard apply and would not need maintaining.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP toolset: in-model built-ins (e.g. google_search) can be shadowed by a server tool; a server tool named set_model_response aborts the run

2 participants