Skip to main content
In my last post on subagents, I mentioned in passing that once you go past 30–50 tool definitions, models start getting worse at picking the right tool. That one line got more questions than anything else in the post. It’s not hard to see why: adding tools is ridiculously easy now. Install one MCP server and you’ve got a dozen or twenty tools. Hook up GitHub, Slack, a browser, a database, and you’re at dozens, maybe a hundred, before you know it. Too many tools, not enough context
More tools is a good thing. The bill comes due in context. Tool definitions take up space, tool results take up space, and a model choosing among a pile of look-alike tools is more likely to choose wrong.
In this post I want to walk through the problem in the order we actually tackled it in MiniMax Code (MCode for short): cut first, then add tool search, and finally move to PTC (Programmatic Tool Calling — letting the model write a program that calls tools). For each step: what it fixes, what it doesn’t, and where we got burned.

First, figure out where the context is going

Figure 1: How context is spent under full loading, tool search, and PTC Tools cost you context in two ways, plus one side effect. Cost #1: definitions. Every tool’s name, description, and parameter schema sits in context whether or not you use it this turn. Anthropic ran the numbers in Introducing advanced tool use: five servers — GitHub, Slack, Sentry, Grafana, Splunk — means 58 tools and roughly 55K tokens gone before the conversation even starts. Internally they’ve seen tool definitions hit 134K before optimization. Cloudflare’s number is even wilder: exposing all 2,500+ of its API endpoints as native MCP tools would take about 1.17M tokens of definitions alone — bigger than most models’ entire context window (source). Cost #2: intermediate results. Every tool call dumps its full result back into the conversation. Read a big file, run the test suite, query a table — thousands of lines come in, and the model may need a handful. The sneakier cost is round trips: every tool call means the model re-reasons over the full context, so ten calls are ten full requests. Anthropic’s Code execution with MCP has a good example: moving a two-hour meeting transcript from Google Drive into Salesforce means the transcript passes through context twice — tens of thousands of extra tokens. The side effect: picking the wrong tool. With enough tools, names and descriptions inevitably overlap — think notification-send-user vs. notification-send-channel — and the model picks the wrong one or passes the wrong arguments. One of my teammates put it bluntly: hand a model seventy-odd tools at once and its brain kind of overflows. That lines up roughly with the 30–50 rule of thumb from the last post. Once you split the bill this way, the rest is easy to follow: tool search mostly cuts cost #1, PTC mostly cuts cost #2.

Step 0: Cut first

Before any clever mechanism, the cheapest fix is always to load less. Nothing fancy here, but it solves a surprising amount of the problem.
  • Merge tools around user intent. Don’t map low-level APIs 1:1 to tools. “List issues,” “filter issues by label,” and “filter issues by author” can be one tool with parameters.
  • Make names and descriptions distinguishable. The model picks tools by name and description. If two descriptions read alike, the model can’t tell them apart either. While you’re at it, document the return format — you’ll need it later when the model writes code against these tools.
  • Group them. OpenAI’s tool search docs recommend grouping tools into namespaces by domain, ideally fewer than 10 functions each. Clear groups make things faster to find, for people and models alike.
  • Decide what stays resident. Tools you use nearly every turn — read file, edit file, run command — should always be loaded. More on this later.
But cutting only goes so far. We can’t delete the MCP servers and plugins users install themselves. The tool count keeps climbing, and that’s when you need the next step. Figure 2: The basic tool search flow — every tool is registered in a catalog, and its full definition is loaded only when needed The idea is simple: register every tool, but keep the full definitions out of context. Give the model a search tool; it looks up what it needs, and only those tools’ definitions get loaded. Everyone does roughly the same thing. Anthropic lets you mark tools with defer_loading: true and pairs them with a Tool Search Tool, using regex, BM25, or your own search. OpenAI’s tool search is similar, and it appends loaded definitions to the end of context so the prefix stays the same and the prompt cache isn’t invalidated. Claude Code now enables tool search by default: core tools like Bash, Read, and Edit load up front, everything else is discovered on demand. You can also set it to auto, which only kicks in once deferrable tool definitions exceed 10% of context (docs). The impact is real. Per Anthropic, up-front tool definition overhead drops by about 85%, and on their MCP evals Claude Opus 4 went from 49% to 74% accuracy, and Opus 4.5 from 79.5% to 88.1% (source). Beyond saving tokens, tool selection gets more accurate, because the model has fewer candidates in front of it.

