Docs Look up a tool in the MCP vcon bundle
Look up a tool in the MCP vcon bundle
On this page
The vcon bundle exports, validates and analyzes vCon conversation
containers, reports a directory of them, reads one from the operator’s vCon store, counts the vCon export
spool, and reports the SIPREC recording metadata a call carried.
Load only these tools with --mcp-tools vcon, 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 |
|---|---|---|
siprec_metadata | call_id | The SIPREC (RFC 7866) recording metadata a call carried: session id, mode, participants with AOR and display name, and the recorded streams, each naming its m= line label and the participant that sends it |
export_vcon | call_id?, filter?, limit?, cursor?, media? | Dialogs as vCon conversation containers, structured JSON, each with its SHA-256. One call_id or a whole filtered set, paged with cursor. Unsigned, retained audio inline unless media is omit, and every omission stated in the response |
validate_vcon | call_id?, container? | Checks a container against the working group’s draft-ietf-vcon-vcon-core-04 schema and names every finding |
analyze_vcon | container or container_base64, limit? | Rebuilds the SIP messages a stored container holds and analyzes them apart from the loaded capture: per dialog the --json-dialogs object, the signaling diagnosis and the ladder, plus the capture analysis and what a container cannot reproduce |
fetch_vcon | uuid | Connects, off by default. One container from the operator’s [vcon_fetch] store by uuid, returned inline with the checks it passed. Needs --mcp-allow-vcon-fetch; the store and its keys come from operator configuration only |
vcon_directory_report | directory, limit?, cursor?, max_response_bytes? | Every stored container in a directory of the file root: per container its status, form, schema finding count, calls, dispositions and duration, or why sipnab skipped it, then the totals. The report -I <dir> --report prints |
vcon_spool_status | limit?, cursor?, rule? | Counts the --export-vcon-dir spool: containers pending, delivered and failed, with the store’s answer from each failed container’s record. A snapshot of files; sends, moves and deletes nothing |
Diagnose one call
You have a call-id. These explain what happened to that one call – its signaling, its media, and the specific negotiations that tend to break.
siprec_metadata
“Was this call recorded, and who is on the recording?” A session recording
client (SRC) — OpenSIPS’s siprec module, an SBC — sends the recording server
an INVITE whose body carries an application/rs-metadata+xml part describing
what it is about to record. sipnab reads that part off the wire and returns it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id | string | A Call-ID the store holds. | Required — the call fails. |
Read recorded first. It is false when the dialog carried no SIPREC
metadata, and the response then holds only call_id, reason,
capture_identity and schema_version. The reason says what that does and
does not mean: sipnab reports what reached the capture point, so a recorder
signaling on a path it cannot see is indistinguishable here from a call nobody
recorded. It is not a statement that the call went unrecorded.
Otherwise siprec carries:
| Field | Answers |
|---|---|
session_id | The recording session the SRC opened, which ties two legs of one recording together |
mode | What the SRC declared it is recording, from <datamode> — complete for the whole session |
participants | Each party, with participant_id, aor and name when the SRC sent one. A missing name is absent, not empty: an SRC that has no display name writes a self-closing <nameID> |
streams | Each recorded stream, with stream_id, label and participant_id |
label and participant_id are the two that matter. The label is the SDP
a=label naming the m= line the SRC cut the recorded stream from, so on a
call with audio and video it says which is which. The participant_id is the party
that sends that stream, which is the only route from a recorded stream back
to a person — nothing inside the stream element names one.
sipnab reads it from
<participantstreamassoc>, whose <send> children mean ownership and whose
<recv> children do not. The party at the other end hears a stream without
owning it.
// siprec_metadata { "call_id": "4f1c0a2e-siprec@172.28.0.31" }
{
"schema_version": 1,
"call_id": "4f1c0a2e-siprec@172.28.0.31",
"recorded": true,
"siprec": {
"session_id": "4f1c0a2e",
"mode": "complete",
"participants": [
{ "participant_id": "9b2d1f00", "aor": "sip:alice@example.invalid", "name": "Alice" },
{ "participant_id": "c7e40a13", "aor": "sip:bob@example.invalid" }
],
"streams": [
{ "stream_id": "1a2b3c4d", "label": "0", "participant_id": "9b2d1f00" },
{ "stream_id": "2b3c4d5e", "label": "1", "participant_id": "9b2d1f00" },
{ "stream_id": "3c4d5e6f", "label": "2", "participant_id": "c7e40a13" }
]
}
}
sipnab reads SIPREC and does not speak it. It is not a session recording client and not a recording server — it observes the signaling between them, which is the same posture it takes toward every other protocol it reads.
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_vcon
One observed dialog as a vCon conversation container
(draft-ietf-vcon-vcon-core,
syntax 0.4.0) — the interchange format a conversation travels in once it
leaves the system that captured it. Backed by
output::vcon.
--export-vcon writes the same container from the CLI.
Unlike its file-writing neighbors this tool writes nothing and needs no
--mcp-file-root. It returns the container inline, so read_only_hint is
true and the agent decides what to do with it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id | string | A Call-ID the store holds. An unknown one fails with invalid_params (-32602) naming the value. | Give filter instead. |
filter | string | A filter alias or a raw DSL expression, the vocabulary list_dialogs takes. The --export-vcon-when flag takes the same vocabulary, aliases included. Every matching dialog comes back. | Give call_id instead. |
limit | integer | 1 to --mcp-max-rows (1000 by default). | 50. |
cursor | string | The previous response’s next_cursor, passed back verbatim. It is the <RFC 3339 created_at>|<Call-ID> cursor list_dialogs uses. A cursor whose time half is not RFC 3339 fails with invalid_params (-32602). | The first page. |
media | string | inline: audio the run retained travels inline in a recording Dialog Object. omit: the exporter considers no audio, and each container records media: "not-considered". sipnab refuses any other value before the tool runs, with an error result naming inline and omit. | inline. |
Give call_id or filter, never both. The CLI refuses the same pair, because
a request naming a dialog AND a rule for choosing dialogs has two answers and
names neither.
Export a set in one call. An agent asked for every failed call used to list
dialogs and then issue one export_vcon per row — on a real capture, hundreds
of round trips to do what --export-vcon-when does in one invocation. Pass the
filter here instead. A container is a large answer, so limit bounds the set.
total_matched counts the whole store, so it is the number to page against.
The tool withholds a dialog the run denies. On a run started with
--content-deny-header NAME, the tool does not export a dialog whose messages
carry that header, as the batch export does not write it. Asked for that
dialog by call_id, the tool refuses with not_enabled and names the header.
Under a filter, the tool leaves such dialogs out, and withheld_by_deny says
how many it left out.
Page through a large set. Containers come back ordered by the dialog’s
created_at, then Call-ID. When more match than limit allows, truncated
is true and next_cursor names the last container returned. Pass it back as
cursor for the next page. The last page has truncated: false and a null
next_cursor. The cursor is a position within one capture: compare
capture_identity.instance between pages, as with every paged tool, because
open_capture replaces the dialogs a cursor points into.
Leave the audio out. A container that carries audio is up to the
inline-media budget (5 MiB by default) larger than one that does not. With
media: "omit" the exporter considers no audio, and each container says so in
its own completeness carrier with media: "not-considered" and a caveat that
names the omission as a fact about the export, not about the call. The
containers stay valid against the core-04 schema.
Read one container as a resource. resources/read on
sipnab://vcon/{call_id} returns the container this tool builds for that
dialog, with audio inline, and completion/complete fills in call_id from
the Call-IDs the capture holds. sipnab refuses a container over the 8 MiB
resource limit with invalid_params. Call this tool for it instead, with
media: "omit" if the audio is what makes it large. Only a build with the
vcon feature lists the template.
A filter matching nothing answers with an empty set. “No call failed” is a finding, and a refusal there would make a clean capture look like a broken request.
There is no format. A vCon IS a JSON container defined by the draft, so a
markdown arm would render a document whose whole purpose is to travel between
machines — and an agent offered one would eventually hand the prose to
something expecting a container.
What sipnab claims, and what it refuses to claim. The container is an observer vCon and says so in its own parties. sipnab read a mirror port: it did not place the call, record it, or obtain anyone’s permission to keep it. So four things are absent by design, and their absence is the message:
- No signature and no encryption. A JWS over this container reads as the domain that constructed this vouches for it. What sipnab could truthfully sign is these bytes are what sipnab observed, and no signature algorithm carries that distinction. A verifier would check it, get back “authentic”, and draw the wrong conclusion.
- No party name sipnab vouches for, and
validationis always"none".FromandToare a claim the sender made about itself. sipnab EMITS anamewhen the wire carried a display name – besidesip_display_name, not instead of it – so a generic vCon reader shows a named party. What sipnab refuses is the claim, not the field. Treat the name as personal data: a redaction step must covernameas well assip_display_name. - No consent and no lawful-basis attachment. An empty consent field reads as “nobody recorded consent”, a statement about the CALL. The truth is a statement about the producer: sipnab was never in a position to record one.
- No
url, ever. Media travels INLINE or not at all, because sipnab hosts nothing a URL could reach and a dead link inside a record is indistinguishable from evidence somebody removed.
What happens to the audio. dialog.type: "recording" is a FORMAT term for
a Dialog Object carrying media. A consumer’s recordings table is a PROVENANCE
term for containers from an in-path recorder. sipnab emits the first and is
never the second: it reconstructed the audio from a mirror port, and every WAV
it writes says “not a recording made by the endpoints”.
So audio the run RETAINED travels inline – base64url, mediatype: "audio/x-wav", and a content_hash of sha512- plus the base64url SHA-512 of
the DECODED bytes, which sha512sum on the exported .wav reproduces. The
duration on that object is the FILE’s, never the call’s. sipnab keeps whole
calls by default. When frames are missing from the start of the file (an
explicit [limits] max_audio_frames cap dropped them, or the
[limits] max_audio_bytes budget released them), a recording-set wraps it
carrying the CALL’s media window, so
the two clocks stand side by side – that is the only way the format can say
“this file is a fragment of that call”.
capture_completeness.media says which of five things happened, so nobody has
to read a missing recording object as an answer:
media | What it means |
|---|---|
carried | The audio is inline in a recording Dialog Object |
refused-over-budget | sipnab decoded audio and REFUSED to inline it. One probed store answers 204 and drops a payload over 10485760 bytes without telling the producer, so the emitter enforces a 5 MiB budget itself. The audio exists and was not truncated |
none-decodable | The run decoded no audio. media_note reports the measurement – never that the call was silent |
not-considered | Nobody asked this export for media: the call to export_vcon gave media: "omit". A fact about the export, not about the call |
withheld-by-redaction | The export ran with --redact, and redaction deletes audio rather than replacing it with a token, so the exporter considered none. The Dialog Object is the signaling one. A fact about the export, not about the call. The MCP tools do not redact, so they never write it |
What the capture MISSED travels with the container, in two places. vCon has
no field meaning “this record is incomplete” — dialog.type: "incomplete" says
the CALL did not complete, which accuses the traffic rather than the tap. So the
caveat rides in the analysis object AND in a sipnab-capture-completeness
attachment, both built from ONE value so the two cannot contradict each other.
Read capture_completeness.note before the contents: every clause in it is a
measurement of what this run READ and dropped.
Every container comes back with its SHA-256. sipnab computes it exactly as
--vcon-digest does — one function, over the container’s own serialized bytes
— so a SHA256SUMS line or a store’s ledger entry compares against this value
directly. That is what binds one emission to one ledger entry.
The digest identifies the DOCUMENT, not the dialog. created_at records the
moment sipnab wrote the container, so re-exporting one call produces a second
document with a new digest and the same uuid. Deduplicate on the uuid.
What the container omits reaches you in the response. A container over the size budget carries no audio, which looks exactly like a conversation that had none, and the caveat explaining the difference lives inside an attachment in the container. So the response repeats it where a caller reads it.
completeness.note is the container’s own caveat, verbatim.
completeness.max_inline_media_bytes is the budget this run ENFORCED, never the
compiled-in default. completeness.omissions carries one row per loss, each
naming the counter it came from, how many, and in what unit. An empty list and
complete: true say the container lost nothing.
The response is an envelope, so one dialog and a filtered set arrive in one shape:
// export_vcon { "filter": "response_code >= 400", "limit": 2 }
{
"schema_version": 1,
"returned": 2,
"total_matched": 7,
"truncated": true,
"next_cursor": "2016-11-26T14:52:59.666393Z|1-1966@10.0.2.20",
"capture_identity": { /* which capture, and which revision of its stores */ },
"containers": [
{
"call_id": "1-1966@10.0.2.20",
"digest": "b6f0a1c9d84e2f7a3b5c6d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c",
"completeness": {
"note": "Produced by sipnab 0.5.141 on node capture-01. ... sipnab read 852 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. A capture-level analysis ran and ranked no blind spots.",
"media": "none-decodable",
"media_note": "No audio payload retained: ...",
"max_inline_media_bytes": 5242880,
"complete": true,
"omissions": []
},
"container": { /* the vCon itself, shown in full below */ }
}
]
}
A run that lost something fills the same block instead:
"completeness": {
"note": "Produced by sipnab 0.5.141 on node capture-01. ... — INCOMPLETE: 3 header line(s) exceeded the parser's length cap and were dropped, ...",
"media": "refused-over-budget",
"media_note": "...",
"max_inline_media_bytes": 1048576,
"complete": false,
"omissions": [
{ "kind": "media_refused_over_budget", "count": 1, "unit": "recording" },
{ "kind": "headers_dropped_oversize", "count": 3, "unit": "header" }
]
}
One row per — INCOMPLETE: clause in the note, always. One set of counters
produces both the rows and the prose, so a reader holding either has the whole
account.
The container example runs against tests/pcap-samples/sip-rtp-g711.pcap:
// the "container" member of one entry above
{
"vcon": "0.4.0",
"uuid": "0158a120-0392-86a4-a667-78f1c5213800",
"created_at": "2026-08-24T22:13:31.238368261+00:00",
"extensions": ["sip-signaling", "CC"],
"parties": [
{
"sip": "sip:sipp@10.0.2.20:5060",
"sip_contact": "sip:sipp@10.0.2.20:5060",
"sip_display_name": "PCMU/8000",
"validation": "none"
},
{
"sip": "sip:test@10.0.2.15:5060",
"sip_display_name": "test",
"sip_user_agent": "FreeSWITCH-mod_sofia/1.6.12-20-b91a0a6~64bit",
"validation": "none"
},
{
"role": "observer",
"sip_user_agent": "sipnab/0.5.124 (observer; node capture-01)",
"validation": "none"
}
],
"dialog": [ { "sip_call_id": "1-1966@10.0.2.20" } ],
"attachments": [
{
"purpose": "sip-message-trace",
"party": 2,
"mediatype": "application/json",
"encoding": "json",
"body": { "schema_version": 1, "sip_call_id": "1-1966@10.0.2.20", "messages": [ /* the same objects --json emits, one per message */ ] }
},
{
"purpose": "sipnab-capture-completeness",
"party": 2,
"mediatype": "application/json",
"encoding": "json",
"body": {
"note": "Produced by sipnab 0.5.127 on node capture-01. sipnab OBSERVED this dialog and took no part in it: ... sipnab read 852 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. A capture-level analysis ran and ranked no blind spots.",
"node": "capture-01",
"sipnab_version": "0.5.124",
"frames_read": 852,
"undecodable_frames": 0,
"sip_discarded_by_port_gate": 0,
"sip_discarded_by_websocket_gate": 0,
"messages_evicted": 0,
"dialogs_refused": 0,
"dialogs_rotated": 0,
"blind_spots": [],
"media": "none-decodable",
"media_note": "No audio payload retained: sipnab measured 425 RTP packet(s) of PCMU on 1 decodable stream, but kept none of their payload, so there is nothing to decode. This is a statement about what this run kept, not a finding that the call was silent. ..."
}
}
],
"analysis": [
{
"type": "report",
"dialog": 0,
"vendor": "sipnab",
"product": "sipnab 0.5.175 (passive observer; not a recording system)",
"schema": "sipnab-dialog-diagnosis/1",
"mediatype": "application/json",
"encoding": "json",
"body": { "schema_version": 1, "sip_call_id": "1-1966@10.0.2.20", "final_status_code": 200, "capture_completeness": { /* the same object the attachment carries */ } }
}
]
}
An unknown Call-ID answers with an error, never an empty container:
// export_vcon { "call_id": "no-such@nowhere" }
{ "code": -32602, "message": "call_id 'no-such@nowhere' not found" }
A filter that selects nothing answers with an empty set instead, because “nothing matched” is a finding about the capture and “that call is not here” is a mistake in the request.
Three things a reader must not conclude.
blind_spots: [] is not the same answer as an absent blind_spots. The empty
array means a capture analysis ran and ranked nothing. The absent field means
nobody looked. Both doors here always run the analysis, so an absent field on a
container from this tool would be a defect rather than a clean bill.
The observer party is always the LAST entry, and every attachment’s party
index points at it. Do not hard-code 2: the count changes the day a container
carries a party the two observed headers did not name.
uuid is a UUIDv8 whose timestamp half is the DIALOG’s, not the export’s, so
re-exporting one dialog out of one capture keeps one identifier and a consumer
can deduplicate on it. Its other 74 bits are digests keyed on the Call-ID,
the capture identity and the node name, so two dialogs that opened in the
same millisecond on the same node still differ in them.
A binary built without the vcon Cargo feature does not offer this tool:
it is absent from tools/list.
server_capabilities lists what a given binary carries.
validate_vcon
Checks a vCon container against the working group’s
draft-ietf-vcon-vcon-core-04 schema, vendored unchanged
(tests/schemas/vcon.schema.json). Backed by
output::vcon_schema.
Run it before handing containers to a conserver. A store that refuses one reports the refusal to whoever POSTed it, never to whoever built it — and a validation pass over 4,216 real containers found 2 the schema rejects, with nothing on any surface saying so.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id | string | A Call-ID the store holds. sipnab exports its container, then checks it. | Give container instead. |
container | object | A container you already hold — one sipnab wrote, one another producer wrote, one a store rejected. It must be the object itself, not a string holding it. | Give call_id instead. |
Give one, never both.
Two verdicts.
verdict | What it means |
|---|---|
valid | Nothing disagrees with the schema |
invalid | At least one finding. errors carries each one |
Each finding names the JSON Pointer of the offending value, the schema keyword
that refused it and what was wrong. The response is schema_version 2. Version
1 also had deviations, explanations and a third verdict,
valid-except-documented-deviation, for the empty Dialog Object {} that
draft-ietf-vcon-vcon-core-03 allowed in its prose and rejected in its schema.
Section 4.3 of core-04 replaced that object by a placeholder
that names its type, so no deviation remains to excuse, and {} is now an ordinary
error.
// validate_vcon { "call_id": "1-1966@10.0.2.20" }
{
"schema_version": 2,
"verdict": "valid",
"schema_path": "tests/schemas/vcon.schema.json",
"schema_id": "https://ietf.org/vcon/schemas/unsigned-vcon.json",
"errors": [],
"call_id": "1-1966@10.0.2.20"
}
A rule core-04 added: parties MUST NOT be present on a transfer Dialog
Object (section 4.3.4):
// validate_vcon { "container": { "uuid": "018f3a2b-4c5d-8e6f-9012-3456789abcde", "created_at": "2026-09-01T12:00:00Z", "dialog": [ { "type": "transfer", "parties": [0, 1] } ] } }
{
"schema_version": 2,
"verdict": "invalid",
"schema_path": "tests/schemas/vcon.schema.json",
"schema_id": "https://ietf.org/vcon/schemas/unsigned-vcon.json",
"errors": [
{
"instance_path": "/dialog/0",
"keyword": "not",
"detail": "carries `parties`, which the schema forbids here"
}
]
}
A container that disagrees with the schema is an ANSWER, not a tool error. The
call fails with invalid_params when the REQUEST is wrong: neither
argument, both arguments, an unknown call_id, or a container that is not a
JSON object. It also fails with invalid_params (reason: invalid_value,
param: container) for a container whose critical lists an extension sipnab
does not support, naming each one:
section 4.1.4 of draft-ietf-vcon-vcon-core-04 says a reader “MUST NOT attempt to process or operate on the
vCon, except to reject it or report unsupported content”. sipnab supports
sip-signaling and CC.
The validator reads the vendored file rather than a transcription of it.
jsonschema is a dev-dependency, so a validator built on it would ship only in
the test tree — which is where the gap already was. This walks the schema
itself, implementing the draft-07 subset the file uses, and it refuses to guess:
a keyword outside that subset makes every validation report invalid naming
the keyword. Re-vendoring a richer schema therefore fails loudly instead of
quietly certifying less than it claims.
A binary built without the vcon Cargo feature does not offer this tool:
it is absent from tools/list.
server_capabilities lists what a given binary carries.
analyze_vcon
Analyzes a stored vCon container you hand over: one sipnab exported, or one
another producer wrote. Backed by
output::vcon_analysis,
which POST /v1/vcon/analyze runs too, so
the two surfaces answer one container the same way.
sipnab rebuilds the container’s SIP messages and analyzes them in stores of
their own. The tool leaves the loaded capture untouched, records nothing, and
connects nowhere. It reads two stored forms: a sip-message-trace attachment (sipnab’s,
and the trace form of draft-howe-vcon-sip-signaling-00) and message/sip
attachments, one whole message each. It opens a signed, encrypted or gzip
format container first, with the server’s --vcon-key, --vcon-signer-ca and
--vcon-signer-sha256: see
A signed, encrypted or compressed container.
To analyze a container on the command
line, give it to -I instead: see
Analyze a stored vCon.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
container | object | The container itself, not a string holding it, in the unsigned, signed or encrypted form. | Give this or container_base64. With neither, the call fails with invalid_params. |
container_base64 | string | The container’s bytes in standard base64, in any form, the gzip format included. | Give this or container. With both, the call fails with invalid_params. |
limit | integer | The most dialogs to return. Ceiling --mcp-max-rows. | 50. total_dialogs counts every dialog the container holds, and truncated says whether the limit cut the list. |
Two verdicts.
verdict | What it means |
|---|---|
analyzed | sipnab rebuilt at least one SIP message, and the answer holds the analysis |
unreadable | The container does not open (an encrypted container and no --vcon-key, a signature that does not verify, a signer the checks refuse), stores no SIP message sipnab can rebuild, is not a vCon at all, or lists in critical an extension sipnab does not support (sipnab supports only sip-signaling and CC; section 4.1.4 of draft-ietf-vcon-vcon-core-04). reason says which, and the answer holds no analysis. For an unsupported critical extension it holds no schema findings either |
A schema finding does not stop the analysis. schema_findings lists every
place the container disagrees with the vendored draft-ietf-vcon-vcon-core-04
schema, in the shape validate_vcon reports them, beside
either verdict. A container that went through vcon.store, which hands
extensions back as an object, draws one finding and an analysis.
The answer lists what a container cannot reproduce, and fills none of it in.
not_recomputed holds one sentence per gap. A vCon stores no RTP, so sipnab
computes no stream, MOS, jitter or loss. The producer’s own media_quality
rows come back verbatim in stored_media_quality, as its numbers. The trace
stores no request-URI, so a rebuilt request carries
sip:request-uri-not-stored.invalid. It keys headers by name, so header order
across names does not survive. A body other than SDP, and headers the producer did not
store, are absent. The capture analysis in analysis opens with a
rebuilt_from_vcon finding that says the same, beside the producer’s own
caveat (producer_note).
Each rebuilt message carries input_origin: "vcon" and no frame pointer,
because it was never a frame anyone can read again.
Real output for the container --export-vcon 1-1966@10.0.2.20 --node-name tap-a
writes from tests/pcap-samples/sip-rtp-g711.pcap:
// analyze_vcon { "container": <the contents of call.vcon.json> }
{
"analysis": {
"complete": false,
"dialogs_examined": 1,
"findings": [
{
"evidence": [
{
"counts": {
"messages": 6
},
"endpoints": [
"inline",
"vcon 0158a120-0392-857f-8193-d90b5d03d6c4"
],
"note": "6 SIP message(s) rebuilt from this container. Not recomputed or not stored: media: a vCon carries no RTP, so no stream, MOS, jitter or loss was computed from it; the producer's analysis holds 1 media_quality row(s), its own numbers, not recomputed; request-URI: not stored for 3 request(s), which carry sip:request-uri-not-stored.invalid; header order: the trace keys headers by name, so their order across names is the container's, not the wire's, and headers the producer did not store (credential headers, when sipnab produced it) are absent. The producer's caveat: Produced by sipnab 0.5.208 on node tap-a. sipnab OBSERVED this dialog and took no part in it: the parties below are what the From and To headers said, not identities anyone established, and nothing here is signed. No media is in this container because this run decoded none for the dialog; the capture_completeness media_note says what was measured. That is a statement about what this run kept, not a finding that the call was silent. sipnab read 852 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. A capture-level analysis ran and ranked no blind spots."
}
],
"evidence_omitted": 0,
"kind": "rebuilt_from_vcon",
"occurrences": 1,
"severity": "blind",
"unit": "container"
}
],
"frames_read": 6,
"schema_version": 1,
"streams_examined": 0
},
"dialogs": [
{
"call_id": "1-1966@10.0.2.20",
"dialog": {
"call_id": "1-1966@10.0.2.20",
"diagnosis": {
"hints": [],
"nat_mismatch": false,
"no_media": false,
"one_way_audio": false,
"private_media_address": false
},
"duration_sec": 8.504,
"final_status_code": 200,
"final_status_reason": "OK",
"from": "sipp",
"from_display": "PCMU/8000",
"input_origin": "vcon",
"method": "INVITE",
"msg_count": 6,
"schema_version": 1,
"sdp_timeline": [
{
"codecs": [
"PCMU"
],
"direction": "offer",
"media_addr": "10.0.2.20",
"media_port": 6000,
"mode": "recvonly",
"timestamp": "2016-11-26T14:52:59.666393+00:00"
},
{
"codecs": [
"PCMU",
"telephone-event"
],
"direction": "answer",
"event": "MediaAnchorChange",
"media_addr": "10.0.2.15",
"media_port": 27942,
"mode": "sendonly",
"timestamp": "2016-11-26T14:52:59.670743+00:00"
}
],
"state": "Completed",
"streams": [],
"termination": {
"cause_code": 16,
"cause_text": "NORMAL_CLEARING",
"frame_ref": 4,
"protocol": "Q.850",
"source_header": "Reason"
},
"timing": {
"retransmits": 0,
"setup_ms": 4,
"teardown_ms": 0,
"trying_delay_ms": 0
},
"to": "test",
"to_display": "test"
},
"ladder": [
{
"dst": "10.0.2.15:5060",
"label": "INVITE",
"src": "10.0.2.20:5060",
"timestamp": "2016-11-26T14:52:59.666393+00:00",
"transport": "UDP"
},
{
"dst": "10.0.2.20:5060",
"label": "100 Trying",
"src": "10.0.2.15:5060",
"timestamp": "2016-11-26T14:52:59.666545+00:00",
"transport": "UDP"
},
{
"dst": "10.0.2.20:5060",
"label": "200 OK",
"src": "10.0.2.15:5060",
"timestamp": "2016-11-26T14:52:59.670743+00:00",
"transport": "UDP"
},
{
"dst": "10.0.2.15:5060",
"label": "ACK",
"src": "10.0.2.20:5060",
"timestamp": "2016-11-26T14:52:59.670837+00:00",
"transport": "UDP"
},
{
"dst": "10.0.2.20:5060",
"label": "BYE",
"src": "10.0.2.15:5060",
"timestamp": "2016-11-26T14:53:08.170086+00:00",
"transport": "UDP"
},
{
"dst": "10.0.2.15:5060",
"label": "200 OK",
"src": "10.0.2.20:5060",
"timestamp": "2016-11-26T14:53:08.170676+00:00",
"transport": "UDP"
}
],
"signaling": {
"abandoned": null,
"ack_missing": null,
"auth_loop": null,
"final_failure": null,
"hints": [],
"post_dial_delay": null,
"registration_failure": null,
"retransmissions": null
}
}
],
"messages_rebuilt": 6,
"not_recomputed": [
"media: a vCon carries no RTP, so no stream, MOS, jitter or loss was computed from it",
"the producer's analysis holds 1 media_quality row(s), its own numbers, not recomputed",
"request-URI: not stored for 3 request(s), which carry sip:request-uri-not-stored.invalid",
"header order: the trace keys headers by name, so their order across names is the container's, not the wire's, and headers the producer did not store (credential headers, when sipnab produced it) are absent"
],
"producer_note": "Produced by sipnab 0.5.208 on node tap-a. sipnab OBSERVED this dialog and took no part in it: the parties below are what the From and To headers said, not identities anyone established, and nothing here is signed. No media is in this container because this run decoded none for the dialog; the capture_completeness media_note says what was measured. That is a statement about what this run kept, not a finding that the call was silent. sipnab read 852 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. A capture-level analysis ran and ranked no blind spots.",
"schema_findings": [],
"schema_version": 1,
"stored_media_quality": [
{
"codec": "PCMU",
"jitter_ms": 0.0054046519599899685,
"loss_pct": 0.0,
"mos": 4.358100599599484,
"mos_grounded": true,
"mos_grounding": "published",
"packets": 425,
"r_factor": 90.79987028835296,
"ssrc": "0x343da99b"
}
],
"total_dialogs": 1,
"truncated": false,
"uuid": "0158a120-0392-857f-8193-d90b5d03d6c4",
"verdict": "analyzed"
}
A container with no readable SIP is an answer with verdict: unreadable, not a
tool error. The call fails with invalid_params (-32602) only when
container is missing (reason: missing_param) or is not a JSON object
(reason: invalid_value), with param: container in both
error data objects.
fetch_vcon
Reads one container from the operator’s vCon store by uuid and returns it in
the answer. It writes no file. Backed by
app::vcon_fetch:
the request, the credential, the CA, the size limit, the decryption and the
signer checks are sipnab --vcon-fetch’s, by the same function
(Fetch a stored vCon).
GET /v1/vcon/fetch/{uuid} gives the same
answer.
It connects to the store, so it is off by default. It needs
--mcp-allow-vcon-fetch and a store in the [vcon_fetch] section of the
config file (url, kind, auth_file, and decrypt_key for encrypted
containers). Every other tool in this bundle answers from bytes sipnab already
holds. The store, its kind, the credential and the key come from operator
configuration only: the tool takes a uuid and nothing that picks a
destination, and the answer never holds a key or the credential.
read_only_hint is true and open_world_hint is true.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
uuid | string | The vCon’s uuid: 1 to 64 characters, each a letter, a digit or -. Anything else fails with invalid_params (-32602) before sipnab sends anything. | Required. |
Returns:
| Field | Type | Description |
|---|---|---|
uuid | string | The uuid asked for |
stored_form | string | The form the store held the container in: unsigned, signed or encrypted |
decrypted | boolean | true when sipnab decrypted an encrypted container with [vcon_fetch] decrypt_key |
signature_verified | boolean | true when the container’s signature verified, and passed [vcon_fetch] signer_ca and signer_sha256 when the operator sets them: the signed form inside a decrypted container, or a signed container that is not encrypted, which needs no key. sipnab refuses a signature that does not verify, as the readers do. false for an unsigned container and for one left encrypted |
signer_sha256 | string or null | The SHA-256 fingerprint of the certificate the signature verified against, as openssl x509 -noout -fingerprint -sha256 prints it |
schema_findings | array | Where the container fails the draft-ietf-vcon-vcon-core-04 schema, one line each. Empty for a valid container and for one left encrypted |
container | object | The vCon: the unsigned container when sipnab decrypted it, else as the store holds it, a signed container with its signature, without the store kind’s envelope |
A second content block is the untrusted-content note: the container holds whatever its producer stored.
Refusals, each invalid_params (-32602) unless it says otherwise:
| Cause | Message |
|---|---|
No --mcp-allow-vcon-fetch | fetching a vCon from the store is off on this server: start sipnab with --mcp-allow-vcon-fetch, and configure the store in [vcon_fetch] |
The flag, and no [vcon_fetch] url | --mcp-allow-vcon-fetch is set, but no vCon store is configured: set [vcon_fetch] url and [vcon_fetch] auth_file in the config file |
The store answered 404 | the store holds no such vCon (404) |
Anything else the fetcher refuses: another status, a timeout, an answer over [vcon_fetch] max_size, another uuid inside, a key that does not decrypt, a signer that fails a check | internal_error (-32603), the fetcher’s reason fenced as untrusted text, with the credential removed |
A [vcon_fetch] store whose credential or key file sipnab cannot read stops
the run at startup, naming the flag.
// fetch_vcon { "uuid": "0158a120-0392-8751-9a04-9decf1485b22" },
// on a run started with -N --mcp --mcp-allow-vcon-fetch and a [vcon_fetch]
// conserver store holding that container
{
"schema_version": 1,
"uuid": "0158a120-0392-8751-9a04-9decf1485b22",
"stored_form": "unsigned",
"decrypted": false,
"signature_verified": false,
"signer_sha256": null,
"schema_findings": [],
"container": { "analysis": [ ... ], "attachments": [ ... ], "uuid": "0158a120-0392-8751-9a04-9decf1485b22", ... }
}
vcon_directory_report
Reports every stored vCon container in one directory of --mcp-file-root:
which ones sipnab read, what each one held, which ones it skipped and why, and
the totals. It is the report sipnab -N -I <dir> --report prints after the
dialog table: see
Read many containers at once. One
function builds both,
output::dialog_report::vcon_containers_report.
sipnab reads each container with the reader -I uses, and rebuilds the calls
into stores of their own. The tool leaves the loaded capture untouched,
changes no file, and connects nowhere. It opens a signed, encrypted or gzip
format container with the server’s --vcon-key, --vcon-signer-ca and
--vcon-signer-sha256.
- It reads the files directly in the directory. It does not descend into subdirectories.
- It does not follow a symbolic link.
- A file is a container when its content says so, as for
-I: the first character other than whitespace is{, or the file is gzip that inflates to one. A capture or a README in the directory is not a row.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
directory | string | A directory directly inside --mcp-file-root, by bare name, or . for the root itself. | Required. A path, or a name that leads outside the root, fails with invalid_params. |
limit | integer | The most rows to return. Ceiling --mcp-max-rows. | 50. |
cursor | string | The next_cursor of the previous page: the file name it ended at. | The first page. |
max_response_bytes | integer | The byte budget for this page. Ceiling --mcp-max-response-bytes. | The ceiling. The budget cuts rows whole, and truncated_by says bytes. |
Rows, then totals. One row per container file, in file-name order.
| Field | What it holds |
|---|---|
status | read or skipped |
file | The file name, relative to directory |
uuid | The container’s uuid, or null when it did not open far enough to show one |
form | The outermost form the file arrived in: unsigned, signed, encrypted, gzip, or unknown for bytes that are not a JSON object |
validation_findings | Places the container disagrees with the vendored draft-ietf-vcon-vcon-core-04 schema. Null when the schema check did not run: the file did not open, or is not JSON |
calls_rebuilt, call_ids | The calls (dialogs, by Call-ID) rebuilt from it |
dispositions | Those calls counted by state, the word the --report dialog table’s State column prints |
duration_secs | Those calls’ durations added up, each as the dialog table’s Duration column computes it |
skip_reason | On a skipped row only: no_key, no_sip_signaling, not_json, not_a_vcon, not_opened, unsupported_critical or unreadable |
skip_detail | On a skipped row only: the same reason in full |
totals adds up every row of the directory, whichever page this is:
containers, read, skipped, skipped_by_reason, failed_validation
(containers with at least one schema finding), calls, dispositions and
duration_secs. A call two containers both hold counts once in the totals,
and once in each of their rows.
no_key is an encrypted container on a server without --vcon-key. The row
names the flag. sipnab never reports an encrypted container as one with no SIP.
Real output for a directory stored holding two unsigned containers, the gzip
format of a third, an encrypted container, a container from the
vcon-supreme-court-arguments dataset (which stores no SIP) and a file cut off
after 30 bytes. This is the second page of three rows:
// vcon_directory_report { "directory": "stored", "limit": 3, "cursor": "c-busy.vcon.json.gz" }
{
"containers": [
{
"call_ids": [],
"calls_rebuilt": 0,
"dispositions": {},
"duration_secs": 0,
"file": "d-sealed.vcon.json",
"form": "encrypted",
"skip_detail": "cannot open the vCon container: the container is encrypted (draft-ietf-vcon-vcon-core-04 section 5.3), and no decryption key was given: give --vcon-key, or [vcon_input] key in the config file",
"skip_reason": "no_key",
"status": "skipped",
"uuid": null,
"validation_findings": null
},
{
"call_ids": [],
"calls_rebuilt": 0,
"dispositions": {},
"duration_secs": 0,
"file": "e-dataset.vcon.json",
"form": "unsigned",
"skip_detail": "a vCon container with no SIP signaling sipnab can read: it has no sip-message-trace or message/sip attachment",
"skip_reason": "no_sip_signaling",
"status": "skipped",
"uuid": "01a0be36-e172-8a14-9dd8-dd37220d739c",
"validation_findings": 4
},
{
"call_ids": [],
"calls_rebuilt": 0,
"dispositions": {},
"duration_secs": 0,
"file": "f-malformed.vcon.json",
"form": "unknown",
"skip_detail": "not a vCon container: not JSON (EOF while parsing a list at line 1 column 30)",
"skip_reason": "not_json",
"status": "skipped",
"uuid": null,
"validation_findings": null
}
],
"directory": "stored",
"next_cursor": null,
"returned": 3,
"schema_version": 1,
"totals": {
"calls": 3,
"containers": 6,
"dispositions": {
"Completed": 1,
"Failed": 1,
"InCall": 1
},
"duration_secs": 9,
"failed_validation": 1,
"read": 3,
"skipped": 3,
"skipped_by_reason": {
"no_key": 1,
"no_sip_signaling": 1,
"not_json": 1
}
},
"truncated": false
}
A build without the vcon Cargo feature does not register this tool.
vcon_spool_status
Counts the vCon export spool: the directory --export-vcon-dir names, which
the capture’s --export-vcon-when writes containers into and which
sipnab --vcon-forward delivers to a
store. For each container the store refused, it returns the record the
forwarder wrote beside it. Backed by
app::vcon_forward,
whose file rules it reuses.
The same answer comes from
GET /v1/vcon/spool and from
sipnab --vcon-spool-status <SPOOL_DIR>
(CLI reference), built by one function,
app::vcon_spool::snapshot. This tool adds one thing the other two do not: it
fences each store answer as untrusted text and cuts it to
--mcp-max-body-bytes.
The answer is a snapshot of files on disk, taken when as_of says. The
forwarder is a separate process and may move a file a moment later. This tool
reads files and does nothing else: it sends nothing, fetches nothing, and
moves, deletes or creates no file, so the capture process keeps making no
outbound vCon connection. read_only_hint is true.
It needs a run started with --export-vcon-when and --export-vcon-dir.
Without them it fails with invalid_params (-32602) naming the flags. It
reads the forwarder’s DEFAULT directories beside the spool, delivered/ and
failed/. A forwarder started with --vcon-forward-done or
--vcon-forward-failed moves files to directories this tool does not read,
and does not count.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
limit | integer | 1 to --mcp-max-rows (1000 by default). Bounds the list of failed records; the three counts are never bounded. | 50. |
cursor | string | The previous response’s next_cursor, passed back verbatim. | The first page. |
rule | string | The export rule whose spool to read: a [[vcon.rule]] name, or --export-vcon-when for the flags’ rule. Several spools and no rule, or a rule the run does not have, fail with invalid_params (-32602) listing the run’s rule names. | The run’s only spool. |
Returns:
| Field | Type | Description |
|---|---|---|
as_of | string | When this tool read the directories, RFC 3339 UTC |
note | string | One sentence saying the answer is a snapshot and that this tool moves nothing |
spool, delivered_dir, failed_dir | object | Each directory’s path and whether it exists. A directory that does not exist counts as empty |
pending | integer | Containers in the spool, by the forwarder’s rule: regular files ending in .json whose names do not start with a dot |
delivered | integer | Containers in delivered/ |
failed | integer | Containers in failed/, the .error.json records not counted |
failures | array | One page of failed containers by file name: file, and record, the <file>.error.json record (at, status, method, url, body, body_truncated, reason) |
returned | integer | Rows in failures |
truncated | boolean | Whether failed containers remain after this page |
next_cursor | string or null | Pass back as cursor for the next page; null on the last page |
rule | string | The export rule whose spool this is |
record.body is the start of the store’s answer as the forwarder kept it,
fenced as untrusted text and cut to --mcp-max-body-bytes (4096 by default).
record.status, method, url and body are null when the forwarder refused
the container before sending it, and record.reason then says why. A failed
container whose record is missing, unreadable or not in the forwarder’s shape
has a null record and a record_problem saying which.
// vcon_spool_status {}, on a run started with --export-vcon-dir vcons
{
"schema_version": 1,
"as_of": "2026-10-12T00:59:14.262Z",
"note": "A snapshot of files on disk at as_of. The vCon forwarder (sipnab --vcon-forward) is a separate process that moves files out of the spool; whatever produced this answer sends, fetches, moves and deletes nothing. ...",
"spool": { "path": "vcons", "exists": true },
"delivered_dir": { "path": "vcons/delivered", "exists": true },
"failed_dir": { "path": "vcons/failed", "exists": true },
"pending": 1,
"delivered": 1,
"failed": 1,
"failures": [
{
"file": "refused.vcon.json",
"record": {
"at": "2026-10-11T11:58:02Z",
"body": "⟦untrusted-capture-data⟧{\"error\":\"vCon already exists\"}⟦/untrusted-capture-data⟧",
"body_truncated": false,
"file": "refused.vcon.json",
"method": "POST",
"reason": null,
"status": 409,
"url": "https://vcon.example.com/v1/vcons"
}
}
],
"returned": 1,
"truncated": false,
"next_cursor": null
}
A build without the vcon Cargo feature does not register this tool.