Skip to content

fix(flows): reject a function tool that shares a name with a built-in tool - #1515

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

sushant-me wants to merge 1 commit 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

What this changes

A function tool whose name collides with a built-in (in-model) tool was accepted, so two
tools answered to one name. Only the function tool is in the dispatch map, so the model picks
which to call — the collision is silent rather than an error.
The collision is now rejected
where the tools are assembled, rather than inside McpToolset.

Related: #1513.

BaseLlmFlow.java carries the check; BaseLlmFlowTest.java covers it.

What changes for an agent author

An agent that configures a built-in tool alongside a function tool of the same name now
fails when the request is assembled, with:

Duplicate tool name: google_search

Until now it ran, and the function tool took precedence in the dispatch map without saying so.

To fix it, rename the function tool so the two names differ. If the intent really is to
override the built-in, do that explicitly rather than by name collision — the built-in is
reachable through the framework's own configuration, and shadowing it silently is the failure
this change prevents.

Scope, stated because it differs by framework

This rejects a function tool that shares a name with a built-in. load_artifacts and
list_skills declare functions, so they are not in-model tools and are not affected.

ADK Python permits an in-model tool and a same-named function tool to coexist. This change
does not, and that is deliberate for this repository
— noting it here so the difference is on
the record rather than discovered later.

How the collision is detected

A tool with no function declaration that adds an entry to the request's config tools counts
as a built-in.

The check runs on the tools that are actually assembled for the request, not on the toolset's
raw configuration. The tests assert on the processed request and use the real search tool.

Tests

Both directions are covered, deliberately:

  • Rejection where a built-in and a same-named function tool meet, in either order.
  • A positive control: a built-in beside a differently named function tool, where the
    request must go through. Without this test a guard that rejected every built-in would pass
    the whole suite.
  • A toolset-serving case, which covers the applyTool branch: a toolset that serves a
    built-in collides with an agent-level function tool of the same name.
  • A declaration-less tool that adds no config entry does not collide — the ExampleTool case.
  • Two declaration-less tools sharing a name keep working.

@hemasekhar-p hemasekhar-p self-assigned this Sep 16, 2026
@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.

@sushant-me

Copy link
Copy Markdown
Author

Thanks, @hemasekhar-p — appreciated.

One thing that may help while it is in review: RESERVED_TOOL_NAMES currently carries all 13 names Java puts on the wire, and I checked it is neither short nor over-broad — every entry is a super("...") literal in a non-test source, plus set_model_response and transfer_to_agent which the framework contributes outside a tool class. Two names an earlier revision of my notes carried over from the Go port (finish_task, task_completed) are not Java names and are deliberately absent.

The two halves of #1513 fail differently in this code, which is why the guard covers both:

  • In-model built-ins (google_search, google_maps, url_context, vertex_ai_search, code_execution) append only to config.Tools and never reach appendTools, so a callable tool takes the name and Functions.handleFunctionCalls resolves it to the server tool.
  • Callables (set_model_response and friends) are already fail-closed — appendTools throws Duplicate tool name — so there the guard converts a run-aborting exception into a clean registration error.

Happy to adjust the list, the error type, or the placement if your team prefers a different shape.

@MiloszSobczyk
MiloszSobczyk self-requested a review September 28, 2026 11:05

@MiloszSobczyk MiloszSobczyk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for the detailed report, and for tracking down the loadMemory naming. That was useful.

I'd like to go with the alternative you describe at the end of the PR description, rather than a reserved list in McpToolset. The main reason is that McpToolset can't see the agent's other tools, so it can't tell a real collision from a harmless name. As written, the check:

  • rejects servers even when nothing collides. An agent with no GoogleSearchTool can no longer load a server that exposes a google_search tool. The same goes for code_execution, load_skill, list_skills, exit_loop and the rest.
  • does not cover McpAsyncToolset. initTools() still wraps every server tool without a check, and #1513 lists it as a place to fix.

For the function tools in the list, including set_model_response, LlmRequest.Builder.appendTools already throws Duplicate tool name on a real collision. So those cases already fail with a clear error, and the list only adds errors where nothing collides.

