Skip to content

Collapse an MRTR chain into one interaction with per-hop timing #201

Description

@kerlenton

Context

Under Multi Round-Trip Requests one logical tool call is several requests on the wire. The server answers with an InputRequiredResult, the client gathers the answers from the user, and the client re-issues the original call under a fresh id. The MRTR page of the 2026-07-28 specification is explicit that the hops are separate requests.

The JSON-RPC id MUST be different between the initial request and the retry, as they are independent requests.

Note that the requests in each step are completely independent: the server processing the retry does not need any information beyond what is directly present in the retry request.

The same page puts no bound on how many hops there can be.

Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request. Servers MAY choose to return an InputRequiredResult on multiple attempts at the same request if they want to repeatedly prompt the user for information until they have what they need to complete the request.

mcpsnoop already does the hard part. matchRetry at internal/store/store.go:1378 infers the link from the echoed requestState or from the answered key set. The retry is mapped onto the same call object at internal/store/store.go:497, so the operation keeps one pending slot. completeCall at internal/store/store.go:841 deliberately leaves the call open on an input_required result so its duration spans the whole exchange, and both read paths skip the continuation so the chain counts once, at internal/store/views.go:663 and internal/store/views.go:690. The TUI even labels a retry row continues id N at internal/tui/view.go:787. docs/2026-07-28-mrtr-breaks-latency.md:36 records the reasoning, that the seconds the human spent answering are the interval you most want to see.

The gap is everything underneath that one number. A chain is stored as a single call with one Start and one End at internal/store/views.go:29, a Duration() that subtracts them at internal/store/views.go:50, and a ToolStats at internal/store/views.go:313 carrying one duration series. The call struct at internal/store/store.go:126 keeps no hop history at all, and the two fields that could have supplied one, mrtrState and mrtrKeys, are cleared the instant the retry is matched at internal/store/store.go:498. parseInputRequired at internal/store/store.go:1289 already reads which methods each InputRequiredResult asked for, but that parse feeds only the conformance warnings at internal/store/conformance.go:67 and internal/store/conformance.go:236 and is then discarded.

Reproduction

Save this as mrtr.jsonl. It is one book_flight chain of three hops where the server works 0.4s, 0.3s and 0.5s while the user takes 12s and then 25s to answer, followed by an ordinary fast call for contrast.

{"session_id":"demo","server_label":"booking","seq":1,"ts":"2026-07-28T12:00:00.000Z","direction":"c2s","transport":"stdio","raw":{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"to":"JFK"}}}}
{"session_id":"demo","server_label":"booking","seq":2,"ts":"2026-07-28T12:00:00.400Z","direction":"s2c","transport":"stdio","raw":{"jsonrpc":"2.0","id":1,"result":{"resultType":"input_required","requestState":"st-1","inputRequests":{"confirm":{"method":"elicitation/create","params":{"message":"Confirm $840?"}}}}}}
{"session_id":"demo","server_label":"booking","seq":3,"ts":"2026-07-28T12:00:12.400Z","direction":"c2s","transport":"stdio","raw":{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"book_flight","arguments":{"to":"JFK"},"requestState":"st-1","inputResponses":{"confirm":{"action":"accept"}}}}}
{"session_id":"demo","server_label":"booking","seq":4,"ts":"2026-07-28T12:00:12.700Z","direction":"s2c","transport":"stdio","raw":{"jsonrpc":"2.0","id":2,"result":{"resultType":"input_required","requestState":"st-2","inputRequests":{"seat":{"method":"elicitation/create","params":{"message":"Window or aisle?"}}}}}}
{"session_id":"demo","server_label":"booking","seq":5,"ts":"2026-07-28T12:00:37.700Z","direction":"c2s","transport":"stdio","raw":{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"book_flight","arguments":{"to":"JFK"},"requestState":"st-2","inputResponses":{"seat":{"action":"accept"}}}}}
{"session_id":"demo","server_label":"booking","seq":6,"ts":"2026-07-28T12:00:38.200Z","direction":"s2c","transport":"stdio","raw":{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"booked"}]}}}
{"session_id":"demo","server_label":"booking","seq":7,"ts":"2026-07-28T12:00:39.000Z","direction":"c2s","transport":"stdio","raw":{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"lookup_price"}}}
{"session_id":"demo","server_label":"booking","seq":8,"ts":"2026-07-28T12:00:39.200Z","direction":"s2c","transport":"stdio","raw":{"jsonrpc":"2.0","id":4,"result":{"content":[]}}}

The server spent 1.2s in total. The user spent 37s. Here is what check reports today.

$ mcpsnoop check --max-duration 5s mrtr.jsonl
session demo: errors=0 invalid=0 warnings=0 mismatches=0 pending=0 deprecated=0 missing_frames=0
assertion failed: 1 tool call exceeded the 5s budget (worst: tool "book_flight" took 38.2s)
$ echo $?
1

