{"slug": "designing-an-mcp-tool-suite-for-a-crm-four-lessons", "title": "Designing an MCP tool suite for a CRM: four lessons", "summary": "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.", "body_md": "# Designing an MCP tool suite for a CRM: four lessons\n\nWe 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.\n\nNone of this was obvious from reading the MCP spec. Here's what we actually found building against it.\n\n*Historical diagram from August 2026. The current server also includes `upload-file` and `create-upload-url`.*\n\n## \n\n| Tool family | Count | Design choice | \n|---|---|---|\n| 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 | \n| 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 | \n| Workspace and cross-entity utilities | 10 | `search` ,`fetch` , account context, five workspace reads, and the`upload-file` and`create-upload-url` tools | \n\nThe 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.\n\n## \n\n`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.\n\nWe 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.\n\nThe 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.\n\n## \n\nCreate and update tool schemas use this instruction for `custom_fields`:\n\n\"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').\"\n\nThat'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.\n\nThis 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.\n\n## \n\nCustom 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.\n\nBoth 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.\n\nThe `CustomFieldType` enum owns input formats and examples used by both schema descriptions. Rich-editor values accept Markdown or HTML and persist as HTML.\n\nThis boundary keeps presentation specific to the client while centralizing normalization. A new field needs no hand-written schema slot in each tool.\n\n## \n\nOur 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.\n\nThe 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.\n\n## \n\nAn 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.\n\n`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.\n\nThat 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.\n\nNeither 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.\n\n## \n\nMCP'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.\n\nWe 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:\n\n``` php\n$tools = (new ReflectionClass(RelaticleServer::class))->getDefaultProperties()['tools'];\nforeach ($tools as $toolClass) {\n    expect(app($toolClass)->annotations())->toHaveKey('openWorldHint');\n}\n```\n\nThe 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.\n\n## \n\nWe 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.\n\nWe'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.\n\nIf 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).\n\n[#mcp](https://relaticle.com/blog/tag/mcp)", "url": "https://wpnews.pro/news/designing-an-mcp-tool-suite-for-a-crm-four-lessons", "canonical_source": "https://relaticle.com/blog/designing-an-mcp-tool-suite-for-a-crm", "published_at": "2026-09-11 09:00:00+00:00", "updated_at": "2026-10-06 22:17:04.932602+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "ai-tools", "developer-tools"], "entities": ["Relaticle", "MCP", "ChatGPT", "Company Knowledge connector", "Filament", "CanonicalRecordUrl"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/designing-an-mcp-tool-suite-for-a-crm-four-lessons", "markdown": "https://wpnews.pro/news/designing-an-mcp-tool-suite-for-a-crm-four-lessons.md", "text": "https://wpnews.pro/news/designing-an-mcp-tool-suite-for-a-crm-four-lessons.txt", "jsonld": "https://wpnews.pro/news/designing-an-mcp-tool-suite-for-a-crm-four-lessons.jsonld"}}