Skip to content
Pyrula

MCP servers

mcp_servers= connects an agent to remote Model Context Protocol servers. Their tools become Pyrula @tools, so the model calls them the same way it calls native tools.

Declare the servers on the decorator, then hand the discovered tools to the model with ctx.mcp_tools:

from pyrula.agents.kafka import kafka_agent
from pyrula.agents.mcp import MCPServer, BearerToken
@kafka_agent(input_topic="issues", group="triage",
mcp_servers=[MCPServer(name="gh", url="https://mcp.example/github",
auth=BearerToken(env="GH_MCP_TOKEN"))])
async def triage(ctx, issue: Issue) -> None:
async for _ in ctx.llm.stream(
messages=[{"role": "user", "content": issue.body}],
tools=[*ctx.mcp_tools("gh")],
):
...

The decorator owns the connection: the worker opens one session per server at startup and closes it on shutdown. ctx.mcp_tools("gh") returns that server’s tools; ctx.mcp_tools() returns every connected server’s tools. They splat into tools=[...] alongside native tools, so the list you write is the full set the model sees.

Needs pip install pyrula-agents[mcp].

A discovered tool is named server.tool (gh.search_issues), which keeps it distinct from native tools and from other servers. It runs through the same path as a native tool: the result is recorded and replayed on recovery, the per-call timeout and retries apply, and otel spans are emitted. Nothing about MCP is special to the runtime once the tool is in tools=[...].

A counterfactual branch mocks write-capable tools so a re-run never triggers real side effects. Pyrula reads the MCP readOnlyHint annotation to decide: a tool that declares readOnlyHint=true is a read and runs in branches; a tool that omits it is treated as a write and is mocked. The safe default is write, so a server that under-annotates errs toward not firing real side effects.

If a read-only tool gets mocked because its server omits the hint, set it straight per server:

MCPServer(name="gh", url=..., overrides={"search_issues": "read"})

overrides, allow, and deny all key on the bare tool name (search_issues), not the namespaced gh.search_issues. To see what a server exposed and how each tool was classified, use describe_mcp_tools(pool) from pyrula.agents.inspection.

Auth comes from the environment, read when the worker connects:

from pyrula.agents.mcp import BearerToken, Headers
BearerToken(env="GH_MCP_TOKEN") # Authorization: Bearer <$GH_MCP_TOKEN>
Headers(env={"X-Api-Key": "ACME_API_KEY"}) # arbitrary headers from env vars

A missing env var fails at connect with the variable named. Secrets never enter the event log; only tool results are recorded.

A server that is briefly unreachable at startup is retried before the worker gives up (connect_retries, connect_backoff_s on MCPServer); a server that stays unreachable fails the worker at boot rather than starting without its tools. If a session drops mid-run, the next call to that server reconnects once and retries. A run that already used a server still replays from its recorded results even when that server is down later.

v1 is for hosted or remote MCP servers. You declare a server by URL and auth, the same way you’d register a tool. There is no discovery or marketplace, and no local-process management: stdio servers (the local community ecosystem) are not run for you. If you need one, front it with an stdio-to-HTTP proxy and point the URL at that.

  • Remote HTTP transport only (streamable HTTP). stdio is deferred behind the transport seam.
  • Tools only. MCP resources and prompts are not exposed yet.
  • Text results. Image and embedded-resource content raises rather than being dropped silently.
  • A result larger than the agent’s max_event_payload_bytes is truncated (by bytes) before it is recorded.
  • Calls to a single server serialize (one session per server). Parallel fan-out to one server is a later improvement.
  • Bearer and header auth. OAuth is not in v1, though the auth path is built so it slots in without changing the transport.