Skip to content

MCP Tools Captures

github-actions[bot] edited this page Oct 17, 2026 · 1 revision

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

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. The listing skips directories. The TUI's file browser lists by the same rule. open_capture also accepts a capture with no extension at all, which this listing leaves out. To open one, ask the operator for its name.

Without --mcp-file-root the whole file-tool group is off, and this answers with a refusal, not an empty list:

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

Otherwise it answers with captures sorted by filename (upper case first), plus schema_version. With --mcp-file-root tests/pcap-samples it returns 36 of the directory's 38 entries. The two it leaves out are PROVENANCE.md, which is not a capture, and SIP_CALL_RTP_G711, a capture with no extension:

// list_captures {}
{
  "schema_version": 1,
  "captures": [
    { "filename": "Asterisk_ZFONE_XLITE.pcap", "kind": "capture", "bytes": 255581,
      "encrypted": false, "first_packet": "2010-09-27T07:12:49+00:00" },
    { "filename": "DTMFsipinfo.pcap", "kind": "capture", "bytes": 25429,
      "encrypted": false, "first_packet": "2011-04-27T08:14:29+00:00" },
    { "filename": "SIP_DTMF2.cap", "kind": "capture", "bytes": 420411,
      "encrypted": false, "first_packet": "2005-09-09T12:02:25+00:00" },
    …
  ]
}

Use first_packet to choose a file before opening it. It is the timestamp of the file's first record, in whole seconds. Among forty rotated captures, it narrows the search for a call from 14:02 to the two files that could hold it, without opening any. open_capture replaces the loaded capture, and find_in_captures searches files without doing so.

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.

The listing carries no dialog count and no last-packet time, because both need the whole file parsed. To learn which file holds a call, use find_in_captures.

find_in_captures

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

Use it to find which file in --mcp-file-root holds a call, for example a Call-ID across rotated captures. open_capture would replace the loaded capture and void every cursor you hold. find_in_captures reads each file into a scratch store, applies the filter, and leaves 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

The examples run against a file root holding three captures from tests/pcap-samples: SIP_DTMF2.cap, sip-problem-call.pcap and sip-rtp-g711.pcap:

sipnab --mcp -N -I tests/pcap-samples/sip-rtp-g711.pcap --mcp-file-root /var/spool/sipnab-captures
// 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": 3, "files_total": 3, "elapsed_ms": 8,
                "cancel_requested": false },
  "sweep": {
    "matches": [
      { "filename": "sip-rtp-g711.pcap", "dialogs_matched": 1,
        "first_call_id": "1-1966@10.0.2.20" }
    ],
    "files_examined": 3,
    "files_total": 3,
    "unreadable": [],
    "complete": true
  },
  "source_exhausted": true,
  "source_stopped_early": false
}

A filter that does not parse fails with invalid_params and data.reason: "invalid_filter" before the sweep opens any file.

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-1",
  "status": "running",
  "limits": { "max_files": 20, "deadline_ms": 30000 },
  "progress": { "files_examined": 0, "files_total": 3, "elapsed_ms": 1,
                "cancel_requested": false },
  "source_exhausted": true,
  "source_stopped_early": 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.

Each running sweep is one operating system thread with its own scratch dialog and stream stores, so --mcp-sweep-max-running bounds the threads and stores agents can hold at once. A held result keeps one entry per file the sweep matched and one per file it could not read. sipnab refuses 0 and any value above the accepted range, from the flag and from the key.

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 that reports no matches has shown only that the call is not in the files it read. complete is true in exactly one case: every candidate file 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 such file with its reason. A reason comes from the operating system and from libpcap. sipnab strips control characters and keeps the first 200 characters.

With one unreadable file in the root and a Call-ID that is in none of them:

// find_in_captures { "filter": "call_id == \"zzz\"" }
{
  …
  "sweep": {
    "matches": [],
    "files_examined": 3,
    "files_total": 4,
    "unreadable": [
      { "filename": "broken.pcap",
        "reason": "Failed to open pcap file '/var/spool/sipnab-captures/broken.pcap': libpcap error: unknown file format" }
    ],
    "complete": false
  }
}

Check complete after a match too. On a rotated spool a call that spans a rotation is in two files, and a sweep that stopped early may have found only one of them.

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:

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. A call with "max_files": 1 shows the same thing on a smaller root:

// find_in_captures { "filter": "call_id == \"zzz\"", "max_files": 1 }
{
  …
  "limits": { "max_files": 1, "deadline_ms": 30000 },
  "sweep": {
    "matches": [],
    "files_examined": 1,
    "files_total": 4,
    "unreadable": [],
    "complete": false,
    "stopped_because": "max-files"
  }
}

