# Calling MCP servers

> Call tools from remote MCP servers inside a Pyrula turn.

Source: https://docs.pyrula.io/agents/integrations/mcp/

`mcp_servers=` connects an agent to remote [Model Context Protocol](https://modelcontextprotocol.io) servers. Their tools become Pyrula [`@tool`](https://docs.pyrula.io/agents/authoring/tools.md)s. Ships with `pip install "pyrula[agents]"`.

```python
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](https://docs.pyrula.io/agents/integrations/guarantees.md); the `mcp.server` row is [`build_mcp_server`](https://docs.pyrula.io/agents/integrations/mcp-server.md).

## What the model reads

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.

## Read vs write

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:

```python
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.

## Approval

```python
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)`](https://docs.pyrula.io/agents/authoring/tools.md#approval). A name the server doesn't expose fails at connect. `@kafka_agent` refuses a server that sets it.

## Auth

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

```python
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.

## Resilience

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.

## Limits

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