Andrew Fribush

← All posts

Lazy Tool Loading Over MCP

· November 30, 2026 · 13 min read

fedpipe, the federal open-data MCP server I maintain, registered 369 tools when started eager at commit 413372f. In a client that loads every tool's full schema up front, those 369 definitions measure 85,530 tokens before the user types a word. That is 43% of a 200K window, and 102,566 tokens with the server's 17,036-token instruction block. In a client that lists tool names and loads a schema only when the model reaches for that tool, the same 369 tools cost 2,131 tokens. So the default start mode for an MCP server comes down to one question: does the client keep schemas out of the model's context until a tool is used? Most clients don't, and for those lazy loading is the right default.

I made lazy fedpipe's default twice. The first time it lasted one commit. A stdio session in FastMCP, the framework the server runs on, is registered only after the initialize handshake finishes. A load_modules call that arrived early announced its new tools to an empty session list, and the client's view stayed frozen at the 7 tools it started with. The commit that reverted the default recorded the reproduction: "0ms delay -> tools invisible; 1500ms -> fine." The commit after it made load_modules wait for the session to register. It verified the fix 5 of 5 at zero delay where it had been 0 of 5, and made lazy the default again.

The scope is one server and one token measurement of each start mode, taken by driving the server built at 413372f over stdio. Two kinds of client behave in opposite ways, and there is one race and one fix. Answer quality under lazy and eager loading was not measured. Tool counts differ across the server's files and commits, so each sentence below names the commit or file its count comes from.

fig. 2.18.1 · tokens the tool list puts in context at session start, eager against lazy, on the 369-tool server.
LAZY START 1,564 TOKENS; EAGER 85,530 WHERE SCHEMAS LOAD UP FRONTONE BAR IS THE TOKENS THE 369-TOOL SERVER’S TOOL LIST PUTS IN CONTEXT ATSESSION START, ON A 200K WINDOW; THE ACCENT BAR IS THE LAZY DEFAULT.050K100K150K200K · THE WINDOWCLIENT LOADS EVERY SCHEMA UP FRONT · CLAUDE DESKTOP AND MOST CLIENTSEAGER · 369 TOOLS85,530 TOKENS · 43% OF THE WINDOWLAZY · 7 TOOLS1,564 TOKENS · 0.78% OF THE WINDOWCLIENT DEFERS SCHEMAS, LISTS NAMES ONLY · CLAUDE CODEEAGER · 369 TOOLS2,131 TOKENS · 1.1% OF THE WINDOWLAZY · 7 TOOLS20 TOKENS · 0.01% OF THE WINDOW

Measured with gpt-tokenizer o200k_base over the tools/list a stdio client receives: name, description and input schema per tool, or the names alone. One full tool definition averages 232 tokens. The server’s instruction block adds 17,036 tokens eager and 492 lazy on top of these bars.

source: fedpipe 413372f, tools/list over stdio in --eager and default (lazy) mode; tokens by gpt-tokenizer o200k_base over {name, description, input_schema} per tool, names joined for the names-only client; client behaviour from README.md:111-118 · lazyStart.data.ts

The Question Is Whether the Client Keeps Schemas Out of Context

A tool definition in MCP carries a name, a description and an input schema, and a client learns them by sending tools/list. What the client does with the answer decides the cost. fedpipe's definitions average 232 tokens each at 413372f (the shortest is 60, the median 205, the longest 1,128, nih_search_projects). Of the 85,530 tokens, descriptions are 21,796 and schemas 58,202. A tool's name averages under 5 tokens.

A client that loads schemas up front puts all of that into the model's context on the first request. Claude Desktop and most other clients behave that way, according to the "Which mode for which client?" section that b39cf0d added to the README. Started eager, fedpipe costs those clients 85,530 tokens of definitions, and 102,566 with its instructions, which the README puts at "~100K tokens before you type anything". Started lazy, it costs 1,564 tokens for 7 tools, plus a 492-token instruction block that lists the modules a client can load.

A client that defers schemas shows the model the names and fetches a schema when a tool is picked. Claude Code works this way. Its tool search is on by default. Its documentation says "only tool names and server instructions load at session start." For a deferring client eager costs 2,131 tokens of names, about 1% of the window, and lazy saves almost nothing.

The protocol defines how a server announces new tools mid-session, and leaves to each client whether to act on the announcement. In the MCP specification (2025-06-18), a server declares "listChanged": true in its tools capability, and "when the list of available tools changes, servers that declared the listChanged capability SHOULD send a notification", notifications/tools/list_changed. The specification's message flow then has the client send tools/list again and receive the updated tools. If the client ignores the notification, a tool loaded mid-session exists on the server and never appears in the client.

