| name | build-mcp-servers |
| source | botcore |
| description | Guides construction of MCP servers using FastMCP 2.x with emphasis on token budget management, tool consolidation, output truncation, and the execute pattern for Opus compatibility. Covers async subprocess patterns, safe output limits, and action-based tool design. Use when creating MCP servers, debugging tool overload or context overflow, optimizing token usage, fixing Opus silent failures, or consolidating tools. Triggers: MCP, FastMCP, tool schema, token limit, context overflow, MCP server, execute pattern, tool consolidation.
|
| version | 1.0.0 |
| triggers | ["MCP","FastMCP","tool schema","token limit","context overflow","MCP server","execute pattern","tool consolidation"] |
| portable | true |
Building MCP Servers
Expert guidance for building MCP servers with FastMCP 2.x that work reliably with Claude and other LLMs.
Capabilities
- FastMCP 2.x Server Setup -- Modern async MCP server scaffolding with stdio and SSE transports
- Token Budget Management -- Prevent context overflow and model crashes with safe limits
- Tool Consolidation -- Reduce tool count using action-based and provider-based patterns
- Output Truncation -- Enforce safe character limits on all tool outputs
- Execute Pattern -- Code-execution tool that bypasses Opus schema incompatibilities
- Async Subprocess -- Non-blocking command execution with timeout and stdin isolation
- Troubleshooting -- Diagnose connection failures, context overflow, and platform-specific issues
Routing Logic
Core Principles
1. Keep Tool Count Under 10
LLMs struggle when presented with 10-20+ tools. Consolidate related operations behind a single tool using an action parameter.
@mcp.tool(name="dev", description="Dev tools. Actions: lint, test, build.")
async def dev(action: str, params: dict = None) -> str:
if action == "lint": ...
elif action == "test": ...
elif action == "build": ...
2. Keep Descriptions Under 50 Tokens Each
Tool descriptions are loaded into context on every request. Target less than 500 tokens total across all tools, under 50 per tool.
description="""Consolidated development tools for the monorepo.
Actions: lint, test, build. Params: {"package": "...", "fix": bool}
Example: {"action": "lint", "params": {"package": "core"}}"""
description="""Dev tools. Actions: lint, test, build.
Params: {"package": "...", "fix": bool}"""
3. Truncate All Outputs
Large outputs crash Opus 4.5 and consume excessive context. Enforce a hard limit.
MAX_OUTPUT_LENGTH = 8000
def truncate_output(output: str, max_length: int = 8000) -> str:
if len(output) <= max_length:
return output
truncated = output[:max_length]
last_newline = truncated.rfind('\n')
if last_newline > max_length * 0.8:
truncated = truncated[:last_newline]
return truncated + "\n\n... (truncated for token safety)"
4. Use the Execute Pattern for Opus Compatibility
Claude Opus can silently fail on tools with complex Pydantic/JSON Schema parameters while simpler tools on the same server work fine. The fix: expose an execute tool with a single code: str parameter.
@mcp.tool()
async def my_execute(code: str) -> dict:
"""[Execute] Run Python with server functions available.
Functions: search(query, top=5), graph_stats(). All async.
Example: `results = await search("button"); print(results)`
"""
import io
from contextlib import redirect_stdout, redirect_stderr
stdout_buf, stderr_buf = io.StringIO(), io.StringIO()
namespace = _NAMESPACE.copy()
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
if "await " in code:
wrapped = "async def __main__():\n"
wrapped += "\n".join(f" {line}" for line in code.split("\n"))
wrapped += "\n return locals()"
exec(compile(wrapped, "<execute>", "exec"), namespace)
result_locals = await namespace["__main__"]()
namespace.update(result_locals)
else:
exec(compile(code, "<execute>", "exec"), namespace)
return {"stdout": stdout_buf.getvalue(), "stderr": stderr_buf.getvalue()}
Why it works: The schema is trivially simple (code: str). Opus writes Python fluently -- it is the structured parameter serialization that fails. Keep original tools for Gemini/GPT compatibility.
5. Always Use Async Subprocess with stdin=DEVNULL
Sync subprocess blocks MCP heartbeats. On Windows, stdin inheritance causes deadlock with the MCP stdio transport.
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
Quick Reference
Safe Token Budgets
| Component | Limit | Reasoning |
|---|
| Tool descriptions (total) | <500 tokens | Loaded on every request |
| Single tool description | <50 tokens | Keep concise |
| Command output | 8,000 chars | ~2,000 tokens |
| Research output | 12,000 chars | ~3,000 tokens, still safe |
| Tool count | <10 tools | LLMs degrade above 10-20 |
Minimal FastMCP 2.x Server
from fastmcp import FastMCP
mcp = FastMCP("my-server", instructions="Brief description.")
@mcp.tool(name="my-tool", description="Concise description under 50 tokens.")
async def my_tool(action: str) -> str:
return "SUCCESS\n\nResult here"
def run_server():
mcp.run()
Consistent Return Format
return "SUCCESS\n\nOutput here..."
return "FAILED\nError: reason\n\nPartial output..."
Claude Code Configuration
In ~/.claude/mcp.json:
{
"mcpServers": {
"my-server": {
"command": "python",
"args": ["-m", "mypackage.mcp_server"]
}
}
}
Checklist
Before shipping an MCP server, verify:
When to Escalate
- Context overflow persists despite following all guidelines above
- Need to expose 20+ distinct operations that resist consolidation
- Integration with non-Python MCP clients or SDKs
- Real-time streaming requirements beyond stdio/SSE
- Opus failures that persist even with the execute pattern