By Jordan Tate, Head of Growth at Conduyt.
Our MCP server, conduyt-mcp, registers 393 tools in version 4.105.7. That is a lot of surface for a language model to choose from, and most of what we learned getting there has nothing to do with the CRM itself. It is about naming, metadata, and saying no early.
This post walks through the decisions that held up, with real snippets from the published package and the relevant parts of the MCP specification. If you are building a server with more than a handful of tools, most of this applies to you.
Start by admitting you will not wrap everything
The package ships a coverage matrix. For 4.105.7 it lists 1,162 REST endpoints: 347 covered by a tool, 355 deliberately excluded, 460 planned. Excluded means things like unauthenticated public surfaces and browser-only OAuth flows, each with a written reason.
That file is also exposed as a tool, conduyt_mcp_coverage, whose description tells the model to use it "to know up front whether a capability you discovered in conduyt_api_catalog is executable here, instead of failing mid-task." An agent that can ask "is this possible here?" before it starts wastes far fewer turns than one that discovers the gap halfway through a job.
Naming: one prefix, boring verbs
Every one of the 393 tool names is conduyt_ plus snake_case. The most common second word is a plain verb:
| Verb | Tools |
|---|---|
| list | 60 |
| get | 53 |
| update | 45 |
| create | 37 |
| delete | 17 |
Boring is the point. If the model has seen conduyt_list_deals, it can guess conduyt_list_tasks exists and what it returns. We are not perfectly consistent. Integration tools are namespaced by product, like conduyt_zoom_test or conduyt_mailchimp_push_now, and that is fine as long as the pattern inside each family holds.
The prefix matters more than it looks. Agents often run several servers at once, and an unprefixed list_contacts will collide with someone else's.
Give every tool a human title
The spec defines title as an optional "human-readable name of the tool for display purposes," and the schema sets display precedence as title, then annotations.title, then name. All 393 Conduyt tools carry a title, and every title is unique.
Titles are for the person approving the call, not the model. "Delete Contact" in a confirmation prompt reads better than conduyt_delete_contact, and the spec asks clients to put a human in the loop who can deny tool invocations. Make that human's job easy.
The four hints, and why a missing one should be a crash
The MCP schema defines four boolean hints on ToolAnnotations. Paraphrasing the spec's definitions:
-
readOnlyHint: the tool does not modify its environment. Defaultfalse. -
destructiveHint: the tool may perform destructive updates; if false, only additive ones. Only meaningful when not read-only. Defaulttrue. -
idempotentHint: calling it repeatedly with the same arguments has no additional effect. Only meaningful when not read-only. Defaultfalse. -
openWorldHint: the tool may interact with an open world of external entities. Defaulttrue.
Look at those defaults. A tool with no annotations is, as far as a client is concerned, a non-idempotent, destructive, open-world write. A careful client will nag the user on every call to it, including your harmless get_contact. A careless client will treat everything the same and the hints are wasted.
So we made a missing row fatal. All titles and hints live in one table, and a wrapper around server.tool refuses to register any tool that is not in it. Here is the shape, lightly trimmed from dist/tool-annotations.js:
const row = (title, readOnlyHint, destructiveHint, idempotentHint) => ({
title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint: false,
});
const ro = (title) => row(title, true, false, true);
const create = (title) => row(title, false, false, false);
const update = (title) => row(title, false, false, true);
const destroy = (title) => row(title, false, true, true);
export const TOOL_ANNOTATIONS = {
conduyt_list_contacts: ro("List Contacts"),
conduyt_create_contact: create("Create Contact"),
conduyt_update_contact: update("Update Contact"),
conduyt_delete_contact: destroy("Delete Contact"),
// ...389 more
};
export function annotateRegistrations(server) {
const original = server.tool.bind(server);
server.tool = (name, ...rest) => {
const a = TOOL_ANNOTATIONS[name];
if (!a) throw new Error(`tool ${name} has no row in src/tool-annotations.ts: add a title and its four MCP hints`);
const registered = original(name, ...rest);
const { title, ...hints } = a;
registered.title = title;
registered.annotations = { title, ...hints };
return registered;
};
}
The wrapper is installed before any tool module runs, so a new tool without a row breaks the server at startup, not quietly in production. For conduyt_delete_contact, the annotation object that comes out of that row looks like this:
{
"title": "Delete Contact",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
The four helpers cover most tools, but 44 rows set their hints by hand, and the interesting rows, hand-set or not, carry a comment explaining the choice, often pointing at the route that justifies it:
-
conduyt_update_dealis not idempotent, because an identical PATCH re-dispatches thedeal.updatedevent and writes another audit row. -
conduyt_update_calling_hoursis destructive, because turning the calling-time check off re-enables outreach at any hour. -
conduyt_export_activities_csvis not read-only even though it reads CRM data, because it writes a file on your machine and can overwrite one.
The totals across all 393 tools: 168 read-only, 61 destructive, 295 idempotent (the 168 reads plus 127 writes), and openWorldHint: false on every tool. That last one is our call, since each tool's domain is the CRM account behind the key. You could reasonably argue a tool that sends a text message touches the open world, and I would not fight hard on it.
One caution from the spec itself: annotations are hints, "not guaranteed to provide a faithful description of tool behavior," and clients should never base tool use decisions on annotations from untrusted servers. Treat them as a contract you owe your users, not a security boundary.
Writes need idempotency keys, not just idempotent hints
An agent that times out mid-send will often retry. For a text message that means a duplicate in a customer's pocket. Two patterns in the package:
conduyt_send_sms takes an optional idempotencyKey (8 to 200 characters), described as an "Operation key for at-most-once sends." Reuse the same key when retrying a send whose outcome you did not see, and the server answers with the original message instead of sending again.
conduyt_pipeline_health_fix_next_action sends its key as an Idempotency-Key header, and its description spells out every outcome: same key and same body replays the first task, same key with a different body is a 422, a 409 means either still processing or a spent key, and a 503 tells you whether to retry with a new key or verify first.
Note that conduyt_send_sms still has idempotentHint: false. The key is optional, so the hint describes the call without it. Hints should describe the worst case a client can trigger.
The HTTP client backs this up. It only ever retries GETs (at most 2 retries), never mutations. When a write times out, the error says "The server may have applied it: read the current state before repeating it." That one sentence prevents a lot of double writes.
Cap everything a model can ask for
Models ask for large pages. Put the ceiling in the schema so the bad call fails as invalid input instead of as a slow response. A few of the caps in the package: per-page limits max out at 100 on the pipeline health views, 50 on batch-operation pages, and 500 when picking contacts from a saved list for a sequence.
conduyt_list_contacts defaults to 25 per page, with 100 as the documented maximum, and its notes tell the model to "page on by has_more, never by total," because totals can be deferred or cached while has_more is always exact. Under every call sits a response-size cap, 5 MB by default, and the error tells the model what to do next: "Narrow the query or paginate."
Remember that tools/list itself is paginated in the spec, with a cursor and nextCursor. At 393 tools, clients that respect that matter.
Scope by API key, and say the scope out loud
The server refuses to start without a key carrying the cdy_ prefix, and every call runs as that key. There is no separate MCP permission model to drift out of sync with the API. The spec says servers must implement proper access controls; reusing the API's own scopes was the simplest way to get there.
Two things make scopes usable by a model. First, conduyt_check_permissions returns the key's tier, scopes, rate limits and usage, so an agent can check before it tries. Second, descriptions name the scope a tool needs. conduyt_create_short_link says the key needs the messages scope. A model that reads that will stop and report rather than loop on 403s.
Reject arguments you do not know
The SDK wraps a tool's input shape in a non-strict object, so a misspelled argument is silently stripped and the call "succeeds" without it. For a destructive tool that is dangerous: expected_updated_at instead of expectedUpdatedAt means your concurrency check never ran. 80 Conduyt tools opt into a strict schema where an unknown key is an InvalidParams error and the advertised JSON Schema says additionalProperties: false.
Descriptions should say when not to use the tool
With 393 tools, the model's hardest job is picking between two plausible ones. The fix is a description that names its neighbor. Here is a hypothetical weak description:
Exports activities to CSV.
And an excerpt from the real one for conduyt_export_activities_csv:
Writes `out_path` (an absolute path; an existing file is refused unless
`overwrite: true`) and returns only { path, rows, bytes, parts }: the CSV
content is NEVER returned to you (it can be 50 MB). For a small read use
conduyt_list_activities instead.
The second version tells the model what it gets back, what it will not get back, and which tool to pick if this is the wrong one. Same idea in conduyt_pipeline_health_fix_deal, which handles four health checks and sends the other two to conduyt_pipeline_health_fix_next_action.
Pair that with discovery tools. conduyt_api_catalog, conduyt_mcp_coverage and conduyt_resolve_names exist so the model can find the right tool and the right IDs before it acts. If you want to see how we pitch this to non-developers, our AI page covers the product side.
The checklist
If I were starting a large MCP server tomorrow:
- One prefix, verb-first names, the same verbs everywhere.
- A title on every tool, unique.
- All four hints set explicitly in one table, and registration fails without a row.
- Hand-set hints get a comment that points at the code that justifies them.
- Optional idempotency keys on every send and create, and no automatic retries of writes.
- Hard caps in the input schema, plus a response-size cap with an actionable error.
- Permissions come from the API key, and descriptions name the scope.
- Strict input on anything destructive.
- Every description says when to use a different tool.
- A coverage tool, so the agent knows what is missing before it starts.
None of this is clever. It is the boring stuff that keeps a model from guessing, and at a few hundred tools, guessing is the expensive part. The package is public on npm if you want to read the whole table, and Conduyt is the CRM behind it.
Drafted with AI assistance from Conduyt's published MCP package and the MCP spec; figures checked against conduyt-mcp 4.105.7.
Top comments (1)
Strong checklist. One thing I would add from the user side: at version 4.105.x the tool surface clearly moves often, and most people install MCP servers with an unpinned
npxline (67% of npm and PyPI servers in the public configs we checked this weekend). So a release that adds a destructive tool or flips a hint reaches every agent on its next restart, with no review. Since the annotation table is already the single source, generating a "tools added / hints changed" section for each release from its diff would let users check before they bump a pinned version.