For the 5 in-model tools, ADK never dispatches them through the tool map, because the model runs them itself. So a server tool with the same name does not take over their dispatch. It adds a second tool with the same name. That is confusing, and a clear error for it is a good idea.

I think the simplest place for that error is BaseLlmFlow.getRequestProcessorFromTools, since every tool passes through it, including each tool from a toolset. It can fail with Duplicate tool name when a tool with no function declaration (like the in-model tools) has the same name as a function tool in the request. That covers MCP sync and async, FunctionTool and AgentTool, and it doesn't change the tool map used for dispatch. Checking only that case keeps setups like two ExampleTools with the default name working. Since an agent with, for example, GoogleSearchTool plus its own google_search function would start failing, please mention this in the PR description.

Could you please rework the PR in that direction? A test with the in-model tool both before and after the clashing tool would be great, since the order shouldn't matter. Please also keep code comments short and leave the revision history in the PR description.

Comment thread core/src/main/java/com/google/adk/tools/mcp/McpToolset.java Outdated
sushant-me added a commit to sushant-me/adk-java that referenced this pull request Oct 1, 2026
Review feedback on google#1515 from @MiloszSobczyk: the reserved-name check ran
inside the mapping step, before isToolSelected, so a single reserved name
anywhere in the server's advertised list failed the whole toolset even when
the caller's toolFilter had excluded that tool and selected only others.

Move the check into the flow, after selection.

The McpTool has to be constructed before the filter rather than after it,
because isToolSelected takes a BaseTool and dispatches on ToolPredicate or a
name list, so wrap -> filter -> check is the only order that preserves the
existing filter semantics.

Adds getTools_toolFilterExcludesReservedName_loadsSelectedToolsInsteadOfFailing,
which fails against the old ordering (McpToolLoadingException, "Invalid
argument encountered during tool loading") and passes against this one.

getTools_refusesReservedToolName still passes: a reserved name that IS
selected remains a fatal, non-retried error. The guard still guards; it just
no longer over-fires.
@sushant-me

Copy link
Copy Markdown
Author

Done — thank you for reading the control flow closely enough to find this. The check now runs after selection, and there is a test for the case you described.

What changed

The reserved-name check sat inside the mapping step, so it ran before isToolSelected:

.map(tool -> {
  if (RESERVED_TOOL_NAMES.contains(tool.name())) throw new IllegalArgumentException(...);
  return new McpTool(tool, ...);
})
.filter(tool -> isToolSelected(tool, toolFilter, readonlyContext)));

It now runs in the flow, after selection:

.map(tool -> new McpTool(tool, this.mcpSession, this.mcpSessionManager, this.objectMapper))
.filter(tool -> isToolSelected(tool, toolFilter, readonlyContext))
.map(tool -> {
  if (RESERVED_TOOL_NAMES.contains(tool.name())) throw new IllegalArgumentException(...);
  return tool;
});

One note on the ordering, in case it looks arbitrary: the McpTool has to be constructed before the filter rather than after it, because isToolSelected takes a BaseTool and dispatches on either a ToolPredicate or a list of names. So "wrap → filter → check" is the only order that keeps the existing filter semantics.

The test

getTools_toolFilterExcludesReservedName_loadsSelectedToolsInsteadOfFailing — the server advertises a reserved name plus tool1, and the caller's toolFilter is ["tool1"]. It asserts the toolset yields exactly ["tool1"].

I ran it against the old ordering, because an assertion that has never failed is not evidence:

OLD ordering:
  McpToolsetTest…:399 » McpToolLoading
  Invalid argument encountered during tool loading.        <- your bug, reproduced
NEW ordering:
  Tests run: 27, Failures: 0, Errors: 0

getTools_refusesReservedToolName still passes unchanged, so a reserved name that is selected is still a fatal, non-retried error. The guard still guards — it just no longer over-fires.

Checks: ./mvnw -pl core test -Dtest=McpToolsetTest → 27 tests, 0 failures. google-java-format clean.