The flag overrides the key. sipnab refuses 0 and any value above the accepted range, from the flag and from the key.

Choose max_files and deadline_ms

max_files bounds how many files a sweep opens. deadline_ms bounds how long it runs, which is what limits a sweep over large files: twenty small files and twenty 2 GB files are the same max_files. The sweep checks the deadline before each file and before each packet, so it overruns the deadline by at most 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.

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.

Polling the job that the "wait_seconds": 0 call above started:

// find_in_captures_status { "job_id": "sweep-1" }
{
  "schema_version": 2,
  "job_id": "sweep-1",
  "status": "done",
  "limits": { "max_files": 20, "deadline_ms": 30000 },
  "progress": { "files_examined": 3, "files_total": 3, "elapsed_ms": 3,
                "cancel_requested": false },
  "sweep": {
    "matches": [
      { "filename": "sip-rtp-g711.pcap", "dialogs_matched": 1,
        "first_call_id": "1-1966@10.0.2.20" }
    ],
    "files_examined": 3,
    "files_total": 3,
    "unreadable": [],
    "complete": true
  },
  "source_exhausted": true,
  "source_stopped_early": false
}

A second poll of the same id fails, because the first poll took the result:

{ "code": -32602,
  "message": "unknown sweep job 'sweep-1': it was never started, its result was already handed over, it finished more than 600 seconds ago (--mcp-sweep-result-retention-secs), or 16 newer results were waiting (--mcp-sweep-max-held-results)",
  "data": { "param": "job_id", "reason": "not_found" } }

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.

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

The example cancels a sweep over 63 files, started with "wait_seconds": 0 on a server run with --mcp-sweep-max-files 100:

// cancel_find_in_captures { "job_id": "sweep-1" }
{
  "schema_version": 2,
  "job_id": "sweep-1",
  "status": "running",
  "limits": { "max_files": 100, "deadline_ms": 30000 },
  "progress": { "files_examined": 13, "files_total": 63, "elapsed_ms": 303,
                "cancel_requested": true },
  "source_exhausted": true,
  "source_stopped_early": false
}

The next find_in_captures_status poll returns "status": "canceled" with:

"sweep": {
  "matches": [],
  "files_examined": 13,
  "files_total": 63,
  "unreadable": [],
  "complete": false,
  "stopped_because": "canceled"
}

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.

Use it to compare a capture against a baseline, for example today's traffic against yesterday's. Two captures share no Call-IDs, so the tool compares aggregates, not dialogs: it 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, with --mcp-file-root 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 holds."
}

Buckets rank by absolute movement, not by size, so the first row is the largest change. delta is b - a throughout. A value absent from one side counts as zero there, so a response code that appears only in b shows as a positive delta.

Neither file becomes the loaded capture. The tool reads both into private stores that the call discards, so answers about the loaded capture do not change. The read runs on a separate thread, so sipnab keeps answering other MCP and REST requests while it runs.

The tool is readOnlyHint: false for one reason: reading the files adds to the process-wide undecodable-frame counts and the ICMP evidence that get_capture_report reports. open_capture does the same.

Read the a and b blocks before the deltas. dialogs_dropped above zero means that side reached the per-file dialog ceiling (100000 dialogs, the default of --limit, which this tool does not read), so its counts are lower bounds, not totals. read_error names a read that stopped early, as on the newest file of a rotating capture that sipnab or another tool still writes to. The tool reports a partial read instead of refusing it, and summary states both conditions in words.

The tool refuses four requests:

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 dimension check runs before the tool opens either file. The same-file check runs after both names are fully resolved, so it also catches two spellings of one file. A capture that yielded no dialogs and reported a read error fails with internal_error instead of producing a diff.

The dimensions are the nine aggregate_dialogs accepts. group_dialogs's to_domain, hour and next_hop are not accepted.

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.

Name Type Legal values If omitted
refs string[] At least one pointer in <source>#<ordinal>@<digest> form. An empty array fails with invalid_params (refs must name at least one frame pointer; …). 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.

Where pointers come from. Not every tool returns one, and two key names are in use:

  • frame_ref — the findings lint_dialog and validate_message return. A finding also cites a message index; the pointer identifies the frame without that list.
  • 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. It is never "" or frame 0.

The example follows the frame pointer that list_dialogs returned for the first call in tests/pcap-samples/sip-rtp-g711.pcap, on a server started with --mcp-file-root tests/pcap-samples, 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 '<file-root>/bogus': libpcap error: <file-root>/bogus: No such file or directory"
    }
  ]
}

