871 MCP Tools, Near-Zero Context Window: Discover-First Tool Discovery
An MCP server with hundreds of tools burns a fixed slice of the context window before the task even starts, and tool-selection accuracy degrades past a few dozen tools and collapses past a hundred. Here is how I covered all of Google Workspace (871 tools across 28 services) while keeping the idle context cost near zero, and generated the long tail instead of hand-writing it.
An MCP server that eagerly registers hundreds of tools quietly poisons the context window of the assistant it is meant to help. Past a few dozen live tools, selection accuracy gets worse, not better. That is the tension I ran into building mcp-google-multi: I wanted a single server that covers all of Google Workspace, every method the OAuth surface can reach. That is 871 tools across 28 services. And I wanted the idle cost of having all of it available to sit near zero.
This piece is about how those two goals got reconciled. If you want the product tour, multi-account, install, what it actually is, that lives in the case study. Here I want to stay on the engineering: the context-window math, generating the long tail from Google's own API descriptions, and the discover-first architecture that keeps 871 tools from ever touching the context window at rest.
More tools is not more capability
The instinct, when you set out to cover an entire API, is that more tools equals more capability. For an AI assistant it is closer to the opposite. Every tool definition you register is text that gets serialized into the model's context before it reads a single word of the actual task. Register enough of them and two things happen: you burn a large, fixed slice of the context window on schemas the assistant will never call in this conversation, and you make its job of picking the right tool harder by drowning the good candidate in noise.
Published research puts numbers on this. Tool-selection accuracy has a knee somewhere around 20 to 30 live tools and degrades sharply past 100; on one 527-tool benchmark, roughly half of all failures were the model reaching for the wrong tool, not calling the right tool incorrectly. Anthropic's own tool-search work reports the same shape from the other direction: deferring tool definitions and loading them on demand recovered accuracy substantially across models, one result going from about 49 percent to 74 percent, while cutting tool-definition tokens by roughly 85 percent, from around 77,000 to under 9,000. My server runs inside that mechanism, which made the lesson concrete. The obvious way to ship exhaustive coverage, loading 871 schemas up front, would have made the product measurably worse than the leaner curated version it grew out of.
Why I stopped hand-writing tools
The project did not start at 871 tools. It started as a curated set, one file per service, each tool hand-written with an ergonomic interface: gmail_search, drive_read, calendar_quick_add. That approach is the right one for the operations people reach for every day, and it carried the project a long way. Gmail, Drive, Calendar, Sheets, Docs, Contacts, and Search Console came first, and by the time Tasks, Meet, Forms, Chat, and Admin joined them the curated set was about 175 tools across a dozen services.
Then I did the arithmetic on finishing the job. Covering every OAuth-reachable Workspace method by hand meant writing something like seven hundred more tools, each one a schema, a handler, and a maintenance liability, and each one going stale the moment Google shipped a new method. Hand-curation does not scale to an entire API surface, and worse, most of those tools would be for methods almost nobody calls. I did not want to spend a month writing the long tail. I wanted to generate it.
Generating the long tail from Google's Discovery documents
Google publishes machine-readable descriptions of its own APIs, called Discovery documents: for each API, a JSON file listing every method, its HTTP verb, its path and query parameters, its request and response shapes, and its required OAuth scopes. That is exactly the raw material a tool generator needs.
So I built a build-time generator. It reads a local snapshot of the Discovery documents for the Workspace APIs, around thirty of them that collapse into 28 services, then emits one tool per method: a name of the form {service}_{resource}_{method}, typed fields for the path and query parameters, a loose JSON body for the request (deliberately not exploded into a mega-union of every request variant), an always-available field mask, and a read-or-write classification. Any method that a superior hand-written tool already covers is skipped, so the generator only ever fills the gap the curated layer leaves.
That produced 689 generated tools across 26 of those services, which together with 182 curated tools, a few more added alongside the generator, is where the 871 total comes from. The remaining two services, Docs and Slides, emit zero generated tools because they are fully curated: the hand-written versions do things generated code gets wrong, like summary-first document reads for Docs and a per-slide text digest for Slides.
Three properties made the generated layer something I trust rather than something I babysit. It is deterministic: stable sort order, stable escaping, so regenerating against a new Google API revision produces a clean, reviewable diff. The generated files are never hand-edited: corrections (descriptions rewritten for a model rather than a human API reader, task keywords, classification overrides) live in a separate side-file, so regeneration never clobbers hand-tuning. And the coverage document is itself generated from the live tool registry, which means the docs cannot drift from the code. That last property caught a real bug, which I will come back to.
The sharp edges of generating a tool surface
Generating hundreds of tools from a real API surfaces edges you never hit hand-writing forty.
Read versus write classification cannot be inferred from the verb or the name. This is the one that actually matters for safety, and I came to think of it as naming is security. My curated tools inferred their create, update, or delete class from the tool name with verb regexes, which is fine when you control the names. It is unsafe the moment names come from an API: a POST is not always a create (plenty of POST methods delete), and operation names like transfer or an undeploy-style verb match no known pattern and would silently classify as reads. A write misread as a read walks straight past a deny-by-default write gate. So generated tools derive their class from HTTP semantics at generation time, not from the name: GET and HEAD are reads, DELETE is a delete, PUT and PATCH are updates, and ambiguous POST methods are classified by their last path segment so a POST-as-delete like batchDelete is still gated as a delete.
The MCP tool-name limit is 64 characters, and you do not find that out until a deeply nested method blows past it. Seven Google Classroom methods, the add-on attachment student-submission endpoints, generated names longer than the limit. The generator reports over-length names as a build-visible class with an explicit override, rather than truncating blindly, because blind truncation would just manufacture a second collision problem.
And collisions are the third edge. A generated full-PUT tool for the directory users update method derived the name admin_users_update, which was already the name of a curated tool wrapping the safer patch method. The generated tool won the registry map and silently overwrote the curated one. Nothing errored. What caught it was the generator's own tier-classification pass: reconciling generated against curated, it flagged the name collision, and the curated count had quietly dropped by one, from 182 to 181, with the total falling to 870. I renamed the generated tool to admin_users_replace and made the generator fail the build on any generated-versus-curated name collision, turning a silent data-loss bug into a loud build failure.
Even with 689 generated tools, I wanted a guarantee that no method could be unreachable. Two eager tools provide it: one searches Google's Discovery index for a method by intent, the other invokes any method by its Discovery id. They share the exact dispatch path the generated tools use, and, critically, they run the same write-control and scope checks as every named tool, so the escape hatch cannot become a hole in the policy. Discovery documents are fetched at runtime and cached on disk for seven days with a stale-if-offline fallback, so it keeps working even when Google's Discovery endpoint does not.
The core idea: discover-first
Generating the long tail solved coverage. It did nothing for the context window; if anything it made the problem worse, because now there really were 871 schemas that could be loaded. The architecture that solves the context problem is what I call discover-first, and it is the piece I would keep if I could keep only one.
At rest, the server advertises almost nothing. The tool list a client sees on boot contains about a dozen per-service discovery meta-tools, one {service}_discover per service family (gmail_discover, drive_discover, sheets_discover, and so on, thirteen in total), plus the two escape-hatch tools. Every one of the 871 operational tools is registered but hidden. Each discover tool is cheap and does real work in its description: it states the negative space plainly (the operational tools for this service are hidden, call me first), inlines the service's operation vocabulary so the model knows what the service can do, and takes an optional query to filter the catalog it returns.
When the assistant calls gmail_discover, the server reveals Gmail's operational tools and fires a tools/list_changed notification so the client re-fetches the list. Now the assistant sees Gmail's tools, and only Gmail's, for the task in front of it. The idle context cost of having all of Google available drops from roughly 40,000 tokens, if every schema were eager, to about 1,000. You get the discoverability of an exhaustive catalog at the context cost of a tiny one.
One discovery tool per service, not one global router. A single global tool cannot fit a thousand operations into one description without becoming the very context bloat it is supposed to prevent, and it adds an extra hop to every single call. About a dozen service-scoped discover tools sits comfortably under the tool-selection knee while keeping each returned catalog small and relevant.
Hiding tools without breaking clients
The mechanism underneath this matters more than it looks, because the obvious way to build it is a trap. MCP has notions that sound purpose-built for progressive disclosure: tool_reference content blocks, and a server-side defer_loading hint. I checked them against the MCP SDK, and strict clients reject unknown content types and strip nonstandard fields off tool definitions, which would break real clients rather than help them. On top of that, a stdio server cannot reliably set per-tool deferral from its own side, and some clients ignore server-side defer configuration entirely.
So the durable mechanism is none of those. Hiding is implemented as a custom handler on tools/list that returns only the eager set, paired with tools/list_changed notifications when a service is revealed. This works on any spec-compliant client. Two details make it safe. First, hiding is listing-only, never a call gate: every tool stays registered and directly callable the whole time, so an existing prompt that calls a tool by name keeps working with zero discovery overhead. Discovery is progressive disclosure for context savings, not access control. Second, the spec requires at least one non-deferred tool, so defer everything is not an option; the dozen discover meta-tools are exactly that mandatory eager set, so the constraint pointed at the right design.
The other half of the context budget: response trimming
Discover-first fixes the input side of the context budget, the tool schemas the assistant carries before it does anything. The output side needs its own answer, because Google's API responses are fat. Reading a twenty-page document whole is roughly 10,000 tokens, about 12 times what a summary of it costs, and an assistant that reads three of them has spent its budget before it starts thinking.
So responses are trimmed by default. Opt-in optimization delivers nothing to the 99 percent of users who never turn it on, so the correct default is on, with a single opt-out and generous escape valves: globally compact JSON re-serialization, character caps and paging on document reads, a body cap on email reads, and trimmed list views that drop empty and rarely-useful fields. The aggressive default is only safe because every cap has a per-call override for when the assistant genuinely needs the whole thing. One easy-to-miss detail lives inside those caps: a byte-blind character cap can slice through a multi-byte character and leave a lone surrogate, which serializes to malformed JSON, so every cap drops a trailing lone surrogate to keep the output valid Unicode.
Keeping it safe: deny-by-default write control
Exhaustive coverage cuts both ways. A server that can read every inbox can also permanently delete mail, strip Drive permissions, and change Admin SDK settings. Handing an assistant that reach without a policy layer would be reckless, so every operation that creates, updates, or deletes is gated, and the default is deny. Reads are never gated.
The knob is deliberately small: a single one-word profile, read-only, safe-writes, or full-writes. The trick that makes this scale to hundreds of write tools is that safe-writes is computed automatically from each tool's own destructive classification, the same read-or-write metadata the generator bakes in. Adding hundreds of generated write tools needed zero configuration change, because they classify themselves. Glob patterns give granular overrides when the profile is too coarse, and deny always wins.
Two things keep this from being theater. Enforcement lives server-side in the dispatch path and gates on the registry's classification flag, never on the tool name, because MCP's tool annotations are hints that clients honor unevenly and cannot be trusted as a boundary. And the generic escape hatch is gated identically: a mutating method invoked through it gets the same verdict a named tool would, so it cannot be used to route around the policy.
This is also where the classification work paid for itself, and where it nearly did not. In review I caught that under the safe-writes profile, POST methods that actually delete (Gmail's batchDelete, trash, clear) would have been allowed, because a naive reading had them deriving create from their POST verb. A permanent, irreversible delete slipping through a guardrail literally named safe-writes is about the worst failure a write-control system can have, and no test caught it. Adversarial reading did. The fix is the same principle as the generator: classify from HTTP semantics, so a POST-as-delete is a delete. Once that held, disabled mutations simply never surface to the assistant in the first place, which is both safer and cleaner than letting it try and be refused.
What I would do differently, and what is next
A few things I would revisit. The override side-file that supplies model-friendly descriptions and classification corrections is manual toil, and every time Google revises an API someone has to eyeball the new methods and their inferred read-or-write class; I would like more of that verification to be mechanical. The line for when a method deserves promotion from a generated tool to a hand-curated one is still a judgment call I make by feel.
The honest lesson from real users is that the interesting architecture was never where they got hurt. The walls people actually hit were cross-platform file IO, not tool discovery. An external contributor, @mjreddy, fixed a one-line bug (opening a temp file read-only before an fsync that Windows rejects on a read-only handle) that had made authentication impossible for every Windows user while passing every test on Linux and macOS. Another, @trevor-commits, hardened concurrent token persistence against corruption. Codegen and context-window math were the fun part; the durability came from other people running it on machines I do not have.
The exhaustive-coverage milestone is done. mcp-google-multi is MIT-licensed, published to npm, runs locally over stdio, and uses your own OAuth credentials. It is 871 tools across 28 services with an idle context cost near a thousand tokens. The code is on GitHub and the package is on npm. If you are wrestling with the same too-many-tools problem in your own MCP server, the two ideas that did the heavy lifting are worth stealing: generate the breadth from the API's own machine-readable description, and never let more than a handful of tools be visible at rest.