Chapter 1 of 6 · 17 min read
Orthogonality in a tool surface
On this page
An MCP server is a program you write and run that fronts an existing system — an issue tracker's REST API, a deployment platform, a documentation index — and advertises what that system can do to a language model over the Model Context Protocol, or MCP, whose JSON-RPC conventions the earlier topics of this part set out. A client starts the server and asks it for a list, and the server answers with one entry per operation it offers, each entry carrying a name, a description of a sentence or two, and a JSON Schema for its arguments. Those entries are the tools, and the list of them taken together is the server's surface, which is the useful word for it because the list is precisely what shows. The model reads the names, the descriptions and the schemas, and it never reads your handlers, your database, your README, or the conversation in which you decided which operations were worth exposing.
A layer like this exists because the description of an operation has to live somewhere the caller can actually read it. Without the server, every assistant that wants to touch the tracker needs its own glue code and its own account of what "close an issue" means; with it, the operations are described once, in a form any MCP client can discover at connection time and any model can call. You meet a surface whenever an assistant in your editor can read your tickets, an on-call bot can pull logs, or a chat application can file a ticket for you — a server somewhere handed the model a list of tools, and the model picked one off it. The surface is therefore the design artefact this topic works on, and the first property to get right about it is whether any two of its entries are competing for the same job.
Tools are one of MCP's primitives, alongside resources and prompts, and the detail that drives this whole chapter is an asymmetry in what the protocol provides for them. A tool call is two decisions taken together: which tool, and with what arguments. The protocol gives the second decision a machine-checkable contract — the JSON Schema in the tool entry, which a client or a server can validate a call against and reject with a message saying which field was wrong. It gives the first decision nothing of the kind. There is no schema over the choice, no field to constrain it, and no response code for you picked the wrong tool, because a call to a tool that exists, with arguments that validate, is a well-formed call by every rule the protocol has. So when two entries both fit a request, nothing on either side of the connection is arbitrating between them, and the only thing that can is whatever ranking the model does over strings it has just read.
The tie a surface can create
Consider an issue-tracker server with four read tools, and an eval suite — a fixed set of tasks,
replayed against the server, scored on which tool was called and what came back — of 120 tasks, 14
of them phrased the way a project manager actually phrases things: "what is Priya working on this
sprint", "is anyone still on the billing migration", "show me Dan's open bugs". On nine of those
fourteen, the model calls search_issues with the query string Priya. That tool runs full-text
search over issue titles and bodies, so it returns 23 issues, six of which are assigned to her and
seventeen of which merely mention her in a description or a comment. The five issues assigned to
her that never spell her name in the text do not appear at all. find_issues_by_assignee exists,
takes a user id, and would have returned exactly the eleven open issues she owns.
Those numbers decompose twice, and setting the two decompositions side by side is what makes the damage legible, because neither is visible in the result the user sees. Count the returned set first: twenty-three came back, six of which are hers and seventeen of which are not, and six plus seventeen is the twenty-three with nothing left over. Now count the true answer: eleven issues are assigned to her, six of them appeared and five did not, and again the parts close on the whole. The six is the same six in both sums, which is what makes them two views of one picture rather than two unrelated tallies. Read as fractions, six out of twenty-three means roughly a quarter of what came back belonged in the answer, and six out of eleven means a bit over half of the answer came back.
What matters more than either fraction is that the two kinds of error are invisible in opposite ways. The seventeen extras are real issues with real titles that genuinely mention Priya, so they read as a slightly long but reasonable list — there is nothing in them to notice. The five missing ones leave no trace at all: an absent row has no length, no highlight and no error. So a user who wanted a status update gets a list that is wrong in both directions and looks right in both, and no downstream check exists that could compare it against the eleven, because the eleven were never computed.
Nobody wrote a bad tool there. search_issues does full-text search correctly,
find_issues_by_assignee does an indexed lookup correctly, and both have descriptions a reviewer
signed off on. The failure is in the moment before either of them ran, when something had to decide
which of four names answered the question, and that decision had no schema, no validation and no
error path. It produced an answer that looked like an answer.
Stating the overlap precisely
When a colleague says a tool set is "not orthogonal", the claim is specific and checkable: some task in scope has two reasonable tools, or some tool's job overlaps another's, so the caller is left to break a tie that the surface should have settled for them. The word is borrowed from linear algebra, where orthogonal vectors have no component in common, and the analogy holds at exactly the right level of precision — each tool should cover a direction of capability that no other tool covers any part of.
Being an analogy, it breaks somewhere, and it breaks in two places worth knowing before leaning on it. The first is that orthogonality of vectors is computed from the vectors: the answer is in the objects, and two people who do the arithmetic get the same result. Tool overlap is not in the objects. It is a judgement about how a sentence will be read by a caller who has only that sentence, so two careful reviewers can mark the same cell differently and both be reasonable — which is exactly why the test below is a marked table that people fill in rather than a property you can compute from the registration JSON. The second is that an orthogonal set of vectors can still fail to span its space, and the same holds here: a surface can be perfectly orthogonal and still be missing a job. A gap of that kind shows up as a row with no marks at all, which the table does record — but only if the task made it onto the list, and the sources named below are all records of what people asked of the system as it already is. A capability nobody has ever had is a capability nobody files a support message about. So the table is a reliable instrument for collisions and a partial one for gaps, and the two failures need different evidence.
The way to test the claim is to write the tasks down and mark, for each one, every tool that a careful reader would consider defensible. The tasks are not invented for the exercise, because invented tasks tend to be phrased in the vocabulary of the tools they were invented to justify. They come from whatever record already exists of people asking for the thing: messages in the team's support channel, the eval suite if there is one, the endpoints the old REST API served most often, the saved filters in the tracker's own UI. Twenty to forty tasks is enough for the shape to show, and the marking rule matters more than the count — a cell is marked plausible when a reader holding only that tool's name and description would consider the call defensible, which is a lower bar than "returns the best result". The model is choosing under exactly that constraint, since only the name, description and schema cross to it.
| Task in scope | list_issues | search_issues | find_issues_by_assignee | get_sprint_issues |
|---|---|---|---|---|
| Priya's open issues this sprint | plausible | plausible | correct | plausible |
| Everything in sprint 24 | plausible | — | — | correct |
| Issues mentioning "OAuth refresh" | — | correct | — | — |
| Open bugs, any assignee | correct | plausible | — | — |
| Dan's bugs in the current sprint | plausible | plausible | plausible | plausible |
Four of the five rows have more than one defensible answer, and the last row has four. That table is what non-orthogonal means, and it is worth producing before any description work, because it tells you whether you are looking at a wording problem or a shape problem. A surface where every row has exactly one plausible entry can be improved by better descriptions. A surface that looks like this one cannot, since improving a description shifts the ranking between two candidates without removing the candidate.
That belief — sharper wording will fix it — is the one to expect in yourself and in the first review comment, and it is worth understanding why it is tempting rather than simply being told it is wrong. It is tempting because everywhere else in a system, an ambiguity between two things a caller might mean is a documentation defect, and writing the documentation is the fix. It is tempting for a second reason that is harder to argue with: sharpening does move the number. Some fraction of the calls that were going to the wrong tool start going to the right one, so the first re-run shows an improvement and the approach looks validated.
What is actually true is a statement about what the operation can do. Ranking takes a fixed set of candidates and puts them in an order. Nothing in an order deletes a member, so both tools remain answers to the request, and the effect of better wording is to change how often the wrong one wins rather than whether it can. That is why the improvement is real and partial at the same time, and why the residual does not go to zero however many drafts you write: each draft is competing against a phrasing of the task you have not seen yet. Removing the tie means removing a candidate, which is a change to the list rather than to any entry in it.
The overlap here has a particular structure. Three of the four tools are the same query with a
different predicate pinned open: get_sprint_issues is a filter on sprint, find_issues_by_assignee
is a filter on assignee, list_issues is a filter on status with everything else unbound. Only
search_issues does something genuinely different, because relevance ranking over text is a
different operation from filtering on structured fields. The four names are one job wearing four
hats.
Collapsing the set into a filter object
Here is what the four look like when registered, trimmed to the parts that matter:
[
{ "name": "list_issues",
"description": "List issues, optionally filtered by status.",
"inputSchema": { "type": "object",
"properties": { "status": { "type": "string" }, "limit": { "type": "integer" } } } },
{ "name": "search_issues",
"description": "Search issues by keyword.",
"inputSchema": { "type": "object",
"properties": { "query": { "type": "string" } }, "required": ["query"] } },
{ "name": "find_issues_by_assignee",
"description": "Find issues assigned to a user.",
"inputSchema": { "type": "object",
"properties": { "assignee": { "type": "string" } }, "required": ["assignee"] } },
{ "name": "get_sprint_issues",
"description": "Get all issues in a sprint.",
"inputSchema": { "type": "object",
"properties": { "sprint_id": { "type": "string" } }, "required": ["sprint_id"] } }
]The replacement is one tool whose parameters are the union of the predicates, with the text search kept as a field rather than a separate tool:
{
"name": "query_issues",
"description": "Query issues by any combination of filters. Filters combine with AND. Omit a filter to leave that dimension unconstrained.",
"inputSchema": {
"type": "object",
"properties": {
"assignee": { "type": "string", "description": "User id or email, e.g. priya@example.com. Use the exact id from list_users." },
"status": { "type": "string", "enum": ["open", "in_progress", "blocked", "closed"] },
"sprint_id": { "type": "string", "description": "Sprint identifier, e.g. SPR-24. Use \"current\" for the active sprint." },
"labels": { "type": "array", "items": { "type": "string" } },
"text": { "type": "string", "description": "Free-text match against title and body. Ranks by relevance." },
"updated_since": { "type": "string", "format": "date-time" },
"limit": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 }
},
"additionalProperties": false
}
}The Priya task now has one shape and no competitors:
{ "name": "query_issues",
"arguments": { "assignee": "priya@example.com", "sprint_id": "current", "status": "open" } }What changed is where the decision lives. In the four-tool surface, the model produced a tool name by
ranking four strings against a request, and nothing on the server could tell whether the ranking was
right — search_issues with query: "Priya" is a perfectly valid call that returns a perfectly valid
result set, so the failure surfaces as a wrong answer several turns later, if at all. In the one-tool
surface, the model produces field values, and the schema checks them. A misspelled field is rejected by
additionalProperties: false. A status of "done" is rejected by the enum with a message naming the
four accepted values. A limit of 5,000 is clamped by maximum. The decision moved out of unvalidated
ranking and into a place where a machine checks the work and can say what was wrong.
The two surfaces also fail differently, and the difference is mostly about how long a defect survives before anyone notices it.
| What goes wrong | Four tools | One tool |
|---|---|---|
| An assignee question routed to text search | A valid call returning 23 plausible issues, no error anywhere | Not expressible; assignee is a field, not a competing tool |
A status the server does not recognise, such as done | Accepted by status: string, returns an empty list that reads as "no issues" | Rejected at validation, with the four accepted values named |
A misspelled parameter, such as asignee | Ignored by most handlers, returns the unfiltered list | Rejected by additionalProperties: false |
| A limit of 5,000 | Whatever the handler happens to do, often a 30-second query | Clamped by maximum: 200 before it reaches the database |
Only the first row is about selection at all; the rest are ordinary input errors that the four-tool surface was equally capable of catching and did not, because loose schemas and tool proliferation tend to arrive together on the same afternoons. What the rows share is that the four-tool version turns a mistake into a well-formed wrong answer, and the one-tool version turns it into a rejection the caller can read and correct. A wrong answer costs an entire agent run and a conversation with the user about why the numbers looked odd; a rejection costs one extra call.
The misspelled-parameter row deserves its own step, because the reason it is silent is a
property of filters rather than an accident of any particular handler. A filter is
subtractive: supplying one removes rows from the result, and supplying none returns
everything. A handler that does not recognise asignee drops it, which means one subtraction
does not happen, which means the result set is larger than it should be. Errors in
filtering therefore have a direction. A lost filter never produces an empty list or a
missing-field error — the two shapes a caller would notice — it produces a longer list, and
a longer list is exactly what a caller cannot distinguish from a correct one. Every unknown
key that a schema silently discards is a permission granted by omission, which is what
additionalProperties: false is for: it converts a silent widening into a named rejection.
Where collapsing is the wrong move
The pattern is easy to over-apply, and the over-applied version is a single issues tool with an
action enum of query, create, close and comment, plus a bag of optional parameters that
different actions interpret differently. That surface is smaller, and it is not more orthogonal, because
it puts the same ranking problem inside a parameter where the schema is even less able to check it. A
JSON Schema cannot cleanly express "body is required when action is comment and forbidden otherwise" in
a form every client validates, so the required-field checks that made the collapse worth doing go away,
and a model that picks close when it meant comment gets no error at all. The four query tools
collapsed well because they were one job with different predicates; query and create are two jobs, and
jobs are what a tool should be.
Orthogonality is also not the same as minimalism, even though the example above reduced a count. A twelve-tool surface where each tool owns a distinct job — query, create, transition, comment, attach, link, watch, and so on — is orthogonal, and a three-tool surface with two overlapping reads is not. Counting tools tells you about context cost and about the probability of a collision; it does not tell you whether a collision exists. The table of tasks against tools does.
The token figures from that rewrite make the point about counting concrete, and they do not divide the way people expect. Four definitions cost about seven hundred and twenty tokens, which is around a hundred and eighty each. Collapsing them to one did not therefore cost a hundred and eighty: the survivor came out at about three hundred and ten, because every predicate that had been a tool became a documented field on the tool that remained, and the description grew to explain how the filters combine. So the saving is roughly four hundred and ten tokens, a bit under sixty per cent, rather than the three-quarters a naive four-into-one would suggest. The general shape is that a collapse moves description weight from the list into one entry and keeps some of it, which is worth knowing before promising a context budget a consolidation will not deliver.
There are real costs to the collapse, and the honest one is that you have absorbed a routing decision
rather than eliminated it. text runs against a search index that takes 180–400 ms; the structured
filters run against Postgres indexes that answer in 20–40 ms. A caller who supplies both gets whichever
plan your server chooses, and now that plan is your problem. This is a good trade because it is a
decision you can measure, log and change in one place, where the model's tool choice was a decision you
could only influence through wording and observe through evals. It is still a decision, and pretending
the filter object made it disappear leads to servers with a text field that quietly turns every query
into a full-text scan.
The size of that trade is worth reading off the two bands rather than eyeballing them, and
the useful discipline is to compare like ends. The fast end of search against the fast end of
the index is one hundred and eighty milliseconds against twenty, which is a factor of nine;
the slow end against the slow end is four hundred against forty, a factor of ten. Because
both comparisons land in the same place, the ratio is stable across the range and the
statement to carry is that text costs roughly an order of magnitude more than a structured
filter, whatever the load. That is what makes the plan choice worth writing down: a query
carrying both a text value and an assignee value has a fast plan and a slow one available
to it, they differ by about that factor, and if your server does not choose deliberately then
it is choosing anyway.
Finally, none of this substitutes for the naming and description work of topic 6.4, on tools over MCP. A
single query_issues with a vague description and undocumented field formats is a worse tool than four
sharp ones; the collapse removes the tie, and the remaining tool still has to be legible on its own.
Orthogonality is therefore a property of the whole list rather than of any entry in it, which is why it cannot be reviewed one pull request at a time and why the table is the instrument. Write the tasks down, mark every defensible tool for each, and the shape of the surface tells you which problem you have: a row with one mark and a poor result is a description to rewrite, and a row with three marks is a design to change. Where the marks cluster, the tools underneath them are usually one job with different predicates pinned open, and a filter object with a validating schema is the form that job actually wants.
With four tools, the consolidation is obvious enough that most engineers would arrive at it by reading the list. The interesting case is the server that got to nineteen tools one reasonable afternoon at a time, where no single addition looked like the one that broke it, and where the cost of each addition falls on every tool already registered.
Building a server — read the current version at https://daviddominguez.dev/learn/en/building-a-server/orthogonality-in-a-tool-surface