status has three values:

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: the tool returns no bytes from a capture that changed after a run recorded the pointer.

The file root confines every source. The tool takes only the final path component of the pointer's source and resolves it inside --mcp-file-root under the same rule the file tools use, so copy the capture into the file root before following its pointers. 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 returns a frame's bytes as hex. This tool follows the same pointer and reports what the frame contains: the link type, the innermost addressing, and one row per SIP header with the byte range where that header sits. Use it to quote the exact header a finding is about.

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.

It takes one pointer per call, unlike show_evidence. Pass field to return only the header you need.

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": "⟦untrusted-capture-data⟧Contact⟦/untrusted-capture-data⟧",
        "value": "⟦untrusted-capture-data⟧sip:sipp@10.0.2.20:5060⟦/untrusted-capture-data⟧",
        "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": "⟦untrusted-capture-data⟧INVITE sip:test@10.0.2.15:5060 SIP/2.0⟦/untrusted-capture-data⟧"
  },
  "source": "sip-rtp-g711.pcap",
  "status": "verified"
}

sipnab fences the header names, values and start line, because the sender wrote them. header_count counts the headers in the message and headers_returned counts this answer. index is the header ordinal a lint finding cites. The message_byte_* pair locates the header inside the SIP message, and the frame_byte_* pair locates it inside the whole frame, so frame_byte_start to frame_byte_end in show_evidence's hex is the header itself.

The ranges come from the parser, which records each header's byte range as it reads the message. A folded header's range spans every continuation line the parser unfolded into it, so the range covers bytes the unfolded value does not reproduce verbatim. A line the parser drops (no colon, not UTF-8, too long, or past the per-message header cap) does not affect the other headers' ranges.

A header that came from no bytes carries no range, and ranges_unavailable counts them. That happens to a header synthesized or rewritten after the parse:

{ "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." } }

The frame-relative pair is also omitted when the transport payload does not sit at exactly one place in the frame, as with a decapsulated or reassembled payload. Omitted keys are absent, never 0 or "".

status carries the same three values show_evidence uses, with the same meanings. An unresolvable pointer returns only pointer, status, reason and schema_version:

// 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 keys report a frame that resolved but did not decode. decode_unavailable appears when the capture does not reopen for its link-layer type, 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. As with show_evidence, the tool never opens the path in the pointer: it keeps the final component and resolves it inside --mcp-file-root, which is why source above reads sip-rtp-g711.pcap. A pointer naming a file outside the root comes back unresolvable with the confined path in its reason.

The answer carries no timestamp. For the message's time, call get_message, which returns it beside the same pointer. A frame carrying two SIP messages in one TCP segment decodes as the first one, because a pointer names a frame, not a message.

build_evidence_package

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

Use it to hand a set of calls to someone else, such as a carrier or another team. It writes one directory: a pcapng of the signaling, a ladder and an RTP-stats file per call, a manifest, and a README stating that the pcapng frames were rebuilt from parsed messages. Forward the whole directory, so the recipient gets the README with the files.

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": ["1-1966@10.0.2.20", "1-1968@10.0.2.20"],
//                          "filename": "escalation-4417" }
// against tests/pcap-samples/sip-rtp-g711.pcap
{
  "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.",
  "source_exhausted": true,
  "source_stopped_early": false
}

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

{
  "schema_version": 1,
  "sipnab_version": "0.5.222",
  "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" }
  ]
}

File names use an ordinal, never the Call-ID. manifest.json maps each ordinal to its Call-ID.

The pcapng holds every named call's messages in one chronological run, not grouped by leg, so a multi-leg call reads as one timeline. Its frames come from export_capture's writer, with the same limits: the SIP layer is faithful, everything under it is rebuilt from recorded addresses and ports, sipnab writes a SIP-over-TCP message 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. The README says so.

The ladder and the stats are the output of render_ladder and rtp_stats for the same calls.

The tool creates nothing until every Call-ID checks out. An unknown id anywhere in the list fails with invalid_params (call_id 'nope' not found) and leaves no directory behind. A write that fails afterwards removes the part-built directory.

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 with call_ids must name at least one call: 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.

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, so a finding can name a call the store no longer holds. 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": "1-1966@10.0.2.20", "detail": "x" }
{
  "schema_version": 1,
  "seq": 0,
  "written_at": "2026-10-16T11:57:35.132789476+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": "134b7818df009db9a44c86-2",
    "dialog_generation": 13,
    "stream_generation": 2
  },
  "source_exhausted": true,
  "source_stopped_early": false
}