How we use it in MCode

Tool search is on by default in MCode. Here’s how we split things:
  • Core built-in tools stay resident. Read, write, edit, search, run command — the high-frequency tools always carry their full definitions. Making the model search first just to save a few hundred tokens isn’t worth it.
  • Low-frequency built-in tools become searchable. Things you rarely need go into the catalog and load on use.
  • MCP tools switch automatically based on size. If there are only a few tools with small definitions, they load normally. Once the count or size crosses a threshold, they move into tool search automatically. Users don’t have to configure anything.
The most obvious change after we shipped it: context got cleaner, and the model got more reliable. That backs up the point about cost #1 — often the model isn’t short on capability, it’s just staring at too much stuff it doesn’t need.

Where we got burned

  • If it can’t be found, it doesn’t exist. Tool search turns “can the model use this tool?” into “can the model find this tool?” Vague description, mismatched keywords, and that tool might as well not be there. Sometimes the model keeps rephrasing its search and goes around in circles. So “make descriptions distinguishable” matters even more at this stage.
  • Don’t assume default retrieval works everywhere. Someone benchmarked a tool search setup that tied the best configuration on a clean benchmark, then fell 19 points behind on 525 real MCP tools (@kachar136). Retrieval quality varies a lot by tool set. Test on yours.
  • Not everything belongs in there. If a category of tools needs to fetch its catalog over the network every turn, putting it behind tool search can actually slow down time to first token. We hit exactly this and moved those tools back into the resident set.
  • “In the request” doesn’t mean “the model has seen it.” Deferred tools show up in the request’s tool list, but at any given moment the model may not have seen their full definitions yet. When debugging or running evals, look at what the model could actually see at that point, not just what the request listed.
Tool search fixes the definitions problem, but it does nothing for the round trips: every call’s result still lands in the conversation in full, and ten calls are still ten full requests. It does have one very important side effect, though: it forces you to clean up your tool catalog — what’s resident, what’s on demand, whether descriptions are clear. Get that right, and the next step becomes possible.

Step 2: PTC — let the model write code that calls tools

Figure 3: One call at a time vs. one program. PTC packs many calls into a single request and keeps intermediate results in the sandbox The idea behind PTC: instead of having the model call one tool, look at the result, and decide what’s next, let it write a program that loops, runs things in parallel, filters, aggregates, and hands back only what’s needed. The thousands of lines of intermediate output stay in the sandbox, not the conversation. Ten calls fit into one request, and the model only has to reason once. Why is writing code better than calling tools directly? Cloudflare’s Code Mode: the better way to use MCP explains it well: models have seen enormous amounts of real code, but far fewer tool-call formats, and most of those are synthetic training data. Asking a model to write code lets it work the way it knows best. This has pretty much become industry consensus, just under different names:
  • Anthropic’s Programmatic Tool Calling: tools are called from inside a code execution environment. On complex research tasks, average tokens dropped from 43,588 to 27,297 — a 37% reduction — with slightly better accuracy on several evals (source). In another post, they organize MCP tools as code files the model reads on demand; one example went from 150K tokens to 2,000 (source).
  • Cloudflare’s Code Mode: just two tools, search() and execute(), cover 2,500+ endpoints in about 1,000 tokens (source).
  • OpenAI’s Programmatic Tool Calling: the model writes JavaScript that calls tools marked as programmatically callable, inside a hosted runtime.
  • Pi’s Codemode: code runs on the harness side, and each tool can be configured individually as deferred or code-only (source).

How we do it in MCode

PTC in MCode is still experimental and we’re still polishing it. The model gets a run_code tool; it writes a chunk of JavaScript, and inside that program it calls the agent’s existing tools through tools.*. A few key design decisions:
  • JavaScript. In our experience models write JS more reliably, and a small JS engine compiles to WASM, so we don’t have to ship a separate interpreter.
  • The program itself can’t touch anything. Code runs in a lightweight JS engine inside an isolated worker, with no file, network, or process access. The only thing it can do is call tools the agent already has. To be clear, this isn’t a security-audited, hardened sandbox. The boundary it enforces is: code can’t go around the tools to do things on its own.
  • No permission bypass. Every tools.* call inside a program goes through the exact same path as a direct call: argument validation, hooks, permission approval, UI display — nothing skipped. If an action needs user confirmation when called directly, it still needs confirmation inside a program.
  • Failures return results, not exceptions. Models rarely write try/catch. If one failed tool crashes the whole program, all the earlier work is wasted. So a failed tool returns a result containing the error, and the program decides what to do.

