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:

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_toolsload_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.