Skip to content

live SSE relay for tools/call#151

Draft
kam-validmind wants to merge 15 commits into
mainfrom
sse_mcp
Draft

live SSE relay for tools/call#151
kam-validmind wants to merge 15 commits into
mainfrom
sse_mcp

Conversation

@kam-validmind

@kam-validmind kam-validmind commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What and why?

The problem: when an agent calls a slow tool through Atryum, Atryum used to wait for the tool to completely finish, then send back one final answer. If the tool took 30 seconds, the agent saw and heard nothing for 30 seconds, then got the result all at once.

The fix: Atryum can now forward "still working, X% done" style updates to the agent while the tool is still running, instead of making it wait for one final answer. This only happens if the agent says it can handle that (Accept: text/event-stream) and the upstream tool server supports it — otherwise nothing changes, and the agent gets the same single response as before.

Before vs. after:

sequenceDiagram
    autonumber
    participant Agent
    participant Atryum
    participant Tool as Upstream tool server

    rect rgb(245, 235, 235)
    Note over Agent,Tool: Before this PR
    Agent->>Atryum: "run this tool"
    Atryum->>Tool: "run this tool"
    Note over Atryum,Tool: (silence for the whole run, could be minutes)
    Tool-->>Atryum: final result
    Atryum-->>Agent: final result
    end
Loading
sequenceDiagram
    autonumber
    participant Agent
    participant Atryum
    participant Tool as Upstream tool server

    rect rgb(230, 245, 235)
    Note over Agent,Tool: After this PR (only if Agent asks for it)
    Agent->>Atryum: "run this tool, I can read a live stream"
    Atryum->>Tool: "run this tool"
    loop while the tool keeps working
        Tool-->>Atryum: "33% done…"
        Atryum-->>Agent: "33% done…"
    end
    Tool-->>Atryum: final result
    Atryum-->>Agent: final result
    end
Loading

A quick glossary, since this touches a protocol most of the codebase doesn't deal with directly:

Term Plain-English meaning
MCP Model Context Protocol — the standard agents use to call tools. Messages are JSON.
Upstream The tool server doing the actual work (e.g. a GitHub MCP server).
Downstream The agent/agent harness that's waiting on Atryum for an answer.
SSE (Server-Sent Events) A way for a server to keep one HTTP connection open and send multiple small messages over time, instead of one message and done. This is what makes "live updates" possible over plain HTTP.
Progress notification A "still working, here's how far along I am" message. It's one-way — no reply expected.
Terminal response The actual final answer to the tool call. There's exactly one of these per call, streaming or not.
Progress token An ID the agent hands Atryum so progress updates can be matched back to the right call.

Where the progress updates can come from. Most upstream servers put progress messages on the same HTTP response as the tool call itself ("Path A" below). A few SDKs (notably the reference Python MCP SDK) instead send progress on a second, separate connection shared by every in-flight call ("Path B"). Atryum listens on both, so either kind of upstream server works:

flowchart TB
    Downstream[Agent]
    Relay[Atryum]
    Upstream[Upstream tool server]
    CallPath["Path A: the response to this one tool call<br/>carries progress AND the final answer"]
    StandalonePath["Path B: one shared connection<br/>carries progress ONLY, for all active calls"]

    Downstream <-->|"tool call"| Relay
    Relay --- CallPath
    CallPath --- Upstream
    Relay --- StandalonePath
    StandalonePath --- Upstream
Loading

Because Path B is shared across every call currently in flight, Atryum has to make sure one caller's progress updates never leak to a different caller. It does this by swapping in a unique progress token for each call before forwarding it upstream, then swapping the caller's original token back in before relaying the update — so two agents that happen to pick the same token number can never see each other's progress.

Safety nets included in this PR (see docs/architecture.md → "Live SSE relay for tools/call" for the full write-up, and CHANGELOG.md for the complete list):

  • A kill switch — stream_relay_enabled = false turns this whole feature off and restores the old one-response-at-the-end behavior, no rollback needed.
  • Timeouts for "upstream went quiet too long," "upstream took too long overall," and "one message was too big" — so a broken or hostile upstream can't hang a request forever or blow up Atryum's memory.
  • If the connection to a well-behaved upstream drops mid-stream, Atryum reconnects and picks up where it left off instead of restarting or losing progress.
  • Every progress message and the final result get written to Atryum's audit log (invocation_events), same as before — reviewers/admins don't lose visibility just because delivery got more real-time.
  • Side fix, unrelated to streaming but found while working on this: if an agent disconnected mid-call on a normal (non-streaming) tool call, the invocation could get stuck showing executing forever in the audit log, even though it had actually finished. That's fixed too.

