Architecture
mount() wraps a Burr Application in a FastMCP server, registers four MCP
tools (step, reset_session, fork_at, fork_from_past), and exposes the
theodosia:// resource family for the agent.
Graph topology
Section titled “Graph topology”The agent can only move along the graph’s edges; the server refuses any step that
is not a reachable transition. theodosia render <target> prints the graph in the
terminal (or --mermaid / --dot for docs):
The four-tool surface
Section titled “The four-tool surface”Every server mounted in STEP mode registers the same tools regardless of how complex the FSM is:
step(action, inputs)runs one transition. AnnotateddestructiveHint=True, idempotentHint=False.reset_session()rebuilds this session’s Application from the factory. AnnotateddestructiveHint=True, idempotentHint=True.fork_at(sequence_id)rolls this session back to a prior history entry. AnnotateddestructiveHint=True, openWorldHint=False.fork_from_past(app_id, sequence_id)resumes another session’s state through the persister. Hidden from the listing when nostate_loaderorLocalTrackingClientis wired, since it would only ever refuse. AnnotateddestructiveHint=True, openWorldHint=Truebecause the source state lives outside this session’s history.
The annotations are FastMCP ToolAnnotations; capable clients render the
right confirmations and recovery affordances per tool.
Plus two synthetic tools from FastMCP’s ResourcesAsTools transform,
list_resources() and read_resource(uri) (both readOnlyHint=True), so
tools-only clients can reach the theodosia:// resources without
resources/read.
The action namespace lives in step’s action argument schema and at
theodosia://graph, not in the tool listing. The listing stays compact; the agent
learns the verbs from the graph resource.
The step response
Section titled “The step response”step returns two content blocks: a short human-readable summary line (for
clients that render server logs inline, like Step 3: verify_usage ✓ → resolve)
and a structured JSON payload with the machine-readable result. A programmatic
client should read the structured payload (FastMCP exposes it as
result.structured_content, or the JSON block of result.content), not the
summary string. Both success and refusal come back on this same shape: a refusal
is {"error": "invalid_transition", "valid_next_actions": [...], ...}, an
action-body failure is {"error": "action_error", "error_message": "..."}.
Action selection
Section titled “Action selection”Burr’s astep picks the next action via app.get_next_action(), which returns
the first transition whose condition is true. Under MCP the client named the
action to run. _step_application overrides get_next_action for the duration
of one step to return the client-named action, calls astep, then restores the
original. This is the bridge between MCP’s “client chose X” semantics and
Burr’s transition-condition semantics, and the only Burr internal Theodosia
touches.
Before running, the step checks reachability against the live transitions. An
unreachable action is refused with invalid_transition and the response carries
valid_next_actions, so a client without its own model of the graph can recover
from a single error.
Per-session isolation
Section titled “Per-session isolation”mount(...) accepts an Application instance (shared state across all
sessions) or a callable factory (one Application per MCP session). The session
store is a plain dict keyed by ctx.session_id, held in mount’s closure
scope. Each entry holds the Application built lazily on first touch, a
per-session asyncio.Lock, and the history and subrun records.
The builder seam
Section titled “The builder seam”A factory can return either a built Application or an unbuilt
burr.core.ApplicationBuilder. When it returns a builder, Theodosia calls
builder.with_identifiers(app_id=session_id).build(), so the Burr app_id
equals the FastMCP session id and the tracker directory
(storage_dir/project/<session_id>/) is bound to the session. It never drifts
when the session is reset, because the rebuild stamps the same id again. This is
the recommended form when you use Burr tracking. The () -> Application form and
a bare Application instance still work unchanged.
def build_application(): return ( ApplicationBuilder() .with_actions(...) .with_transitions(...) .with_tracker(tracker(project="incident")) .with_entrypoint("acknowledge") # no .build() )
mount(build_application, name="incident")Eviction is lazy: stale entries are dropped on the next access, not on a
background timer. session_ttl_seconds (default 3600) and max_sessions
(default 100) each cap the store; set either to None to disable that form of
eviction.
FastMCP’s ctx.set_state(serializable=False) is request-scoped, not
session-scoped, so it is not suitable for caching the Application across calls in
one session. The closure dict is.
What mount() accepts
Section titled “What mount() accepts”mount( application_or_factory, *, name="theodosia", instructions=None, hooks=[...], # Burr LifecycleAdapter list middleware=[...], # FastMCP Middleware list upstream={"name": {...}}, # other MCP servers callable from action bodies personas=..., # PERSONA.md identity layer state_loader=..., # for fork_from_past against a custom persister action_timeout_seconds=None, # hard timeout per action session_ttl_seconds=3600, max_sessions=100,)hooks are attached via Burr’s public LifecycleAdapterSet.with_new_adapters,
the same path Burr uses internally to wire its own TracerFactoryContextHook.
Hooks attached this way fire on the same surfaces as
ApplicationBuilder.with_hooks(...).
middleware runs after Theodosia’s built-in input-coercion middleware, so user
middleware (OpenTelemetry, rate limiting, structured logging) sees the
post-coercion tool arguments.
upstream opens an MCP client session to each named server and binds them so
action bodies can call their tools via theodosia.call_upstream(server, tool, args). The agent sees only step; the upstream servers are not in its tool
listing. Every upstream call advances state by construction because it runs
inside an action body. See Driving other MCP servers.
mount_multi(applications, hooks=[...], middleware=[...]) composes several
Burr Applications into one MCP server with FastMCP namespacing; the kwargs
forward to every sub-application’s mount() call.
Persistence: save + resume
Section titled “Persistence: save + resume”Persistence splits into two primitives that take the same persister:
ApplicationBuilder.with_state_persister(persister)is the saver. Burr’sPersisterHookcallspersister.save(...)after every step.ApplicationBuilder.initialize_from(persister, resume_at_next_action=True, default_state={...}, default_entrypoint="...")is the loader. At session start it tries to read the latest snapshot for the currentapp_idand resumes mid-walk; falls back to the defaults when no snapshot exists.
You need both for a true save-and-resume loop. The fork_from_past meta-tool
also reaches the loader, so an agent can resurrect any past session by
(app_id, sequence_id) without anything in the factory. See
examples/sqlite_persister.py for both factories side by side.
Input coercion middleware
Section titled “Input coercion middleware”mount() builds the FastMCP server with strict_input_validation=False and
adds a middleware that, for any tools/call, parses JSON-string values into
objects when the tool’s declared schema allows object or array. The step
tool’s inputs parameter is typed dict | str | None so the advertised schema
includes string in its anyOf.
Both moves are needed for clients that validate outbound requests against the advertised schema (so the schema must accept the string form) and serialize nested-object arguments as JSON strings (so the middleware must coerce them before the action body runs).