-
Notifications
You must be signed in to change notification settings - Fork 0
MCP
sipnab can run as a Model Context Protocol server, exposing its read-only analysis surface (dialogs, streams, RTP quality, diagnostic hints, security findings, call reports) as tools that an AI agent — Claude Code, Claude Desktop, or any MCP-capable client — can call to debug captures interactively.
MCP is a fourth output mode alongside the existing TUI, -N CLI, and
--json modes. The same parser, dialog state machine, RTP store, and
diagnostic engine drive every output. Switching to MCP gives a remote or
local agent the ability to query live captures in natural language,
without you having to memorize CLI flags.
MCP support is feature-gated. Build with the mcp feature for the stdio
transport, or mcp-http for the HTTP transport, e.g.
cargo build --release --no-default-features --features native,hep,api,mcp,mcp-httpThe default build does not include mcp — operators who'll never expose
the MCP surface pay zero binary size for it. Run sipnab --version to
see the features a binary was compiled with.
The simplest way to drive sipnab from a local agent is to replay a pcap.
Stdio is the default transport, so no --mcp-transport is needed:
sipnab --mcp -N -I capture.pcapTo serve a live capture instead, run as root or grant the binary
CAP_NET_RAW:
sudo sipnab --mcp -N -d eth0--mcp requires -N/--no-tui: stdout is the JSON-RPC wire, so the
TUI and stdout-writing flags (--json, --report, …) are rejected. This
is the one invariant to remember; every example below carries -N for
that reason. No token is needed for stdio — it is a private pipe between
client and server.
Add this server to your MCP client. For Claude Desktop / Claude Code, the config block looks like:
{
"mcpServers": {
"sipnab": {
"command": "sipnab",
"args": ["--mcp", "-N", "-I", "/path/to/capture.pcap"]
}
}
}When the agent runs on a different host, switch to the HTTP transport:
sipnab --mcp -N --mcp-transport http \
--mcp-bind 127.0.0.1:8731 \
--mcp-token-file /etc/sipnab/mcp.token \
-I capture.pcapThe agent then connects to https://your-host/mcp with a Bearer <token> header.
- The default bind is loopback. Non-loopback binds must supply a
credential — either a static token (
--mcp-token/--mcp-token-file/SIPNAB_MCP_TOKEN) or a signing key for self-describing signed bearer tokens (--mcp-signing-key/--mcp-signing-key-file/SIPNAB_MCP_SIGNING_KEY); otherwise sipnab refuses to start (D18). -
--mcp-token-fileis preferred over--mcp-token/SIPNAB_MCP_TOKEN(no token inpsoutput or unit files). - For TLS, terminate it in nginx in front of sipnab. Bind sipnab to
127.0.0.1:8731and let nginx handle the public 443 endpoint.
Non-loopback binds require a bearer token. Generate one once — the middle command overwrites any token already in that file, and every agent still configured with the old value is then locked out:
# Run all of these, in order.
sudo mkdir -p /etc/sipnab
head -c 32 /dev/urandom | base64 | sudo tee /etc/sipnab/mcp.token >/dev/null
sudo chmod 600 /etc/sipnab/mcp.tokenGive the client the token:
sudo cat /etc/sipnab/mcp.tokenand configure it as a bearer token for http://capture01.example.net:8731.
The HTTP transport refuses requests whose Host header isn't in its
allowlist. The default set is localhost, 127.0.0.1, ::1. When
clients reach sipnab via a hostname or non-loopback IP, add it to the
allowlist (repeatable); otherwise rmcp returns
403 Forbidden: Host header is not allowed:
sipnab --mcp -N --mcp-transport http \
--mcp-bind 0.0.0.0:8731 \
--mcp-token-file /etc/sipnab/mcp.token \
--mcp-allowed-host capture.example.com \
--mcp-allowed-host 203.0.113.7 \
-I capture.pcapThe literal * disables host checking entirely — only do that behind a
network-level source-IP allowlist as the substitute defense.
/etc/systemd/system/sipnab-mcp.service (a packaged variant ships in
packaging/sipnab.service),
here fed by a HEP listener — common on a capture host:
[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 \
--mcp-token-file /etc/sipnab/mcp.token \
-L 0.0.0.0:9060 --hep-parse
User=sipnab
Group=sipnab
NoNewPrivileges=true
ProtectSystem=strict
ReadOnlyPaths=/etc/sipnab
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target# Run all of these, in order.
sudo systemctl daemon-reload
sudo systemctl enable --now sipnab-mcpThe HEP listener needs no capture privileges (plain UDP socket), so the
unit runs as an unprivileged user. For live interface capture instead of
HEP, grant the binary CAP_NET_RAW:
sudo setcap cap_net_raw+ep /usr/local/bin/sipnabThe v0.5 sipnab MCP tool surface. All tools are read-only; all responses are bounded by default (HARD_LIMIT = 1000).
| Tool | Parameters | Returns |
|---|---|---|
list_dialogs |
filter?, limit?
|
Dialog summaries with optional alias / DSL filter |
get_dialog_report |
call_id, format?
|
Structured per-call report (JSON / Markdown / text) |
find_problems |
kinds?, limit?
|
Dialogs matching one or more diagnostic alias names |
get_dialog |
call_id, max_messages?, cursor?
|
Paginated dialog with full SIP messages |
get_message |
call_id, index
|
Single SIP message at a given index |
render_ladder |
call_id, format?
|
Call-flow ladder (Markdown / text) |
rtp_stats |
call_id |
Per-stream RTP quality + media diagnosis |
search_messages |
query, limit?
|
Substring search across method/From/To/UA/body |
tail_dialogs |
cursor?, limit?
|
Cursor-based incremental dialog fetch |
security_findings |
kinds?, since?, limit?
|
Recent scanner / fraud / digest / reg-flood alerts |
stats |
-- | Aggregate counters (dialog_count, stream_count, etc.) |
Returns dialog summaries from the live capture store.
| Name | Type | Description |
|---|---|---|
filter |
string? | Diagnostic alias name (problems, slow-setup, short-calls, one-way, nat-issues, codec-asym, ptime-asym, payload-asym, duration-asym, late-media) or a raw filter DSL expression. |
limit |
u32? | Max dialogs to return. Default 50, max 1000. |
Returns — array of DialogSummary:
Per-call diagnostic report for one Call-ID. Backed by
output::generate_call_report — same content as --call-report.
| Name | Type | Description |
|---|---|---|
call_id |
string | Required. |
format |
"json" | "markdown" | "text" | Default "json". |
JSON output is a structured object; Markdown/text are returned as a
single text content. Unknown call_id returns invalid_params (-32602).
Convenience wrapper over list_dialogs that ORs each named alias.
| Name | Type | Description |
|---|---|---|
kinds |
string[]? | Aliases to OR. Default ["problems"]. |
limit |
u32? | Default 50, max 1000. |
Unknown aliases return invalid_params (-32602) with the offending name.
Paginated dialog with full SIP messages.
| Name | Type | Description |
|---|---|---|
call_id |
string | Required. |
max_messages |
u32? | Default 100, max 1000. |
cursor |
u32? | Index of first message to return. Default 0. |
Returns { dialog, messages, total_messages, next_cursor, complete }.
Single SIP message at a given zero-based index.
| Name | Type | Description |
|---|---|---|
call_id |
string | Required. |
index |
u32 | Required. |
Out-of-range indexes return invalid_params (-32602).
Call-flow ladder for one Call-ID.
| Name | Type | Description |
|---|---|---|
call_id |
string | Required. |
format |
"markdown" | "text" | Default "markdown". |
Output is byte-identical to sipnab --call-report <id> --markdown /
--call-report <id> for the same dialog.
Per-stream RTP quality plus media diagnosis for the dialog.
| Name | Type | Description |
|---|---|---|
call_id |
string | Required. |
Returns { call_id, streams, diagnosis } where streams is an array
of stream JSON objects (codec, MOS, jitter, loss%, packets, SSRC,
quality intervals) and diagnosis includes the standard one-way /
NAT-mismatch flags plus the Phase 8.7 asymmetry signals
(codec_asymmetry, ptime_asymmetry, payload_type_asymmetry,
duration_asymmetry, late_media).
Case-insensitive substring search over method, status, From, To, User-Agent, and body across all dialogs.
| Name | Type | Description |
|---|---|---|
query |
string | Required, non-empty. |
limit |
u32? | Default 50, max 1000. |
Returns array of { call_id, message_index, snippet }. Snippets are
capped at 4 KB.
Incremental fetch of the dialogs updated after a cursor position.
| Name | Type | Description |
|---|---|---|
cursor |
string? | The previous response's next_cursor, passed back verbatim (<RFC 3339>|<Call-ID>). Omit on first call. |
limit |
u32? | Default 50, max 1000. |
Returns { dialogs, next_cursor, source_exhausted }.
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 being dropped or returned 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.
Recent findings from active detection rules (scanner, fraud, digest, reg-flood, etc.). Backed by the AlertEngine's bounded ring buffer (default 1000 entries, kept in memory only).
| Name | Type | Description |
|---|---|---|
kinds |
string[]? | Filter to specific rule names. Empty = all kinds. |
since |
string? | RFC 3339; only findings strictly after. |
limit |
u32? | Default 50, max 1000. |
Returns array of { rule_name, src_ip, detail, timestamp }. When the
AlertEngine isn't attached (no detection rules configured), returns an
empty array rather than erroring.
Aggregate counters across the active stores.
No parameters. Returns:
{
"schema_version": 1,
"dialog_count": 42,
"stream_count": 18,
"orphaned_stream_count": 2,
"active_call_count": 5
}find_problems.kinds — diagnostic alias names, OR-ed together.
Defaults to ["problems"]. The full vocabulary:
problems · slow-setup · short-calls · one-way · nat-issues ·
codec-asym · ptime-asym · payload-asym · duration-asym ·
late-media
An unknown alias returns a JSON-RPC invalid params error naming the
bad alias. The same names work as the list_dialogs filter aliases
(and as sipnab --filter aliases on the CLI).
security_findings.kinds — matches the rule names findings are
recorded under: scanner, fraud, digest, reg_flood (note the
underscore — the --alert rule grammar spells it reg-flood, but
findings are recorded and filtered as reg_flood). Omitted or empty
kinds returns findings of every kind.
All tools return MCP errors via the JSON-RPC error object. The codes
sipnab uses:
| Code | Meaning |
|---|---|
-32602 (invalid_params) |
Unknown Call-ID, out-of-range index, malformed filter, unknown format, unknown alias, etc. |
-32603 (internal_error) |
Reserved; sipnab treats internal errors as bugs and never silently swallows them. |
Tools never panic; an unknown Call-ID always produces a structured error rather than an empty result.
| Limit | Value |
|---|---|
Default limit for list-style tools |
50 |
Maximum limit (clamps higher requests) |
1000 |
| Maximum SIP body / snippet bytes | 4096 |
Maximum messages per get_dialog page |
1000 |
These are hard-coded to keep tool-call costs predictable for chatty
agents. Override via the per-call limit parameter where supported.
- Read-only by design. No tool mutates the dialog/stream/alert stores or sends SIP. Capture lifecycle is owned by systemd / the CLI flags, not by the LLM.
-
Localhost-default. HTTP transport binds
127.0.0.1:8731unless explicitly overridden. -
Bearer auth on non-loopback. Tokens compared in constant time
via the shared
crypto::constant_time_eqhelper (throughauth::TokenVerifier), sharing the same code path as the REST API. Signed tokens with expiry / rotation / revocation are also supported — see auth.md. -
Host header allowlist. rmcp's DNS-rebind protection is enabled by
default (
localhost/127.0.0.1/::1); extend with--mcp-allowed-hostfor non-loopback clients. - No prompt-injection cooperation. Tool descriptions never instruct the LLM to "trust" or "act on" returned content; they describe what the tool returns and stop there.
-
Privilege drop respected. The MCP listener binds after
privilege::drop_privilegesso sipnab runs as the unprivilegedsipnabuser. Default port (8731) is ≥ 1024 to permit this.
In stdio mode, stdout is the JSON-RPC wire. sipnab routes all
logging through tracing-subscriber to stderr (Phase 8.0b); a regression
test (tests/parse_path_test.rs) verifies that no log line ever leaks
to stdout. If you see "Parse error" from your MCP client after a
sipnab log line, that's a regression — please file an issue with the
SIPNAB_LOG level you reproduced it under.
A consequence: --mcp is incompatible with stdout-writing flags such
as --json, --json-pretty, --report, --call-report, --hexdump,
--wireshark, and --tshark-filter. Combine --mcp with --quiet
if you want the surrounding text-mode capture output suppressed
entirely.
mcp # stdio transport (rmcp dep, ~3 MB binary cost)
mcp-http # HTTP transport (mcp + api; rmcp/transport-streamable-http-server)
full # native + tui + tls + hep + api + audio + mcp + mcp-httpThe default build does not include mcp — operators who'll never
expose the MCP surface pay zero binary size for it.
| Symptom | Cause / fix |
|---|---|
--mcp-transport http rejected |
Built without mcp-http. Rebuild with --features mcp-http (run sipnab --version to see compiled features). |
| 401 from the server | Token mismatch — compare the client's bearer token with the token file; check for a trailing newline stripped by your client. |
| 403 / host rejected | DNS-rebind protection: add the hostname clients use via --mcp-allowed-host. |
| Server starts, then "no packets" | If feeding via HEP, confirm the sender targets the -L port and watch for the idle warning (no packets for 30s) in the logs. |
Concrete examples for the MCP clients people actually use.
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"sipnab": {
"command": "sipnab",
"args": ["--mcp", "-N", "-I", "/path/to/capture.pcap", "--quiet"]
}
}
}For a live capture (requires CAP_NET_RAW or root — Claude Desktop won't grant either, so this is for environments where you'll manually setcap the binary):
{
"mcpServers": {
"sipnab-live": {
"command": "sudo",
"args": ["-n", "sipnab", "-N", "--mcp", "-d", "eth0", "--quiet"]
}
}
}(sudo -n fails fast if no NOPASSWD rule is in place — keeps the agent from hanging on a password prompt.)
Restart Claude Desktop. The agent will list sipnab under "Connected" — ask it "what dialogs failed in this capture?" and watch it call find_problems for you.
Run these from your project directory. For stdio against a fixed pcap, the
-- ends the claude mcp add flags so the trailing sipnab -N --mcp ... is
treated as the command to launch:
claude mcp add sipnab -- sipnab -N --mcp -I "$PWD/capture.pcap" --quietFor HTTP against a remote sipnab, the flags come before the positional name and URL:
claude mcp add --transport http \
--header "Authorization: Bearer $(cat ~/.config/sipnab/token)" \
sipnab-remote https://capture.example.com/mcpEither way, confirm the server registered:
claude mcp listThis is the simplest way to confirm the server is alive without an MCP
client. The whole block is one pipeline — the brace group feeds sipnab's
stdin and the sleeps pace the handshake — so paste it as a unit:
# Run all of these, in order.
{
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}'
sleep 0.3
echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
sleep 0.1
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
sleep 0.5
} | sipnab -N --mcp -I capture.pcap --quiet | head -c 2000Expected first line of response:
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"rmcp","version":"1.6.0"},"instructions":"sipnab MCP server — read-only access ..."}}Set the token and endpoint once. Every request below expands $TOKEN and
$URL, so run them in the same shell:
# Run all of these, in order.
TOKEN=$(cat /etc/sipnab/mcp.token)
URL="http://capture.example.com:8731/mcp"Initialize the session:
curl -sS "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'Call find_problems with several diagnostic aliases at once:
curl -sS "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"find_problems",
"arguments":{"kinds":["one-way","late-media","codec-asym"]}}}'The find_problems response (formatted for readability). Every sipnab
tool wraps its payload in the standard MCP envelope: the JSON result is
serialized as a string inside result.content[0].text (a "text"
content block), so clients parse content[0].text a second time to get
the actual array:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "[{\"call_id\":\"abc123@host\",\"state\":\"InCall\",\"method\":\"INVITE\",\"from_user\":\"1001\",\"to_user\":\"1002\",\"msg_count\":5,\"duration_sec\":12.4,\"created_at\":\"2026-06-12T14:03:21+00:00\",\"updated_at\":\"2026-06-12T14:03:33+00:00\",\"timing\":{\"pdd_ms\":180,\"setup_ms\":2134,\"retransmits\":0,\"duration_ms\":null}}]"
}
],
"isError": false
}
}Each array element is a dialog summary (call_id, state, method,
from_user, to_user, msg_count, duration_sec, created_at,
updated_at, timing) — the compact projection; the full aggregated
dialog document is what get_dialog_report returns (the
REST API returns the same shape).
Fetch one dialog a page at a time, starting at the first message:
curl -sS "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"get_dialog",
"arguments":{"call_id":"abc123@host","cursor":0,"max_messages":50}}}'Pull recent security findings, narrowed to two rule names:
curl -sS "$URL" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"security_findings",
"arguments":{"kinds":["scanner","reg_flood"],"limit":20}}}'Common failure modes:
| Status | Cause |
|---|---|
401 |
Missing or wrong Authorization: Bearer ...
|
403 Forbidden: Host header is not allowed |
Your Host: doesn't match the rmcp allowlist. Either send Host: localhost explicitly, or start sipnab with --mcp-allowed-host <your-host>
|
404 |
Wrong path — must be exactly /mcp
|
406 Not Acceptable |
Missing Accept: application/json, text/event-stream
|
"""Minimal MCP client driving sipnab over stdio."""
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main(pcap: str) -> None:
params = StdioServerParameters(
command="sipnab",
args=["--mcp", "-N", "-I", pcap, "--quiet"],
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# 1. List tools
tools = await session.list_tools()
for t in tools.tools:
print(f"{t.name:20s} {t.description[:60]}")
# 2. Find one-way audio + late-media problems
res = await session.call_tool(
"find_problems",
{"kinds": ["one-way", "late-media"], "limit": 50},
)
for content in res.content:
if content.type == "text":
print(content.text[:500])
if __name__ == "__main__":
import sys
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "capture.pcap"))Install + run:
# Run all of these, in order.
pip install 'mcp>=1.0'
python sipnab_mcp.py /path/to/capture.pcap// npm i @modelcontextprotocol/sdk
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const transport = new StdioClientTransport({
command: "sipnab",
args: ["--mcp", "-N", "-I", process.argv[2] ?? "capture.pcap", "--quiet"],
});
const client = new Client({ name: "sipnab-demo", version: "0.1" });
await client.connect(transport);
const tools = await client.listTools();
console.log(`${tools.tools.length} tools available`);
const result = await client.callTool({
name: "find_problems",
arguments: { kinds: ["nat-issues", "one-way"], limit: 20 },
});
console.log(JSON.stringify(result, null, 2));
await client.close();Website · Repository · Issues · Generated from docs/ — edit there, not here.
Getting started
Using the TUI
CLI & automation
Configuration
Integrations (API & MCP)
Development & internals
[ { "call_id": "abc@host", "state": "Completed", "method": "INVITE", "from_user": "1001", "to_user": "1002", "msg_count": 7, "duration_sec": 91.3, "created_at": "2026-05-02T14:02:11Z", "updated_at": "2026-05-02T14:03:42Z", "timing": { "pdd_ms": 820, "setup_ms": 1400, "retransmits": 0, "duration_ms": 89100 } } ]