From 672d53796f80cc0bad401d47021c1edf8618e7af Mon Sep 17 00:00:00 2001 From: Eric Law <39393654+acn-ericlaw@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:35:56 +0800 Subject: [PATCH 1/2] feat(mcp-binding): one composable route per allowlisted MCP tool, on the official MCP SDK The MCP binding (extensions/mcp-binding) is step 4 of RFC-0011 / ADR-0033: a polyglot function host that serves the tools of MCP servers as composable routes. Servers are named in resources/mcp-servers.yml, each with its transport (stdio, a subprocess of this process with the SDK's minimal environment plus the listed variables, or http, a streamable HTTP URL with headers), its credential references (${VAR}, resolved at boot and never logged) and its allowlist mapping each remote tool to a route and the SHA-256 digest of its input schema. Boot connects each server inside the serving loop, lists its tools and refuses to start by name when a tool is missing or its schema changed; a route's reply is the structured content, else the blocks' text; isError is a 400 carrying the server's text, a timeout 408, a protocol failure 502, a lost session 503. `describe` prints each tool's digest and a catalog row for the graph. mcp.health reports the servers and their connections. Platform gains on_startup, on_cleanup and create_app, so an extension can do asynchronous boot work before the first request. The mcp extra pins mcp>=2,<3. The shared vector file tests/vectors/mcp-binding-vectors.json pins the digest, the verification and the result mapping for both packs; the fixture server of tests/fixtures drives both transports; 46 tests, the full suite 397 passed. The HTTP transport was also driven against a public MCP server (the Mermaid Chart endpoint). Memory: session 2026-11-14 [Claude Code] - the fact mcp-binding-route-per-tool and the thread mcp-binding. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 22 + README.md | 3 +- extensions/mcp-binding/README.md | 151 +++++ extensions/mcp-binding/mcp_binding.py | 561 ++++++++++++++++++ .../mcp-binding/resources/application.yml | 39 ++ .../mcp-binding/resources/mcp-servers.yml | 47 ++ memory/continuity.md | 16 + memory/open-threads/thread-mcp-binding.md | 8 + memory/sessions/2026-11-14-073523.md | 55 ++ pyproject.toml | 4 + src/mercury_composable/server.py | 49 +- tests/fixtures/mcp_test_server.py | 54 ++ tests/test_mcp_binding.py | 402 +++++++++++++ tests/vectors/mcp-binding-vectors.json | 522 ++++++++++++++++ 14 files changed, 1923 insertions(+), 10 deletions(-) create mode 100644 extensions/mcp-binding/README.md create mode 100644 extensions/mcp-binding/mcp_binding.py create mode 100644 extensions/mcp-binding/resources/application.yml create mode 100644 extensions/mcp-binding/resources/mcp-servers.yml create mode 100644 memory/open-threads/thread-mcp-binding.md create mode 100644 memory/sessions/2026-11-14-073523.md create mode 100644 tests/fixtures/mcp_test_server.py create mode 100644 tests/test_mcp_binding.py create mode 100644 tests/vectors/mcp-binding-vectors.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bf8384f..978bb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,28 @@ `tools`, and on `llm.stream` any of them is a 400. A loop turn needs `llm.transcript.store`; the sample configuration sets `memory`. +3. **The MCP binding: `extensions/mcp-binding/`, one composable route per allowlisted MCP tool (RFC-0011 / ADR-0033, + build plan step 4).** A polyglot function host on the official MCP SDK (`mcp` 2.x, the new `mcp` extra: + `pip install 'mercury-composable[mcp]'`). Servers are named in `resources/mcp-servers.yml`, each with its transport + (`stdio` with a command this process starts, or `http` with a streamable HTTP URL), its credential references + (`${VAR}` placeholders resolved from the environment at boot, never logged; a stdio subprocess gets the SDK's minimal + environment plus the variables listed), and its allowlist mapping each remote tool name to a route and the SHA-256 + digest of the tool's input schema in canonical JSON. Boot connects every server inside the serving loop, lists its + tools, and refuses to start - by name - when an allowlisted tool is missing or its schema no longer matches the + digest, so a server-side change stops a deploy instead of reaching a model at run time. A route's body is the tool's + arguments; its reply is the server's structured content, else the text of its content blocks; a result marked + `isError` is a 400 carrying the server's text (the agent loop returns it to the model as that call's error), a slow + call a 408, a protocol failure a 502, a lost session a 503. `python mcp_binding.py describe [server]` prints each + tool's digest and a catalog row for the graph, for the developer to review: the model reads the graph's descriptions, + never the server's. `mcp.health` reports each server and its connection. The shared vector file + `tests/vectors/mcp-binding-vectors.json` (the Node.js twin runs the same bytes) pins the digest, the allowlist + verification and the result mapping; the rest of the tests drive a fixture MCP server of this repository over both + transports, and the HTTP transport was driven on 2026-11-14 against a public MCP server (the Mermaid Chart endpoint): `describe` + listed its tool with the digest, and a bound route returned the server's structured content. **With it, `Platform` gains `on_startup(callback)` and `on_cleanup(callback)`** - async callbacks run + inside the server's event loop before the first request and at shutdown, the place for an extension's asynchronous + boot work - and `Platform.create_app()`, the hook-bearing application `run` serves and a test may serve with + aiohttp's runner. **Upgrade note:** none - nothing changes for an application that registers no callback. + ### Changed 1. **The `resources` folder is the one next to the application file; the working directory is never searched.** diff --git a/README.md b/README.md index eaae426..2456bc6 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,8 @@ A function always runs in this process, never in a subprocess or an embedded int The one subprocess the pack may start is an **MCP server reached over stdio**: the pack runs it as a child process and talks to it over its standard input and output. That is why the MCP clients of the engines' agent loop (RFC-0011) live in the language packs: no engine starts a -child process. The stdio MCP binding itself is still to come. +child process. The MCP binding in `extensions/mcp-binding/` is that client: one composable route per +allowlisted remote tool, over stdio or streamable HTTP, on the official MCP SDK. The LLM helper in `extensions/llm-helper/` is an application on this package, not part of it. Its `llm.chat` is configurable: a stateless chat, or one turn of a multi-turn tool-use loop whose diff --git a/extensions/mcp-binding/README.md b/extensions/mcp-binding/README.md new file mode 100644 index 0000000..625e1a2 --- /dev/null +++ b/extensions/mcp-binding/README.md @@ -0,0 +1,151 @@ +# The MCP binding + +A polyglot function host that serves the tools of [MCP](https://modelcontextprotocol.io) servers as composable routes, +one route per allowlisted remote tool - adapter family B of the AI agent orchestration concept, built as step 4 of +RFC-0011 / ADR-0033 (the agent loop). A graph's agent loop (`llm.run` in the engines) names a route in its tool +catalog; the engine reaches the route through declarative Event-over-HTTP like any other function; this app issues +`tools/call` to the server behind it and returns the result. The engines stay MCP-free: a stdio server runs as a +subprocess of this process, never of an engine, and the server's credentials stay in this process's environment. + +The client library is the official MCP SDK (`mcp` on PyPI, 2.x). The Node.js twin (mercury-nodejs +`extensions/mcp-binding`) speaks the same contract, and both are pinned by the shared vector file +`tests/vectors/mcp-binding-vectors.json`. + +## Run + +```bash +pip install 'mercury-composable[mcp]' +mercury-serve extensions/mcp-binding/mcp_binding.py +python extensions/mcp-binding/mcp_binding.py # the same without mercury-serve +``` + +The binding reads `resources/application.yml` next to `mcp_binding.py`, whichever directory the command runs from; +`-Dkey=value` overrides any key. It listens on port 8088 by default (`rest.server.port`). With no server configured +it starts and serves no tool route. + +## The server file + +`mcp.servers` names the file (default `mcp-servers.yml` in the resources folder; a relative path is read from there): + +```yaml +servers: + documents: + transport: stdio + command: npx + args: ['-y', '@modelcontextprotocol/server-filesystem', '/srv/documents'] + tools: + read_text_file: + route: docs.read.text.file + schema_sha256: 'c6b1...' + list_directory: + route: docs.list.directory + schema_sha256: '9a0e...' + tracker: + transport: http + url: 'https://mcp.example.internal/mcp' + headers: + Authorization: 'Bearer ${TRACKER_MCP_TOKEN}' + timeout_ms: 20000 + tools: + create_issue: + route: tracker.create.issue + schema_sha256: '41d7...' +``` + +| Key | Meaning | +| --- | --- | +| `transport` | `stdio` (a command this process starts) or `http` (a streamable HTTP endpoint). Required. | +| `command`, `args`, `env`, `cwd` | The stdio server. The subprocess gets the SDK's minimal default environment plus the variables under `env`, each a credential reference such as `${GITHUB_TOKEN}`, resolved from this process's environment at boot. | +| `url`, `headers` | The http server and the headers it is reached with; a header value may be a reference such as `Bearer ${TOKEN}`. | +| `timeout_ms` | How long one `tools/call` may take; default `mcp.call.timeout.ms` (30000), at least 1000. | +| `instances` | How many calls one route serves at once; default 10. | +| `tools` | The allowlist: each remote tool name maps to `route` (a Mercury route name, lowercase with a period) and `schema_sha256`, the digest of the tool's input schema. Required, at least one. | + +Any other key is refused by name, so a misspelt setting never falls back to a default. A reference that resolves to +nothing (`${TOKEN}` with no `TOKEN` set) stops the boot by name, and a resolved value is never logged. A route may be +bound once across all servers. + +### Digests: the server cannot change a tool under the graph + +A graph's catalog row carries the tool's description and input schema, written on the graph and certified with it, and +the model reads the graph's text, never the server's. The digest ties the two together: `schema_sha256` is the SHA-256 of +the tool's input schema in canonical JSON (keys sorted, no spaces, Unicode kept). At boot the binding lists each server's +tools and refuses to start when an allowlisted tool is missing or its schema no longer hashes to the pinned digest, so a +server-side change stops a deploy with its reason instead of becoming run-time errors a model improvises around. Print +the digests, and a catalog row to review before the graph carries it, with: + +```bash +python extensions/mcp-binding/mcp_binding.py describe documents +``` + +```text +# MCP server documents (stdio) offers 14 tool(s) + +## read_text_file - bound as docs.read.text.file (digest ok) +schema_sha256: c6b1... +catalog row (review the description before the graph carries it): +{ + "name": "read_text_file", + "route": "docs.read.text.file", + "description": "...", + "input_schema": { ... } +} +``` + +A tool the server offers but the allowlist omits is shown as `not allowlisted` and is never served. + +## The route contract + +| | | +| --- | --- | +| Request | The event body is the tool's arguments, a map. Anything else is a 400. | +| Reply | The server's structured content when it gives one, else the text of its content blocks joined by newlines; a non-text block is named by its type (`[image]`, `[resource_link] file:///...`). | +| 400 | The server marked the result an error (`isError`): the reply carries the server's text. The agent loop returns it to the model as that call's error, so the model reads it and chooses another step. | +| 408 | The server did not answer within `timeout_ms`. | +| 502 | The server answered outside the protocol, or the SDK reported a protocol error. | +| 503 | The server is not connected (its process ended, or the endpoint is gone). | + +The routes are ordinary composable functions: a Java or Rust engine maps them in its `yaml.event.over.http` file to this +app's `/api/event`, and a graph's catalog row names the route: + +```yaml +event: + http: + - route: 'docs.read.text.file' + target: 'http://127.0.0.1:8088/api/event' +``` + +```json +{ "name": "read_text_file", "route": "docs.read.text.file", "strict": true, + "description": "Read a document of the store's policy folder by its path.", + "input_schema": { "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] } } +``` + +One session per server is shared by its routes, held by a task of its own for the life of the process. The health route +`mcp.health` (the `/health` actuator, `mandatory.health.dependencies`) reports each server, its transport, its tool count +and whether it is connected, and answers 503 naming a server whose session is gone. + +## Security + +- **No engine runs a child process.** The MCP client, the subprocess and the credentials live here, in the pack, behind + the engine's Event-over-HTTP map (the agent loop's design spec in the mercury-composable repository, + `draft-design-specs/llm-helper-agent-loop.md`, section 9). +- **Credentials are references**, resolved from the environment at boot and never logged. The subprocess receives the + SDK's minimal default environment plus the variables listed, not this process's whole environment. +- **The allowlist is the surface.** A tool that is not listed is not served; a listed tool whose schema changed is not + served either, until the digest is reviewed and updated. +- **The model never reads the server's descriptions.** The catalog row on the graph is the developer's text, reviewed + through `describe`, so injection through a tool description is closed by construction. + +## Tests + +```bash +pip install -e '.[dev]' +pytest tests/test_mcp_binding.py +``` + +The shared vector file pins the pure half (the digest, the allowlist verification, the result mapping) in both packs; +the rest of the tests drive a small MCP server of this repository (`tests/fixtures/mcp_test_server.py`, on the official +SDK) over stdio and over streamable HTTP: the boot that registers one route per tool and fails fast by name, the calls, +the credential references that reach the subprocess and never a log, the health route, the describe output, and the +platform's startup hook that runs the boot before the first request. diff --git a/extensions/mcp-binding/mcp_binding.py b/extensions/mcp-binding/mcp_binding.py new file mode 100644 index 0000000..9915782 --- /dev/null +++ b/extensions/mcp-binding/mcp_binding.py @@ -0,0 +1,561 @@ +""" +The MCP binding - a polyglot function host that serves the tools of MCP servers as composable +routes, one route per allowlisted remote tool (RFC-0011 / ADR-0033, section 9 of the agent loop's +design spec). The engines stay MCP-free: a graph's agent loop (``llm.run``) names a route in its +tool catalog, the engine reaches it through declarative Event-over-HTTP like any other function, +and this app issues ``tools/call`` to the server behind it. A stdio server runs as a subprocess of +this process, never of an engine, and its credentials stay in this process's environment. + +Run: pip install 'mercury-composable[mcp]' + mercury-serve extensions/mcp-binding/mcp_binding.py + +Servers are named in ``resources/mcp-servers.yml`` (the ``mcp.servers`` key of ``application.yml`` +names the file): each has its transport (``stdio`` with a command, or ``http`` with a URL), its +credential references (``${ENV_VAR}`` placeholders, resolved from this process's environment at +boot and never logged), and its allowlist mapping each remote tool to a route with the SHA-256 +digest of the tool's input schema. Boot fails fast when an allowlisted tool is missing from +``tools/list`` or its schema no longer matches the digest, so a server-side change stops a deploy +instead of becoming run-time errors a model improvises around. + + python extensions/mcp-binding/mcp_binding.py describe [server ...] + +prints each server's tools with their digests and a catalog row to paste onto the graph, for the +developer to review: the model sees the graph's descriptions, never the server's. + +The Node.js twin (mercury-nodejs extensions/mcp-binding) speaks the same contract, and both are +pinned by the shared vector file tests/vectors/mcp-binding-vectors.json. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import re +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +import yaml + +try: + import httpx2 + from mcp import ClientSession, types + from mcp.client.stdio import StdioServerParameters, get_default_environment, stdio_client + from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client + from mcp.shared.exceptions import MCPError +except ImportError as missing_sdk: # the SDK is this app's one dependency - teach the fix + raise SystemExit( + "The MCP binding needs the MCP SDK - pip install 'mercury-composable[mcp]'" + ) from missing_sdk + +from mercury_composable import ( + AppException, + Body, + app_config, + default_registry, + get_logger, + platform, + preload, +) +from mercury_composable.registry import validate_route + +log = get_logger(__name__) + +# --- the contract's defaults and limits ---------------------------------------------- + +DEFAULT_SERVERS_FILE = "mcp-servers.yml" +DEFAULT_CALL_TIMEOUT_MS = 30000 +DEFAULT_INSTANCES = 10 +MIN_CALL_TIMEOUT_MS = 1000 +TRANSPORT_STDIO = "stdio" +TRANSPORT_HTTP = "http" +TRANSPORTS = (TRANSPORT_STDIO, TRANSPORT_HTTP) +SERVER_KEYS = ("transport", "command", "args", "env", "cwd", "url", "headers", "timeout_ms", + "instances", "tools") +TOOL_KEYS = ("route", "schema_sha256") +# the MCP specification's recommendation for a tool name +TOOL_NAME = re.compile(r"[A-Za-z0-9_.-]{1,128}") +DIGEST = re.compile(r"[0-9a-f]{64}") +# the API-safe name a graph's catalog row may use for a route, derived for the describe output +API_SAFE = re.compile(r"[^a-zA-Z0-9_-]") + + +class BindingError(Exception): + """A configuration or boot failure: the application must not start.""" + + +@dataclass(frozen=True) +class ToolBinding: + """One allowlisted remote tool and the route that serves it.""" + name: str + route: str + schema_sha256: str + + +@dataclass(frozen=True) +class ServerSpec: + """One configured MCP server.""" + name: str + transport: str + tools: tuple[ToolBinding, ...] + command: str | None = None + args: tuple[str, ...] = () + env: dict[str, str] = field(default_factory=dict) + cwd: str | None = None + url: str | None = None + headers: dict[str, str] = field(default_factory=dict) + timeout_ms: int = DEFAULT_CALL_TIMEOUT_MS + instances: int = DEFAULT_INSTANCES + + +# --- the pure half: digests, verification and the result mapping (shared vectors) ----------- + + +def schema_digest(schema: Any) -> str: + """The SHA-256 of a tool's input schema in canonical JSON: keys sorted, no spaces, Unicode + kept. The same bytes in both packs, so a digest pinned by one is checked by the other.""" + canonical = json.dumps(schema, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def verify(spec: ServerSpec, offered: list[dict[str, Any]]) -> list[tuple[ToolBinding, dict[str, Any]]]: + """Match the allowlist against the tools a server offers (``tools/list`` as plain maps with + ``name``, ``description`` and ``input_schema``). Every allowlisted tool must be offered and its + input schema must hash to the pinned digest; the first failure names the tool.""" + by_name = {str(tool.get("name")): tool for tool in offered} + bound: list[tuple[ToolBinding, dict[str, Any]]] = [] + for binding in spec.tools: + tool = by_name.get(binding.name) + if tool is None: + raise BindingError( + f"MCP server {spec.name} does not offer the tool {binding.name} - " + f"it offers {', '.join(sorted(by_name)) or 'no tools'}") + digest = schema_digest(tool.get("input_schema")) + if digest != binding.schema_sha256: + raise BindingError( + f"MCP server {spec.name} changed the input schema of {binding.name} - " + f"its digest is now {digest}, the configuration pins {binding.schema_sha256}") + bound.append((binding, tool)) + return bound + + +def tool_result(result: dict[str, Any]) -> Any: + """The reply of a route from a ``CallToolResult`` as a plain map (``content``, + ``structured_content``, ``is_error``): the structured content when the server gives it, else the + text of the content blocks joined by newlines; a result marked ``is_error`` is a 400 carrying the + server's text, which the agent loop returns to the model as that call's error.""" + content = result.get("content") or [] + texts: list[str] = [] + for block in content: + block_type = block.get("type") + if block_type == "text": + texts.append(str(block.get("text", ""))) + elif block_type in ("resource_link", "resource"): + resource = block.get("resource") or block + texts.append(f"[{block_type}] {resource.get('uri', '')}".rstrip()) + else: + texts.append(f"[{block_type}]") + text = "\n".join(texts) + if result.get("is_error"): + raise AppException(400, text or "The tool reported an error without a message") + structured = result.get("structured_content") + if structured is not None: + return structured + return text + + +def as_plain(result: types.CallToolResult) -> dict[str, Any]: + """The SDK's result as the plain map the shared contract speaks.""" + return { + "content": [block.model_dump(mode="json", by_alias=False) for block in result.content], + "structured_content": result.structured_content, + "is_error": bool(result.is_error), + } + + +def tool_as_plain(tool: types.Tool) -> dict[str, Any]: + return {"name": tool.name, "description": tool.description or "", "input_schema": tool.input_schema} + + +# --- configuration ---------------------------------------------------------------------- + + +def _resolve(value: Any) -> Any: + """``${VAR}`` and ``${VAR:default}`` placeholders from the environment or the configuration, the + pack's rule for every setting; the resolved value is never logged.""" + if isinstance(value, str): + return app_config().resolve_text(value) + return value + + +def _str_map(server: str, key: str, value: Any) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict): + raise BindingError(f"MCP server {server}: {key} must be a map of names to values") + resolved: dict[str, str] = {} + for name, item in value.items(): + text = _resolve(item) + if text is None or text == "": + raise BindingError(f"MCP server {server}: {key}.{name} resolves to nothing - set the variable it names") + resolved[str(name)] = str(text) + return resolved + + +def _tools(server: str, value: Any) -> tuple[ToolBinding, ...]: + if not isinstance(value, dict) or not value: + raise BindingError(f"MCP server {server}: tools must map at least one remote tool name to its route and digest") + bindings: list[ToolBinding] = [] + routes: set[str] = set() + for name, item in value.items(): + name = str(name) + if not TOOL_NAME.fullmatch(name): + raise BindingError(f"MCP server {server}: '{name}' is not a tool name (letters, digits, '_', '.' and '-', up to 128)") + if not isinstance(item, dict): + raise BindingError(f"MCP server {server}: tool {name} must be a map with route and schema_sha256") + unknown = sorted(set(item) - set(TOOL_KEYS)) + if unknown: + raise BindingError(f"MCP server {server}: tool {name} has unknown keys {', '.join(unknown)} - the keys are route and schema_sha256") + try: + route = validate_route(str(item.get("route") or "")) + except ValueError as exc: + raise BindingError(f"MCP server {server}: tool {name}: {exc}") from exc + if route in routes: + raise BindingError(f"MCP server {server}: the route {route} is bound twice") + routes.add(route) + digest = str(item.get("schema_sha256") or "").lower() + if not DIGEST.fullmatch(digest): + raise BindingError( + f"MCP server {server}: tool {name} needs schema_sha256, the 64-hex digest of its input schema " + f"- run 'python mcp_binding.py describe {server}' to print it") + bindings.append(ToolBinding(name=name, route=route, schema_sha256=digest)) + return tuple(bindings) + + +def _server(name: str, value: Any, default_timeout_ms: int) -> ServerSpec: + if not isinstance(value, dict): + raise BindingError(f"MCP server {name} must be a map") + unknown = sorted(set(value) - set(SERVER_KEYS)) + if unknown: + raise BindingError(f"MCP server {name} has unknown keys {', '.join(unknown)} - the keys are {', '.join(SERVER_KEYS)}") + transport = str(value.get("transport") or "") + if transport not in TRANSPORTS: + raise BindingError(f"MCP server {name}: transport must be stdio or http") + timeout_ms = int(value.get("timeout_ms") or default_timeout_ms) + if timeout_ms < MIN_CALL_TIMEOUT_MS: + raise BindingError(f"MCP server {name}: timeout_ms must be at least {MIN_CALL_TIMEOUT_MS}") + instances = int(value.get("instances") or DEFAULT_INSTANCES) + tools = _tools(name, value.get("tools")) + if transport == TRANSPORT_STDIO: + command = _resolve(value.get("command")) + if not command: + raise BindingError(f"MCP server {name}: a stdio server needs a command") + raw_args = value.get("args") or [] + if not isinstance(raw_args, list): + raise BindingError(f"MCP server {name}: args must be a list") + args = tuple(str(_resolve(a)) for a in raw_args) + cwd = _resolve(value.get("cwd")) + if value.get("url") or value.get("headers"): + raise BindingError(f"MCP server {name}: url and headers belong to an http server") + return ServerSpec(name=name, transport=transport, tools=tools, command=str(command), args=args, + env=_str_map(name, "env", value.get("env")), cwd=None if cwd is None else str(cwd), + timeout_ms=timeout_ms, instances=instances) + url = _resolve(value.get("url")) + if not url: + raise BindingError(f"MCP server {name}: an http server needs a url") + if value.get("command") or value.get("args") or value.get("env") or value.get("cwd"): + raise BindingError(f"MCP server {name}: command, args, env and cwd belong to a stdio server") + return ServerSpec(name=name, transport=transport, tools=tools, url=str(url), + headers=_str_map(name, "headers", value.get("headers")), + timeout_ms=timeout_ms, instances=instances) + + +def load_servers(path: str | None = None) -> list[ServerSpec]: + """The server file: ``mcp.servers`` names it (default ``mcp-servers.yml`` in the resources folder + next to this file, or an absolute path).""" + config = app_config() + default_timeout = int(config.get("mcp.call.timeout.ms", DEFAULT_CALL_TIMEOUT_MS)) + file = path or str(config.get("mcp.servers", DEFAULT_SERVERS_FILE)) + if not os.path.isabs(file): + base = config.resources_dir or os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources") + file = os.path.join(base, file) + if not os.path.isfile(file): + raise BindingError(f"MCP server file not found - {file} (mcp.servers names it)") + with open(file, encoding="utf-8") as f: + data = yaml.safe_load(f.read()) or {} + servers = data.get("servers") if isinstance(data, dict) else None + if servers is None: + servers = {} + if not isinstance(servers, dict): + raise BindingError(f"{file} must hold a 'servers' map") + if not servers: + log.warning("No MCP server configured in %s - the binding serves no tool route", file) + specs = [_server(str(name), value, default_timeout) for name, value in servers.items()] + routes: dict[str, str] = {} + for spec in specs: + for binding in spec.tools: + owner = routes.get(binding.route) + if owner: + raise BindingError(f"The route {binding.route} is bound by both {owner} and {spec.name}") + routes[binding.route] = spec.name + return specs + + +# --- one connection per server, held by its own task -------------------------------------- + + +class Server: + """One MCP server: its session lives in a task of its own, so the transport's context managers + are entered and left on one task (the SDK's cancel scopes require it), and every route of the + server shares the session.""" + + def __init__(self, spec: ServerSpec): + self.spec = spec + self.session: ClientSession | None = None + self.bound: list[tuple[ToolBinding, dict[str, Any]]] = [] + self._ready = asyncio.Event() + self._closing = asyncio.Event() + self._task: asyncio.Task[None] | None = None + self._failure: BaseException | None = None + + @property + def connected(self) -> bool: + return self.session is not None + + async def start(self) -> None: + self._task = asyncio.create_task(self._run(), name=f"mcp:{self.spec.name}") + await self._ready.wait() + if self._failure is not None: + raise self._failure + + async def close(self) -> None: + self._closing.set() + if self._task is not None: + try: + await self._task + except Exception: # noqa: BLE001 - a transport closing noisily must not fail the shutdown + log.warning("MCP server %s closed with an error", self.spec.name) + + @asynccontextmanager + async def _transport(self) -> AsyncIterator[tuple[Any, Any]]: + spec = self.spec + if spec.transport == TRANSPORT_STDIO: + env = get_default_environment() + env.update(spec.env) + params = StdioServerParameters(command=str(spec.command), args=list(spec.args), env=env, cwd=spec.cwd) + # the subprocess writes its own logs to this process's real stderr - a replaced + # sys.stderr (a test's capture) has no file descriptor to inherit + errlog = sys.__stderr__ or sys.stderr + async with stdio_client(params, errlog=errlog) as streams: + yield streams + else: + seconds = spec.timeout_ms / 1000 + client = create_mcp_http_client(headers=spec.headers or None, + timeout=httpx2.Timeout(seconds, read=seconds)) + async with client, streamable_http_client(str(spec.url), http_client=client) as streams: + yield streams + + async def _run(self) -> None: + try: + seconds = self.spec.timeout_ms / 1000 + async with ( + self._transport() as (read, write), + ClientSession(read, write, read_timeout_seconds=seconds) as session, + ): + await session.initialize() + offered = await _list_tools(session) + self.bound = verify(self.spec, offered) + self.session = session + self._ready.set() + await self._closing.wait() + except asyncio.CancelledError: + raise + except BaseException as exc: # noqa: BLE001 - reported to the starter, or logged at run time + leaf = _leaf(exc) + if not self._ready.is_set(): + self._failure = leaf + else: + log.warning("MCP server %s disconnected: %s", self.spec.name, leaf) + finally: + self.session = None + self._ready.set() + + async def call(self, name: str, arguments: dict[str, Any]) -> Any: + session = self.session + if session is None: + raise AppException(503, f"MCP server {self.spec.name} is not connected") + try: + result = await session.call_tool(name, arguments, read_timeout_seconds=self.spec.timeout_ms / 1000) + except MCPError as exc: + if "timed out" in str(exc.message).lower() or "timeout" in str(exc.message).lower(): + raise AppException(408, f"MCP server {self.spec.name} did not answer {name} within " + f"{self.spec.timeout_ms} ms") from exc + raise AppException(502, f"MCP server {self.spec.name} failed {name}: {exc.message}") from exc + except TimeoutError as exc: + raise AppException(408, f"MCP server {self.spec.name} did not answer {name} within " + f"{self.spec.timeout_ms} ms") from exc + if not isinstance(result, types.CallToolResult): + raise AppException(502, f"MCP server {self.spec.name} answered {name} outside the tool contract") + return tool_result(as_plain(result)) + + +def _leaf(exc: BaseException) -> BaseException: + """The first leaf of an exception group: the SDK's transports run in a task group, which wraps + the failure of the code inside them.""" + # duck-typed, so Python 3.10 (where the group class is anyio's backport) reads the same + while isinstance(getattr(exc, "exceptions", None), (list, tuple)) and exc.exceptions: # type: ignore[attr-defined] + exc = exc.exceptions[0] # type: ignore[attr-defined] + return exc + + +async def _list_tools(session: ClientSession) -> list[dict[str, Any]]: + """Every page of ``tools/list`` as plain maps.""" + offered: list[dict[str, Any]] = [] + cursor: str | None = None + while True: + params = types.PaginatedRequestParams(cursor=cursor) if cursor else None + page = await session.list_tools(params=params) + offered.extend(tool_as_plain(tool) for tool in page.tools) + cursor = page.next_cursor + if not cursor: + return offered + + +# --- the application: boot, the routes and the health check ------------------------------ + + +class Binding: + def __init__(self) -> None: + self.servers: dict[str, Server] = {} + + async def boot(self) -> None: + """Connect every configured server, verify its allowlist and register the routes - before + the first request; a failure stops the application with its reason.""" + try: + specs = load_servers() + for spec in specs: + server = Server(spec) + await server.start() + self.servers[spec.name] = server + for binding, _tool in server.bound: + default_registry.register(binding.route, _handler(server, binding), instances=spec.instances) + log.info("MCP server %s: tool %s served as %s", spec.name, binding.name, binding.route) + log.info("MCP binding ready - %d server(s), %d route(s)", len(self.servers), + sum(len(s.bound) for s in self.servers.values())) + except Exception as exc: # noqa: BLE001 - any boot failure stops the application, by name + leaf = _leaf(exc) + log.error("MCP binding cannot start - %s", leaf) + await self.shutdown() + raise SystemExit(1) from leaf + + async def shutdown(self) -> None: + for server in list(self.servers.values()): + await server.close() + self.servers.clear() + + +def _handler(server: Server, binding: ToolBinding): + async def call(_headers: dict[str, str], body: Body) -> Any: + if not isinstance(body, dict): + raise AppException(400, f"The arguments of {binding.name} must be a map") + return await server.call(binding.name, body) + call.__name__ = f"mcp_{binding.name}" + return call + + +binding = Binding() + + +@preload(route="mcp.health", instances=5, private=True) +async def mcp_health(headers: dict[str, str], _body: Body) -> Any: + """Health check speaking the engines' interface contract (type=info / type=health): which + servers are configured, how many tools each serves, and whether each is connected.""" + if headers.get("type") == "info": + return { + "service": "mcp.binding", + "href": "http://127.0.0.1", + "servers": [ + {"name": s.spec.name, "transport": s.spec.transport, "tools": len(s.bound), "connected": s.connected} + for s in binding.servers.values() + ], + } + disconnected = [name for name, s in binding.servers.items() if not s.connected] + if disconnected: + raise AppException(503, f"MCP server(s) not connected: {', '.join(disconnected)}") + return "mcp.binding is running fine" + + +# --- describe: the digests and the catalog rows for the developer ------------------------------ + + +def _api_safe(name: str) -> str: + return API_SAFE.sub("_", name)[:128] or "tool" + + +async def _describe(spec: ServerSpec) -> list[dict[str, Any]]: + server = Server(spec) + server_spec_without_check = ServerSpec(**{**spec.__dict__, "tools": ()}) + probe = Server(server_spec_without_check) + await probe.start() + try: + session = probe.session + assert session is not None + offered = await _list_tools(session) + finally: + await probe.close() + del server + return offered + + +def describe(names: list[str]) -> int: + """Print each server's tools, their input-schema digests and a catalog row for the graph.""" + try: + specs = load_servers() + except BindingError as exc: + print(f"Cannot describe - {exc}", file=sys.stderr) + return 1 + wanted = [s for s in specs if not names or s.name in names] + missing = sorted(set(names) - {s.name for s in specs}) + if missing: + print(f"Unknown server(s): {', '.join(missing)} - configured: {', '.join(s.name for s in specs)}", file=sys.stderr) + return 1 + for spec in wanted: + offered = asyncio.run(_describe(spec)) + configured = {b.name: b for b in spec.tools} + print(f"# MCP server {spec.name} ({spec.transport}) offers {len(offered)} tool(s)") + for tool in offered: + name = tool["name"] + digest = schema_digest(tool["input_schema"]) + bound = configured.get(name) + state = ("bound as " + bound.route + (" (digest ok)" if bound.schema_sha256 == digest else " (DIGEST CHANGED)")) if bound else "not allowlisted" + print(f"\n## {name} - {state}\nschema_sha256: {digest}") + row = { + "name": _api_safe(name), + "route": bound.route if bound else "", + "description": tool["description"], + "input_schema": tool["input_schema"], + } + print("catalog row (review the description before the graph carries it):") + print(json.dumps(row, indent=2, ensure_ascii=False)) + return 0 + + +def _main(argv: list[str]) -> int: + # -Dkey=value arguments belong to the configuration, which reads them from sys.argv itself + words = [a for a in argv if not a.startswith("-D")] + if words and words[0] == "describe": + return describe(words[1:]) + platform.run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(_main(sys.argv[1:])) +else: + # loaded by mercury-serve: the servers connect inside the serving loop, before the first request + platform.on_startup(binding.boot) + platform.on_cleanup(binding.shutdown) diff --git a/extensions/mcp-binding/resources/application.yml b/extensions/mcp-binding/resources/application.yml new file mode 100644 index 0000000..4430066 --- /dev/null +++ b/extensions/mcp-binding/resources/application.yml @@ -0,0 +1,39 @@ +# The MCP binding's configuration - the engines' "resources" convention. +# mercury-serve (and a direct `python mcp_binding.py`) loads resources/application.yml from the +# resources folder next to the application file (this one), never from the working directory. +# Any key can be overridden at run time with the engines' -D syntax, e.g.: +# mercury-serve extensions/mcp-binding/mcp_binding.py -Dmcp.servers=/etc/mercury/mcp-servers.yml + +application.name: 'mcp-binding' +info.app.description: 'Mercury Composable MCP binding (one composable route per allowlisted MCP tool)' + +rest.server.port: 8088 + +# text (default) | json (pretty-printed) | compact (single-line JSONL) +log.format: text +log.level: INFO + +# Actuator /health dependencies - routes of health check functions speaking the engines' +# type=info / type=health contract (mcp.health in mcp_binding.py). It reports each configured +# server, its tool count and whether its session is connected. +mandatory.health.dependencies: 'mcp.health' + +# Opt-in lists for the /env endpoint - nothing is ever dumped wholesale. +show.env.variables: 'LOG_LEVEL' +show.application.properties: 'application.name, rest.server.port, mcp.servers, mcp.call.timeout.ms' + +# --- the binding's settings ----------------------------------------------------------------- +# The server file: the servers by name, each with its transport, its credential references and its +# allowlist of remote tools mapped to routes (see mcp-servers.yml next to this file). A relative +# path is read from this resources folder. +mcp.servers: 'mcp-servers.yml' +# how long one tools/call may take, unless a server sets its own timeout_ms (at least 1000) +mcp.call.timeout.ms: 30000 + +# OpenTelemetry forwarder (opt-in) - the engines' opentelemetry-forwarder twin. Switch it on +# at run time with -Dotel.forwarding=true; the endpoint and credential come from the +# environment. Header names are logged, never values. +otel.forwarding: false +otel.exporter.otlp.endpoint: '${OTLP_API_ENDPOINT:http://localhost:4318/v1/traces}' +otel.exporter.otlp.headers: '${OTLP_AUTH_HEADER} ${OTLP_TOKEN}' +otel.service.name: '${OTLP_SERVICE_NAME:mcp-binding}' diff --git a/extensions/mcp-binding/resources/mcp-servers.yml b/extensions/mcp-binding/resources/mcp-servers.yml new file mode 100644 index 0000000..5c78adf --- /dev/null +++ b/extensions/mcp-binding/resources/mcp-servers.yml @@ -0,0 +1,47 @@ +# MCP servers by name. A graph never carries a command line or a URL: its tool catalog names the +# ROUTE each remote tool is served as, and this file maps the route to the server and the tool. +# +# Every allowlisted tool pins the SHA-256 digest of its input schema (canonical JSON: keys sorted, +# no spaces). Boot fails when the server no longer offers the tool or its schema changed, so a +# server-side change stops a deploy instead of becoming run-time errors. Print the digests, and a +# catalog row to review and paste onto the graph, with: +# +# python extensions/mcp-binding/mcp_binding.py describe [server ...] +# +# Credentials are references: a ${NAME} placeholder resolves from this process's environment at +# boot (${NAME:default} with a default) and is never logged. A stdio server runs as a subprocess of +# this process with the SDK's minimal default environment plus the variables listed under env; an +# http server is reached at its URL with the headers listed. +# +# Keys per server: transport (stdio | http), command, args, env, cwd (stdio), url, headers (http), +# timeout_ms (per call, at least 1000; default mcp.call.timeout.ms), instances (per route, default +# 10), tools (remote tool name -> {route, schema_sha256}). Unknown keys are refused by name. +# +servers: {} + # Replace the empty map with your servers. The samples below are the shape, not a running + # configuration: fill the digests from `describe` before starting the binding, or the boot + # refuses them by name. With no server configured the binding starts and serves no tool route. + # + # documents: + # transport: stdio + # command: npx + # args: ['-y', '@modelcontextprotocol/server-filesystem', '/srv/documents'] + # tools: + # read_text_file: + # route: docs.read.text.file + # schema_sha256: '<64 hex from describe>' + # list_directory: + # route: docs.list.directory + # schema_sha256: '<64 hex from describe>' + # + # tracker: + # transport: http + # url: 'https://mcp.example.internal/mcp' + # headers: + # Authorization: 'Bearer ${TRACKER_MCP_TOKEN}' + # timeout_ms: 20000 + # tools: + # create_issue: + # route: tracker.create.issue + # schema_sha256: '<64 hex from describe>' + # diff --git a/memory/continuity.md b/memory/continuity.md index c006e8e..ac68aa8 100644 --- a/memory/continuity.md +++ b/memory/continuity.md @@ -150,6 +150,22 @@ file, `llm-loop-vectors.json`, pins it ([[llm-chat-loop-contract]]). +- **The MCP binding serves one composable route per allowlisted MCP tool, and the server cannot change a tool under the + graph (RFC-0011 / ADR-0033 step 4; built 2026-11-14, Python first).** `extensions/mcp-binding/mcp_binding.py` on the + official MCP SDK 2.x: servers by name in `resources/mcp-servers.yml` (`stdio`, a subprocess of this process with the SDK's + minimal environment plus the listed `env` references; or `http`, a streamable HTTP URL with `headers`); `${VAR}` resolves at + boot and is never logged; each allowlisted tool maps to a route and pins the SHA-256 of its input schema in canonical JSON. + Boot, inside the serving loop before the first request (`Platform.on_startup`), connects each server in its own task, + lists its tools, and refuses to start by name when a tool is missing or its schema changed; `describe` prints the digests + and a catalog row the developer reviews, because the model reads the graph's descriptions, never the server's. A route's + reply is the structured content else the blocks' text; `is_error` is a 400 carrying the text (the loop returns it to the + model), a timeout 408, a protocol failure 502, a lost session 503. The pure half (digest, verification, result mapping) is + pinned by `tests/vectors/mcp-binding-vectors.json`, shared byte-identical with the Node twin; the transports by the fixture + server `tests/fixtures/mcp_test_server.py`. **How to apply:** a new MCP tool for an agent is a server entry and an allowlist + row here plus a catalog row on the graph, never engine code; a schema change is a digest the developer re-reviews; a + credential is a reference in the server file, resolved from this process's environment. + + ## Conventions - **Quality gates (adopted 2026-08-23, Eric's IDE review round): ruff + basedpyright + diff --git a/memory/open-threads/thread-mcp-binding.md b/memory/open-threads/thread-mcp-binding.md new file mode 100644 index 0000000..794c2dd --- /dev/null +++ b/memory/open-threads/thread-mcp-binding.md @@ -0,0 +1,8 @@ +- [ ] **The MCP binding (RFC-0011 / ADR-0033 build plan step 4): `extensions/mcp-binding/`, built 2026-11-14, PR pending.** + The extension, the `Platform` startup and cleanup hooks, the `mcp` extra, the shared vector file (sha256 `4bacc319…`) and + 46 tests; the HTTP transport driven live against a public MCP server (the Mermaid Chart endpoint) the same day. Next: the + PR merged; the Node twin (mercury-nodejs `extensions/mcp-binding`, the same vector bytes); the engine-side drive with an + MCP tool in a graph's catalog (tracked in mercury-composable `agent-loop-llm-run`); the pack's catch-up release carries it + ([[pack-catch-up-4-12-25]]). Fact: [[mcp-binding-route-per-tool]]. + → serves: vision-mercury-python + diff --git a/memory/sessions/2026-11-14-073523.md b/memory/sessions/2026-11-14-073523.md new file mode 100644 index 0000000..072e7c2 --- /dev/null +++ b/memory/sessions/2026-11-14-073523.md @@ -0,0 +1,55 @@ +# Session (2026-11-14T07:35:23.000Z) + +**Agent:** Claude Code +**Title:** The MCP binding - one composable route per allowlisted MCP tool (RFC-0011 / ADR-0033 build plan step 4, Python first) + +## Context + +Follows 2026-11-13-030139. In mercury-composable, sprint 3 of the AI SDLC epic drove the agent loop live through both packs +(WP1, its PR #624) and RFC-0011 was promoted to ADR-0033 (#625); Eric then directed WP2: the MCP bindings for the helpers, +Python first. This pack's part is built here on branch `feat/mcp-binding`: the extension `extensions/mcp-binding/`, the +`Platform` startup and cleanup hooks it needs, the `mcp` extra, the shared vector file and the tests. The venv is +`~/sandbox/.venv` (uv, Python 3.14.7, `mcp` 2.3.0). + +## What was built + +- **`extensions/mcp-binding/mcp_binding.py`** on the official MCP SDK 2.x (snake_case fields; `streamable_http_client`; + the server class is `MCPServer`). Servers by name in `resources/mcp-servers.yml` (`mcp.servers` names the file): + `stdio` (a command this process starts with the SDK's minimal environment plus the `env` variables listed) or `http` + (a streamable HTTP URL with `headers`); `${VAR}` references resolve at boot through `AppConfig.resolve_text` and + are never logged; an allowlist maps each remote tool to a route and the SHA-256 of its input schema in canonical JSON + (keys sorted, no spaces, Unicode kept). Boot (inside the serving loop, before the first request) connects each server + in a task of its own - the SDK's transports run in anyio task groups whose cancel scopes must be entered and left on + one task - lists its tools, verifies the allowlist and registers one route per tool with `default_registry.register`; + a failure stops the application by name (`SystemExit(1)`), the exception group unwrapped to its leaf. A route's body + is the arguments; the reply is `structured_content` else the content blocks' text; `is_error` → 400 with the text, + a timeout → 408, a protocol error → 502, no session → 503. `describe [server]` prints each tool's digest and a catalog + row for the graph. `mcp.health` (private) reports the servers and their connections. +- **`Platform.on_startup` / `on_cleanup` / `create_app`** in `src/mercury_composable/server.py`: async callbacks attached to + the aiohttp application's lifecycle, `run` unchanged for every other application. +- **The `mcp` extra** (`mcp>=2,<3`) in `pyproject.toml`, also in `dev`. +- **Tests:** `tests/test_mcp_binding.py` (46 cases) - the shared vector file `tests/vectors/mcp-binding-vectors.json` + (4 digest, 6 verify, 10 result cases; sha256 `4bacc319…`) for the pure half, the Node twin to run the same bytes; the + fixture server `tests/fixtures/mcp_test_server.py` (on `MCPServer`: echo, add, fail, slow, env_present) over stdio and + over streamable HTTP; the boot that fails by name; credentials reaching the subprocess and never a log; health; describe; + the platform hooks through aiohttp's `AppRunner`. Full suite 397 passed, 64 skipped (live Redis). Ruff clean. +- **Live, the same day:** the HTTP transport against a public MCP server, the Mermaid Chart endpoint Eric noticed among + Claude Code's connectors - `describe` listed its one tool with the digest; a bound route (`diagram.render`) returned + the server's structured content (boot 2.1 s, the call 3.8 s). A stdio server from a public package could not be fetched in + this session (the npx fetch was denied), so that drive is Eric's to run. + +## Lessons + +- The MCP SDK 2.x wraps a plain return of a tool as structured content (`{"result": ...}`) unless the tool declares + `structured_output=False`; the binding prefers structured content, which is the contract - the fixture declares it. +- A stdio subprocess inherits a stderr file descriptor: hand it `sys.__stderr__`, since a replaced `sys.stderr` (a test's + capture) has none. +- Python 3.10 compatibility: unwrap exception groups by duck typing (`.exceptions`), not by the 3.11 builtin's name. + +## Memory References + +- Referenced: llm-chat-loop-contract (the extension shape and the vector-file practice it set), stateless-functions-contract + (the Scope's one allowed subprocess, an MCP server over stdio - the binding is that client), mcp-binding-route-per-tool + (created) +- Created: mcp-binding-route-per-tool (tier: working) +- Thread raised: mcp-binding diff --git a/pyproject.toml b/pyproject.toml index 90db96a..2caeaa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,12 +46,16 @@ bedrock = ["anthropic[bedrock]>=1,<2"] # Redis as the helper's transcript store (llm.transcript.store: redis) - an agent loop's turns # can then land on any replica; a single process keeps its transcripts in memory without it redis = ["redis>=5,<9"] +# the MCP binding (extensions/mcp-binding) - the official MCP SDK's client, one route per +# allowlisted remote tool; the package itself stays SDK-free +mcp = ["mcp>=2,<3"] dev = [ "pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.9", "anthropic[bedrock]>=1,<2", "redis>=5,<9", + "mcp>=2,<3", # the store's compare-and-set is a Lua script; the [lua] extra runs it in the fake "fakeredis[lua]>=2.26", ] diff --git a/src/mercury_composable/server.py b/src/mercury_composable/server.py index c3144b1..6d1d1ac 100644 --- a/src/mercury_composable/server.py +++ b/src/mercury_composable/server.py @@ -25,6 +25,7 @@ import asyncio import contextlib +from collections.abc import Awaitable, Callable from aiohttp import web @@ -277,26 +278,56 @@ class Platform: def __init__(self, registry: FunctionRegistry | None = None): self.registry = registry or default_registry + self._startup: list[Callable[[], Awaitable[None]]] = [] + self._cleanup: list[Callable[[], Awaitable[None]]] = [] - def run(self, port: int | None = None, host: str = "127.0.0.1") -> None: + def on_startup(self, callback: Callable[[], Awaitable[None]]) -> None: + """Run an async callback inside the server's event loop before the first request is + served - the place for an extension's asynchronous boot work, such as connecting to a + peer and registering the routes it serves. A callback that raises stops the application + before it listens, so a boot failure is a failure to start, never a run-time surprise.""" + self._startup.append(callback) + + def on_cleanup(self, callback: Callable[[], Awaitable[None]]) -> None: + """Run an async callback when the application shuts down, after the last request.""" + self._cleanup.append(callback) + + def create_app(self) -> web.Application: + """The Event API application with this platform's startup and cleanup callbacks + attached, and the opt-in OpenTelemetry forwarder activated on its extension route; + ``run`` serves it, and a test may serve it with aiohttp's own runner.""" config = app_config() - app_name = config.get_property("application.name", "application") - actual_port = int(port if port is not None else config.get("rest.server.port", 8085)) # the opt-in OpenTelemetry forwarder registers on the extension route # before the route list is announced; its HTTP client closes with the app exporter = otel.activate(config, self.registry) - server = EventApiServer(self.registry) + app = EventApiServer(self.registry).create_app() + for callback in self._startup: + app.on_startup.append(_lifecycle(callback)) + for callback in self._cleanup: + app.on_cleanup.append(_lifecycle(callback)) + if exporter is not None: + async def _close_exporter(_app: web.Application) -> None: + await exporter.close() + app.on_cleanup.append(_close_exporter) + return app + + def run(self, port: int | None = None, host: str = "127.0.0.1") -> None: + config = app_config() + app_name = config.get_property("application.name", "application") + actual_port = int(port if port is not None else config.get("rest.server.port", 8085)) + app = self.create_app() for route, service in sorted(self.registry.routes().items()): visibility = "PRIVATE" if service.private else "PUBLIC" log.info("Loaded %s %s, instances=%d", visibility, route, service.instances) log.info("Configuration: %s", config.source) log.info("%s - Event API service started on port %d", app_name, actual_port) - app = server.create_app() - if exporter is not None: - async def _close_exporter(_app: web.Application) -> None: - await exporter.close() - app.on_cleanup.append(_close_exporter) web.run_app(app, host=host, port=actual_port, print=None, handle_signals=True) +def _lifecycle(callback: Callable[[], Awaitable[None]]) -> Callable[[web.Application], Awaitable[None]]: + async def run(_app: web.Application) -> None: + await callback() + return run + + platform = Platform() diff --git a/tests/fixtures/mcp_test_server.py b/tests/fixtures/mcp_test_server.py new file mode 100644 index 0000000..58be819 --- /dev/null +++ b/tests/fixtures/mcp_test_server.py @@ -0,0 +1,54 @@ +"""A small MCP server on the official SDK, for the MCP binding's tests. + + python tests/fixtures/mcp_test_server.py # stdio (the default transport) + python tests/fixtures/mcp_test_server.py http 8765 # streamable HTTP on 127.0.0.1:8765/mcp + +Five tools cover the result shapes the binding maps: ``echo`` answers text (the SDK would otherwise +wrap a plain return as structured content), ``add`` answers structured content, ``fail`` reports a tool error, ``slow`` answers after a delay (a timeout +test), and ``env_present`` says whether a variable reached the subprocess's environment - +its presence only, never its value. +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError + +server = MCPServer("fixture", version="1.0.0") + + +@server.tool(description="Echo the text back.", structured_output=False) +def echo(text: str) -> str: + return text + + +@server.tool(description="Add two whole numbers.", structured_output=True) +def add(a: int, b: int) -> dict[str, int]: + return {"sum": a + b} + + +@server.tool(description="Fail with the reason given.", structured_output=False) +def fail(reason: str) -> str: + raise ToolError(reason) + + +@server.tool(description="Answer after a delay, in seconds.", structured_output=False) +async def slow(seconds: float) -> str: + await asyncio.sleep(seconds) + return "done" + + +@server.tool(description="Whether an environment variable reached this server.", structured_output=True) +def env_present(name: str) -> dict[str, bool]: + return {"present": name in os.environ} + + +if __name__ == "__main__": + if len(sys.argv) > 2 and sys.argv[1] == "http": + server.run(transport="streamable-http", host="127.0.0.1", port=int(sys.argv[2])) + else: + server.run() diff --git a/tests/test_mcp_binding.py b/tests/test_mcp_binding.py new file mode 100644 index 0000000..da1d4f8 --- /dev/null +++ b/tests/test_mcp_binding.py @@ -0,0 +1,402 @@ +"""The MCP binding (extensions/mcp-binding/mcp_binding.py). + +The shared contract file tests/vectors/mcp-binding-vectors.json (byte-identical in mercury-nodejs) pins +the pure half: the input-schema digest, the allowlist verification and the result mapping. The tests +after the vector runs drive the real transports against the fixture server of tests/fixtures +(a small MCP server on the official SDK, over stdio and over streamable HTTP): the boot that registers +one route per allowlisted tool and fails fast by name, the calls through the registered handlers, the +credential references that reach the subprocess and never a log, the health route, the describe +output, and the platform's startup hook that runs the boot before the first request. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import importlib.util +import json +import logging +import os +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import Any + +import pytest +import yaml +from aiohttp import web + +from mercury_composable import AppException, app_config, default_registry, platform + +_TESTS = Path(__file__).resolve().parent +_BINDING = _TESTS.parent / "extensions" / "mcp-binding" / "mcp_binding.py" +_FIXTURE = _TESTS / "fixtures" / "mcp_test_server.py" +_VECTORS = _TESTS / "vectors" / "mcp-binding-vectors.json" +# the Node.js twin pins the same digest: change the file in both packs or in neither +_VECTORS_SHA256 = "4bacc319859847ff82e5d169ad9c0dea4ffdd6fe06883537ebf5ee66bdba907a" + +_spec = importlib.util.spec_from_file_location("mcp_binding_under_test", _BINDING) +assert _spec is not None +assert _spec.loader is not None +# typed Any: the module is loaded dynamically from a file path, so its attributes are +# unknowable statically - Any tells every analyzer to trust the runtime +binding: Any = importlib.util.module_from_spec(_spec) +sys.modules.setdefault("mcp_binding_under_test", binding) +_spec.loader.exec_module(binding) + +VECTORS: dict[str, Any] = json.loads(_VECTORS.read_text(encoding="utf-8")) + +FIXTURE_TOOLS = ("echo", "add", "fail", "slow", "env_present") + + +def _spec_of(case_server: dict[str, Any]) -> Any: + tools = tuple(binding.ToolBinding(name=n, route=t["route"], schema_sha256=t["schema_sha256"]) + for n, t in case_server["tools"].items()) + return binding.ServerSpec(name=case_server["name"], transport="stdio", tools=tools, command="x") + + +# --- the shared vectors ------------------------------------------------------------------- + + +def test_the_vector_file_is_the_one_the_node_twin_runs() -> None: + digest = hashlib.sha256(_VECTORS.read_bytes()).hexdigest() + assert digest == _VECTORS_SHA256, "update both packs' pins when the shared file changes" + + +@pytest.mark.parametrize("case", VECTORS["digest_cases"], ids=lambda c: c["name"]) +def test_schema_digest_contract(case: dict[str, Any]) -> None: + assert binding.schema_digest(case["schema"]) == case["sha256"] + + +@pytest.mark.parametrize("case", VECTORS["verify_cases"], ids=lambda c: c["name"]) +def test_verify_contract(case: dict[str, Any]) -> None: + spec = _spec_of(case["server"]) + expect = case["expect"] + if "error" in expect: + with pytest.raises(binding.BindingError) as failure: + binding.verify(spec, case["offered"]) + assert str(failure.value) == expect["error"] + else: + bound = binding.verify(spec, case["offered"]) + assert [b.route for b, _tool in bound] == expect["bound"] + + +@pytest.mark.parametrize("case", VECTORS["result_cases"], ids=lambda c: c["name"]) +def test_tool_result_contract(case: dict[str, Any]) -> None: + expect = case["expect"] + if "error" in expect: + with pytest.raises(AppException) as failure: + binding.tool_result(case["result"]) + assert failure.value.status == expect["error"]["status"] + assert failure.value.message == expect["error"]["message"] + else: + assert binding.tool_result(case["result"]) == expect["reply"] + + +# --- the fixture server over the real transports ------------------------------------------ + + +@pytest.fixture(scope="module") +def offered() -> dict[str, dict[str, Any]]: + """What the fixture server offers, as the binding reads it: the digests the tests pin come + from the server itself, so a change of the SDK's schema generation cannot break them.""" + spec = binding.ServerSpec(name="probe", transport="stdio", tools=(), command=sys.executable, + args=(str(_FIXTURE),)) + tools = asyncio.run(binding._describe(spec)) + return {tool["name"]: tool for tool in tools} + + +def _servers_file(tmp_path: Path, servers: dict[str, Any]) -> Path: + path = tmp_path / "mcp-servers.yml" + path.write_text(yaml.safe_dump({"servers": servers}, sort_keys=False), encoding="utf-8") + return path + + +def _stdio(offered: dict[str, dict[str, Any]], tools: dict[str, str], **extra: Any) -> dict[str, Any]: + """A stdio server entry for the fixture, every tool with the digest the server reports.""" + entry: dict[str, Any] = { + "transport": "stdio", "command": sys.executable, "args": [str(_FIXTURE)], + "tools": {name: {"route": route, "schema_sha256": binding.schema_digest(offered[name]["input_schema"])} + for name, route in tools.items()}, + } + entry.update(extra) + return entry + + +async def _call(route: str, body: Any) -> Any: + service = default_registry.get(route) + assert service is not None, f"{route} is not registered" + return await service.handler({}, body) + + +@pytest.fixture +def servers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Point the binding at a temporary server file and give the test a fresh Binding.""" + def point(entries: dict[str, Any]) -> Any: + path = _servers_file(tmp_path, entries) + app_config().set("mcp.servers", str(path)) + return binding.Binding() + yield point + app_config().set("mcp.servers", binding.DEFAULT_SERVERS_FILE) + + +@pytest.mark.asyncio +async def test_boot_registers_one_route_per_allowlisted_tool_and_calls_them( + offered: dict[str, dict[str, Any]], servers: Any) -> None: + app = servers({"fixture": _stdio(offered, {"echo": "fixture.echo", "add": "fixture.add", "fail": "fixture.fail"})}) + await app.boot() + try: + assert sorted(app.servers) == ["fixture"] + assert [b.route for b, _ in app.servers["fixture"].bound] == ["fixture.echo", "fixture.add", "fixture.fail"] + assert await _call("fixture.echo", {"text": "hello"}) == "hello" + assert await _call("fixture.add", {"a": 1, "b": 2}) == {"sum": 3} + with pytest.raises(AppException) as failure: + await _call("fixture.fail", {"reason": "the vault is closed"}) + assert failure.value.status == 400 + assert "the vault is closed" in failure.value.message + with pytest.raises(AppException) as not_a_map: + await _call("fixture.echo", "hello") + assert not_a_map.value.status == 400 + assert "must be a map" in not_a_map.value.message + finally: + await app.shutdown() + assert not app.servers + + +@pytest.mark.asyncio +async def test_a_slow_tool_is_a_408_within_the_servers_timeout(offered: dict[str, dict[str, Any]], servers: Any) -> None: + app = servers({"fixture": _stdio(offered, {"slow": "fixture.slow"}, timeout_ms=1000)}) + await app.boot() + try: + started = time.monotonic() + with pytest.raises(AppException) as failure: + await _call("fixture.slow", {"seconds": 5}) + assert failure.value.status == 408, failure.value.message + assert "within 1000 ms" in failure.value.message + assert time.monotonic() - started < 4 + finally: + await app.shutdown() + + +@pytest.mark.asyncio +async def test_a_missing_tool_fails_the_boot_by_name(offered: dict[str, dict[str, Any]], servers: Any, + caplog: pytest.LogCaptureFixture) -> None: + entry = _stdio(offered, {"echo": "fixture.echo"}) + entry["tools"]["nope"] = {"route": "fixture.nope", "schema_sha256": "0" * 64} + app = servers({"fixture": entry}) + with caplog.at_level(logging.ERROR), pytest.raises(SystemExit) as stop: + await app.boot() + assert stop.value.code == 1 + assert "does not offer the tool nope" in caplog.text + assert not app.servers, "a failed boot leaves no connection behind" + + +@pytest.mark.asyncio +async def test_a_changed_schema_fails_the_boot_by_name(offered: dict[str, dict[str, Any]], servers: Any, + caplog: pytest.LogCaptureFixture) -> None: + entry = _stdio(offered, {"echo": "fixture.echo"}) + entry["tools"]["echo"]["schema_sha256"] = "f" * 64 + app = servers({"fixture": entry}) + with caplog.at_level(logging.ERROR), pytest.raises(SystemExit): + await app.boot() + assert "changed the input schema of echo" in caplog.text + assert binding.schema_digest(offered["echo"]["input_schema"]) in caplog.text, "the current digest is shown" + + +@pytest.mark.asyncio +async def test_credential_references_reach_the_subprocess_and_never_a_log( + offered: dict[str, dict[str, Any]], servers: Any, monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture) -> None: + monkeypatch.setenv("FIXTURE_TOKEN", "a-value-that-must-not-appear") + app = servers({"fixture": _stdio(offered, {"env_present": "fixture.env.present"}, + env={"FIXTURE_FORWARDED": "${FIXTURE_TOKEN}"})}) + with caplog.at_level(logging.DEBUG): + await app.boot() + try: + assert await _call("fixture.env.present", {"name": "FIXTURE_FORWARDED"}) == {"present": True} + assert await _call("fixture.env.present", {"name": "FIXTURE_TOKEN"}) == {"present": False}, \ + "only the listed variables reach the subprocess, not the whole environment" + finally: + await app.shutdown() + assert "a-value-that-must-not-appear" not in caplog.text + + +def test_an_unset_credential_reference_is_refused_at_load(offered: dict[str, dict[str, Any]], servers: Any, + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FIXTURE_MISSING_TOKEN", raising=False) + servers({"fixture": _stdio(offered, {"echo": "fixture.echo"}, env={"TOKEN": "${FIXTURE_MISSING_TOKEN}"})}) + with pytest.raises(binding.BindingError) as failure: + binding.load_servers() + assert "env.TOKEN resolves to nothing" in str(failure.value) + + +@pytest.mark.parametrize(("entry", "message"), [ + ({"transport": "ftp", "tools": {"echo": {"route": "a.b", "schema_sha256": "0" * 64}}}, "transport must be stdio or http"), + ({"transport": "stdio", "tools": {"echo": {"route": "a.b", "schema_sha256": "0" * 64}}}, "a stdio server needs a command"), + ({"transport": "http", "tools": {"echo": {"route": "a.b", "schema_sha256": "0" * 64}}}, "an http server needs a url"), + ({"transport": "stdio", "command": "x", "url": "http://x", "tools": {"echo": {"route": "a.b", "schema_sha256": "0" * 64}}}, + "url and headers belong to an http server"), + ({"transport": "stdio", "command": "x", "colour": "blue", "tools": {"echo": {"route": "a.b", "schema_sha256": "0" * 64}}}, + "has unknown keys colour"), + ({"transport": "stdio", "command": "x", "tools": {}}, "tools must map at least one remote tool"), + ({"transport": "stdio", "command": "x", "tools": {"echo": {"route": "NoDots", "schema_sha256": "0" * 64}}}, "Invalid route name"), + ({"transport": "stdio", "command": "x", "tools": {"echo": {"route": "a.b", "schema_sha256": "short"}}}, "needs schema_sha256"), + ({"transport": "stdio", "command": "x", "tools": {"echo": {"route": "a.b", "schema_sha256": "0" * 64, "mode": "x"}}}, + "has unknown keys mode"), + ({"transport": "stdio", "command": "x", "timeout_ms": 10, "tools": {"echo": {"route": "a.b", "schema_sha256": "0" * 64}}}, + "timeout_ms must be at least 1000"), + ({"transport": "stdio", "command": "x", "tools": {"echo": {"route": "a.b", "schema_sha256": "0" * 64}, + "add": {"route": "a.b", "schema_sha256": "0" * 64}}}, + "the route a.b is bound twice"), +]) +def test_the_server_file_is_checked_before_any_connection(servers: Any, entry: dict[str, Any], message: str) -> None: + servers({"s": entry}) + with pytest.raises(binding.BindingError) as failure: + binding.load_servers() + assert message in str(failure.value), str(failure.value) + + +def test_a_route_bound_by_two_servers_is_refused(servers: Any) -> None: + tool = {"echo": {"route": "shared.route", "schema_sha256": "0" * 64}} + servers({"one": {"transport": "stdio", "command": "x", "tools": tool}, + "two": {"transport": "stdio", "command": "y", "tools": tool}}) + with pytest.raises(binding.BindingError) as failure: + binding.load_servers() + assert "bound by both one and two" in str(failure.value) + + +def test_an_empty_server_map_starts_with_no_route(servers: Any, caplog: pytest.LogCaptureFixture) -> None: + servers({}) + with caplog.at_level(logging.WARNING): + assert binding.load_servers() == [] + assert "No MCP server configured" in caplog.text + + +def _free_port() -> int: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return int(probe.getsockname()[1]) + + +def _wait_for(port: int, process: subprocess.Popen[bytes], seconds: float = 20) -> None: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if process.poll() is not None: + raise AssertionError(f"the fixture server exited with {process.returncode}") + with socket.socket() as probe: + probe.settimeout(0.2) + if probe.connect_ex(("127.0.0.1", port)) == 0: + return + time.sleep(0.1) + raise AssertionError("the fixture server did not listen in time") + + +@pytest.fixture +def http_fixture_port(): + """The fixture server over streamable HTTP, started synchronously so the async test only talks to it.""" + port = _free_port() + with subprocess.Popen([sys.executable, str(_FIXTURE), "http", str(port)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) as process: + try: + _wait_for(port, process) + yield port + finally: + process.terminate() + process.wait(timeout=10) + + +@pytest.mark.asyncio +async def test_the_http_transport_serves_the_same_routes(offered: dict[str, dict[str, Any]], servers: Any, + monkeypatch: pytest.MonkeyPatch, http_fixture_port: int) -> None: + monkeypatch.setenv("FIXTURE_HTTP_TOKEN", "bearer-value") + app = servers({"remote": { + "transport": "http", "url": f"http://127.0.0.1:{http_fixture_port}/mcp", + "headers": {"Authorization": "Bearer ${FIXTURE_HTTP_TOKEN}"}, + "tools": {"echo": {"route": "remote.echo", "schema_sha256": binding.schema_digest(offered["echo"]["input_schema"])}, + "add": {"route": "remote.add", "schema_sha256": binding.schema_digest(offered["add"]["input_schema"])}}, + }}) + await app.boot() + try: + assert await _call("remote.echo", {"text": "over http"}) == "over http" + assert await _call("remote.add", {"a": 20, "b": 22}) == {"sum": 42} + finally: + await app.shutdown() + + +@pytest.mark.asyncio +async def test_health_reports_each_server_and_its_connection(offered: dict[str, dict[str, Any]], servers: Any) -> None: + app = servers({"fixture": _stdio(offered, {"echo": "fixture.echo"})}) + await app.boot() + try: + binding.binding.servers = app.servers + info = await binding.mcp_health({"type": "info"}, {}) + assert info["service"] == "mcp.binding" + assert info["servers"] == [{"name": "fixture", "transport": "stdio", "tools": 1, "connected": True}] + assert await binding.mcp_health({"type": "health"}, {}) == "mcp.binding is running fine" + finally: + await app.shutdown() + binding.binding.servers = app.servers + assert (await binding.mcp_health({"type": "info"}, {}))["servers"] == [] + lost = binding.Server(binding.ServerSpec(name="gone", transport="stdio", tools=(), command="x")) + binding.binding.servers = {"gone": lost} + try: + with pytest.raises(AppException) as failure: + await binding.mcp_health({"type": "health"}, {}) + assert failure.value.status == 503 + assert "gone" in failure.value.message + finally: + binding.binding.servers = {} + + +def test_describe_prints_the_digests_and_a_catalog_row(offered: dict[str, dict[str, Any]], servers: Any, + capsys: pytest.CaptureFixture[str]) -> None: + servers({"fixture": _stdio(offered, {"echo": "fixture.echo"})}) + assert binding.describe(["fixture"]) == 0 + out = capsys.readouterr().out + assert f"schema_sha256: {binding.schema_digest(offered['echo']['input_schema'])}" in out + assert "## echo - bound as fixture.echo (digest ok)" in out + assert "## add - not allowlisted" in out + assert '"route": "fixture.echo"' in out + assert '"description": "Echo the text back."' in out + assert binding.describe(["unknown"]) == 1 + assert "Unknown server(s): unknown" in capsys.readouterr().err + + +@pytest.mark.asyncio +async def test_the_platform_runs_startup_and_cleanup_callbacks_around_the_app() -> None: + events: list[str] = [] + + async def start() -> None: + events.append("start") + + async def stop() -> None: + events.append("stop") + + fresh = platform.__class__(default_registry) + fresh.on_startup(start) + fresh.on_cleanup(stop) + runner = web.AppRunner(fresh.create_app()) + await runner.setup() + assert events == ["start"], "the startup callback runs before the app listens" + await runner.cleanup() + assert events == ["start", "stop"] + + +@pytest.mark.asyncio +async def test_a_failing_startup_callback_stops_the_app_before_it_listens() -> None: + async def start() -> None: + raise SystemExit(1) + + fresh = platform.__class__(default_registry) + fresh.on_startup(start) + runner = web.AppRunner(fresh.create_app()) + with pytest.raises(SystemExit): + await runner.setup() + await runner.cleanup() + + +def test_the_fixture_server_has_the_tools_the_tests_rely_on(offered: dict[str, dict[str, Any]]) -> None: + assert sorted(offered) == sorted(FIXTURE_TOOLS) + assert os.path.isfile(_FIXTURE) diff --git a/tests/vectors/mcp-binding-vectors.json b/tests/vectors/mcp-binding-vectors.json new file mode 100644 index 0000000..64d7cea --- /dev/null +++ b/tests/vectors/mcp-binding-vectors.json @@ -0,0 +1,522 @@ +{ + "contract": "mcp-binding", + "version": 1, + "description": "The MCP binding's shared vectors (RFC-0011 / ADR-0033, build plan step 4): the pure half of the binding, run by mercury-python and mercury-nodejs against the same file. digest_cases pin the canonical JSON a tool's input schema is hashed as (keys sorted, no spaces, Unicode kept, SHA-256 hex). verify_cases pin how an allowlist is matched against the tools a server offers (tools/list as plain maps: name, description, input_schema) - the bound routes in allowlist order, or the exact error that stops the boot. result_cases pin how a CallToolResult (content, structured_content, is_error) becomes a route's reply or its error (status and exact message). No server is started by these cases; the transports are tested by each pack with a fixture server.", + "digest_cases": [ + { + "name": "an empty object schema", + "schema": { + "type": "object", + "properties": {} + }, + "sha256": "efddc7bd8bbcef73a14eb1ace1ffdaec81e518ef1e13c1e9271d0b8acb694a49" + }, + { + "name": "key order does not matter", + "schema": { + "properties": { + "path": { + "type": "string" + } + }, + "type": "object", + "required": [ + "path" + ] + }, + "sha256": "923b083c9bcab2e3d3e07c7d9b4401688ad9f3d5e2c2f0c48fcd43052bdcb71b" + }, + { + "name": "unicode is hashed as unicode, not escaped", + "schema": { + "type": "object", + "properties": { + "größe": { + "type": "number", + "description": "Größe in m²" + } + } + }, + "sha256": "941a30e2610da07a5943b278d3c10279c177f6fd92989a7757bc685c67a2c3e8" + }, + { + "name": "a nested schema with every value kind", + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 80 + }, + "priority": { + "type": "string", + "enum": [ + "low", + "high" + ] + } + }, + "required": [ + "title" + ] + }, + "sha256": "3b47472833fac74ebc509de9790f13829ea4ed62f413f4d774d7936e063207cd" + } + ], + "verify_cases": [ + { + "name": "verify/every-allowlisted-tool-is-offered-and-unchanged", + "server": { + "name": "docs", + "tools": { + "read_text_file": { + "route": "docs.read.text.file", + "schema_sha256": "6acc092731905473c2bfced982253c9a85e0190cca13fb933deeaa4069819479" + }, + "list_directory": { + "route": "docs.list.directory", + "schema_sha256": "923b083c9bcab2e3d3e07c7d9b4401688ad9f3d5e2c2f0c48fcd43052bdcb71b" + } + } + }, + "offered": [ + { + "name": "read_text_file", + "description": "Read a text file.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "the file's path" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory", + "description": "List a directory.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + ], + "expect": { + "bound": [ + "docs.read.text.file", + "docs.list.directory" + ] + } + }, + { + "name": "verify/a-tool-the-server-offers-but-the-allowlist-omits-is-ignored", + "server": { + "name": "docs", + "tools": { + "read_text_file": { + "route": "docs.read.text.file", + "schema_sha256": "6acc092731905473c2bfced982253c9a85e0190cca13fb933deeaa4069819479" + } + } + }, + "offered": [ + { + "name": "read_text_file", + "description": "Read a text file.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "the file's path" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory", + "description": "List a directory.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + ], + "expect": { + "bound": [ + "docs.read.text.file" + ] + } + }, + { + "name": "verify/a-missing-tool-names-itself-and-what-is-offered", + "server": { + "name": "docs", + "tools": { + "write_file": { + "route": "docs.write.file", + "schema_sha256": "6acc092731905473c2bfced982253c9a85e0190cca13fb933deeaa4069819479" + } + } + }, + "offered": [ + { + "name": "read_text_file", + "description": "Read a text file.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "the file's path" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory", + "description": "List a directory.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + ], + "expect": { + "error": "MCP server docs does not offer the tool write_file - it offers list_directory, read_text_file" + } + }, + { + "name": "verify/a-server-with-no-tools", + "server": { + "name": "docs", + "tools": { + "read_text_file": { + "route": "docs.read.text.file", + "schema_sha256": "6acc092731905473c2bfced982253c9a85e0190cca13fb933deeaa4069819479" + } + } + }, + "offered": [], + "expect": { + "error": "MCP server docs does not offer the tool read_text_file - it offers no tools" + } + }, + { + "name": "verify/a-changed-schema-names-the-tool-and-both-digests", + "server": { + "name": "docs", + "tools": { + "read_text_file": { + "route": "docs.read.text.file", + "schema_sha256": "923b083c9bcab2e3d3e07c7d9b4401688ad9f3d5e2c2f0c48fcd43052bdcb71b" + } + } + }, + "offered": [ + { + "name": "read_text_file", + "description": "Read a text file.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "the file's path" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory", + "description": "List a directory.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + ], + "expect": { + "error": "MCP server docs changed the input schema of read_text_file - its digest is now 6acc092731905473c2bfced982253c9a85e0190cca13fb933deeaa4069819479, the configuration pins 923b083c9bcab2e3d3e07c7d9b4401688ad9f3d5e2c2f0c48fcd43052bdcb71b" + } + }, + { + "name": "verify/the-first-failure-in-allowlist-order-stops-the-boot", + "server": { + "name": "docs", + "tools": { + "list_directory": { + "route": "docs.list.directory", + "schema_sha256": "6acc092731905473c2bfced982253c9a85e0190cca13fb933deeaa4069819479" + }, + "write_file": { + "route": "docs.write.file", + "schema_sha256": "6acc092731905473c2bfced982253c9a85e0190cca13fb933deeaa4069819479" + } + } + }, + "offered": [ + { + "name": "read_text_file", + "description": "Read a text file.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "the file's path" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory", + "description": "List a directory.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + } + ], + "expect": { + "error": "MCP server docs changed the input schema of list_directory - its digest is now 923b083c9bcab2e3d3e07c7d9b4401688ad9f3d5e2c2f0c48fcd43052bdcb71b, the configuration pins 6acc092731905473c2bfced982253c9a85e0190cca13fb933deeaa4069819479" + } + } + ], + "result_cases": [ + { + "name": "result/text-content-is-the-reply", + "result": { + "content": [ + { + "type": "text", + "text": "hello" + } + ], + "structured_content": null, + "is_error": false + }, + "expect": { + "reply": "hello" + } + }, + { + "name": "result/several-text-blocks-join-with-newlines", + "result": { + "content": [ + { + "type": "text", + "text": "line 1" + }, + { + "type": "text", + "text": "line 2" + } + ], + "structured_content": null, + "is_error": false + }, + "expect": { + "reply": "line 1\nline 2" + } + }, + { + "name": "result/structured-content-wins-over-text", + "result": { + "content": [ + { + "type": "text", + "text": "{\"sum\": 3}" + } + ], + "structured_content": { + "sum": 3 + }, + "is_error": false + }, + "expect": { + "reply": { + "sum": 3 + } + } + }, + { + "name": "result/an-empty-result-is-an-empty-text", + "result": { + "content": [], + "structured_content": null, + "is_error": false + }, + "expect": { + "reply": "" + } + }, + { + "name": "result/a-non-text-block-is-named-by-its-type", + "result": { + "content": [ + { + "type": "image", + "data": "aGk=", + "mimeType": "image/png" + }, + { + "type": "text", + "text": "caption" + } + ], + "structured_content": null, + "is_error": false + }, + "expect": { + "reply": "[image]\ncaption" + } + }, + { + "name": "result/a-resource-link-carries-its-uri", + "result": { + "content": [ + { + "type": "resource_link", + "uri": "file:///srv/docs/a.md", + "name": "a.md" + } + ], + "structured_content": null, + "is_error": false + }, + "expect": { + "reply": "[resource_link] file:///srv/docs/a.md" + } + }, + { + "name": "result/an-embedded-resource-carries-its-uri", + "result": { + "content": [ + { + "type": "resource", + "resource": { + "uri": "file:///srv/docs/a.md", + "text": "..." + } + } + ], + "structured_content": null, + "is_error": false + }, + "expect": { + "reply": "[resource] file:///srv/docs/a.md" + } + }, + { + "name": "result/is-error-is-a-400-carrying-the-text", + "result": { + "content": [ + { + "type": "text", + "text": "No such file: /srv/docs/missing.md" + } + ], + "structured_content": null, + "is_error": true + }, + "expect": { + "error": { + "status": 400, + "message": "No such file: /srv/docs/missing.md" + } + } + }, + { + "name": "result/is-error-without-text-still-names-itself", + "result": { + "content": [], + "structured_content": null, + "is_error": true + }, + "expect": { + "error": { + "status": 400, + "message": "The tool reported an error without a message" + } + } + }, + { + "name": "result/is-error-with-structured-content-is-still-an-error", + "result": { + "content": [ + { + "type": "text", + "text": "denied" + } + ], + "structured_content": { + "code": "denied" + }, + "is_error": true + }, + "expect": { + "error": { + "status": 400, + "message": "denied" + } + } + } + ] +} From b4febf03d0f4d9ba57d40f5942bd0f4488aefe3f Mon Sep 17 00:00:00 2001 From: Eric Law <39393654+acn-ericlaw@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:44:58 +0800 Subject: [PATCH 2/2] fix(mcp-binding): build the HTTP client directly - the SDK's helper is not part of its typed surface basedpyright on CI (Python 3.10) reported create_mcp_http_client as not exported from mcp.client.streamable_http: it lives in a private module and the re-export is untyped. The binding now builds the httpx2.AsyncClient itself, with the server's headers, the call timeout as the deadline and redirects followed, and hands it to streamable_http_client as before. Tests and the type check are green locally with the venv's interpreter. Co-Authored-By: Claude Fable 5.1 --- extensions/mcp-binding/mcp_binding.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/extensions/mcp-binding/mcp_binding.py b/extensions/mcp-binding/mcp_binding.py index 9915782..f2121fb 100644 --- a/extensions/mcp-binding/mcp_binding.py +++ b/extensions/mcp-binding/mcp_binding.py @@ -45,7 +45,7 @@ import httpx2 from mcp import ClientSession, types from mcp.client.stdio import StdioServerParameters, get_default_environment, stdio_client - from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client + from mcp.client.streamable_http import streamable_http_client from mcp.shared.exceptions import MCPError except ImportError as missing_sdk: # the SDK is this app's one dependency - teach the fix raise SystemExit( @@ -354,8 +354,9 @@ async def _transport(self) -> AsyncIterator[tuple[Any, Any]]: yield streams else: seconds = spec.timeout_ms / 1000 - client = create_mcp_http_client(headers=spec.headers or None, - timeout=httpx2.Timeout(seconds, read=seconds)) + # the SDK's own HTTP client, with this server's headers and the call timeout as its deadline + client = httpx2.AsyncClient(headers=spec.headers or None, timeout=httpx2.Timeout(seconds, read=seconds), + follow_redirects=True) async with client, streamable_http_client(str(spec.url), http_client=client) as streams: yield streams