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.

  1. [server] Do Step 0 and create the user (2B step 2, without the setcap).

  2. [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.target
    
    sudo systemctl daemon-reload && sudo systemctl enable --now sipnab-mcp
    
  3. [server] Open udp/9063 from the proxies’ addresses in the host firewall.

  4. [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 siptrace module with duplicate_uri pointed at sip:capture01.example.net:9063 and HEP mode enabled. See the siptrace docs for the handful of modparams.

  5. [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 — a no packets for 30s warning means the HEP sender isn’t reaching the -L port (firewall, wrong port, wrong host).

  6. [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_id and a second trace() 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.

  1. [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
    
  2. [server] Install nginx and certbot:

    sudo apt install nginx certbot python3-certbot-nginx
    

    Then issue the certificate. certbot needs capture.example.com already 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
    
  3. [server] Site config (/etc/nginx/sites-available/sipnab-mcp, symlink into sites-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 off is load-bearing: the streamable-HTTP transport answers with text/event-stream, and buffering proxies stall it.

  4. [server] Firewall: open tcp/443 only; 8731 stays closed to the outside.

  5. [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
    
  6. [laptop] Verify with claude mcp list; on failure, test the path layer by layer: curl against https://capture.example.com/mcp from 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:

  1. [each server] Any persistent wiring above (2B shown here).

  2. [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"
    done
    

    Then confirm all three registered:

    claude mcp list
    
  3. [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-name line is the same wiring the 2A/2B/2C sections document, applied three times. The tunnel, NAT and ProxyJump commands 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-name and 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_correlated returning zero legs is not a failure here, it is the finding.
  • A call that went back-to-back gets a new Call-ID, and strategy names 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:

strategyCrosses a B2BUA?What a match is worthidentifier_match
session_idYes, by design (RFC 7989)An identifier both ends agreed ontrue
x_call_idOnly if the box inserts itAn identifier, by vendor conventiontrue
charging_vector_related_icidYes — 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 ittrue
sdp_originOnly if the box forwards SDP untouchedAn identifier of the MEDIA session, not the dialogtrue
charging_vector_icidNot by design — an ICID names ONE dialog and a B2BUA is twoAn intermediary carried a per-dialog identifier onto a second dialogtrue
via_branchNo — a B2BUA opens a new transactionSame transaction, so: same hop, not across onetrue
timing_heuristicNoA guess from endpoint overlap and elapsed timefalse

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:

FieldRead it as
strategyWhich of the seven above matched, per leg
identifier_matchtrue: two ends agreed on an identifier. false: a guess
heuristic_onlytrue: every returned leg rests on a guess, so the whole tree is a hypothesis
timing_clockPresent 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.nodeDoes not
capture_status, list_dialogs, tail_dialogs, find_correlated, search_messages, search_by_timeget_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 different icid-value on each side, and plain icid-value equality does not solve the re-origination case. What crosses that hop is the separate related-icid parameter (§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 same icid-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)
SetupRegister N servers with the client--hep-send on each node into one --hep-listen collector
Packet dataNever leaves the nodeConcentrates on the collector
CorrelationAgent joins the answersOne store, find_correlated runs unchanged
CostMore round trips, one per nodeBandwidth, 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.