Typed tool returns: enforced ToolResult|ToolError contract, served as MCP structured content#10
Open
ciaransweet wants to merge 2 commits into
Open
Typed tool returns: enforced ToolResult|ToolError contract, served as MCP structured content#10ciaransweet wants to merge 2 commits into
ciaransweet wants to merge 2 commits into
Conversation
…tructured content
Tools now annotate their returns as XResult | ToolError (TypedDicts from
mcp_runtime.tool_result). An extended to_fastmcp derives each tool's MCP
outputSchema from that annotation, advertises it in tools/list, and
validates every result before sending as structuredContent — never
FastMCP's {"result": ...} wrapping. Tools whose annotation doesn't offer a
required str message abort build_server at startup, and the toolset
contract test converts every tool of every toolset through the same gate.
The hello and credential-demo toolsets and the new-toolset scaffold are
converted, and the README documents the contract for template users
(Typed tool returns section).
Ported from ecmwf/dss-agentic-ai-services#56.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Tool results now travel over MCP as typed
structuredContent, backed by a return contract the server enforces at startup. Every tool annotates its return asXResult | ToolError;mcp-runtimederives the tool'soutputSchemafrom that annotation, advertises it intools/list, and validates every result against it before sending. A tool whose annotation doesn't follow the contract abortsbuild_serverat startup — and fails the contract test in CI.Port of ecmwf/dss-agentic-ai-services#56 (where the contract is proven against a full 10-tool toolset), so the template stays the canonical pattern.
The contract (
mcp_runtime.tool_result)ToolResult— success: a required strmessage(the human-readable answer) plus per-tool data keys, declaredNotRequiredon a subclass.ToolError— structured error: requirederrorkind anddetail. Tools that raise instead can drop this arm — exceptions become MCPisErrorresults.is_error()— aTypeIsguard that narrowsdict[str, Any] | ToolErrorin both branches.Returns use TypedDict call syntax (
ToolResult(message=...)): mypy-checked construction, plain dict at runtime. The schema, the type checker, and the startup gate enforce the same contract.What changed
mcp_runtime/tool_result.pyandmcp_runtime/fastmcp_output.py;server.pyuses the extendedto_fastmcp, which wraps the upstream conversion (langchain-mcp-adapters discards return annotations, so FastMCP never emittedstructuredContent) and attachesRootModel[XResult | ToolError]— never FastMCP's{"result": ...}wrapping, no null-padding, object-rooted advertised schema. Undeclared keys are dropped fromstructuredContent: the annotation is the complete client-visible contract.helloandcredential-democonverted;whoaminow returnsmessage+account, demonstrating the convention.scripts/new-toolsetscaffold generates a conforming tool, so new toolsets pass the gate from the first commit.test_contract.pyconverts every tool of every toolset through the startup gate in CI; newtest_fastmcp_output.pycovers schema shapes, exact structured payloads, and every rejection case.Testing
./scripts/lint.tools/listadvertises the schemas andtools/callreturnsstructuredContentalongside the usual text block.For example,
whoami's fulltools/listentry:{ "name": "whoami", "description": "Report which account the calling user's credential belongs to.\n\n Requires the caller's token in the `x-demo-token` HTTP header of the MCP\n request \u2014 ask the user to configure it if missing; it cannot be passed\n as an argument.", "inputSchema": { "description": "Report which account the calling user's credential belongs to.\n\nRequires the caller's token in the `x-demo-token` HTTP header of the MCP\nrequest \u2014 ask the user to configure it if missing; it cannot be passed\nas an argument.", "properties": {}, "title": "whoami", "type": "object" }, "outputSchema": { "description": "The resolved account id, also stated in the message.", "properties": { "message": { "title": "Message", "type": "string" }, "account": { "title": "Account", "type": "string" } }, "required": [ "message" ], "title": "WhoamiResult", "type": "object" } }🤖 Generated with Claude Code