Docs Look up a tool in the MCP captures bundle

Look up a tool in the MCP captures bundle

On this page

The captures bundle works with capture files in the file root: listing, searching, comparing, opening and exporting them, and the evidence that ties a finding to captured bytes.

Load only these tools with --mcp-tools captures, or combine bundles as Choosing which tools load describes. Every tool on this page follows the rules every tool follows, and each section states only what its tool does differently. The MCP tool reference lists the other bundles and finds any tool by name.

ToolParametersReturns
list_captures–Capture files and stored vCon containers in --mcp-file-root, with sizes and each one’s kind
find_in_capturesfilter, max_files?, deadline_ms?, wait_seconds?Which capture files hold dialogs matching a filter, without opening any of them; a sweep that outlasts its wait returns a job
find_in_captures_statusjob_id, wait_seconds?A find_in_captures job’s progress, or its result once it finishes
cancel_find_in_capturesjob_idStops a running find_in_captures job
compare_capturesa, b, dimensions?, top_n?Diffs two capture files in --mcp-file-root by aggregate, ranked by how far each bucket MOVED. Neither becomes the loaded capture
show_evidencerefs, max_bytes?Follows frame pointers back to the captured bytes: verified, unverified, or unresolvable with a reason
decode_evidenceframe_ref, field?Decodes the frame one pointer names: link type, addressing, and each SIP header’s byte range inside the message and the frame
build_evidence_packagecall_ids, filenameWrite. One directory of escalation artifacts in --mcp-file-root: pcapng, ladder and RTP stats per call, manifest, and the rebuilt-frames README
save_findingssummary, call_id?, detail?Write. Records the agent’s conclusion to sipnab’s log. Needs --mcp-allow-save-findings; no tool reads it back
export_capturefilenameWrites held SIP signaling to a pcap in --mcp-file-root (re-synthesized frames, no RTP)
open_capturefilenameDestructive. Replaces every dialog and stream with another capture from --mcp-file-root. Needs --mcp-allow-open-capture; loads in the background

Survey — what is in this capture

Start here when you have a capture and no particular suspect. These answer “what is in it”, “how much”, and “is the server even seeing traffic”.

list_captures

Capture files and stored vCon containers in the configured root, with their sizes and the instant each one’s first packet arrived.

Parameters: none.

It lists what sipnab can open by name, matched case-insensitively:

  • .pcap, .pcapng and .cap files, their gzip-compressed forms such as *.pcap.gz, and archives of captures (.tar, .tgz, .tar.gz, and .zip in builds with the archive feature). Each has kind: "capture".
  • Stored vCon containers: *.vcon.json, the name sipnab writes the unsigned, signed and encrypted forms under, and *.vcon.json.gz, the name gzip gives the gzip format. Each has kind: "vcon". open_capture and find_in_captures read them as -I does. The .<name>.partial file the fetcher and the forwarder write before they move a container into place is not listed.

Each entry carries encrypted: true for a ZIP holding an encrypted member, read from the archive’s directory alone, so listing never tries a password. It is false for a vCon container, encrypted or not. It skips directories. The TUI’s file browser lists by the same rule. open_capture accepts one thing this listing leaves out, a capture with no extension at all, so an agent that treats the listing as the whole set it may open misses those. Ask the operator, or try the name.

list_captures {}

Without --mcp-file-root the whole file-tool group is off, and this answers with a refusal rather than an empty list — “no directory configured” and “the directory is empty” are different facts:

{ "code": -32602,
  "message": "file tools are disabled: start sipnab with --mcp-file-root <DIR>" }

Otherwise it answers with captures sorted by filename, plus schema_version. Running it against tests/pcap-samples returns 36 of the directory’s 38 entries. The two it leaves out are PROVENANCE.md, which is not a capture, and one capture carrying no extension at all:

// list_captures {}
{
  "schema_version": 1,
  "captures": [
    { "filename": "Asterisk_ZFONE_XLITE.pcap", "kind": "capture", "bytes": 255581,
      "first_packet": "2010-09-27T07:12:49+00:00" },
    { "filename": "DTMFsipinfo.pcap", "kind": "capture", "bytes": 25429,
      "first_packet": "2011-04-27T08:14:29+00:00" },
    { "filename": "b2bua-asterisk.pcapng", "kind": "capture", "bytes": 114952,
      "first_packet": "2016-05-18T18:23:03+00:00" }
    // ... 27 more
  ]
}

first_packet is what turns a listing into a choice. It is the timestamp on the first record of the file, read whole seconds, so forty rotated captures narrow to the two that could hold a call from 14:02 without opening each one in turn. Opening one is not free: open_capture replaces the loaded capture and voids every cursor and Call-ID an agent is holding.

The key reads null whenever sipnab got no first packet out of the file — a rotated capture that never received one, or a file it could not open — so treat null as “unknown”, not as “empty”. An archive always reads null: its first packet belongs to whichever member holds the earliest traffic, and finding out means unpacking it.

For a vCon container, first_packet is the time of the earliest SIP message it stores. sipnab reads the whole container to find it, as -I does. It reads null for a container sipnab cannot open, such as an encrypted one on a run with no --vcon-key.

sipnab reports neither a dialog count nor a last-packet time here, because both need the whole file parsed and a listing that costs a full read of every capture in the root is a listing nobody runs.

