Skip to content

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.

yaml
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}/risk

Full detail: MCP server reference.

bash
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"
  }'
ts
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 ​

TransportUse
streamable_httpThe current HTTP transport. Preferred.
sseLegacy HTTP plus server-sent events.
stdioA 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:

TierMeaning
readRetrieves information.
writeChanges state.
destructiveDeletes.
financialSpends money.
privilegedActs with elevated authority.

confirmation_policy on the server takes three values:

ValueEffect
neverNever ask.
destructiveDefault. Ask at destructive and above — so also financial and privileged.
alwaysAsk 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:

ToolScope
assistant.list, assistant.getassistant.read
assistant.create, assistant.publishassistant.write / assistant.publish
tool.list, tool.createtool.read / tool.write
squad.listsquad.read
phoneNumber.listphone_number.read
trunk.listtrunk.read
campaign.listcampaign.read
knowledgeBase.listknowledge.read
call.list, call.getcall.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.

Next ​

  • Tools — the other 24 tool types.
  • Security — why results are wrapped.

Documented against the code. Anything not yet implemented is labelled, never described as working.