PI6 is an AI-native CRM I built from scratch, with its MCP server as a first-class part of the product rather than a bolt-on. A team points Claude, Cursor, or Codex at their workspace and works their pipeline in natural language. It is hosted, multi-tenant, and can write, and those three properties shaped most of the decisions below. This is how I build an MCP server, using PI6 as the example.
Deciding what to expose
I start at the API layer. The MCP server runs on the same application and API PI6 itself uses, so building a tool is mostly deciding which of the product's existing operations an agent should be able to call, then shaping the input and output for a model rather than for the UI.
Not everything the app can do belongs in the MCP server. A larger surface gives me more to secure and gives the model a longer list to choose from, and it selects less reliably as that list grows. Every tool therefore maps to a permission the caller's role has to hold, so a read-only connection never sees the write tools at all. A connection can also carry a tool allowlist — a fixed set of tool names it is restricted to regardless of role — which lets me hand an outside integration like ChatGPT a token that can only call list_prospects, get_prospect, and add_analysis_entry and nothing else.
Writes get validated in layers. The tool schema is a Zod schema that checks types and bounds at the edge. The middleware checks the caller's role and the allowlist before the handler runs, and the service underneath validates again against its own rules. The model is acting on a real tenant's data, so the server treats every call as untrusted input. The worst a confused or manipulated model can do is send a well-formed request the server is free to reject.
The authentication choice
There is a real choice in how an MCP server authenticates. A shared bearer token is simple and works for a personal or single-tenant server, and PI6 supports that mode for direct integrations. For everything else it runs a full OAuth 2.1 authorization server, because a hosted multi-tenant product that can write needs per-user, per-workspace, revocable access rather than one shared secret.
I wanted the most modern experience. You put the workspace URL into Claude or Codex, a browser opens, you click authorize, and the client comes back connected. The flow is authorization code with PKCE, and S256 is the only code challenge method the server accepts.
A few of the choices underneath are worth describing. Access tokens are opaque and row-backed, not JWTs. A token is mcp_ followed by 32 random bytes, and only its SHA-256 hash is stored, so the database never holds a usable token. Each one lives in a connections row with its tenant, client, role, expiry, and last-used time. I chose this over self-contained JWTs because a token can be revoked instantly by flipping a column, with no denylist to propagate, and because every use leaves an audit row. The cost is a database lookup on each request, which for a CRM is nothing.
Refresh tokens rotate, with family-based theft detection. Each refresh token carries a family id, and consuming one issues a replacement in the same family. If an already-consumed refresh token is ever replayed, the server treats the replay as theft and revokes the whole family at once.
Strict detection like that has a known false positive. Rotation and the response are not one atomic step, so a dropped connection right after rotation can leave the client holding the old, already-consumed token, and a retry with it is indistinguishable from theft. The standard fix is a bounded grace window, where for a few seconds after rotation a replay of the just-consumed token re-rotates within the family instead of revoking it. That window has to fire at most once per consumed token, because an unbounded one lets a client stuck in a retry loop sit in the grace path and never trip theft detection at all.
Access tokens live for fifteen minutes, so a leaked one has a small window. Tokens can also be sender-constrained with DPoP. When a token is bound to a client's key, every request has to carry a fresh proof signed by that key, and a stolen bearer token by itself is not enough to use it.
Binding a token to one workspace
Multi-tenancy shows up in the token itself. A token is issued for a specific workspace, and the guard on each MCP request requires that the token's audience match the workspace being called exactly. A token minted for one tenant is rejected outright at another. Underneath, every tool call sets the tenant into request-local context, and a Prisma extension applies tenant-scoped row-level security off that value. This is the same isolation I cover in testing row-level security for tenant isolation. Platform-admin tokens carry a separate scope that passes a sentinel through that filter to allow cross-tenant reads, and nothing else gets it.
The client_id that is a URL
Clients like ChatGPT connect through the client-id-metadata-document pattern, where the client_id is itself an https URL pointing at a document that describes the client, including its redirect URIs. The server fetches that document and binds the redirect to whatever the client declared, so I do not have to hard-code a provider's callback domain.
Fetching a URL an unauthenticated caller controls is a server-side request forgery vector, and it has to be treated as one. The fetch is https only. It resolves DNS and rejects the request if any resolved address is private, loopback, or link-local. From there it connects to the pre-validated address directly, so no second DNS lookup can resolve somewhere else, and the rebinding window is closed. Timeouts and body size are capped, and redirects are not followed. Getting that wrong would let a client-supplied URL reach into the internal network.
Writing tool descriptions
Once the server is exposed and secured, the tool descriptions decide whether an agent uses it well. The model reads a description the way a developer reads documentation, except that it takes every word literally and reads nothing else. Some of mine started as terse notes, the way I write code comments, and the model made wrong calls against them. I rewrote them to say when to use each tool, what it returns, and what to call next, and that changed behavior more than any schema change. In the TypeScript SDK a tool is a name, a description, a schema, and a handler, and the pattern looks like this.
server.registerTool(
"search_contacts",
{
description:
"Search a workspace's contacts by name or company. Returns ids and one line summaries. Call get_contact with an id for full detail.",
inputSchema: { query: z.string() },
},
async ({ query }) => ({
content: [{ type: "text", text: await searchContacts(query) }],
}),
);The last sentence of that description does more work than the schema does, because it tells the model what to do once the call succeeds.
Errors are part of the interface
Error messages are read the same way as descriptions. When a call fails, the model chooses its next move from the error text and nothing else, so the error is part of the tool's interface rather than a log line meant for me. An error that names what went wrong and what to try instead gets a useful retry. A bare 500 gets a guess, and the guess is usually wrong in a way that costs another call to find out.
Dogfooding with an agent
I find these problems by having an LLM use the server against the live product and keeping a running log of everything that goes wrong. It catches more than my unit tests do, because the failures show up in the sequence of a real task rather than in isolation.
The log fills up with the specific ways a tool surface fails an agent. Passing a malformed or non-existent id used to return a raw database exception with an internal file path instead of a clean "not found," which tells the model nothing it can act on. A tool documented a status value the real enum did not have, so the model used it and got a raw error back. A list endpoint embedded a full notes field on every row, and a sixty-row response ran to ninety kilobytes of mostly unwanted context. I trimmed the list rows to ids and summaries and kept the full detail behind the get. More than once the log simply said there was no tool for something the agent needed to do, which is the clearest signal I get for what to build next.
None of those are model problems. They are interface problems, and the agent finds them because it reads the descriptions and the errors and acts on exactly what they say. I write both for the model, and I spend as much time on the words as on the handlers.
About the author
Graham Morley , Software engineer and technical leader
I have built and delivered production software since 2011, including SOC 2 Type 1 and ISO 27001 certified platforms, DeFi systems that held more than 20 million dollars with zero exploits, and AI products that raised private equity funding. I build, review, and lead engineering across the stack, and I work with AI coding agents every day.
Get in touch