Content
# PJSUA MCP Server
An [MCP](https://modelcontextprotocol.io/) server that gives AI assistants control over many SIP user agents at once. Built on [PJSUA2](https://www.pjsip.org/) (pjproject 2.14.1) with Python 3.13, packaged in Docker.
One MCP server process manages N phones side by side. Each phone gets its own `pj.Account` and its own UDP transport inside a single `pj.Endpoint`. When you add a phone the server registers 22 per-phone action tools (`<phone_id>_make_call`, `<phone_id>_hangup`, …) via `mcp.add_tool()` and fires `notifications/tools/list_changed`; when you drop the phone those tools disappear again.
On top of those atomic tools, the server ships an **event-driven scenario engine** (`src/scenario_engine/`) — an LLM agent describes a multi-step SIP flow once as a YAML scenario, and the engine executes the whole thing inside its own asyncio loop without per-step LLM-turn latency. Scenarios compose atomic **patterns** (14 ship out-of-the-box) or define inline `hooks:` for one-off flows, and return a complete timeline of every event + action for post-mortem inspection. See the **Scenario Engine** section below.
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ AI Assistant (Claude, etc.) │
│ │
│ "Load the test profile, then call from a to 002" │
└──────────────┬───────────────────────────────────────────────┘
│ MCP (JSON-RPC over stdio)
▼
┌──────────────────────────────────────────────────────────────┐
│ PJSUA MCP Server (one Docker container) │
│ │
│ ┌───────────────────── scenario engine ─────────────────┐ │
│ │ EventBus ◄── emit reg.* / call.state.* / dtmf.* / im │ │
│ │ ▲ from pjsua callbacks │ │
│ │ │ │ │
│ │ HookRuntime ActionExecutor │ │
│ │ │ (maps 19 actions │ │
│ │ │ to CallManager etc.) │ │
│ │ │ │ │
│ │ Orchestrator ──► run_scenario / validate_scenario │ │
│ │ │ │
│ └──────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌────────────┐ ┌───────────────┐ ┌─────────────────────┐ │
│ │ SipEngine │ │ PhoneRegistry │ │ CallManager │ │
│ │ (Endpoint, │ │ dict[pid] │ │ dict[call_id], │ │
│ │ codecs, │ │ → SipAccount │ │ per-phone queues, │ │
│ │ per-phone │ │ + Config │ │ incoming routing, │ │
│ │ transports)│ │ │ │ always-on recording│ │
│ └──────┬─────┘ └──────┬────────┘ └──────┬──────────────┘ │
│ │ │ │ │
│ │ ┌────┴─── phone_tool_factory ─────┐ │
│ │ │ register_phone_tools(mcp, pid) │ │
│ │ │ → 22 closures per phone │ │
│ │ │ → mcp.add_tool / remove_tool │ │
│ │ └─────────────────────────────────┘ │
│ │ │
│ ┌──────┴──────────────────────────────────────────────────┐ │
│ │ PJSUA2 / pjproject 2.14.1 │ │
│ └──────────────────────┬──────────────────────────────────┘ │
│ │ SIP/UDP (1 socket per phone) │
│ ┌──────────────┐ │ │
│ │ SipLogWriter │ ◄────┘ captures every SIP message │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ PcapManager │ tcpdump — host-wide or BPF per phone │
│ └──────────────┘ │
└──────────────────────────────────────────────────────────────┘
│ SIP/UDP
▼
┌─────────────┐
│ SIP PBX / │
│ Registrar │
└─────────────┘
```
## MCP Tools
### Static (11 — always present)
#### Phone CRUD
| Tool | Description |
|------|-------------|
| `list_phones` | All registered phones with registration state, transport port, active-call count, per-phone tool names |
| `add_phone` | Create a transport + SipAccount, send REGISTER, register 22 per-phone action tools |
| `drop_phone` | Hang up the phone's calls, unregister, close transport, unload its per-phone tools |
| `get_phone` | Full info for one phone — credentials (sans password), reg state, active calls, `recording_enabled` |
| `update_phone` | Mutate runtime settings — `auto_answer` / `codecs` (per-phone SDP filter, instant) / `recording_enabled` / `capture_enabled` (instant), or credentials (forces reregister) |
| `load_phones` | Bulk-add every phone listed in a YAML profile. Atomic replace by default (`merge=True` for upsert) |
#### Global diagnostics
| Tool | Description |
|------|-------------|
| `get_sip_log` | Retrieve pjsip log entries (raw text). `phone_id` ownership filter (Call-ID/transport-port/REGISTER); `call_id`/`method`/`direction`/`status_code`/`cseq` narrow further; `filter_text` substring escape hatch |
| `get_call_messages` | Structured SIP messages — parsed headers + parsed SDP. Same filter set as `get_sip_log` minus `filter_text`. Built for programmatic plan checks |
| `list_recordings` | Walk `/recordings/` (and legacy flat files) for every WAV; filter by `phone_id` / `call_id` |
| `analyze_capture` | Parse `/captures/<phone_id>/call_<call_id>_*.pcap` into structured RTP/RTCP flow counts. Surfaces `phone_rtp_codecs_seen` + `non_phone_codecs_on_phone_port` (compared against the phone's `codecs` config) so the caller can verify per-phone SDP filter conformance without ad-hoc pcap parsing in bash |
Per-phone packet capture lives on `update_phone(phone_id=..., capture_enabled=true/false)` — auto-starts a tcpdump on the first audio-active call and stops on the last disconnect. The pcap path lands in the WAV's `.meta.json` sidecar (alongside `local_rtp_port` / `remote_rtp_port`, snapshotted while media is ACTIVE) so recording, capture, and `analyze_capture` all pair up on disk.
#### Scenario engine
| Tool | Description |
|------|-------------|
| `validate_scenario` | Static dry-run — catches unknown actions, unknown event types, malformed hooks — without touching pjsua |
| `run_scenario` | Execute a scenario dict, auto-validates first, returns full timeline + status + errors |
### Per-phone dynamic (22 per active phone)
Registered when `add_phone` (or `load_phones`) brings a phone online; unregistered on `drop_phone`. Examples below use phone `a`:
| Tool | Description |
|------|-------------|
| `a_make_call` | Outbound INVITE with optional custom SIP headers |
| `a_answer_call` | Answer an incoming call on phone a (auto-selects first queued if `call_id` omitted) |
| `a_reject_call` | Reject with a SIP status code (486 / 603 / 480) |
| `a_hangup` | BYE an active call |
| `a_get_call_info` | State, codec, duration, RTP stats, remote/local Contact, recording path |
| `a_get_call_history` | Completed calls on phone a |
| `a_list_calls` | Compact state summary of a's tracked calls |
| `a_get_active_calls` | Active calls with full info + RTP |
| `a_send_dtmf` | Send DTMF digits on a's call |
| `a_hold` / `a_unhold` | Re-INVITE sendonly / resume |
| `a_blind_transfer` | REFER to redirect a's call |
| `a_attended_transfer` | REFER+Replaces. Both legs must belong to phone a — cross-phone bridging is rejected |
| `a_conference` | Bridge multiple a-owned calls into a conference |
| `a_play_audio` / `a_stop_audio` | Play WAV into a call / resume MOH |
| `a_get_recording` | Path/size of the WAV + sidecar meta for a call on phone a |
| `a_send_message` / `a_get_messages` | SIP MESSAGE outbox / inbox |
| `a_register` / `a_unregister` | Fresh REGISTER cycle / de-REGISTER (symmetric pair) |
| `a_get_registration_status` | Quick reg state for phone a |
Total surface with N phones: 10 + 22·N.
## Quick Start
### 1. Build the Docker image
```bash
docker compose build
```
### 2. Connect to an AI assistant
Add to your MCP client config (e.g. `.mcp.json`):
```json
{
"mcpServers": {
"pjsua": {
"command": "docker",
"args": ["compose", "-f", "/absolute/path/to/pjsua_mcp/docker-compose.yml",
"run", "--rm", "-i", "pjsua-mcp"]
}
}
}
```
### 3. Describe your phones (YAML profile)
The server ships with no SIP credentials — you describe your phones in a YAML profile that stays on your host.
```bash
cp config/phones.example.yaml config/phones.yaml
$EDITOR config/phones.yaml
```
`config/phones.yaml` is gitignored; only `phones.example.yaml` is tracked. docker-compose bind-mounts `./config` → `/config` (read-only).
Minimal profile:
```yaml
defaults: # optional — merged into every phone, phone-level keys win
domain: sip.example.com
password: change_me
codecs: [PCMA, telephone-event] # default for phones that don't override
auto_answer: false
phones:
- phone_id: a
username: "1001"
- phone_id: b
username: "1002"
codecs: [PCMU, telephone-event] # phone-level override wins over defaults
auto_answer: true
```
Per-phone `codecs:` is the SDP rewrite filter — every outgoing offer or
answer this phone produces lists ONLY these codecs. RTP send/receive
naturally follows because pjsua's media activation picks codecs from
{SDP-advertised} ∩ {endpoint-enabled} (the endpoint pins a fixed
superset at startup). DTMF (`telephone-event`) is auto-preserved by
the rewriter even when not explicitly listed. Phones without a
`codecs` list fall back to whatever the endpoint superset offers.
### 4. Load the profile and run scenarios
```
mcp__pjsua__load_phones() # reads /config/phones.yaml
# → every phone registers; a_make_call, b_hangup, … appear via tools/list_changed.
mcp__pjsua__a_make_call(dest_uri="sip:002@sip.example.com")
mcp__pjsua__a_get_call_info(call_id=0)
mcp__pjsua__a_hangup(call_id=0)
```
`load_phones` is **atomic replace** by default: before loading, every existing phone's active calls are hung up and the phones are dropped. Pass `merge=True` to keep phones that aren't listed in the new profile.
For ad-hoc additions without touching the profile file:
```
mcp__pjsua__add_phone(phone_id="alice",
domain="sip.example.com",
username="1099", password="x",
codecs=["PCMA", "telephone-event"])
# → phone alice's INVITEs list only PCMA + telephone-event in SDP,
# and RTP for this phone uses PCMA.
mcp__pjsua__drop_phone(phone_id="alice")
```
## Call Scenarios — two styles
The server supports two modes for driving calls:
- **Atomic-tool mode** — call per-phone tools directly (`a_make_call`,
`b_answer_call`, …) and poll between steps with `time.sleep`. Good for
interactive debugging and one-off experiments.
- **Scenario-engine mode** — describe the whole flow as a YAML scenario and
hand it to `run_scenario`. The engine arms hooks against its event bus
and drives the flow in one tight asyncio loop, so timings are
deterministic and there's no per-step LLM-turn latency. Good for
reproducible test cases and ticket reproducers.
The examples below show the engine form (preferred for repeatable work);
the atomic-tool equivalent is always available as a fallback.
All examples assume the profile is already loaded (`a`, `b`, `c` online).
Replace URIs with your registrar's.
### Basic call: A → B with DTMF
```yaml
# scenario
name: a-to-b-dtmf
phones: [a, b]
patterns:
- {use: auto-answer, phone_id: b, delay_ms: 500}
- {use: send-dtmf-on-confirmed, phone_id: a, digits: "1234"}
- {use: hangup-after-duration, phone_id: a, duration_ms: 5000}
- {use: make-call-and-wait-confirmed, phone_id: a,
dest_uri: "sip:002@sip.example.com"}
stop_on: [{phone_id: a, event: call.state.disconnected}]
timeout_ms: 15000
```
```python
run_scenario(scenario=<dict above>)
# Returns: {status: "ok", timeline: [...events + actions with ms offsets...]}
```
Atomic-tool equivalent:
```
a_make_call(dest_uri="sip:002@sip.example.com")
# b auto-answers (auto_answer: true in profile)
a_send_dtmf(call_id=0, digits="1234")
a_hangup(call_id=0)
```
### Auto-answer (IVR / bot mode)
Set `auto_answer: true` for a phone in YAML or toggle at runtime:
```
update_phone(phone_id="b", auto_answer=True)
```
…or build it into the scenario with the `auto-answer` pattern.
### Blind transfer: B transfers A → C
```yaml
name: blind-transfer
phones: [a, b, c]
patterns:
- {use: auto-answer, phone_id: b, delay_ms: 200}
- {use: auto-answer, phone_id: c, delay_ms: 200}
- {use: blind-transfer, phone_id: b,
transfer_to: "sip:003@sip.example.com", after_ms: 2000}
- {use: hangup-after-duration, phone_id: a, duration_ms: 5000}
- {use: make-call-and-wait-confirmed, phone_id: a,
dest_uri: "sip:002@sip.example.com"}
stop_on: [{phone_id: c, event: call.state.disconnected}]
timeout_ms: 12000
```
Atomic-tool equivalent: see `b_blind_transfer(dest_uri=...)`.
### Attended transfer: B holds A, consults C, bridges A ↔ C
The flow has enough steps that it's clearer as **inline scenario hooks**
than as a composite pattern:
```yaml
name: attended-transfer
phones: [a, b, c]
patterns:
- {use: auto-answer, phone_id: b, delay_ms: 200}
- {use: auto-answer, phone_id: c, delay_ms: 200}
- {use: make-call-and-wait-confirmed, phone_id: a,
dest_uri: "sip:002@sip.example.com"}
hooks:
- when: call.state.confirmed
on_phone: a
once: true
then:
- wait: 1000ms
- hold
- make_call: {phone_id: a, to: "sip:003@sip.example.com"}
- wait: 2500ms
- attended_transfer
stop_on: [{phone_id: c, event: call.state.disconnected}]
timeout_ms: 15000
```
Both legs must belong to the same phone — cross-phone attended transfer
returns an error with a clear message.
### 3-way conference
```yaml
name: conference
phones: [a, b, c]
patterns:
- {use: auto-answer, phone_id: b, delay_ms: 100}
- {use: auto-answer, phone_id: c, delay_ms: 100}
initial_actions:
- {action: make_call, phone_id: a, to: "sip:002@sip.example.com"}
- {action: make_call, phone_id: a, to: "sip:003@sip.example.com"}
hooks:
- when: call.state.confirmed
on_phone: a
once: true
then:
- wait: 2500ms
- action: conference
phone_id: a
call_ids: auto # engine resolves to all active calls on a
- wait: 5000ms
- hangup_all: {phone_id: a}
stop_on: [{phone_id: a, event: call.state.disconnected}]
timeout_ms: 20000
```
### Codec selection & mid-call change
Per-phone codec list goes through the SDP rewriter; mid-call re-INVITE
via the `reinvite-codec-change` pattern still uses global priorities:
```yaml
patterns:
- {use: reinvite-codec-change, phone_id: a, new_codec: G722,
trigger_at_ms: 3000}
- {use: hangup-after-duration, phone_id: a, duration_ms: 5000}
- {use: make-call-and-wait-confirmed, phone_id: a,
dest_uri: "sip:002@sip.example.com"}
```
### SIP messaging
```yaml
initial_actions:
- {action: send_message, phone_id: a,
to: "sip:002@sip.example.com", body: "Hello!"}
stop_on: [{event: im.received, phone_id: b}]
timeout_ms: 2000
```
### Monitoring (always atomic — read-only introspection)
```
list_phones() # reg state + active-call counts
a_get_active_calls() # a's active calls with RTP
a_list_calls() # compact summary incl. DISCONNECTED
get_sip_log(phone_id="a", last_n=30)
```
## Scenario Engine
**Goal.** Let an LLM write a multi-step SIP flow once and have it execute
deterministically. The engine replaces "call tool, wait 2 s, call next
tool" loops (which burn wall-clock on LLM-turn latency and race against
real SIP timers) with a YAML flow that runs in one asyncio loop.
### Two tools in a typical workflow
```python
validate_scenario(scenario=<dict>) # static dry-run (no pjsua touched)
run_scenario(scenario=<dict>) # execute and return the timeline
```
Both tools accept a scenario only as a Python dict — file paths are not
supported (the agent runs outside the container, so paths don't translate).
Scenarios are authored as **inline `hooks:`** — `when: <event>` + `then: [<actions>]`.
**The canonical reference** for anatomy, idioms, full action surface, and
worked examples (blind transfer, attended transfer) lives in the
`pjsua-scenarios` skill that ships with this MCP.
### Event taxonomy
Hooks listen on:
- **Call state**: `call.state.{calling,incoming,early,connecting,confirmed,disconnected}`
- **DTMF**: `dtmf.in`, `dtmf.out`
- **Registration**: `reg.{started,success,failed,unregistered}`
- **Messaging**: `im.received`
- **Scenario lifecycle**: `scenario.{started,stopped}`
- **User-emitted**: `user.<name>` (from the `emit` action)
### Action vocabulary (19 actions)
- **Call control**: `answer`, `hangup`, `hangup_all`, `reject`, `hold`,
`unhold`, `send_dtmf`, `blind_transfer`, `attended_transfer`,
`conference`, `make_call`
- **Media**: `play_audio`, `stop_audio`, `send_message`, `set_codecs`
- **Flow control**: `wait`, `wait_until`, `emit`, `checkpoint`, `log`
Defaults inherited at dispatch time: `phone_id` from hook's `on_phone` or
triggering event, `call_id` from the triggering event.
### `stop_on` filters
```yaml
stop_on:
- phone_id: a
event: call.state.disconnected
call_id: 2 # specific call-id
- event: call.state.disconnected
match: {last_status: "4xx"} # predicate — supports exact, list,
# "4xx"/"5xx", "~regex"
```
### Pre-flight validation
`run_scenario` auto-runs `validate_scenario` first. Typos (wrong action,
wrong event prefix, malformed hooks) return `status="error"` in <100 ms —
no wall-clock burned on the timeout.
### Worked examples
Examples live in the `pjsua-scenarios` skill (shipped alongside the MCP):
- `.claude/skills/pjsua-scenarios/SKILL.md` — anatomy, idioms, simple-call template
- `.claude/skills/pjsua-scenarios/examples/blind-transfer.md` — REFER + sequencing
- `.claude/skills/pjsua-scenarios/examples/attended-transfer.md` — coordinator hook + REFER/Replaces
## Call Info & RTP Statistics
`<phone>_get_call_info` returns live call data, including RTCP-derived RTP stats:
```json
{
"phone_id": "a",
"call_id": 0,
"state": "CONFIRMED",
"remote_uri": "sip:002@sip.example.com",
"remote_contact": "<sip:192.0.2.10:5060;ob>",
"local_contact": "<sip:1001@192.0.2.20:5062>",
"codec": "PCMA",
"duration": 45,
"recording_file": "/recordings/a/call_0_20260101_141603_528491.wav",
"playing_file": "/app/audio/moh.wav",
"rtp": {
"tx_packets": 2250, "tx_bytes": 360000,
"rx_packets": 2248, "rx_bytes": 359680,
"rx_loss": 0, "rx_dup": 0, "rx_reorder": 0, "rx_discard": 0,
"rx_jitter_usec": 875, "rtt_usec": 6362
}
}
```
`<phone>_get_active_calls` returns this for every active call on the phone at once — no need to iterate `call_id`s.
## Call Recording (per-phone toggle, paired pcap)
Recording is **off by default** — opt in per phone with
`recording_enabled: true` in YAML or `recording_enabled=True` in
`add_phone`. When enabled, every call on the phone is written to the
container path `/recordings/<phone_id>/` as two paired files:
```
/recordings/
├── a/
│ ├── call_0_20260422_145828_123456.wav # local + remote audio mixed
│ └── call_0_20260422_145828_123456.meta.json # context sidecar
├── b/
│ └── ...
```
The filename carries a microsecond suffix so a single call can produce
several WAVs if recording is toggled mid-call (see below). The sidecar
carries the context the WAV itself lacks:
```json
{
"phone_id": "a", "call_id": 0, "direction": "outbound",
"started_at": "2026-04-22T14:58:28+00:00",
"ended_at": "2026-04-22T14:58:54+00:00",
"duration": 26, "codec": "PCMA",
"remote_uri": "sip:123002@...", "last_status": 200, "last_status_text": "OK",
"recording": "/recordings/a/call_0_20260422_145828_123456.wav",
"pcap": "/captures/a/call_0_20260422_145828.pcap"
}
```
`pcap` is populated whenever per-phone auto-capture
(`capture_enabled=true`) was running during the call. The pcap lives
under `/captures/<phone_id>/` with the same basename as the recording,
so audio and signalling pair up without any timestamp matching.
### Per-phone toggle: `recording_enabled`
Each phone carries a `recording_enabled` flag (default `false`). Set it
up-front in YAML or at runtime — toggles take effect instantly on every
active call of that phone:
```yaml
# config/phones.yaml
defaults:
domain: sip.example.com
password: xxx
# recording_enabled: false # default — nobody records
phones:
- phone_id: a
username: "1001"
recording_enabled: true # per-phone opt-in
- phone_id: b
username: "1002" # stays off
```
```
add_phone(phone_id="c", domain="...", username="1003", password="x",
recording_enabled=True) # opt-in at add time
update_phone(phone_id="a", recording_enabled=True) # flip on mid-call
update_phone(phone_id="a", recording_enabled=False) # flip back off
```
Every `off → on` opens a fresh WAV with a new microsecond-unique filename
and every `on → off` closes the current WAV and writes its `.meta.json`
sidecar. So `on → off → on → off → on → hangup` produces **three**
WAV+sidecar pairs under `/recordings/<phone_id>/`, not one. Use
`list_recordings(phone_id=..., call_id=...)` to see every segment for
a given call; `<phone>_get_recording(call_id=...)` returns only the
currently-open segment.
**To hide recording files from the host entirely**, drop the
`./recordings` bind mount from your `docker-compose.yml` — `/recordings`
will live inside the ephemeral container FS and disappear with `--rm`.
**Music-on-Hold** plays automatically when a call connects — Suite Espanola Op. 47 — Leyenda (Albeniz), CC0 public domain from FreeSWITCH/MUSOPEN, 8kHz WAV. Use `<phone>_play_audio` to override, `<phone>_stop_audio` to resume MOH.
## SIP Log Inspection
Every SIP message the PJSUA2 stack processes is captured by a custom `LogWriter` into a bounded in-memory deque (5000 entries):
```
get_sip_log() # everything (all phones)
get_sip_log(last_n=20)
get_sip_log(filter_text="401") # raw substring escape hatch
get_sip_log(phone_id="a") # ownership-resolved (Call-ID + transport port + REGISTER username)
get_sip_log(phone_id="a", call_id=0) # narrow to one SIP dialog
get_sip_log(phone_id="a", method="INVITE") # structured method filter
get_sip_log(phone_id="a", direction="TX") # outgoing only
get_sip_log(phone_id="a", status_code=200) # 200 OK responses only
get_sip_log(phone_id="a", method="INVITE", direction="TX") # composable
```
Phone filtering uses structural ownership rather than substring match,
so messages on another phone's leg (e.g. bob's RX INVITE with
`From: <sip:alice@>`, or bob's `[DISCONNECTED]` dump showing alice's URI
in `To:`) do **not** leak into alice's filtered log. Entries whose owner
cannot be resolved structurally fall back to substring match and the
response surfaces a `warning` field with the count.
Each entry contains:
- `level` — pjsip log level (1=error … 5=trace)
- `msg` — full log line, including SIP message dumps
- `thread` — originating pjlib thread name
### Structured messages (`get_call_messages`)
When you need parsed SDP / headers (codec lists, media ports, RTCP port,
direction) instead of raw text, use `get_call_messages`. Same filter set
as `get_sip_log` minus `filter_text`:
```
get_call_messages(phone_id="a", call_id=0, method="INVITE", direction="TX")
```
Returns one entry per SIP message:
```json
{
"phone_id": "a",
"messages": [
{
"ts": "16:05:25.556",
"direction": "TX",
"method": "INVITE",
"cseq": 8398,
"call_id": "5d0cbc47-...",
"from": "sip:6001@asterisk",
"to": "sip:6002@asterisk",
"headers": {"Call-ID": "...", "CSeq": "8398 INVITE", "Content-Type": "application/sdp"},
"sdp": {
"version": 0,
"origin": {"username": "-", "ip": "192.168.1.40"},
"media": [{
"type": "audio", "port": 4000, "protocol": "RTP/AVP",
"payload_types": [0, 120],
"codecs": [
{"pt": 0, "name": "PCMU", "clock_rate": 8000},
{"pt": 120, "name": "telephone-event", "clock_rate": 8000, "fmtp": "0-16"}
],
"direction": "sendrecv", "rtcp_port": 4001
}]
}
}
],
"total_count": 1
}
```
Responses also include `status_code`. SIP messages without an
`application/sdp` body have `sdp: null`. pjlib library log lines and
pjsua call-dump summaries (`[DISCONNECTED]`) are dropped silently —
they have no SIP envelope to structurize.
## Packet Capture
Two independent modes coexist: **manual** (one-shot tcpdump you fire
from a tool call) and **auto-capture** (per-phone `capture_enabled`
flag — tcpdump opens on the first audio-active call and closes on the
last disconnect). Both land under `/captures/<phone_id>/` with the same
basename as the recording, so pcap and WAV pair up on disk.
### Per-phone auto-capture (`capture_enabled`)
Default is `false` — no tcpdump runs unless you opt in. Turn it on in
YAML or at runtime; the state is checked per call, so you can flip it
mid-session:
```yaml
# config/phones.yaml
phones:
- phone_id: a
username: "1001"
capture_enabled: true # every call on 'a' → pcap
- phone_id: b
username: "1002" # inherits default → no pcap
```
```
add_phone(phone_id="c", domain="...", username="1003", password="x",
capture_enabled=True) # opt-in at add time
update_phone(phone_id="a", capture_enabled=False) # flip off mid-call
update_phone(phone_id="a", capture_enabled=True) # flip back on
```
On→off during a live call flushes and closes the current pcap; off→on
opens a fresh pcap with a new microsecond-unique filename. Off→on **does
not** retroactively capture packets from earlier in the call.
Each auto-capture uses the broad BPF filter `udp`, so a re-INVITE that
changes the RTP port (hold/unhold, codec swap) does not drop any packets
mid-call. The tradeoff is disk: on a noisy network the pcap grows faster
than if we locked to a single port. If you need to trim, split the pcap
post-hoc — see below.
In a conference (two active calls on one phone) a **single** pcap is
kept for the phone, not one per leg. The first call starts it; the last
disconnect closes it.
### Splitting SIP and RTP after the fact
Because the BPF filter is broad (`udp`), the pcap contains both SIP
signalling and RTP media interleaved. Split with `tshark` post-hoc:
```bash
tshark -Y 'sip' -r captures/a/call_0_*.pcap -w sip_only.pcap
tshark -Y 'rtp' -r captures/a/call_0_*.pcap -w rtp_only.pcap
```
## Dynamic Tool Registration
`load_phones` / `add_phone` / `drop_phone` call `mcp.add_tool()` and `mcp.remove_tool()` at runtime. The MCP server announces the change via `notifications/tools/list_changed`; compatible clients rescan the tool list immediately.
```
# Fresh server
list_tools() → 14 static tools
add_phone("alice", ...) → 14 + 22 = 36 tools (alice_make_call, alice_hangup, ...)
add_phone("bob", ...) → 14 + 22·2 = 58 tools
drop_phone("alice") → 14 + 22 = 36 tools
```
The `tools_changed=True` capability is opt-in in the MCP protocol; the server enables it via a `create_initialization_options` monkey-patch on startup.
## Testing
### Unit tests
```bash
docker compose run --rm --entrypoint pytest pjsua-mcp tests/ -m "not integration" -v
```
Covers SipEngine lifecycle, PhoneRegistry CRUD + two-account isolation, CallManager lookups, PcapManager, SipLogWriter, plus the full **scenario engine** suite: EventBus pub/sub + threading, HookRuntime match semantics, every wired action via MockCallManager, TimelineRecorder offsets, pre-flight validator covering every action/event prefix. Fast (~7 s, ~125 tests), no network.
### Integration tests (self-contained)
```bash
docker compose -f docker-compose.test.yml run --build --rm test-runner
```
Runs one MCP server subprocess per test class + an Asterisk PBX container on an isolated Docker network (ext 6001/6002/6003). Exercises registration, outbound/inbound calls, blind + attended transfer, conference, codec negotiation, SIP MESSAGE, reject, history, YAML profile loading (replace vs merge), dynamic tool add/remove, cross-phone attended-transfer rejection, per-phone recording layout with paired pcap and `.meta.json` sidecar.
The full suite runs in ~2 minutes (~90 tests).
```
┌──────────────────────────────────────────────────────────┐
│ Docker Compose network: sipnet │
│ │
│ ┌──────────────────────────────────────────────────────┐│
│ │ test-runner container ││
│ │ ││
│ │ pytest spawns ONE MCP server subprocess per test ││
│ │ class. That server adds several phones via ││
│ │ add_phone / load_phones and drives them: ││
│ │ ││
│ │ ┌──────────────────────────────────────┐ ││
│ │ │ MCP Server (a, b, c managed inside) │ ││
│ │ └──────────────┬───────────────────────┘ ││
│ │ │ SIP/UDP ││
│ │ ▼ ││
│ │ ┌──────────────────────┐ ││
│ │ │ Asterisk PBX │ ││
│ │ │ ext 6001/6002/6003 │ ││
│ │ └──────────────────────┘ ││
│ └──────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────┘
```
## Publishing to Harbor (or any OCI registry)
This repo ships only the image artifact. Distribution to clients (wrapper scripts, slash-commands, MCP config) belongs to a separate **plugin repo** that references the published image by tag.
### One-time setup
1. Put registry coordinates in `.env` (gitignored; see `.env.example`):
```
HARBOR_HOST=harbor.example.corp
HARBOR_PROJECT=voip-tools
HARBOR_IMAGE=pjsua-mcp
```
2. Cache credentials once: `docker login "$HARBOR_HOST"` — they live in `~/.docker/config.json`.
### Publish a release (manual)
```bash
./scripts/publish.sh v0.3.0 # builds, tags :v0.3.0 + :latest, pushes both
./scripts/publish.sh v0.3.0-rc1 --no-latest # pre-release — keep :latest pointing at stable
./scripts/publish.sh v0.3.0 --platform linux/amd64,linux/arm64 # multi-arch via buildx
```
The script is read-only until `docker push` runs — safe to dry-run manually. `.dockerignore` keeps the build context small (excludes `captures/`, `recordings/`, `config/phones.yaml`, `.env`, CI files), so nothing secret or bulky gets shipped into image layers.
### How clients consume it
In your plugin-repo's wrapper script:
```bash
IMAGE="${PJSUA_MCP_IMAGE:-harbor.example.corp/voip-tools/pjsua-mcp:v0.3.0}"
exec docker run -i --rm \
--network host \
--cap-add NET_RAW --cap-add NET_ADMIN \
--user "$(id -u):$(id -g)" \
-v "$CONFIG_DIR:/config:ro" \
-v "$DATA_DIR/captures:/captures" \
-v "$DATA_DIR/recordings:/recordings" \
"$IMAGE"
```
Pin a **specific semver tag** in the plugin — never `:latest` for production clients — so a breaking image change doesn't silently land on every user's machine.
## Project Structure
```
pjsua_mcp/
├── src/
│ ├── server.py # MCP entry point, 19 static tool definitions, lifespan
│ ├── sip_engine.py # Endpoint lifecycle, per-phone transport create/close, codecs
│ ├── account_manager.py # PhoneRegistry, PhoneConfig, SipAccount (emits reg.* / im.* events)
│ ├── call_manager.py # SipCall, per-phone queues, incoming routing (emits call.state.* / dtmf.in)
│ ├── phone_tool_factory.py # 22 closures × N phones; add_tool / remove_tool
│ ├── sip_logger.py # Custom LogWriter → bounded deque
│ ├── pcap_manager.py # tcpdump subprocess management
│ └── scenario_engine/ # Event-driven scenario runtime
│ ├── event_bus.py # Thread-safe pub/sub; wildcard subscribe; wait_for
│ ├── hook_runtime.py # Arm hooks, match events, dispatch actions
│ ├── action_executor.py # 19 actions → CallManager / PhoneRegistry / SipEngine
│ ├── orchestrator.py # ScenarioRunner — arms hooks, runs initial_actions, awaits stop_on
│ ├── timeline.py # Chronological event+action recorder with ms offsets
│ └── validator.py # Pre-flight static checker (typos, unknown actions/events)
├── config/
│ ├── phones.example.yaml # YAML profile template (tracked)
│ └── .gitignore # ignores phones.yaml (real credentials stay out of git)
├── audio/
│ └── moh.wav # Default MOH — CC0, FreeSWITCH/MUSOPEN
├── tests/
│ ├── conftest.py
│ ├── test_sip_engine.py
│ ├── test_sip_logger.py
│ ├── test_account_manager.py # legacy single-account API kept compatible
│ ├── test_phone_registry.py # multi-phone registry + two-account isolation
│ ├── test_call_manager.py
│ ├── test_pcap_manager.py
│ ├── test_integration.py # end-to-end against Asterisk
│ ├── scenario_engine/ # ~70 unit tests for the engine
│ │ ├── test_event_bus.py
│ │ ├── test_hook_runtime.py
│ │ ├── test_orchestrator.py
│ │ ├── test_timeline.py
│ │ ├── test_validator.py
│ │ └── test_actions_direct.py
│ └── asterisk/
│ ├── Dockerfile
│ ├── pjsip.conf
│ ├── extensions.conf
│ └── modules.conf
├── scripts/
│ └── publish.sh # Build + tag + push image to Harbor (manual one-liner)
├── Dockerfile # Multi-stage: build pjproject + runtime
├── .dockerignore # Trim build context (ignore recordings/captures/secrets)
├── docker-compose.yml # Mounts ./config (ro), ./recordings, ./captures
├── docker-compose.test.yml # Asterisk + test runner on sipnet
├── .env.example # UID/GID + HARBOR_HOST/HARBOR_PROJECT (copy to .env)
├── requirements.txt # mcp[cli], PyYAML, pydantic, pytest, jinja2, jsonschema
├── pyproject.toml
└── .mcp.json # MCP client config for AI assistants
```
## Technical Notes
- **Python 3.13 + pjproject 2.14.1** — built from source in a multi-stage Docker build. Python 3.13 removed `distutils`, so `setuptools` is installed before building the SWIG bindings.
- **Null audio device** — runs headless in Docker with no sound card. ALSA library is still linked at runtime.
- **One `pj.Endpoint`, N `pj.Account`** — pjsua2's native multi-account model. Each phone gets its own UDP transport (`ep.transportCreate`), so packet capture and SIP Contact ports stay distinct per phone.
- **Incoming call routing** — each `SipAccount`'s `onIncomingCall` callback is wired via a per-phone closure in `CallManager._make_incoming_handler`, so the call lands in the right phone's `_incoming_queue`.
- **Threading model** — `threadCnt=0` with manual event loop polling from the asyncio thread (~50 polls/sec). SWIG director callbacks (LogWriter) don't work reliably from executor threads.
- **stdout protection** — C-level fd 1 is redirected to stderr at startup. MCP JSON-RPC uses a saved copy of the original stdout fd. Prevents pjlib console output from corrupting the MCP channel.
- **SIP log** — `consoleLevel=5` (matching `level=5`) ensures the global log level isn't suppressed. The LogWriter captures everything into a thread-safe bounded deque.
- **Auto-answer** — deferred to the event poll loop (not inside `onIncomingCall`) to avoid PJSUA2 call state machine issues.
- **Recording** — per-phone `recording_enabled` flag (default off — opt in per phone). When on, writes to `/recordings/<phone_id>/call_<call_id>_<ts>_<us>.wav` plus a `.meta.json` sidecar with call context and the paired pcap path (when a capture is running for the phone). The recorder is connected AFTER player setup to avoid conference bridge disruption and reconnected on every `onCallMediaState`. Local + remote audio mixed into one mono WAV. Toggling `recording_enabled` mid-call via `update_phone` opens/closes distinct WAV segments — each with its own sidecar — so a single call can emit several recordings if the operator wants finer-grained capture.
- **Auto-capture** — per-phone `capture_enabled` flag (default off). Opens a dedicated `tcpdump -i any udp` subprocess on the first audio-active call and closes it on the last disconnect. Filter stays broad so re-INVITE RTP port changes don't drop packets; split SIP and RTP with `tshark -Y` after the fact. Start/stop requests come from pj callback threads; actual subprocess launches run on the asyncio poll loop via a deque-based pending queue (same pattern as `process_auto_answers`). Conference (2+ calls on one phone) shares a single pcap, counted via `_active_calls_by_phone`.
- **Re-INVITE** — audio player is reconnected to the new `aud_med` port after re-INVITE (codec change, conference conversion) so TX keeps flowing.
- **Dynamic tool registration** — `tools_changed=True` capability enabled via `create_initialization_options` monkey-patch; `ctx.session.send_tool_list_changed()` fires after each phone add/drop (once per batch for `load_phones`).
- **Stale call cleanup** — disconnected calls are removed from tracking; accounts are shut down before re-registration to prevent ghost sessions.
- **Single point of failure** — one container crash now drops all N phones. Acceptable for a dev/test stand. Docker-compose can `restart: unless-stopped` if you need resilience.
- **MOH** — Suite Espanola Op. 47 — Leyenda (Albeniz), classical guitar, CC0 public domain from FreeSWITCH/MUSOPEN.
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
everything-claude-code
Complete Claude Code configuration collection - agents, skills, hooks,...
markitdown
MarkItDown-MCP is a lightweight server for converting URIs to Markdown.
cc-switch
All-in-One Assistant for Claude Code, Codex & Gemini CLI across platforms.
servers
Model Context Protocol Servers
servers
Model Context Protocol Servers
Time
A Model Context Protocol server for time and timezone conversions.