How we reduced a 34-tool MCP server to 3 visible tools at startup, empirically discovered that Windsurf doesn't re-fetch
tools/listbetween turns, and built a workaround that makes the whole thing work anyway.
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.
FastMCP v3 has a Visibility transform that hides/shows tools, resources, and prompts
based on tags. The plan:
- Tag every tool with its category (
plans,executions,keywords,parameters,system,scheduler) - Tag 3 gateway tools with
gateway - Apply global transforms to hide all tools except
gateway - Add an
enable_tools(category)tool that usesenable_components()to show a category
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"}))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"}))# 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: ...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.
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."
),
}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.
Tags serve dual purpose after this change:
| Tag type | Purpose |
|---|---|
gateway |
Essential — Visibility(True, tags={"gateway"}) controls startup visibility |
| Category tags | Filter schemas in enable_tools() response + sent via ToolListChangedNotification (future-proofing for compliant clients) |
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...]
- FastMCP v3 Visibility transforms work correctly for hiding tools at startup
match_all=Trueis broken when combined withcomponents— don't use it- Windsurf does not re-fetch
tools/listbetween turns (empirically verified) ToolListChangedNotificationis 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)
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)