Designing an MCP tool suite for a CRM: four lessons Relaticle built its CRM MCP server around the product's existing write actions and now exposes 39 tools as of September 22, 2026, according to the company's account of the build. The team reported four design lessons — explicit schemas, shared validation, clear authorization, and reliable client discovery — and said its `search` and `fetch` tools match the exact pair ChatGPT's Company Knowledge connector requires, with both pinned read-only, idempotent, and closed-world in their MCP tool annotations. Relaticle said its first `search` implementation published URLs like `/app/{segment}/{id}` that omitted the workspace slug Filament routing requires, causing every citation URL to 404, and fixed it with a single `CanonicalRecordUrl` class used by both tools. Designing an MCP tool suite for a CRM: four lessons We built Relaticle's MCP server around the CRM's existing write actions. Four lessons shaped it: explicit schemas, shared validation, clear authorization, and reliable client discovery. Custom fields use different descriptions in chat and MCP, but both normalize inputs through the same service. The current server exposes 39 tools, checked September 22, 2026. None of this was obvious from reading the MCP spec. Here's what we actually found building against it. Historical diagram from August 2026. The current server also includes upload-file and create-upload-url . | Tool family | Count | Design choice | |---|---|---| | Company / People / Opportunity CRUD | 15 5 × 3 | List, Get, Create, Update, Delete only. Relationships are singular foreign keys company id , contact id validated inline on create/update, no separate attach tool needed | | Task / Note CRUD + relationships | 14 7 × 2 | Same five, plus Attach/Detach. Tasks and notes are polymorphic many-to-many across companies, people, and opportunities tasks also get assignees , so a single FK field can't express the relationship | | Workspace and cross-entity utilities | 10 | search , fetch , account context, five workspace reads, and the upload-file and create-upload-url tools | The tool families have different responsibilities. Company, people, and opportunity relationships use fields on create and update. For example, an opportunity can reference a company. Task/Note relationships are associative: one task can touch three companies and two people at once, so they get their own sync endpoints. We didn't add Attach/Detach to the other three for consistency's sake. There's nothing for them to attach to. search and fetch aren't generic tools we happened to name that way. They're the exact pair ChatGPT's Company Knowledge connector requires: a client that finds records by query and cites them by a stable URL. Both carry Name 'search' and Name 'fetch' attributes so the wire name matches regardless of class naming, and both are pinned read-only, idempotent, and closed-world in their MCP tool annotations. We got the contract wrong on the first pass. search published URLs like /app/{segment}/{id} , omitting the workspace slug Filament's routing actually requires, so every citation URL 404'd, including for the record's own owner. fetch only knew how to parse that same broken shape, so it also rejected the real URL a user would copy out of their browser address bar. Tasks and notes had no per-record page at all, so two of the five searchable entity types had no citable URL to offer in the first place. The fix was CanonicalRecordUrl , one class that both builds and parses the URL shape, used by both tools. Splitting build and parse logic across two files is how they drift; a search result a client can't paste back into fetch is worse than no citation at all. Create and update tool schemas use this instruction for custom fields : "Custom field values as key-value pairs. IMPORTANT: You MUST first read the crm-schema resource to discover valid field codes for this entity type. Unknown field codes will be rejected. Use exact field codes from the schema e.g. 'job title', not 'jobTitle' ." That's not API documentation. It's an instruction aimed at an agent mid-task, written to preempt the two mistakes models actually make: guessing a plausible-looking field code instead of looking one up, and camelCasing a snake case key because that's the more common convention in training data. The tool descriptions do the same thing at a coarser grain: "Create a new company in the CRM. Use the crm-schema resource to discover available custom fields," pointing the model at the per-entity schema resource relaticle://schema/company , one URI per entity before it calls the tool at all. This only works because the schema resource is cheap to read and cached per workspace for 60 seconds. If discovery were another database round trip on every field lookup, we'd be tempted to inline the whole schema into every tool description instead, and the token cost would show up on every single call rather than once per resource read. Custom fields belong to each workspace. Chat's CustomFieldsSchemaDescriber presents field codes and option labels inside the tool schema. MCP's CustomFieldSchema returns structured definitions containing labels and option IDs. Both write surfaces use CustomFieldInput::normalize before ValidCustomFields validates the result. Choice fields accept option labels or IDs. Ambiguous labels fail validation, so an option ID resolves duplicate-label cases. The CustomFieldType enum owns input formats and examples used by both schema descriptions. Rich-editor values accept Markdown or HTML and persist as HTML. This boundary keeps presentation specific to the client while centralizing normalization. A new field needs no hand-written schema slot in each tool. Our delete tools look different depending on which client is calling them, and that's a real design choice, not drift. The chat delete tool takes ids: string , one or many, and its docstring says exactly that: "Pass one id to delete a single company, or many to delete them all in one call." A multi-id request becomes one PendingAction that a human reviews as part of a plan, with batch decisions available per item, instead of the model firing off three separate delete calls that each need their own approval prompt. The MCP delete tools take a single scalar id . There's no approval step to batch for: an MCP write executes immediately once the OAuth token clears its ability check, so there's nothing analogous to "reduce the number of cards a human has to click through." That's a real gap for an agent doing bulk cleanup deleting twelve stale opportunities means twelve calls today , and we don't think it's obviously the right tradeoff, just the one we made by not revisiting it once the pattern existed on the chat side. An MCP write has to answer two separate questions before it touches the database: does this token permit the operation, and may this user change the record. Those are enforced in different places on purpose. ChecksTokenAbility handles the first, and it has to special-case OAuth. A Sanctum personal access token can carry granular abilities read , create , update , delete , but a Passport OAuth token, the kind Claude or ChatGPT actually holds, can only ever request mcp:use , because that's the single scope laravel/mcp https://github.com/laravel/mcp publishes in its authorization-server metadata. Per-ability OAuth grants aren't expressible, so holding mcp:use authorizes the toolset as a whole; which workspace's data those tools can reach is bound separately, on the token itself. That binding is SetApiWorkspaceContext . OAuth tokens carry an authoritative workspace id , and the middleware ignores X-Workspace-Id for those tokens. It sets tenant context and the web guard, then applies WorkspaceScope to scoped models. Its terminate method clears those scopes. The middleware explicitly supports FPM rather than Octane because its static model state can survive a failed cleanup under Octane. Neither of those checks validates that a foreign key in the request body actually belongs to the caller's workspace. That's TenantFkValidator::assertOwned , called inside the action itself. CreateOpportunity checks that company id and contact id exist and belong to the caller's workspace before the insert, independent of whatever the middleware already scoped. A token with mcp:use and a valid workspace binding still can't point a new opportunity at a company ID it copied from a different tenant's data by guessing, because nothing this deep in the write path trusts scoping alone. MCP's tool annotations https://modelcontextprotocol.io/specification/2025-06-18/server/tools tool-annotations readOnlyHint , idempotentHint , destructiveHint , openWorldHint aren't decoration. Claude and ChatGPT's directory submission checks read them, and a client can reasonably use destructiveHint to decide whether to ask a user before calling a tool. We annotate every tool: reads are marked read-only and idempotent. Updates, deletes, and detaches carry destructive hints where appropriate. Task writes that send notifications and file uploads declare open-world behavior. We shipped a gap anyway. The first version of our annotation test asserted specific expectations against a hand-picked list of tool classes, and WhoAmiTool was never added to that list, so 31 of the 32 tools we had at the time declared openWorldHint and the test stayed green while the 32nd silently didn't. The fix wasn't a bigger list. It was reflecting on RelaticleServer::$tools itself and asserting the property against every class actually registered there, so a new tool can't skip the check by nobody remembering to add it to a dataset: php $tools = new ReflectionClass RelaticleServer::class - getDefaultProperties 'tools' ; foreach $tools as $toolClass { expect app $toolClass - annotations - toHaveKey 'openWorldHint' ; } The same lesson, that the boundary is bigger than you think, showed up at the transport level too. With MCP DOMAIN set, the MCP server is mounted at a subdomain root that's also its OAuth protected-resource identifier. RFC 9728 clients discover that identifier by GET -ing the bare URL and treating any 200 as the protected-resource metadata document. Our root route served an HTML info banner there, so an rmcp-based client Codex, in our case read the banner as metadata, found no resource field in it, and failed with a fairly opaque "Protected resource metadata missing required resource field." Claude and ChatGPT never hit this, because their SDK only ever fetches /.well-known paths, which is exactly why it shipped unnoticed. The fix inverts the default: a bare GET now gets the correct protocol response, and the banner only renders for an actual top-level browser navigation, detected via Sec-Fetch-Mode: navigate , a browser navigation signal. Non-browser clients can also send that header, so it is not an authentication control. We originally maintained separate custom-field translation paths. The current implementation shares normalization through CustomFieldInput and format descriptions through CustomFieldType . That reduces drift while letting chat emphasize labels and MCP expose structured definitions. Schema parity still deserves tests whenever the supported value formats change. We'd also revisit MCP delete batching now, rather than leaving it as an asymmetry we only noticed while writing this up. It was the right call to ship single-record deletes first and get the approval-batching right on the chat side, where the UX cost of not batching was immediate and visible. But an agent cleaning up a CRM in bulk is a real MCP use case, and today it pays for that in call count for no safety benefit. The OAuth scope check doesn't get any more thorough by asking it twelve times instead of once. If you're building an MCP server against production data, the pattern that held up best for us was routing every write through the same action classes your web app already uses, then treating the MCP tool as a thin translation layer on top: schema in, validated call to the action, resource out. Everything else, annotations, custom-field discovery, canonical URLs, is protocol-shaped decoration on that same core. Full tool list and setup instructions are at /developers/mcp https://relaticle.com/developers/mcp ; for the user-facing view of the same server, see connecting Claude via MCP https://relaticle.com/blog/how-to-connect-claude-to-your-crm-with-mcp and what agent-native actually means https://relaticle.com/blog/what-agent-native-crm-actually-means . mcp https://relaticle.com/blog/tag/mcp