# Serving tools over MCP

> Expose Pyrula tools to Claude Code, Cursor or any MCP host with build_mcp_server, where each call is a durable run that finishes even if the host disconnects.

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

`build_mcp_server` turns Pyrula [`@tool`](https://docs.pyrula.io/agents/authoring/tools.md)s into a [Model Context Protocol](https://modelcontextprotocol.io) server for Claude Code, Cursor, Claude Desktop or an agent of your own. Each call is submitted as a durable run to your store and executed by a Pyrula worker, so a tool that is halfway through when the host closes or crashes still finishes, and its result is in the store. [Calling MCP servers](https://docs.pyrula.io/agents/integrations/mcp.md) covers the other direction. Ships with `pip install "pyrula[agents]"`.

## A server and its worker

Two processes share one store. The MCP server submits each tool call as a run and waits for the result. A worker claims the run and executes the tool body.

1. Put the tools, the store and the server in one module (`tools_server.py` below).
2. Start a worker that imports that module, so the tool workflows it registers are served.
3. Point the MCP host at the server.

```python
from pyrula.agents import tool
from pyrula.agents.mcp import build_mcp_server
from pyrula.workflows import SQLiteStore

store = SQLiteStore("runs.db")

@tool(read_only=True)
async def lookup_order(ctx, order_id: str) -> str:
    """Look up an order's status."""
    return await orders.status(order_id)

@tool
async def refund_order(ctx, order_id: str) -> str:
    """Refund an order."""
    return await payments.refund(order_id)

server = build_mcp_server([lookup_order, refund_order], store=store)

if __name__ == "__main__":
    server.run_stdio()
```
```python
import asyncio
from pyrula.workflows import WorkflowWorker
from tools_server import store

asyncio.run(WorkflowWorker(store=store).run())
```


`build_mcp_server` registers each tool as a workflow named `mcp:tool:<tool name>`
(`name_prefix=` changes the prefix). A worker that has not imported the module never
claims those runs, and the host's call waits until `timeout`. Use a store both processes
can reach: `SQLiteStore` on one host, `ValkeyStore` across hosts. `MemoryStore` works only
when the server and the worker share a process, as in the example below.

`build_mcp_server` returns a `DurableMCPServer`. `run_stdio()` serves one host over stdin and
stdout; [Serving over HTTP](#serving-over-http) covers the network. Its `sdk_server` property
is the MCP SDK's own server object (`FastMCP` on `mcp` 1.x, `MCPServer` on 2.x), for anything
Pyrula does not wrap. That object carries no Pyrula authentication and its type is not
guaranteed across `mcp` releases, so annotate it `Any`.

## Serving over HTTP

HTTP is served only behind an authentication decision. `auth` is a required keyword on
`http_app` and `run_http`, and it takes the providers `RunRouter` takes: `APIKeyAuth`,
`JWTAuth`, or any callable that takes `HTTPAuthorizationCredentials` and returns the
caller. Every request, POST, GET and DELETE alike, must carry `Authorization: Bearer ...`
that the provider accepts; otherwise the answer is a plain 401 with
`WWW-Authenticate: Bearer`. A provider that returns `None` or raises refuses the request the
same way; only its own 403 or 503 keeps its status. A session is bound to the principal that
opened it, so another key presenting that session id gets 404, as does an id the server never
issued. Past 10,000 open sessions a new one is refused with 503 until some close or sit idle for
an hour. The binding lives in the server process, like the SDK's own sessions, so several
replicas need sticky routing. A provider's principal is identified by its `str()`, so a custom
provider must return something whose `str()` is the caller's stable id (a string such as a user
id); an empty or falsy value is refused.

```python
import os

from pyrula.workflows.web import APIKeyAuth

auth = APIKeyAuth({"desk": os.environ["MCP_KEY_DESK"], "ci": os.environ["MCP_KEY_CI"]})

server.run_http(auth=auth, host="127.0.0.1", port=8000)       # serves http://127.0.0.1:8000/mcp
app = server.http_app(auth=auth, path="/mcp")                   # or mount the ASGI app yourself
```

`auth=None` serves open on purpose, and `run_http` refuses it on any host other than
loopback unless you pass `allow_unauthenticated=True`. The verified principal is the caller's
identity (see [Identity](#identity)). An HTTP app serves once: build a new server to serve
again. This gate does not speak MCP OAuth discovery, so a host that can only obtain tokens
through it cannot connect; hosts that send a static header (Claude Code `--header`, Cursor
`headers`) can. For SDK-native OAuth, configure the SDK's server through `sdk_server`, which
then has no Pyrula authentication and no principal: every caller shares one idempotency scope
unless you set `identity_from_request` to read the SDK's own access-token context.

## Claude Code and Cursor

For a stdio server, the host starts the process itself. In Claude Code:

```bash
claude mcp add pyrula-tools -- python /path/to/tools_server.py
```
In Cursor, add it to `.cursor/mcp.json` in the project (or `~/.cursor/mcp.json` for every project):

```json
{
  "mcpServers": {
    "pyrula-tools": {
      "command": "python",
      "args": ["/path/to/tools_server.py"]
    }
  }
}
```

A server you run yourself with `run_http` is added by URL and key instead:
`claude mcp add --transport http pyrula-tools http://127.0.0.1:8000/mcp --header "Authorization: Bearer $MCP_KEY_DESK"`
in Claude Code, and
`{"url": "http://127.0.0.1:8000/mcp", "headers": {"Authorization": "Bearer ${env:MCP_KEY_DESK}"}}`
as the server entry in Cursor. Either way the worker runs separately, and the host never
starts it.

## What the host sees

Every tool is listed with its name, its docstring as the description, and an input schema built from its parameters. A parameter named `ctx` is left out of the schema. A tool declared `read_only=True` carries the MCP `readOnlyHint`; every other tool states `readOnlyHint=false`. A tool whose first parameter is annotated `ToolContext` gets a real `ToolContext` with an HTTP client; any other tool gets the workflow context.

A call that succeeds returns the tool's return value. The text of an exception your tool body
raises reaches the caller, so keep secrets out of tool error messages; any other server fault is
logged and answered as "internal error". A call whose run fails, is cancelled or
outlives `timeout` returns an MCP tool error with the reason.

## A host that disconnects

The run belongs to the store, not to the host's connection. When the host closes the session, cancels the call or crashes, the server stops waiting and the worker keeps going, so the tool body finishes once and its result is recorded.

Whether the host gets that result back depends on the idempotency token. A host that sends a token in the request's `_meta` (`{"idempotency_key": "refund-o-17"}`) re-attaches to the same run when it calls again with that token: a finished run answers at once, a running one is waited for, and the tool body does not run a second time. Without a token every call is its own run, so a retry after a disconnect runs the tool again. The JSON-RPC request id is not a fallback, since ids restart with every session.

A token is scoped to the caller and the tool. The caller is the authenticated principal of an
HTTP server, or whatever `identity_from_request` returns when you set it. Two callers that
send the same token get separate runs, and each reads only its own result. The run id
carries a hash of the identity, never the identity itself. A request that
`identity_from_request` maps to `None` has no caller to scope to, so its token is ignored and
every call is a fresh run. A caller with no identity at all (stdio, or an HTTP server served
with `auth=None`) shares one scope with every such caller. That suits a stdio server with a
single host; an open HTTP server logs a warning, once per process, the first time a token
arrives that way. The arguments are part of the call for as long as the run exists: reusing a
token with different arguments fails with an `IdempotencyConflict` tool error, and the first
run is unchanged. A token must be a string of 1 to 256 characters; anything else is a tool
error. Make each token unique per call, for example a UUID the client keeps for its retries
of that one call.

For one caller, tool and token, the tool body completes at most once, and every repeat with
the same arguments returns that result: after the client reconnects, after the MCP server
process restarts and after a worker restarts, as long as the store is durable (`SQLiteStore`,
`PostgresStore` or `ValkeyStore`; a `MemoryStore` gives this only within one process). It lasts
as long as the run is retained, because the run id is the key. A worker that crashes inside
the tool body runs that body again on resume, as any step does. A principal that changes
its name loses the dedupe across the change.

Reusing a token with different arguments fails with an `IdempotencyConflict` tool error, and the first run is unchanged. A token must be a string of 1 to 256 characters. Make each token unique per call, for example a UUID the client keeps for its retries of that one call.

Claude Code and Cursor send no token, so treat their calls as at least once for anything that writes. Or pass `idempotency_from_request=`, a function that takes the request (`request.id`, `request._meta`) and returns a token unique per call, or `None` for a fresh run.

The following example runs a server and a worker in one process, cancels a refund halfway through as a disconnecting host would, then calls again with the same token:

```python
import asyncio

from pyrula.agents import tool
from pyrula.agents.mcp import build_mcp_server
from pyrula.workflows import MemoryStore, WorkflowWorker

try:
    from mcp.client.client import Client as connect   # mcp 2.x
except ImportError:                                   # mcp 1.x
    from mcp.shared.memory import create_connected_server_and_client_session as connect

refunds: list[str] = []

@tool(read_only=True)
async def lookup_order(ctx, order_id: str) -> str:
    """Look up an order's status."""
    return f"{order_id}: shipped"

@tool
async def refund_order(ctx, order_id: str) -> str:
    """Refund an order."""
    await asyncio.sleep(0.5)
    refunds.append(order_id)
    return f"refunded {order_id}"

store = MemoryStore()
server = build_mcp_server([lookup_order, refund_order], store=store, poll_interval=0.05)

async def main() -> None:
    worker = WorkflowWorker(store=store)
    serving = asyncio.create_task(worker.run())
    token = {"idempotency_key": "refund-o-17"}

    async with connect(server.sdk_server) as host:
        listed = await host.list_tools()
        hints = {t.name: t.annotations.model_dump(by_alias=True)["readOnlyHint"] for t in listed.tools}
        assert hints == {"lookup_order": True, "refund_order": False}

        call = asyncio.create_task(
            host.call_tool("refund_order", {"order_id": "o-17"}, meta=token)
        )
        await asyncio.sleep(0.2)
        call.cancel()   # the host goes away mid-refund

    async with connect(server.sdk_server) as host:
        result = await host.call_tool("refund_order", {"order_id": "o-17"}, meta=token)

    assert result.content[0].text == "refunded o-17"
    assert refunds == ["o-17"]   # the second call re-attached; the body ran once
    worker.stop()
    await serving

asyncio.run(main())
```
## Time limits

- `timeout=` on `build_mcp_server` (default `None`, no limit) is how long the server waits for a result. Past it the host gets a tool error, and the run carries on.
- The tool's own `@tool(timeout=...)` (default 30 seconds) is the run's timeout.

`poll_interval` (default 0.5 seconds) is how often the server checks the store for the result.

## Approval and model calls

`build_mcp_server` refuses a tool declared `@tool(requires_approval=True)` with a `ToolRegistrationError`, since an MCP tool server has nobody to ask. A tool gated by a predicate is built, and a call whose arguments meet the predicate fails. Keep approval in an agent that calls the tool; see [Approval](https://docs.pyrula.io/agents/authoring/tools.md#approval).

A tool body here runs outside any agent turn, so it has no `ctx.llm`, no subagents and no spend caps. The tool call is one atomic step: the body cannot call `ctx.step`, `ctx.interrupt`, timers or signals. For multi-step work, expose a tool that starts a workflow with [`run_and_await`](https://docs.pyrula.io/agents/integrations.md#custom-integrations).

## Identity

A run's owner is the identity of whoever called. On an HTTP server that is the principal the
auth provider returned (an `APIKeyAuth` key name, a `JWTAuth` subject), with no wiring.
`identity_from_request=` overrides it: it takes the same request object as
`idempotency_from_request` (`request.id`, `request._meta`, and `request.principal`) and returns
a string or `None` (anonymous). Over stdio there is no principal, so every call is anonymous
unless you set it. The identity also scopes idempotency tokens, as described under
[A host that disconnects](#a-host-that-disconnects).

## Limits

- Tools only. MCP resources and prompts are not served.
- A call returns one result; the server does not stream progress or partial output.
- No approval on this path, as above.
- The `mcp` package is supported from 1.24 up to, but not including, 3.0 (the `pyrula-agents` install
  keeps to that range).
- Idempotency comes only from a client-sent `_meta.idempotency_key` or your
  `idempotency_from_request`, and it is scoped by the caller's identity. Callers with no
  identity share one scope.

This server is the `mcp.server` row of the [adapter guarantee matrix](https://docs.pyrula.io/agents/integrations/guarantees.md).
