Skip to content

feat(http-transport): replace tiny_http with axum/SSE Streamable HTTP (#227) - #264

Merged
elasticdotventures merged 9 commits into
mainfrom
task/227-sse-streamable-http
Oct 19, 2026
Merged

elasticdotventures merged 9 commits into
mainfrom
task/227-sse-streamable-http

Conversation

@elasticdotventures

@elasticdotventures elasticdotventures commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Summary

Replaces the single-threaded tiny_http HTTP transport with an axum-based server supporting SSE server-initiated push notifications — implementing the Streamable HTTP pattern MCP specifies (issue #227).

Also adds the ProjectEventBus general PM event hook system (issue #259 PM-hooks gap) and wires the register_project_hook MCP tool.

Routes

Method Path Purpose
GET /health Liveness probe
POST / JSON-RPC dispatch; returns application/json
GET /sse SSE stream for notifications/tools/list_changed events

Key design decisions

  • handle_request stays synchronous — POST / runs it via tokio::task::spawn_blocking
  • tokio::sync::broadcast channel (cap 64) distributes SSE events; GET /sse subscribes via BroadcastStream
  • POST / detects _list_changed sentinel, strips it, pushes notifications/tools/list_changed SSE
  • build_http_app is library-exported so binary and integration tests share identical router code

ProjectEventBus (PM hooks)

  • event_bus.rs: ProjectEventBus { register / fire / fire_if }, ProjectEvent with constructors for tool_called, task_created, task_done, lifecycle_transition
  • Arc<ProjectEventBus> on TurboLedgerService; initialized in from_manifest_str
  • WorkflowArgs::RegisterProjectHook variant; dispatch in handle_workflow_tool
  • HSM transition dispatch fires tool_called + lifecycle_transition project events

Tests

  • 7 http_transport integration tests (health, tools/list, 204 notification, SSE content-type, list_changed broadcast+strip, 400, 404)
  • 5 event_bus unit tests (empty, fires all, fire_if filters, attr lookup, order)
  • 56/56 ledgerr-mcp lib tests PASS (--features http-transport)

🤖 Generated with Claude Code

elasticdotventures and others added 3 commits October 11, 2026 08:10
…#227)

Replace the single-threaded tiny_http POST-only HTTP transport with an
axum-based server that supports SSE server-initiated push notifications,
implementing MCP's Streamable HTTP pattern.

Changes:
- Remove `tiny_http` dep; add `axum = "0.8"` and `tokio-stream = { features = ["sync"] }`
  under the `http-transport` feature flag
- Rewrite `serve_http()` using axum with a current-thread tokio runtime
- Three routes: GET /health (liveness), POST / (JSON-RPC; runs handle_request
  via spawn_blocking), GET /sse (SSE stream for server-initiated notifications)
- POST / detects `_list_changed` sentinel in responses, strips it, and pushes
  `notifications/tools/list_changed` to all SSE subscribers via broadcast channel
- `agent_id_from_headers` updated to take `axum::http::HeaderMap` (std HeaderMap)
  instead of `&[tiny_http::Header]` — case-insensitive via lowercase key lookup
- Default (stdio) transport: unchanged, 44/44 tests PASS
- With `--features http-transport`: clean compile, no warnings in the binary

Closes #227

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…project_hook MCP tool (#259/#227)

- Add `event_bus.rs`: `ProjectEventBus` with `register/fire/fire_if`; `ProjectEvent`
  convenience constructors (`tool_called`, `task_created`, `task_done`,
  `lifecycle_transition`); 5 unit tests pass
- Wire `Arc<ProjectEventBus>` onto `TurboLedgerService` (`project_event_bus` field,
  initialized in `from_manifest_str`, `register_project_hook` and
  `register_project_hook_tool` public methods)
- Add `WorkflowArgs::RegisterProjectHook` variant to `contract.rs`; dispatch wired in
  `mcp_adapter.rs` (`handle_workflow_tool`)
- Fire `tool_called` project event from HSM transition/resume dispatch via
  `fire_tool_event`; fire `lifecycle_transition` event on successful transition
- Add axum/SSE `http_transport.rs` (`build_http_app`, `agent_id_from_headers`); 7
  integration tests; `POST /` strips `_list_changed` sentinel and pushes SSE notification
- Remove `tiny_http`; add `axum = "0.8"` + `tokio-stream` under `http-transport` feature
- Server binary `serve_http()` delegates to `ledgerr_mcp::http_transport::build_http_app`

Evidence: 56/56 ledgerr-mcp lib tests PASS (--features http-transport), 1/1 msft-agent-gov PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… general dispatch, real from-state in lifecycle_transition, task_created/task_done events

- Server binary: fire `tool_called` project event for every successful `tools/call`
  dispatch (after the result match, before JSON-RPC envelope is built)
- `dispatch_hsm "transition"`: capture current state via `hsm_status_tool` before
  the transition so `lifecycle_transition` event carries real `from` (not "?");
  fire `task_done` event when new state is `summarize`
- `dispatch_hsm "resume"`: fire `task_created` on successful checkpoint resume
  (agent picking up a workflow task); `resume_from` used as task_id
- Add 3 more `event_bus` unit tests: `task_created_event_has_correct_attrs`,
  `task_done_event_has_correct_attrs`, `tool_called_event_has_correct_attrs`
- Verified `budget_reconcile_returns_well_formed_report_shape` passes (3/3) in
  this worktree — prior timeout was compile-time, not test-logic

Evidence: 59/59 ledgerr-mcp lib tests PASS (--features http-transport);
          3/3 budget_reconcile integration tests PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
&crate::event_bus::ProjectEvent::lifecycle_transition(&from_state, &to),
);
// Reaching the Summarize state means the workflow task is complete.
if response.state.to_ascii_lowercase() == "summarize" {
&crate::event_bus::ProjectEvent::lifecycle_transition(&from_state, &to),
);
// Reaching the Summarize state means the workflow task is complete.
if response.state.to_ascii_lowercase() == "summarize" {
elasticdotventures and others added 3 commits October 11, 2026 09:17
…echart unification plan

WorkflowArgs gains four task-management actions, each firing a typed ProjectEvent:
- task_create → task_created event
- task_list   → tool_called event
- task_done   → task_done event
- task_update → task_updated event (new event kind)

TurboLedgerService gains task_store: Mutex<BTreeMap<String, TaskEntry>> as the
in-memory backing store. Every task lifecycle operation fires through
ProjectEventBus so registered hooks (b00t, agents, CI gates) observe every event.

Also adds:
- ProjectEvent::task_updated constructor + 2 new unit tests in event_bus.rs
- docs/statechart-unification-plan.md: full integration plan for
  lazily::StateChart ↔ b00t HsmMachine ↔ kr0ki ProcessChart ↔ ufo-types
  including b00t hook contract table, migration phases, and open gaps

Regenerated: docs/agent-mcp-runbook.md, docs/mcp-capability-contract.md,
scripts/mcp_cli_demo.sh, ui/docs/public/viz-manifest.json

61/61 ledgerr-mcp lib tests PASS (--features http-transport)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire `fire_tool_event` at the top of `tools/call` handler in
`ledgerr-mcp-server.rs` so every incoming tool call fires a
`ProjectEvent::tool_called` hook, not just task-management operations.

Add `hook_fires_for_every_tool_family` unit test proving all 9 tool
families trigger the hook — replaces the implicit assumption with an
explicit contract.

PM/task events (task_created, task_done, task_updated, tool_called) now
form a complete hook surface: registered hooks observe 100% of activity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ooks design doc

Phase 4 statechart integration:
- `hsm_transition_tool` now fires `ProjectEvent::lifecycle_transition(from, to)`
  on `project_event_bus` after every successful HSM advance — every registered
  hook (statechart, cost-checkpoint, LFMF, etc.) observes 100% of lifecycle
  state changes, not just task-management operations.

Test: `hsm_04_transition_fires_lifecycle_event_on_project_event_bus` — registers
a hook, advances `ingest.pending → normalize.ready`, asserts one `lifecycle_transition`
event fires with correct `from`/`to` attrs. 4/4 hsm_contract tests pass.

Docs: `docs/pm-task-hooks-design.md` — complete reference for the PM/task hook
architecture: event taxonomy, dispatch path diagram, hook registration (in-process
and MCP), statechart integration points, and test coverage table. Closes the
"no design doc" gap flagged by the stop hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…se 4c+)

Add HsmChart to TurboLedgerService so PM lifecycle_transition events fire
from lazily statechart entry actions rather than manual inline dispatch:

- lifecycle_pm_chart_def(): flat chart with states matching HsmMachine token
  space; "lifecycle_transition" entry action on each non-initial state
- HsmChart: wraps ThreadSafeStateChart + ThreadSafeContext, advance_count for
  fast-forward on restored sessions
- TurboLedgerService.hsm_chart: Mutex<Option<HsmChart>>, initialised in new()
- hsm_transition_tool: calls chart.try_advance() before firing ProjectEvent;
  chart authority audited via _chart_driven bool, best-effort fallback
- dispatch_hsm: removes duplicate lifecycle_transition fire (was double-firing
  via both lib.rs and mcp_adapter.rs)
- 4 HsmChart unit tests + hsm_05 integration test (5/5 PASS)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sk_status MCP tool)

- pm_hooks.rs: PmHookEvent enum, TaskStatusStore (thread-safe BTreeMap
  projection), PmHookHandler trait, TaskStatusPmHookHandler, LoggingPmHookHandler,
  PmHookDispatcher (registers on ProjectEventBus, converts lifecycle_transition
  events to typed PmHookEvents)
- TurboLedgerService::new(): builds TaskStatusStore, registers PmHookDispatcher
  (logging + task-status handlers) on project_event_bus at construction time
- TaskStatus variant added to WorkflowArgs; task_status_tool() method on service
- task_status MCP action routed in mcp_adapter.rs handle_workflow_tool
- docs/mcp-capability-contract.md: regenerated with task_status in workflow row
- 9 pm_hooks unit tests: from_transition mapping, TaskStatusStore apply/snapshot/reset,
  dispatcher fires on lifecycle_transition, dispatcher ignores non-lifecycle events
- All 20 ledgerr-mcp test suites pass (0 failures)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
/// Returns `None` for unrecognised tokens (forward-compatible).
pub fn from_transition(from: &str, to: &str) -> Option<Self> {
let _ = from; // reserved for future guard conditions
let mut parts = to.splitn(2, '.');
/// Returns `None` for unrecognised tokens (forward-compatible).
pub fn from_transition(from: &str, to: &str) -> Option<Self> {
let _ = from; // reserved for future guard conditions
let mut parts = to.splitn(2, '.');
pub fn from_transition(from: &str, to: &str) -> Option<Self> {
let _ = from; // reserved for future guard conditions
let mut parts = to.splitn(2, '.');
let stage = parts.next()?;
pub fn from_transition(from: &str, to: &str) -> Option<Self> {
let _ = from; // reserved for future guard conditions
let mut parts = to.splitn(2, '.');
let stage = parts.next()?;
let _ = from; // reserved for future guard conditions
let mut parts = to.splitn(2, '.');
let stage = parts.next()?;
let sub = parts.next()?;
let _ = from; // reserved for future guard conditions
let mut parts = to.splitn(2, '.');
let stage = parts.next()?;
let sub = parts.next()?;
…ly runtime

PmHookEvent now covers all ProjectEvent variants: StageReady/Blocked,
PipelineComplete/Reset, TaskCreated/Done/Updated, ToolCalled.
PmHookDispatcher.register_on_bus routes every event through
from_project_event — hooks fire on each event, not just lifecycle
transitions.

TaskStatusStore gains apply_task() for task events keyed as
"task:<id>"; TaskStatusPmHookHandler dispatches correctly by variant.

ooda_chart.rs: live lazily ThreadSafeStateChart executor for the OODA
proposal lifecycle. States mirror ufo-types OodaPhase; transitions
mirror OodaEvent::target. SCXML export (ufo-types) stays unchanged as
interchange-only; this is the in-process runtime for PM hook dispatch
and SSE events.

13 pm_hooks tests pass; 5 ooda_chart tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@elasticdotventures

Copy link
Copy Markdown
Member Author

Extended in latest commit (5809d52):

  • PmHookEvent now covers ALL ProjectEvent kinds (StageReady/Blocked, PipelineComplete/Reset, TaskCreated/Done/Updated, ToolCalled) — hooks fire on each event, not just lifecycle transitions
  • OodaChart (new): live lazily ThreadSafeStateChart executor for OODA proposal lifecycle; states mirror ufo-types OodaPhase; SCXML export in ufo-types is interchange-only, this is the in-process runtime
  • 13 pm_hooks tests + 5 ooda_chart tests all pass

@elasticdotventures

Copy link
Copy Markdown
Member Author

Adversarial Review: feat(http-transport): replace tiny_http with axum/SSE Streamable HTTP (#227)

Provider: sm3lly/neo-coder | Generated: 2026-10-13T13:27Z


🟢 Advocate

Approach Assessment

The PR replaces a single-threaded tiny_http server with an axum-based Streamable HTTP implementation. This is a sound architectural upgrade that:

  1. Aligns with MCP Streamable HTTP spec — Implements the SSE pattern specified in issue HTTP transport: move to streaming (SSE / Streamable HTTP) for server-initiated notifications #227, enabling server-initiated push notifications
  2. Maintains backward compatibility — The existing handle_request dispatcher remains transport-agnostic; only the HTTP layer changes
  3. Uses battle-tested dependencies — axum 0.8.9 is the current stable release with strong ecosystem support
  4. Adds PM hook infrastructure — The ProjectEventBus system is a clean addition that enables observability without coupling tool handlers to notification logic

The separation of concerns is correct: the binary wires up the transport, the library exports build_http_app for reuse, and the event bus is initialized in the service constructor.

Strong Points

Clean transport abstraction:

  • handle_request stays synchronous and pure — no tokio runtime changes to the dispatch path
  • spawn_blocking is used correctly for the sync handler within async axum routes
  • The broadcast channel pattern (cap 64) is appropriate for SSE fan-out

Thoughtful design decisions:

  • _list_changed sentinel detection and stripping is a clean way to signal SSE events without breaking the JSON-RPC response contract
  • build_http_app exported as library function enables identical router code in binary and integration tests
  • The lazily dependency for statechart/thread-safe patterns is well-chosen for the event bus

Comprehensive test coverage:

  • 7 integration tests for HTTP transport (health, tools/list, 204, SSE content-type, broadcast+strip, 400, 404)
  • 5 unit tests for event bus (empty, fires all, fire_if filters, attr lookup, order)
  • All 56/56 ledgerr-mcp lib tests pass with the new feature flag

Proper dependency management:

  • Feature-gated correctly (http-transport feature)
  • Removes tiny_http and its transitive deps (ascii, chunked_transfer)
  • Adds only necessary new deps (axum, tokio-stream, lazily)

Evidence

  • Test results: 56/56 tests pass (--features http-transport)
  • Integration coverage: Tests verify SSE content-type, broadcast delivery, sentinel stripping, and error cases
  • No breaking changes: Existing stdio transport unchanged; HTTP transport is opt-in via feature flag
  • Dependency hygiene: Cargo.lock shows clean removal of old deps and addition of new ones with proper version pinning

Recommended to Merge If...

  1. The axum version pinning in Cargo.lock is intentional (0.8.9 vs 0.7.9 for other crates) — appears correct as this is the newest stable
  2. The lazily dependency's statechart feature is actually used (appears so based on event bus design)
  3. No performance regression concerns for the sync handle_request path (unchanged code)

Verdict: This is a well-architected, well-tested upgrade that modernizes the HTTP transport while preserving backward compatibility. The PM hook infrastructure is a clean addition that will enable future observability features. Approve.


🔴 Challenger

Concerns

handle_request is still synchronous — the PR's main selling point is hollow
The PR claims to implement "Streamable HTTP" but keeps handle_request on spawn_blocking. This means:

  • SSE push works, but each JSON-RPC request still blocks a tokio thread
  • Under concurrent load, you'll exhaust the blocking thread pool
  • The "single-threaded, one request at a time" comment in the old code was honest; the new code is more complex but has the same throughput ceiling
  • If the goal is Azure Container Apps scale-to-zero, this is fine — but don't market it as "streamable" when the request path is still sync

broadcast::channel(64) is a silent data-loss mechanism

  • When the channel is full, send() drops the oldest event — callers never know
  • A slow SSE subscriber that falls behind will miss events with no backpressure signal
  • For notifications/tools/list_changed this is probably acceptable, but the PR doesn't document this tradeoff

lazily crate dependency is unexplained and unusual

  • lazily (v0.59) is not a mainstream Rust crate — no explanation in the PR for why it's needed
  • The Cargo.toml adds it with features = ["statechart", "thread-safe"] — statechart? For what?
  • Risk: obscure dependency = obscure bugs, less maintenance, harder to audit

Two axum versions in the lockfile (0.7.9 and 0.8.9)

  • ledgrrr-sysml-adapter and rotel-visual still use axum 0.7.9
  • ledgerr-mcp uses 0.8.9
  • This doubles the axum code in the final binary and creates upgrade friction
  • Should these be upgraded together, or is there a reason to keep them split?

global_raw_service().fire_tool_event() calls are in the hot path

  • Every tools/call now fires two project events (one at dispatch, one at completion)
  • If any subscriber to the event bus is slow or blocks, it slows down all tool calls
  • No timeout, no async dispatch for the events themselves
  • The PR doesn't mention performance implications

_list_changed sentinel is a magic string convention

  • The PR says POST / "detects _list_changed sentinel, strips it"
  • This is a fragile protocol — what if a legitimate tool returns a field named _list_changed?
  • No schema validation, no type safety — just string matching on a convention
  • Should be documented as a protocol requirement for all tools that want to trigger SSE notifications

Missing Coverage

No benchmark or performance comparison

  • Old tiny_http vs new axum: what's the latency difference?
  • Memory footprint? The PR adds axum + tokio-stream + lazily — that's significant
  • For a server that handles MCP tool calls, this matters

No error handling for broadcast channel full

  • Tests cover the happy path of SSE events
  • No test for what happens when 65+ events are sent rapidly
  • No test for subscriber that disconnects mid-stream

No test for concurrent SSE subscribers

  • The PR mentions BroadcastStream but tests only show single-subscriber scenarios
  • What happens when subscriber A is slow and subscriber B is fast?

No documentation update

  • The README or docs don't mention the new /sse endpoint
  • No documentation of the _list_changed sentinel protocol
  • No documentation of how to use register_project_hook

No migration/rollback plan

  • What if axum has issues in production? How do you roll back to tiny_http?
  • The feature flag http-transport exists, but the code is rewritten — there's no "old path" to fall back to

No monitoring/metrics

  • No metrics for SSE subscriber count
  • No metrics for broadcast channel depth
  • No tracing for event bus fires

Alternative Approaches

Consider rmcp after all

  • The PR explicitly rejects the rmcp SDK, but the reasoning is weak: "no functional gain over this"
  • rmcp has built-in Streamable HTTP support, session management, and is maintained by the MCP ecosystem
  • Rolling your own SSE transport means maintaining it forever; rmcp would get updates from the community
  • The "riskier rewrite" argument is valid, but this PR is already a rewrite — might as well go further

Use tokio::sync::mpsc with per-subscriber channels instead of broadcast

  • Broadcast drops events when full; mpsc gives you backpressure
  • More complex to implement, but more correct for a notification system
  • Or use a hybrid: broadcast for the event, but track subscriber lag

Defer lazily until it's actually needed

  • The PR adds it but doesn't explain why
  • If it's for the event bus, consider std::sync::Mutex<Vec<Callback>> first
  • Don't add dependencies until the simpler approach is proven insufficient

Blocking Issues

The lazily dependency needs justification

  • Until the PR explains why lazily is needed (and why statechart feature specifically), this should block merge
  • It's an unusual choice that needs defense

No test for the _list_changed sentinel interaction with real tool responses

  • The PR tests SSE broadcasting in isolation
  • No integration test showing a real tool call that returns _list_changed and verifies the SSE event fires
  • This is the core feature of the PR and it's not tested end-to-end

@elasticdotventures

Copy link
Copy Markdown
Member Author

Backlog Grooming — Suggested Merge Order

All 15 open PRs have been adversarially reviewed. Grouping by dependency and risk:

🚨 Blocked (author action required before merge)

📄 Docs-only (safe to merge now — passing CI, no code risk)

🏗 Foundation (merge these before their dependents)

  1. feat(http-transport): replace tiny_http with axum/SSE Streamable HTTP (#227) #264 (axum/SSE HTTP transport — replaces tiny_http) ← merge first
  2. feat(revision-io): inline revision module; bump ufo-types to 1846e6f #270 (revision-io — inline revision module + ufo-types bump) ← merge before feat(ledger-core): repoint verify.rs onto ufo_types::MultiModelVerifier (#218) #271
  3. feat(statechart): Phases 1-3 — lazily::StateChart dep + HsmHookRegistry + register_hook MCP tool (#259) #263 (statechart Phases 1-3 — lazily::StateChart) ← merge before feat(kr0ki-core): SysML-v2 ActionUsage/Succession → lazily StateChart (issue #259 phase 4) #265, feat(b00t-iface): SurfaceChart — lazily ThreadSafeStateChart live execution layer #267, feat(kr0ki-core): Phase 5 — DecisionNode/ForkNode/JoinNode/MergeNode in ProcessChart (closes #268) #273

🔗 Chains (merge after their foundations)

🔌 Independent (can merge any order once deps land)

🤖 Grooming via automated adversarial review — 2026-10-13

@elasticdotventures
elasticdotventures merged commit 01721e3 into main Oct 19, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants