Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

Calling MCP servers

mcp_servers= connects an agent to remote Model Context Protocol servers. Their tools become Pyrula @tools. Ships with pip install "pyrula[agents]".

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

The worker opens one session per server at startup and closes it on shutdown. Per-server options on MCPServer: timeout_s=30, retry=2, cache_ttl_s, allow / deny, connect_timeout_s=10, max_tool_pages=100. ctx.mcp_tools("gh") returns that server’s tools and ctx.mcp_tools() every server’s; splat them into tools=[...] next to native tools.

The startup listing follows the server’s nextCursor through every page of tools/list. The handshake and then the whole listing each get connect_timeout_s. A listing that cannot be read to its end fails the connect with an MCPConnectionError naming the server, rather than loading some of the tools: the server needs more than max_tool_pages pages, repeats a cursor it was already sent, lists one tool twice (on one page or two), answers a page with an error or a malformed reply, or does not finish within connect_timeout_s (the message says how many pages were read). Raise max_tool_pages for a server that really pages that far. connect_retries covers only the connection and handshake, so a failed listing is not retried: a server whose tool list changes while it is being paged fails startup.

A discovered tool is named server.tool (gh.search_issues) and runs like a native one: the result is recorded and replayed on recovery, and timeout, retries and spans apply. This is the mcp.client row of the guarantee matrix; the mcp.server row is build_mcp_server.

Text blocks pass through verbatim, joined with newlines. Other content becomes a placeholder: [image: image/png], [audio: audio/wav], [resource link: file:///report.pdf], [resource: file:///blob.bin (application/octet-stream)] (embedded text resources contribute their text). With no content blocks, the result is structuredContent as compact JSON with sorted keys.

A result flagged isError raises MCPToolError with the server’s text. The call is recorded as failed and replays as failed without calling the server. It is not retried.

In a counterfactual replay, a write-capable MCP tool is refused before it is invoked. A tool with readOnlyHint=true is a read and runs live in branches; one without the hint is a write. The hint is taken at the server’s word, though the MCP spec calls annotations untrusted, so for a server you don’t control overrides={"name": "write"} beats the hint. To classify a read-only tool that omits it:

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

overrides, allow and deny key on the bare tool name (search_issues). describe_mcp_tools(pool) from pyrula.agents.inspection shows each tool and its classification.

MCPServer(name="gh", url=..., requires_approval=("delete_issue", "merge_pull_request"))

Each live call to a named tool parks the run on an approval, as for @tool(requires_approval=True). A name the server doesn’t expose fails at connect. @kafka_agent refuses a server that sets it.

Read from the environment when the worker connects; a missing variable fails at connect, naming it.

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

Secrets never enter the event log; only tool definitions and results are recorded.

A server that answers 401 or 403 raises MCPAuthError, at connect (the handshake and the tool listing) and on a tool call alike. It is not retried and the pool does not reconnect for it: the same credentials would be refused again. For a tool call the server refused the request before acting on it, so the tool did not run and a write is not MCPCallOutcomeUnknown. The next call to that server reconnects before it sends and reads the credentials from the environment again, so a rotated token is picked up. The message names the server and the status; it never repeats the server’s response or your credentials.

An unreachable server is retried at startup (connect_retries=3 attempts, connect_backoff_s=0.5 base), then fails the worker at boot. The pool sends a call at most once unless sending it again is safe. (A worker that crashes after a send and before the result is recorded runs the call again on resume, as for any tool.) A call that finds its session dropped reconnects first and then sends. A call that was already sent when the session dropped may have run: it is sent again only if the server marks the tool readOnlyHint or idempotentHint (or overrides says read), or if the server answered the call with 404 because it no longer had the session (after a restart) and so ran nothing. A server on mcp 1.24 or 1.25 answers 400 for an unknown session, which no client can tell from other refusals, so after a restart a write tool there raises MCPCallOutcomeUnknown and a read tool is sent again. A 401 or 403 is not a drop (see Auth); a 400 is treated as one, because of that older answer. Any other tool raises MCPCallOutcomeUnknown, which is recorded as the tool’s failure and not retried; its message tells the model to check the effect before calling again. A timed-out write is not resent either; its error says it may have run. The server’s hints are taken at its word, though the MCP spec calls annotations untrusted: overrides={"name": "write"} opts a tool out of any resend. retry is the retry budget of read-only and idempotent tools only; every other MCP tool gets none, so a transport failure or timeout is not silently resent. A run that already used a server replays from recorded definitions and results even if the server is later down.

  • Remote streamable HTTP only; stdio servers are not run for you (front one with a stdio-to-HTTP proxy).
  • Tools only; resources and prompts are not exposed.
  • Image, audio and binary content reaches the model as a placeholder.
  • A result over max_event_payload_bytes is truncated before it is recorded.
  • Calls to one server serialize (one session per server).
  • Bearer and header auth only; no OAuth.

View this page as Markdown