Skip to content

fix(mcp): refuse the reserved tool name set_model_response - #7145

Open
sushant-me wants to merge 1 commit into
google:mainfrom
sushant-me:fix/mcp-reserve-set-model-response
Open

sushant-me wants to merge 1 commit into
google:mainfrom
sushant-me:fix/mcp-reserve-set-model-response

Conversation

@sushant-me

Copy link
Copy Markdown

Fixes #7144

Problem

_RESERVED_TOOL_NAMES exists to refuse MCP tool names the framework itself puts on the wire. set_model_response belongs to that class but was not in the set, so a server advertising it was accepted where transfer_to_agent and the adk_request_* names are rejected.

It is a framework-owned wire name on every count:

  • SetModelResponseTool is injected whenever output_schema is set alongside other tools — flows/llm_flows/prompt/_schema.py:54
    set_response_tool = SetModelResponseTool(agent.output_schema)
    llm_request.append_tools([set_response_tool])
  • the framework then instructs the model to answer through that name (_schema.py:57-64), and
  • base_llm_flow.py reads the result back by the same name — "Check if this is a set_model_response function response".

LlmRequest.append_tools resolves the resulting duplicate by last-wins with only a warning:

if tool.name in self.tools_dict:
    # Both declarations are still advertised to the model, but only one
    # tool can hold the name, so calls land on the survivor.
    logging.warning("Duplicate tool name %r: ...", tool.name)
self.tools_dict[tool.name] = tool

So a server-supplied set_model_response competes for the name, and if it is the survivor the agent's structured final answer is dispatched to the third-party server rather than captured by the framework — precisely the outcome this guard was added to prevent.

Fix

 _RESERVED_TOOL_NAMES = frozenset({
     REQUEST_EUC_FUNCTION_CALL_NAME,
     REQUEST_CONFIRMATION_FUNCTION_CALL_NAME,
     REQUEST_INPUT_FUNCTION_CALL_NAME,
     transfer_to_agent.__name__,
+    # Injected by the output-schema processor whenever output_schema is set
+    # alongside other tools (flows/llm_flows/prompt/_schema.py) and read back
+    # by name in base_llm_flow.py, so it is a framework-owned wire name too.
+    # Spelled out because the function is defined inside
+    # SetModelResponseTool.__init__ and is not importable.
+    'set_model_response',
 })

The name is written literally rather than as something.__name__, unlike transfer_to_agent: the callable is defined inside SetModelResponseTool.__init__, so there is no module-level binding to import. Importing it would have raised ImportError for every importer of mcp_tool.

Tests

Both existing reserved-name tests are extended rather than duplicated:

  • test_mcp_tool.py::test_init_reserved_name — adds "set_model_response" to the parametrized list, asserting MCPTool construction raises ValueError.
  • test_mcp_toolset.py::test_get_tools_skips_reserved_names — the server now also advertises set_model_response; the assertion that only valid_tool survives is unchanged.

Scope note

I checked only this name end to end. Other in-model names (google_search, url_context, code_execution, …) may warrant the same treatment, but I could not confirm each reaches tools_dict, so they are deliberately not included here.

@sushant-me

sushant-me commented Sep 16, 2026

Copy link
Copy Markdown
Author

Adding the reachability argument, because the fix only matters if the server's tool is actually the survivor rather than merely a duplicate.

LlmRequest.append_tools resolves a repeated name by last-wins — models/llm_request.py:309:

self.tools_dict[tool.name] = tool

So the question is which registration runs second. In BaseLlmFlow._preprocess_async:

  • base_llm_flow.py:536for processor in self.request_processors: runs _output_schema_processor.request_processor (single_flow.py:72), which is the code that appends set_model_response at prompt/_schema.py:54.
  • base_llm_flow.py:555 — only after that loop completes: await _process_agent_tools(invocation_context, llm_request), which is where the agent's toolsets are resolved and MCP tool declarations enter the request.

The framework's name is registered in the earlier step and the server's in the later one, so the server's tool is the survivor. The shadowing is reachable, not theoretical.

While confirming that, I checked the other tool names the framework injects, and I do not believe they belong in _RESERVED_TOOL_NAMES:

  • finish_task (agents/llm_agent.py:1337) and task_completed (agents/sequential_agent.py:208) are appended to agent.tools at construction. They therefore resolve in the same pass as the MCP tools and are appended last, so the framework wins and the server's tool is the one that gets shadowed.
  • adk_handle_model_error (plugins/_reflect_retry_model_plugin.py:175) assigns llm_request.tools_dict[...] directly instead of going through append_tools, so it overwrites whatever held the name rather than being overwritten.

set_model_response is the one I found that sits in the class the guard exists for: registered by a request processor, and therefore before tool resolution. Happy to be corrected on any of the above.

@sushant-me
sushant-me force-pushed the fix/mcp-reserve-set-model-response branch from 67d06ae to 2a6fc6f Compare September 16, 2026 19:59
_RESERVED_TOOL_NAMES covers the names the framework itself puts on the wire,
so a server advertising one cannot have its tool dispatched in place of the
framework's own. set_model_response belongs to that set but was missing.

SetModelResponseTool is injected into the request whenever output_schema is
configured alongside other tools (flows/llm_flows/prompt/_schema.py), the
framework tells the model to answer through it by name, and base_llm_flow.py
reads the result back by that same name. Because LlmRequest.append_tools
resolves a duplicate name by last-wins with only a warning, an MCP server
advertising set_model_response could otherwise receive the agent's structured
final answer instead of the framework.

The name is spelled out rather than imported: the function is defined inside
SetModelResponseTool.__init__, so there is no module-level binding to import.

Extends both existing reserved-name tests.

Fixes google#7144
@sushant-me
sushant-me force-pushed the fix/mcp-reserve-set-model-response branch from 2a6fc6f to e1f65da Compare September 17, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

set_model_response is missing from the MCP _RESERVED_TOOL_NAMES guard

2 participants