find_in_captures

Which of these files holds the call, asked without giving up the one you are working in.

list_captures narrows forty rotated files to the two that could hold a call, by time. That is a filter, not an answer. The question an operator actually has — “which of these holds Call-ID X” — had no tool at all: the only way inside another file is open_capture, documented Destructive, which replaces every dialog and stream and mints a new capture_identity that voids every cursor you hold.

This sweeps instead: a scratch store per file, the filter applied, the loaded capture untouched. An archive in the root counts as one file: the sweep reads every capture inside it into the one scratch store, and a match names the archive. The sweep opens the files list_captures lists, stored vCon containers included: it reads the SIP messages a container stores, as -I does.

NameTypeLegal valuesIf omitted
filterstringA filter DSL expression, the same vocabulary every other filtering tool takes. Unparseable fails with invalid_params before the sweep opens anythingRequired
max_filesu32?Files to open before stopping. Clamped to the operator’s --mcp-sweep-max-files ([limits] mcp_sweep_max_files, default 20); 0 means that ceilingThe ceiling
deadline_msu64?Wall-clock the sweep may spend. Clamped to the operator’s --mcp-sweep-deadline-ms ([limits] mcp_sweep_deadline_ms, default 30000); 0 means that ceilingThe ceiling
wait_secondsu32?Seconds this call waits for the sweep to finish before it returns the running job. Clamped to --mcp-max-wait-seconds (default 60); 0 returns at once30
// find_in_captures { "filter": "call_id == \"1-1966@10.0.2.20\"" }
{
  "schema_version": 2,
  "job_id": "sweep-1",
  "status": "done",
  "limits": { "max_files": 20, "deadline_ms": 30000 },
  "progress": { "files_examined": 12, "files_total": 12, "elapsed_ms": 840,
                "cancel_requested": false },
  "sweep": {
    "matches": [
      { "filename": "rotated-03.pcap", "dialogs_matched": 1,
        "first_call_id": "1-1966@10.0.2.20" }
    ],
    "files_examined": 12,
    "files_total": 12,
    "unreadable": [],
    "complete": true
  }
}

The sweep is a background job

The sweep reads its files on its own thread, never on the thread that serves MCP and REST. While it reads, sipnab answers every other MCP call and every REST request as usual.

The call that starts it waits up to wait_seconds for it to finish. When the sweep finishes in that time, the same call returns status: "done" and the sweep object. When it does not, the call returns its job_id, status: "running" and progress, and no sweep:

// find_in_captures { "filter": "call_id == \"1-1966@10.0.2.20\"", "wait_seconds": 0 }
{
  "schema_version": 2,
  "job_id": "sweep-2",
  "status": "running",
  "limits": { "max_files": 40, "deadline_ms": 3600000 },
  "progress": { "files_examined": 3, "files_total": 40, "elapsed_ms": 112,
                "cancel_requested": false }
}

Poll it with find_in_captures_status and stop it with cancel_find_in_captures. All three tools return this one shape. status is running, done, or canceled, and sweep is present only once the job has finished.

The job holds no --mcp-max-concurrent slot. A call holds a slot only while it waits, and --mcp-max-wait-seconds bounds the wait. Operator settings bound the jobs themselves:

BoundFlagConfig keyDefaultAccepted
Sweeps running at once, per server--mcp-sweep-max-running[limits] mcp_sweep_max_running41 to 64
Finished results waiting for a poll, per server--mcp-sweep-max-held-results[limits] mcp_sweep_max_held_results161 to 256
Seconds a finished result waits for a poll, from the moment it finished--mcp-sweep-result-retention-secs[limits] mcp_sweep_result_retention_secs6001 to 43200

With the defaults, while four sweeps run, sipnab refuses a fifth with invalid_request. The message reports the configured bound, names --mcp-sweep-max-running and [limits] mcp_sweep_max_running, and names the two tools that end a sweep. One poll hands over a finished result. After that poll, after the retention passes with no poll, or once the held bound of newer results wait, the job id is unknown and sipnab refuses a poll of it with invalid_params. That message reports the configured retention and held bound and names both flags. Past the held bound, sipnab drops the result of the earliest-started sweep first. The flag overrides the key.

The maxima, and why each is where it is:

  • --mcp-sweep-max-running accepts at most 64. Each running sweep is one operating system thread reading capture files, with its own scratch dialog and stream stores, so the bound caps the threads and stores that agents can hold at once. 64 is sixteen times the default.
  • --mcp-sweep-max-held-results accepts at most 256. A held result keeps one entry per file the sweep matched and one per file it could not read, so the memory results hold is this bound times the largest result. 256 is four held results per running sweep at the running maximum, the ratio the defaults carry.
  • --mcp-sweep-result-retention-secs accepts at most 43200, twelve hours, the same span as the --mcp-sweep-deadline-ms maximum: a finished result waits at most as long as the longest sweep may run.

sipnab refuses 0 and any value above the maximum from the flag and from the key. In the source, the defaults are DEFAULT_MCP_SWEEP_MAX_RUNNING (4), DEFAULT_MCP_SWEEP_MAX_HELD_RESULTS (16) and DEFAULT_MCP_SWEEP_RESULT_RETENTION_SECS (600), and the maxima are MAX_MCP_SWEEP_MAX_RUNNING (64), MAX_MCP_SWEEP_MAX_HELD_RESULTS (256) and MAX_MCP_SWEEP_RESULT_RETENTION_SECS (43200).