How to test

  • go test ./... (or just check) — runs the full new test suite with no network access needed. If you want to look at specific pieces: internal/mcp/http_stream_test.go (Path A), internal/mcp/standalone_stream_test.go (Path B), internal/invocation/stream_execution_test.go and stream_sink_internal_test.go (audit logging), internal/api/sse_relay_test.go (the HTTP-facing relay).
  • Two extra tests run against real MCP servers instead of fakes. They're opt-in (skipped by default, and skip themselves cleanly if the tool below isn't installed) so CI stays fast:
    • just mcp-everything-test — spins up the official @modelcontextprotocol/server-everything reference server and calls its trigger-long-running-operation tool through a real Atryum server. Needs npx.
    • just mcp-standalone-stream-test — spins up a small Python MCP server (internal/api/testdata/mcp_standalone_fixture/server.py) to test the shared-connection (Path B) case specifically. Needs uv.
  • To poke at it by hand: configure an [[mcp_servers]] entry pointing at either server above, call one of its slow tools with Accept: text/event-stream set on the request, and watch invocation_events fill in with multiple stream_event rows as it runs instead of a single row at the end.

What needs special review?

  • internal/api/sse_relay.go + internal/invocation/stream_sink.go — this is where "don't lose progress, but also don't let a slow audit write block a live update" is implemented. Trickiest part of the PR.
  • internal/mcp/standalone_stream.go — the progress-token swap described above, plus what happens when the upstream session gets renewed mid-flight.
  • The three-way distinction between stream_canceled / stream_aborted_downstream / stream_timeout in the audit log — worth checking the reasoning holds up. Background in the fix: address stream relay review findings and finalize buffered calls after disconnect; harden relay lifecycle commit messages.
  • Whether the new default timeout/size values in atryum.example.toml are reasonable for real-world upstreams.

Dependencies, breaking changes, and deployment notes

  • No breaking changes for existing tool calls — streaming only turns on when both the agent asks for it and the upstream supports it. Everything else behaves exactly as before.
  • stream_relay_enabled can disable the new behavior at startup, if something unexpected shows up with a production upstream.
  • No new dependencies ship in the binary. The two real-server tests need npx/uv on the machine running them, but they're excluded from the default go test ./... / CI run and skip themselves gracefully if those tools are missing.

Data impact

  • Yes — this change is data-impacting
  • No — this change is not data-impacting

Justification: No existing customer data, config format, or API response shape changes. The only new persisted data is additional audit rows (stream_event, stream_completed, stream_delivery in invocation_events) for calls that use streaming — purely additive telemetry, nothing existing is modified or removed.

Release notes

Atryum can now stream live progress updates from a tool call back to the calling agent as they happen, instead of making the agent wait silently for one final answer. This works whether the upstream tool server sends progress on the tool call's own response or on a separate shared connection (used by some SDKs, including the reference Python MCP SDK). See the [Unreleased] section of CHANGELOG.md for the new configuration options and full behavior guarantees.

Checklist

  • What and why
  • Screenshots or videos (Frontend)
  • How to test
  • What needs special review
  • Dependencies, breaking changes, and deployment notes
  • Labels applied
  • Data impact classified
  • PR linked to Shortcut
  • Unit tests added (Backend)
  • Tested locally
  • Documentation updated (if required)
  • Environment variable additions/changes documented (if required) - put it in a markdown file

Move transport, timeout, parsing, relay, and audit logic into focused files so each path can be understood and tested independently.
Bound upstream message memory, add reconnect backoff, and keep standalone progress streams aligned with MCP session renewal. Retry transient standalone failures and expose buffer drops to stream observers.
Replace per-invocation audit goroutines with an ordered shared dispatcher, classify quiet downstream cancellation, record terminal delivery separately, and avoid claiming success before durable persistence.
Correctness:
- guard the stdio stderr boundedBuffer with a mutex: os/exec's copy
  goroutine can still be writing when error paths read Len/String
  before cmd.Wait runs
- flag server-to-client requests routed via the standalone stream as
  ServerRequest, matching the POST-stream classification, so audit
  doesn't mislabel sampling/elicitation requests as notifications
