Overview
Let long-running commands emit structured NDJSON progress events on stderr,
and have the MCP adapter map them to notifications/progress (progressToken)
so hosts render live progress. Today an agent gets silence from command start
until the final payload lands, with no way to distinguish "still working" from
"hung."
User Story
As an LLM agent (or its host) invoking a long-running ax-go command, I want
structured progress events during execution, So that I can show progress,
detect stalls, and decide whether to keep waiting instead of guessing from
silence.
Problem Statement
stderr is already the diagnostics channel (stream-separation mandate), but the
library emits nothing there during a long operation except opportunistic
zerolog lines — which are unstructured for progress purposes and not mapped to
any MCP notification. Under the MCP server runtime (mcp.Serve), the dispatcher
buffers the command's entire stdout and returns it as one text block only after
the command exits (internal/mcpserver/dispatch.go successResult), so a host
sees no incremental signal at all. The design questions are (1) the progress
event schema and (2) how it maps to the MCP progressToken mechanism when the
host advertises the capability.
Proposed Solution
Define a small, additive progress-event contract and a runtime emitter, plus an
MCP bridge that fires notifications/progress only when the host cooperates.
Technical Approach
- Define a
ProgressEvent struct in contract/ (stable JSON shape): fields such
as progress (completed units), total (optional), message, and correlation
IDs (trace_id/span_id reusing contract.Metadata conventions). Keep it a
struct, not a map, for determinism.
- Add a runtime emitter (root
ax package) that writes one NDJSON event per line
to stderr via the existing locked stderr writer
(internal/telemetry.NewLockedWriter, already wired in execute.go:119) so
progress lines never interleave with logs or the error envelope. Use zerolog
field methods / struct marshaling — never fmt.Sprintf of user input
(log-forgery guardrail).
- MCP mapping ("Declared Claims, Gated Projections"): the dispatcher reads the
client's progressToken from the tool-call params and translates each stderr
progress event into an SDK notifications/progress message
(github.com/modelcontextprotocol/go-sdk/mcp) — but ONLY when the host
advertised the capability AND the session is interactive; otherwise the events
stay on stderr and behavior is unchanged (fall back, don't fail).
- Because the current dispatcher buffers stdout to completion, wiring live
progress requires reading stderr incrementally during dispatch rather than only
forwarding it after ExecuteContext returns (forwardStderr).
Files Likely Affected
contract/ (new file, e.g. progress.go) - ProgressEvent shape + JSON contract
logger.go / new root emitter - stderr NDJSON progress writer using the locked writer
execute.go - expose the progress sink via context (like mode/dry-run/idempotency)
internal/mcpserver/dispatch.go - capture progressToken, translate events to notifications/progress, incremental stderr handling
mcp/server.go / mcp/options.go - surface any host-capability gating knobs
examples/integration/main.go - a demo long-running command emitting progress
Acceptance Criteria
Out of Scope
- Progress bars / human-mode rendering (agents consume structured events; human formatting is separate)
- Cancellation semantics beyond the existing context cancellation
- Persisting or replaying progress history
Technical Notes
The ProgressEvent shape lives in the CONTRACT packages so thin consumers pin
it without the telemetry stack. Additive and additive-tolerant (Constitution
XI) — new event type + optional MCP notifications, no change to existing stdout
payloads or the error envelope. Stream separation is non-negotiable: progress
goes to stderr only. MCP behavior is host-cooperative — fires only when the
host advertises the capability and the session is interactive, else falls back
silently. Routes through the Spec Kit workflow; the event schema and MCP mapping
are the core research questions.
Testing Strategy
Related
Overview
Let long-running commands emit structured NDJSON progress events on
stderr,and have the MCP adapter map them to
notifications/progress(progressToken)so hosts render live progress. Today an agent gets silence from command start
until the final payload lands, with no way to distinguish "still working" from
"hung."
User Story
As an LLM agent (or its host) invoking a long-running ax-go command, I want
structured progress events during execution, So that I can show progress,
detect stalls, and decide whether to keep waiting instead of guessing from
silence.
Problem Statement
stderris already the diagnostics channel (stream-separation mandate), but thelibrary emits nothing there during a long operation except opportunistic
zerolog lines — which are unstructured for progress purposes and not mapped to
any MCP notification. Under the MCP server runtime (
mcp.Serve), the dispatcherbuffers the command's entire stdout and returns it as one text block only after
the command exits (
internal/mcpserver/dispatch.gosuccessResult), so a hostsees no incremental signal at all. The design questions are (1) the progress
event schema and (2) how it maps to the MCP
progressTokenmechanism when thehost advertises the capability.
Proposed Solution
Define a small, additive progress-event contract and a runtime emitter, plus an
MCP bridge that fires
notifications/progressonly when the host cooperates.Technical Approach
ProgressEventstruct incontract/(stable JSON shape): fields suchas
progress(completed units),total(optional),message, and correlationIDs (
trace_id/span_idreusingcontract.Metadataconventions). Keep it astruct, not a map, for determinism.
axpackage) that writes one NDJSON event per lineto
stderrvia the existing locked stderr writer(
internal/telemetry.NewLockedWriter, already wired inexecute.go:119) soprogress lines never interleave with logs or the error envelope. Use zerolog
field methods / struct marshaling — never
fmt.Sprintfof user input(log-forgery guardrail).
client's
progressTokenfrom the tool-call params and translates each stderrprogress event into an SDK
notifications/progressmessage(
github.com/modelcontextprotocol/go-sdk/mcp) — but ONLY when the hostadvertised the capability AND the session is interactive; otherwise the events
stay on stderr and behavior is unchanged (fall back, don't fail).
progress requires reading stderr incrementally during dispatch rather than only
forwarding it after
ExecuteContextreturns (forwardStderr).Files Likely Affected
contract/(new file, e.g.progress.go) -ProgressEventshape + JSON contractlogger.go/ new root emitter -stderrNDJSON progress writer using the locked writerexecute.go- expose the progress sink via context (like mode/dry-run/idempotency)internal/mcpserver/dispatch.go- captureprogressToken, translate events tonotifications/progress, incremental stderr handlingmcp/server.go/mcp/options.go- surface any host-capability gating knobsexamples/integration/main.go- a demo long-running command emitting progressAcceptance Criteria
ProgressEventNDJSON lines tostderrwith stable field shapesstdout(stream-separation invariant holds)progressToken, events surface asnotifications/progress; without it, events remain on stderr and dispatch behavior is unchangedgo test -race)Out of Scope
Technical Notes
The
ProgressEventshape lives in the CONTRACT packages so thin consumers pinit without the telemetry stack. Additive and additive-tolerant (Constitution
XI) — new event type + optional MCP notifications, no change to existing stdout
payloads or the error envelope. Stream separation is non-negotiable: progress
goes to
stderronly. MCP behavior is host-cooperative — fires only when thehost advertises the capability and the session is interactive, else falls back
silently. Routes through the Spec Kit workflow; the event schema and MCP mapping
are the core research questions.
Testing Strategy
ProgressEventNDJSON emitter (field shapes, stderr-only, locked-writer non-interleaving)ProgressEventJSON shapeprogressTokenpresent, events map to notifications; without one, they don't (fall-back path)Related
internal/mcpserver)