MCP Tools Signaling - NormB/sipnab GitHub Wiki
The signaling bundle answers follow-up questions about SIP signaling: single
messages, call flows, SDP negotiation, related call legs, RFC conformance,
time windows, and reproducing a call.
Load only these tools with --mcp-tools signaling, 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 |
|---|---|---|
timeline |
bucket_seconds? |
Call volume per fixed-width interval, so a gap or a spike is visible without reading every dialog |
group_dialogs |
by, metrics?, filter?, top_n?
|
Carrier metrics per group — ASR, NER, ACD, post-dial delay, MOS, retransmissions — each beside the population it rests on |
search_by_time |
start, end?, filter?, limit?, cursor?
|
Dialogs whose first message falls in an RFC 3339 window |
validate_filter |
expr |
Whether a filter DSL expression parses, the parser's message when it does not, and how many dialogs it selects — with no rows |
tail_dialogs |
cursor?, limit?
|
Cursor-based incremental dialog fetch |
await_condition |
filter, timeout_seconds?, poll_interval_ms?
|
Waits until a filter matches or a deadline passes, whichever is first, so a live capture costs one call instead of a polling loop |
find_correlated |
call_id, limit?
|
The other legs of the same call across a B2BUA, each with a score AND the strategy that matched it |
get_call_tree |
call_id, limit?
|
Every leg of one call as a tree, each edge carrying its parent, depth, score, strategy and whether the walk went through it |
compare_dialogs |
call_id_a, call_id_b
|
Two calls side by side, with the differences named |
get_dialog_report |
call_id, format?
|
Structured per-call report (JSON / Markdown / text) |
get_message |
call_id, index
|
Single SIP message at a given index |
render_ladder |
call_id, format?
|
Call-flow ladder (Markdown / text), or an annotated Mermaid diagram |
get_sdp_timeline |
call_id |
SDP offer/answer exchanges in order: codecs, ptime, direction |
check_codec_negotiation |
call_id |
Codecs offered vs answered and whether they intersect — for 488s |
lint_dialog |
call_id, rulesets?, severity_min?, suppression_file?
|
Conformance findings for one call, declaration against observation included, each with its RFC and section |
validate_message |
call_id, index, suppression_file?
|
Conformance findings for one message, read alone |
explain_rule |
rule_id |
The catalog entry behind one rule identifier: citation, basis, scope, selectors |
explain_response_code |
code |
IANA registry meaning and class for a SIP status code |
generate_repro |
call_id, format?, pin?, vary?, filename?
|
A SIPp scenario replaying one call, with the hypothesis as an input: pin holds the suspected cause fixed, vary regenerates identity |
generate_wireshark_filter |
call_id, include_media?
|
A Wireshark display filter selecting one call's signaling and its RTP by SSRC, plus the tshark line that applies it |
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".
Call volume over time, in fixed-width buckets.
aggregate_dialogs answers "how many, grouped by what" and is blind to WHEN.
A trunk that failed for ninety seconds and a trunk that fails one call in
forty produce the same bucket counts, and only the shape over time separates
them. This returns one row per interval so the shape is readable without
fetching every dialog and bucketing them in the model's head -- the counting
task language models get wrong most reliably.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
bucket_seconds |
integer? | Greater than zero. A zero-width bucket describes no interval and every dialog would fall into all of them at once, so it fails with invalid_params rather than dividing by zero. |
60. |
Returns — an object wrapping the series, not a bare array:
| Field | Type | Description |
|---|---|---|
buckets |
object[] | One row per interval, oldest first. Each carries start (inclusive, RFC 3339), bucket_seconds and dialogs. |
returned |
u64 | Rows in buckets, so counting the array is never necessary. |
bucket_seconds |
u64 | The width actually used, echoed because you may have omitted it. |
source_exhausted |
bool |
true once sipnab has read the capture source to its end. |
source_stopped_early |
bool |
true when a source's read ended before the source did. |
schema_version |
u32 |
1 for this shape. |
There is no truncated and no cursor: this tool takes no limit and returns
every bucket in the capture.
The object is the reason to read this tool's answer rather than count it. A
bare array has no key to carry source_exhausted, so a series drawn while the
capture was still loading looked exactly like one drawn over the whole file.
Wrapping the rows gives that fact somewhere to land.
Two properties are deliberate. Buckets align to the epoch, not to the first dialog, so two captures of the same window produce identical boundaries and line up against each other. Aligning to the earliest call would instead shift every boundary whenever that call changed. The series also keeps empty buckets, as the middle row above shows -- a gap is a finding, and a series that silently drops it renders as continuous traffic on a shorter axis.
Carrier metrics per group, computed inside the store.
aggregate_dialogs answers "how many" and stops.
Every question beginning with WHICH — which trunk is failing, which User-Agent
has the worst audio, which hour it started — wants a rate, and a rate is the one
thing a language model cannot recover from a page of rows: it would have to hold
every dialog, classify each outcome and divide.
Agents stop early and answer from the rows they happen to hold, which is how a truncated page becomes a confident verdict about a carrier. So this tool computes the rate where the dialogs live and reports the population underneath it.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
by |
string | ONE of state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec, to_domain, hour, next_hop. Anything else — including two names in one string, such as "src.ip,hour" — fails with invalid_params naming all twelve. |
Required. |
metrics |
string[]? | Any of count, asr, ner, acd, pdd_p50, pdd_p95, mos_p10, retransmit_rate. The tool sorts and de-duplicates the list, so the key order in the answer never depends on the order you wrote. An unknown name fails naming all eight. |
All eight. |
filter |
string? | Alias or DSL, applied before grouping — the same vocabulary list_dialogs takes. |
No filter — the whole store. |
top_n |
integer? | Bounded by --mcp-max-rows, the same knob that bounds dialog rows. Everything past it lands in other_count rather than disappearing. |
50, the same default as any row limit. |
// group_dialogs { "by": "next_hop", "metrics": ["count", "asr", "ner"], "top_n": 2 }
{
"schema_version": 1,
"group_by": "next_hop",
"metrics": ["asr", "count", "ner"],
"units": { "asr": "percent", "count": "dialogs", "ner": "percent" },
"groups": [
{
"value": "203.0.113.9:5060",
"count": 412,
"metrics": { "asr": 71.6, "count": 412.0, "ner": 98.27 },
"not_grounded": {},
"population": {
"dialogs": 412, "seizures": 405, "answered": 290, "delivered": 398,
"completed_calls": 288, "pdd_measured": 401,
"mos_grounded_dialogs": 260, "retransmits": 14
}
},
{
"value": "203.0.113.10:5060",
"count": 77,
"metrics": { "asr": null, "count": 77.0, "ner": null },
"not_grounded": {
"asr": "no INVITE in this group reached a final response, so nothing in it was a decided call attempt",
"ner": "no INVITE in this group reached a final response, so nothing in it was a decided call attempt"
},
"population": {
"dialogs": 77, "seizures": 0, "answered": 0, "delivered": 0,
"completed_calls": 0, "pdd_measured": 0,
"mos_grounded_dialogs": 0, "retransmits": 0
}
}
],
"other_count": 11,
"distinct_values": 6,
"total_matched": 500,
"capture_identity": { "node": "capture-01", "instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 4113, "stream_generation": 902 }
}A null metric is an answer, and not_grounded says which population went
missing. The second group above holds 77 REGISTER dialogs. An ASR of 0 there
would name a working registrar a dead trunk, which is a wrong answer rather than
a missing one. That is the discipline mos_grounded already applies to a MOS on
an unpublished codec, extended to the other seven metrics.
Every figure names its unit in the answer. An ASR of 0.65 and an ASR of
65 describe the same trunk and differ by a factor of a hundred, so units
travels with the numbers rather than living on this page. asr and ner are
PERCENTS here and in evaluate_expectations, which
takes its thresholds in the same unit: a rule reads "value": 99, matching the
number you read in a report.
The two tools disagreed on this before 0.5.130 -- one percent, one a ratio -- and a threshold copied between them was wrong by a hundredfold in the direction that always passes.
The eight metrics, and what each one measures:
| Metric | Unit | Definition | Population it rests on |
|---|---|---|---|
count |
dialogs | Dialogs in the group. | dialogs |
asr |
percent | Answer-seizure ratio: 2xx answers over seizures. | seizures |
ner |
percent | Network effectiveness ratio (ITU-T E.411): seizures the network delivered over seizures. | seizures |
acd |
seconds | Average call duration: mean CONVERSATION time, not dialog span. | completed_calls |
pdd_p50, pdd_p95
|
milliseconds | Post-dial delay percentiles, nearest rank. | pdd_measured |
mos_p10 |
mos | Tenth-percentile MOS across dialogs holding a stream sipnab can score, worst stream per dialog. | mos_grounded_dialogs |
retransmit_rate |
retransmissions per dialog |
retransmits over dialogs. Never refused — a group exists because a dialog fell into it. |
dialogs |
A seizure is an INVITE dialog that reached a final response. Both halves carry weight. A REGISTER is not a call attempt, and an INVITE still ringing when the capture ended has not failed — counting it as a failed seizure reports a live capture as an outage that looks worse the earlier you look.
NER credits the far end where ASR does not. A trunk full of 486s has an ASR of zero and works perfectly: the network delivered every call and the callee was busy, so reading the ASR alone escalates a healthy carrier.
The five codes that
count as delivered are 480, 486, 487, 600 and 603 — the callee
unavailable, busy, busy everywhere, declining, or the CALLER hanging up on a
call that had already reached the far end.
408 Request Timeout is
deliberately absent. A proxy emits it both for a silent phone and for an
unreachable next hop, so crediting it would credit the network for calls that
may never have arrived, which is the exact misattribution NER exists to prevent.
ACD times the conversation, not the dialog. A call that rang for five seconds, talked for sixty and took one more on the BYE handshake spans 66 seconds and contributes 60. Averaging the span bills the caller for the ring-back, which is not what any carrier means by average call duration.
Percentiles use nearest rank, with no interpolation. An interpolated p95 returns a number no call experienced, and these figures get quoted back to a carrier as evidence about real calls. Every metric rounds to two decimal places, because a ratio over three dialogs is not accurate to fourteen — and the population sits beside it, so anyone wanting the exact ratio divides the two integers.
Groups rank by dialog count, largest first, ties broken by value. Ranking by
the metric would answer a different question per call and could not page against
other_count. That remainder carries a count and nothing else, deliberately: an
ASR averaged over the groups that fell off the end is an average of averages
across the smallest groups, which is the arithmetic that turns one busy trunk
into a capture-wide verdict. The groups plus other_count always equal
total_matched.
Three dimensions live here and not in aggregate_dialogs, and the set is a
strict SUPERSET rather than an overlapping one, so an agent never has to
discover per tool which vocabulary it is talking to:
| Dimension | Value | Fenced? |
|---|---|---|
to_domain |
The host half of the To URI, port stripped, IPv6 brackets kept. | Yes — the sender wrote it. |
hour |
The calendar hour the dialog opened in, aligned to the epoch like timeline buckets, RFC 3339. |
No |
next_hop |
host:port the opening message went to, read off the IP and transport headers. |
No |
Fencing follows the same rule as everywhere else. to_domain, to.user,
from.user and ua arrive wrapped in ⟦untrusted-capture-data⟧ because a
sender chose them. A state name, a status code, an address, a codec and
next_hop come back verbatim, because they are sipnab's own derivation and
telling an agent to distrust them would tell it to distrust the analysis.
Note next_hop names the peer only from the vantage point of a capture taken
beside the sender, which is where a proxy's own trunk traffic gets captured.
One dimension, for the reason aggregate_dialogs gives:
two is a pivot table, and a pivot table wants a UI. Narrow with filter
instead. Reach for aggregate_dialogs when a bare count answers the question —
it does the same walk without computing seven metrics.
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.
Returns dialogs whose first message falls in the window, oldest first.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
start |
string | An inclusive RFC 3339 instant, such as "2026-07-31T14:00:00Z". A malformed one fails with start 'x' is not RFC 3339. |
Required — the call fails. |
end |
string? | An exclusive RFC 3339 instant after start. At or before start fails with invalid_params (-32602). |
Everything from start onward. |
filter |
string? | An alias name or a raw DSL expression, ANDed with the window. | The window alone decides the page. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 rows. |
cursor |
string? | The previous response's next_cursor, verbatim (<RFC 3339 created_at>|<Call-ID>). A malformed timestamp half fails with invalid_params. |
Starts at the oldest dialog in the window. |
fields |
string[]? | Field names to keep on each row, plus call_id. The row here is already narrow, so name one of the four below — see Narrowing a row with fields. |
The whole row. |
max_response_bytes |
u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes" — see Rules every tool follows. |
The ceiling. |
Returns { dialogs, returned, total_matched, truncated, truncated_by, next_cursor, capture_identity, schema_version }, with truncated_by present
only when a bound cut the page.
Each row carries call_id, created_at, state and final_status_code —
a narrower row than list_dialogs returns, with no
msg_count, no from_user, no timing and no frame. No markers appear
here either, because none of those four fields is free text. Feed a call_id to another
tool when you need the rest.
total_matched counts every dialog in the window before limit applies, so a
small answer from a quiet window reads differently from a truncated one.
filter turns "failed calls between 14:00 and 14:05" into a single call. The
window narrows first and the filter runs over what survives.
The example runs against tests/pcap-samples/sipp-branch-scenario.pcapng. The
same window without a filter answers total_matched: 247:
// search_by_time { "start": "2016-11-17T21:52:35Z", "end": "2016-11-17T21:53:00Z",
// "filter": "problems", "limit": 2 }
{
"schema_version": 1,
"dialogs": [
{
"call_id": "[email protected]",
"created_at": "2016-11-17T21:52:36.203349+00:00",
"final_status_code": 403,
"state": "Failed"
},
{
"call_id": "[email protected]",
"created_at": "2016-11-17T21:52:37.703349+00:00",
"final_status_code": 403,
"state": "Failed"
}
],
"returned": 2,
"total_matched": 16,
"truncated": true,
"truncated_by": "rows",
"next_cursor": "2016-11-17T21:52:37.703349Z|[email protected]",
"capture_identity": {
"node": "capture-01",
"instance": "12100b18cb6971cf461cff-1",
"dialog_generation": 9015,
"stream_generation": 0
}
}truncated: true is not a dead end. Pass next_cursor back to
continue through the window. total_matched keeps counting the whole window
rather than the remainder, so it does not shrink as you page — without a
cursor the only way past a truncated window would be to narrow it, putting
every row past the 1000-row ceiling out of reach. The cursor is
the same compound form list_dialogs issues, and it is opaque:
pass it back exactly as it arrived.
Compiles a Filter DSL expression, counts what it selects, and returns no rows.
The DSL narrows list_dialogs, find_problems,
search_by_time, aggregate_dialogs
and group_dialogs, and before this tool every way of trying
an expression cost a page. An agent converging on a working filter — widen it,
narrow it, try the other field name — paid for a page of fenced summaries per
attempt and discarded every row.
The two failure modes also look alike from
outside: a malformed expression comes back as invalid_params, which lands in
the error channel where a model acts on it least, and an expression that parses
but selects nothing returns the same empty page as an empty capture.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
expr |
string | A raw filter DSL expression, or one of the diagnostic aliases list_dialogs takes. Nothing you can put here fails the call. |
Required — the call fails. |
A parse failure is a successful call. Every other tool rejects a bad filter
with invalid_params, which is right there and wrong here: learning what is
wrong with an expression is the entire point of this tool, and an error object
carries that message where a model is least likely to act on it. So a bad
expression answers valid: false with the parser's own text — position, caret
and hint included — and the only error this tool raises is one it did not
expect.
// validate_filter { "expr": "state = Failed" }
{
"schema_version": 1,
"expr": "state = Failed",
"valid": false,
"error": "invalid filter 'state = Failed': unexpected input at position 6: '='\n state = Failed\n ^\nvalid operators: ==, !=, <, <=, >, >=, =~ (regex)\nsee docs/filter-dsl.md for fields, values, and diagnostic aliases",
"total_matched": null,
"total_dialogs": 2
}total_matched stays null on a parse failure rather than dropping to 0,
because a zero there reads as "parsed, matched nothing" — the opposite of what
happened. Fix the expression and the count arrives, measured against the whole
store with no cursor and no truncation. The example runs against
tests/pcap-samples/sip-rtp-g711.pcap:
// validate_filter { "expr": "state == 'Completed' and rtp.mos < 4.5" }
{
"schema_version": 1,
"expr": "state == 'Completed' and rtp.mos < 4.5",
"valid": true,
"error": null,
"total_matched": 1,
"total_dialogs": 2
}Read total_dialogs beside total_matched. It is the denominator, and it
comes back even on a parse failure: 0 matches out of 0 dialogs is an empty
capture, 0 out of 4000 is an expression that selects nothing, and no single
number separates them.
Aliases expand here exactly as they do everywhere else, because this tool calls
the same compiler the other tools call. A second parser would eventually accept
an expression list_dialogs then rejects, which is worse than no tool at all.
This response carries your own expression and two integers and nothing read off
the wire, so it appends no provenance note.
Incremental fetch of the dialogs updated after a cursor position.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
cursor |
string? | The previous response's next_cursor, verbatim (<RFC 3339>|<Call-ID>). A bare RFC 3339 timestamp also parses, and filters strictly after it. |
Starts from the beginning of the store. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 rows. |
fields |
string[]? | Field names to keep on each row, plus call_id. Same rules as list_dialogs — see Narrowing a row with fields. |
The whole row. |
max_response_bytes |
u64? | Bytes for the whole response. Ceiling is --mcp-max-response-bytes (1048576 unless the operator changed it). Higher clamps to it, 0 means the ceiling. Past it the page ends early at a whole row with truncated_by: "bytes" — see Rules every tool follows. |
The ceiling. |
Returns { dialogs, next_cursor, source_exhausted, source_stopped_early, capture_identity }, where
dialogs holds the same summary rows list_dialogs returns,
fencing included. This page object carries no returned or total_matched —
"how many are there" is not a question a tail can answer, because the store
keeps changing underneath it. Poll until dialogs comes back empty and
source_exhausted is true.
truncated and truncated_by appear only when the byte budget cut the page:
truncated: true and truncated_by: "bytes". A tail page cut by limit says
nothing extra, because its next_cursor already names the last row returned
and the next poll continues from there either way.
next_cursor is compound — <RFC 3339>|<Call-ID> — not a bare
timestamp. Dialogs can share an updated_at, so resuming from the
(updated_at, Call-ID) pair is what keeps a tie group split across a
page boundary from vanishing or arriving twice.
Pass it back
unmodified. A client that rebuilds a bare timestamp from a dialog's
updated_at instead falls back to the pre-compound strictly after
filter and loses or repeats the tied dialogs — that bare-timestamp
form is still accepted, so the mistake is silent rather than an error.
| occurs in neither an RFC 3339 timestamp nor a valid Call-ID
(RFC 3261 word), so the split is unambiguous.
source_exhausted is false while more dialog updates can still
arrive and true once the capture source is fully drained — the end of
an -I pcap replay, or a live capture that has hit its
--count/--duration/--autostop stop condition. sipnab keeps
serving MCP after that, so this is the flag to poll to learn a replay
has finished: stop when it turns true instead of polling forever.
// tail_dialogs { "limit": 1 }
{
"dialogs": [
{
"call_id": "[email protected]",
"state": "Completed",
"method": "INVITE",
"from_user": "⟦untrusted-capture-data⟧sipp⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧test⟦/untrusted-capture-data⟧",
"msg_count": 6,
"final_status_code": 200,
"duration_sec": 8.504,
"created_at": "2016-11-26T14:52:59.666393+00:00",
"updated_at": "2016-11-26T14:53:08.170676+00:00",
"timing": {
"pdd_ms": null,
"setup_ms": 4,
"retransmits": 0,
"duration_ms": 8499
},
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"input_origin": "wire"
}
],
"next_cursor": "2016-11-26T14:53:08.170676Z|[email protected]",
"source_exhausted": true,
"source_stopped_early": false,
"capture_identity": {
"node": "capture-01",
"instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 13,
"stream_generation": 2
}
}Waits until a filter selects at least one dialog, or until a deadline passes —
whichever happens first. One call replaces a tail_dialogs
loop in which every turn that finds nothing still costs a model call.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
filter |
string | A named alias or a filter DSL expression, exactly what list_dialogs accepts. validate_filter checks one without waiting. |
Required — the call fails. |
timeout_seconds |
u32? | Ceiling is --mcp-max-wait-seconds (60 by default). Higher clamps to it and sets timeout_clamped. 0 means "look once and answer". |
30 seconds. |
poll_interval_ms |
u32? | Floor is 100 ms. sipnab raises anything smaller and returns the effective value. | 500 ms. |
Returns { matched, stopped_because, dialogs, returned, total_matched, truncated, elapsed_ms, timeout_seconds, timeout_clamped, poll_interval_ms, polls, scans, source_exhausted, capture_identity }, where dialogs holds the
same summary rows list_dialogs returns, fencing included,
and is empty unless matched is true.
There is no page-size argument. This tool answers did it happen, and the rows
are evidence that it did rather than a page to work through — so it returns
what any list tool returns to a caller that named no limit: the default fifty
rows, cut to --mcp-max-rows when the operator set that lower.
list_dialogs with the same filter is the paging surface,
with the cursors and field projection that belong there. The response carries
reported either way, so a bounded page never reads as a smaller event than it
was.
A deadline that passes is an answer, not an error. It comes back as a
successful call carrying matched: false — because "the fault has not
reproduced" and "the tool broke" are different findings, and an agent has to
be able to tell them apart.
stopped_because names which of three endings this was, and the two negative
ones differ in whether asking again could ever help:
| Value | matched |
Meaning |
|---|---|---|
condition_met |
true |
The filter selected at least one dialog |
deadline |
false |
The wait ran out. Not yet — asking again may still find it |
source_exhausted |
false |
The capture source drained. Not ever, from this capture: the wait returns early rather than holding a slot to learn nothing |
The numbers this tool applies, and what moves each one:
-
Deadline default: 30 seconds (
DEFAULT_TIMEOUT_SECONDS). Used when the call names notimeout_seconds. -
Deadline ceiling: 60 seconds (
DEFAULT_MCP_MAX_WAIT_SECONDS, whichawait_conditionreads asDEFAULT_MAX_WAIT_SECONDS). The shipped value of--mcp-max-wait-seconds. The operator moves it, and a request above it is clamped rather than refused. -
Poll default: 500 milliseconds (
DEFAULT_POLL_INTERVAL_MS). Used when the call names nopoll_interval_ms. -
Poll floor: 100 milliseconds (
MIN_POLL_INTERVAL_MS). A smaller request meets that floor, so no caller can turn this into a spin loop, and the response carries the value actually used.
polls counts the looks and scans counts the looks that ran the filter. They
differ because both stores number their revisions, so a look at a capture that
has not moved resolves by comparing two integers rather than by re-running
the filter — an idle capture costs almost nothing to watch.
Nothing this call allocates outlives it. There is no subscription registry, no
per-client filter and no background task, which is why the deadline is not a
detail: it is what ends the server's obligation. --mcp-max-wait-seconds
bounds that obligation for the same reason --mcp-max-rows bounds a response,
and for a reason of its own — a waiting call occupies one of
--mcp-max-concurrent slots while producing nothing.
// await_condition { "filter": "state == Failed", "timeout_seconds": 20 }
{
"schema_version": 1,
"filter": "state == Failed",
"matched": true,
"stopped_because": "condition_met",
"dialogs": [
{
"call_id": "[email protected]",
"state": "Failed",
"method": "INVITE",
"from_user": "⟦untrusted-capture-data⟧sipp⟦/untrusted-capture-data⟧",
"to_user": "⟦untrusted-capture-data⟧test⟦/untrusted-capture-data⟧",
"msg_count": 4,
"duration_sec": 1.204,
"created_at": "2016-11-26T14:52:59.666393+00:00",
"updated_at": "2016-11-26T14:53:00.870676+00:00",
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546"
}
],
"returned": 1,
"total_matched": 1,
"truncated": false,
"elapsed_ms": 4512,
"timeout_seconds": 20,
"timeout_clamped": false,
"poll_interval_ms": 500,
"polls": 10,
"scans": 3,
"source_exhausted": false,
"capture_identity": {
"node": "capture-01",
"instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 41,
"stream_generation": 7
}
}Finds the other legs of one call — the far side of a B2BUA, SBC or PBX hop.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds — the leg to correlate from. | Required — the call fails. |
limit |
u32? | Ceiling is --mcp-max-rows (1000 by default). Higher clamps to it, 0 means the default. |
50 legs. |
Returns source_call_id, legs, total_matched, heuristic_only,
capture_identity, timing_clock and schema_version. A leg the source has no
relationship with answers with an empty legs array and total_matched: 0,
never an error. There is no cursor — raise limit to reach past a truncated
answer, and a call with more than 1000 correlated legs is a capture problem
rather than a paging one.
The example runs against
tests/pcap-samples/sip-rtp-g711.pcap,
whose two calls share an SDP origin:
// find_correlated { "call_id": "[email protected]" }
{
"schema_version": 1,
"source_call_id": "[email protected]",
"legs": [
{ "call_id": "[email protected]", "score": 90, "strategy": "sdp_origin",
"identifier_match": true, "observed_gap_ms": null }
],
"total_matched": 1,
"heuristic_only": false,
"capture_identity": {
"node": "capture-01",
"instance": "1d1a718cb5c33b7c52754-1",
"dialog_generation": 13,
"stream_generation": 2
},
"timing_clock": null // non-null only when a timing_heuristic leg is returned
}Read strategy, not just score. Two strategies score 100 and they are not
the same claim:
strategy |
What it means | Survives a B2BUA? |
|---|---|---|
session_id |
RFC 7989 Session-ID matched |
Yes, by design |
x_call_id |
A configured header matched (none configured by default) | Only if the SBC inserts it |
charging_vector_related_icid |
One leg's RFC 7315 related-icid names the other's icid-value
|
Yes — but only when the B2BUA chose to emit it (MAY) |
sdp_origin |
The RFC 8866 SDP origin tuple matched | Only if the SBC forwards SDP untouched |
charging_vector_icid |
Both legs carry the same RFC 7315 icid-value
|
Not by design: an ICID identifies one dialog, and a B2BUA is two |
via_branch |
Two INVITEs shared a Via branch | No: a new transaction gets a new branch |
timing_heuristic |
Same endpoint, close in time | Not an identifier at all |
The two P-Charging-Vector rows are one header and two different claims.
RFC 7315 section 4.6 says the ICID identifies a dialog, so a conformant B2BUA emits
a different icid-value on each side and charging_vector_icid is silent
across it — a match there means some intermediary copied a per-dialog
identifier onto a second dialog, which no RFC grants.
The parameter that
addresses the hop is related-icid
(RFC 7315 section 4.6.4.1), and it is optional.
Two limits worth knowing before you rely on either:
- The first proxy generates the icid (RFC 7315 section 5.6), so a leg arriving from an endpoint carries none and this is useless at the access edge.
-
RFC 7315 section 4.6.2.2 lets the next hop "modify the contents", which
RFC 7315 section 6.6 calls normal behavior, so unlike
Session-IDthere is no end-to-end constancy requirement at all.
Full argument:
docs/design/icid-correlation.md.
Neither strategy puts the matched value in the response. RFC 7315 section 4.6's own
suggested construction embeds the generating proxy's hostname or address in the
icid, so it is operator-internal rather than opaque, and strategy names the
strategy and nothing else.
identifier_match carries that distinction as a boolean, so a caller can filter
on it without knowing which names mean what. heuristic_only says whether
every returned leg came from a guess — a call tree built only from timing is a
hypothesis, and an agent that cannot tell presents it as a finding.
observed_gap_ms appears only for timing_heuristic, because there it is
the evidence: a 15 ms gap on a quiet box and a 1,900 ms gap on a busy SBC score
identically and mean very different things. On an identifier match it is null,
since the elapsed time is not why they matched.
A caveat worth stating plainly: most deployments emit no correlation header at all. Where none is present, the only strategy left is the bottom row, and on a busy SBC unrelated calls routinely share an endpoint inside its window.
Every leg of one call, as a tree.
The TUI has stitched B2BUA legs together since the x key, and nothing on the
MCP surface reached it. find_correlated answers one hop
from one Call-ID, so an agent facing a carrier call across an SBC and a PBX —
three or four dialogs deep — had to call it on each result and assemble the
graph itself. Multi-leg is the normal case in carrier work, and
get_dialog hands back a quarter of the call without saying so.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | Any leg of the call. The walk is symmetric, so naming the B-leg returns the same set rooted differently. A Call-ID the store does not hold fails with call_id '...' not found. |
Required — the call fails. |
limit |
u32? | Maximum legs including the root. Ceiling is --mcp-max-rows (1000 by default), higher clamps to it, 0 means the default. |
50 legs. |
The example runs against
tests/pcap-samples/sip-rtp-g711.pcap,
whose two calls share an SDP origin, with each leg's dialog summary trimmed to
its first three keys:
// get_call_tree { "call_id": "[email protected]" }
{
"schema_version": 1,
"root_call_id": "[email protected]",
"legs": [
{
"call_id": "[email protected]",
"depth": 0,
"parent_call_id": null,
"score": null,
"strategy": null,
"identifier_match": null,
"followed": true,
"dialog": { "call_id": "[email protected]", "state": "Completed", "method": "INVITE" }
},
{
"call_id": "[email protected]",
"depth": 1,
"parent_call_id": "[email protected]",
"score": 90,
"strategy": "sdp_origin",
"identifier_match": true,
"followed": true,
"dialog": { "call_id": "[email protected]", "state": "InCall", "method": "INVITE" }
}
],
"total_legs": 2,
"max_depth": 1,
"truncated": false,
"heuristic_edges": 0,
"total_messages": 10,
"first_activity": "2016-11-26T14:52:59.666393+00:00",
"last_activity": "2016-11-26T14:53:08.290927+00:00"
}Every leg past the root names the leg it came from (parent_call_id), how far
out it sits (depth), and the strategy and score of that one edge — the same
strategy vocabulary find_correlated tabulates. The root
carries null for all four, because the caller named it rather than any
strategy matching it.
The walk stops at a timing guess, and says so. Six of the seven correlation
strategies compare a value both legs carry, and the walk expands those
transitively. timing_heuristic links two INVITE dialogs that share an endpoint
address and started inside the leg-correlation window, so on a proxy carrying
ten calls a second every dialog sits within a guess of every other. Expanded
transitively that is not a call tree, it is the capture.
Such an edge still
appears in legs with followed: false, so an agent that wants the next hop
calls get_call_tree again rooted there and sees what it costs.
Read followed before you read the shape. A leaf appears for two opposite
reasons — a timing_heuristic edge the walk declined to expand, or a leg the
row cap cut the walk short of — and followed reflects what the walk actually
visited rather than what it intended to visit. A leg still sitting in the queue
when limit ended the walk reports followed: false, because claiming it
had searched that subtree would describe work nobody did. truncated says
the cap fired at all.
heuristic_edges counts the guesses in the whole tree. Zero means every link is
an identifier both legs carried. total_messages is the size of the merged
ladder the TUI's extended flow renders, and first_activity and
last_activity bracket the call rather than the root leg.
An unknown call_id fails with invalid_params rather than answering with an
empty tree. A call with no other legs and a Call-ID nobody holds are different
facts, and one empty answer for both collapses them — a lone dialog comes back
as a one-leg tree with total_legs: 1 and max_depth: 0.
"Why did this one work and that one not?"
Parameters: call_id_a and call_id_b (both string, both required) — two
Call-IDs the store holds. Either one unknown fails with invalid_params naming
it. No optional parameters, and passing the same Call-ID twice is legal and
answers with an empty differences.
Each side reports call_id, state, final_status_code, msg_count,
methods (sorted) and hints. differences names the fields that differ, so
you are not diffing two objects by eye. The example compares the two calls in
tests/pcap-samples/sip-rtp-g711.pcap:
// compare_dialogs { "call_id_a": "[email protected]", "call_id_b": "[email protected]" }
{
"schema_version": 1,
"a": { "call_id": "[email protected]", "state": "Completed", "final_status_code": 200,
"msg_count": 6, "methods": ["ACK", "BYE", "INVITE"], "hints": [] },
"b": { "call_id": "[email protected]", "state": "InCall", "final_status_code": 200,
"msg_count": 4, "methods": ["ACK", "INVITE"], "hints": [] },
"differences": ["state", "msg_count", "methods"]
}final_status_code matches on both sides here and so stays out of
differences — the second call simply never sent a BYE.
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.
Per-call diagnostic report for one Call-ID. Backed by
output::generate_call_report — same content as --call-report.
| 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. |
Required — the call fails. |
format |
string? |
"json", "markdown" or "text". Anything else fails with unknown format 'x', expected json|markdown|text. |
"json". |
"json" answers with the structured object below. "markdown" and "text"
answer with one text block holding the rendered report, byte-identical to what
render_ladder produces for the same dialog. All three append
the provenance note as a second content block. The two rendered formats also
carry the INCOMPLETE RUN block described under
get_capture_report when the capture was not read in
full. "json" carries source_exhausted and source_stopped_early as fields
instead.
The example runs against tests/pcap-samples/sip-rtp-g711.pcap:
// get_dialog_report { "call_id": "[email protected]" }
{
"schema_version": 1,
"call_id": "[email protected]",
"state": "Completed",
"method": "INVITE",
"final_status_code": 200,
"final_status_reason": "OK",
"from": "sipp",
"from_display": "PCMU/8000",
"to": "test",
"to_display": "test",
"msg_count": 6,
"duration_sec": 8.504,
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b94678546",
"timing": {
"retransmits": 0,
"setup_ms": 4,
"teardown_ms": 0,
"trying_delay_ms": 0
},
"diagnosis": {
"hints": [
"RTP flowed 10.0.2.15:27942 -> 10.0.2.20:6000 only (SSRC 0x343da99b). No reverse media flow detected."
],
"nat_mismatch": false,
"no_media": false,
"one_way_audio": true
},
"sdp_timeline": [ /* the same rows get_sdp_timeline returns */ ],
"streams": [ /* the same rows rtp_stats returns, without mos */ ]
}A termination block appears beside these fields on a call that said why it
ended — the same object, from the same assembler, that
triage_call documents on the core bundle page. It is absent from the example
because this sample's BYE carries no Reason header and no vendor
equivalent, which is the honest rendering: sipnab drops the block, and never
sends null, when nothing on the wire named a cause.
This report bundles what triage_call,
get_sdp_timeline and rtp_stats answer
separately, so one call replaces three when you already know which call to read.
Its streams rows omit mos and mos_grounded — ask rtp_stats when the
question is audio quality rather than what the call did.
Unlike the summary rows elsewhere, from and to here arrive unfenced, and
the trailing provenance note explains why: a rendered report interleaves
sipnab's diagnosis with header values the sender wrote, and fencing the whole
document would tell an agent to distrust the analysis as well.
Single SIP message at a given zero-based index.
| Name | Type | Legal values | If omitted |
|---|---|---|---|
call_id |
string | A Call-ID the store holds. | Required — the call fails. |
index |
u32 |
0 to msg_count - 1 for that dialog. |
Required — the call fails. |
Where an index comes from, because the number means nothing on its own:
msg_count on any list_dialogs row is the count, so the last
valid index is msg_count - 1. get_dialog reports each page's
first_index, so the Nth row of that page sits at first_index + N, and every
lint_dialog finding reports the message_index it fired on.
An index at or past the end fails with index 999 out of range for dialog with 7 messages — the count is in the message, so a caller that guessed corrects
itself without another round trip.
Returns one message in the same shape get_dialog uses for a
messages row, plus the provenance note as a second content block. The
difference is the fencing: this tool wraps from, to, contact, sdp, ua,
reason, malformed and extension_headers, and leaves call_id, addresses,
ports, method, status_code, cseq and timestamps verbatim so they pass into
the next call.
// get_message { "call_id": "[email protected]", "index": 0 }
{
"schema_version": 1,
"call_id": "[email protected]",
"method": "INVITE",
"is_request": true,
"cseq": { "method": "INVITE", "number": 1 },
"from": "⟦untrusted-capture-data⟧\"PCMU/8000\" <sip:[email protected]:5060>;tag=1⟦/untrusted-capture-data⟧",
"to": "⟦untrusted-capture-data⟧test <sip:[email protected]:5060>⟦/untrusted-capture-data⟧",
"contact": "⟦untrusted-capture-data⟧sip:[email protected]:5060⟦/untrusted-capture-data⟧",
"sdp": "⟦untrusted-capture-data⟧v=0\no=- 42 42 IN IP4 10.0.2.20\ns=-\nc=IN IP4 10.0.2.20\nt=0 0\nm=audio 6000 RTP/AVP 0\na=rtpmap:0 PCMU/8000\na=recvonly\n⟦/untrusted-capture-data⟧",
"src": "10.0.2.20",
"src_port": 5060,
"dst": "10.0.2.15",
"dst_port": 5060,
"transport": "UDP",
"timestamp": "2016-11-26T14:52:59.666393+00:00",
"frame": "tests/pcap-samples/sip-rtp-g711.pcap#0@db88659b946785