Designing a CRM MCP Server: 37 Tools, 4 Lessons - Relaticle             [  Back to blog ](https://relaticle.com/blog) 

 ![Designing an MCP tool suite for a CRM: four lessons](https://relaticle.com/storage/ink/01M17KWKXTN38DSBQ83EP03CHQ.png)We built an MCP server for Relaticle, our CRM, with 37 tools covering companies, people, opportunities, tasks, and notes. Four things mattered more than we expected going in. Schema descriptions are the only documentation a model ever reads, so they have to carry imperative instructions, not prose. Every write still goes through the same action classes the web app uses, which meant tenant isolation and authorization were already solved before the first MCP tool existed. Custom fields needed two completely different translation layers, because chat and MCP clients see the world differently: one gets option labels, the other gets IDs. And the boundary of "the server" turned out to be bigger than the tool list, because a Claude-shaped client and a bare [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) client will probe the same URL and expect different things from it.

None of this was obvious from reading the MCP spec. Here's what we actually found building against it.

![Relaticle's 37 MCP tools grouped by entity: companies, people, opportunities, tasks, notes and workspace](https://relaticle.com/storage/ink/01M12RRDBW8CWGZR6CNZFZ8VWN.png)

[](#the-tool-families-and-why-theyre-not-symmetric "Permalink")The tool families, and why they're not symmetric
---------------------------------------------------------------------------------------------------------------

| 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 | 8 | `search` + `fetch` (the OpenAI/ChatGPT connector citation contract), `who-ami-tool` (token, team, and ability introspection), and five workspace-level reads: entity schema, custom-field definitions, record counts, opportunity aggregates, and the activity feed |

37 tools total. The asymmetry is deliberate, not an oversight. Company/People/Opportunity relationships are structural: a deal belongs to one company, so that's a plain field on create/update. 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-are-a-contract-not-a-convenience "Permalink")Search and fetch are a contract, not a convenience
--------------------------------------------------------------------------------------------------------------------

`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.

[](#schema-descriptions-are-prompts-not-documentation "Permalink")Schema descriptions are prompts, not documentation
--------------------------------------------------------------------------------------------------------------------

Every create/update tool schema includes this on the `custom_fields` field, verbatim:

> "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 team 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.

[](#two-different-custom-field-bridges-for-two-different-trust-levels "Permalink")Two different custom-field bridges for two different trust levels
---------------------------------------------------------------------------------------------------------------------------------------------------

Custom fields are per-tenant and dynamic. Any team can define their own fields on any entity, so a tool schema can't hardcode them. Both MCP and chat solve this the same way at a high level (describe the fields, validate against them, translate them), but they're two separate implementations, not one shared bridge.

Chat's `CustomFieldsSchemaDescriber` writes field descriptions as label-based prose, because the model in chat is working from a conversation, not a structured lookup: `status (single-choice, one of: "New", "Contacted", "Qualified")`. Its companion `CustomFieldsRequestValidator` then translates whatever label the model wrote back into an option ID before the value hits the database, because "the user said Contacted" is the natural unit a chat model produces.

MCP's `ResolvesEntitySchema` does the opposite. It hands back the actual option IDs in the schema resource (`{"id": "01j...", "label": "Contacted"}`), and `ValidCustomFields` expects the ID directly, not the label. An MCP-connected agent already did a structured lookup to get the schema, so passing the ID back is less ambiguous than round-tripping through a label, and it means a client with two options that share a display label (which the choice-field UI happens to allow) doesn't have to guess.

Neither choice is wrong for its client. The point is that "custom fields work automatically" is true from the outside and false on the inside. It's two purpose-built translation layers, not one generic one, and that's worth knowing before you assume you can point a new integration at either.

[](#batching-belongs-to-the-approval-surface-not-the-protocol "Permalink")Batching belongs to the approval surface, not the protocol
------------------------------------------------------------------------------------------------------------------------------------

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 a single card (or a per-item batch they can approve individually), 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.

[](#every-write-is-authenticated-twice-by-two-different-things "Permalink")Every write is authenticated twice, by two different things
--------------------------------------------------------------------------------------------------------------------------------------

An MCP write has to answer two separate questions before it touches the database: does this token have this ability, and does this user own the thing they're trying to change. 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 team's data those tools can reach is bound separately, on the token itself.

That binding is `SetApiTeamContext`. It resolves the team from the OAuth token's `team_id` (stamped at consent time and treated as authoritative; the `X-Team-Id` header is ignored for OAuth tokens), sets it as the model-layer tenant scope, and switches the auth guard to `web` so the same Filament policies and observers the app UI uses apply unchanged to API and MCP requests. Every scoped model gets `TeamScope` applied for the duration of the request, and the scopes are cleared in `terminate()`, which the middleware's own docblock flags as unsafe under Octane, since a failed `terminate()` would leak one tenant's scope into the next request. We run FPM, not Octane, specifically because of that.

Neither of those checks validates that a foreign key in the request body actually belongs to the caller's team. That's `TenantFkValidator::assertOwned()`, called inside the action itself. `CreateOpportunity` checks that `company_id` and `contact_id` exist and belong to the caller's team before the insert, independent of whatever the middleware already scoped. A token with `mcp:use` and a valid team 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.

[](#tool-annotations-are-a-policy-surface-and-a-hand-maintained-list-will-drift "Permalink")Tool annotations are a policy surface, and a hand-maintained list will drift
------------------------------------------------------------------------------------------------------------------------------------------------------------------------

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, non-destructive writes closed-world, deletes and detaches destructive.

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:

```
$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 header no in-page `fetch()` or non-browser HTTP client can set.

[](#what-wed-do-differently "Permalink")What we'd do differently
----------------------------------------------------------------

The custom-fields bridge is the one piece of this we'd genuinely rebuild. Having `ResolvesEntitySchema`/`ValidCustomFields` (MCP) and `CustomFieldsSchemaDescriber`/`CustomFieldsRequestValidator` (chat) as fully independent implementations means a new custom field type has to be taught to both, and the two have already drifted once: MCP surfaces option IDs, chat surfaces labels, and there's no shared test that would catch them falling further out of sync on something like multi-select formatting. We'd rather have one core describer with two thin adapters on top than two parallel trees.

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).

 Tagged: [ #mcp ](https://relaticle.com/blog/tag/mcp) 

   ###    On this page
