Skip to content

feat(server): let sellers set content[].text on MCP success results instead of a JSON copy of structuredContent #1302

Closed

Description

@bokelley

Summary

On 8.0.0, an MCP tool success sends a JSON copy of the whole response as content[0].text, even when the response has a human-readable message. _register_tool returns the handler dict (adcp/server/serve.py:3080-3089), and FastMCP's convert_result turns it into text with pydantic_core.to_json(..., indent=2) (mcp/server/mcpserver/utilities/func_metadata.py:132, :571-572). The error path already does the human-readable version: build_mcp_error_result writes "{code}[{field}]: {message}" plus the suggestion as the text fallback "for LLM tool-use surfaces, log viewers" (adcp/server/translate.py:212-214, :255-261). Success results have no equivalent.

Reproduction (adcp 8.0.0)

import asyncio
import json

from adcp.server import ADCPHandler, create_mcp_server

PRODUCTS = [{"product_id": f"p{i}", "name": f"Product {i}"} for i in range(3)]


class Seller(ADCPHandler):
    async def get_products(self, params, context=None):
        return {"products": PRODUCTS, "message": "Found 3 products matching 'video'."}


async def main():
    mcp = create_mcp_server(Seller(), name="seller", validation=None, advertise_all=True)
    result = await mcp.call_tool("get_products", {"buying_mode": "brief", "brief": "video"})
    text = result.content[0].text
    print("message field:  ", result.structured_content["message"])
    print("content[0].text:", len(text), "chars;",
          "identical to structuredContent:", json.loads(text) == result.structured_content)

asyncio.run(main())

Output:

message field:   Found 3 products matching 'video'.
content[0].text: 320 chars; identical to structuredContent: True

With a real get_products catalog the text block is the full product list, so an LLM client reads every product twice.

response_enhancer (adcp/server/helpers.py:442-492) can't help. It mutates the response dict in place, so any change also lands in structuredContent and in the A2A response.

Why an adopter hits this

Embedded Sales Agent wants concise text for conversational MCP clients and the canonical payload unchanged in structuredContent. It added human_readable_mcp_results (core/middleware/mcp_human_readable_results.py, 79 LOC), a SkillMiddleware that does the following:

  • checks context.metadata["transport"] == "mcp". The SDK's auth_context_factory sets this key (adcp/server/auth.py:757-761); the dispatcher doesn't, so it isn't part of the SkillMiddleware contract.
  • imports mcp.types and returns a CallToolResult(content=[summary], structured_content=result).

This works because _register_tool passes CallToolResult through unchanged (serve.py:3084-3085). But the SkillMiddleware docs (serve.py:395-401) describe the return value only as the dispatch result the A2A transport serialises, so this depends on undocumented behavior. Each seller that wants readable text has to rebuild the same thing.

Ask

Add an opt-in, MCP-only hook. Keep today's default, since MCP recommends the serialized JSON text block for backwards compatibility. For example:

serve(..., mcp_result_text=lambda tool_name, result, context: str | None)

It would run after validation and the context echo. A string replaces content[0].text; None keeps the JSON fallback. structuredContent would be unchanged. A smaller alternative is a built-in flag that uses the response's message field as the text when it is present.

Activity

  1. added
    enhancementNew feature or request
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
  2. bokelley commented on Oct 2, 2026

    @bokelley
    ContributorAuthor

    Triage

    Classification: Feature request
    Bucket(s): handlers / server (no matching repo label — flagged in run summary)
    Status: ready-to-implement
    Milestone: omitted (no target version stated)

    What the experts said:

    • code-reviewer: Non-breaking, structuredContent preserved; one blocker — _AdcpFuncMetadata.convert_result bypass is gated on is_error=True only, so a success-path CallToolResult falls through to super().convert_result() and may double-wrap content[]; the condition must be widened before merging.
    • dx-expert: Ship Option A (callable) with a string shorthand as a union type; do not ship Option B (bool flag) alone; blocker: mirror the param on serve() as well as create_mcp_server(), and type context as ToolContext | None, not Any.

    My take: Both experts converge — this is a clean opt-in addition that leaves defaults unchanged. The implementation brief below covers the two blockers they raised.

    Implementation scope:

    • Add mcp_result_text: str | Callable[[str, dict, ToolContext | None], str | None] | None = None to create_mcp_server(), serve(), and _register_tool() in src/adcp/server/serve.py. A string value means "use this field from the result dict as text, fall back to JSON if absent." A callable receives (tool_name, result_dict, context_or_none) and returns the text or None.
    • In _register_tool's fn(), after the result dict is built (post-model_dump), if mcp_result_text is set: evaluate it, and if a non-None string is produced, return CallToolResult(content=[TextContent(type="text", text=text)], structured_content=result_dict) directly.
    • Fix the convert_result bypass (src/adcp/server/serve.py — _AdcpFuncMetadata): widen the bypass condition from is_error=True to also cover pre-built success results, e.g. isinstance(result, CallToolResult) and (result.is_error or result.content), or add a sentinel attribute. Without this, the pre-built CallToolResult falls through to super().convert_result() and re-wraps.
    • Async callables should be await-ed inside async def fn(); document that sync callables are also accepted.
    • Required checks: pytest on the _register_tool / serve.py test files; mypy src/ (types changed); ruff check . / black --check ..
    • Non-breaking rationale: new optional param, None default preserves current JSON-serialized-text behavior for all existing callers; no public symbol renamed or removed.

    Triaged by Claude Code. Session: https://claude.ai/code/session_01DA9mCQfk62p91tKkfE2uYV


    Generated by Claude Code

  3. added and removed
    claude-triagingTriage routine is actively working on this issue (1-3 min)
    on Oct 2, 2026
  4. bokelley commented on Oct 4, 2026

    @bokelley
    ContributorAuthor

    Fixed by #1337 (squash 6e676cd): typed opt-in MCP success-text callbacks; structuredContent stays exact and the fallback/A2A paths are unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions