Docs MCP Across an Estate
MCP Across an Estate
Several SIP servers feeding one capture host, reaching sipnab from outside the network, one agent holding many capture hosts, and following one call across an SBC, a proxy and a PBX.
On this page
MCP deployment gets one sipnab answering one agent. This page is what comes after that: several SIP servers feeding one capture host, an agent that has to reach it from somewhere else, one agent holding several capture hosts at once, and the hardest of them — following a single call as it crosses an SBC, a proxy and a PBX that each see a different Call-ID.
These four build on each other in that order, and each one names the SINGLE sipnab it starts from, so you can stop at whichever answers your question. Every scenario assumes you have already done Step 0.
Collect captures from several SIP servers in one place
Central capture host fed by HEP.
You usually can’t (and shouldn’t) run a packet-capturing debugger on every production SIP proxy. Instead the proxies mirror signaling to one capture host via HEP — OpenSIPS, Kamailio, and FreeSWITCH all speak it — and one sipnab MCP service sees calls from the whole estate. The HEP listener is a plain UDP socket: no capture privileges, no setcap, fully unprivileged.
-
[server] Do Step 0 and create the user (2B step 2, without the
setcap). -
[server] Install
/etc/systemd/system/sipnab-mcp.service— this variant listens for HEP on udp/9063 and serves MCP on loopback (pair it with the 2C tunnel; for the 2B token shape instead, use its--mcp-bind/token/allowed-host lines):[Unit] Description=sipnab MCP server (HEP listener) After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=/usr/local/bin/sipnab --mcp -N --mcp-transport http \ --mcp-bind 127.0.0.1:8731 \ -L 0.0.0.0:9063 --hep-parse User=sipnab Group=sipnab NoNewPrivileges=true ProtectSystem=strict PrivateTmp=true Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.targetsudo systemctl daemon-reload && sudo systemctl enable --now sipnab-mcp -
[server] Open udp/9063 from the proxies’ addresses in the host firewall.
-
[proxy] Point each proxy’s HEP mirror at the capture host.
OpenSIPS (3.x) —
opensips.cfg:loadmodule "proto_hep.so" loadmodule "tracer.so" modparam("tracer", "trace_id", "[sipnab]uri=hep:capture01.example.net:9063;version=3;transport=udp")and in the main route (traces the dialog’s SIP both ways):
trace("sipnab", "t", "sip");Kamailio — the
siptracemodule withduplicate_uripointed atsip:capture01.example.net:9063and HEP mode enabled. See the siptrace docs for the handful of modparams. -
[server] Verify HEP is arriving. Place a test call through a proxy, then:
curl -sS http://127.0.0.1:8731/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'then watch
journalctl -u sipnab-mcp -f— ano packets for 30swarning means the HEP sender isn’t reaching the-Lport (firewall, wrong port, wrong host). -
[laptop] Wire up exactly as scenario 2C steps 3–4 (or 2B steps 8–9 for the token shape). Then ask across the estate: “search all proxies’ traffic for Call-ID X and render the ladder.”
Coexisting with Homer (or any observability host)
A Homer / heplify-server box is a natural home for sipnab — the HEP plumbing from the proxies already exists. Two things to arrange:
- Port: heplify-server owns udp/9060, so sipnab takes its own
(udp/9063 above) and each proxy mirrors to both destinations —
OpenSIPS: add a second
trace_idand a secondtrace()call; Kamailio: a second duplicate destination. If a sender can’t dup, put a small UDP fan-out (e.g. socat) in front. - Budget: cap sipnab’s footprint with
[limits](below) so the box’s primary tenant keeps its headroom.
Anything else on the host — an OpenTelemetry collector, Prometheus, etc. — is simply a neighbor process. sipnab neither speaks OTLP nor conflicts with it.
Reach sipnab from outside your network
Internet-exposed endpoint with nginx TLS in front.
When agents connect from outside your network and SSH isn’t an option: keep sipnab on loopback and let nginx own the public endpoint.
-
[server] Build the loopback service with a token: 2B steps 1–3, then a unit whose ExecStart binds loopback but keeps auth and allows the public hostname (nginx forwards the client’s
Host:header):ExecStart=/usr/local/bin/sipnab --mcp -N --mcp-transport http \ --mcp-bind 127.0.0.1:8731 \ --mcp-token-file /etc/sipnab/mcp.token \ --mcp-allowed-host capture.example.com \ -d eth0 -
[server] Install nginx and certbot:
sudo apt install nginx certbot python3-certbot-nginxThen issue the certificate. certbot needs
capture.example.comalready resolving to this host, asks for a contact address, and rewrites the nginx config in place — run it on its own so you can answer it:sudo certbot --nginx -d capture.example.com -
[server] Site config (
/etc/nginx/sites-available/sipnab-mcp, symlink intosites-enabled,sudo nginx -t && sudo systemctl reload nginx):server { listen 443 ssl; server_name capture.example.com; ssl_certificate /etc/letsencrypt/live/capture.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/capture.example.com/privkey.pem; location /mcp { proxy_pass http://127.0.0.1:8731; proxy_set_header Host $host; proxy_buffering off; # SSE responses must stream proxy_read_timeout 3600s; } }proxy_buffering offis load-bearing: the streamable-HTTP transport answers withtext/event-stream, and buffering proxies stall it. -
[server] Firewall: open tcp/443 only; 8731 stays closed to the outside.
-
[laptop] Token copy as in 2B step 8, then:
claude mcp add --transport http \ --header "Authorization: Bearer $(cat ~/.config/sipnab/capture.token)" \ sipnab-prod https://capture.example.com/mcp -
[laptop] Verify with
claude mcp list; on failure, test the path layer by layer: curl againsthttps://capture.example.com/mcpfrom the laptop, then the loopback curl (2B step 6) on the server.
Query many capture hosts from one agent
Run scenario 2B, 2C, or 4 on each capture host, then give each its own entry in one client config:
-
[each server] Any persistent wiring above (2B shown here).
-
[laptop] Register them all — the loop is one command and does nothing unless you take the whole of it:
# Run all of these, in order. for h in nyc1 chi1 lax1; do claude mcp add --transport http \ --header "Authorization: Bearer $(cat ~/.config/sipnab/$h.token)" \ "sipnab-$h" "https://$h.example.net/mcp" doneThen confirm all three registered:
claude mcp list -
[laptop] Ask cross-fleet questions — tool names are namespaced per server (
mcp__sipnab-nyc1__list_dialogs,mcp__sipnab-chi1__list_dialogs, …), so the agent can fan out:the caller says the 14:02 UTC call dropped; find its Call-ID on all three sites and compare the ladders.
Follow one call across an SBC and its PBXes
Federated tracing. Each node keeps its own capture; the agent does the joining.
flowchart LR
AG["agent or script (laptop)"]
SBC["sipnab on the SBC<br/>node: sbc-edge-1"]
PX["sipnab on the proxy<br/>node: proxy-1"]
P1["sipnab on the PBX<br/>node: pbx-1"]
AG -->|"1. find_correlated(access leg)"| SBC
SBC -.->|"a Call-ID, and the strategy that tied it"| AG
AG -->|"2. get_dialog(whatever the SBC named)"| PX
AG -->|"3. and again, one hop further in"| P1
The previous section registers several servers. This one is about the ORDER to ask them in, and about what the answer is worth.
Wire up three nodes from one laptop
Three hops means three MCP servers and one client. Nothing about the wiring is different from a single node — you register each one, and the client namespaces the tools per server. What changes is only how the laptop reaches each server.
The laptop reaches all three directly. Nothing between them, so use the 2C shape on each node — a loopback bind plus an SSH tunnel per node, which needs no token and opens no port:
# Run all of these, in order.
ssh -f -N -L 8811:127.0.0.1:8731 sbc-edge-1.example.net
ssh -f -N -L 8812:127.0.0.1:8731 proxy-1.example.net
ssh -f -N -L 8813:127.0.0.1:8731 pbx-1.example.net
The local port differs per node; the remote port does not, because each node binds its own loopback. Then register the three:
# Run all of these, in order.
claude mcp add --transport http sipnab-sbc http://127.0.0.1:8811/mcp
claude mcp add --transport http sipnab-proxy http://127.0.0.1:8812/mcp
claude mcp add --transport http sipnab-pbx http://127.0.0.1:8813/mcp
Give each node a --node-name that matches how you think of it, because that
string is what comes back in capture_identity.node and it is the only thing
that attributes a fact to a box:
ExecStart=/usr/local/bin/sipnab --mcp -N --mcp-transport http \
--mcp-bind 127.0.0.1:8731 --node-name sbc-edge-1 -d eth0
The nodes are behind NAT. The tunnel commands above still work, because SSH dials outward from the laptop and the tunnel carries the MCP traffic back — no inbound rule, no port forward, no change to the MCP wiring. What does not work is 2B (HTTP plus a token) against a node behind NAT: there is no address to put in the URL. If SSH itself cannot reach the node, the node has to reach out instead, which means the HEP shape — Collect captures from several SIP servers in one place — and then you have one server, not three.
A jump host sits in the way. Put the hop in your SSH config rather than in the sipnab wiring, and every command above still works word for word:
Host sbc-edge-1.example.net proxy-1.example.net pbx-1.example.net
ProxyJump bastion.example.net
For the 2A stdio shape, ssh -J bastion.example.net sbc-edge-1.example.net /usr/local/bin/sipnab --mcp -N … does the same thing inline.
Not measured. Everything in this subsection above the
--node-nameline is the same wiring the 2A/2B/2C sections document, applied three times. The tunnel, NAT andProxyJumpcommands were not run against three real hosts for this page: there was one machine available. The behavior that was measured — on three sipnab servers on one host, each with its own--node-nameand its own capture — is everything below, and the transcripts say which build produced them.
Ask the SBC first
The SBC is the only box that saw both sides of the call, so its
find_correlated result names the core-side Call-ID to look up next. Querying
the PBXes first means guessing which one took the call, and usually means asking
all of them.
That ordering also matters for a reason the section on performance makes concrete: server-side query time is under a millisecond, while each agent round trip costs seconds. Following one pointer beats fanning out.
Ask it first even when you expect the box to stay a proxy on this call. If it
did, find_correlated returns nothing and you carry the same Call-ID inward,
which costs one query; if it did not, you now hold the identifier the next hop
knows the call by. The next section is about telling those two apart.
Read what matched, because the topology is not fixed
Here is the thing that makes this hard, and it is not a corner case: an SBC or proxy may run back-to-back on one call and stay a proxy on the next. Whether it re-originates depends on the call type, on which endpoints take part, and on configuration that can change while you are watching. So you cannot pick a correlation strategy in advance, and any procedure that says “our SBC is a B2BUA, therefore look for X” is right until the day it quietly is not.
The way out is to stop predicting and start reading. You ask the same question
every time — find_correlated on the leg you have — and the answer tells you
which topology you were in:
- A call that stayed in proxy mode keeps its Call-ID across the hop.
Nothing correlates, because there is nothing to correlate to: the same
identifier is simply present on the next node.
find_correlatedreturning zero legs is not a failure here, it is the finding. - A call that went back-to-back gets a new Call-ID, and
strategynames what tied the two together — or admits to a guess.
Every leg carries the strategy that matched it, and the strategies are not degrees of one thing:
strategy | Crosses a B2BUA? | What a match is worth | identifier_match |
|---|---|---|---|
session_id | Yes, by design (RFC 7989) | An identifier both ends agreed on | true |
x_call_id | Only if the box inserts it | An identifier, by vendor convention | true |
charging_vector_related_icid | Yes — but only when the B2BUA chose to emit it (RFC 7315 makes it a MAY) | The intermediary declared the link itself, in the parameter the RFC provides for it | true |
sdp_origin | Only if the box forwards SDP untouched | An identifier of the MEDIA session, not the dialog | true |
charging_vector_icid | Not by design — an ICID names ONE dialog and a B2BUA is two | An intermediary carried a per-dialog identifier onto a second dialog | true |
via_branch | No — a B2BUA opens a new transaction | Same transaction, so: same hop, not across one | true |
timing_heuristic | No | A guess from endpoint overlap and elapsed time | false |
Listed in evaluation order, which is descending score. The loop stops at the first match, then sorts by score, so a leg that satisfies two strategies comes back as the stronger one.
Four fields decide how much of that tree you should act on:
| Field | Read it as |
|---|---|
strategy | Which of the seven above matched, per leg |
identifier_match | true: two ends agreed on an identifier. false: a guess |
heuristic_only | true: every returned leg rests on a guess, so the whole tree is a hypothesis |
timing_clock | Present only when the answer contains a time-based match. Absent means no leg needed one |
sipnab omits timing_clock rather than sending a healthy-looking default when
every leg matched on an identifier — a clock reading beside an identifier match
invites you to weigh one against the other, and they do not trade off.
Check the clock before believing a timing_heuristic match across nodes.
The window is two seconds unless --leg-correlation-window says otherwise, and
the failure is silent in both directions: a fast clock misses legs that belong
together, a slow one pulls unrelated calls in.
timing_clock reports the answering node’s NTP discipline at the moment of the
query (synchronized, max_error_us, est_error_us, available), and
capture_health reports the same under clock for any node you want to check
without running a correlation. synchronized: false means treat the tree as a
hypothesis. synchronized: true with a max_error_us approaching the two-second
window means the same thing — the flag says a time daemon is disciplining the
clock, not that the clock is accurate to within the window you are matching in.
Compare the two answers to the same question
Both transcripts below are real output from sipnab 0.5.95, from the script in
Drive it from a script, against sipnab servers reading
tests/pcap-samples/. Same command shape, opposite evidence.
The hop stayed a proxy. Zero legs, and the Call-ID turns up unchanged one node in — which is what proxy mode looks like, not what a lost call looks like:
[proxy] sipnab 0.5.97 node=proxy-1
[pbx] sipnab 0.5.97 node=pbx-2
[proxy] 0 leg(s) correlated to [email protected]
(nothing correlated: a call that stayed in proxy mode keeps its
Call-ID, so ask the other nodes for the SAME id.)
[pbx] holds [email protected] (node=pbx-2, state=Completed, 11 msgs)
The hop went back-to-back. A leg comes back, and the strategy names a guess from a 3 ms gap and a shared endpoint — no identifier crossed the box:
[sbc] sipnab 0.5.97 node=sbc-edge-1
[sbc] 1 leg(s) correlated to [email protected]:5060
[email protected]
via timing_heuristic [GUESS] score 50, gap 3ms
!! every leg was a timing guess, not an identifier match.
clock on sbc: synchronized=True max_error_us=295000
The window is 2s. Skew larger than that invents legs and hides legs.
Read the second one carefully, because it is the case operators act on wrongly.
A leg came back. It has a Call-ID, a score, and a plausible-looking 3 ms gap. It
is still a guess: identifier_match is false and heuristic_only is true.
Two unrelated calls through the same SBC inside the same window produce output
that looks exactly like this.
Now read max_error_us, and do not read it once. It is a live reading, not a
constant, and on this one host it has reported 0.295 s (the run above),
1.944 s and 2.38 s — an order of magnitude apart, all three while
saying synchronized=True, and the last of them past the 2 s correlation
window entirely. At the high end the clock could account for the entire match
on its own; at the low end it could not. Nothing in the output tells you which
run you are looking at except the number itself, so read yours each time. A
figure quoted from another run — including the ones on this page — says nothing
about your box.
Check what federation cannot prove
If the box emits no Session-ID and no X-Call-ID, and re-originates SDP, then
nothing in the signaling proves the two legs are one call. The honest answer
is that they may be, and sipnab says so rather than drawing a tree on a timing
guess. Configuring the box to insert a correlation identifier is the fix —
Choose a correlation identifier covers the
options. sipnab watches a wire and cannot add one, because the SBC forwards its
own message to the far side regardless of what sipnab saw.
Attribution needs one more step than you might expect. Not every response
carries capture_identity — the whole-store answers do, the per-dialog ones do
not. Measured against 0.5.98:
Carries capture_identity.node | Does not |
|---|---|
capture_status, list_dialogs, tail_dialogs, find_correlated, search_messages, search_by_time | get_dialog, get_dialog_report, triage_call, capture_health |
So call capture_status once per node and hold the name, rather than expecting
every answer to carry it. It matters: “answered 407” is incomplete until you
know which box answered, and with three servers registered the agent has three
places that sentence could have come from. Per-message answers carry a frame
pointer instead (tests/pcap-samples/sip-proxy.pcap#0@a57665bcdb62f03a), which
names the capture the message came out of rather than the node.
Choose a correlation identifier
The transcript above ends in a guess. The durable fix is not a better guess, it is an identifier that survives the hop. Three options get raised, and they are not equally good.
1. Configure the SBC, proxy and PBX to insert one. Do this. RFC 7989
Session-ID exists for exactly this problem: it is a pair of UUIDs, one
contributed by each endpoint, and each side reports the pair from its own point
of view, so it survives a box that rewrites Call-ID, From tag and Via. sipnab
already reads it — src/sip/session_id.rs parses the header, intersects the
non-nil halves rather than comparing strings (the halves swap direction across a
B2BUA, so string equality would find nothing and look exactly like “unrelated
calls”), and correlation on it reports strategy: session_id with
identifier_match: true. Nothing on the sipnab side needs changing. The work is
one config line per box, and it converts every future trace from a guess into
evidence.
2. Use an identifier the network already carries. This is what x_call_id,
sdp_origin and the two P-Charging-Vector strategies are: sipnab is already
looking for them, so if your SBC emits X-Call-ID by vendor convention,
forwards SDP untouched so the RFC 8866 origin tuple survives, or sits in a
carrier network where RFC 7315 charging headers are on the wire anyway, you get
an identifier match today with no configuration at all. Check before you plan
work — run find_correlated on a known B2BUA call and see what strategy comes
back. Three caveats worth knowing: sdp_origin identifies the media session
rather than the dialog, so it goes away the moment anything re-originates SDP;
via_branch, though it is an identifier match, never crosses a B2BUA, because a
back-to-back user agent opens a new transaction by definition; and
charging_vector_icid is the weaker of the two charging strategies for the same
reason the note below gives — a conformant B2BUA gives each of its two dialogs
its own icid, so plain equality across one is a vendor behavior rather than
something the RFC promises.
3. Have sipnab compute its own identical id on each node. Do not. The
appeal is obvious — no config change on any SIP box — and it is the wrong trade.
sipnab is a passive wire observer: it cannot inject a header, so “the same id on
both nodes” would have to be computed from what each node independently sees.
Across a re-originating B2BUA there is no guaranteed invariant to compute it
from. Call-ID, From tag, Via branch, Contact and usually the SDP are all
legitimately new on the far side — that is what re-origination means, not a
defect to work around. Any id derived from the remainder is a heuristic dressed
as an identifier, and that is worse than the labeled heuristic already in the
output: timing_heuristic announces itself as a guess and sets
heuristic_only, whereas a computed id would arrive looking like proof and
correlate two unrelated calls with no field left to catch it.
That is the same judgement this codebase makes elsewhere. sipnab records the
RFC 7329 legacy Session-ID form as an interop notice, not a violation,
because a single message cannot distinguish a legacy implementation from a
broken one — so the finding states what arrived on the wire and declines to
assert which. A computed cross-node id would be the opposite move: asserting an
identity the wire never established.
For IMS and carrier readers: sipnab now reads
P-Charging-Vector, in two strategies, and the difference between them decides whether it helps you. RFC 7315 §4.6 says the ICID identifies a dialog, and a B2BUA is two dialogs — so a conformant B2BUA emits a differenticid-valueon each side, and plainicid-valueequality does not solve the re-origination case. What crosses that hop is the separaterelated-icidparameter (§4.6.4.1), whose value is the icid of the original dialog, and which the B2BUA MAY emit rather than must. So:
charging_vector_related_icid(95) — the intermediary declared the link. This is the one that crosses a B2BUA, and only when the box chose to send it.charging_vector_icid(85) — the two legs carry the sameicid-value. Useful where it happens, and it means some intermediary copied a per-dialog identifier onto a second dialog; no RFC grants that.Two further limits, both from the RFC rather than from the implementation. The first proxy generates the icid (§5.6), so the leg arriving from an endpoint carries none and this is useless at the access edge. And §4.6.2.2 permits the next hop to “modify the contents”, which §6.6 calls normal behavior — there is no end-to-end constancy requirement of any kind, so this is not a substitute for
Session-ID. Full argument, including what is still unverified:docs/design/icid-correlation.md.
Choose between federated and centralised
Both work, and the choice is about where packet data lives rather than which is newer.
| Federated (this section) | Centralised (HEP) | |
|---|---|---|
| Setup | Register N servers with the client | --hep-send on each node into one --hep-listen collector |
| Packet data | Never leaves the node | Concentrates on the collector |
| Correlation | Agent joins the answers | One store, find_correlated runs unchanged |
| Cost | More round trips, one per node | Bandwidth, and a PII decision |
Centralising needs no new code. See Collect captures from several SIP servers in one place. It is also what Homer does, at the scale of a whole enterprise system; sipnab is one binary, and it can feed Homer rather than replace it.