- normalize CR/CRLF in downstream SSE framing: a raw CR inside
  upstream JSON would split the data frame for a compliant parser

Safety and audit truthfulness:
- substitute derived stream bounds when SetStreamOptions was never
  called, so an embedder wiring NewService directly never runs an
  unbounded relay
- reset the idle timeout on SSE keepalive/comment lines so a busy-but-
  quiet upstream heartbeating through a long tool run isn't cut off
- send a static message in the terminal SSE error frame; internal
  error text (SQL/driver detail) stays in the server log
- audit a bare request-context cancel as stream_canceled rather than
  stream_aborted_downstream: shutdown and a quiet disconnect are
  indistinguishable without a failed downstream write
- rename the failure audit's events_relayed to events_total to match
  stream_completed; cap the session-expired body drain at 64KB

Readability:
- extract callTimeoutGuard.timeoutErr, drainTrailingProgress, and a
  shared toolCallEnvelopeID; move the header-timeout fallback into
  config.EffectiveStreamHeaderTimeoutSeconds; document the
  stream_completed durability-before-delivery latency bound

Tests:
- fix unsynchronized test-server counters (latent -race flakes)
- pin drainTrailingProgress settle-window semantics deterministically
- add end-to-end MaxDuration, resume-failure, StreamStats delivery,
  and keepalive-liveness coverage; poll for pending_approval instead
  of sleeping in the approval-gate test
- run buffered finalization writes on a bounded detached context so a
  downstream disconnect can't leave the row stuck executing (matches
  the streaming path; regression test added)
- emit the mcp.tools.call trace event on the error-after-stream-started
  path (finalize_failed: true)
- defer sseRelaySink.stopHeartbeat in the handler so the heartbeat
  goroutine can't outlive a panicking handler; count delivered (not
  attempted) frames in eventCount
- shard index modulo in uint64; queue capacity floored at 1; wrap both
  persistence errors consistently
- shared test upstream helpers fail cleanly (t.Errorf + HTTP error)
  instead of hanging on t.Fatalf from a handler goroutine
- document relay behavior guarantees and the buffered fix in the
  changelog; note SetStreamRelayEnabled is startup-wiring only
acquireStandaloneStreamWithLimit only spawns the GET-connect goroutine
and returns immediately; the caller then sent tools/call right away.
If the upstream's first progress notification fired before that GET
reached the server, it had nowhere to land and was silently dropped —
reproduced as 2/3 progress notifications instead of 3 in
TestMCPToolsCallAgainstRealStandaloneStreamServer.

Add a ready signal closed after the stream's first connect attempt
(success or failure), and wait on it before sending the dependent
tools/call, bounded by HeaderTimeout so a hung upstream can't stall
the call indefinitely.
@kam-validmind kam-validmind added the enhancement New feature or request label Jul 24, 2026
The integration suite had zero coverage of the SSE relay: no streaming MCP
target, no timing-aware verification, and fake_agent.py never requested
SSE. Adds an everything-streaming target (the official reference
"everything" server over stdio) and a fake_agent.py --stream mode that
reads the tools/call response incrementally, timestamps each frame, and
fails unless progress measurably arrives before the terminal result --
proving live delivery over a real process/network boundary, distinct from
the existing Go E2E tests (mcp-everything-test, mcp-standalone-stream-test),
which prove the same for the upstream-facing HTTP relay internals against
real SDKs. Sharing the server-everything dependency with mcp-everything-test
is an accepted, minor redundancy in exchange for not maintaining a second
hand-rolled MCP fixture.

Also fixes two pre-existing bugs this surfaced, both blocking every case on
stock macOS (bash 3.2 + BSD awk), not just the new target:
render_atryum_config piping a multi-line TOML block through `awk -v`
(swapped for a small Python substitution), and five call sites expanding
empty bash arrays under `set -u` (fixed with the portable
"${arr[@]+"${arr[@]}"}" idiom).
@kam-validmind kam-validmind changed the title Sse mcp live SSE relay for tools/call Jul 24, 2026
"Upstream"/"downstream" were used throughout the doc (Runtime ingress,
Component boundaries, the SSE relay section) but only ever defined ~200
lines in, under a feature-specific subsection. The System context diagram
also had two different nodes both labeled "MCP client" — the downstream
agent's connection and Atryum's own upstream-facing client — one of them
literally named "Upstream", colliding with what "upstream" means everywhere
else in the doc (the external tool server, the diagram's separate "Tools"
node).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant