The question your PBX software gets asked most isn’t technical. It’s “is the trunk down?” and “who’s on a call right now?”, from people who’ll never open a CLI. pbx-mcp is our open source answer: an MCP server that hands live Asterisk and FreeSWITCH state to an AI assistant, read-only unless you say otherwise. Four weeks in, the code was the easy part. Here’s what the safety model taught us.
What an MCP server for PBX software actually does
MCP, the Model Context Protocol, is the way assistants like Claude Desktop or Cursor call tools. pbx-mcp registers a set of tools, each described well enough that the model picks the right one without being told, and answers them by talking to Asterisk over the Manager Interface and to FreeSWITCH over the Event Socket. Ask “why is my SIP trunk not registering?” and the assistant runs freeswitch_sofia_status, reads the gateway table, and tells you which one is stuck in TRYING. No dashboard, no copy-pasting from a terminal.
It runs with npx -y pbx-mcp or as a container, speaks stdio so nothing listens on a port, and the only runtime dependencies are the MCP SDK and Zod. The AMI and ESL clients underneath are hand rolled and published separately, which matters later. We validated the gap before writing it: the one “asterisk” package on npm belongs to a code security company that shares the name, and nothing at all existed for FreeSWITCH.

Lesson one: gate at registration, not at runtime
Pointing a language model at a live switch is a bad idea if you do it lazily. Our first design decision, and the one every reviewer since has singled out, is that the write tools are never registered with the model unless PBX_MCP_ALLOW_WRITE=true is set. asterisk_originate, asterisk_hangup and their FreeSWITCH twins simply don’t exist as far as the assistant can see.
That’s different from registering them and refusing at call time, and the difference is bigger than it sounds. A visible but refusing tool still burns context on its schema, the model will cheerfully retry it, and a determined one will try to route around the refusal. A tool it can’t see is a tool it can’t want. One commenter put it better than I had: the default doesn’t protect the operator who flips the flag on day one, because they’ve made a conscious call. It protects the person wiring the server into an autonomous loop who never read past the quickstart and is one hallucinated argument away from hanging up a live channel. They never flip the flag because they never knew it existed. That’s the whole win.
Lesson two: read-only is only as true as the allow list

