Building Your Own MCP Server
Your data and business rules live in your own systems — and that's exactly where they should stay. This page gives a developer everything they need to expose those systems to Perfox agents as an MCP server: a standalone HTTP service in any language, running in your own infrastructure, with no dependency on Perfox internals.
If you're a developer at Acme Diagnostics who wants the agent to look up a patient booking, or a developer at Acme Store who wants it to check inventory, this is your starting point. All you need to know is how to handle an HTTP POST.
The wire contract
Perfox calls your server as a JSON-RPC 2.0 client over HTTP — Streamable HTTP, with Accept: application/json, text/event-stream — to a single endpoint (conventionally POST /mcp). You answer three methods:
| Method | When Perfox calls it | What you return |
|---|---|---|
initialize | Session handshake — the client sends protocolVersion: '2024-11-05', capabilities: {}, and clientInfo. | Your protocol version + capabilities. |
tools/list | On registration and whenever the tool list is re-synced. | A tools array of descriptors. |
tools/call | Each time the agent uses one of your tools mid-turn. | A content array with the result. |
A tool descriptor is { name, description, input_schema }. Perfox accepts both inputSchema and input_schema keys, so either spelling works. The session id returned in the mcp-session-id response header is echoed back as Mcp-Session-Id on subsequent calls. Perfox retries transient failures with backoff (up to two attempts by default), and any HTTP 401/403 surfaces to you as an authentication error rather than a silent drop.
The agent never sees your code — only the descriptors. A precise description (what the tool does and when to call it) is the single biggest lever on whether the agent calls the right tool with the right arguments.
A worked example — booking lookup at Acme Support
Setup: Acme Support's developer adds a get_booking tool to their MCP server. The description reads: "Look up a booking by its id. Call this when the customer asks about an existing booking. Returns status, date, and items."
Action: Priya messages the Acme Support agent: "What's the status of my booking B-4821?" The agent reads Priya's conversation identity from the X-Sa-End-User-* headers your server receives, picks the get_booking tool, passes booking_id: "B-4821", and your server queries your own database.
Result: Your server returns { status: "dispatched", date: "2026-07-30", items: ["report-kit"] }. The agent tells Priya: "Your booking B-4821 is dispatched and should arrive on 30 July."
What just happened: Perfox routed the intent to your tool, your server fetched the data, and the agent composed a natural-language reply — all without Perfox ever touching your database directly.
Here's what that server code looks like:
js
// Works in Node/TS, Python, Go — anything that can answer an HTTP POST.
app.post('/mcp', async (req, res) => {
const { method, params, id } = req.body;
if (method === 'initialize') {
return res.json({
jsonrpc: '2.0', id,
result: { protocolVersion: '2024-11-05', capabilities: { tools: {} } },
});
}
if (method === 'tools/list') {
return res.json({
jsonrpc: '2.0', id,
result: {
tools: [{
name: 'get_booking',
description: 'Look up a booking by its id. Call this when the customer '
+ 'asks about an existing booking. Returns status, date, items.',
input_schema: {
type: 'object',
properties: { booking_id: { type: 'string' } },
required: ['booking_id'],
},
}],
},
});
}
if (method === 'tools/call' && params?.name === 'get_booking') {
const { booking_id } = params.arguments;
const booking = await db.bookings.findOne({ id: booking_id });
return res.json({
jsonrpc: '2.0', id,
result: { content: [{ type: 'text', text: JSON.stringify(booking) }] },
});
}
res.status(400).json({ jsonrpc: '2.0', id, error: { code: -32601, message: 'unknown_method' } });
});How Perfox identifies the end-user to your server
On every tools/call, Perfox forwards the conversation's collected identity as X-Sa-End-User-* HTTP headers, so your server can resolve the caller against your own records without re-asking. A header is sent only when Perfox actually holds that field, and what is available depends on where the conversation came from — see the table below the headers:
| Header | Carries |
|---|---|
X-Sa-End-User-Name | Display name. |
X-Sa-End-User-Phone | Phone. |
X-Sa-End-User-Email | Email. |
X-Sa-End-User-External-Id | Your own stable customer id — the strongest anchor. |
X-Sa-End-User-Attributes | A JSON-object string of the open attributes bag for your own custom fields. |
X-Sa-Conversation-Id | The conversation id — present from the very first call. |
X-Sa-Channel | Where the conversation came from: web, phone, whatsapp, sms or email. |
X-Sa-User-Token | The tenant_session_token a web visitor's page supplied, verbatim — see User Context & Identity. Present only on turns that carried it. |
Empty values are omitted, so a header is present only when Perfox actually knows that field.
What arrives depends on where the conversation started. Design your server for the channels you actually serve rather than assuming a single fixed shape:
| Conversation started from | End-user identity | X-Sa-Channel | X-Sa-User-Token |
|---|---|---|---|
| Web chat or web voice | All available fields | web | Yes, when the page supplied one |
| Phone call | Phone from caller ID, plus anything already on the customer's record | phone | No — a phone call has no browser session |
| WhatsApp, SMS or email | Phone or email from the channel | That channel | No |
| A webhook, by default | None — the run has no end user | heartbeat | No |
| A webhook with identity mapping | The fields you mapped | The channel you labelled it | No |
A webhook is an anonymous system run unless you tell it where the caller's identity lives in your payload — see Webhook trigger. If your server relies on X-Sa-End-User-Phone to recognise someone, that is the setting that makes it arrive.
Perfox also sends X-Sa-Tenant-Id, a stable identifier for your Perfox workspace — useful alongside X-Sa-Conversation-Id when your server looks a conversation up through the Perfox API.
X-Sa-Conversation-Id is worth building on: because it is present on the very first call and never changes, your server can key its own session on it — a login, a cart, an OTP exchange — and correlate calls across turns without depending on anything else being replayed back to you.
These are signals, not proof of authentication. They tell your server what the conversation believes about the caller; your server decides whether to trust them. To gate per-customer data behind real proof, use the auth-context broker below.
The auth-context broker — proving who the caller is
Every conversation starts anonymous, even when a channel asserts an identity such as a caller ID or phone number — a channel-asserted identity is a low-trust signal, not proof of the human. Elevation to authenticated is your explicit decision, made through the broker.
On every tools/call, alongside the identity headers above, Perfox passes your server three additional headers: X-Sa-Conversation-Id, X-Sa-Auth-Callback, and X-Sa-Auth-Callback-Token. When your server has verified the caller — through an OTP, a login check, or any mechanism you choose — POST back to the callback URL Perfox gave you:
http
POST /conversations/<conversation_id>/auth-context
Authorization: Bearer <callback-token> # the token Perfox handed you
Content-Type: application/json
{
"authenticated": true,
"user_context": { "external_id": "cust_123", "name": "Asha", "phone": "+91…" },
"forward_payload": "<your business-signed token>",
"forward_as": "header:X-Acme-Auth",
"trust": "verified",
"expires_at": "…"
}authenticated: truestamps the conversation as authenticated, which is what Perfox checks before unlocking a customer's own documents.user_contextis merged (display-only) into the conversation's identity.forward_payloadis stored verbatim and opaque — Perfox never reads it — and replayed on every subsequenttools/call, placed perforward_as(header:<name>— defaultX-Sa-Forward-Payload—body:<key>, orquery:<name>). This is how you carry your own signed session back to yourself without Perfox needing to understand it.expires_atis enforced: once it passes, the payload stops being replayed. It ends the credential replay only — it does not sign the person out, so sendauthenticated: falsewhen you want that.
Each callback is a partial update, not a replacement. Omitting forward_payload keeps replaying the one you already gave us; send a new string to rotate it, or null to clear it while the person stays signed in. forward_as follows the same rule. expires_at is the exception: it belongs to the credential it shipped with, so when you rotate the payload, send its expires_at again — otherwise the new credential is unbounded, and we surface that on the conversation's timeline and in your logs rather than letting it pass unnoticed.
Two timing details that save a debugging session. The forwarding context is assembled once per turn, so the callback you POST from inside a tool handler takes effect on the next turn — the remaining tool calls of the turn that triggered your login still carry the pre-login context. And forward_as: header:Authorizationoverwrites any bearer credential you configured on the server record itself, so pick a different header if you need both.
Every outcome is visible on the conversation's timeline — signed in, signed out, expired, and rejected callbacks with their reason — so a broken integration shows up instead of failing quietly. A success returns { "ok": true, "auth_state": "..." }; rejections are 400 (missing_authenticated, bad_forward_as, bad_expires_at), 401 (missing_token, invalid_token — the callback token is good for one hour and is reissued on every tool call, so always use the one from the most recent call), or 404 when the conversation isn't yours. authenticated is required and must be a real boolean.
The callback is authenticated by either the per-conversation signed callback token (primary) or a long-lived API key sk_… (fallback, for servers that construct the call themselves). Perfox acts as a pure broker: it forwards identity and replays your opaque payload, but the decision "is this caller authenticated" lives entirely in your code.
Registering and iterating
Once your server answers tools/list and tools/call, register it in Connect → Integrations → Browse → Add custom server (or through the management API). A connection records its name, description, URL (conventionally <base>/mcp), transport (HTTP (Streamable) or SSE), auth type (None, Bearer Token, API Key with a header name, Basic, or OAuth 2.1), the encrypted secret — attached at call time, so you can rotate it with Edit connection without touching any agent — a request timeout (30 seconds by default), and an Oversized results policy.
Registration validates the URL against server-side-request-forgery abuse, encrypts your auth secret at rest, and returns a discovery error if the initial tools/list probe fails — so you catch a misconfigured server the moment you save it. Hosted providers can instead be one-click-connected through the integrations catalog and OAuth2.1 flow.
Agents read a cached tool list, so when you ship a new tool it stays invisible until you re-sync — a one-click force-re-discovery of your live tool list. Adding a tool is non-breaking; keep tool names stable and evolve description / input_schema, since renaming a tool is a breaking change. Because your MCP server deploys independently of Perfox, you ship domain changes without any Perfox redeploy or agent rebuild.
Keep results a size the model can read
Return only what the agent needs. If a single tools/call returns more than the AI model can read at once, the connection's Oversized results setting takes over — by default the agent sees the first results plus the true total and can page through the rest on text channels. Tools that regularly return too much get a Too large badge on the Integrations page, with how often and by how much. Fewer results per call, or fewer fields per result, keeps answers fast and cheap on every channel, voice especially.
You can now connect your own systems to any Perfox agent
Your server handles a standard HTTP POST, returns JSON, and never needs to know anything about how Perfox works internally. From here, you might want to:
- Registering an MCP server — walk through the form fields, auth options, and the re-sync flow to make your server visible to agents.
- What MCP Is in Perfox — understand how agents discover and decide which tools to call, and how your server fits into that picture.
- Authentication & the Auth-Context Broker — go deeper on trust levels, the
forward_payloadpattern, and how to gate sensitive data.