A sweep stops before its next packet, not only before its next file, when the deadline passes, when cancel_find_in_captures stops it, and when sipnab receives SIGTERM or SIGINT. stopped_because then says deadline, canceled or shutdown, and the file it was reading is not counted in files_examined.

Read complete before believing an empty result

A sweep runs under bounds, and one that reports no matches has not shown the call is absent — only that it did not find it in what it managed to read. complete is true in exactly one case: every candidate examined, and every one of them readable.

It is false when the sweep stopped early — stopped_because says max-files, deadline, canceled or shutdown — and false when the sweep could not open some file. unreadable names each of those, with its reason, and never skips one silently: the file nobody could look in is exactly the one that might hold the call. A reason comes from the OS and from libpcap, so sipnab strips control characters and keeps the first MAX_REASON_CHARS (200).

A match does not excuse an incomplete sweep either. On a rotated spool a call that spans a rotation is in two files, so finding it in one says nothing about the other.

limits is what this sweep ran under: the caller’s max_files and deadline_ms after sipnab clamped them to the operator’s ceilings.

The ceilings are the operator’s settings

The operator sets both ceilings, and a caller can only ask for less:

CeilingFlagConfig keyDefaultAccepted
Files per sweep--mcp-sweep-max-files[limits] mcp_sweep_max_files201 to 4294967295
Milliseconds per sweep--mcp-sweep-deadline-ms[limits] mcp_sweep_deadline_ms300001 to 43200000

A spool that rotates forty files needs --mcp-sweep-max-files 40 before one sweep can reach the oldest file. With the default, the sweep stops after 20 files with stopped_because: "max-files" and complete: false. The flag overrides the key.

The defaults are DEFAULT_MCP_SWEEP_MAX_FILES (20) and DEFAULT_MCP_SWEEP_DEADLINE_MS (30000), which find_in_captures reads as DEFAULT_MAX_FILES (20) and DEFAULT_DEADLINE_MS (30000). The maxima are MAX_MCP_SWEEP_MAX_FILES (4294967295), the largest value of the per-call max_files, a 32-bit unsigned integer, and MAX_MCP_SWEEP_DEADLINE_MS (43200000), twelve hours. A sweep runs as a background job and holds no MCP slot, so the deadline maximum bounds how long one sweep may keep a thread and a disk busy. sipnab refuses 0 and any value above the maximum from the flag and from the key.

Why two bounds and not one

max_files makes the cost predictable. deadline_ms is the one that matters: a file’s cost is its size, which the caller cannot see, so twenty small files and twenty 2 GB files are the same max_files and a very different wait. The sweep tests the deadline before each file and before each packet, so no file overruns it by more than one packet.

find_in_captures_status

Polls a find_in_captures job. It waits up to wait_seconds for the job to finish and answers with the same shape find_in_captures returns: status: "running" with progress, or done or canceled with the sweep result. It hands the result over once, and sipnab refuses the next poll of that id.

NameTypeLegal valuesIf omitted
job_idstringThe job_id a find_in_captures call returned. An id the server does not hold fails with invalid_params, naming the idRequired
wait_secondsu32?Seconds to wait for the job to finish before answering. Clamped to --mcp-max-wait-seconds; 0 answers at once30

The wait does not block the server: sipnab answers other calls and REST requests while it lasts.

// find_in_captures_status { "job_id": "sweep-2" }
{
  "schema_version": 2,
  "job_id": "sweep-2",
  "status": "done",
  "limits": { "max_files": 40, "deadline_ms": 3600000 },
  "progress": { "files_examined": 40, "files_total": 40, "elapsed_ms": 95210,
                "cancel_requested": false },
  "sweep": {
    "matches": [
      { "filename": "rotated-39.pcap", "dialogs_matched": 1,
        "first_call_id": "1-1966@10.0.2.20" }
    ],
    "files_examined": 40,
    "files_total": 40,
    "unreadable": [],
    "complete": true
  }
}

cancel_find_in_captures

Stops a running find_in_captures job before its next packet. It returns at once, with progress.cancel_requested: true. A following find_in_captures_status poll reports status: "canceled", with stopped_because: "canceled", complete: false, and the files read to the end before the cancel. For a job that had already finished, this call returns its result and hands it over.

NameTypeLegal valuesIf omitted
job_idstringThe job_id a find_in_captures call returned. An id the server does not hold fails with invalid_params, naming the idRequired
// cancel_find_in_captures { "job_id": "sweep-3" }
{
  "schema_version": 2,
  "job_id": "sweep-3",
  "status": "running",
  "limits": { "max_files": 40, "deadline_ms": 3600000 },
  "progress": { "files_examined": 7, "files_total": 40, "elapsed_ms": 18044,
                "cancel_requested": true }
}

The tool is readOnlyHint: true: it stops a read that find_in_captures started and changes no capture, store or file.

Find — narrow to the calls that matter

You know something is wrong but not which call. These cut a capture down to the dialogs worth reading: by symptom, by text, by time, or by relationship to a call you already have.

compare_captures

Is today worse than yesterday, and where.