The tool is blamed for 38.2s of which it is responsible for 1.2s. eval reads store.Calls and CallView.Duration() at cmd/mcpsnoop/check.go:179 to cmd/mcpsnoop/check.go:193, and that duration is now wall clock for the entire interaction, so --max-duration is unusable as a server latency budget against any tool that elicits. There is no flag that measures the server's share.

The exports say the same thing eight times and never say the frames belong together.

$ mcpsnoop export --format text mrtr.jsonl | grep '^#'
#1 2026-07-28T12:00:00Z c2s request tools/call id=1 status=ok duration_ms=38200.000 tool=book_flight
#2 2026-07-28T12:00:00.4Z s2c response id=1 status=ok duration_ms=38200.000 tool=book_flight
#3 2026-07-28T12:00:12.4Z c2s request tools/call id=2 status=ok duration_ms=38200.000 tool=book_flight
#4 2026-07-28T12:00:12.7Z s2c response id=2 status=ok duration_ms=38200.000 tool=book_flight
#5 2026-07-28T12:00:37.7Z c2s request tools/call id=3 status=ok duration_ms=38200.000 tool=book_flight
#6 2026-07-28T12:00:38.2Z s2c response id=3 status=ok duration_ms=38200.000 tool=book_flight
#7 2026-07-28T12:00:39Z c2s request tools/call id=4 status=ok duration_ms=200.000 tool=lookup_price
#8 2026-07-28T12:00:39.2Z s2c response id=4 status=ok duration_ms=200.000 tool=lookup_price
$ mcpsnoop export --format json mrtr.jsonl | jq .summary.tools[0]
{
  "name": "book_flight",
  "calls": 1,
  "errors": 0,
  "pending": 0,
  "p50_ms": 38200,
  "p95_ms": 38200,
  "p99_ms": 38200,
  "result_bytes": 45,
  "max_result_bytes": 45
}
$ mcpsnoop export --format json mrtr.jsonl | grep -ci 'mrtr\|round_trip'
0
$ mcpsnoop export --format html mrtr.jsonl | grep -ci 'mrtr\|continues'
0

Five things are not recoverable from any of that output.

  1. How many round trips the operation took. The count exists nowhere in the store, the TUI or the exports.
  2. How much of the 38.2s was the server and how much was the client. Both server work and user thinking land in one scalar.
  3. What each hop asked for. parseInputRequired reads the inputRequests methods and throws them away once the warnings are computed.
  4. What the user answered. The exported call carries hop one's params because matchRetry never updates root.params, so the inputResponses on hops two and three appear only in the raw event bodies.
  5. The bytes the intermediate hops cost. Each InputRequiredResult in the fixture is 150 bytes, and ToolStats.ResultBytes reports 45, because internal/store/views.go:721 reads c.result, which by then holds only the final result.

--format har compounds it. internal/exporter/har.go:131 puts the whole duration into wait, so a HAR viewer draws a 38.2s server wait that did not happen.

Everything needed to fix this is already on the wire and already stored. The frame timestamps give per-hop server time and per-hop client turnaround by subtraction, the linked hop count gives the round trip count, and EventView.MRTRRoot at internal/store/views.go:107 already groups them. Nothing new needs to be captured, and the shim does not change.

Decision needed first

Should --max-duration keep meaning wall clock for the whole interaction?

Changing it to measure only the server's share would silently loosen every existing CI gate, and it would contradict the position taken in docs/2026-07-28-mrtr-breaks-latency.md:36, that folding the human interval in is the point. Leaving it as wall clock keeps a gate that no MRTR tool can pass on a slow user.

The recommendation here is to leave --max-duration alone and add sibling assertions that name what they measure, so nobody's existing pipeline changes behaviour on upgrade. Pick this before writing code, since the flag surface and the help text follow from it. If a maintainer prefers to redefine --max-duration instead, that is a breaking change and needs a note in the release.

Proposed change

Add an interaction view over the linked chain, derived at read time from the events the store already holds.

Give the store Interactions(sessionID string) []InteractionView in internal/store/views.go, built by walking sess.events and grouping on mrtrRoot, exactly as Calls and ToolSummary already walk them. A call with no retry is a one hop interaction, so the type describes every operation uniformly rather than only MRTR ones.

Each InteractionView carries the root call id, the method and operation name, the round trip count, the terminal outcome, and a slice of hops. Each hop carries its request id, its request and response timestamps, the server time for that hop, the client turnaround before it, and the sorted method names the InputRequiredResult asked for, which parseInputRequired already computes at internal/store/store.go:1301. Two rollups sit on the interaction, total server time as the sum of the hop server times, and total client turnaround as the remainder, so ServerTime + ClientTurnaround == Duration by construction.

Derive rather than store. The chain has no upper bound in the spec, so accumulating hops on the call struct would add an unbounded per-call allocation and walk straight into #167. Deriving from events costs nothing extra today. If #167 later evicts events, the durable minimum to keep on call is the round trip count and the two rollup totals, three scalars rather than a growing slice.

