Zac Sheffer · 2026.9.22 · 6 min read
We ship an MCP server alongside our REST API. Same operations, different transport. This post covers why we added it, how the tool surface is designed, and what we got wrong the first time.
The REST API works fine for programs. MCP adds three things for agents:
The tradeoff: MCP is a second surface to maintain. The two must agree. We accepted that because agent-facing use is where the product is going.
The same tools serve four transports:
Every path builds the same context: user, tenant, assurance level. The in-process path is the important one. Because Kai's intake agent calls the same handlers external clients do, CRM semantics are single-sourced. No internal API to drift.
Not all authenticated callers are equally trusted. A voice session is "identified" — the calling platform verified the number, but nobody typed a password. An OAuth session carries higher assurance.
Every tool declares a minimum assurance level. The dispatcher checks it. Pass or fail, no negotiation. This borrows from NIST SP 800-63 (AAL1 through AAL3).
Concrete example: a voice agent can search_contacts and get_deal_status. It cannot call report_deal_revenue. The server decides, not the model.
Role gates layer on top. They check the role in the target org, not in any org the user belongs to. "Finance somewhere" is not "finance here." Denials distinguish not-a-member from insufficient-role because those mean different things when debugging.
snake_case names. The protocol is JSON-RPC 2.0. CamelCase tool names caused parsing failures in two agent frameworks we tested. snake_case worked everywhere. We went with it.
Fifteen tools, not fifty. Each tool is one verb on one resource. search_contacts searches contacts. create_referral creates a referral. No manage_contacts with a mode parameter. The MCP tools spec says tools should have clear descriptions and input schemas. A tool that does multiple things based on a parameter is a tool the agent will misuse.
Fifteen is also a context window constraint. An agent that enumerates fifty tools fills its context with descriptions before doing any work. Fifteen fits comfortably in a system prompt.
We have rejected convenience tools like search_and_create_contact. The two-step pattern is what the agent should learn. Collapsing steps into one tool hides logic the agent needs to see.
MCP responses serve two consumers: a model that pays per token and a UI that wants the full payload.
Every tool returns both: a one-line content summary for the model ("found 3 contacts at Novaform, most recent added in June") and the complete structuredContent for the UI. The model reasons on the summary. The UI renders the records.
Errors stay out of structuredContent. It is success-only. Failures travel as typed errors the model can narrate.
Two other contract decisions: read tools carry the protocol's read-only and idempotent annotations so clients can retry and parallelize them. Create tools return isNew: false on duplicates instead of erroring — "already exists" is information, not failure.
Tools too coarse. First version had a sync_crm tool that triggered a full sync for a given CRM. An agent that wanted to sync one contact had to trigger the whole thing. We broke it into sync_company, sync_contact, sync_deal. Agent behavior improved immediately because the tool granularity matched the decision granularity.
Auth too permissive. First version checked for a valid session but not assurance level per tool. Every authenticated caller could call every tool. We caught it in testing when a voice-agent session called report_deal_revenue. That should have been denied. The assurance-level gate fixed it. The test is permanent.
Error messages too helpful. Denial messages originally explained which role was required and in which org. Useful for debugging, bad for production — an adversarial agent can enumerate the permission model from denials. Errors now say "not authorized" or "not found" and nothing more. Detail goes to logs.
Output schemas are generated from the same Zod schemas that validate our data. A Zod version conflict between our schemas and the MCP SDK forced us below the SDK's convenience layer, which also cost us automatic response validation.
The replacement: a test suite that asserts schema-payload agreement for every tool by exercising each tool's declared output schema against real handler responses. SDK auto-validation checks shapes at runtime when a client is already connected. The test suite checks them in CI before a client exists. Given the choice, take the gate over the safety net.
The REST API answers what a program can do. The MCP server answers what an agent can do, given a specific identity, in a specific org, with a specific role, at a specific assurance level. That question is harder because the caller is fluent and has no memory of your security model.
The same rigor is what lets an agent gate its own recommendations and lets us ship an AI layer that acts like staff. One tool set, four transports, no special cases.
Introzy is free to start. No card required.