Baseline comparison is what turns a capture tool into an operations tool, and nothing else on this surface reaches past the loaded capture. The answer has to be a diff of AGGREGATES rather than of dialog lists — two captures never share a Call-ID, so comparing rows finds every dialog different and says nothing. This tool reads both files, groups each one the way aggregate_dialogs groups, and ranks the buckets by how far they MOVED.

NameTypeLegal valuesIf omitted
astringThe baseline: a bare filename inside --mcp-file-root.Required — the call fails.
bstringThe capture to hold against it, in the same root.Required — the call fails.
dimensionsstring[]?From the aggregate_dialogs vocabulary: state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec. A repeated name de-duplicates rather than failing. Anything else fails naming the nine.["state", "response_code"] — how many calls reached each state, and which codes they ended on.
top_ninteger?Rows per dimension. Everything past it sums into other. Ceiling is --mcp-max-rows.50.

The example diffs two files in tests/pcap-samples:

// compare_captures { "a": "sip-rtp-g711.pcap", "b": "sip-auth-failure.pcapng",
//                    "dimensions": ["state"], "top_n": 4 }
{
  "schema_version": 1,
  "a": { "filename": "sip-rtp-g711.pcap", "packets": 852, "dialogs": 2,
         "streams": 2, "dialogs_dropped": 0, "read_error": null },
  "b": { "filename": "sip-auth-failure.pcapng", "packets": 8, "dialogs": 2,
         "streams": 0, "dialogs_dropped": 0, "read_error": null },
  "dimensions": [
    {
      "dimension": "state",
      "buckets": [
        { "value": "Completed",  "a": 1, "b": 0, "delta": -1 },
        { "value": "Failed",     "a": 0, "b": 1, "delta":  1 },
        { "value": "InCall",     "a": 1, "b": 0, "delta": -1 },
        { "value": "Terminated", "a": 0, "b": 1, "delta":  1 }
      ],
      "other": { "value": "(other)", "a": 0, "b": 0, "delta": 0 },
      "distinct_values": 4
    }
  ],
  "summary": "'sip-rtp-g711.pcap' (2 dialogs) is the baseline; 'sip-auth-failure.pcapng' (2 dialogs) is held against it, so delta is b minus a. Neither is the capture this server has loaded."
}

Buckets rank by absolute movement, not by size. The question is what changed, and the largest bucket is usually the one that changed least, so “today is worse than yesterday, and here is where” reads off the first row. delta is b - a throughout. A value absent from one side counts as zero there rather than as missing, because a response code appearing only in b IS the finding.

Neither file becomes the loaded capture. Both reads land in private stores that the call drops before it builds the answer, so what an agent asked a minute ago stays true afterwards. The read runs on a blocking thread, since two whole captures inside a tool handler would otherwise hold the single runtime thread the MCP server and the REST API share.

One thing does escape that isolation, which is why this tool carries readOnlyHint: false: reading a file through the shared pipeline bumps the PROCESS-WIDE undecodable-frame tallies and the ICMP evidence store that get_capture_report reports. It destroys nothing, and something observable moves. This reuses open_capture’s existing behavior rather than inventing a new one.

Read the two CaptureSide blocks before the deltas. dialogs_dropped above zero means that side hit the dialog ceiling and every count under it is a FLOOR rather than a total, and read_error names a read that stopped early. A truncated pcap is the normal state of a rotating capture’s newest member, so a partial read reports rather than fails — and summary restates both conditions in words, because a comparison against a partial population is a wrong answer wearing the shape of a right one.

Four refusals, each cheaper than the mistake it prevents:

SituationAnswer
No --mcp-file-rootfile tools are disabled: start sipnab with --mcp-file-root <DIR> to enable them
A path rather than a bare name'../../etc/passwd' is not a bare filename. These tools take a name, not a path, ...
a and b resolving to one file'x.pcap' and 'x.pcap' are the same file (...); a capture compared with itself differs from itself nowhere
A dimension outside the vocabularycannot compare on 'hour'; one of: state, response_code, ...

The vocabulary check runs BEFORE either file opens, because reading two captures is the most expensive thing this surface does and a mistyped dimension must not cost it. The same-file check runs after the resolver fully resolves both names, so it catches two spellings of one file as well as the same name twice. And a capture that yielded no dialogs AND reported a read error fails with internal_error rather than diffing: every bucket would appear to collapse to zero, which is a finding that is not there.

Note the dimension list here is the plain aggregate_dialogs nine. group_dialogs’s three extras — to_domain, hour, next_hop — do not apply, and hour in particular would compare two captures of different days bucket by bucket.

Evidence and provenance

Every finding a tool reports points back at captured bytes. These retrieve those bytes, prove which frame they came from, and package them so someone else can check the work.

show_evidence

Follows a frame pointer back to the bytes it names, turning one from a string into something a reader can check without reopening the capture.

NameTypeLegal valuesIf omitted
refsstring[]At least one pointer in <source>#<ordinal>@<digest> form. An empty array fails with refs must name at least one frame pointer, because an empty result would read as “nothing resolved”.Required — the call fails.
max_bytesu32?1 to 4096 bytes of hex per frame. Higher clamps to 4096, and to the frame length when the frame is shorter. 0 means zero bytes here, not the default — this is the one parameter on the surface that does not read 0 as “unset”.256 bytes, enough for a SIP start line and its headers.

