Skip to content

Save the filtered view as a capture you can reopen and share #206

Description

@kerlenton

Context

The stream filter is the fastest way to get from a whole session down to the six frames that
matter, and it is the one part of mcpsnoop that cannot leave the TUI. The grammar lives entirely
in internal/tui, in filterEvents at internal/tui/model.go:1074 and matchToken at
internal/tui/model.go:1097, and the result is only ever assigned back to the model in
applyFilter at internal/tui/model.go:662. No command can reach it. newExportCmd declares its
flags at cmd/mcpsnoop/main.go:512-517, newCheckCmd at cmd/mcpsnoop/check.go:141-146 and
newOpenCmd at cmd/mcpsnoop/main.go:846-849, and none of the three has anything that narrows a
session.

$ mcpsnoop export trim2.jsonl --filter 'tool:echo'
mcpsnoop: unknown flag: --filter
$ mcpsnoop check trim2.jsonl --filter 'tool:echo'
mcpsnoop: unknown flag: --filter

The TUI cannot save its own filter either. exportCurrent at internal/tui/model.go:1282 calls
exporter.Build(m.store, id) at internal/tui/model.go:1296, which takes the session by id and
knows nothing about m.query, so pressing e under an active filter writes the whole session
anyway.

The second half of the problem is that nothing mcpsnoop writes can be read back. The format set is
fixed at internal/exporter/exporter.go:26-30 and holds json, html, text, har and otlp, with no
jsonl among them.

$ mcpsnoop export trim2.jsonl -T jsonl
mcpsnoop export: unknown export format "jsonl" (want json, html, text, har, or otlp)

ResolveSessionPath at internal/exporter/exporter.go:196 resolves an argument as a session id or
an existing file, and load at internal/exporter/exporter.go:266-288 then decodes it as a stream
of proxy.Envelope. An export is not that, so the artefact README.md:279-283 recommends handing
to somebody else is a dead end on arrival.

$ mcpsnoop export trim2.jsonl -T json --redact-secrets -o shared.json
$ mcpsnoop check shared.json
mcpsnoop check: shared.json: no envelopes found

open is worse rather than better, because it has no equivalent guard. loadOpenStore at
cmd/mcpsnoop/main.go:903-912 feeds the file straight to proxy.Decode
(internal/proxy/decode.go:12), and a single pretty printed export object decodes into an envelope
with every field at its zero value, so the file loads as a session with nothing in it instead of
being refused.

This is the workflow docs/POST_MORTEM.md already asks people to carry out by hand. It tells a
reporter to include "The filter that made the problem visible, if you used one" at line 106, then
says "Trim or redact frames before you share them" at line 109. mcpsnoop ships the redact half and
none of the trim half.