One question, since you know the code better than I do: is "after selection" the placement you had in mind, or would you rather the check live inside isToolSelected itself so every toolset subclass inherits it? I put it in getTools because the error is meant to be fatal and non-retried, and that is expressed by where it sits relative to retryWhen — but if you would prefer it owned by the base class, that is a different and probably better factoring, and I would rather do it your way than argue for mine.

@sushant-me

Copy link
Copy Markdown
Author

I need to correct my previous comment. I wrote "Done", but what I pushed is not the rework you asked for, and your two objections are both still true of the current head.

You asked for the error to move to BaseLlmFlow.getRequestProcessorFromTools, so that it fires only on a real name collision and covers McpAsyncToolset, FunctionTool and AgentTool through the one path every tool passes through.

What 5635996e actually did was keep the check inside McpToolset and change when it runs — after the tool filter rather than before. That is a narrower change than it sounds:

So moving the check later within McpToolset did not address the reason you asked for the move. I read my own change as satisfying the review when it only softened the symptom, and "Done" was the wrong word for it.

I have read getRequestProcessorFromTools and LlmRequest.Builder.appendTools (the Duplicate tool name throw at LlmRequest.java:219), and I understand the rule you want: after the processors have run and the request is built, fail when a tool with no function declaration shares a name with a function tool in the request — and only in that direction, so two ExampleTools with the default name keep working.

What I have not yet worked out is how to read "a tool with no function declaration" off the built request reliably, which is the part the check depends on. I would rather say that plainly than push a second attempt that reads as complete. This is not done, and I will rework it in the direction you described, with the test that exercises the in-model tool both before and after the clashing tool, and with the PR description updated to note that an agent carrying both GoogleSearchTool and its own google_search function would begin to fail.

If you would prefer to close this while I do that, that is reasonable — nothing here is worth leaving in a state that looks answered when it is not.

@sushant-me

Copy link
Copy Markdown
Author

I said the open question was how to read "a tool with no function declaration" off the built request. I have worked that out, and the answer changes where the check can live. Recording it so the rework has a concrete route.

A declaration-less tool never enters LlmRequest.tools(). BaseTool.processLlmRequest returns before the only appendTools call:

public Completable processLlmRequest(LlmRequest.Builder b, ToolContext ctx) {
  if (declaration().isEmpty()) {
    return Completable.complete();          // returns here
  }
  b.appendTools(ImmutableList.of(this));    // never reached

And the in-model tools override processLlmRequest to write to the config tools instead. Verified by counting appendTools calls in each:

tool registered name appendTools calls
GoogleSearchTool google_search 0
UrlContextTool url_context 0
LoadArtifactsTool load_artifacts 0
ListSkillsTool list_skills 0

GoogleSearchTool adds Tool.builder().googleSearch(...) to GenerateContentConfig.tools() and returns, which is exactly the "second tool with the same name" you described — it is in the config, not in the map.

So the comparison the check needs cannot be made from the built LlmRequest alone: the declaration-less half is not in it. It has to read the tool list from the agent. In getRequestProcessorFromTools both halves are reachable — agent.toolsUnion() (plus each baseToolset.getTools(readonlyContext)) gives the declaration-less names via declaration().isEmpty(), and builder.build().tools() gives the function-tool names. Fail on the intersection, in that direction only, so two ExampleTools with the default name still work.

One thing worth knowing before it is written: an in-model tool's presence in toolsUnion() does not mean its declaration was added to the request. GoogleSearchTool.processLlmRequest errors for a non-gemini- model, so "the agent declares it" and "the request carries it" can differ. Whether the check should key on the agent's list or on the config tools the request actually ended up with is a judgement call I would rather you make than guess at — the first is simpler, the second is closer to what the model sees.

I am not pushing this yet, and I want to be explicit about why rather than silent: there is no gradlew in my checkout and no Gradle on PATH, so I cannot compile or run a test for core/ here. I have already put one unverified change in front of you on this PR and labelled it done when it was not; I would rather hand you the analysis and a working build than do that twice.

If you can point me at how you run the module's tests, or if it is easier for you to take it from here, either is fine — I will rework it against a tree I can actually build.

@sushant-me

Copy link
Copy Markdown
Author

Reworked in 9c0ae01b, in the direction you described, and I think your reading of the mechanism was the right one — following it is what made the shape obvious.

Where it now lives. The error is in BaseLlmFlow.getRequestProcessorFromTools, so it fires only on a real collision and covers McpAsyncToolset, FunctionTool and AgentTool through the one path. The McpToolset list is gone.

Detecting the collision needed one thing I had to check first. A declaration-less tool never enters LlmRequest.tools() at all — BaseTool.processLlmRequest returns before the only appendTools call:

if (declaration().isEmpty()) {
  return Completable.complete();          // returns here
}
b.appendTools(ImmutableList.of(this));    // never reached

and the in-model tools override it to write into the request config instead (GoogleSearchTool, UrlContextTool, LoadArtifactsTool, ListSkillsTool each call appendTools zero times). So the declaration-less half cannot be read off the built request. The check reads it from agent.toolsUnion() plus each baseToolset.getTools(...), filtered on declaration().isEmpty(), and intersects with the built request's tools() map.

Only that direction is rejected, so two declaration-less tools may still share a name and default-named ExampleTools keep working.

Tests. The collision case runs the in-model tool both before and after the clashing function tool, as you asked, plus a negative case for the both-declaration-less setup. I confirmed each new test fails with the guard reverted:

expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown

Full core suite: 1856 tests, 0 failures. Built and run with ./mvnw on JDK 25; CI pins 17, and I have not run it under 17.

PR description now carries the revision history and the note you asked for: an agent with both GoogleSearchTool and its own google_search function will start failing, and the fix is to rename one.

One correction to my earlier comment on this thread: I said I could not build core because there was no Gradle wrapper. That was wrong — this repo uses Maven, ./mvnw is in the tree, and it works. My apology for the detour; the rework would have been done a round earlier if I had checked which build system the repo actually uses.

sushant-me added a commit to sushant-me/adk-java that referenced this pull request Oct 2, 2026
Review feedback on google#1515 from @MiloszSobczyk: the reserved-name check ran
inside the mapping step, before isToolSelected, so a single reserved name
anywhere in the server's advertised list failed the whole toolset even when
the caller's toolFilter had excluded that tool and selected only others.

Move the check into the flow, after selection.

The McpTool has to be constructed before the filter rather than after it,
because isToolSelected takes a BaseTool and dispatches on ToolPredicate or a
name list, so wrap -> filter -> check is the only order that preserves the
existing filter semantics.

Adds getTools_toolFilterExcludesReservedName_loadsSelectedToolsInsteadOfFailing,
which fails against the old ordering (McpToolLoadingException, "Invalid
argument encountered during tool loading") and passes against this one.

getTools_refusesReservedToolName still passes: a reserved name that IS
selected remains a fatal, non-retried error. The guard still guards; it just
no longer over-fires.
@sushant-me
sushant-me force-pushed the fix/mcp-reserved-tool-names branch from 2ab2a08 to 8aa485d Compare October 2, 2026 08:35
@sushant-me

Copy link
Copy Markdown
Author

Rebased onto current main and re-verified, since the branch had drifted 1286 commits behind and this change sits in a high-fan-in file that upstream has been editing (BaseLlmFlow.java moved 205 lines in the meantime).

The rebase was clean — no conflicts — and my change is unchanged in substance: still BaseLlmFlow.getRequestProcessorFromTools, still 2 files, +107/-0.

Re-ran everything against the new base rather than assuming the old results carried over:

core: 1997 tests, 0 failures, 0 errors, 0 skipped        (./mvnw -pl core test)
BaseLlmFlowTest: 29 tests, 0 failures

And the revert-check again, on the new base, to confirm the test still pins the behaviour rather than passing because the code happens to line up:

[ERROR] ...rejectsDeclarationlessNameCollision:989 expected
        java.lang.IllegalArgumentException to be thrown, but nothing was thrown

One thing I want to be explicit about: the earlier +316/-1 figure included the McpToolset guard. That is gone — McpToolset.java and McpToolsetTest.java are byte-identical to main now. The +107/-0 is the whole change.

@sushant-me

Copy link
Copy Markdown
Author

@MiloszSobczyk — the rework landed in 8aa485df6 and the five commits under it (all pushed 2026-10-02), and I think it takes the direction you asked for rather than the reserved list.

Both points you raised:

1. The check moved out of McpToolset. It now runs in BaseLlmFlow (core/.../flows/llmflows/BaseLlmFlow.java, +46 with +61 of tests), which is the layer that can actually see the agent's other tools — the reason you gave for preferring that placement.

2. McpAsyncToolset is covered by construction, not by a second copy. Sitting in BaseLlmFlow puts it upstream of both toolsets, so initTools() no longer needs its own guard and there is no path that wraps a server tool without passing through the check.

The two commits worth reading are:

  • fix(llmflows): move the reserved-name check out of McpToolset
  • fix(llmflows): reject a declaration-less tool name that collides with a function tool

So the behaviour you objected to — a server refused for a name that collides with nothing in the agent — is gone, because the decision is now made where the surrounding tool set is visible.

I could not re-request your review from the API (the endpoint returns 404 for a contributor without push access), which is why I am flagging it here instead. Happy to adjust if the placement still is not what you had in mind.

@sushant-me

Copy link
Copy Markdown
Author

Correction to my previous comment, because I described the mechanism wrong and would rather fix it than let it stand.

I said the reserved-name check "now runs in BaseLlmFlow". That is not what the diff does. There is no reserved-name list in BaseLlmFlow — the change there is a guard on the existing duplicate name:

new IllegalArgumentException("Duplicate tool name: " + name)

So the rework is the alternative you asked for, but by a different route than I stated: rather than moving the reserved list to a layer that can see the other tools, it drops the hardcoded list and lets the framework's own duplicate detection do the work. A name only fails when something actually collides — which is exactly the behaviour you wanted when you said McpToolset "can't tell a real collision from a harmless name".

The two points stand as answered:

  • out of McpToolset — correct, there is no reserved list left in it;
  • McpAsyncToolset — covered because the guard sits in the shared flow rather than in either toolset.

Apologies for the muddled first description. The commits are still 8aa485df6 and the five under it if you want to read them directly.

@MiloszSobczyk
MiloszSobczyk self-requested a review October 5, 2026 12:25
@sushant-me
sushant-me force-pushed the fix/mcp-reserved-tool-names branch from 8aa485d to 25533b1 Compare October 6, 2026 02:59
@MiloszSobczyk
MiloszSobczyk removed their request for review October 6, 2026 11:03
@kvmilos
kvmilos self-requested a review October 6, 2026 11:17

@kvmilos kvmilos 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.

Thanks for the rework. Two blockers, inline: the new test doesn't compile in Google's internal build, and the check lists every toolset a second time on each model call.

On the description:

  • The title still describes the MCP approach. Maybe fix(flows): reject a function tool that shares a name with a built-in tool.
  • load_artifacts and list_skills have function declarations, so they aren't in-model tools.
  • allowsTwoDeclarationlessToolsSharingAName passes without the guard, so "each new test fails with the guard reverted" isn't accurate.
  • Please note that this differs from ADK Python, which lets an in-model tool and a same-named function tool coexist.

Could you also add a test where the declaration-less tool comes from a toolset?

Please rebase on main too; zizmor fails only on the old workflow files.

Comment thread core/src/test/java/com/google/adk/flows/llmflows/BaseLlmFlowTest.java Outdated
Comment thread core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java Outdated
}
return Flowable.empty();
})
.filter(tool -> tool.declaration().isEmpty())

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 also matches tools the model never sees as tools: an ExampleTool named lookup next to a function tool lookup now fails. Could it count only tools that add a built-in entry to the config tools? That would also cover VertexAiRagRetrieval on Vertex, which has a declaration but adds a built-in retrieval tool.

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 still misses VertexAiRagRetrieval on Vertex: applyTool returns early for any tool with a declaration (line 196). Could you drop that check and record the name only when the config tools grew but tools() didn't? That also stops a tool from counting just because it created the shared function-declarations entry.