A pointer whose @digest half is missing still resolves, and comes back unverified rather than verified. Batching is the normal use — one bad pointer never discards the rest, so each entry reports its own status.

Not every tool returns a pointer, and the two that do use different key names. A caller planning around “every tool returns frame_ref” would look for a key most responses do not carry:

  • frame_ref — the findings lint_dialog and validate_message return. Named apart because a finding cites a message index, and the pointer is what makes it checkable without the list that index counts within.
  • frame — list_dialogs, find_problems, tail_dialogs, get_dialog (its dialog and its messages), get_message, the JSON get_dialog_report, and the streams in rtp_stats.
  • No pointer at all — search_messages, search_by_time, find_correlated, triage_call, check_codec_negotiation, diagnose_registration, compare_dialogs, get_sdp_timeline, the RTCP remote reports, and the capture-level counters.

A fact with no pointer omits the key entirely — never "", never frame 0, both of which read as a real pointer.

The example follows the frame pointer that list_dialogs returned for the first call in tests/pcap-samples/sip-rtp-g711.pcap, with max_bytes cut to 64 to keep the hex short:

// show_evidence { "refs": ["tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546"],
//                 "max_bytes": 64 }
{
  "schema_version": 1,
  "requested": 1,
  "resolved": 1,
  "verified": 1,
  "summary": "1 of 1 pointer(s) resolved; 1 verified against a recorded digest",
  "frames": [
    {
      "pointer": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
      "status": "verified",
      "source": "sip-rtp-g711.pcap",
      "ordinal": 0,
      "frame_bytes": 500,
      "hex_bytes_shown": 64,
      "truncated": true,
      "hex": "00 00 00 00 00 00 00 00 00 00 00 00 08 00 45 00 01 e6 fe 17 40 00 40 11 22 cd 0a 00 02 14 0a 00 02 0f 13 c4 13 c4 01 d2 1a 06 49 4e 56 49 54 45 20 73 69 70 3a 74 65 73 74 40 31 30 2e 30 2e 32"
    }
  ]
}

source reports the bare filename, not the path the pointer carried — the tool keeps only the final component and pushes it through the file-root guard. A pointer that resolves to nothing keeps its entry and gains a reason instead of the byte fields:

// show_evidence { "refs": ["bogus#1@deadbeef"] }
{
  "schema_version": 1,
  "requested": 1,
  "resolved": 0,
  "verified": 0,
  "summary": "0 of 1 pointer(s) resolved; 0 verified against a recorded digest",
  "frames": [
    {
      "pointer": "bogus#1@deadbeef",
      "status": "unresolvable",
      "reason": "cannot open '<file-root>/bogus': Failed to open pcap file ..."
    }
  ]
}

status has three values and they are deliberately not interchangeable:

StatusMeans
verifiedThe frame is there and its bytes hash to what the pointer recorded. The capture has not changed under the claim.
unverifiedThe frame is there, the pointer carried no @digest, so this checked nothing. The bytes could come from a rotated capture.
unresolvableNo bytes. reason says why — a malformed pointer, a source outside the file root, a frame past the end, or a digest mismatch.

A digest mismatch is unresolvable, not a resolved frame with a warning. Returning bytes from a capture that changed after someone made the pointer would manufacture exactly the confidence this feature exists to provide.

The file root confines every source. A pointer carries whatever path the producing run read, which usually sits outside the server’s reach. The tool therefore takes only the final component and pushes it through the same guard the file tools use. A pointer naming a live device or a HEP listener is unresolvable: sipnab retains parsed messages, not frames, so there is nothing on disk to seek to.

One bad pointer never discards the rest of a batch — each gets its own entry, so a caller can tell which one failed. max_bytes caps the hex per frame (default 256, maximum 4096) and truncated says when a frame was longer.

decode_evidence

Follows one frame pointer down to the byte range of a single header.

show_evidence answers half the provenance question — are these the bytes behind the claim — and hands back a hexdump. That leaves a reader holding 500 bytes of Ethernet, IP, UDP and SIP when one header line provoked the finding. This tool follows the same pointer through the same resolver and reports what the frame CONTAINS: the link type, the innermost addressing, and one row per SIP header with where that header sits.

NameTypeLegal valuesIf omitted
frame_refstringONE pointer in <source>#<ordinal>@<digest> form — the frame key on a dialog, message or stream, or the frame_ref key on a lint_dialog or validate_message finding. Both names carry the same text. A blank string fails with invalid_params.Required — the call fails.
fieldstring?Keep only headers with this name, matched case-insensitively against the canonical long form (Contact, never m).Every header in the message.

One pointer rather than a batch, unlike show_evidence: a status line per pointer is small, and a whole packet’s structure per pointer fills a context window with frames nobody asked to read. field is the compact form and the one an agent chasing a finding wants — a REGISTER carries a dozen headers and the malformed one is a single line.

The example follows the pointer list_dialogs returns for the first call in tests/pcap-samples/sip-rtp-g711.pcap:

