I run both Asterisk and FreeSWITCH. Have done for years. And the thing that still slows me down isn't anything hard, it's the constant switching between two vocabularies that do the same job.
Want to know if an extension is registered?
On Asterisk that's pjsip show endpoints, unless the box is old enough that it's still sip show peers.
On FreeSWITCH it's sofia status profile internal reg.
Neither is difficult. But when you're three levels into debugging why calls to one country are failing, at 2am, on a switch you last touched eight months ago, the wrong syntax is one more thing between you and the answer.
So I built pbx-mcp. It's an MCP server that puts one tool surface in front of both.
What it looks like in use
You ask your assistant:
Which extensions are offline right now?
It picks the right tool for whichever PBX you configured and gives you a table. That part is mildly useful.
The part that actually changed how I work is chained questions:
Outbound calls to the UK are failing. Can you work out why?
The assistant checks the switch is alive, checks whether the trunk is registered upstream, lists recent channels to see how far calls get, and pulls the dialplan for the route. Four commands across two syntaxes. I didn't have to know the order, and I didn't have to remember any of them.
That's the real win. Not saving keystrokes. Not having to hold the debugging sequence in my head.
How it's built
Two protocol clients, no third party dependencies:
AMI (Asterisk Manager Interface) is a line protocol on TCP 5038. The server greets you with Asterisk Call Manager/8.0.0, then it's Key: Value pairs terminated by a blank line. You correlate requests and responses with an ActionID. List actions send a response with EventList: start, then one event per row, then a Complete event.
ESL (FreeSWITCH Event Socket Layer) is TCP 8021. It sends Content-Type: auth/request, you reply auth <password>, and after that it's header blocks with an optional Content-Length body.
Both are just framed text over TCP, so hand rolling them keeps the install small and the behaviour predictable. The only runtime dependencies are the MCP SDK and Zod.
One bug worth mentioning
The ESL client originally emitted a frame as soon as it found the \n\n that ends the header block. That works fine until you run show channels on a busy switch, the body spans several TCP segments, and you get a confidently truncated answer with no error.
The fix is to read Content-Length and wait for the whole declared body before emitting anything:
const declared = Number(headers["Content-Length"] ?? 0);
if (declared > 0 && this.buffer.length < bodyStart + declared) return;
Four lines. But it's the difference between "here are your 12 channels" and "here are your 12 channels" when there were actually 90. Silent truncation is the worst failure mode because the answer looks fine.
The safety part, which I thought about more than the protocol part
A PBX is not a scratch pad. reload drops every registration on a profile. An originate on a live trunk is a real call with a real bill attached. So the default posture is look but don't touch, and the guards are layered:
Read-only by default. The CLI and API passthroughs check against allow lists. core show, pjsip show, dialplan show on the Asterisk side; status, show, sofia on the FreeSWITCH side.
Word level scanning on FreeSWITCH. sofia is allow listed, but sofia profile internal restart is not a read. A prefix check would wave that straight through, so every word in the command gets checked against a list of state changing verbs.
Write tools aren't registered at all in read-only mode. This one matters more than the rest. originate and hangup never appear in tools/list unless you explicitly set PBX_MCP_ALLOW_WRITE=true. A model can't call a tool it can't see, so this isn't a refusal the model might argue its way around. The tool simply doesn't exist.
Header injection is blocked. AMI is a newline delimited protocol, so a caller ID string containing \r\n could smuggle in an extra header. Every field gets checked.
Output is clamped at 20,000 characters, because one show channels on a busy switch will otherwise eat the entire context window.
Try it
npx -y pbx-mcp
Claude Desktop config:
{
"mcpServers": {
"pbx": {
"command": "npx",
"args": ["-y", "pbx-mcp"],
"env": {
"ASTERISK_AMI_HOST": "10.0.0.10",
"ASTERISK_AMI_USERNAME": "mcp",
"ASTERISK_AMI_PASSWORD": "your-secret",
"FREESWITCH_ESL_HOST": "10.0.0.11",
"FREESWITCH_ESL_PASSWORD": "your-password"
}
}
}
}
Running only one of the two? Drop the other pair of lines. It registers tools for what you configured, so an Asterisk-only setup never sees a FreeSWITCH tool.
The one thing that catches people: pbx-mcp runs on your machine, not on the PBX, so your machine needs to reach port 5038 or 8021 on the phone system. Most installs bind those to localhost. An SSH tunnel is the quick answer, and please don't just open the port to the internet.
There's a user guide covering the PBX side setup for both, with a troubleshooting section keyed to the actual error strings you'll hit.
What I'd like feedback on
It's MIT licensed and on GitHub.
Two things I'm genuinely unsure about:
Which tools are missing? I built the ones I reach for. Queue stats, CDR lookups and conference room state are the obvious gaps, and I'd rather hear which one you'd actually use than guess.
Is the write mode gate the right shape? Hiding the tools entirely is blunt. It works, but it means you decide at startup rather than per call. A per call confirmation would be friendlier and I'm not yet convinced it would be safer.
Issues and pull requests welcome. If you're adding a tool, write the description for someone who has never seen your dialplan, because the model picks tools from those descriptions and a vague one is a broken one.
I'm Tahir Almas, and I build telephony software at ICT Innovations. We've been shipping open source and commercial VoIP since 2005, and this came out of the same AMI and ESL groundwork behind ICTCore.
Top comments (0)