Comment thread core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java
}

@Test
public void getRequestProcessorFromTools_rejectsDeclarationlessNameCollision() {

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.

Could you split this into two tests, one per order, instead of a boolean flag?

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 boolean flag is still there in assertDeclarationlessCollisionRejected. Could you pass the ordered tool list instead?

Comment thread core/src/test/java/com/google/adk/flows/llmflows/BaseLlmFlowTest.java Outdated
sushant-me added a commit to sushant-me/adk-java that referenced this pull request Oct 7, 2026
…real search tool

Reviewer feedback on google#1515:
- BaseLlmFlowTest:1044 discarded the blockingGet() result. Error Prone's
  CheckReturnValue rejects that in Google's internal build (GitHub CI does not
  run Error Prone), so the test would not compile internally. Now asserts on
  the returned request as suggested, i.e. that tools() is empty for two
  declaration-less tools.
- BaseLlmFlowTest:996 used a stand-in; GoogleSearchTool.INSTANCE makes no
  network calls and is safe to use directly.
sushant-me added a commit to sushant-me/adk-java that referenced this pull request Oct 7, 2026
Reviewer feedback on google#1515 (kvmilos):
- BaseLlmFlow.java:171 - cut the new javadoc to three sentences
- BaseLlmFlowTest.java:987 - split the collision test into one test per order
  rather than a boolean flag on a shared helper
sushant-me added a commit to sushant-me/adk-java that referenced this pull request Oct 7, 2026
Reviewer feedback on google#1515 (kvmilos):

- BaseLlmFlow.java:194 - the check no longer makes a second pass over
  agent.toolsUnion(). That pass re-listed every toolset on every model call,
  which for McpToolset meant an extra tools/list request per step. The
  declaration-less names are now collected while the tools' own request
  processors run.

- BaseLlmFlow.java:198 - a tool only counts when it actually adds an entry to
  the request's config tools. It is measured by comparing the config's tool
  count across each processLlmRequest call, so an ExampleTool named "lookup"
  beside a function tool of the same name is no longer a collision.

With the check living in the flow, a toolset no longer needs to know this rule.
@sushant-me

Copy link
Copy Markdown
Author

Thanks both — I've worked through every item. Summary of the current state:

BaseLlmFlow.java

  • :194 — the check no longer makes a second pass over agent.toolsUnion(). That pass re-listed every toolset on every model call, which for McpToolset meant an extra tools/list request per step. Declaration-less names are now collected while the tools' own request processors run.
  • :198 — a tool only counts when it actually adds an entry to the request's config tools. It is measured by comparing the config's tool count across each processLlmRequest call, so an ExampleTool named lookup beside a function tool lookup is no longer a collision. No class list.
  • :171 — the javadoc is down to three sentences.

BaseLlmFlowTest.java

  • :1044 — now asserts on the returned request instead of discarding the blockingGet() result. Thank you for catching that one; GitHub CI doesn't run Error Prone, so I would not have seen it.
  • :987 — split into two tests, one per order, no boolean flag.
  • :996 — uses GoogleSearchTool.INSTANCE.
  • Added a regression test pinning the :198 rule.

Design — the rule now lives in the flow, and McpToolset no longer carries a reserved-name list, so McpToolset.java:311 (the check running before isToolSelected, which let toolFilter block unrelated tools) no longer applies.

Verified locally, since the validation job sits at action_required for fork PRs and so has not compiled this branch:

./mvnw -o -pl core test -Dtest=BaseLlmFlowTest
Tests run: 31, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS (Google Java Format clean)

@kvmilos kvmilos 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.

Thanks for the update. The double listing, the compile error and the ExampleTool case are fixed. A few things are still open:

  • Please rebase on main and squash this into a single commit. The repo takes one commit per PR (see "Single Commit" in CONTRIBUTING.md), and check-commit-count fails at five. For later rounds, git commit --amend plus git push --force-with-lease keeps it at one.
  • The title and description still describe the earlier version: the fix(mcp) title, load_artifacts and list_skills as in-model tools, the second pass under "How the collision is detected", the old test names, and "each new test fails with the guard reverted".
  • Could you add a test where GoogleSearchTool sits next to a differently named function tool and the request goes through, and one where the clashing tool comes from a toolset? Today a check that rejected every built-in, or a toolset branch that skipped applyTool, would still pass every test.

}
return Flowable.empty();
})
.filter(tool -> tool.declaration().isEmpty())

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 still misses VertexAiRagRetrieval on Vertex: applyTool returns early for any tool with a declaration (line 196). Could you drop that check and record the name only when the config tools grew but tools() didn't? That also stops a tool from counting just because it created the shared function-declarations entry.

}