// decode_evidence { "frame_ref": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
//                   "field": "Contact" }
{
  "frame_bytes": 500,
  "link_type": 1,
  "network": { "src_addr": "10.0.2.20", "src_port": 5060,
               "dst_addr": "10.0.2.15", "dst_port": 5060, "transport": "UDP" },
  "ordinal": 0,
  "payload_bytes": 458,
  "payload_offset": 42,
  "pointer": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
  "schema_version": 1,
  "sip": {
    "header_count": 9,
    "headers_returned": 1,
    "headers": [
      { "name": "Contact", "value": "sip:sipp@10.0.2.20:5060", "index": 5,
        "message_byte_start": 227, "message_byte_end": 259,
        "frame_byte_start": 269, "frame_byte_end": 301 }
    ],
    "is_request": true,
    "method": "INVITE",
    "start_line": "INVITE sip:test@10.0.2.15:5060 SIP/2.0"
  },
  "source": "sip-rtp-g711.pcap",
  "status": "verified"
}

header_count counts the message and headers_returned counts this answer, so field never hides how much it filtered out. index is the ordinal a lint finding cites, so a caller holding one pairs the two without matching on header names. The message-relative pair locates the header inside the SIP bytes and the frame-relative pair locates it inside the whole frame, which is what turns show_evidence’s hexdump into a quotation.

The ranges come from the parse itself. SipHeader carries the byte range of its own logical line, recorded by the parser as it walks, so a range cannot disagree with the header beside it. A folded header spans every continuation line the parser unfolded into it, so the range covers bytes the unfolded value does not reproduce verbatim.

This replaced a second walk of the header grammar, paired with the parse positionally. Two walks of one grammar part company — over a line with no colon, a non-UTF-8 line, an over-long one, or the per-message header cap — and a range pinned one header early still resolves, so it reads as evidence.

The old design handled that by dropping the WHOLE set on any disagreement, which meant one junk line cost every other header its range. There is one walk now, so a line the parser drops costs nothing.

A header that came from no bytes carries no range, and ranges_unavailable counts them. Headers are also built by hand — synthesized in a test, or rewritten by a transform — and a plausible range invented for one of those would point at bytes that never said it:

{ "sip": { "header_count": 7, "headers_returned": 7, "headers": [ /* one row with no byte keys */ ],
           "ranges_unavailable": "1 header(s) carry no byte range, because they came from no bytes in this message -- synthesized, or rewritten after the parse. A plausible range for one of those would point at bytes that never said it." } }

Citing bytes that never carried the value would be worse than citing none, precisely because it resolves. The frame-relative pair also drops on its own when the transport payload does not sit at exactly one place in the frame — a decapsulated or reassembled payload need not be a contiguous slice at all, and two candidate offsets make frame[start..end] a coin toss. Keys stay absent rather than empty throughout, the rule frame_ref already follows: 0 and "" both read as real values.

status carries the same three words show_evidence uses, with the same meanings. An unresolvable pointer keeps its own compact shape — no addressing, no headers, no byte counts — so a thin decode can never pass for a partial one:

// decode_evidence { "frame_ref": "uprobe:opensips/1234#7" }
{
  "schema_version": 1,
  "pointer": "uprobe:opensips/1234#7",
  "status": "unresolvable",
  "reason": "read 7 came from the TLS library inside opensips (pid 1234); it was never a frame on any wire, so there is no capture to seek into and no bytes to verify it against. This is not a missing file"
}

Two more refusals sit between “resolved” and “decoded”, and each keeps its own key rather than pretending to a decode. decode_unavailable appears when the frame resolves and the capture does not reopen for its link-layer type — decoding an SLL or PPPoE capture as Ethernet produces addressing that looks decoded and is wrong — and when the frame carries no SIP-bearing transport. not_sip appears when the transport payload does not parse as SIP.

The file root confines every source. A pointer carries whatever path the producing run read, usually an absolute path far outside this server’s reach. This tool never opens that path: it keeps the final component and pushes it through resolve_in_root, the same guard the file tools use, which is why source above reads sip-rtp-g711.pcap and not the path in the pointer.

Without that step, a tool taking a caller-supplied path and returning the decoded contents of the file there is an arbitrary-file-read primitive wearing a readOnlyHint. A pointer naming a file outside the root comes back unresolvable with the confined path in its reason.

No timestamp, deliberately. The resolver returns the frame’s bytes without the capture record that framed them, so any time here would be an invention — and a manufactured capture time on an evidence surface is the exact failure this mechanism exists to prevent. get_message carries the message’s time alongside the same pointer. For the same honesty, a frame carrying two SIP messages packed into one TCP segment decodes as the FIRST one: the pointer is frame-granular, so it cannot name the second.

build_evidence_package

One directory holding everything an escalation needs for a set of calls.

Analysis ending in a paragraph creates work: somebody has to translate it into an attachment, a ticket or a message to a carrier. This tool ends the analysis in an artifact instead — a pcapng of the signaling, a ladder and an RTP-stats file per call, a manifest, and a README carrying the rebuilt-frames disclaimer. The disclaimer lives INSIDE the directory on purpose. The directory is what gets forwarded, and whoever opens it at the carrier never saw the tool that made it.

