ADR 0019 — Live engine events on the MCP transport¶
Status: Accepted
Context¶
The console (ADR 0015) consumes engine.run(on_event=…) in-process: state-by-
state progress renders while the run is in flight. Every other MCP client —
Claude Code, a future OpenTUI/Ink front-end, any agent host — still sees a
commissioned run as a black box until the result returns. The stack question
raised while planning the console UI ("more modern TUIs?") resolved to: keep
the bundled console on Textual, and buy the modern-client option at the layer
that lasts — the transport. Without events on the wire, an external client
would have to re-architect the engine; with them, it is just another renderer.
Decision¶
run and resume forward the engine's on_event stream as MCP logging
notifications: logger mklang.event, message = the event dict as JSON
(run-start / state-start / state-done / branch-done, unchanged shape).
Purely additive — result shapes are untouched and clients that ignore logging
notifications see exactly the old behavior.
Scheduling detail that is easy to get wrong under MCP SDK v2: plain def
tools run on a worker thread (no running event loop), while fan-out
branches also emit from workers. run / resume are therefore async def
so the forwarder can capture the server loop on the tool body, and the
blocking engine.run is offloaded with asyncio.to_thread. Events still
schedule ctx.log(...) via asyncio.run_coroutine_threadsafe — the one path
safe from any thread that never blocks the emitter. Like the engine's own
observer seam, forwarding is isolated: a transport hiccup can never affect
the run.
Context injection still needs a real (non-string) Context annotation in
module globals; from __future__ import annotations stringifies it, so the
type is imported at module load (with a no-extra placeholder).
Protocol note (SEP-2577, 2026-07-28): MCP-level logging is deprecated but
still delivered. Clients on modern links must opt in (log_level=… on the
SDK Client). Replacing this channel (e.g. subscriptions) is a follow-up
when hosts reliably consume an alternative; the wire logger name and JSON
payload stay the contract until then.
[maybe] External console client (OpenTUI/Ink class) is now a ROADMAP item
that needs no engine work: speak MCP, render mklang.event notifications.
Consequences¶
- Any MCP client can render live run progress today; the bundled Textual console keeps its richer in-process seam.
- The event vocabulary is now a wire contract shared by two surfaces; changes to it must consider both (trace stays the canonical record).
- In-memory tests pin the stream (
logging_callbackon the client session): sequence starts withrun-startand carries per-state completions.