Server specs + registry
An MCPServerSpec describes how to reach one MCP server: launch it as
a local process (stdio) or connect to it over HTTP. An MCPRegistry
combines several specs into one tool host for an agent, and handles
name clashes, failures and cleanup.
MCPServerSpec.stdio(...)
Launches an MCP server as a subprocess and talks to it over stdio.
from loomflow.mcp import MCPServerSpec
spec = MCPServerSpec.stdio(
name="git", # registry id
command="uvx", # the binary to launch
args=["mcp-server-git", "--repository", "/Users/me/code/myrepo"],
env={"GIT_AUTHOR_NAME": "agent"}, # optional extra env
)The name becomes the prefix for tool-name disambiguation
(git__commit etc.).
MCPServerSpec.http(...)
Talks to a hosted MCP server over Streamable HTTP transport.
spec = MCPServerSpec.http(
name="hosted",
url="https://example.com/mcp/",
headers={
"Authorization": "Bearer sk-...",
"X-Tenant-Id": "acme",
},
)The framework keeps a single connection open per server for the lifetime of the registry; reconnects on transient failures.
MCPRegistry: combine multiple servers
from loomflow import Agent
from loomflow.mcp import MCPRegistry, MCPServerSpec
registry = MCPRegistry([
MCPServerSpec.stdio("git", "uvx", ["mcp-server-git", "--repository", "."]),
MCPServerSpec.stdio("fs", "npx", ["-y", "@modelcontextprotocol/server-filesystem", "."]),
MCPServerSpec.http("hosted", url="https://example.com/mcp/"),
])
agent = Agent("...", model="claude-opus-4-7", tools=registry)MCPRegistry implements the ToolHost protocol. Pass it directly
to Agent(tools=...).
Tool-name conflict resolution
If two servers expose a tool with the same simple name:
- Both get qualified as
server__tool:git__statusandfs__status. - The agent sees both qualified names in the tool list.
- The qualified form is always accepted at call time; the registry
strips the prefix before forwarding. The dotted
git.statusthat earlier releases used still resolves too.
If only one server has the name, it stays bare (and its qualified forms are accepted as well):
commit # only git ships commit
fs__status, git__status # both ship status; both qualifiedThe separator is a double underscore because Anthropic and OpenAI
only accept tool names matching ^[a-zA-Z0-9_-]{1,128}$: a . in a
tool name is rejected. For the same reason, any other character
outside that set in an MCP tool’s name is replaced with _ in the
name the model sees.
Upgrading from 0.16 or earlier. Qualified MCP tool names changed
from server.tool to server__tool. Calls by the old dotted name
still work, but permission lists (allowed_tools / denied_tools),
hooks and audit queries that match on tool names see the new name.
Listing tools at runtime
tools = await registry.list_tools()
for t in tools:
print(t.name, t.description)list_tools is a coroutine. The registry queries each server’s
tools/list endpoint on first call and caches the result. A server
that pages its listing gets followed through nextCursor until it
stops returning one, so every page is included; the same goes for
resources/list and prompts/list.
Destructive MCP tools
A server’s tool annotations decide whether the tool counts as
destructive: destructiveHint: true marks it destructive, and when a
tool has no destructiveHint, readOnlyHint: false does too. A tool
with neither isn’t. Destructive MCP tools are gated like
@tool(destructive=True): under StandardPermissions(mode=Mode.DEFAULT)
(or ACCEPT_EDITS) each call goes to the
approval handler.
(Before 0.17, on mcp SDK 2.x the annotations weren’t read, so these
tools ran without asking.)
The hints are the server’s own claim. For a server you don’t fully
trust, name its risky tools in denied_tools or gate them with your
own policy instead of relying on its annotations.
Fault isolation + unavailable
One dead server must not make every other server unusable. The registry connects all clients in parallel and isolates per-server failures at every stage:
- Connect failures are logged, not raised — the healthy servers’ tools register normally.
- Listing failures skip that server’s tools from the index; its
name is recorded in
registry.unavailableand the pull is retried on the nextrefresh(). - Call-time transport failures (broken pipe, server restart, connection drop) trigger a reconnect-and-retry — once, and only for errors that look like connection failures. A mid-execution error may mean the server already performed a side effect, so it is surfaced instead of silently re-run.
await registry.connect()
if registry.unavailable:
log.warning("degraded MCP servers: %s", registry.unavailable)listChanged → automatic refresh
MCP servers can announce that their tool list changed
(notifications/tools/list_changed) — a server that gains or loses
capabilities mid-run. The registry handles this automatically: the
notification flags the server stale, and the next registry operation
(list, call) re-pulls that server only and rebuilds the index.
No polling, no restart, no re-listing of healthy siblings.
To observe changes, subscribe to the diff stream:
async for event in registry.watch():
print(event.kind, event.tool, event.server) # "added" / "removed" / "updated"watch() registers eagerly (no events lost between subscribe and
iterate) and each subscriber has a bounded buffer — a slow consumer
drops events rather than blocking refreshes.
Lazy initialization
MCPRegistry connects to its servers lazily. The subprocess
launches and the HTTP connection opens on the first agent.run. This
keeps Agent(...) synchronous and avoids paying for unused servers
in tests.
Cleanup
Hold the registry across runs, and close it when you’re done:
await registry.aclose() stops the stdio subprocesses and closes the
HTTP connections. The Agent doesn’t close a registry it was given.
The registry is also an async context manager, which connects on entry
and closes on exit:
from loomflow import Agent
from loomflow.mcp import MCPRegistry, MCPServerSpec
async with MCPRegistry([MCPServerSpec.stdio("git", "uvx", ["mcp-server-git"])]) as registry:
agent = Agent("...", model="claude-opus-4-7", tools=registry)
await agent.run("summarise the last five commits")Errors
| Error | Cause |
|---|---|
MCPError | Connect-phase failure: SDK not installed, bad spec, unsupported transport, unknown server / resource / prompt. |
ToolResult(ok=False) | A tool call failed (unknown tool, server-side isError, transport failure after the one reconnect retry). The error text reaches the model as the tool result; the run continues. |
Server failures never crash a run: a server that can’t list tools is
simply absent from the index (and named in unavailable), and a
failing tool call surfaces to the model as an error result it can
react to.
Multiple instances of the same server. Two stdio specs with the
same command but different name and different --repo (etc.) run
as two independent subprocesses. Useful for “git for repo A” and
“git for repo B” in the same agent.