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_agentfrom 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].
Tools behave like any other tool
Section titled “Tools behave like any other tool”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=[...].
Read vs write
Section titled “Read vs write”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 varsA missing env var fails at connect with the variable named. Secrets never enter the event log; only tool results are recorded.
Resilience
Section titled “Resilience”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.
v1 limits
Section titled “v1 limits”- 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_bytesis 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.