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]".
A server and its worker
Section titled “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.
- Put the tools, the store and the server in one module (
tools_server.pybelow). - Start a worker that imports that module, so the tool workflows it registers are served.
- Point the MCP host at the server.
from pyrula.agents import toolfrom pyrula.agents.mcp import build_mcp_serverfrom 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)
@toolasync 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()import asynciofrom pyrula.workflows import WorkflowWorkerfrom 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.
Serving over HTTP
Section titled “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.
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/mcpapp = server.http_app(auth=auth, path="/mcp") # or mount the ASGI app yourselfauth=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.
Claude Code and Cursor
Section titled “Claude Code and Cursor”For a stdio server, the host starts the process itself. In Claude Code:
claude mcp add pyrula-tools -- python /path/to/tools_server.pyIn 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.
What the host sees
Section titled “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
Section titled “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:
import asyncio
from pyrula.agents import toolfrom pyrula.agents.mcp import build_mcp_serverfrom pyrula.workflows import MemoryStore, WorkflowWorker
try: from mcp.client.client import Client as connect # mcp 2.xexcept 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"
@toolasync 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
Section titled “Time limits”timeout=onbuild_mcp_server(defaultNone, 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
Section titled “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.
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.
Identity
Section titled “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.
Limits
Section titled “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
mcppackage is supported from 1.24 up to, but not including, 3.0 (thepyrula-agentsinstall keeps to that range). - Idempotency comes only from a client-sent
_meta.idempotency_keyor youridempotency_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.