The Microsoft Agent Framework shipped progressive MCP disclosure as a config flag, Anthropic ships the same shape as its Tool Search Tool, and both come with the same asterisk: you can build this yourself. This is the how. It's about forty lines over a plain MCP client, and doing it by hand is the fastest way to understand what the flag actually does — and where the security boundary has to live.
The problem, in one number#
Every MCP tool ships a JSON schema — name, description, every parameter and its type — and all of it loads into the context window before the model reads your first request. A typical GitHub + Slack + Sentry + Grafana + Splunk setup runs about 55,000 tokens of definitions just sitting there, per Anthropic's own docs. And it's not only cost: the same docs note tool-selection accuracy degrades sharply once you exceed 30-50 visible tools. The thing you added to make the agent capable is the thing making it choose wrong.
Progressive disclosure fixes both by refusing to front-load. The model starts with almost nothing and pulls schemas in as it needs them.
The whole pattern is three meta-tools#
You expose three tools to the model, and hide the real ones behind them:
list_tools— returns the names and one-line descriptions of available tools, never the full schemas. This is the model's index.load_tool(name)— materializes one tool's full schema into context so the model can call it correctly.unload_tool(name)— drops a schema the model is done with, reclaiming the tokens.
Plus two pieces of config: an always_load set (your three-to-five hottest tools, pinned so common paths skip discovery) and an allowed_tools allow-list that bounds what can ever be loaded or called.
Wire it over any MCP client#
Cache the server's real catalog once, then present the model only the meta-tools plus whatever is currently loaded. Here's the core in Python against the reference MCP client:
class ProgressiveTools:
def __init__(self, session, allowed, always_load=()):
self.session = session # an open MCP ClientSession
self.allowed = set(allowed) # the security boundary
self.loaded = {} # name -> full tool schema
self.catalog = {} # name -> {desc, schema}
async def refresh(self):
# pull the server's real tools once; re-run on tools/list_changed
for t in (await self.session.list_tools()).tools:
if t.name in self.allowed:
self.catalog[t.name] = {"desc": t.description, "schema": t.inputSchema}
for name in always_load:
self.loaded[name] = self.catalog[name]["schema"]
def meta_tools(self):
return [
{"name": "list_tools", "description": "Search available tools by keyword. Returns names + summaries.",
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}},
{"name": "load_tool", "description": "Load one tool's full schema so you can call it.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}},
"required": ["name"]}},
{"name": "unload_tool", "description": "Drop a loaded tool you no longer need.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}},
"required": ["name"]}},
]
def tools_for_model(self):
# what the model sees this turn: meta-tools + only the loaded real tools
loaded = [{"name": n, "description": self.catalog[n]["desc"],
"input_schema": self.loaded[n]} for n in self.loaded]
return self.meta_tools() + loaded
The dispatch is a match on the meta-tools; everything else is a real MCP tools/call:
async def dispatch(self, call):
if call.name == "list_tools":
q = (call.args.get("query") or "").lower()
hits = [f"{n}: {v['desc']}" for n, v in self.catalog.items()
if q in n.lower() or q in (v['desc'] or '').lower()]
return "\n".join(hits[:25]) or "no matches"
if call.name == "load_tool":
name = call.args["name"]
if name not in self.allowed: # boundary check — not optional
return f"error: {name} is not permitted"
self.loaded[name] = self.catalog[name]["schema"]
return f"loaded {name}; its schema is now available"
if call.name == "unload_tool":
self.loaded.pop(call.args["name"], None)
return "unloaded"
# a real tool — enforce the allow-list here too, then forward to MCP
if call.name not in self.allowed:
return f"error: {call.name} is not permitted"
return await self.session.call_tool(call.name, call.args)
Your agent loop doesn't change. Each turn you pass tools_for_model() to the model; when it calls a meta-tool you mutate the loaded set and continue; when it calls a real tool you forward it. The model naturally learns to list_tools → load_tool → call, then unload_tool when it moves on.
The allow-list is the security boundary, and it lives in the proxy — not the prompt. An unloaded tool is not a blocked tool.
The one invariant you must not get wrong#
Note the two allowed checks in dispatch. That is the whole security story, and it is the piece people drop when they build this in a hurry. **Progressive disclosure changes when a permitted tool's schema enters context — never whether a tool is permitted.** A tool being unloaded is a token optimization, not an access control. If the only thing stopping the model from calling delete_repo is that its schema isn't loaded, a model that guesses the name and arguments will call it anyway. Enforce allowed_tools on every load_tool and every real tools/call, in code, on the server side of your agent.
What it costs, and how to make the cost vanish#
The tax is one extra round-trip the first time the model reaches for an unfamiliar tool: it loads the schema, then calls the tool, where front-loading would have called it directly. Two mitigations kill most of it. Pin your hottest three-to-five tools to always_load so the common paths never discover anything. And keep the catalog cached in memory — list_tools should be a local lookup, refreshed only on the server's tools/list_changed notification, never a live round-trip per query.
For catalogs in the hundreds, don't dump the full index — make list_tools a real search (substring is fine to start; BM25 over names and descriptions scales further). That's the same call Anthropic made productizing it, and the reason both the tool-search and code-execution shapes of this fix exist: past a certain tool count, the index itself is the thing you have to page.
Forty lines, one invariant, and your agent stops paying rent on tools it never calls.
Companion piece: progressive disclosure is one of two things the frameworks ship as a flag that you're better off owning yourself over a plain MCP client. The other is durable task handles for slow tools — see how to not orphan an MCP task for the client-side store the stateless spec pushes onto you.