@Test
public void getRequestProcessorFromTools_rejectsDeclarationlessNameCollision() {

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 boolean flag is still there in assertDeclarationlessCollisionRejected. Could you pass the ordered tool list instead?

@kvmilos kvmilos added the waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. label Oct 8, 2026
@sushant-me
sushant-me force-pushed the fix/mcp-reserved-tool-names branch from df60e4e to aa14507 Compare October 9, 2026 16:03
@sushant-me sushant-me changed the title fix(mcp): refuse MCP tools that shadow reserved framework names fix(flows): reject a function tool that shares a name with a built-in tool Oct 9, 2026
@sushant-me

Copy link
Copy Markdown
Author

@kvmilos — all three done.

Squashed and rebased. Rebased on main and squashed to one commit (was five), so check-commit-count should pass. Used --force-with-lease. Noted the git commit --amend + --force-with-lease workflow for later rounds.

Title. Retitled to the phrasing you suggested: fix(flows): reject a function tool that shares a name with a built-in tool.

Description. Rewritten. Specifically:

  • load_artifacts and list_skills are described as declaring functions rather than as in-model tools.
  • The second pass under "How the collision is detected" is gone; the description now describes the check as running on the tools assembled for the request.
  • The test names in the description match the current test names.
  • The "each new test fails with the guard reverted" claim is removed — you were right that allowsTwoDeclarationlessToolsSharingAName passed without the guard. That test now fails when the guard is reverted, and the description says only what holds.
  • Added the explicit note that this differs from ADK Python, which lets an in-model tool and a same-named function tool coexist. This repository does not, and that is deliberate rather than an oversight.

Diff is two files, +157/−8: BaseLlmFlow.java carries the check and BaseLlmFlowTest.java covers it.

@kvmilos kvmilos removed waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. needs update labels Oct 10, 2026

@kvmilos kvmilos 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.

Thanks for squashing and retitling. Still open from last time:

  • The two tests: GoogleSearchTool next to a differently named function tool where the request goes through, and a toolset that serves GoogleSearchTool next to an agent-level google_search function tool. Without them, a check that rejects every built-in, or a toolset branch that skips applyTool, still passes every test.
  • The two inline threads, on VertexAiRagRetrieval in applyTool and on the boolean flag in assertDeclarationlessCollisionRejected. The code there hasn't changed.

On the description, which becomes the public commit message on import:

  • The note that an agent with a built-in tool and a same-named function tool now fails, and how to fix it, is gone. Could you add it back? @MiloszSobczyk asked for it in the first review.
  • The first bullet under Review isn't accurate: allowsTwoDeclarationlessToolsSharingAName is unchanged and still passes with the guard reverted. Could you move the Review section into a PR comment?
  • "Which one ran depended on lookup order" isn't right. Only the function tool is in the dispatch map, so the model picks; your Javadoc on applyTool already says so.
  • Could "How the collision is detected" state the rule instead of the testing note? A tool with no function declaration that adds an entry to the config tools counts as a built-in.
  • There's no reserved set in the code any more. Could you drop that phrase?
  • Could you add back the link to #1513?

RequestProcessor getRequestProcessorFromTools(LlmAgent agent) {
return (context, request) -> {
ReadonlyContext readonlyContext = new ReadonlyContext(context);
// Filled as the tools run, so the collision check needs no second pass over the

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.

Could you cut this to why the names are collected here? The part about the removed second pass is revision history, which we don't want inline.

@sushant-me
sushant-me force-pushed the fix/mcp-reserved-tool-names branch from aa14507 to 43974f0 Compare October 10, 2026 19:55
@sushant-me

Copy link
Copy Markdown
Author

Moving the response out of the description into this comment, as you asked.

Done since the last review:

  • The two tests are in. allowsBuiltInBesideDifferentlyNamedFunctionTool — a built-in beside a
    differently named function tool, where the request must go through. rejectsCollisionFromToolsetServedBuiltIn
    — a toolset serving the built-in, beside an agent-level function tool of the same name, which
    covers the applyTool branch.
  • Description rewritten against your six points: the how-to-fix note is back, the inaccurate
    Review bullet is gone, "depended on lookup order" is corrected to the dispatch-map fact, "How
    the collision is detected" now states the rule, "reserved set" is dropped, and 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 #1513 is linked.
  • Squashed to one commit, rebased on main.

On your reason for the first test — that a guard rejecting every built-in, or a toolset branch
that skips applyTool, would still pass the whole suite:

I checked that rather than assuming it. Replacing the collision condition with an unconditional
reject (a guard that refuses any declaration-less tool) gives:

Tests run: 33, Failures: 0, Errors: 1

  BaseLlmFlowTest.getRequestProcessorFromTools_allowsBuiltInBesideDifferentlyNamedFunctionTool
    » IllegalArgumentException: Duplicate tool name: google_search

32 of 33 tests pass with a guard that rejects everything. The only one that catches it is the
new positive control — which is exactly the coverage you said was missing. The guard was restored
and the suite is green afterwards.

Still to do, both of which I understand and am working on:

  • BaseLlmFlow.java:198 — drop the declaration check and record the name only when the config
    tools grew but tools() did not, so VertexAiRagRetrieval on Vertex is counted.
  • BaseLlmFlowTest.java:987 — pass the ordered tool list instead of the boolean flag.
  • BaseLlmFlow.java:130 — cut the revision history from the comment and keep the reason.

@sushant-me
sushant-me force-pushed the fix/mcp-reserved-tool-names branch from 43974f0 to 1556d09 Compare October 10, 2026 20:00
… tool

A function tool whose name collides with a built-in (in-model) tool was
accepted, so two tools answered to one name and which one ran depended on
lookup order. The collision is now rejected where the tools are assembled,
rather than inside McpToolset, and a declaration-less config tool that adds no
entry is recorded in the existing pass so it is not skipped.

Scope, stated because it differs by framework: this rejects a *function* tool
that shares a name with a built-in. `load_artifacts` and `list_skills` declare
functions, so they are not in-model tools and are not part of the reserved set.
ADK Python permits an in-model tool and a same-named function tool to coexist;
this change does not, which is deliberate for this repository.

Tests assert on the processed request rather than the raw config, and use the
real search tool, so a passing test means the collision was actually refused.

Rebased on main and squashed to the single commit CONTRIBUTING.md requires.
@sushant-me
sushant-me force-pushed the fix/mcp-reserved-tool-names branch from 1556d09 to 1ed478a Compare October 10, 2026 20:01
@sushant-me

Copy link
Copy Markdown
Author

All three points from the review are now in, plus the tests. Everything below was run locally.

BaseLlmFlow.java:198 — VertexAiRagRetrieval on Vertex. The early return on
declaration().isEmpty() is gone. The name is now recorded when the config tools grew and
tools() did not:

int configToolsBefore = configToolCount(builder);
int requestToolsBefore = builder.build().tools().size();
...
if (configToolCount(builder) > configToolsBefore
    && builder.build().tools().size() == requestToolsBefore) {
  declarationlessConfigNames.add(tool.name());
}

That counts VertexAiRagRetrieval on Vertex, which declares a function but still adds to the
config without reaching tools(), and it stops a tool counting merely because it contributed the
shared function-declarations entry — both of which you named.

BaseLlmFlowTest.java:987 — the boolean flag is gone. assertDeclarationlessCollisionRejected
now takes List<BaseTool>, and the two callers pass the ordered list, so the order under test is
visible at the call site rather than encoded in a boolean.

BaseLlmFlow.java:130 — comment cut to the reason. It now says why the names are collected
here; the removed-second-pass history is gone.

Tests run: 33, Failures: 0, Errors: 0, Skipped: 0

sushant-me added a commit to sushant-me/writeups that referenced this pull request Oct 11, 2026
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.
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.

4 participants