MCP servers
CallAgent is an MCP client: connect a server, its tools are discovered, and the assistant can call them like any other tool. It is also an MCP server, so Claude, Cursor or any other MCP client can manage an organization directly.
Attached whole, not flattened
An MCP server is attached to an assistant as one tool binding, not as twenty separate tool definitions pasted in. That matters for three reasons: the tool list stays readable, a server can add a tool without anyone editing an assistant, and one permission policy covers everything the server exposes rather than twenty policies that drift apart.
name: internalSystems
type: mcp
serverId: 01JQ…
permissionPolicy: allow_selected
allowedTools: [searchOrders, getShipment, listReturns]
confirmationRequiredFor: [destructive, financial, privileged]Internally the binding is resolved into callable tools at call setup — the model sees the individual tools, you manage one attachment.
API
GET /api/v1/mcp-servers
POST /api/v1/mcp-servers
GET /api/v1/mcp-servers/{mcpServer}
PATCH /api/v1/mcp-servers/{mcpServer}
DELETE /api/v1/mcp-servers/{mcpServer}
POST /api/v1/mcp-servers/{mcpServer}/test connectivity check
POST /api/v1/mcp-servers/{mcpServer}/discover re-read the tool list
GET /api/v1/mcp-servers/{mcpServer}/tools
POST /api/v1/mcp-servers/{mcpServer}/tools/{toolName}/enabled
POST /api/v1/mcp-servers/{mcpServer}/tools/{toolName}/riskFull detail: MCP server reference.
curl -X POST "$CALLAGENT_API_URL/mcp-servers" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order system",
"transport": "streamable_http",
"url": "https://mcp.example.com/",
"tool_policy": "allow_selected"
}'const auth = { Authorization: `Bearer ${process.env.CALLAGENT_API_KEY}` };
const base = process.env.CALLAGENT_API_URL;
const server = await fetch(`${base}/mcp-servers`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Order system',
transport: 'streamable_http',
url: 'https://mcp.example.com/',
tool_policy: 'allow_selected',
}),
}).then((response) => response.json());
// Prove it works before an assistant depends on it.
await fetch(`${base}/mcp-servers/${server.data.id}/test`, { method: 'POST', headers: auth });
await fetch(`${base}/mcp-servers/${server.data.id}/discover`, { method: 'POST', headers: auth });test and discover are separate on purpose: "the server answers" and "these are its tools today" are different facts, and you want the first before an assistant depends on the second.
Transports
| Transport | Use |
|---|---|
streamable_http | The current HTTP transport. Preferred. |
sse | Legacy HTTP plus server-sent events. |
stdio | A local process. Development only. |
Authentication
Static headers, a bearer token from the credential store, or OAuth through the connection manager. Secrets are held as credentialRefs and resolved per call — the gateway never receives the whole credential table.
Risk tiers and confirmation
Every discovered tool is cached with a risk tier. Five exist, and they are the same vocabulary native tools use, so one confirmation policy covers both:
| Tier | Meaning |
|---|---|
read | Retrieves information. |
write | Changes state. |
destructive | Deletes. |
financial | Spends money. |
privileged | Acts with elevated authority. |
confirmation_policy on the server takes three values:
| Value | Effect |
|---|---|
never | Never ask. |
destructive | Default. Ask at destructive and above — so also financial and privileged. |
always | Ask for every tool. |
The contract has a richer policy that this API does not expose
McpServerConfigSchema in @callagent/contracts has mode: never · always · risk_at_or_above · risk_in_set with a threshold and a riskSet. POST /mcp-servers accepts only the three above. Configure through the API, using the three.
The two are combined as a floor, never a reduction: whatever the control plane computes can only add confirmation on top of what the server's own policy already requires. Nothing can quietly make a tool need less confirmation than the server asked for.
POST /mcp-servers/{id}/tools/{tool}/risk overrides a tier the server declared, and POST /mcp-servers/{id}/tools/{tool}/enabled disables one tool without disconnecting the server.
Discovery caching
The field is cache_ttl_seconds — seconds, minimum 60, maximum 86400, default 900 (15 minutes). Every read clamps it to at least 60, so there is no way to disable the cache from this API. POST /mcp-servers/{id}/discover forces a rediscovery now, and php artisan callagent:mcp:refresh-discovery re-discovers servers whose cache has gone stale.
(discoveryTtlMs, and 0 meaning "no cache", exist in the gateway's own tool-binding config. They are not this field.)
Prompt injection
MCP tool results are untrusted content. They are wrapped before they reach the model, so instructions inside a result cannot be mistaken for instructions from you. Every tool result in the contracts carries untrusted: true as a literal — it is not a flag something can forget to set.
Do not switch this off. An MCP server you do not control is, from the model's point of view, a stranger writing directly into the conversation.
CallAgent as an MCP server
POST /mcp exposes CallAgent itself over the streamable HTTP transport, authenticated with an ordinary API key and scoped by that key's permissions — an MCP client is another API consumer, and giving it a privileged path would make the most powerful entry point the least examined one.
GET /mcp returns 405 by design: this server sends no unsolicited notifications, and a client holding a stream open for them would wait forever.
The tools it exposes are list-and-read plus a few writes, each gated by the corresponding scope:
| Tool | Scope |
|---|---|
assistant.list, assistant.get | assistant.read |
assistant.create, assistant.publish | assistant.write / assistant.publish |
tool.list, tool.create | tool.read / tool.write |
squad.list | squad.read |
phoneNumber.list | phone_number.read |
trunk.list | trunk.read |
campaign.list | campaign.read |
knowledgeBase.list | knowledge.read |
call.list, call.get | call.read |
Thirteen tools, and that is the whole list. Tools whose subsystem is unfinished are absent rather than stubbed — the registry's own comment is that advertising one is worse than omitting it. So there is no assistant.update, no call.create, no call.end, no phoneNumber.create, no analytics.query.
Point an MCP client at it with a key scoped to exactly what that client should be able to do — a read-only key produces a read-only assistant.
Two implementations exist; only one is mounted
packages/mcp contains a TypeScript MCP server declaring 24 tools. Nothing imports it. It is a scaffold with no endpoint anywhere in either application.
The live one is the PHP server at POST /mcp, with the thirteen tools above. If you find a 24-tool list in the source, that is the scaffold.
Separately, the seeded feature flag for module.mcp_server still reads not_implemented. Treat the flag as out of date rather than the endpoint as absent; see Implementation status.