Mount Burr state-machine Applications as MCP servers.
Project description
Theodosia
Theodosia serves a Burr state machine as an MCP server. The agent calls one step tool; the server checks reachability against the graph before each action runs, refuses out-of-order calls with the legal next moves, and records every attempt.
An open 1T-parameter model (Kimi K2.6) investigating a live incident on rails: each Grafana query is recorded as evidence, out-of-phase calls are refused, and the conclusion stays gated until the evidence cross-references. The investigation FSM (Phoebe) is the workflow; Theodosia is what makes the model drive it.
- The server enforces the graph. An unreachable action returns a structured refusal listing the ones that are reachable, and the agent self-corrects from it.
- Every step, its inputs, the state change, refusals, and timing are recorded. Replay any session step by step (
theodosia sessions show, the Burr UI) and fork from any past state. - Drive the same graph from your own Python or hand it to an external LLM over MCP. The workflow is a versioned artifact, not tied to either.
- Apache Burr is the workflow engine; FastMCP is the MCP layer. Theodosia is the thin layer that makes one drive the other.
Why this shape works
LLM agents fail at procedural work in predictable ways: they skip steps, stop too early or not at all, and declare success without verifying. Research on why multi-agent systems fail (MAST, Cemri et al.) finds the interventions that held came from architecture, external verification and a termination-enforcing state machine, not prompt tweaks. Theodosia is that state machine, served over the wire. It removes the structural failures, not reasoning errors inside a valid step: the agent keeps its full toolset (including other MCP servers via upstream) and chooses freely within each step.
More: IBM IT-Bench + MAST · MAST, UC Berkeley · Microsoft AIOpsLab · Grafana o11y-bench
What the rails do, shown
Two grader-verified cases (case study):
the same model (Kimi K2.6) on o11y-bench incident tasks,
run free-ranging with the raw Grafana toolset versus on rails through
Phoebe. Free-ranging, it trails off without
an answer, on one task across all three runs, on another it solves it twice and
abandons it once. On rails, the conclude gate forces a committed, correct
conclusion every time. o11y-bench's own grader is the witness: "There is no
final response message in the transcript, it ends with tool calls and thinking
blocks."
On these two cases the rails did not cost accuracy, and the agent finished the ones it would otherwise abandon. Every run is a recorded, replayable, forkable artifact (every step, input, state change, and refusal). A full aggregate across the category is pending a clean benchmark run. Design rationale is in the research foundation.
Try it in 30 seconds
uv pip install theodosia
theodosia primer
No API key, no LLM, no setup. Walks the coffee-order FSM through Theodosia's step tool in-process, prints the timeline with state diffs, then provokes one structured refusal so you see what an agent recovers from. Same output every run.
Install
uv pip install theodosia # or: pip install theodosia
Python 3.11 through 3.13. Optional extras: theodosia[observability], theodosia[ui], theodosia[all].
On a slim Docker image (python:3.13-slim, Alpine) the install pulls a psutil build that needs gcc and python3-dev. Either use the full python:3.13 image, or apt-get install -y gcc python3-dev before pip install.
Quickstart
from burr.core import ApplicationBuilder, State, action
from burr.core.action import Condition
from theodosia import mount
@action(reads=[], writes=["item", "stage"])
def take_order(state: State, item: str) -> State:
return state.update(item=item, stage="ordered")
@action(reads=["stage"], writes=["stage"])
def pay(state: State, amount: float) -> State:
return state.update(stage="paid")
def build_application():
is_ordered = Condition.expr("stage == 'ordered'")
return (
ApplicationBuilder()
.with_actions(take_order=take_order, pay=pay)
.with_transitions(("take_order", "pay", is_ordered))
.with_state(item=None, stage="empty")
.with_entrypoint("take_order")
.build()
)
if __name__ == "__main__":
mount(build_application, name="coffee").run()
Save as coffee.py and run python coffee.py to serve over stdio. Pass a factory (a callable returning a built Application) so each MCP session gets its own isolated state. Passing an already-built Application works too but shares state across sessions.
To exercise it without a real client, use FastMCP's in-process Client:
import asyncio
from fastmcp import Client
from coffee import build_application
async def main():
async with Client(mount(build_application, name="coffee")) as client:
r = await client.call_tool("step", {"action": "pay", "inputs": {"amount": 5.0}})
print(r.structured_content) # refusal: take_order required first
r = await client.call_tool("step", {"action": "take_order", "inputs": {"item": "mocha"}})
r = await client.call_tool("step", {"action": "pay", "inputs": {"amount": 5.0}})
print(r.structured_content)
asyncio.run(main())
A client that calls pay before take_order gets a refusal it can recover from: the valid actions ride on every response.
{ "error": "invalid_transition", "valid_next_actions": ["take_order"] }
A smaller example, the same mechanism: an agent ordering coffee, refused when it tries to pay before ordering and recovering from the refusal.
Primitives at a glance
The vocabulary you meet, in roughly the order you reach for it. Every Theodosia server in STEP mode exposes the same four MCP tools regardless of FSM complexity, and a fixed set of theodosia:// resources for inspection.
mount(application, *, hooks=[...], middleware=[...], upstream=..., personas=...): wraps a BurrApplication(or factory) as a FastMCP server. Returns the server; call.run()to serve, or pass to FastMCP's in-memoryClientfor tests. The optional kwargs forward BurrLifecycleAdapterinstances, FastMCPMiddlewareinstances, upstream MCP clients, and PERSONA.md identity layers without making you reach into the underlying objects.- The four-tool surface: every mounted server exposes
step(action, inputs),reset_session,fork_at(sequence_id), andfork_from_past(app_id, sequence_id), each carrying FastMCPToolAnnotations(destructiveHint,idempotentHint,openWorldHint) so capable clients can render the right confirmations. The action namespace lives instep's argument schema; FSM complexity changes the schema, not the tool count. FastMCP'sResourcesAsToolstransform adds two more (list_resources,read_resource) for clients that don't implement nativeresources/read; the architectural surface is still four. - Structured refusals: five
refusal_reasonvalues fromstep:invalid_transition,unknown_action,validation_failed,action_timeout,action_error. Every refusal carriesvalid_next_actionsso the agent self-corrects from the response.fork_at/fork_from_pastreturn their ownerrorcodes (cannot_fork_to_refusal,unknown_past_run,no_tracker, ...) on the same wire shape. theodosia://resources:graph,state,next,history,subruns,trace,session. The agent reads state from these instead of guessing.upstream: a Burr action body calling tools on other MCP servers viacall_upstream(server, tool, args). The agent never sees those servers; only Theodosia'sstep.Persona: PERSONA.md identity layer mounted as MCP prompts. Same FSM, different actor; same audit trail. Frame-aware placeholders ({state.x},{action.name}) interpolate against the live session.Assembly: a frozen bundle of a workflow plus its personas, upstream config, instructions, and metadata.Assembly(...).serve()mounts;from_yamlloads from disk.fork_at/fork_from_past: branch any run at any past sequence id. Replay the prefix, diverge from the chosen point.theodosia.tracker(project): a BurrLocalTrackingClientdefaulted to~/.theodosiaso LLM-driven sessions stay separate from code-driven Burr runs in~/.burr. Use it in your builder:.with_tracker(theodosia.tracker("my-project")). HonorsTHEODOSIA_HOMEandbuild_cli(home=...).- Hooks: Burr's lifecycle adapters (
PreRunStepHook,PostRunStepHook,PreStartStreamHook, persister hooks, etc.) attach viamount(..., hooks=[hook1, hook2])or viaApplicationBuilder.with_hooks(...)in your factory. Either path works. - Middleware: FastMCP
Middlewareinstances attach viamount(..., middleware=[mw1, mw2]). Use for OpenTelemetry spans, rate limiting, structured logging, per-call metrics. drive_claude(server, anthropic, *, prompt, ...): one-line glue between a mounted server and the Anthropic SDK. Lists the FSM's tools, injectstheodosia://graph/state/nextinto the system prompt, loops turn-by-turn until terminal ormax_turns. Optional[claude]extra.
Scope
Theodosia does not include an agent, a model, or a workflow engine. It mounts an existing Burr Application and gates an MCP client's access to it. The rails are only as tight as the graph you author.
Command line
theodosia serve module:app # mount as MCP server (stdio, default)
theodosia serve module:app --transport http --port 8000 # serve over HTTP instead
theodosia render module:app # draw the state machine in the terminal (--mermaid / --dot)
theodosia doctor module:app # statically validate the graph; exits nonzero for CI
theodosia sessions show <id> # full timeline: per-step state diff + timing; prints Burr UI URL
theodosia sessions show <id> --open # also open the Burr UI replay in the browser
theodosia sessions diff <a> <b> # cross-session: action path divergence + final-state diff
theodosia watch # live-tail a running session
theodosia logs --refusals # only the steps that were refused
theodosia status # tracker storage + recent activity snapshot
theodosia verify # recompute the ledger's hash chain; catches edits/reorders/copies
theodosia primer # 30-second offline tour, no API key needed
theodosia ui # open the Burr UI (auto-bootstraps via uvx, or install `theodosia[ui]`)
A downstream package can ship its own command (my-fsm serve, my-fsm doctor, ...) with build_cli.
Observability and replay
A run is not a chat log you have to reconstruct, it is a replayable artifact. Every session is recorded through Burr's tracker, so you can replay any finished run step by step, with its state diffs, refusals, and timing:
theodosia sessions show <session-id>
seq action state change
0 start_investigation incident set, phase=triage, datasources discovered
1 record_probe findings=[1], backends=[prometheus]
2 record_probe findings=[2], backends=[prometheus, loki]
3 advance_phase phase=verify
4 conclude ✓ (terminal) primary_service=…, root_cause=…
Tail a live run (theodosia watch), open it in the Burr UI for the transition graph and time-travel, or fork from any past state (fork_at) to branch the investigation and try a different path. Refusals are recorded too, they appear in the timeline like any other step. A free-ranging agent at the same accuracy hands you a transcript; this hands you the run, replayable and forkable, with proof of which steps were enforced.
Documentation
Full docs at msradam.github.io/theodosia.
| Section | What it covers |
|---|---|
| Authoring a graph | Build a Burr Application from scratch and serve it, with the traps newcomers hit |
| Examples | Standalone agents built with Theodosia (Phoebe, triage, deploy-gate, coffee) and the in-repo FSMs. New here? Start with examples/CURATED.md. |
| Architecture | The four-tool surface, structured refusals, how mount() drives Burr |
| What works through mount() | Typed state, persistence, hooks, parallelism, sub-applications, telemetry |
| Observability | The theodosia:// resources, the CLI, the Burr UI, OpenTelemetry |
| Security model | The agent trust boundary: what Theodosia enforces, and what it does not |
| Case study | Same model, on rails vs free-ranging: where the rails make the agent finish, grader-verified |
| Research foundation | The published evidence behind the design, and what rails do not fix |
| Driving other MCP servers | upstream: a Burr action calling tools on other MCP servers |
| CLI | serve / doctor / render / sessions / watch / logs / status / report / primer, and build_cli |
| Deployment recipes | Ten copy-pasteable configs: Claude Code, Cursor, mcphost, fast-agent, HTTP, SSE, Lambda, Kubernetes, Slack webhook, embedded sub-app |
Compose with Philip
Philip is the sibling library that lifts existing operational artifacts into Burr Applications Theodosia mounts. Philip handles the lift; Theodosia handles the wire. The composition is one line:
import philip, theodosia
# Ansible playbook -> Burr -> MCP server (the canonical path)
theodosia.mount(philip.from_playbook("backup.yml"), name="backup-mcp").run()
Philip ships deterministic lifts for Ansible YAML (from_playbook), Mermaid stateDiagram-v2 (from_mermaid), and Excalidraw sketches (from_excalidraw().to_burr()), plus Hamilton-target lifts for Mermaid flowcharts and SQL CTEs.
Ansible's when:, register:, notify:, and handlers preserve into the lifted Burr transitions, so a step() from the wrong state refuses with valid_next_actions derived from the YAML's real preconditions. Mermaid and Excalidraw lifts work the same way through Theodosia: a branched diagram exposes the branch through step(action="<state>", inputs={"choice": "<label>"}), the matching outbound transition becomes the only reachable next action, and the wrong choice refuses the same way the rest of the surface does.
Agents built with Theodosia
Standalone repositories, each a real agent you can clone and run:
| Repo | What it is |
|---|---|
| Leavitt | On-call AI diagnostician. Reads metrics, logs, client load, and feature-flag state through MCP; classifies upstream responses (ok / error / malformed) so one bad source cannot poison the diagnosis; degrades or declines under chaos instead of guessing. Read-only by construction. Won the Crusoe track at the DevNetwork AI+ML Hackathon 2026. |
| Phoebe | SRE incident-investigation FSM (the hero above). Keeps the full Grafana toolset; the FSM gates the procedure and the audit trail. Ships a Harbor agent for Grafana's o11y-bench. |
| triage-agent | Support triage: investigate before you decide, enforced by the graph. |
| deploy-gate-agent | A change/deploy gate: ordered gates, a health gate, an audit trail, and a call out to a filesystem MCP server via upstream. |
| coffee-agent | The toy: a coffee-order state machine an LLM drives one enforced step at a time. |
Examples and tests
examples/ ships self-contained FSMs (pure-FSM, typed state, hooks, persistence, real shellouts, LLM-in-the-graph, SKILL-to-FSM, upstream, multi-graph), each runnable with uv run python examples/<file>.py. Run the suite with uv run pytest.
Acknowledgements
Theodosia is glue between two libraries that do the hard parts: Apache Burr provides the state-machine Application, the transition graph, and the tracking UI; FastMCP provides the MCP server, the transforms, and the client behind upstream. The SKILL demos under examples/skills/ are reproduced verbatim from Anthropic and Trail of Bits with attribution.
Theodosia is an independent project, not affiliated with or endorsed by the Apache Software Foundation, DAGWorks, the Apache Burr project, or FastMCP.
License and notice
Apache 2.0. Theodosia is independent open-source work by Adam Munawar Rahman and does not represent the views of IBM Corporation or any other employer. See NOTICE.md.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file theodosia-0.4.0.tar.gz.
File metadata
- Download URL: theodosia-0.4.0.tar.gz
- Upload date:
- Size: 5.3 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
140d2b11bce7b7e3e23eebfd1bbe2fb04f6c8f0f43eace64553a39e1e877814b
|
|
| MD5 |
f45643b28b0044ae43cf59e43339a21f
|
|
| BLAKE2b-256 |
bdd70f07584c56daf710f8cc01a8d5a414518ae02b95d9058e7d868825e5b9f0
|
Provenance
The following attestation bundles were made for theodosia-0.4.0.tar.gz:
Publisher:
release.yml on msradam/theodosia
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
theodosia-0.4.0.tar.gz -
Subject digest:
140d2b11bce7b7e3e23eebfd1bbe2fb04f6c8f0f43eace64553a39e1e877814b - Sigstore transparency entry: 1675634955
- Sigstore integration time:
-
Permalink:
msradam/theodosia@583a0061fe64ecf7af4d56598e6ba9053069a129 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/msradam
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@583a0061fe64ecf7af4d56598e6ba9053069a129 -
Trigger Event:
push
-
Statement type:
File details
Details for the file theodosia-0.4.0-py3-none-any.whl.
File metadata
- Download URL: theodosia-0.4.0-py3-none-any.whl
- Upload date:
- Size: 128.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d55642db3d754c84797337a2a86a160af80b603d90efd60eee2d78a2c34f4896
|
|
| MD5 |
8ce45a55c32a7b5ea5ea591dc61227e3
|
|
| BLAKE2b-256 |
c16a85d6e742448c0670f9bbf4476de1e549cc296535489d5df83c616be6b502
|
Provenance
The following attestation bundles were made for theodosia-0.4.0-py3-none-any.whl:
Publisher:
release.yml on msradam/theodosia
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
theodosia-0.4.0-py3-none-any.whl -
Subject digest:
d55642db3d754c84797337a2a86a160af80b603d90efd60eee2d78a2c34f4896 - Sigstore transparency entry: 1675634995
- Sigstore integration time:
-
Permalink:
msradam/theodosia@583a0061fe64ecf7af4d56598e6ba9053069a129 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/msradam
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@583a0061fe64ecf7af4d56598e6ba9053069a129 -
Trigger Event:
push
-
Statement type: