Skip to content

Typed tool returns: enforced ToolResult|ToolError contract, served as MCP structured content#10

Open
ciaransweet wants to merge 2 commits into
mainfrom
feat/typed-tool-results
Open

Typed tool returns: enforced ToolResult|ToolError contract, served as MCP structured content#10
ciaransweet wants to merge 2 commits into
mainfrom
feat/typed-tool-results

Conversation

@ciaransweet

Copy link
Copy Markdown
Contributor

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 as XResult | ToolError; mcp-runtime derives the tool's outputSchema from that annotation, advertises it in tools/list, and validates every result against it before sending. A tool whose annotation doesn't follow the contract aborts build_server at 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 str message (the human-readable answer) plus per-tool data keys, declared NotRequired on a subclass.
  • ToolError — structured error: required error kind and detail. Tools that raise instead can drop this arm — exceptions become MCP isError results.
  • is_error() — a TypeIs guard that narrows dict[str, Any] | ToolError in 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

  • New mcp_runtime/tool_result.py and mcp_runtime/fastmcp_output.py; server.py uses the extended to_fastmcp, which wraps the upstream conversion (langchain-mcp-adapters discards return annotations, so FastMCP never emitted structuredContent) and attaches RootModel[XResult | ToolError] — never FastMCP's {"result": ...} wrapping, no null-padding, object-rooted advertised schema. Undeclared keys are dropped from structuredContent: the annotation is the complete client-visible contract.
  • hello and credential-demo converted; whoami now returns message + account, demonstrating the convention.
  • scripts/new-toolset scaffold generates a conforming tool, so new toolsets pass the gate from the first commit.
  • README: the "Adding a toolset" example follows the contract, and a new "Typed tool returns" section documents how to annotate, the rules/gotchas, and how to verify locally.
  • test_contract.py converts every tool of every toolset through the startup gate in CI; new test_fastmcp_output.py covers schema shapes, exact structured payloads, and every rejection case.

Testing

  • 65 tests pass; ruff + mypy clean via ./scripts/lint.
  • Verified end-to-end over an in-memory MCP session: tools/list advertises the schemas and tools/call returns structuredContent alongside the usual text block.

For example, whoami's full tools/list entry:

{
  "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

ciaransweet and others added 2 commits July 14, 2026 17:54
…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>
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.

1 participant