The finding goes to sipnab's log and nowhere else. No tool reads it back, it appears in no query result, and no analysis uses it. readable_over_mcp is always false. Read findings in the log: journalctl -u sipnab, syslog, or stderr. sipnab logs the line at info as the agent's claim, so --quiet hides it:

INFO serve_inner: sipnab::mcp::findings: MCP agent recorded a finding: audit probe seq=0 written_at=2026-10-16T11:57:59.714713194+00:00 call_id="1-1966@10.0.2.20" capture_instance="1363cc18df00a3adb2f107-2" dialog_generation=13 stream_generation=2 detail="x"

On a server started without --mcp-allow-save-findings the call fails with saving findings is disabled: start sipnab with --mcp-allow-save-findings to permit it. A stock server accepts no writes at all.

Two bounds apply, and the response reports both:

  • sipnab clips summary at 500 characters and detail at 4096, sets truncated, and reports the original lengths in *_chars_submitted.
  • One process accepts 1000 findings by default (--mcp-max-findings, [limits] mcp_max_findings). Past that, sipnab refuses writes with an error naming the limit. remaining counts down to it.

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.

The file holds no RTP, no RTCP and no non-SIP traffic, and writes a SIP-over-TCP message as UDP. Exporting tests/pcap-samples/sip-rtp-g711.pcap, which holds 852 packets, writes 10.

Nothing inside the file says that the frames were rebuilt. When you forward it, say so. build_evidence_package includes a README that does.

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. These tools take a name, not a path, and write only inside the configured --mcp-file-root. 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. Against tests/pcap-samples/sip-rtp-g711.pcap:

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

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 several captures, where restarting the server would end the clients' sessions. Over stdio, start sipnab again with a different -I instead, which leaves a clean store.

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. sipnab refuses a call carrying any argument named like a password before the tool runs, and its audit record shows the value as [REDACTED]:

argument `password` refused: sipnab never takes an archive password in an MCP
tool call, because an argument passes through the model's context, the
client's transcript and the provider's logs. Treat the password you sent as
exposed. The operator configures one when starting sipnab:
--archive-password-file, --archive-password-command, --archive-password-stdin,
the systemd credential archive-password, or SIPNAB_ARCHIVE_PASSWORD.

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.

The example starts from tests/pcap-samples/sip-rtp-g711.pcap, with --mcp-allow-open-capture and a file root holding sip-problem-call.pcap:

// open_capture { "filename": "sip-problem-call.pcap" }
{
  "schema_version": 1,
  "status": "loading",
  "filename": "sip-problem-call.pcap",
  "path": "/var/spool/sipnab-captures/sip-problem-call.pcap",
  "capture_identity": {
    "node": "capture-01",
    "instance": "13650418df00a4d0f42050-4",
    "dialog_generation": 14,
    "stream_generation": 3
  },
  "discarded_dialogs": 2,
  "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",
  "source_exhausted": false,
  "source_stopped_early": false
}

The call returns as soon as the background read starts, not when it finishes, and sipnab keeps answering other MCP and REST requests while the read runs. Poll capture_status and watch load.packets climb until load.done turns true. A second later the same session's capture_status shows:

"load": {
  "filename": "sip-problem-call.pcap",
  "instance": "13650418df00a4d0f42050-4",
  "packets": 23,
  "elapsed_sec": 1,
  "done": true,
  "error": null
},
"dialog_count": 5,
"source_exhausted": true

elapsed_sec stops counting when done turns true: it is how long the read took, and every later capture_status reports the same value.

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 many dialogs the swap discarded.

The tool refuses in four situations, and each refusal names what to do instead:

Refusal Why
--mcp-allow-open-capture missing The operator did not enable it. The tool is still listed, so an agent can tell "not permitted here" from "this build cannot"
The source is a live interface A live capture never finishes writing to the stores. There is 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

Without the flag, the refusal reads opening a capture is disabled: start sipnab with --mcp-allow-open-capture to permit it. A stock server holds the capture it was started on. Called before sipnab has read the startup capture to the end, it reads refusing to open 'sip-problem-call.pcap': the current source has not finished reading. Poll capture_status until source_exhausted is true, then call again.

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 'sip-problem-call.pcap': the confirmation was declined;
nothing was done. The loaded capture is untouched and its 2 dialog(s) are
still addressable.

The refusal is invalid_params (-32602) with data.reason: "declined".

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

A filename must be a bare name inside the root, under the same rule every file tool applies.

Home

Getting started

Using the TUI

CLI & automation

Configuration

Integrations (API & MCP)

Your SIP server

Add to your voice stack

Development & internals

Clone this wiki locally