Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
`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
listed its tool with the digest, and a bound route returned the server's structured content. Every bound route is an alias of one `@preload` function,
`mcp.tool` (private, bound to no tool), registered at boot with the server's `instances`; the function reads the route it
was called as from its execution context (`get_trace().route`). **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.
Expand Down
8 changes: 8 additions & 0 deletions extensions/mcp-binding/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ One session per server is shared by its routes, held by a task of its own for th
`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.

Every bound route is served by one function, `mcp.tool`, declared with `@preload` like any composable function and private:
the host answers 403 for it on the wire, and it is bound to no tool. At boot, once a server's allowlist is verified, the
binding registers each allowlisted tool's route as an alias of that function - `default_registry.register(route, mcp_tool,
instances=...)`, the pack's counterpart of the engines' `Platform.register`, with the server's `instances` - so every route
is a service of its own, with its own mailbox and workers. A call learns the route it was addressed to from its execution
context (`get_trace().route`, the pack's counterpart of the engines' read-only `my_route` header), which names the server
and the remote tool; a call to `mcp.tool` by its own name is a 404.

## Security

- **No engine runs a child process.** The MCP client, the subprocess and the credentials live here, in the pack, behind
Expand Down
48 changes: 34 additions & 14 deletions extensions/mcp-binding/mcp_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
app_config,
default_registry,
get_logger,
get_trace,
platform,
preload,
)
Expand Down Expand Up @@ -426,12 +427,14 @@ async def _list_tools(session: ClientSession) -> list[dict[str, Any]]:
return offered


# --- the application: boot, the routes and the health check ------------------------------
# --- the application: boot, the tool function and the health check ------------------------------


class Binding:
def __init__(self) -> None:
self.servers: dict[str, Server] = {}
# what each bound route serves - the server and the allowlisted tool - filled at boot
self.routes: dict[str, tuple[Server, ToolBinding]] = {}

async def boot(self) -> None:
"""Connect every configured server, verify its allowlist and register the routes - before
Expand All @@ -442,33 +445,50 @@ async def boot(self) -> None:
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()))
for tool, _schema in server.bound:
# the route is an alias of mcp_tool - the one function under the configured
# name, the engines' Platform.register shape - with the server's instances
self.routes[tool.route] = (server, tool)
default_registry.register(tool.route, mcp_tool, instances=spec.instances)
log.info("MCP server %s: tool %s served as %s", spec.name, tool.name, tool.route)
log.info("MCP binding ready - %d server(s), %d route(s)", len(self.servers), len(self.routes))
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

def bound(self, route: str | None) -> tuple[Server, ToolBinding]:
"""The server and the allowlisted tool behind a bound route; a 404 for any other route."""
found = self.routes.get(route or "")
if found is None:
raise AppException(404, f"No MCP tool is bound to the route {route or '(none)'}")
return found

async def shutdown(self) -> None:
for server in list(self.servers.values()):
await server.close()
self.servers.clear()
self.routes.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()


binding = Binding()
@preload(route="mcp.tool", instances=DEFAULT_INSTANCES, private=True)
async def mcp_tool(_headers: dict[str, str], body: Body) -> Any:
"""The one function behind every bound route. The boot registers each allowlisted tool's route
as an alias of this function (the engines' ``Platform.register``: one function, several
routes), so a call learns the route it was addressed to from its execution context -
``get_trace().route``, the pack's counterpart of the engines' read-only ``my_route`` header -
and the route names the server and the remote tool. The event body is the tool's arguments,
a map; the reply is the result as ``tool_result`` shapes it. ``mcp.tool`` itself is private
and bound to no tool: it declares the function, and a call to it by that name is a 404."""
info = get_trace()
server, tool = binding.bound(info.route if info else None)
if not isinstance(body, dict):
raise AppException(400, f"The arguments of {tool.name} must be a map")
return await server.call(tool.name, body)


@preload(route="mcp.health", instances=5, private=True)
Expand Down
9 changes: 8 additions & 1 deletion memory/continuity.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,14 @@
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
server `tests/fixtures/mcp_test_server.py`. **The shape (2026-11-14, Eric's direction):** every bound route is an alias of
ONE `@preload` function, `mcp.tool` - private, bound to no tool - registered at boot with the server's `instances` through
`default_registry.register`, the pack's counterpart of the engines' `Platform.register`; the function reads the route it
was called as from `get_trace().route`, the pack's counterpart of the engines' read-only `my_route` header (the host strips
`my_*` headers at ingress and adds none). A generic client - one route, the server and the tool encoded in the call - was
suggested and set aside the same day: ADR-0033 rejects it as alternative (c), since the catalog cannot certify a target
hidden in the call; the certifiable form is an optional `headers` map on the catalog row, an engine change in both
engines, not taken. **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.
<!-- id: mcp-binding-route-per-tool | created: 2026-11-14 | last_used: 2026-11-14 | uses: 1 | tier: working | origin: 2026-11-14-073523 -->
Expand Down
7 changes: 5 additions & 2 deletions memory/open-threads/thread-mcp-binding.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
- [ ] **The MCP binding (RFC-0011 / ADR-0033 build plan step 4): `extensions/mcp-binding/`, built 2026-11-14; PR #51 MERGED 2026-11-14 (merge `ea3783d`).**
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 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
46 tests; the HTTP transport driven live against a public MCP server (the Mermaid Chart endpoint) the same day. The Node
twin landed the same day (mercury-nodejs #118, the same vector bytes), and the engine-side drive ran with an MCP tool in a
graph's catalog (mercury-composable #626 and #627, the `diagram-assistant` example on the Mermaid server). The `@preload`
shape - every bound route an alias of the one function `mcp.tool` - is PR #52 from branch `refactor/mcp-binding-preload`
(opened 2026-11-14; the Node twin of that shape is Eric's call). Next: the pack's catch-up release carries it
([[pack-catch-up-4-12-25]]). Fact: [[mcp-binding-route-per-tool]].
→ serves: vision-mercury-python
<!-- id: mcp-binding | created: 2026-11-14 | last_used: 2026-11-14 | uses: 1 | tier: working | origin: 2026-11-14-073523 -->
57 changes: 57 additions & 0 deletions memory/sessions/2026-11-14-104712.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Session (2026-11-14T10:47:12.000Z)

**Agent:** Claude Code
**Title:** The MCP binding's bound routes become aliases of one `@preload` function, `mcp.tool`

## Context

Follows 2026-11-14-073523. After the engine-side drive of the diagram assistant (mercury-composable #626 and #627), Eric
asked which function of this pack serves `diagram.render.mcp`. The answer was a closure: `_handler(server, binding)`
returned an inner `call` that `Binding.boot` registered with `default_registry.register`, one closure per allowlisted
tool. Eric asked for the composable shape instead: the function exposed with the `@preload` decorator, as `llm.chat` and
`mcp.health` are.

## The design question, and Eric's ruling

A `@preload` route is a literal at import time, while the binding's routes come from the servers file at boot, so one
declared function has to serve every bound route and learn which one it was called as. The pack's host strips `my_*`
headers at ingress and adds only `my_correlation_id`, so the engines' `my_route` header is not available to a function;
but the bus sets a `TraceInfo` whose `route` is the executing service's route for every execution, and `get_trace()` is
public - the pack's counterpart of the engines' `PostOffice.getRoute()`. No SDK change was needed.

Eric then suggested a generic MCP client: one fixed route, the destination server encoded in the call, reusable for any
server. ADR-0033 (accepted the same day in mercury-composable) lists that shape as rejected alternative (c): a target hidden
in the call is one the catalog cannot certify. The certifiable form would be an optional `headers` map on the catalog row,
sent by the loop with each call - an engine change in both engines, with the shared vectors, both packs, the example and
the ADR amended. Offered the three shapes, Eric chose to keep one route per allowlisted tool and refactor the shape only.

## What changed (branch `refactor/mcp-binding-preload`, PR #52)

- `extensions/mcp-binding/mcp_binding.py`: `mcp_tool` is declared with `@preload(route="mcp.tool", instances=10,
private=True)`; it reads `get_trace().route`, resolves the server and the allowlisted tool through `Binding.bound(route)`
(404 for a route bound to none, `mcp.tool` itself included), checks that the body is a map (400) and calls the tool.
`Binding.boot` fills `routes` (route to server and tool) and registers each allowlisted tool's route as an alias of
`mcp_tool` with the server's `instances`; `shutdown` clears both tables. The `_handler` factory is gone.
- `tests/test_mcp_binding.py`: the calls go through `PostOffice` local RPC, the path behind the HTTP host, so the alias
dispatch and the route in the execution context are what the tests exercise; the `po` fixture closes the registry's bus
after each test (its workers bind to the loop of their first use); the `servers` fixture installs the fresh `Binding`
as the module's instance, the one `mcp_tool` and `mcp_health` read; the first test pins the shape: every bound route's
handler is `mcp_tool`, public, with the server's `instances` (3), `mcp.tool` private, a call to it a 404.
- `extensions/mcp-binding/README.md` and the CHANGELOG's Unreleased entry describe the shape.

## Verification

- `ruff check .` clean; `basedpyright` 0 errors when run with the venv's interpreter (`--pythonpath ~/sandbox/.venv/bin/python`;
without it the checker resolves no package and reports 62 environmental errors); `tests/test_mcp_binding.py` 46 passed;
the suite 397 passed, 64 skipped, as before the change.
- Live, through the serving path (`mercury-serve` with the Playground example's `mermaid-servers.yml`, the public Mermaid
Chart MCP server), four calls over Event-over-HTTP to `/api/event`: `diagram.render.mcp` answered 200 with the server's
structured content (`valid: true`; the host logged `Handled diagram.render.mcp status=200 exec_time=3597 ms`); `mcp.tool`
answered 403 `mcp.tool is private`; a body that is not a map 400 naming the tool; an unknown route 404 from the host. The
process was stopped afterwards. Lesson for the next drive: the PostOffice client posts to the URL as given, so the
endpoint is the full `/api/event` URL, not the origin.

## Memory References

- Relied on: mcp-binding-route-per-tool (the binding's contract, unchanged; edited with the shape and the generic-client ruling)
- Threads: mcp-binding (edited: the Node twin and the engine-side drive recorded as done, this refactor added)
Loading
Loading