Skip to content

fix(mcp): emit search results once instead of content + structuredContent - #14175

Open
swissmike-zh wants to merge 1 commit into
onyx-dot-app:mainfrom
swissmike-zh:fix/mcp-search-single-emission
Open

fix(mcp): emit search results once instead of content + structuredContent#14175
swissmike-zh wants to merge 1 commit into
onyx-dot-app:mainfrom
swissmike-zh:fix/mcp-search-single-emission

Conversation

@swissmike-zh

@swissmike-zh swissmike-zh commented Aug 22, 2026

Copy link
Copy Markdown

Fixes #14171

Problem

search_indexed_documents is annotated -> dict[str, Any], so FastMCP derives an output schema from the annotation and sends the payload twice — once as structuredContent, and again as serialised JSON in a text content block. Every search result crosses the wire in duplicate.

For most tools that is cheap. For a search tool the payload is the retrieved document content, so the duplicate scales with everything retrieved.

Measurement

On fastmcp==3.2.0 (the pinned version), with a single-document payload:

content structuredContent total
current (-> dict[str, Any]) 2531 2541 5072 chars
this PR (-> ToolResult) 2531 0 2531 chars (−50.1%)

In a self-hosted deployment with an agent client that forwards tool results to the model without capping, this was worth roughly 2.4k input tokens per search — about half the tool-result injection. Full standalone reproduction in #14171.

Why this is safe

  • The content text is byte-identical — same pydantic_core.to_json of the same dict — so anything parsing the text path sees no change.
  • The derived schema comes from dict[str, Any], which describes no structure, so clients gain nothing from the structured copy for this tool.
  • search_web and open_urls are deliberately left as-is. They dual-emit too, but their payloads are small enough not to matter, and _error_payload stays a plain dict since open_urls shares it.

Note

output_schema=None on the decorator does not fix this — it drops the declared outputSchema from tools/list but the result still carries both representations. Verified on 3.2.0. An explicit ToolResult is what suppresses the duplicate.

Caveat

Dual emission is the spec-compliant default when a tool declares an output schema, so a client genuinely consuming the structured path for this tool would be affected. If you would rather have this behind a flag than as a change of default, I am happy to rework it — or to close this in favour of an approach you prefer.

Checks

  • ruff format clean (0.16.0, per .pre-commit-config.yaml)
  • ruff check reports no new findings — identical counts before and after
  • Variant has been running in production for about a month

Summary by cubic

Emit search_indexed_documents results once by returning a ToolResult instead of a bare dict, removing the duplicate structuredContent. This halves the wire size on typical payloads and reduces token usage without changing the content text.

  • Old vs new: previously fastmcp derived a schema from dict[str, Any] and sent both structuredContent and text; now only text content is sent.
  • The text payload is byte-identical to before; parsers of the text path see no change.
  • search_web and open_urls are unchanged.
  • Migration: if any client consumes structuredContent for search_indexed_documents, switch to parsing the content text.

Written for commit 2ae57d2. Summary will update on new commits.

Review in cubic

…tent

search_indexed_documents is annotated -> dict[str, Any], so FastMCP derives
an output schema and sends the payload twice: once as structuredContent and
again as serialised JSON in a text block. For a search tool the payload is
the retrieved document content, so the duplicate scales with everything
retrieved - measured at ~50% of the tool result on a realistic payload.

The derived schema comes from dict[str, Any] and conveys no structure, so
clients gain nothing from the structured copy. Returning an explicit
ToolResult emits the payload once; the content text is byte-identical, so
consumers parsing the text path are unaffected.

search_web and open_urls are left as-is - they dual-emit too, but their
payloads are small enough not to matter.
@swissmike-zh
swissmike-zh requested a review from a team as a code owner August 22, 2026 19:08
@greptile-apps

greptile-apps Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

PR author is not in the allowed authors list.

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 1 file

Confidence score: 1/5

  • backend/onyx/mcp_server/tools/search.py imports ToolResult from a path absent in the pinned FastMCP 3.2.0, causing ModuleNotFoundError during import and preventing the MCP server from starting — update the import to the compatible fastmcp.tools.bas... path.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="backend/onyx/mcp_server/tools/search.py">

<violation number="1" location="backend/onyx/mcp_server/tools/search.py:10">
P0: With the repository's pinned FastMCP 3.2.0, this import path does not exist, so importing `search.py` raises `ModuleNotFoundError` and prevents the MCP server from starting. Import `ToolResult` from `fastmcp.tools.base` instead.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

import httpx
import pydantic_core
from fastmcp.server.auth.auth import AccessToken
from fastmcp.tools.tool import ToolResult

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.

P0: With the repository's pinned FastMCP 3.2.0, this import path does not exist, so importing search.py raises ModuleNotFoundError and prevents the MCP server from starting. Import ToolResult from fastmcp.tools.base instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/onyx/mcp_server/tools/search.py, line 10:

<comment>With the repository's pinned FastMCP 3.2.0, this import path does not exist, so importing `search.py` raises `ModuleNotFoundError` and prevents the MCP server from starting. Import `ToolResult` from `fastmcp.tools.base` instead.</comment>

<file context>
@@ -5,7 +5,9 @@
 import httpx
+import pydantic_core
 from fastmcp.server.auth.auth import AccessToken
+from fastmcp.tools.tool import ToolResult
 from pydantic import BaseModel, TypeAdapter, ValidationError
 
</file context>
Suggested change
from fastmcp.tools.tool import ToolResult
from fastmcp.tools.base import ToolResult

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 search_indexed_documents returns every result twice (content + structuredContent), doubling tokens for forwarding clients

1 participant