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.

Serving tools over MCP

build_mcp_server turns Pyrula @tools into a Model Context Protocol 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 covers the other direction. Ships with pip install "pyrula[agents]".

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.
tools_server.py
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()
worker.py
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 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.

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.

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

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

Terminal window
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):

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

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.

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:

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())
  • 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.

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.

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.

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.

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

View this page as Markdown