That gives fedpipe three start modes, one for each answer:

  • fedpipe starts lazy with 7 discovery tools: three resolvers (resolve_entity, resolve_person, resolve_place), find_tools, load_modules, code_mode and clear_cache. The model calls load_modules with a module name or one of 14 domains, and the server registers those tools and sends tools/list_changed.
  • fedpipe --modules a,b registers exactly those modules at start, for an operator who already knows which agencies the session needs.
  • fedpipe --eager registers everything at start, for a client that ignores tools/list_changed and so would never see a tool loaded later.

The lazy default serves the client that loads schemas and honors the notification. It costs 1,564 tokens at start, and each module costs what its definitions cost when the model asks for it. Loading the sec module in a measured run took the client from 7 tools to 17 and from 1,564 tokens to 4,143, with one tools/list_changed received in between.

fig. 2.18.3 · a lazy session’s context on the 200K window as the reader loads domains through load_modules.
LAZY START: 1,564 TOKENS FOR 7 TOOLS; EAGER: 85,530ACCENT: A LAZY SESSION’S TOKENS ON A 200K WINDOW, 7 DISCOVERY TOOLS THEN ASEGMENT PER MODULE; GREY: THE SAME TOOLS AS NAMES.050K100K150K200K · THE WINDOWEAGER START · 369 TOOLS · 85,5301,564 TOKENS · 0.78%20 TOKENS AS NAMES ONLYLOADED: 7 DISCOVERY TOOLS (RESOLVE_ENTITY, RESOLVE_PERSON, RESOLVE_PLACE,FIND_TOOLS, LOAD_MODULES, CLEAR_CACHE, CODE_MODE)COUNT IS TOOLS:ECONOMY 59FINANCE 60HEALTH 81LEGISLATION 101SPENDING 86SAFETY 65ENVIRONMENT 36HOUSING 22EDUCATION 20INTERNATIONAL 23JUSTICE 17TRANSPORTATION 16ENERGY 8AGRICULTURE 7ALL 45 MODULES

Each segment is the measured tokens of that module’s tool definitions (gpt-tokenizer o200k_base over name, description and input schema), as load_modules registers them; a module in two domains counts once, as the server resolves it. Loading everything holds 85,888 tokens: load_modules itself stays in the list, which an eager start at this commit leaves out.