Two of the read-only tools are raw pass-throughs, asterisk_cli and freeswitch_api, filtered by an allow list. The same reviewer called that spot “a write in a trenchcoat the moment the allow list has a gap”, and then found the gap from the README alone. conference was on the FreeSWITCH read list because conference 3001 list is genuinely useful. The policy checked the first word against the list, then only rejected the line if some later word matched a list of scary verbs. kick wasn’t on it. So conference 3001 kick all sailed through read-only mode and dropped every caller in the room.
Before changing anything, I compiled the old policy and ran commands through it, partly so I wasn’t fixing a bug I’d only imagined. Five got through: the one he found, plus hup all, mute 1, a record that writes a file to disk, and db insert, which writes to the switch database. The last two I hadn’t spotted when I first replied, so it was worse than I’d made it sound, and I said so in the thread. A deny list of scary words only catches what you thought of. It’s the wrong shape for the job.
Release 0.1.2 switched FreeSWITCH to a prefix allow list matched against the whole command, the way the Asterisk side already worked. sofia status reads. sofia profile internal restart isn’t a listed prefix, so it’s refused. conference got its own positional rule because the room name sits between the verb and the subcommand. fsctl came off the read list entirely. The five holes are regression tests now, CI runs them on Node 18, 20 and 22, and I verified the fix against the published npm tarball rather than the local build before telling anyone it was done. The list stays hardcoded on purpose. An environment variable that extends it has the same failure mode as the write flag: someone pastes it out of a blog post and the list means nothing. If a command belongs there, it belongs there for everyone, and that’s a pull request.
Lesson three: the injection nobody thinks about is in the caller ID
Asterisk’s Manager Interface is newline delimited. A caller ID field with a carriage return and line feed inside it is otherwise a free command, because the next line is parsed as a new header. It’s about four lines of code to reject, and it bothers me how rarely it’s there. The same goes for shell metacharacters in anything that reaches a CLI: we reject them rather than escape them, because escaping is where the clever bugs live. And output is clamped at 20,000 characters, since show channels on a busy box will happily eat an assistant’s whole context window and leave it unable to answer the question it was asked. The commenter who found the allow-list hole said the CRLF guard was what made him trust the rest of the design. I’d argue that’s the right instinct: check the boring input paths first, and the flashy features earn trust by association.
Lesson four: a dry run is worth more than a warning
The idea came from the same thread, from someone who ships a memory server where the dangerous verb is “forget” rather than “hangup”. Add a mode on the destructive tools that returns what would happen without doing it. So 0.1.2 also shipped asterisk_hangup_preview and freeswitch_hangup_preview, registered even in read-only mode. They show which live channels a hangup would match and warn when the matched channel is bridged, since the far end drops with it. Read-only by contract, but they exercise the write path’s planning logic, so a cautious operator can see the blast radius before ever setting the flag. If I were building any PBX software integration that can touch a live call, I’d copy this pattern before copying anything else.
Lesson five: what shipping it taught us about being found
The thread that produced all of the above is on r/mcp: pbx-mcp, an MCP server for Asterisk and FreeSWITCH, read-only by default. It worked because it led with the problem and the safety model, kept the links to the end, and closed with a real question about whether read-only defaults survive contact with users. Every correction got a reply, the fix shipped in two days, and the follow-up named the commit. One commitment took longer: someone asked for a demo video, and what eventually went up is a narrated walkthrough rather than the live screen capture I’d promised. I said so in the thread instead of pretending a slideshow was the same thing. Credibility on those forums is a balance, and overclaiming costs more than the gap it covers.
Distribution had its own lessons. The official MCP Registry wouldn’t accept a publish under our organisation’s namespace from an interactive login; only GitHub Actions with an OIDC token from the org-owned repository could claim io.github.ictinnovations/pbx-mcp. Glama’s quality score sat at 17 percent until we cut a Glama release, which is a different thing from a GitHub release. And the honest number: the seven product-specific MCP servers we published afterwards, for ICTPBX, ICTFax and the rest, show zero npm downloads, because their users must already run the matching product and developer registries can’t reach them. pbx-mcp, which works against any Asterisk or FreeSWITCH, is the one people find. Build the generic thing first.
Where this fits with ICTPBX
pbx-mcp talks to the switches underneath, so it works against a stock Asterisk or FreeSWITCH as much as against the FreeSWITCH inside ICTPBX. For the PBX layer itself there’s a separate read-only server, ictpbx-mcp, which reads extensions, DIDs, SIP trunks, tenants and statistics through the ICTPBX REST API. Enterprise Edition also ships an AI voice agent for managing the PBX by phone, which is where the “a misheard word on a phone line is easier than a typo in a terminal” argument for confirm steps came from. And if you’d rather own the whole stack, ICTPBX Community Edition is the open source multi-tenant PBX on ICTCore and FreeSWITCH under MPL 2.0, with an Ansible role that installs it in one play. The allow-list thinking above applies just as much to the toll fraud problem: the commands you didn’t think to deny are the ones that cost you.
The open source pieces around it
- asterisk-ami-node and freeswitch-esl-node: the zero dependency protocol clients pbx-mcp is built on, with the CRLF guard included, if you want the protocol layer without the MCP part.
- asterisk-ai-voice-agent: a self-hosted AI voice agent for Asterisk calls, and the AudioSocket library underneath it.
- The other product servers: ictfax-mcp, ictcontact-mcp, ictbroadcast-mcp, ictdialer-mcp, ictcrm-mcp and ictexam-mcp.
- Everything else, including the LangChain toolkits, n8n nodes and the explainer videos, is indexed on the ICT Innovations open source page.
Frequently asked questions
Is it safe to connect an AI assistant to production PBX software?
With the right defaults, yes. pbx-mcp starts read-only, never registers the call control tools unless you set a flag, filters raw commands through a hardcoded allow list, rejects shell metacharacters and AMI header injection, and clamps output. Treat the write flag like root and give the AMI user only the permissions the read tools need.
Which Asterisk and FreeSWITCH versions does pbx-mcp support?
Any Asterisk with the Manager Interface, with PJSIP endpoint tools falling back to chan_sip peers on older installs, and any FreeSWITCH with the Event Socket enabled. You need Node 18 or newer to run it, or use the container and keep Node off the PBX host.
Does it work with ICTPBX?
Yes, at two levels. pbx-mcp reads the FreeSWITCH underneath ICTPBX directly. ictpbx-mcp reads the PBX layer itself, tenants, extensions, DIDs and trunks, through the ICTPBX REST API, and is read-only.
What did the read-only allow list get wrong originally?
It allowed a command by its first word and then denied it only if a later word looked dangerous. Five FreeSWITCH commands passed that check in read-only mode, including a conference kick and a database insert. Version 0.1.2 replaced it with a prefix allow list against the whole command and added those five as regression tests.
Can I extend the allowed commands for my own site?
Not through configuration, and that’s deliberate. An environment variable that widens the allow list would be copied out of a blog post the same way the write flag is. If a command is safe to read, it’s safe for everyone, so it goes in as a pull request with a test.
The code, the tests, the changelog and the user guide are all in the pbx-mcp repository. If you find a command that gets past the list, open an issue. The best review this project has had came from someone who’d never run it.