Only linked hops belong to an interaction. matchRetry refuses an ambiguous link on purpose, per TestMRTRRefusesAnAmbiguousLink at internal/store/store_test.go:1384, and the interaction view must not fill that gap in. An unlinked retry stays its own single hop interaction, and a chain the client abandoned reports the hops observed with a pending outcome. Nothing is inferred that matchRetry would not already assert.

Surface it in three places.

  • TUI. A panel that lists the interactions of the selected session with round trips, wall clock, server time, client turnaround and outcome, and expands to the hop breakdown. The per-tool summary panel at internal/tui/view.go:1410 gains a round trips column so a chatty tool is visible without opening the panel.
  • Export. An interactions array in the JSON export beside calls, a section in text and HTML, and round_trips plus server_time_ms on the existing CallExport at internal/exporter/exporter.go:117 so the per-call record stops implying the whole duration was server work. Set HAR wait to the server time and put the client turnaround in blocked, which is what the field is for, so internal/exporter/har.go:131 stops drawing a wait that did not happen.
  • Check. Two assertions, --max-round-trips N failing when any interaction exceeds N hops, and --max-server-duration D failing on the server share alone. Both are computed from timestamps with no judgement about intent, so they satisfy the rule that a check must be decidable from the wire.

These are assertions, not signals, so they are off by default and a default check run is unaffected.

Acceptance criteria

  • store.Interactions(sessionID) returns one entry per operation, with a hop slice, a round trip count, per-hop server time and per-hop client turnaround, the methods each InputRequiredResult asked for, and the terminal outcome.
  • For the fixture above it reports one book_flight interaction with 3 round trips, 1.2s server time and 37s client turnaround, and one lookup_price interaction with 1 round trip and 200ms server time.
  • ServerTime + ClientTurnaround == Duration holds for every interaction, including a one hop one, where client turnaround is zero.
  • A retry that matchRetry refuses to link appears as its own single hop interaction and is never folded into another, asserted alongside TestMRTRRefusesAnAmbiguousLink at internal/store/store_test.go:1384.
  • An abandoned chain, the fixture truncated after seq 4, reports the hops observed with a pending outcome and no invented final hop.
  • A chain carrying an MRTRStateIssue still reports its hops, so a requestState violation does not also destroy the timing view.
  • Calls and ToolSummary still report one call per chain, so this change adds a view rather than re-splitting the operation.
  • The TUI lists interactions with round trips, wall clock, server time and client turnaround, and the per-tool panel shows a round trips column.
  • export --format json emits an interactions array, and round_trips and server_time_ms appear on each CallExport.
  • export --format har puts server time in wait and client turnaround in blocked, and the two still sum to the entry time.
  • check --max-round-trips 2 fails on the fixture and names the tool and the count, --max-round-trips 3 passes.
  • check --max-server-duration 1s fails on the fixture and reports 1.2s rather than 38.2s, --max-server-duration 2s passes.
  • check --max-duration behaviour is byte for byte unchanged, covered by the existing TestCheckMaxDurationAssertion at cmd/mcpsnoop/check_test.go:433.
  • A default check run over the fixture still passes, since the new assertions are opt in.
  • No change to internal/proxy, since nothing new is read from the wire.

Out of scope

  • Counting the intermediate InputRequiredResult payloads in ToolStats.ResultBytes. It is the same root cause, internal/store/views.go:721 sees only the final result, but changing that field's meaning affects the context-cost accounting and the drift comparison and deserves its own issue.
  • Any judgement about whether a client's turnaround was reasonable, or whether a chain asked for too much. The house rule against heuristics that guess at intent applies. Report the numbers and let the assertion thresholds decide.
  • Reconstructing an interaction across a mcpsnoop diff of two sessions.
  • Replay of a chain. Replaying hop one alone produces an InputRequiredResult and nothing else useful, which is a separate design question and overlaps Replay is unavailable for sessions captured over HTTP #164.
  • Any change to the shim. It stays dumb, all of this is hub side.

Files

  • internal/store/views.go, new InteractionView and HopView types and the Interactions method, alongside Calls at line 654 and ToolSummary at line 675.
  • internal/store/store.go, reuse parseInputRequired at line 1289 for the per-hop methods, and keep matchRetry at line 1378 as the only authority on what links.
  • internal/store/store_test.go, extend the MRTR tests around lines 1194 to 1440.
  • internal/tui/view.go, the interaction panel, and the per-tool summary at line 1410.
  • internal/tui/model.go, key binding and panel state.
  • internal/exporter/exporter.go, CallExport at line 117 and the summary assembly at line 335.
  • internal/exporter/har.go, the timings at line 131 and line 169.
  • internal/exporter/html.go, the interactions section.
  • cmd/mcpsnoop/check.go, checkAssertions at line 152 and the flag registration at line 144.
  • README.md and docs/, document what the two new assertions measure and why --max-duration still means wall clock.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions