# The MCP context window problem, and the fix

> Connect a few dozen apps and the MCP context window fills with tool definitions before anyone speaks. Here is the search-and-execute fix, and the ranking bugs that nearly spoiled it.

**TL;DR** Past a threshold, advertising every tool individually stops being viable, so the connected tools collapse behind two meta-tools and the model searches instead of reading a list. The interesting part is not the collapse, it is the ranking. A lexical search with no relevance floor always returns something, and something from the app you did not ask about is worse than nothing, because the model calls it.

There is a failure in enterprise MCP deployments that nobody writes about, because it does not look like a failure. Everything works. The model is just worse. The cause is the MCP context window, and what is filling it before anyone has spoken.

It happens like this. A company connects its CRM, its helpdesk, its issue tracker, its payment processor and its file storage. That is five apps and perhaps three hundred tools. Every one of those tools ships a name, a description and a full JSON Schema for its arguments. The schemas are the expensive part; a tool with a dozen optional filter parameters is not a line, it is a page.

All of that arrives before the user has said anything.

The model now opens every conversation carrying a document it did not ask for, in which the thing it needs is one entry. Answers get worse, not because the model got worse, but because the signal-to-noise ratio of its input collapsed. And the cost is paid on every single turn.

## The obvious fix, and why it is only half a fix

The fix is to stop advertising everything. Past a threshold, collapse the connected tools behind two meta-tools: one that searches the catalog and one that executes a tool by name. The model asks for what it needs, gets a handful of matching definitions back, and calls one.

We collapse past **thirty tools**, and there are two details in that number that are easy to get wrong.

**It counts everything together.** Control-plane operations and connected tools are added up before the comparison. Since the control-plane catalog is itself dozens of operations, one connected app is usually enough to cross the line. Collapse is the normal state of a working deployment, not an edge case for large customers. Documentation in this category often implies the opposite, and it leads people to test with two tools connected and conclude the behavior does not apply to them.

**Only half of it collapses.** Control-plane operations stay listed individually, always. They are how a model orients itself, and putting the map behind the search is a good way to make a model that cannot find anything. The search indexes connected tools only and will never return a control-plane operation. We repeat that in the tool description itself, because models kept assuming otherwise.

One thing that is deliberately not clever: **executing by name is only a naming indirection**. It unwraps to the same tool name and the same arguments and then falls through the identical gates as a direct call. There is no separate execution path, and therefore no second place where a permission check could be forgotten. The temptation to give the meta-tool its own fast path is real and should be resisted; it is exactly how you end up with a bypass.

## The ranking is the hard part

Collapsing the list is a few hundred lines. Making the search good enough that a model can rely on it is where the work is, and where we got things wrong twice.

The scoring is lexical over three fields: the tool name, its description, and the connector it belongs to. An exact name match is worth five, a prefix match in either direction is worth three, a connector match two, a description match one. Nothing exotic.

Here is what was missing.

### A ranking with no floor always returns something

A user had exactly one app connected: Notion. They searched for `list_all_cal_com_schedules`.

They got back `list_all_notion_users`.

Read the query as tokens and it is obvious. `list` and `all` matched, and scored five each on a name match. `cal`, `com` and `schedules` matched nothing at all. The tool had a healthy-looking score built entirely out of the two most generic words in the query, and the result list was then sliced to the requested limit regardless of how weak the scores were.

This is the characteristic failure of lexical search in this setting. **A ranking with no floor always returns something.** In an ordinary search box that is mildly annoying, because a person looks at the result and rejects it. Here the consumer is a model, and the model calls it. Something from the app you did not ask about is strictly worse than nothing, because nothing is a signal the model can act on and a wrong tool is a signal it cannot.

The fix is a relevance floor. Each query token is weighted by how rare it is across the index, in the usual inverse-document-frequency way, and a result has to account for at least half of the query's total weight to be returned at all. In the example, `cal`, `com` and `schedules` carry most of the weight precisely because they match nothing, so no Notion tool can clear the bar and the search correctly returns empty.

There is a nice property in that: a token that matches nothing is worth *more* than the rarest token that matches something, since its document frequency is below one. The floor fails on exactly the tokens that should cause it to fail.

### Scaffolding words poisoned the scores

The second bug was subtler and took longer to find.

When one tool is backed by several connected accounts, or has frozen arguments, its description carries generated scaffolding: something like `[Work Notion / Personal Notion, set connection]` or `(frozen: folder_id=HR)` or `(+5 more)`. That text is functional. It tells the model there is a choice to make.

It also meant that four words appeared in nearly every description in the index. So a query containing the word "connection" gave a uniform point to every multi-account tool at once. The scores tied. The tie broke on name order. And the model was handed **an arbitrary account**, deterministically, in a way that looked like a ranking decision.

Four words are dropped from description tokens at index time: `set`, `connection`, `frozen` and `more`. Only from descriptions, and only those four.

What is deliberately kept scorable is the part of that scaffolding that carries information. The account labels stay, because "Work Notion" and "Personal Notion" are exactly what distinguishes two otherwise identical tools. The frozen keys and values stay, because `folder_id=HR` and `folder_id=LEGAL` are the difference between two variants of the same tool and a user searching for one of them means it.

### Tie-breaks decide what the model sees

A small one, with an outsized consequence.

When two tools score the same, something has to order them, and the ranked list is then cut to a limit. That means the tie-break does not decide presentation order. It decides **which tools the model sees at all**.

Advertised tool names use letters, digits, underscores and hyphens, which is precisely the character set that locale-aware string comparison reorders. Under one collation `Get_Page` sorts before `get_alerts`; under another it does not. `list-files` and `list_files` swap depending on locale.

So the tie-break compares codepoints, never locale collation. A search result set that varies with the server's locale is not a bug you would ever find from a bug report.

## What a tool list costs in the MCP context window

Worth being concrete about the size of the problem, because the instinct is to
assume a tool definition is a line or two.

A tool definition is a name, a description, and a JSON Schema for its arguments.
The name and description are cheap. The schema is not. A list operation with a
dozen optional filters, each with a type, a description and sometimes an
enumeration of allowed values, is a page. A create operation for a rich object
can be several.

Multiply by an app. A mature helpdesk connector exposes hundreds of operations.
A payment processor exposes more. Connect four or five systems and the advertised
surface is not a list, it is a reference manual, delivered before the user has
typed anything, on every turn.

Two costs follow, and the second is the one people miss.

**The obvious cost is the budget.** Whatever the window, the fraction of it
holding tool definitions is a fraction not holding the conversation, the
documents, or the results of previous calls. On a long support conversation that
is the difference between the model still having the original ticket in view and
not.

**The less obvious cost is attention.** A model choosing between four hundred
similarly named operations is doing a harder task than one choosing between
eight. Retrieval quality degrades with candidate count in a way that is easy to
miss in testing, because a test with two connected apps never reproduces it. The
failure does not look like an error. It looks like the model picking a
reasonable-sounding neighbor of the right tool.

Collapsing the list improves both. The budget one is arithmetic. The attention
one is why this is worth doing even when the window is large enough to fit
everything.

## Why the collapse is a saving rather than a relocation

One risk in this design deserves stating, because it is the thing that would quietly undo the whole exercise.

Moving tools out of the advertised list and into search results only saves context if a search result is smaller than the listing it replaced. That is not automatic. A tool backed by fifty accounts has a long list of account options in its schema. If a search returned fifty rows of such tools, you would have rematerialized a hundred copies of that option list inside a tool **result**, which no prompt cache covers, having removed them from a listing that the cache did cover.

So every part of a result row is bounded. The account list is included only when more than one account backs the tool, and the row carries a count rather than an unbounded enumeration past a point. The saving is real because the row is bounded, not because the list is shorter.

## The part clients make harder

The threshold moves on its own. Someone's role is narrowed and the control-plane operations they can call drop. An account is disconnected. Two accounts of the same app get merged behind one tool with an account argument, which turns two tools into one. Any of these can push the count back under thirty, at which point the surface stops being collapsed.

Meanwhile, **MCP clients cache the tool list hard**. Some ignore change notifications entirely; others cache until the connection is remade. So the first call after any of those changes is very often made against a stale list.

The concrete case: a client that was told the surface was collapsed calls the search tool, but by now it is not collapsed and there is no such tool.

The wrong answer is a protocol-level invalid-parameters error. That never reaches the model as a tool result; it surfaces as a transport failure, and the only move available is to retry the identical call, which fails identically.

The right answer is to respond in band, as a normal tool result carrying an error, whose text explains that the list is cached from before the threshold moved and that the tools are now listed directly. The model reads it and adapts. Those two names are the only ones whose meaning depends on the collapse state, which is why they are the only ones that force the surface to be resolved before anything else is decided.

The same principle applies to unknown tool names generally. A call to a name that does not exist returns the names that do, up to a bounded number, plus a note that the list is filtered by role and by granted scopes, so a name you expected to find may be a permissions gap rather than a typo. Answering "unknown tool" and stopping reads as a typo and sends the model around the same loop.

## What to take from this

If you are building or evaluating anything that puts a lot of tools in front of a model:

- **Assume collapse is the normal state.** Test with a realistic number of tools connected, not with two.
- **Put a relevance floor on any lexical search a model consumes.** Returning nothing is a valid and useful answer. Returning a confident wrong answer is not.
- **Audit your own generated description text for words that appear everywhere.** Anything you inject into every description is a word that no longer discriminates, and it will tie scores together in ways that look like ranking.
- **Never use locale collation where the result is sliced.** The tie-break is a visibility decision.
- **Answer stale calls in band.** Clients cache; the stale call is the normal first call after a change, not an anomaly.

The governance side of what a model is allowed to see once it has found a tool is in [least privilege for AI agents](/blog/least-privilege-for-ai-agents/), and what gets recorded when it calls one is in [what an AI audit log has to capture](/blog/what-an-ai-audit-log-must-capture/). The [connector catalog](/connectors/) lists the tools each connection exposes, which is the corpus this search runs over.

## FAQ

### At what point does the tool list collapse?

Past thirty tools, counting the control-plane operations and the connected tools together. Because the control-plane catalog alone is dozens of operations, one connected app is normally enough to cross it. Collapse is the ordinary case rather than an edge case, which is worth knowing because a lot of MCP writing assumes the opposite.

### Does the model lose access to tools it cannot see in the list?

No. Everything is still callable; what changes is how it is found. The model searches for a tool by name or purpose and then executes it by name. Control plane operations stay listed individually the whole time, so the things a model needs in order to orient itself never go behind the search.

### Is execute_tool a second execution path with different permissions?

No, and this matters. It is only a naming indirection: it unwraps to the same tool name and the same arguments and falls through the identical gates. There is no privilege in going through it, and a client that reaches a tool by its direct name gets exactly the same checks.

### Why is the search lexical rather than embedding-based?

Because the corpus is tool names and short descriptions, which is close to the case lexical retrieval was designed for, and because a deterministic ranking is much easier to reason about when a wrong answer means the model calls the wrong API. Embeddings are a reasonable future direction, but the failure modes below are ones you would still have to solve.

### My client is not showing a tool I know exists. Why?

Most often the client cached the tool list at connection time and has not refreshed it. Start a new conversation or reconnect. If it still does not appear, the tool may be withheld by a restriction for your role, in which case it was never advertised to you in the first place.
