Skip to content

fix(mcp): bound the MCP output schema sent to Gemini - #7709

Open
danieldutu wants to merge 1 commit into
google:mainfrom
danieldutu:fix/mcp-gemini-output-schema
Open

danieldutu wants to merge 1 commit into
google:mainfrom
danieldutu:fix/mcp-gemini-output-schema

Conversation

@danieldutu

@danieldutu danieldutu commented Oct 24, 2026 •

Copy link
Copy Markdown

Link to Issue

Fixes: #7690

Problem

On the Gemini path, McpTool._build_declaration sends the MCP tool's outputSchema to the model as FunctionDeclaration.response_json_schema with no size limit whenever JSON_SCHEMA_FOR_FUNC_DECL is enabled, which is the default (src/google/adk/features/_feature_registry.py). Google Cloud's remote MCP servers (https://{logging,monitoring,cloudtrace,clouderrorreporting}.googleapis.com/mcp) return proto-derived output schemas that serialize to over 70,000 characters each, and Gemini counts every $ref as if it were inlined when tokenizing. Sending all 18 of those declarations fails on gemini-3.5-flash, gemini-3.8-flash and gemini-4-argon:

400 INVALID_ARGUMENT. {'error': {'code': 400, 'message': 'The input token count exceeds the maximum number of tokens allowed 1048576.', 'status': 'INVALID_ARGUMENT'}}

Without response_json_schema, the same 18 declarations use 14,754 prompt tokens. get_dashboard's declaration alone costs 494,684 prompt tokens.

Solution

Bound the serialized output schema at _MAX_RESPONSE_JSON_SCHEMA_LENGTH = 1024 characters in _build_declaration. The length is measured once on the server's unfenced schema with ensure_ascii=False, and that one decision applies to both _get_declaration and _build_fenced_declaration. When a schema exceeds the bound, response_json_schema is dropped and one warning per tool instance is logged naming the tool and the serialized length. This mirrors the existing ceiling for the same schema on the OpenAI-compatible path: _append_response_schema_to_description (src/google/adk/models/lite_llm.py, _MAX_TOOL_DESCRIPTION_LENGTH = 1024, #6815) omits the rendered schema past 1024 characters. The Anthropic path never sends the schema.

With this bound, from the Google Cloud servers only list_log_names (578) and query_range (730) survive when measured unfenced. The bound is applied before fencing, so the fenced length of those two schemas does not affect the decision. The 18 declarations drop to roughly the 14,754-token baseline. The $defs live inside the serialized document, so at 1024 characters the $ref-inlining expansion Gemini applies is bounded.

Question for maintainers: is 1024 the threshold you want, or should the bound be a different value, and should it be configurable?

Testing Plan

  • Unit tests, serial as the machine load rule requires:
    • uv run pytest tests/unittests/tools/mcp_tool → 457 passed, 143 warnings in 5.03s
    • uv run pytest tests/unittests/tools → 25 failed, 3283 passed; the 25 failures are in test_bash_tool.py (5) and test_skill_toolset.py (20), outside the touched files. The same 25 fail on base f08f9d67 (source restored to base, run with the new tests present: 27 failed, 3281 passed; the 2 extra failures are new tests in test_mcp_tool.py that need the fix).
    • uv run pre-commit run --files src/google/adk/tools/mcp_tool/mcp_tool.py tests/unittests/tools/mcp_tool/test_mcp_tool.py → all hooks Passed
    • New tests in tests/unittests/tools/mcp_tool/test_mcp_tool.py::TestMCPToolWithJsonSchema: test_get_declaration_with_output_schema_under_bound_sent, test_get_declaration_with_oversized_output_schema_dropped_with_warning, test_get_declaration_without_output_schema_and_json_schema_for_func_decl_enabled, test_fenced_declaration_keeps_output_schema_under_bound_before_fencing (12 short descriptions, under 1024 compact, must survive in the fenced declaration), test_get_declaration_measures_non_ascii_output_schema_by_characters (256 non-ASCII characters, 1156 with escapes, 600 characters), test_oversized_output_schema_warns_once_per_tool (three builds across both declaration paths, one warning). The three review tests fail on the previous commit (3 failed, 118 deselected) and pass with the fix.
  • E2E-style offline repro (no network, no API key), before:
serialized response_json_schema sent to the model: 78076 chars
raw mcp tool output_schema: 78076 chars
schema sent verbatim: True
small schema sent: 59 chars
  • After the fix:
serialized response_json_schema sent to the model: 0 chars
raw mcp tool output_schema: 78076 chars
schema sent verbatim: False
small schema sent: 59 chars
Dropping output_schema for MCP tool get_dashboard: serialized length 78076 exceeds 1024 characters; sending it as response_json_schema can push the request past the model's input token limit.
  • The issue's live 400-error repro against the real Google Cloud MCP servers needs a Vertex AI project; I did not run it. If reviewers want it, the repro script from the issue runs unchanged with uv run --with 'google-adk[mcp]' ... repro.py <vertex-project>.
  • The tox multi-version run was not executed locally; CI covers it.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

The tox multi-version run was left to CI. No documentation change is needed: the bound is client-side with no new public option.

Not covered

  • A per-tool or per-toolset opt-out to skip outputSchema entirely (the issue's option i) is not in this change; the fixed bound handles the reported failure. If maintainers want an opt-out, it can be a follow-up.
  • The LiteLLM path already bounds the schema and is unchanged; the Anthropic path never sends it.
  • The bound applies only on the JSON_SCHEMA_FOR_FUNC_DECL path; the legacy _to_gemini_schema path never sent response_json_schema.

@Somay-kousis Somay-kousis 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.

What I ran (head e6cd6a1, merge base f08f9d6):

  • pytest tests/unittests/tools/mcp_tool on head: 454 passed.
  • The 3 new tests with src/ reverted to the merge base: 1 failed (the oversized test), 2 passed. That matches the description.
  • A probe (probes/probe_7709.py) that builds MCPTool declarations with JSON_SCHEMA_FOR_FUNC_DECL on and drives process_llm_request, on head and on the base.

Findings

  1. The bound is measured after fencing, so the model loses schemas far below 1024 characters (blocking). On the model path, process_llm_request swaps in _build_fenced_declaration(). In _build_declaration, fence_schema_descriptions runs first (mcp_tool.py:411-415) and the length check comes after (:417-428). Each description gains about 78 characters of fence markers. Probe: an output schema with 12 short property descriptions is 600 characters compact. _get_declaration() keeps it, but the fenced declaration the model gets is 1536 characters and drops it. So the dev UI (get_tools_info uses _get_declaration) shows a response schema the model never receives, and the real limit shrinks with every description. It also means the description's claim that list_log_names (578) and query_range (730) survive is probably wrong once fenced, if those schemas have any descriptions. I could not check those two servers. Suggest measuring the unfenced output_schema once, before fencing, and using that decision for both declarations.

  2. Non-ASCII schemas are measured as \uXXXX escapes (worth considering). json.dumps at :418 keeps the default ensure_ascii=True, so each non-ASCII character counts as 6. Probe: a schema that is 256 characters with real characters is measured as 1156 and dropped on head. It is sent on the base. Any server with Japanese, Chinese or Korean descriptions hits the bound at roughly a sixth of the intended size. ensure_ascii=False fixes the measure.

  3. The warning fires twice per tool on every model call (worth considering). BaseTool.process_llm_request builds _get_declaration(), then MCPTool replaces it with _build_fenced_declaration(), and both log. Probe: one oversized tool over 3 model calls logs 6 Dropping output_schema warnings. With the 16 dropped Google Cloud tools from the description, that is 32 warnings per turn. The new test only checks a single _get_declaration() call. Warning once per tool instance (or debug, like _append_response_schema_to_description in lite_llm.py) would avoid the noise.

Not a finding, but relevant: #7708 targets the same Gemini oversized-schema problem with a different approach. Worth settling on one.

What I did not check: a live Gemini request, the real Google Cloud MCP schemas, and token counts.

Probe and raw output: probes/probe_7709.py, review_7709_evidence.txt.

McpTool._build_declaration set response_json_schema from the MCP tool's
outputSchema with no size limit whenever JSON_SCHEMA_FOR_FUNC_DECL (the
default) is on. Google Cloud's remote MCP servers serialize proto-derived
output schemas to over 70,000 characters, and Gemini expands each $ref
when tokenizing, so 18 declarations from those servers pushed the request
past the 1,048,576-token input limit on every call (google#7690).

Bound the serialized output schema at 1024 characters, the same ceiling
_append_response_schema_to_description applies to the same schema on the
OpenAI-compatible path (lite_llm.py). Larger schemas are dropped with one
warning per tool instance naming the tool and the serialized length. The
length is measured on the unfenced schema with ensure_ascii=False, so the
plain and fenced declarations decide the same way and non-ASCII text counts
one character each.

Fixes google#7690
@danieldutu
danieldutu force-pushed the fix/mcp-gemini-output-schema branch from e6cd6a1 to 1edde71 Compare October 24, 2026 15:59
@danieldutu

Copy link
Copy Markdown
Author

Thanks for running the probes.

  1. Fixed. _build_declaration now measures the server's unfenced output_schema before fence_schema_descriptions (mcp_tool.py:413-430), so _get_declaration and _build_fenced_declaration make the same decision. Pinned by test_fenced_declaration_keeps_output_schema_under_bound_before_fencing: 12 described properties under 1024 compact characters survive in the fenced declaration. It failed before the change. The bound now uses the unfenced length, so the fenced length of list_log_names (578) and query_range (730) does not affect the decision.
  2. Fixed. The measurement uses ensure_ascii=False (mcp_tool.py:416). Pinned by test_get_declaration_measures_non_ascii_output_schema_by_characters: a schema with 256 non-ASCII characters measures 1156 with escapes and 600 without, and is sent. It failed before the change.
  3. Fixed. McpTool keeps _logged_output_schema_drop (mcp_tool.py:384) and logs the drop once per tool instance. Pinned by test_oversized_output_schema_warns_once_per_tool: three builds over both declaration paths log one warning. It failed before the change.

#7708 (sun04222, same issue) caps the same 1024 characters and logs a warning, with these differences in its diff: it measures after fencing (fence_schema_descriptions runs before the check), uses json.dumps with default separators and ensure_ascii=True through a helper that returns None on a serialization error, logs on every call with no once-per-tool flag, and adds three tests that call only _get_declaration(). It carries a legacy _to_gemini_schema note and no question about the threshold. This PR measures once before fencing and carries the threshold question.

Not covered: a live Gemini request, the real Google Cloud schemas after this change, token counts, a per-toolset opt-out (option i), and a configurable threshold. 25 failures in tests/unittests/tools outside the MCP files also fail on base f08f9d67.

@Somay-kousis

Copy link
Copy Markdown
Contributor

Reran on 1edde71: the 256-char non-ASCII schema measures by characters and is sent, and the fenced and unfenced paths now make the same call for a 600-char schema. Thanks.

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.

MCP outputSchema is sent to Gemini unbounded, exceeding the token limit

3 participants