You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An agent run normally talks to several MCP servers at once. mcpsnoop records each one as its own session with nothing tying them together, so the question people actually have, what happened during this run, has no answer.
Reproduction. One client process spawns three shims, initializes each, lists tools on each, then calls one tool on each. The client propagates the same W3C trace context on every request, which is what a client instrumented with OpenTelemetry does.
$ ls "$MCPSNOOP_HOME"/sessions
files-97144-a9c6e9771489.jsonl
github-97145-5aad0ff61b2c.jsonl
search-97146-bc2bed02c554.jsonl
Everything the capture knows about where a session came from lives in its meta frame.
$ head -1 "$MCPSNOOP_HOME"/sessions/files-97144-a9c6e9771489.jsonl
{"session_id":"files-97144-a9c6e9771489","server_label":"files","seq":1,
"ts":"2026-08-01T19:20:39.290898+03:00","direction":"meta","transport":"stdio",
"raw":{"command":["python3","/tmp/server.py"],"cwd":"/Users/me/work"}}
SessionMeta holds Command and CWD and nothing else (internal/proxy/frame.go:33-38). The shim writes it once at startup (internal/proxy/stdio.go:141-142) and the store keeps exactly those two fields (internal/store/store.go:331-338). SessionHeader has no field a grouping could hang on (internal/store/views.go:114-128), Sessions() returns a flat list in first-seen order (internal/store/views.go:410-433), and the TUI filters that list by label substring only (internal/tui/model.go:1057-1069).
The CLIENT column is not a grouping key and cannot become one. It renders the clientInfo a session negotiated (internal/tui/view.go:431-433, internal/tui/model.go:864-869), so every session any one client ever opened carries the same string.
The consequence shows up in the commands. check with no argument resolves exactly one log, the most recently modified one (internal/exporter/exporter.go:196-237).
Two thirds of the run went unchecked and nothing said so. export is worse, because Load returns the first session id it sees and everything after it is dropped (internal/exporter/exporter.go:262-287).
Eighteen frames went in and six came out, silently.
The grouping key is already on the wire and mcpsnoop already parses it, in one place that nothing else can reach. WriteOTLP reads the request's trace context (internal/exporter/exporter.go:496, parser at :579).
$ for f in "$MCPSNOOP_HOME"/sessions/*.jsonl; do
basename "$f"; mcpsnoop export -T otlp "$f" | jq -r '[..|.traceId?]|map(select(.))|unique[]'
done
files-97144-a9c6e9771489.jsonl
4bf92f3577b34da6a3ce929d0e0e4736
github-97145-5aad0ff61b2c.jsonl
4bf92f3577b34da6a3ce929d0e0e4736
search-97146-bc2bed02c554.jsonl
4bf92f3577b34da6a3ce929d0e0e4736
One trace across all three sessions, and the only way to see it is to export each session by hand and compare. The store, the TUI, check and diff never learn of it. When the client sends no traceparent the exporter mints a synthetic id per session (internal/exporter/exporter.go:492), so the same three-server run comes out as three unrelated traces.
$ # same run, client sends no traceparent
files-97484-cdf01318eae3.jsonl
5404a22e604a0969d6cb1a126394b579
github-97485-3f6b7db0be19.jsonl
f400ce68612ee6f1cadc5e98428dbe14
search-97486-b41c76bbdbc3.jsonl
6e55dcb628e89f4c5924ca457c07630f
The spec reserves the key that makes the first case work. From Base Protocol, Overview, /specification/2026-07-28/basic.
As an exception to the prefix requirement above, the keys traceparent, tracestate, and baggage are reserved for OpenTelemetry trace context propagation. When present, their values MUST follow W3C Trace Context and W3C Baggage formats respectively.
Hosted tracing tools model a session as a set of connected traces and surface failure patterns at that level (Langfuse observability overview). That grouping is close to the whole of what they add over a local capture, and it is the one thing mcpsnoop cannot answer.
Two things already in the tree shorten the work a lot. First, check - on a concatenated stream already reports every session it finds, because summarizeCheck loops over st.Sessions() (cmd/mcpsnoop/check.go:256-258).
The missing piece is not the reporting, it is knowing which logs belong together. Second, Envelope.Raw is the original bytes untouched, so every log already on disk carries whatever traceparent its client sent. Nothing needs recapturing.
Decision needed first
Two questions change the shape of this feature, so settle them before anyone writes code.
What a run is when the client sent no trace context. The obvious second key is the client process, and the 2026-07-28 statelessness rules say plainly that a process is not a conversation. From the same page.
Clients SHOULD NOT use an individual task, thread, or conversation as the lifetime boundary for the stdio process.
This implies that an open connection, such as a STDIO process, is not a conversation or session: clients may interleave unrelated requests on the same transport, and a server must not treat connection or process identity as a proxy for conversation or session continuity.
So the client process is a fact mcpsnoop can read, and it answers a narrower question than "one agent run". Three options.
A, trace id only. Nothing to argue with, entirely spec sanctioned, and empty for every client that does not propagate trace context today, which is the case that motivated this in the first place.
B, trace id when present and client process otherwise, each group labelled by the key that made it. Recommended. Both keys are observed facts, neither is a guess about intent, and labelling them separately means neither is passed off as the other. The cost is that a client which stays open for a week puts a week of work into one process group, so that group must be named for what it is, "client 97001 since 19:20", and never "run".
C, ask the client which run this is. Out, mcpsnoop is local, has no account and makes no network calls of its own, and there is no protocol channel for it.
Session granularity or call granularity. Statelessness lets one stdio process carry several conversations, so one session can legitimately hold frames from more than one trace id. Grouping whole sessions is cheap and fits the current TUI, and it is wrong for that session. Grouping calls is right and turns the sessions list into a runs list whose children are calls drawn from several sessions, which is a much bigger change. Recommendation is session granularity for the first cut, with a session that carried more than one trace id listed under each trace it touched and showing its per-trace call count rather than its total, so the mixed case is visible rather than quietly resolved one way.
Proposed change
1. Record the client process in SessionMeta. Add the parent process id and that process's start time. The pair is what makes it unique, since a bare pid is reused and a reused pid would merge two unrelated runs. This is read once at shim startup, in the same place cwd() is read (internal/proxy/stdio.go:16-19, :141), so the shim stays dumb. It correlates nothing and interprets nothing, it reports one startup fact exactly as it already reports the working directory. Both fields are optional so a log written by an older build still loads and simply has no process key.
Verified as cheap on darwin using a dependency already in the module graph.
golang.org/x/sys is already in go.mod as an indirect requirement. Linux reads field 22 of /proc/<ppid>/stat with the standard library, Windows uses GetProcessTimes from x/sys/windows, and the per-platform file split has precedent in cmd/mcpsnoop/console_unix.go and console_windows.go. Where the start time cannot be read, record the pid alone and mark the key as weak, and do not form a group from a weak key rather than form a possibly wrong one.
2. Lift trace context out of the exporter. Move traceContext, metaString, validTraceState and parseTraceparent (internal/exporter/exporter.go:563-702) into a package the store and the exporter both use, and have the store read the trace id off every request frame it ingests. No shim change, no format change, and it works on logs already on disk.
3. Give the store a run identity. Per session, record the trace ids observed on requests and the client process identity from the meta frame, put a run id and the kind of key that produced it on SessionHeader, and add a Runs() view. Ordering across sessions has to come from Envelope.TS, never from Seq, which is per session (internal/proxy/frame.go:62-63).
4. Group the TUI sessions list by run. Run header rows carrying per-run request, response and error totals, a collapse toggle, and a run:<id> filter token ANDed with the existing tokens the same way the stream filter works (internal/tui/model.go:1057, :1071-1073).
5. Let check and export take a run.--run <id> loads every log of that run into one store. check needs nothing new to report it, summarizeCheck already emits a line per session, and it exits non-zero when any session in the run trips a selected signal. export renders every session of the run ordered by frame timestamp across sessions.
6. Make the synthetic OTLP trace id run scoped. When a run key exists and the client sent no traceparent, derive the synthetic id from the run key rather than from the session id (internal/exporter/exporter.go:492), so exporting a run produces one trace instead of N.
Acceptance criteria
SessionMeta carries the client process identity, parent pid plus that process's start time, written once at shim startup beside cwd()
a log whose meta frame lacks the new fields still loads through open, export and check, and its session simply has no process key
the trace context parser lives in one package used by both the store and the exporter, and every case in internal/exporter/exporter_test.go still passes unchanged, including the invalid traceparent, the future version and the tracestate discard cases
the store records the trace ids seen per session and the client process identity, and exposes a run id plus its key kind on SessionHeader
run membership and within-run ordering are computed from Envelope.TS, with a test proving Seq is not used across sessions
two sessions sharing a parent pid but with different parent start times never land in the same run, covered by a test
a session with neither a trace id nor a readable process identity is shown ungrouped, not placed in an "other" bucket that implies a relationship it does not have
a run group's label names the key that made it, a trace id prefix or a client process with its start time, and never a single word that hides which
the sessions list groups by run with per-run request, response and error totals, and the group can be collapsed
run:<id> works as a filter token and ANDs with the existing tokens
mcpsnoop check --run <id> loads every session of the run, prints one line per session, and exits non-zero when any of them trips a selected signal
mcpsnoop export --run <id> renders every session of the run in timestamp order across sessions, in json, html and text at minimum
mcpsnoop export -T otlp --run <id> emits one trace for a run the client traced, and one run scoped synthetic trace for a run it did not
--run with an unknown id fails with a message naming what it looked for, rather than falling back to the newest session
Out of scope
No heuristic that judges whether two sessions look related. The only keys are a trace id the client put on the wire and a process identity the OS reports. If neither is present there is no group.
No new check signal. Grouping changes what a run of check covers, it adds nothing new to fail on, and nothing here is decided by guessing intent.
No network calls, no account, no daemon. Everything is computed from bytes already in the state directory.
Cross machine grouping. remote forwards a socket, so a remote shim's sessions land in the local state dir with a pid that means nothing here. Those group by trace id or not at all.
HTTP transport gets the trace id key only. Only the stdio shim emits a meta frame at all (internal/proxy/frame.go:20-23, internal/proxy/stdio.go:142), so there is no client process to record for mcpsnoop http.
Context
An agent run normally talks to several MCP servers at once. mcpsnoop records each one as its own session with nothing tying them together, so the question people actually have, what happened during this run, has no answer.
Reproduction. One client process spawns three shims, initializes each, lists tools on each, then calls one tool on each. The client propagates the same W3C trace context on every request, which is what a client instrumented with OpenTelemetry does.
Everything the capture knows about where a session came from lives in its meta frame.
SessionMetaholdsCommandandCWDand nothing else (internal/proxy/frame.go:33-38). The shim writes it once at startup (internal/proxy/stdio.go:141-142) and the store keeps exactly those two fields (internal/store/store.go:331-338).SessionHeaderhas no field a grouping could hang on (internal/store/views.go:114-128),Sessions()returns a flat list in first-seen order (internal/store/views.go:410-433), and the TUI filters that list by label substring only (internal/tui/model.go:1057-1069).The CLIENT column is not a grouping key and cannot become one. It renders the
clientInfoa session negotiated (internal/tui/view.go:431-433, internal/tui/model.go:864-869), so every session any one client ever opened carries the same string.The consequence shows up in the commands.
checkwith no argument resolves exactly one log, the most recently modified one (internal/exporter/exporter.go:196-237).Two thirds of the run went unchecked and nothing said so.
exportis worse, becauseLoadreturns the first session id it sees and everything after it is dropped (internal/exporter/exporter.go:262-287).Eighteen frames went in and six came out, silently.
The grouping key is already on the wire and mcpsnoop already parses it, in one place that nothing else can reach.
WriteOTLPreads the request's trace context (internal/exporter/exporter.go:496, parser at :579).One trace across all three sessions, and the only way to see it is to export each session by hand and compare. The store, the TUI,
checkanddiffnever learn of it. When the client sends no traceparent the exporter mints a synthetic id per session (internal/exporter/exporter.go:492), so the same three-server run comes out as three unrelated traces.The spec reserves the key that makes the first case work. From Base Protocol, Overview,
/specification/2026-07-28/basic.Hosted tracing tools model a session as a set of connected traces and surface failure patterns at that level (Langfuse observability overview). That grouping is close to the whole of what they add over a local capture, and it is the one thing mcpsnoop cannot answer.
Two things already in the tree shorten the work a lot. First,
check -on a concatenated stream already reports every session it finds, becausesummarizeCheckloops overst.Sessions()(cmd/mcpsnoop/check.go:256-258).The missing piece is not the reporting, it is knowing which logs belong together. Second,
Envelope.Rawis the original bytes untouched, so every log already on disk carries whatever traceparent its client sent. Nothing needs recapturing.Decision needed first
Two questions change the shape of this feature, so settle them before anyone writes code.
What a run is when the client sent no trace context. The obvious second key is the client process, and the 2026-07-28 statelessness rules say plainly that a process is not a conversation. From the same page.
So the client process is a fact mcpsnoop can read, and it answers a narrower question than "one agent run". Three options.
Session granularity or call granularity. Statelessness lets one stdio process carry several conversations, so one session can legitimately hold frames from more than one trace id. Grouping whole sessions is cheap and fits the current TUI, and it is wrong for that session. Grouping calls is right and turns the sessions list into a runs list whose children are calls drawn from several sessions, which is a much bigger change. Recommendation is session granularity for the first cut, with a session that carried more than one trace id listed under each trace it touched and showing its per-trace call count rather than its total, so the mixed case is visible rather than quietly resolved one way.
Proposed change
1. Record the client process in
SessionMeta. Add the parent process id and that process's start time. The pair is what makes it unique, since a bare pid is reused and a reused pid would merge two unrelated runs. This is read once at shim startup, in the same placecwd()is read (internal/proxy/stdio.go:16-19, :141), so the shim stays dumb. It correlates nothing and interprets nothing, it reports one startup fact exactly as it already reports the working directory. Both fields are optional so a log written by an older build still loads and simply has no process key.Verified as cheap on darwin using a dependency already in the module graph.
golang.org/x/sysis already in go.mod as an indirect requirement. Linux reads field 22 of/proc/<ppid>/statwith the standard library, Windows usesGetProcessTimesfromx/sys/windows, and the per-platform file split has precedent in cmd/mcpsnoop/console_unix.go and console_windows.go. Where the start time cannot be read, record the pid alone and mark the key as weak, and do not form a group from a weak key rather than form a possibly wrong one.2. Lift trace context out of the exporter. Move
traceContext,metaString,validTraceStateandparseTraceparent(internal/exporter/exporter.go:563-702) into a package the store and the exporter both use, and have the store read the trace id off every request frame it ingests. No shim change, no format change, and it works on logs already on disk.3. Give the store a run identity. Per session, record the trace ids observed on requests and the client process identity from the meta frame, put a run id and the kind of key that produced it on
SessionHeader, and add aRuns()view. Ordering across sessions has to come fromEnvelope.TS, never fromSeq, which is per session (internal/proxy/frame.go:62-63).4. Group the TUI sessions list by run. Run header rows carrying per-run request, response and error totals, a collapse toggle, and a
run:<id>filter token ANDed with the existing tokens the same way the stream filter works (internal/tui/model.go:1057, :1071-1073).5. Let
checkandexporttake a run.--run <id>loads every log of that run into one store.checkneeds nothing new to report it,summarizeCheckalready emits a line per session, and it exits non-zero when any session in the run trips a selected signal.exportrenders every session of the run ordered by frame timestamp across sessions.6. Make the synthetic OTLP trace id run scoped. When a run key exists and the client sent no traceparent, derive the synthetic id from the run key rather than from the session id (internal/exporter/exporter.go:492), so exporting a run produces one trace instead of N.
Acceptance criteria
SessionMetacarries the client process identity, parent pid plus that process's start time, written once at shim startup besidecwd()open,exportandcheck, and its session simply has no process keyinternal/exporter/exporter_test.gostill passes unchanged, including the invalid traceparent, the future version and the tracestate discard casesSessionHeaderEnvelope.TS, with a test provingSeqis not used across sessionsrun:<id>works as a filter token and ANDs with the existing tokensmcpsnoop check --run <id>loads every session of the run, prints one line per session, and exits non-zero when any of them trips a selected signalmcpsnoop export --run <id>renders every session of the run in timestamp order across sessions, in json, html and text at minimummcpsnoop export -T otlp --run <id>emits one trace for a run the client traced, and one run scoped synthetic trace for a run it did not--runwith an unknown id fails with a message naming what it looked for, rather than falling back to the newest sessionOut of scope
checkcovers, it adds nothing new to fail on, and nothing here is decided by guessing intent.remoteforwards a socket, so a remote shim's sessions land in the local state dir with a pid that means nothing here. Those group by trace id or not at all.mcpsnoop http.Files
internal/proxy/frame.go,SessionMetagains the two optional process fieldsinternal/proxy/stdio.go, writes them at startup next tocwd()console_unix.goandconsole_windows.gosplit incmd/mcpsnoopinternal/exporter/exporter.go, the trace context helpers move out and the synthetic trace id becomes run scopedinternal/store/store.go, ingest of the new meta fields and of_meta.traceparenton request framesinternal/store/views.go,SessionHeadergains the run identity, plus aRuns()viewinternal/tui/model.goandinternal/tui/view.go, grouped sessions list, run labels and therun:tokencmd/mcpsnoop/check.go,--runcmd/mcpsnoop/main.go,--runforexportandopengo.mod,golang.org/x/sysmoves from indirect to direct