Prior art is settled here. tshark reads with -r, filters with -Y and writes with -w, and the
manual says of -Y that the filter is "applied before printing a decoded form of packets or writing
packets to a file" (https://www.wireshark.org/docs/man-pages/tshark.html). Wireshark's own
File > Export Specified Packets writes a subset to a capture file it can reopen
(https://www.wireshark.org/docs/wsug_html_chunked/ChIOExportSection.html). A capture tool that can
narrow but cannot save the narrowing is missing the half that makes the narrowing worth anything.

Nothing in revision 2026-07-28 governs how a proxy stores or shares what it observed, so this
changes no wire behaviour and adds no conformance rule. The one line that bears on it at all is the
security principle that "Users must retain control over what data is shared and what actions are
taken", under Security and Trust & Safety at
https://modelcontextprotocol.io/specification/2026-07-28/. Today a user can scrub a capture before
sharing it but cannot make it smaller, which means the only way to share less is to share nothing.

Decision needed before any of this is written

A trimmed log accuses itself of being incomplete, and that has to be settled first. Store.Ingest
at internal/store/store.go:320-327 reads a jump in the per-session Seq as envelopes dropped
upstream, and that count surfaces as SessionHeader.MissingFrames at internal/store/views.go:127,
as missing_frames in every export at internal/exporter/exporter.go:105, as the HAR comment at
internal/exporter/har.go:177, as the red counter in the TUI footer at internal/tui/view.go:216,
and as the incomplete signal in check. Removing exactly one request and its response from a real
capture by hand is enough to trigger it.

$ mcpsnoop check trim2.jsonl
session server.py-97028-e1d8296afd78: errors=0 invalid=0 warnings=0 mismatches=0 pending=0 deprecated=0 missing_frames=2
recorded first-seen tool baseline (trusted, not verified)
check passed

$ mcpsnoop check trim2.jsonl --fail-on incomplete
session server.py-97028-e1d8296afd78: errors=0 invalid=0 warnings=0 mismatches=0 pending=0 deprecated=0 missing_frames=2
check failed: incomplete
$ echo $?
1

There are two ways out and they are not equivalent. Renumbering Seq from one in the subset makes
the gaps vanish, but it also destroys the only thing that lets a reader line the subset up against
the full capture, and it would hide a genuine drop that happened before the filter ran. The other
way is a marker on the log itself saying the gaps are deliberate, which is what I would pick. It
needs to carry the source session's own missing_frames forward as a floor, so that filtering a
capture that really did drop frames does not launder that away.

Whichever is chosen, the answer belongs in the issue before the code does. The incomplete signal
is documented in the --fail-on help at cmd/mcpsnoop/check.go:141, CI is where it gets used, and
every filtered capture would trip it.

Proposed change

Lift the token grammar out of the TUI and give it to the commands, then teach the exporter to write
the one format mcpsnoop can already read.

The lift is close to mechanical. matchToken at internal/tui/model.go:1097 and matchStatus at
internal/tui/model.go:1168 both take a store.EventView and read no model state at all, despite
the *Model receiver, so they move to internal/store as an exported matcher compiled once from a
query string. The TUI then calls the same matcher, which is what keeps the flag and the / prompt
from drifting apart. The placeholder at internal/tui/model.go:577 and the table at
README.md:213-226 become the documentation for both.

On top of that, add jsonl to the format set at internal/exporter/exporter.go:26-30 and a
--filter flag to export, check and open. The jsonl writer re-emits matching envelopes rather
than a rendered view, which is what makes the output something open and check accept.

Three details decide whether this works.

The meta envelope has to survive the filter unconditionally. It is Seq 1 in every capture, it
carries proxy.SessionMeta (internal/proxy/frame.go:33-38), and it is where the session command
and cwd come from. Replay reads them at internal/tui/replay.go:24-26, so a subset that drops it is
a subset you cannot replay from.

The writer has to emit the redacted envelope, not the source line. load at
internal/exporter/exporter.go:282 redacts each envelope and hands the result to Ingest, then
lets it go. A jsonl writer that re-reads the original line would silently ignore --redact-secrets
and friends for this one format, which turns a sharing feature into a leak. Getting this right is
exactly what makes a filtered capture safe to attach to a bug report, since the existing redaction
flags then apply to the subset for free.

The subset has to be honest about what it no longer contains. Session scoped analysis is built from
frames like any other, so filtering them out removes it. A tools-only subset loses the capability
inspector and the tool cost accounting.

$ mcpsnoop export toolsonly.jsonl -T json | jq '{capabilities, definitions: .summary.definitions}'
{
  "capabilities": null,
  "definitions": null
}

Per-request conformance is unaffected, which is worth stating because it is the surprising half.
The checks judge a response by the revision its own request declared and deliberately never fall
back to the session, as requestProtocolVersion and missingResultTypeWarning at
internal/store/store.go:1609-1653 spell out, so dropping initialize from a subset does not
change a single verdict on the frames that remain.

Acceptance criteria

  • The filter grammar lives in internal/store as an exported matcher compiled from a query
    string, and internal/tui calls it rather than keeping a second copy.
  • Every token the TUI supports today behaves identically through the new matcher, covering
    tool, method, id including the MRTR root, task, dir, kind, status with both its
    words and a bare HTTP status number, and bare substring matching.
  • mcpsnoop export -T jsonl writes envelopes, and the result is accepted by check, open,
    export and diff without a special case.
  • export, check and open each accept --filter with the same grammar, and a query that
    matches nothing fails with a clear message rather than writing an empty file.
  • The meta envelope is always written, whatever the query, and replay works from a filtered
    capture opened in the TUI.
  • Redaction flags apply to a jsonl export, verified by a test that filters and redacts in one
    run and asserts the secret is absent from the bytes on disk.
  • A filtered capture does not report itself incomplete, per whatever the decision section above
    settles on, and check --fail-on incomplete passes on one.
  • A filtered capture taken from a source that genuinely dropped frames still reports those
    drops.
  • :export jsonl <path> in the TUI writes the current filter rather than the whole session, and
    e under an active filter does the same or says plainly that it did not.
  • Filtering a filtered capture is idempotent in the sense that the second run neither invents
    new gaps nor loses the first run's provenance.
  • README gains the round trip in the export section, and docs/POST_MORTEM.md:106-109 points at
    the command instead of asking for a manual trim.

Files

  • internal/tui/model.go:1074, :1097, :1168, :662, :577, :1282, :1296, the grammar to
    lift and the TUI export that ignores the filter
  • internal/store/views.go, new home for the matcher, next to EventView at :58
  • internal/store/store.go:320-327, the Seq gap counter the marker has to reach
  • internal/exporter/exporter.go:26-30, :179, :196, :266-288, the format set, the parser, the
    resolver and the loader where redaction is applied
  • internal/proxy/frame.go:33-38, :59, SessionMeta and the envelope a jsonl writer re-emits
  • cmd/mcpsnoop/main.go:512-517, :846-849, :903-912, export and open flags and the open loader
  • cmd/mcpsnoop/check.go:141-146, check flags and the incomplete signal
  • cmd/mcpsnoop/diff.go:19, :66, the other reader that already takes a .jsonl path
  • internal/tui/replay.go:24-26, why the meta envelope has to survive
  • README.md:213-226, :267-283, the filter table and the export section
  • docs/POST_MORTEM.md:104-109, the manual workflow this replaces

Out of scope

  • Any new filter token. This moves the grammar that exists and adds nothing to it. A token for
    something the TUI cannot filter by today is a separate issue.
  • A saved or named filter, a config file of them, or a filter history. One flag and one prompt.
  • Filtering the live capture path. The shim stays dumb and writes everything it sees. Filtering is
    a read-side operation over a finished log, and making the shim selective would mean deciding what
    to keep at a point where nothing has been correlated yet.
  • Merging or concatenating captures, and anything that rewrites Seq, ids or timestamps. The
    subset is a subset.
  • Making open and check accept the json, html, text, har or otlp exports. Those are rendered
    views and reading them back is a different feature with a different shape.
  • Filtering by anything that requires judging what a payload means rather than what it says. Every
    token here is decidable from the frame.

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