Skip to Content
MCPServer specs + registry

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__status and fs__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.status that 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 qualified

The 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.unavailable and the pull is retried on the next refresh().
  • 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

ErrorCause
MCPErrorConnect-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.

Last updated on