MCP Tools Captures - NormB/sipnab GitHub Wiki
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.
| Tool | Parameters | Returns |
|---|---|---|
list_captures |
-- | Capture files and stored vCon containers in --mcp-file-root, with sizes and each one's kind |
find_in_captures |
filter, 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_status |
job_id, wait_seconds?
|
A find_in_captures job's progress, or its result once it finishes |
cancel_find_in_captures |
job_id |
Stops a running find_in_captures job |
compare_captures |
a, 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_evidence |
refs, max_bytes?
|
Follows frame pointers back to the captured bytes: verified, unverified, or unresolvable with a reason |
decode_evidence |
frame_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_package |
call_ids, filename
|
Write. One directory of escalation artifacts in --mcp-file-root: pcapng, ladder and RTP stats per call, manifest, and the rebuilt-frames README |
save_findings |
summary, call_id?, detail?
|
Write. Records the agent's conclusion to sipnab's log. Needs --mcp-allow-save-findings; no tool reads it back |
export_capture |
filename |
Writes held SIP signaling to a pcap in --mcp-file-root (re-synthesized frames, no RTP) |
open_capture |
filename |
Destructive. Replaces every dialog and stream with another capture from --mcp-file-root. Needs --mcp-allow-open-capture; loads in the background |
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".
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,.pcapngand.capfiles, their gzip-compressed forms such as*.pcap.gz, and archives of captures (.tar,.tgz,.tar.gz, and.zipin builds with thearchivefeature). Each haskind: "capture". - Stored vCon containers:
*.vcon.json, the name sipnab writes the unsigned, signed and encrypted forms under, and*.vcon.json.gz, the namegzipgives the gzip format. Each haskind: "vcon".open_captureandfind_in_capturesread them as-Idoes. The.<name>.partialfile 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.
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.
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.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
filter |
string | A filter DSL expression, the same vocabulary every other filtering tool takes. Unparseable fails with invalid_params before the sweep opens anything |
Required |
max_files |
u32? | Files to open before stopping. Clamped to the operator's --mcp-sweep-max-files ([limits] mcp_sweep_max_files, default 20); 0 means that ceiling |
The ceiling |
deadline_ms |
u64? | 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 ceiling |
The ceiling |
wait_seconds |
u32? | 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 once |
30 |
// find_in_captures { "filter": "call_id == \"[email protected]\"" }
{
"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": "[email protected]" }
],
"files_examined": 12,
"files_total": 12,
"unreadable": [],
"complete": true
}
}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 == \"[email protected]\"", "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:
| Bound | Flag | Config key | Default | Accepted |
|---|---|---|---|---|
| Sweeps running at once, per server | --mcp-sweep-max-running |
[limits] mcp_sweep_max_running |
4 | 1 to 64 |
| Finished results waiting for a poll, per server | --mcp-sweep-max-held-results |
[limits] mcp_sweep_max_held_results |
16 | 1 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_secs |
600 | 1 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-runningaccepts 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-resultsaccepts 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-secsaccepts at most 43200, twelve hours, the same span as the--mcp-sweep-deadline-msmaximum: 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.
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 operator sets both ceilings, and a caller can only ask for less:
| Ceiling | Flag | Config key | Default | Accepted |
|---|---|---|---|---|
| Files per sweep | --mcp-sweep-max-files |
[limits] mcp_sweep_max_files |
20 | 1 to 4294967295 |
| Milliseconds per sweep | --mcp-sweep-deadline-ms |
[limits] mcp_sweep_deadline_ms |
30000 | 1 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.
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.
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.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
job_id |
string | The job_id a find_in_captures call returned. An id the server does not hold fails with invalid_params, naming the id |
Required |
wait_seconds |
u32? | Seconds to wait for the job to finish before answering. Clamped to --mcp-max-wait-seconds; 0 answers at once |
30 |
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": "[email protected]" }
],
"files_examined": 40,
"files_total": 40,
"unreadable": [],
"complete": true
}
}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.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
job_id |
string | The job_id a find_in_captures call returned. An id the server does not hold fails with invalid_params, naming the id |
Required |
// 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.
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.
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.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
a |
string | The baseline: a bare filename inside --mcp-file-root. |
Required โ the call fails. |
b |
string | The capture to hold against it, in the same root. | Required โ the call fails. |
dimensions |
string[]? | 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_n |
integer? | 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:
| Situation | Answer |
|---|---|
No --mcp-file-root
|
file 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 vocabulary | cannot 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.
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.
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.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
refs |
string[] | 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_bytes |
u32? | 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 findingslint_dialogandvalidate_messagereturn. 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 JSONget_dialog_report, and the streams inrtp_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:
| Status | Means |
|---|---|
verified |
The frame is there and its bytes hash to what the pointer recorded. The capture has not changed under the claim. |
unverified |
The frame is there, the pointer carried no @digest, so this checked nothing. The bytes could come from a rotated capture. |
unresolvable |
No 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.
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.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
frame_ref |
string | ONE 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. |
field |
string? | 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:[email protected]: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:[email protected]: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.
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.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_ids |
string[] | 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. |
filename |
string | A bare directory name inside --mcp-file-root, and it must not already exist. |
Required โ the call fails. |
// build_evidence_package { "call_ids": ["[email protected]", "[email protected]"],
// "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": "[email protected]",
"ladder": "call-01-ladder.md", "rtp_stats": "call-01-rtp.json" },
{ "index": 2, "call_id": "[email protected]",
"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.
Writes one line to sipnab's log and nothing else. Requires
--mcp-allow-save-findings, which is off by default.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
summary |
string | A one-line conclusion. sipnab clips it at 500 characters and reports the original length. | Required โ the call fails. |
call_id |
string? | 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. |
detail |
string? | Supporting text, clipped at 4096 characters. | The finding carries a summary alone. |
// save_findings { "summary": "audit probe", "call_id": "[email protected]", "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.
Getting the result out of sipnab and into the next tool, the next team, or a bug report someone else can reproduce.
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
}The tools that change server state rather than read it. Every one stays off until you turn it on server-side.
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:
| Refusal | Why |
|---|---|
--mcp-allow-open-capture missing |
The 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 interface | A 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 drained | The original reader is still filling the stores. Poll capture_status until source_exhausted is true |
| A load is already running | Poll 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.
list_captures {}