From 5b0404cc4140c1ba362acb62875195805777a7c4 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Tue, 14 Jul 2026 17:54:26 +0100 Subject: [PATCH 1/2] feat(runtime): enforce typed ToolResult|ToolError returns, emit MCP structured content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 67 ++++++- packages/mcp-runtime/pyproject.toml | 1 + .../src/mcp_runtime/fastmcp_output.py | 124 +++++++++++++ .../mcp-runtime/src/mcp_runtime/server.py | 3 +- .../src/mcp_runtime/tool_result.py | 48 +++++ packages/mcp-runtime/tests/test_contract.py | 8 +- .../mcp-runtime/tests/test_fastmcp_output.py | 166 ++++++++++++++++++ packages/mcp-runtime/tests/test_server.py | 23 ++- scripts/new-toolset | 8 +- .../src/credential_demo/tools.py | 18 +- .../tests/test_credential_demo.py | 3 +- toolsets/hello/src/hello/tools.py | 6 +- toolsets/hello/tests/test_hello.py | 4 +- uv.lock | 2 + 14 files changed, 463 insertions(+), 18 deletions(-) create mode 100644 packages/mcp-runtime/src/mcp_runtime/fastmcp_output.py create mode 100644 packages/mcp-runtime/src/mcp_runtime/tool_result.py create mode 100644 packages/mcp-runtime/tests/test_fastmcp_output.py diff --git a/README.md b/README.md index 61dc79a..ac50244 100644 --- a/README.md +++ b/README.md @@ -108,17 +108,28 @@ tools and merge. 2. Write your tools in `toolsets/my-toolset/src/my_toolset/tools.py`: ```python + from typing import Any, NotRequired + from langchain_core.tools import tool + from mcp_runtime.tool_result import ToolError, ToolResult + + class DoSomethingResult(ToolResult): + """Matches for the query, each with an 'id' and a 'score'.""" + + matches: NotRequired[list[dict[str, Any]]] + @tool - def do_something(query: str, limit: int = 10) -> list[dict]: + def do_something(query: str, limit: int = 10) -> DoSomethingResult | ToolError: """One-line description — docstrings and type hints ARE the MCP schema.""" ... + return DoSomethingResult(message=f"Found {len(matches)} match(es).", matches=matches) TOOLS = [do_something] ``` - `TOOLS` is the only required export. Non-empty docstrings are enforced by a + `TOOLS` is the only required export. Non-empty docstrings and the + [ToolResult return contract](#typed-tool-returns) are enforced by a contract test. If a tool does I/O (HTTP, database), write it as `async def` — `@tool` supports coroutines natively; sync tools are fine for pure computation (the runtime runs them in a thread pool). If a tool @@ -139,6 +150,56 @@ tools and merge. Conventions: directory `toolsets/` (kebab-case) → module `.tools` → service `mcp-`. +## Typed tool returns + +Every tool returns one dict per call, in one of two shapes from +`mcp_runtime.tool_result`: + +- **`ToolResult`** — success: a required str `message` (the human-readable + answer a model or UI reads first) plus any data keys your tool declares. +- **`ToolError`** — a structured error: a short machine-readable `error` + kind and a `detail` saying what happened or what to do next. + +The runtime derives each tool's MCP `outputSchema` from its return +annotation, advertises it in `tools/list`, validates every result against it +before sending, and delivers results as typed `structuredContent` (alongside +the usual text block). A tool whose annotation doesn't follow the contract +**fails at startup** (`build_server` aborts, naming the tool) and fails the +contract test in CI — never silently at chat time. + +How to annotate: + +- Minimum: `-> ToolResult | ToolError` for tools whose message is the whole + answer (drop the `ToolError` arm if the tool raises instead of returning + errors — exceptions become MCP `isError` results, which skip schema + validation). +- Recommended: one `ToolResult` subclass per tool, adding each data key as + `NotRequired[...]`, annotated `-> MyResult | ToolError`. Give the subclass + a one-line docstring — it becomes the schema's `description`. Nested + payloads can be TypedDicts or pydantic models all the way down. +- Construct returns with TypedDict call syntax — + `ToolResult(message=...)`, `ToolError(error="not_found", detail=...)` — + mypy-checked, still a plain dict at runtime. `is_error()` (a `TypeIs` + guard) narrows helper results typed `dict[str, Any] | ToolError` in both + branches. + +Rules and gotchas: + +- Keys not declared in the annotation are silently dropped from + `structuredContent` — the annotation is the complete list of keys a client + can see, and mypy flags undeclared keys in return literals. +- Union arms must all be TypedDicts/pydantic models; bare `str`/`list` + returns and `dict[str, Any]` are rejected at startup (FastMCP would wrap + the former in `{"result": ...}`, changing your payload shape; the latter + guarantees nothing). Put data under a named key instead. +- The annotation must be on the function `@tool` wraps; the runtime reads it + via `tool.coroutine`/`tool.func`. + +Verify locally: `TOOLSET=my-toolset uv run mcp-serve`, then `tools/list` +(via MCP Inspector or `mcp-cli`) shows each tool's `outputSchema`, and +`tools/call` responses carry `structuredContent`. + + ## Removing a toolset ```sh @@ -361,7 +422,7 @@ call, and the tool reads them at call time: from mcp_runtime.credentials import credential_from_header @tool -def whoami() -> dict[str, Any]: +def whoami() -> WhoamiResult: """Report which account the calling user's credential belongs to.""" token = credential_from_header("x-demo-token") ... diff --git a/packages/mcp-runtime/pyproject.toml b/packages/mcp-runtime/pyproject.toml index 4191336..fe8f7d2 100644 --- a/packages/mcp-runtime/pyproject.toml +++ b/packages/mcp-runtime/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "langchain-core<2.0.0,>=1.4.6", "pydantic-settings<3.0.0,>=2.12.0", "fastapi<1.0.0,>=0.136.3", + "typing-extensions>=4.10.0", ] [project.scripts] diff --git a/packages/mcp-runtime/src/mcp_runtime/fastmcp_output.py b/packages/mcp-runtime/src/mcp_runtime/fastmcp_output.py new file mode 100644 index 0000000..3bc58f0 --- /dev/null +++ b/packages/mcp-runtime/src/mcp_runtime/fastmcp_output.py @@ -0,0 +1,124 @@ +"""Upstream ``to_fastmcp`` plus an output schema from the return annotation. + +langchain-mcp-adapters' ``to_fastmcp`` discards the tool's return annotation, +so FastMCP never advertises an ``outputSchema`` and every result reaches +clients as a JSON text block only. This wrapper derives the output model from +the annotation and enforces the :mod:`mcp_runtime.tool_result` contract, so +results also travel as MCP ``structuredContent``. + +Supported annotations: a TypedDict or pydantic model, or a union of them +(``SearchDatasetsResult | ToolError``) — the shapes that map 1:1 onto +``structuredContent``. Anything else (unions with non-dict arms, bare lists, +primitives, ``dict[str, Any]``, no annotation) is refused: FastMCP would +either wrap the value in ``{"result": ...}``, changing the payload shape, or +the schema would guarantee nothing. At least one annotation arm must offer a +required str ``message``. A refused tool raises at conversion, aborting +``build_server`` — the contract fails at deploy time, not silently at chat +time. + +Validation happens on every call: FastMCP validates the returned dict against +the model before emitting ``structuredContent``. Keys not declared in the +annotation are dropped from the structured payload (pydantic's TypedDict +semantics), so a tool's annotation is the complete list of keys a client can +see. +""" + +from types import UnionType +from typing import Any, Union, get_args, get_origin, get_type_hints, is_typeddict + +from langchain_core.tools import BaseTool +from langchain_mcp_adapters.tools import to_fastmcp as _to_fastmcp +from mcp.server.fastmcp.tools import Tool as FastMCPTool +from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata +from pydantic import BaseModel, RootModel + + +def _return_annotation(tool: BaseTool) -> Any: + """The return annotation of the function behind a LangChain tool, if any.""" + fn = getattr(tool, "coroutine", None) or getattr(tool, "func", None) + if fn is None: + return None + return get_type_hints(fn).get("return") + + +def _arms(annotation: Any) -> tuple[Any, ...]: + """The annotation's union members, or the annotation itself.""" + if get_origin(annotation) in (Union, UnionType): + return get_args(annotation) + return (annotation,) + + +def _structured_dict(arm: Any) -> bool: + """Whether one annotation arm maps 1:1 onto a structuredContent object.""" + return is_typeddict(arm) or (isinstance(arm, type) and issubclass(arm, BaseModel)) + + +def _resolve(schema: dict[str, Any], defs: dict[str, Any]) -> dict[str, Any]: + """A schema node with its top-level ``$ref`` resolved against ``$defs``.""" + reference = schema.get("$ref", "") + if reference.startswith("#/$defs/"): + return defs.get(reference.removeprefix("#/$defs/"), {}) + return schema + + +def _shape_schema(schema: dict[str, Any]) -> dict[str, Any]: + """The advertised schema, object-rooted. + + ``RootModel`` schemas root at a ``$ref`` (single model) or an ``anyOf`` + (union). Inline the former; stamp ``"type": "object"`` on the latter — + every arm is an object, and MCP clients expect an object-rooted + ``outputSchema``. + """ + defs = dict(schema.get("$defs", {})) + if schema.get("$ref", "").startswith("#/$defs/"): + inlined = dict(_resolve(schema, defs)) + defs.pop(schema["$ref"].removeprefix("#/$defs/")) + if defs: + inlined["$defs"] = defs + return inlined + if "anyOf" in schema: + return {"type": "object", "anyOf": schema["anyOf"], "$defs": defs} + return schema + + +def _offers_message(schema: dict[str, Any]) -> bool: + """Whether some arm of the schema requires a str ``message`` property.""" + defs: dict[str, Any] = schema.get("$defs", {}) + arms = [_resolve(arm, defs) for arm in schema.get("anyOf", [schema])] + return any( + arm.get("properties", {}).get("message", {}).get("type") == "string" + and "message" in arm.get("required", []) + for arm in arms + ) + + +def to_fastmcp(tool: BaseTool) -> FastMCPTool: + """Convert a LangChain tool to FastMCP, deriving its output schema. + + Raises ``RuntimeError`` (naming the tool) when the return annotation does + not follow the ToolResult contract — see the module docstring. + """ + converted = _to_fastmcp(tool) + annotation = _return_annotation(tool) + if annotation is None or not all( + _structured_dict(arm) for arm in _arms(annotation) + ): + raise RuntimeError( + f"tool {tool.name!r} needs a ToolResult return annotation " + f"(a TypedDict/BaseModel or a union of them; got {annotation!r}) " + "— see mcp_runtime.tool_result" + ) + model: type[BaseModel] = RootModel[annotation] # type: ignore[valid-type] + schema = _shape_schema(model.model_json_schema()) + if not _offers_message(schema): + raise RuntimeError( + f"tool {tool.name!r} does not follow the ToolResult contract: no arm " + "of its return annotation has a required str 'message' property" + ) + converted.fn_metadata = FuncMetadata( + arg_model=converted.fn_metadata.arg_model, + output_model=model, + output_schema=schema, + wrap_output=False, + ) + return converted diff --git a/packages/mcp-runtime/src/mcp_runtime/server.py b/packages/mcp-runtime/src/mcp_runtime/server.py index fa5f868..2c454dc 100644 --- a/packages/mcp-runtime/src/mcp_runtime/server.py +++ b/packages/mcp-runtime/src/mcp_runtime/server.py @@ -14,13 +14,14 @@ from ipaddress import IPv4Address from langchain_core.tools import BaseTool -from langchain_mcp_adapters.tools import to_fastmcp from mcp.server.fastmcp import FastMCP from pydantic import Field, IPvAnyAddress from pydantic_settings import BaseSettings from starlette.requests import Request from starlette.responses import JSONResponse, Response +from mcp_runtime.fastmcp_output import to_fastmcp + class RuntimeSettings(BaseSettings): """Runtime configuration, validated from the environment.""" diff --git a/packages/mcp-runtime/src/mcp_runtime/tool_result.py b/packages/mcp-runtime/src/mcp_runtime/tool_result.py new file mode 100644 index 0000000..77c515a --- /dev/null +++ b/packages/mcp-runtime/src/mcp_runtime/tool_result.py @@ -0,0 +1,48 @@ +"""The base contract every tool return follows. + +A tool returns one dict per call, in one of two shapes: + +- :class:`ToolResult` — success: a ``message`` (the text the model reads) + plus any data keys the tool declares, which the agent captures into + session state. +- :class:`ToolError` — a structured error: a short machine-readable + ``error`` kind and a ``detail`` saying what happened or what to do next. + The agent passes these through to the model untouched. + +Subclass :class:`ToolResult` per tool, declaring each data key as +``NotRequired``, and annotate the ``@tool`` function with the union:: + + class SearchDatasetsResult(ToolResult): + datasets: NotRequired[list[dict[str, Any]]] + + @tool + async def search_datasets(query: str) -> SearchDatasetsResult | ToolError: + ... + +``mcp_runtime.fastmcp_output`` derives the tool's MCP ``outputSchema`` from +the annotation, validates every result against it, and refuses to serve a +tool whose schema does not offer a required str ``message`` — the contract +is a deploy-time gate, not a convention. +""" + +from typing import Any, TypedDict + +from typing_extensions import TypeIs + + +class ToolResult(TypedDict): + """A successful tool return: a ``message`` plus declared data keys.""" + + message: str + + +class ToolError(TypedDict): + """A structured tool error: what kind, and what to do about it.""" + + error: str + detail: str + + +def is_error(result: dict[str, Any] | ToolError) -> TypeIs[ToolError]: + """Whether a helper's raw dict is an error return (truthy ``error`` key).""" + return bool(result.get("error")) diff --git a/packages/mcp-runtime/tests/test_contract.py b/packages/mcp-runtime/tests/test_contract.py index 85ba955..76f057f 100644 --- a/packages/mcp-runtime/tests/test_contract.py +++ b/packages/mcp-runtime/tests/test_contract.py @@ -2,7 +2,8 @@ Doubles as a per-toolset import smoke test and enforces non-empty descriptions (docstrings become the MCP schema, so they are part of the -contract). +contract) and the ToolResult return contract (annotations become the MCP +output schema, so every tool in every toolset must convert cleanly). """ import importlib @@ -11,6 +12,7 @@ import pytest from langchain_core.tools import BaseTool +from mcp_runtime.fastmcp_output import to_fastmcp from mcp_runtime.server import load_credential_headers TOOLSETS_DIR = Path(__file__).resolve().parents[3] / "toolsets" @@ -35,3 +37,7 @@ def test_toolset_contract(toolset): assert tool.description and tool.description.strip(), ( f"{toolset}: tool {tool.name!r} needs a non-empty docstring" ) + # Raises when a tool's return annotation breaks the ToolResult + # contract — the same gate build_server applies at startup. + converted = to_fastmcp(tool) + assert converted.output_schema is not None diff --git a/packages/mcp-runtime/tests/test_fastmcp_output.py b/packages/mcp-runtime/tests/test_fastmcp_output.py new file mode 100644 index 0000000..2b399d7 --- /dev/null +++ b/packages/mcp-runtime/tests/test_fastmcp_output.py @@ -0,0 +1,166 @@ +"""The extended to_fastmcp: derived output schemas and the ToolResult gate.""" + +from typing import Any, NotRequired, TypedDict + +import pytest +from langchain_core.tools import tool +from pydantic import BaseModel + +from mcp_runtime.fastmcp_output import to_fastmcp +from mcp_runtime.tool_result import ToolError, ToolResult + + +class ProbeResult(ToolResult): + items: NotRequired[list[dict[str, Any]]] + + +@tool +async def probe(query: str) -> ProbeResult | ToolError: + """Return items for a query.""" + if query == "boom": + return ToolError(error="bad_query", detail="boom") + if query == "empty": + return ProbeResult(message="Nothing found.") + return ProbeResult(message=f"Found 1 for {query!r}.", items=[{"id": query}]) + + +@tool +def message_only(text: str) -> ToolResult: + """Echo the text (sync tool).""" + return ToolResult(message=text) + + +async def run_structured(converted, arguments: dict[str, Any]) -> Any: + """The structuredContent FastMCP would emit for a call.""" + result = await converted.run(arguments, convert_result=True) + assert isinstance(result, tuple), "no structured content was produced" + _unstructured, structured = result + return structured + + +def test_output_schema_is_object_rooted_union(): + schema = to_fastmcp(probe).output_schema + assert schema is not None + assert schema["type"] == "object" + arms = [ref["$ref"].removeprefix("#/$defs/") for ref in schema["anyOf"]] + assert set(arms) == {"ProbeResult", "ToolError"} + result_arm = schema["$defs"]["ProbeResult"] + assert result_arm["properties"]["message"]["type"] == "string" + assert "message" in result_arm["required"] + assert schema["$defs"]["ToolError"]["required"] == ["error", "detail"] + + +def test_single_arm_schema_is_inlined(): + schema = to_fastmcp(message_only).output_schema + assert schema is not None + assert "$ref" not in schema + assert schema["type"] == "object" + assert "message" in schema["required"] + + +async def test_structured_content_is_the_returned_dict(): + converted = to_fastmcp(probe) + structured = await run_structured(converted, {"query": "era5"}) + assert structured == {"message": "Found 1 for 'era5'.", "items": [{"id": "era5"}]} + + +async def test_absent_optional_keys_are_not_null_padded(): + structured = await run_structured(to_fastmcp(probe), {"query": "empty"}) + assert structured == {"message": "Nothing found."} + + +async def test_error_returns_conform_to_the_schema(): + structured = await run_structured(to_fastmcp(probe), {"query": "boom"}) + assert structured == {"error": "bad_query", "detail": "boom"} + + +async def test_sync_tool_supported(): + structured = await run_structured(to_fastmcp(message_only), {"text": "hi"}) + assert structured == {"message": "hi"} + + +async def test_undeclared_keys_are_dropped_from_structured_content(): + # The annotation is the complete contract: keys a tool sneaks in without + # declaring them do not reach clients as structured content. + @tool + async def sneaky(text: str) -> ToolResult: + """Echo the text.""" + result = ToolResult(message=text) + result["extra"] = "undeclared" # type: ignore[typeddict-unknown-key] + return result + + structured = await run_structured(to_fastmcp(sneaky), {"text": "hi"}) + assert structured == {"message": "hi"} + + +def test_basemodel_with_required_message_accepted(): + class ModelResult(BaseModel): + message: str + count: int = 0 + + @tool + async def modeled(text: str) -> ModelResult: + """Echo the text.""" + return ModelResult(message=text) + + schema = to_fastmcp(modeled).output_schema + assert schema is not None + assert "message" in schema["required"] + + +@pytest.mark.parametrize( + ("annotation", "body"), + [ + (str, "text"), + (dict[str, Any], {"message": "hi"}), + (list[str], ["hi"]), + (None, "text"), + ], + ids=["str", "dict-str-any", "list", "missing"], +) +def test_non_contract_annotations_rejected(annotation, body): + async def loose(text: str): # noqa: ANN202 - annotation applied below + """Echo the text.""" + return body + + loose.__annotations__["return"] = annotation + if annotation is None: + del loose.__annotations__["return"] + with pytest.raises(RuntimeError, match="loose"): + to_fastmcp(tool(loose)) + + +def test_typeddict_without_message_rejected(): + class NoMessage(TypedDict): + data: str + + @tool + async def messageless(text: str) -> NoMessage: + """Echo the text.""" + return NoMessage(data=text) + + with pytest.raises(RuntimeError, match="required str 'message'"): + to_fastmcp(messageless) + + +def test_non_string_message_rejected(): + class NumericMessage(TypedDict): + message: int + + @tool + async def numeric(text: str) -> NumericMessage: + """Echo the text length.""" + return NumericMessage(message=len(text)) + + with pytest.raises(RuntimeError, match="required str 'message'"): + to_fastmcp(numeric) + + +def test_union_with_non_dict_arm_rejected(): + @tool + async def mixed(text: str) -> ToolResult | str: + """Echo the text.""" + return text + + with pytest.raises(RuntimeError, match="mixed"): + to_fastmcp(mixed) diff --git a/packages/mcp-runtime/tests/test_server.py b/packages/mcp-runtime/tests/test_server.py index 8876eba..2b39f65 100644 --- a/packages/mcp-runtime/tests/test_server.py +++ b/packages/mcp-runtime/tests/test_server.py @@ -12,11 +12,18 @@ load_tools, toolset_module_name, ) +from mcp_runtime.tool_result import ToolResult @tool -def echo(text: str) -> str: +def echo(text: str) -> ToolResult: """Echo the text back.""" + return ToolResult(message=text) + + +@tool +def bare_echo(text: str) -> str: + """Echo the text back without following the ToolResult contract.""" return text @@ -102,3 +109,17 @@ async def test_build_server_module_override(monkeypatch): server = build_server("anything", module_name=name) tools = await server.list_tools() assert {tool.name for tool in tools} == {"echo"} + + +async def test_build_server_advertises_output_schema(monkeypatch): + tools_module(monkeypatch, "schema_toolset.tools", TOOLS=[echo]) + server = build_server("schema-toolset") + (listed,) = await server.list_tools() + assert listed.outputSchema is not None + assert "message" in listed.outputSchema["required"] + + +def test_build_server_rejects_non_contract_tool(monkeypatch): + tools_module(monkeypatch, "loose_toolset.tools", TOOLS=[bare_echo]) + with pytest.raises(RuntimeError, match="bare_echo"): + build_server("loose-toolset") diff --git a/scripts/new-toolset b/scripts/new-toolset index a369b60..7776b22 100755 --- a/scripts/new-toolset +++ b/scripts/new-toolset @@ -57,11 +57,13 @@ cat > "$dir/src/$pkg/tools.py" < str: +def example(query: str) -> ToolResult: """TODO: describe what this tool does (the docstring is the MCP schema).""" - return f"you said: {query}" + return ToolResult(message=f"you said: {query}") TOOLS = [example] @@ -72,7 +74,7 @@ from $pkg.tools import example def test_example(): - assert example.invoke({"query": "hi"}) == "you said: hi" + assert example.invoke({"query": "hi"}) == {"message": "you said: hi"} EOF uv add "$name" diff --git a/toolsets/credential-demo/src/credential_demo/tools.py b/toolsets/credential-demo/src/credential_demo/tools.py index 75568e9..2ec75cb 100644 --- a/toolsets/credential-demo/src/credential_demo/tools.py +++ b/toolsets/credential-demo/src/credential_demo/tools.py @@ -9,19 +9,26 @@ import hashlib import logging -from typing import Any +from typing import NotRequired from langchain_core.tools import tool from mcp_runtime.credentials import MissingCredentialError, credential_from_header +from mcp_runtime.tool_result import ToolResult logger = logging.getLogger(__name__) DEMO_TOKEN_HEADER = "x-demo-token" # noqa: S105 - the header's name, not a secret +class WhoamiResult(ToolResult): + """The resolved account id, also stated in the message.""" + + account: NotRequired[str] + + @tool -def whoami() -> dict[str, Any]: +def whoami() -> WhoamiResult: """Report which account the calling user's credential belongs to. Requires the caller's token in the `x-demo-token` HTTP header of the MCP @@ -38,8 +45,11 @@ def whoami() -> dict[str, Any]: "whoami: %s credential present (%d chars)", DEMO_TOKEN_HEADER, len(token) ) # Stub: derive a stable account id instead of calling an upstream API. - account = hashlib.sha256(token.encode()).hexdigest()[:8] - return {"account": f"user-{account}", "status": "ok"} + account = f"user-{hashlib.sha256(token.encode()).hexdigest()[:8]}" + return WhoamiResult( + message=f"The caller's credential belongs to account {account}.", + account=account, + ) TOOLS = [whoami] diff --git a/toolsets/credential-demo/tests/test_credential_demo.py b/toolsets/credential-demo/tests/test_credential_demo.py index dcb0a44..6e869df 100644 --- a/toolsets/credential-demo/tests/test_credential_demo.py +++ b/toolsets/credential-demo/tests/test_credential_demo.py @@ -7,8 +7,9 @@ def test_account_reported_with_credential(): with header_context({DEMO_TOKEN_HEADER: "secret"}): result = whoami.invoke({}) - assert result["status"] == "ok" + assert "belongs to account user-" in result["message"] assert result["account"].startswith("user-") + assert result["account"] in result["message"] def test_account_stable_per_user(): diff --git a/toolsets/hello/src/hello/tools.py b/toolsets/hello/src/hello/tools.py index 75ee255..b27fd0c 100644 --- a/toolsets/hello/src/hello/tools.py +++ b/toolsets/hello/src/hello/tools.py @@ -7,11 +7,13 @@ from langchain_core.tools import tool +from mcp_runtime.tool_result import ToolResult + @tool -def hello(name: str = "world") -> str: +def hello(name: str = "world") -> ToolResult: """Return a friendly greeting (docstrings and type hints ARE the MCP schema).""" - return f"Hello, {name}!" + return ToolResult(message=f"Hello, {name}!") TOOLS = [hello] diff --git a/toolsets/hello/tests/test_hello.py b/toolsets/hello/tests/test_hello.py index 0cea10b..10cc916 100644 --- a/toolsets/hello/tests/test_hello.py +++ b/toolsets/hello/tests/test_hello.py @@ -2,8 +2,8 @@ def test_hello_default(): - assert hello.invoke({}) == "Hello, world!" + assert hello.invoke({}) == {"message": "Hello, world!"} def test_hello_name(): - assert hello.invoke({"name": "dev"}) == "Hello, dev!" + assert hello.invoke({"name": "dev"}) == {"message": "Hello, dev!"} diff --git a/uv.lock b/uv.lock index dc83559..b25e546 100644 --- a/uv.lock +++ b/uv.lock @@ -1057,6 +1057,7 @@ dependencies = [ { name = "langchain-mcp-adapters" }, { name = "mcp" }, { name = "pydantic-settings" }, + { name = "typing-extensions" }, ] [package.metadata] @@ -1066,6 +1067,7 @@ requires-dist = [ { name = "langchain-mcp-adapters", specifier = ">=0.3.0,<0.4.0" }, { name = "mcp", specifier = ">=1.27.2,<2.0.0" }, { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, + { name = "typing-extensions", specifier = ">=4.10.0" }, ] [[package]] From 832bf511f6dc6b01d54ea6a33ded4186ff34f1a2 Mon Sep 17 00:00:00 2001 From: Ciaran Sweet Date: Wed, 15 Jul 2026 10:49:58 +0100 Subject: [PATCH 2/2] Trim verbose comments and docstrings Co-Authored-By: Claude Fable 5 --- .../src/mcp_runtime/fastmcp_output.py | 38 +++++++------------ .../src/mcp_runtime/tool_result.py | 17 ++++----- packages/mcp-runtime/tests/test_contract.py | 3 +- .../mcp-runtime/tests/test_fastmcp_output.py | 2 - 4 files changed, 21 insertions(+), 39 deletions(-) diff --git a/packages/mcp-runtime/src/mcp_runtime/fastmcp_output.py b/packages/mcp-runtime/src/mcp_runtime/fastmcp_output.py index 3bc58f0..e1cd1ba 100644 --- a/packages/mcp-runtime/src/mcp_runtime/fastmcp_output.py +++ b/packages/mcp-runtime/src/mcp_runtime/fastmcp_output.py @@ -1,26 +1,16 @@ """Upstream ``to_fastmcp`` plus an output schema from the return annotation. -langchain-mcp-adapters' ``to_fastmcp`` discards the tool's return annotation, -so FastMCP never advertises an ``outputSchema`` and every result reaches -clients as a JSON text block only. This wrapper derives the output model from -the annotation and enforces the :mod:`mcp_runtime.tool_result` contract, so -results also travel as MCP ``structuredContent``. - -Supported annotations: a TypedDict or pydantic model, or a union of them -(``SearchDatasetsResult | ToolError``) — the shapes that map 1:1 onto -``structuredContent``. Anything else (unions with non-dict arms, bare lists, -primitives, ``dict[str, Any]``, no annotation) is refused: FastMCP would -either wrap the value in ``{"result": ...}``, changing the payload shape, or -the schema would guarantee nothing. At least one annotation arm must offer a -required str ``message``. A refused tool raises at conversion, aborting -``build_server`` — the contract fails at deploy time, not silently at chat -time. - -Validation happens on every call: FastMCP validates the returned dict against -the model before emitting ``structuredContent``. Keys not declared in the -annotation are dropped from the structured payload (pydantic's TypedDict -semantics), so a tool's annotation is the complete list of keys a client can -see. +langchain-mcp-adapters' ``to_fastmcp`` discards the return annotation, so +FastMCP never advertises an ``outputSchema`` and results reach clients as +JSON text only. This wrapper derives the output model from the annotation — +a TypedDict/BaseModel, or a union of them, with a required str ``message`` +on at least one arm (the :mod:`mcp_runtime.tool_result` contract) — so +results also travel as ``structuredContent``. Any other annotation raises at +conversion, aborting ``build_server``: FastMCP would wrap such values in +``{"result": ...}`` or the schema would guarantee nothing. + +FastMCP validates every returned dict against the model; undeclared keys are +dropped, so the annotation is the complete list of keys a client can see. """ from types import UnionType @@ -62,12 +52,10 @@ def _resolve(schema: dict[str, Any], defs: dict[str, Any]) -> dict[str, Any]: def _shape_schema(schema: dict[str, Any]) -> dict[str, Any]: - """The advertised schema, object-rooted. + """The advertised schema, object-rooted as MCP clients expect. ``RootModel`` schemas root at a ``$ref`` (single model) or an ``anyOf`` - (union). Inline the former; stamp ``"type": "object"`` on the latter — - every arm is an object, and MCP clients expect an object-rooted - ``outputSchema``. + (union): inline the former, stamp ``"type": "object"`` on the latter. """ defs = dict(schema.get("$defs", {})) if schema.get("$ref", "").startswith("#/$defs/"): diff --git a/packages/mcp-runtime/src/mcp_runtime/tool_result.py b/packages/mcp-runtime/src/mcp_runtime/tool_result.py index 77c515a..1441400 100644 --- a/packages/mcp-runtime/src/mcp_runtime/tool_result.py +++ b/packages/mcp-runtime/src/mcp_runtime/tool_result.py @@ -3,14 +3,12 @@ A tool returns one dict per call, in one of two shapes: - :class:`ToolResult` — success: a ``message`` (the text the model reads) - plus any data keys the tool declares, which the agent captures into - session state. -- :class:`ToolError` — a structured error: a short machine-readable - ``error`` kind and a ``detail`` saying what happened or what to do next. - The agent passes these through to the model untouched. + plus any data keys the tool declares, captured into session state. +- :class:`ToolError` — failure: a machine-readable ``error`` kind and a + ``detail`` saying what happened or what to do next. -Subclass :class:`ToolResult` per tool, declaring each data key as -``NotRequired``, and annotate the ``@tool`` function with the union:: +Subclass :class:`ToolResult` per tool, one ``NotRequired`` field per data +key, and annotate the ``@tool`` function with the union:: class SearchDatasetsResult(ToolResult): datasets: NotRequired[list[dict[str, Any]]] @@ -20,9 +18,8 @@ async def search_datasets(query: str) -> SearchDatasetsResult | ToolError: ... ``mcp_runtime.fastmcp_output`` derives the tool's MCP ``outputSchema`` from -the annotation, validates every result against it, and refuses to serve a -tool whose schema does not offer a required str ``message`` — the contract -is a deploy-time gate, not a convention. +the annotation and refuses to serve a tool that breaks the contract — a +deploy-time gate, not a convention. """ from typing import Any, TypedDict diff --git a/packages/mcp-runtime/tests/test_contract.py b/packages/mcp-runtime/tests/test_contract.py index 76f057f..f28bfe7 100644 --- a/packages/mcp-runtime/tests/test_contract.py +++ b/packages/mcp-runtime/tests/test_contract.py @@ -37,7 +37,6 @@ def test_toolset_contract(toolset): assert tool.description and tool.description.strip(), ( f"{toolset}: tool {tool.name!r} needs a non-empty docstring" ) - # Raises when a tool's return annotation breaks the ToolResult - # contract — the same gate build_server applies at startup. + # The same ToolResult gate build_server applies at startup. converted = to_fastmcp(tool) assert converted.output_schema is not None diff --git a/packages/mcp-runtime/tests/test_fastmcp_output.py b/packages/mcp-runtime/tests/test_fastmcp_output.py index 2b399d7..01e2617 100644 --- a/packages/mcp-runtime/tests/test_fastmcp_output.py +++ b/packages/mcp-runtime/tests/test_fastmcp_output.py @@ -80,8 +80,6 @@ async def test_sync_tool_supported(): async def test_undeclared_keys_are_dropped_from_structured_content(): - # The annotation is the complete contract: keys a tool sneaks in without - # declaring them do not reach clients as structured content. @tool async def sneaky(text: str) -> ToolResult: """Echo the text."""