NameTypeLegal valuesIf omitted
call_idsstring[]At least one Call-ID the store holds, in the order they should appear. A repeat packages once. More than --mcp-max-rows of them fails, asking for batches.Required — the call fails.
filenamestringA bare directory name inside --mcp-file-root, and it must not already exist.Required — the call fails.
// build_evidence_package { "call_ids": ["1-1966@10.0.2.20", "1-1968@10.0.2.20"],
//                          "filename": "escalation-4417" }
{
  "schema_version": 1,
  "path": "/var/spool/sipnab-exports/escalation-4417",
  "calls": 2,
  "messages": 10,
  "files": [
    "README.md", "manifest.json",
    "call-01-ladder.md", "call-01-rtp.json",
    "call-02-ladder.md", "call-02-rtp.json",
    "signaling.pcapng"
  ],
  "summary": "2 call(s) packaged in /var/spool/sipnab-exports/escalation-4417. README.md inside it states that the frames in signaling.pcapng were rebuilt from parsed messages rather than copied, which is the claim the recipient has to see."
}

manifest.json maps each ordinal back to its Call-ID and repeats the disclaimer as a field:

{
  "schema_version": 1,
  "sipnab_version": "0.5.129",
  "package": "escalation-4417",
  "signaling": "signaling.pcapng",
  "signaling_frames_rebuilt": true,
  "calls": [
    { "index": 1, "call_id": "1-1966@10.0.2.20",
      "ladder": "call-01-ladder.md", "rtp_stats": "call-01-rtp.json" },
    { "index": 2, "call_id": "1-1968@10.0.2.20",
      "ladder": "call-02-ladder.md", "rtp_stats": "call-02-rtp.json" }
  ]
}

Filenames are ordinals, never the Call-ID. A Call-ID is arbitrary text a peer chose: it can hold a separator, a leading dash, four hundred characters, or bytes no filesystem wants. None of that belongs in a name this tool constructs, so the manifest carries the correlation and call-01-ladder.md carries the content.

The pcapng holds every named call’s messages in one chronological run, not grouped by leg. A multi-leg escalation reads as one timeline, and splitting it by leg hides the interleaving that is usually the finding. Its frames come from export_capture’s writer and carry that tool’s whole asterisk: the SIP layer is faithful, everything under it comes rebuilt from recorded addresses and ports, a SIP-over-TCP message writes as UDP, and RTP, RTCP, DNS and ICMP are absent.

The RTP measurements in call-NN-rtp.json come from the ORIGINAL capture and nobody can reproduce them from the pcapng alone, which is exactly why the README says so.

The ladder and the stats come from render_ladder and rtp_stats rather than from a second rendering path, so a package can never disagree with what the same agent saw over the wire.

Nothing gets created until every Call-ID checks out. The tool collects the messages first, so an unknown id in the middle of the list fails with invalid_params and leaves no directory and no claimed name behind. Any write that fails afterwards removes the part-built directory too: a half-written package would hold the name against a retry and read as complete evidence.

The shared file rule applies in full — bare names only, no symlink out of the root, and the tool declines a name already taken, including by a directory:

{ "code": -32602,
  "message": "the requested filename '/var/spool/sipnab-exports/escalation-4417' already exists. sipnab will not write over it, because that file may be the only copy of a capture — choose a name that is not taken." }

An empty call_ids array fails too, with an empty package is a directory of disclaimers.

save_findings

Writes one line to sipnab’s log and nothing else. Requires --mcp-allow-save-findings, which is off by default.

