Skip to content

Instantly share code, notes, and snippets.

@ichoosetoaccept
Created February 23, 2026 12:10
Show Gist options
  • Select an option

  • Save ichoosetoaccept/178d5a0c9aa2db54f11d2e84259b800a to your computer and use it in GitHub Desktop.

Select an option

Save ichoosetoaccept/178d5a0c9aa2db54f11d2e84259b800a to your computer and use it in GitHub Desktop.
MCP Progressive Disclosure: FastMCP v3 Visibility + schema-in-response workaround for Windsurf

MCP Progressive Disclosure: A Battle-Tested Pattern

How we reduced a 34-tool MCP server to 3 visible tools at startup, empirically discovered that Windsurf doesn't re-fetch tools/list between turns, and built a workaround that makes the whole thing work anyway.


The Problem: Tool-Creep

When you build an MCP server that wraps a real API, tool count grows fast. Our STEP test automation server had 34 tools. Every new conversation starts with all 34 crammed into the system prompt — wasted tokens, confused agents, degraded tool-selection accuracy.

The goal: 3 tools visible at startup, everything else hidden until the agent asks for it.


Option A: FastMCP v3 Visibility Transforms

FastMCP v3 has a Visibility transform that hides/shows tools, resources, and prompts based on tags. The plan:

  1. Tag every tool with its category (plans, executions, keywords, parameters, system, scheduler)
  2. Tag 3 gateway tools with gateway
  3. Apply global transforms to hide all tools except gateway
  4. Add an enable_tools(category) tool that uses enable_components() to show a category

The Visibility setup in mcp/__init__.py

from fastmcp.server.transforms import Visibility

# Mount all sub-servers first
mcp.mount(plans.server)
mcp.mount(executions.server)
# ... etc

# Progressive disclosure: hide everything, then show gateway tools
mcp.add_transform(Visibility(False, components={"tool"}))
mcp.add_transform(Visibility(True, tags={"gateway"}, components={"tool"}))

Critical FastMCP v3 gotcha

match_all=True ignores the components filter (short-circuits in source).

# WRONG — this hides prompts and resources too
mcp.add_transform(Visibility(False, match_all=True, components={"tool"}))

# CORRECT — components filter works when match_all is not set
mcp.add_transform(Visibility(False, components={"tool"}))

Tagging tools

# Gateway tools — always visible
@server.tool(tags={"gateway"})
def get_capabilities() -> dict: ...

@server.tool(tags={"gateway"})
def set_tenant(name: str) -> dict: ...

@server.tool(tags={"gateway"})
async def enable_tools(category: Literal["plans", "keywords", "system", "scheduler", "all"], ctx: Context) -> dict: ...

# Category tools — hidden until enabled
@server.tool(tags={"plans"})
def search_plans(query: str | None = None, ...) -> dict: ...

Empirical Testing: Windsurf's Reality

After implementing, we restarted the MCP server and confirmed: 3 tools visible ✅

Then called enable_tools("plans") and checked if new tools appeared.

Result: they did not.

Windsurf does not re-fetch tools/list between turns, and does not handle ToolListChangedNotification. The session-level state from enable_components() was set correctly server-side, but the agent's tool list was frozen at conversation start.


The Fix: Return Schemas in the Response

Since the agent can't see newly-enabled tools in its tool list, we make enable_tools() return the full tool schemas in its response. The agent gets everything it needs to call those tools immediately — within the same turn.

_CATEGORY_TAGS: dict[str, set[str]] = {
    "plans":     {"plans", "executions"},
    "keywords":  {"keywords", "parameters"},
    "system":    {"system"},
    "scheduler": {"scheduler"},
    "all":       {"plans", "executions", "keywords", "parameters", "system", "scheduler"},
}

@server.tool(tags={"gateway"})
async def enable_tools(
    category: Literal["plans", "keywords", "system", "scheduler", "all"],
    ctx: Context,
) -> dict[str, Any]:
    """Activate a category of tools for this session.

    Returns:
        Confirmation with activated category name and full tool schemas for immediate use.
    """
    servers = [
        plans.server,
        executions.server,
        keywords.server,
        params.server,
        scheduler.server,
    ]

    tags = _CATEGORY_TAGS[category]
    await enable_components(ctx, tags=tags, components={"tool"})  # for compliant clients

    tool_schemas: dict[str, Any] = {}
    for srv in servers:
        for tool in await srv.list_tools():
            if tool.tags & tags:
                tool_schemas[tool.name] = {
                    "description": tool.description,
                    "parameters": tool.parameters,  # FunctionTool attribute in FastMCP v3
                }

    return {
        "activated": category,
        "tools": tool_schemas,
        "note": (
            f"{category.title()} tools are now active. "
            "Use the schemas in 'tools' to call them immediately. "
            "They will also appear in your tool list on the next turn."
        ),
    }

Zero duplication

The schemas come from srv.list_tools() — the same FastMCP introspection that generates the tools/list MCP response. FunctionTool.parameters is the JSON Schema FastMCP derives from Python function signatures at registration time. Nothing is hand-written or maintained separately.


Why Tags Still Matter

Tags serve dual purpose after this change:

Tag type Purpose
gateway EssentialVisibility(True, tags={"gateway"}) controls startup visibility
Category tags Filter schemas in enable_tools() response + sent via ToolListChangedNotification (future-proofing for compliant clients)

Agent Flow (Final)

Turn 1 (startup):
  Agent sees: get_capabilities, set_tenant, enable_tools [3 tools]

  → get_capabilities()
    Returns: manifest of all categories + tips
  
  → set_tenant('DT_NGB_eT')
    Returns: tenant anchored, session-level default set

  → enable_tools('plans')
    Returns: {
      "activated": "plans",
      "tools": {
        "search_plans":   { "description": "...", "parameters": {...} },
        "create_plan":    { "description": "...", "parameters": {...} },
        "run_plan":       { "description": "...", "parameters": {...} },
        ... (16 tools total)
      }
    }

  → search_plans(query="guardrail")   ← works immediately, same turn
    Returns: [...plans...]

Key Learnings

  • FastMCP v3 Visibility transforms work correctly for hiding tools at startup
  • match_all=True is broken when combined with components — don't use it
  • Windsurf does not re-fetch tools/list between turns (empirically verified)
  • ToolListChangedNotification is not handled by Windsurf's mcp-go integration
  • The schema-in-response workaround makes progressive disclosure work in any client regardless of notification support
  • srv.list_tools() on FastMCP mounted servers returns all tools unfiltered (global Visibility transforms only apply to the parent server)

Applicability

This pattern works well when:

  • You have many tools grouped into logical categories
  • Agents don't need all categories in every session
  • Your MCP client may not handle ToolListChangedNotification

It's less useful when:

  • You have <10 tools total (just expose them all)
  • Your client reliably re-fetches tools/list (then enable_components() alone suffices)
  • You need true dynamic tool discovery (consider the unblu-mcp TOOL_GATEWAY pattern instead)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment