Ask M.O.T. what it can do and one function answers. The tools/list case in the JSON-RPC handler hands back listMcpTools() with nothing done to it: no sort, no filter, no grouping. That function returns an array, and the array currently holds 35 objects. Each object has three fields:
interface ToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}That is the whole surface. Everything a model knows about M.O.T. before it makes its first call is a name, a sentence or two of English, and a JSON Schema, times 35. No folders. No categories. No tags. Nothing the protocol groups by, and nothing it promises about order.
Two of those three fields turn out to be narrow. A name buys exactly one thing. A schema describes the shape of an argument and stops there. Everything else a model needs at the moment it decides whether and how to call lands in the middle field, because there is nowhere else to put it. That is what makes the description a prompt rather than documentation.
Everything below comes from lib/mcp-tools.ts and the handler beside it, not from tool-design advice. The tools are exposed by an MCP server, which here means a handler at /api/mcp speaking JSON-RPC. What that handler is running on is a separate argument. This one starts after the tools exist.
The one thing a name can carry
Here is one entry from the array, whole, as a model receives it:
{
name: 'mot_get_ticket',
description: 'Get a single ticket by ID, including its full comment history.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Ticket CUID2 id.' },
},
required: ['id'],
},
}That is a good entry, and about as much signal as three fields carry when the tool is simple. Most tools are not that simple, and the array is not that tidy.
It comes back in definition order, which is the order the tools were written. Nothing sorts it later. So memory_recent sits twelfth and memory_context sits twenty-eighth, with fifteen unrelated tools between them. topic_thread_link is fifteenth; topic_thread_summarize is thirty-first. Whatever puts those pairs next to each other in a reader's head, it was not their position.
Which leaves the name as the only lever on the list itself, and thirty-two of the 35 use it the same way: a namespace first, an action second. That sorts them into ten families. Six mot_* ticket tools, nine entity_* graph tools, four topic_thread(s)_*, three chat_*, three memory_*, two procedural_note(s)_*, two maintainer_*, and three families of exactly one member, graph_compact, profile_synthesize and surfacing_preview.
This is the real answer to "isn't 35 tools a lot." The count, on its own, was never the problem. An unsorted flat list is the problem, and a prefix solves it: a model that has just called entity_relate and wants the tools that act on the same edge finds entity_relate_confirm and entity_relate_reject by reading three names, before it reads a word of any description.
The obvious objection is that grouping is the protocol's job and the fix belongs upstream. Give MCP namespaces, or folders, and the prefix scheme becomes redundant. Half of that is fair. The protocol already knows this server has a name: the initialize response hands back serverInfo: { name: 'mot', version: '0.1.0' }, and a client mounting several servers at once has that much to keep one server's tools apart from another's. What it cannot do is reach inside. All 35 tools come back from one listMcpTools() on one server, so a server-level namespace puts all 35 in the same bucket and changes nothing about the list a model has to read. The grouping that matters sits one level below anything the protocol names, between entity_* and chat_* and mot_*, and the name is the only field with room to express it. Aggregating servers only makes it worse: mount three of them and the list gets longer, and none of the new length arrives sorted either.
That is also the ceiling of what a name can do. It buys clustering. It cannot say when in a turn to call the tool, why a field you expected is missing, what an opaque enum value means, what you get back when you pass nothing, or what a failure looks like. All of that has to go somewhere, and there is one field left.
What the schema can't say
On paper the description is documentation for a human skimming tools/list. In practice it is the last thing a model reads before deciding, which makes it a prompt. write_memory's description is three concatenated string literals, and only the first one is doing the documentation job:
Write a durable fact, preference, deadline, or person record to memory.
Call at the END of your reply, after answering the user.
chat_id is NOT an input — it is derived server-side from source_turn_id.
The second sentence is a sequencing instruction. Nothing about the type of a memory record implies when in a turn you should write one, and no field in a ToolDef is for control flow. It went in the description because the description is where a model will see it.
The third is stranger and more useful: it corrects a guess before the model makes it. The inputSchema for write_memory declares six properties and requires all six. chat_id is not among them. But chat_id is the required first argument of both chat_log_turn and chat_recent, and an optional filter on chat_search and memory_recent, so a model that has learned the rest of this surface has decent reason to think it belongs here too. The schema can say the field is absent. It cannot say the absence is deliberate, or that the value is being derived server-side from a field that is present. Absence is not an instruction. So the correction goes in the only field that can hold one.
The same field is where domain knowledge lives when there is no other place for it. mot_list_tickets filters by ministry, and the eight ministry values are opaque strings:
Filter by ministry. education=learning/school, commerce=customers/income,
plenty=bills/renewals, flow=dev/deploys, works=tasks, peace=health/personal,
interior=legal/admin, foreign_affairs=community.
A model reading the enum alone would file a health appointment under peace roughly by luck and a bill under plenty almost never.
The gloss is there because a wrong guess is otherwise the likely outcome.
Compare entity_confirm, whose description spends most of its length on something else entirely: "Returns { error: "not_found" } if the entity does not exist or is superseded. Returns { error: "already_confirmed" } if it is already confirmed." One description carries sequencing and a correction; the other carries the two failures the caller is expected to branch on. Same field, different job, because the field is the only channel either message has.
The general version, once, now that the cases have made it: a schema states what is present. It cannot say why something is absent, it cannot say when in a turn to call, and it cannot teach domain knowledge a model has no way to acquire. All three land in the field the model reads at decision time, whatever that field was nominally for.
A default is a promise, and it lives in prose
Adding semantic search gave three tools a new parameter. chat_search, memory_recent and entity_search each grew a mode property described like this:
Search mode. fts (default): keyword/FTS5. vector: semantic KNN. hybrid: RRF merge of fts + vector.
The enum lists three values. The parenthetical is what says which one you get when you say nothing, and the parenthetical is prose. JSON Schema has a default keyword and this surface does not use it once, on any tool. The defaults that do get announced are announced in an English sentence instead: Default 12. on chat_recent, Default 20. on memory_recent, worker defaults to "all" on maintainer_run, Defaults to open tickets on mot_list_tickets. The ones that go unannounced go unannounced everywhere. chat_search's limit carries minimum: 1 and maximum: 50 and no description at all, and the number 20 that fills it lives in the dispatcher, hundreds of lines away. A model gets the range and never the value.
The default is the whole design here. Every caller that has never heard of vector search keeps getting exactly what it got before, because the zero-argument shape of those three tools did not move. That claim holds in the dispatcher too, not just in the schema text: the mode branch fires only on 'vector' or 'hybrid', and an absent mode falls through to the same synchronous code path that was there before the vector index existed. The mechanism behind the new modes is its own story; the point here is that it arrived without touching what the old shape does.
There is a smaller thing in that string worth noticing. It appears three times in the file, character for character, once per tool. The file is perfectly willing to hoist a shared constant when it wants one, and does: MINISTRY_ENUM, STATUS_ENUM, SEVERITY_ENUM and PROVENANCE_ENUM all sit at the top and get referenced by name. The mode description was not hoisted, and neither was the ministry gloss, which appears twice under two different opening sentences. Three tools describe the same parameter identically because someone kept them identical. No type makes them match. That is the same discipline as the prefixes at a smaller scale, and it holds the same way: a person reads what is already there before adding the next one. Nothing else keeps three copies of a string in step.
The rule the case demonstrates: a tool surface a model has already learned evolves by adding an optional parameter whose default reproduces the old behavior exactly. It does not evolve by changing what happens when nothing new is passed.
A failure is either a plan or an exception
Nothing in a ToolDef describes what comes back. There is an inputSchema and no output schema, so the shape of a result, and the shape of a failure in particular, is either something the description declares or something the model guesses at. lib/mcp-tools.ts answers that two ways at once, and it names them itself. The comment in the middle of the dispatcher calls them Track-1 and Track-2:
// ── Track-2 tools (AC-12): these dispatch cases NEVER throw. The lib functions return
// typed { error } objects on failure; we return text() of them as a SUCCESSFUL MCP
// result (isError:false at the route level), so the caller gets structured JSON to
// branch on — not a plain string inside an isError:true envelope (the Track-1 pattern
// at app/api/mcp/route.ts lines 71–85, which the throwing cases above rely on).Track-1 is the earlier half of the file. Four of the tool cases contain a literal throw new Error(...): mot_get_ticket on a missing ticket, and mot_create_ticket, mot_update_ticket and write_memory on a Zod parse failure. Several of their neighbours never throw explicitly but have no try/catch either, so an underlying failure propagates the same way. The handler catches all of it and returns a technically successful JSON-RPC response with isError: true and the exception message as free text. Its comment explains why that is correct: "the tool call itself succeeded; the tool reported failure." It is correct, per the spec. It also hands the model a sentence to parse.
Track-2 never throws. Failures come back inside a normal isError: false result as a typed object, and the description told the model to expect them, because the description was the only place that announcement could live. entity_supersede checks for a self-supersede first and returns { error: 'self_supersede' } before it touches storage, then { error: 'not_found', id } or { error: 'target_not_found', superseded_by_id } depending on which id missed. Three named failures, distinguishable without reading English.
The convention was worth enforcing one layer earlier than the handlers, too. Before the dispatch switch runs, an ARG_SPECS table checks argument shape for thirteen tools and returns { error: 'invalid_arg', arg: spec.name } if a required string is empty or a required integer is not one. A malformed call never reaches its case. Which thirteen is its own tell: all nine entity_* tools, the three topic_thread_* tools that require an argument, and procedural_note_confirm. Not one mot_* or chat_* tool is in the table. The guard was built around the newer half of the file, and the table's own comment names the exception it makes on purpose: "The three Zod-validated tools (mot_create_ticket, mot_update_ticket, write_memory) validate inside their own cases and are deliberately absent from this table."
Those three are three of the four cases that still throw. They validate with Zod inside their own cases because they were written that way first. And the pre-dispatch guard was added later, around them.
Running two conventions at once has a cost, and the caller pays it. Nothing in a ToolDef says which track a tool is on. entity_confirm declares its two failures in its description; the mot_get_ticket entry quoted whole at the top of this piece says nothing about a missing ticket, and what a missing ticket does is throw. So the same model, calling the same server, gets a parseable object from one half of the list and an English sentence from the other, with nothing in the list to say which is coming. The seam runs where the ARG_SPECS table runs, which is why not one mot_* or chat_* tool is in it.
The line between the two conventions is not where the code happened to land. It is where a failure stops being exceptional and becomes a plan. A model can branch on a stable string the description told it to expect. It cannot reliably parse the same free-text sentence the same way twice.
Three names that don't fit
Three of the 35 put the verb first: write_memory, summarize_and_archive and notify_robin. They are not the same mistake, and the difference is the point of the prefix.
notify_robin costs nothing. It has no siblings and never will, so the clustering a namespace buys is clustering it has no use for. graph_compact, profile_synthesize and surfacing_preview are singletons that take the house shape anyway and read as families with one member. notify_robin reads as a verb, and in its case that is free.
write_memory is the expensive one. It has three noun-first siblings, memory_recent, memory_context and memory_profile, which a model scanning by prefix will find together, and the tool that writes to that same store sits eleventh, under a different prefix entirely. It is also the tool that sits outside ARG_SPECS and inside the throwing set, so the name is the cheapest of its three inconsistencies. summarize_and_archive has no namespace at all: a verb, a conjunction and another verb, with nowhere to sort.
Both of the costly ones sit tenth and eleventh in the array, inside the stretch the file's own comments treat as Track-1, written before it had started labelling tracks at all. Array position is the only chronology on offer here, and by that measure everything from the twelfth entry on is namespace-first, the three singletons included. The one exception is notify_robin. There, the shape buys nothing.
Nothing in either file enforces any of it. ToolDef types the name as string, callMcpTool takes a string, and the only check on a name in 1,038 lines is the switch's default: case, which throws Unknown tool when it has no case for one and asks nothing at all about the shape of one it has. A thirty-sixth tool is free to pick an eleventh prefix, or to throw where all of its neighbours return a typed error, and both the build and the deploy will be fine. The cost lands on the next model to read the list instead. The pattern has held for one reason: whoever added the next tool read the ones already there and matched them. That is a real cost of the design, and the three names already in production are the receipt.
The test that matters
The test was never whether the schema is well documented. It is whether a model holding nothing but that JSON array can make the right call on the first try, and recover cleanly from the wrong one, because a typed { error: "not_found" } is what it reads on the next turn to decide what to do next, and a free-text sentence is what it guesses at.
Whether it does is not something this code can tell you. The route logs one line per call before it dispatches, [MOT/MCP] call: and then the tool name and the argument keys, and a second line only when something throws. Keys, not values: you can see that a call passed mode and never which mode, and the zero-argument case the whole default design turns on writes the shortest line of all. Track-2 thins it further. A typed { error: 'not_found' } comes back as a successful result, so it never reaches the error log at all, and the convention that makes a failure legible to the model is the same one that makes it invisible to the operator. What would settle the question is traces of a model's decisions against this exact array of 35, and neither of these two files produces them. This is a reading of the surface, not a measurement of it.
The protocol asks for three fields. One buys clustering in a list that arrives unsorted. One describes the shape of an argument and stops. Everything else that decides the call, when to make it, why an expected field is missing, what an opaque value means, what happens when you pass nothing, what a failure looks like, is a sentence in the third. Which is also the only one of the three that nothing in the system ever checks.