NameTypeLegal valuesIf omitted
summarystringA one-line conclusion. sipnab clips it at 500 characters and reports the original length.Required — the call fails.
call_idstring?Any string. Not checked against the store, deliberately: a note about a call the store has since dropped is still the note that mattered.The finding records no call.
detailstring?Supporting text, clipped at 4096 characters.The finding carries a summary alone.
// save_findings { "summary": "audit probe", "call_id": "1-1966@10.0.2.20", "detail": "x" }
{
  "schema_version": 1,
  "seq": 0,
  "written_at": "2026-08-13T12:12:17.976455577+00:00",
  "summary_chars_submitted": 11,
  "detail_chars_submitted": 1,
  "truncated": false,
  "recorded_total": 1,
  "remaining": 999,
  "readable_over_mcp": false,
  "delivered_to": "sipnab log (tracing/journald/stderr)",
  "capture_identity": {
    "node": "capture-01",
    "instance": "1d1a718cb5c33b7c52754-1",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

readable_over_mcp: false is a constant, not a state. It appears on every response so a client never has to infer the dead end from the absence of a reader.

It records what the agent concluded, and that is all it does. The finding goes to sipnab’s log and nowhere else: no tool reads it back, it appears in no query result, and no analysis consumes it. There is no list_findings, deliberately.

That dead end is the entire safety argument. Every response on this surface carries attacker-controlled text — From display names, User-Agent strings, raw message snippets — so a write verb reachable from that text must not be able to change what an operator is reading, or to come back later as evidence the agent then cites. The compiler enforces this rather than convention: the annotation types stay private to the MCP module, so no analysis code can name them.

Read your findings where operational facts already live — journalctl -u sipnab, syslog, or stderr. Each line records the agent’s claim as the agent’s claim, never as a measurement of sipnab’s own.

Two bounds, both reported rather than silent. sipnab clips text at 500 characters of summary and 4096 of detail, setting truncated and *_chars_submitted giving the original length. And one process accepts 1000 findings, after which writes are refused with an error naming the limit — never accepted and discarded, because an agent told “recorded” about something the server threw away is worse off than one told plainly that it was not. remaining counts down so the bound is visible before it bites.

Export and handoff

Getting the result out of sipnab and into the next tool, the next team, or a bug report someone else can reproduce.

export_capture

Writes the SIP signaling sipnab is holding to a pcap. Use it to preserve signaling before stopping a live capture — otherwise the messages end with the process.

The file is not a copy of the capture. sipnab keeps parsed messages, not the frames that arrived, so the export rebuilds one Ethernet/IP/UDP frame around each message. The SIP layer is faithful. Everything under it is reconstructed from the addresses and ports sipnab recorded.

Concretely, the file holds no RTP, no RTCP and no non-SIP traffic, and writes a SIP-over-TCP message as UDP. On one measured export, 4,875 of the 5,000 packets that had been on the wire were absent.

That matters beyond the analysis, because the output is a pcap and people forward pcaps. If the file is going to a carrier, a regulator or a court, say what it is — nothing inside it announces that the frames were rebuilt.

Parameters: filename (string, required) — a bare filename inside --mcp-file-root. A path component of any kind fails with '../escape.pcap' is not a bare filename. sipnab does not require a .pcap extension and does not add one, so notes.txt writes a pcap under that name, which list_captures then never lists. No optional parameters.

Returns path (the resolved absolute path, so you learn where the bytes went), messages, bytes and schema_version:

// export_capture { "filename": "sig.pcap" }
{
  "schema_version": 1,
  "path": "/var/spool/sipnab-exports/sig.pcap",
  "messages": 10,
  "bytes": 5673
}

Capture control (opt-in, off by default)

The tools that change server state rather than read it. Every one stays off until you turn it on server-side.

open_capture

Destructive. Requires --mcp-allow-open-capture, which is off by default. It loads another capture from --mcp-file-root and throws away every dialog and stream the server holds.

Use it on a long-lived HTTP server working through a corpus, where a restart costs an operator their session. In either stdio shape, starting sipnab again with a different -I does the same job and leaves a clean store behind, so prefer that.

Parameters: filename (string, required) — a bare filename inside --mcp-file-root, under the rule every file tool applies. Any capture format libpcap reads is fine, and an archive (.tar, .tgz, .tar.gz) loads as the set of captures it holds, in first-packet order, into the one store. No optional parameters, and no way to ask for a merge: this replaces the stores rather than adding to them.

No password argument, on this tool or any other. An argument passes through the model’s context, the client’s transcript and the provider’s logs. OWASP LLM02:2025 says to keep such data out of model inputs, and the MCP specification says credentials never pass through the client. sipnab refuses a call carrying any argument named like a password before the tool runs, and its audit record shows the value as [REDACTED]. The file-opening tools (open_capture, find_in_captures, compare_captures, show_evidence) also refuse any argument they do not take, rather than ignoring it. An encrypted ZIP opens with the passwords the operator configured when starting sipnab, as described in Archives. When members stay locked, capture_status reports load.archive_passwords: how many, and that the operator must supply the password. It never invites the agent to send one.

Returns status ("loading"), filename, path, the new capture_identity, discarded_dialogs, note and schema_version.

// open_capture { "filename": "outage-0722.pcap" }
{
  "schema_version": 1,
  "status": "loading",
  "filename": "outage-0722.pcap",
  "path": "/var/spool/sipnab-captures/outage-0722.pcap",
  "capture_identity": {
    "node": "capture01",
    "instance": "1f4a17c8e2b91d40-2",
    "dialog_generation": 1,
    "stream_generation": 1
  },
  "discarded_dialogs": 128,
  "note": "the previous capture is gone; poll capture_status until load.done is true, and treat every answer carrying a different capture_identity.instance as a different capture"
}

The call returns as soon as the background read starts, not when it finishes. That is deliberate: the REST API and the MCP server share one runtime thread, so a multi-gigabyte read inside the handler stops every other client for its duration. Poll capture_status and watch load.packets climb until load.done turns true.

Every answer afterwards carries a new capture_identity.instance. Treat any cursor, message index or Call-ID from before the swap as void — they addressed a different capture. discarded_dialogs says how much analysis the call threw away, so an agent can report the cost rather than discover it.

Four refusals, each naming what to do instead:

RefusalWhy
--mcp-allow-open-capture missingThe operator did not enable it. The tool is still listed, because “not permitted here” and “this build cannot” are different answers
The source is a live interfaceA live capture’s writer never stops, so a second writer would race it for the life of the process. No opt-out
The source has not drainedThe original reader is still filling the stores. Poll capture_status until source_exhausted is true
A load is already runningPoll capture_status until load.done is true

A fifth, where the client can carry the question. If your client declared MCP’s elicitation capability, a swap that would discard dialogs asks the person driving it to confirm first, and a decline refuses the call:

refusing to open 'outage-0722.pcap': the confirmation was declined; nothing
was done. The loaded capture is untouched and its 13 dialog(s) are still
addressable.

The four refusals above resolve before the question, so sipnab asks nobody to approve a swap sipnab was going to refuse anyway. A swap that would discard nothing does not ask, and a client that declared no elicitation capability is not asked and is not treated as having refused — --mcp-allow-open-capture remains the guard it always was. See the protocol page.

A filename must be a bare name inside the root, under the same rule every file tool applies. sipnab also refuses a capture that belongs to this run’s own -I set, with the output guard’s wording about overwriting — that file is already loaded, and reading it again under a new identity would duplicate what the store holds.