source: fedpipe 413372f, tools/list over stdio in --eager mode mapped to modules by dist/apis/*/index.js; tokens by gpt-tokenizer o200k_base per module · loadModules.data.ts

The figure above runs load_modules on the per-module counts measured at 413372f, with the domain loading that b39cf0d added. Loading the finance and health domains resolves to 18 modules once duplicates are dropped (fred sits in economy, finance and housing), so the session holds 138 tools and 34,777 tokens, 17% of the window. Loading everything holds 85,888 tokens, 358 more than eager. Part of the difference is load_modules itself, 268 tokens, which at 413372f was registered only in lazy mode, and the rest is the JSON array framing counted once per module.

The Default Was Reverted Over a Session-Registration Race

Lazy mode went in as an opt-in flag, --lazy, in commit 2d44713. Its verification ran over stdio: 7 tools at start, resolve_entity working before any load, then load_modules('sec'), the notification, 17 tools, and sec_ticker_lookup callable. The next commit, 8c87eca, made lazy the default, after checking all four mode combinations over stdio and getting 7, 361, 11 and 7 tools.

The commit after that, 17f9218, added three modules and put lazy back behind the flag. Testing had found a case the earlier run missed. When load_modules was the first call of a session and arrived with no delay, the client never saw the new tools. With a 1,500 ms delay before the call they appeared. The commit message called the race "invisible at human speed but real, so it can't be the silent default."

The race is in how FastMCP tracks sessions. On stdio, the server pushes a session into server.sessions only after the initialize handshake completes. FastMCP's addTools sends tools/list_changed to every session in that list, and only once the server is marked running, which on stdio happens right after the push. A load_modules call that ran before the push had no session to notify. Its tools were registered on the server anyway, so the server held 17 and sent nothing. The client had no reason to send tools/list again and kept listing 7.

fig. 2.18.2 · the session-registration race, and the six-line wait that took 0 of 5 runs to 5 of 5.
WAITING FOR THE SESSION TO REGISTER TURNS 0 OF 5 RUNS INTO 5 OF 5ONE TIMELINE PER SESSION; ACCENT MARK: LIST_CHANGED, HOLLOW IF UNHEARD.BEFORE THE FIX · 8c87ecaLOAD_MODULES LANDS BEFORE THE SESSION IS REGISTEREDSERVER.SESSIONS IS EMPTY; ADDTOOLS RUNS AT ONCETOOLS/LIST_CHANGED GOES OUTTO 0 SESSIONS; THE SESSION REGISTERS AFTER, UNTOLDCLIENT READS TOOLS/LISTSTILL 7 TOOLS · 0 OF 5 RUNS SAW THE NEW ONESWITH THE WAIT · 413372fLOAD_MODULES LANDS BEFORE THE SESSION IS REGISTEREDSERVER.SESSIONS IS EMPTY; LOAD_MODULES WAITS FOR ITTOOLS/LIST_CHANGED GOES OUTTO 1 SESSION, ONCE THE POLL SEES IT REGISTERCLIENT READS TOOLS/LIST17 TOOLS, ALL CALLABLE · 5 OF 5 RUNS

The wait is six lines in load_modules: poll server.sessions up to 60 times, 25 ms apart, before addTools; worst case 1,500 ms once per session. FastMCP registers a stdio session only after the initialize handshake, so a load_modules call that lands first found no session to notify. The race was reproduced at a 0 ms delay (tools invisible) and absent at 1,500 ms. Counts are the commits’ own verification runs; the 7-to-17 tool list was re-measured over stdio.

source: fedpipe src/server.ts:887-894; commits 8c87eca, 17f9218, 413372f; tools/list re-measured over stdio at 413372f · sessionRace.data.ts

The fix is a wait before addTools. This is the loop as it stands in load_modules at 796ea9d and on main (src/server.ts, lines 887 to 894), with the comment I wrote for it:

ts
// FastMCP pushes a stdio session into server.sessions only after the// initialize handshake finishes. A load_modules call can land before// that, so addTools' tools/list_changed would notify an empty session// list and the client would never see the new tools. Wait (briefly)// for the session to register so the notification actually reaches it.for (let i = 0; i < 60 && server.sessions.length === 0; i++) {  await new Promise(r => setTimeout(r, 25));}

The loop checks the session list and sleeps 25 ms, up to 60 times. Once the session is registered the condition fails on the first check and the loop costs nothing. The worst case is 60 × 25 ms = 1,500 ms, the same delay that had been enough in the reproduction, and only a load_modules call that arrives before registration pays it. Commit 413372f shipped the wait and made lazy the default again. Its verification ran the full sequence five times at zero delay (lazy start, load, tools/list showing the new tools, the tools callable) and passed 5 of 5, against 0 of 5 before.

The fix is a bounded poll. A cleaner one would wait on a signal from the framework that the session exists, and FastMCP 3.35.0, the version installed under the server's ^3.35.0 dependency, has one: on stdio it awaits session.connect(transport), pushes the session into the list, and then emits a connect event. A load_modules that finds the list empty could wait for that event instead of a clock, and that change is still to make. If 60 tries pass with no session, the loop gives up and addTools runs anyway, so a handshake slower than 1,500 ms would bring the race back.1

What Lazy Costs

The arithmetic for a schema-loading client is 85,530 tokens eager against 1,564 lazy at start, a difference of 83,966. Eager buys one thing with those tokens: every tool is visible from the first request, so the model never has to discover one. Lazy has to replace that with tools the model calls to find what isn't loaded yet.

find_tools searches the full catalog, including modules that aren't loaded, and flags each match as loaded or not, with the instruction to call load_modules for the rest. load_modules accepts module names, a domain such as finance, or all, and drops duplicates when a domain and one of its members are both named. When a name matches nothing, it suggests the closest module by edit distance, if the distance is 3 or less. Commit 2d44713 changed that edit distance to count an adjacent transposition as one edit, so load_modules('fce') suggests fec where it used to suggest fbi.

I haven't measured what lazy loading does to the answers. A model that has to search for a tool may pick a worse one than a model that sees all 369 at once, or it may skip the search and answer without the data. Measuring that would take a fixed set of questions run against both start modes and graded, and that run doesn't exist yet.

The Audit Doc Still Says Opt-In

The fix commit, 413372f, updated the README, the getting-started guide, the CLI help and the CHANGELOG. The CHANGELOG's Added entry now reads "Lazy loading, on by default" and says that load_modules "waits for the session to register before adding tools, so the notification lands even on the first call of a session." It didn't touch docs/tool-audit.md. Its lazy-loading entry still says lazy loading is "--lazy, opt-in", describes the race as unfixed, and ends "so lazy is opt-in, not the default."

The audit doc is a log, written once per finding and not reread when the code changes. The mode table appears in at least four places (the README, the guide, the CLI help and the comment above the mode parsing in src/server.ts). Three commits in a row changed the default. The audit doc's entry was written in the revert commit, 17f9218, and seven later commits edited the file without changing that entry. The fix is one mode table, generated from the code that parses the flags, with the other files linking to it, and a line in the audit doc saying its lazy-loading entry was superseded by 413372f.

What Claude Code Does Now

The README's advice for Claude Code, from b39cf0d through 796ea9d, is "use --eager", because Claude Code "does not re-read the tool list after a mid-session load_modules." That came from testing recorded in commit b39cf0d. The test found that Claude Code took the tool list once at connect and ignored tools/list_changed. The commit doesn't record the Claude Code version it tested. The README's count of "all 394 tools" comes from the same commit. It reported the eager surface as 394 tools, 387 data tools and 7 discovery tools, after it made load_modules register in every mode. At 796ea9d, eager registers 433.

The current Claude Code MCP documentation, read on 2026-10-14, describes a client that acts on the notification. When a server sends list_changed in an interactive session, "Claude Code fetches the updated list from that server, so you don't need to reconnect it." Before v2.1.214, a transient error during that refresh replaced the server's tools with an empty list. On the v2 runtime, notifications from a server on the newer protocol revision arrive over a stream Claude Code holds open. It reopens the stream with limits: three tries if it closes again within 10 seconds, and, for a stream that stays open longer and then closes, a wait of about six hours after five reopens in an hour. Tool search, the deferred-schema path, is on by default and is off with ENABLE_TOOL_SEARCH=false or an ANTHROPIC_BASE_URL pointing to a non-first-party host.

So in current Claude Code --eager costs 2,131 tokens of names, and lazy also works, because the notification is honored. Server instructions load in either mode, and the documentation says Claude Code cuts them at 4,096 characters by default. By that arithmetic most of the 17,036-token eager instruction block never reaches the model there: at 796ea9d the eager block is 104,243 characters, so about 4% of it fits. That last step is my inference from the documented limit; I didn't watch the cut happen in Claude Code.

A third behavior sits on the client's side: from v2.1.221, a discovery cache (MCP_DISCOVERY_CACHE) can load a remote server's tool list from a previous session and connect the server the first time one of its tools is called. That applies to HTTP and SSE servers, not to a stdio server like fedpipe started as a local process.

Limits

The token counts are one measurement, at 413372f, with gpt-tokenizer 4.0.0 and the o200k_base encoding over each tool's name, description and input schema. A client's own tokenizer and its framing of the definitions will give different numbers. The README's "~2K" and "~100K" are its estimates for two kinds of client, and the measured 2,131 and 102,566 sit near them.

The tool count depends on the commit: 360+ in the comment above the mode parsing, 361 at 8c87eca, 369 at 413372f, 394 at b39cf0d, 433 at 796ea9d. At 796ea9d the eager definitions measure 99,082 tokens, and 124,411 with the 25,329-token instruction block.

The 0 of 5 and 5 of 5, and the 0 ms and 1,500 ms, are the verification results recorded in commits 17f9218 and 413372f. I didn't re-run them for this essay. The 7-to-17 count after loading sec was re-run, on the 413372f build.

Answer quality under lazy and eager loading is unmeasured. The wait is a workaround for FastMCP's session registration, and a FastMCP release that changes when sessions are registered changes whether it is needed. Client behavior changes too: the Claude Code facts above are from the documentation as read on 2026-10-14.

Check Your Client Before You Pick the Default

If your MCP server has more than a few dozen tools, find out whether your users' clients load schemas up front and whether they act on tools/list_changed. Start lazy for the ones that load schemas and honor the notification. Before making lazy the default, start a session from nothing and call your loader as its first request, with no delay. Then check that tools/list shows the new tools and that they can be called. That is the run 413372f recorded, five times, before lazy became fedpipe's default for the second time.

Footnotes

  1. The wait checks server.sessions.length === 0. On stdio there is one session per process, so the check is exact. Over FastMCP's HTTP stream transport server.sessions holds every connected session, and a call that arrives while another session is already registered skips the wait. The race and the fix were tested on stdio only. ↩

Andrew Fribush, software and computer engineer in Austin. I build consequential systems where software meets institutions: finance, healthcare, commerce, government data, and the mechanisms that govern AI agents. Taking new engagements: hello@andrewfribush.com.

I write when a system teaches me something. Get new posts by email: no tracking, one-click unsubscribe.