Where we got burned

  • Offering it isn’t enough — you have to actually move the tools in. At first we put run_code at the top level alongside every native tool. The model barely used it, and overhead went up. Only after we moved most tools to code-only did the model start actually writing programs.
  • Not every tool should move. We keep only a few kinds at the top level: tools that interact with the user, tools that change session state, tools whose result you need to read before deciding the next step (like loading a skill or tool search), browser and publishing tools, and subagent dispatch. We tried moving subagent dispatch into programs, and it got used noticeably less.
  • What if the model calls a tool that’s been moved? Give it a hint saying the tool has to be called inside run_code. We found a firm, direct hint works much better than a polite suggestion.
  • For long commands, just wait inside the program. Pushing a long command to the background and polling looks “smarter,” but every poll is another full-context request, which ends up costing more. Blocking on the result inside the program is cheaper.
  • Features keyed on tool names break silently. Once a tool moves into programs, any logic triggered by that tool’s name — certain reminders, stats — stops seeing it get called, and nothing errors out. Audit those dependencies before you move anything.
  • Something in the middle can drop argument types. We hit a case where some hop in the request pipeline coerced types for tools that weren’t declared at the top level, and argument types got lost. The fix was to restore the types from each tool’s own schema.
  • Programs have bugs too. The most common failures are quoting and escaping, especially when building shell commands inside JS strings.
Now the conclusion we think matters most. In internal testing, task scores with PTC on were basically flat; nearly all the savings came from fewer requests. In other words, PTC doesn’t make the model smarter. It just gets the same work done in fewer requests. How much you save depends heavily on whether the model is willing — and able — to batch several calls into one program, and models differ quite a bit on that. Also: a program only saves context if it filters results before handing them back. If it just returns the raw output, you’ve found a new way to stuff the same amount back into the conversation (@jasonfesta made the same point). That’s why tools should return structured data with a documented return format — so the program knows which fields to grab.

Which step when — and can you combine them?

Anthropic’s advice is also to layer by bottleneck: definitions too big → tool search; intermediate results too large → PTC; arguments keep coming out wrong → add usage examples to your tools. Conversely, for one-off simple operations, or when the model genuinely needs to see an intermediate result to decide what’s next, don’t force it into a program. You can absolutely use both together, and we think you should. Tool search organizes the catalog; PTC packs calls into programs on top of that. One detail to watch: OpenAI’s docs say explicitly that tool search runs at the top level and can’t be called from inside a program, so search for and load the tools you need first, then write the program. That’s also why tool search stays at the top level in MCode.

A self-check list

  • Do the math first: how much context do tool definitions take, how much do tool results take per task, and how many round trips does a task average?
  • Tools are merged around user intent, names and descriptions are clearly distinguishable, and return formats are documented.
  • High-frequency core tools stay resident; low-frequency tools and large batches of MCP tools are deferred.
  • You’ve tested tool search recall on your own tool set, not just someone else’s benchmark.
  • Tools that fetch their catalog over the network every turn don’t slow down time to first token.
  • When adopting PTC, you actually move tools into programs, and you’ve thought through which must stay at the top level.
  • Tool calls inside programs go through the same permissions, hooks, and display as direct calls.
  • Failed tools return an error result instead of throwing; tools return structured data where possible.
  • You’ve audited all logic keyed on tool names so nothing silently breaks after the move.
  • Run end-to-end evals before and after — look at scores, but also request counts and tokens.

Closing thoughts

Looking back, these three steps are the same idea as the last post: get the right information into context at the right time. Trimming means putting in less; tool search means putting it in later; PTC means keeping what the model doesn’t need to see outside entirely. One more takeaway: each step lays the groundwork for the next. If your tool catalog is a mess, tool search won’t find things; if tool search isn’t solid, PTC will struggle too. Don’t try to do it all at once. Go bottleneck by bottleneck, and measure at every step. Give MiniMax Code a try, and come talk to us on GitHub about how you manage tools and context. We’d love to hear how other teams are handling it.

References