Repository navigation
feat(server): let sellers set content[].text on MCP success results instead of a JSON copy of structuredContent #1302
Description
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'sauth_context_factorysets this key (adcp/server/auth.py:757-761); the dispatcher doesn't, so it isn't part of theSkillMiddlewarecontract. - imports
mcp.typesand returns aCallToolResult(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
bokelley commentedon Oct 2, 2026
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:
structuredContentpreserved; one blocker —_AdcpFuncMetadata.convert_resultbypass is gated onis_error=Trueonly, so a success-pathCallToolResultfalls through tosuper().convert_result()and may double-wrapcontent[]; the condition must be widened before merging.serve()as well ascreate_mcp_server(), and typecontextasToolContext | None, notAny.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:
mcp_result_text: str | Callable[[str, dict, ToolContext | None], str | None] | None = Nonetocreate_mcp_server(),serve(), and_register_tool()insrc/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 orNone._register_tool'sfn(), after the result dict is built (post-model_dump), ifmcp_result_textis set: evaluate it, and if a non-Nonestring is produced, returnCallToolResult(content=[TextContent(type="text", text=text)], structured_content=result_dict)directly.convert_resultbypass (src/adcp/server/serve.py—_AdcpFuncMetadata): widen the bypass condition fromis_error=Trueto 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-builtCallToolResultfalls through tosuper().convert_result()and re-wraps.await-ed insideasync def fn(); document that sync callables are also accepted.pyteston the_register_tool/serve.pytest files;mypy src/(types changed);ruff check ./black --check ..Nonedefault 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
bokelley commentedon Oct 4, 2026
Fixed by #1337 (squash 6e676cd): typed opt-in MCP success-text callbacks; structuredContent stays exact and the fallback/A2A paths are unchanged.