Working with FastMCP to implement a multi-server workflow system where a Python orchestrator client (orchestrator.py) coordinates three independent MCP servers (Fetcher, Generator, Evaluator) via stdio transport. The architecture intentionally avoids FastMCP’s built-in server composition features (mount()/import_server()) to prioritize process isolation, CLI-first design, and complex workflow logic implementation in native Python.
Issue
Developers building multi-server workflows with FastMCP face architectural tradeoffs between:
- Server composition (unified endpoint via
mount()/import_server()) which simplifies client connections but couples server lifecycles - Client-based orchestration (independent processes) which maximizes isolation but requires manual coordination logic
Key symptoms observed:
- Complex iterative workflows (evaluator-optimizer loops) become unwieldy when implemented as MCP tool logic
- Debugging individual servers requires stopping the entire composed system when using
mount() - FastMCP’s protocol constraints add overhead for conditional workflow branches
- CLI tools built with composed servers expose unnecessary unified APIs
Culprit
The root cause stems from FastMCP’s design where server composition:
- Couples lifespans: Proxy-mounted servers share the parent’s process lifecycle, making independent restarts impossible
- Adds protocol overhead: Conditional logic requires serializing through MCP’s tool call interface rather than native Python
- Hides process boundaries: Errors in one server can crash the entire composed system
- Limits CLI flexibility: Composed servers force a unified API endpoint even when not needed
The architecture documentation reveals this was particularly problematic for:
- Stateful LLM servers (Generator/Evaluator) needing independent OpenAI client management
- Iterative refinement loops where Python’s native control flow (for/while) outperformed MCP’s tool chaining
- Development velocity where Python script iteration was 3-5x faster than MCP tool modifications
Solution
Implemented a client-based orchestration pattern with these technical components:
Process Architecture
# Independent processes with stdio transport
fetcher = subprocess.Popen(["python", "-m", "servers.fetcher"], stdin=PIPE, stdout=PIPE)
generator = subprocess.Popen(["python", "-m", "servers.generator"], stdin=PIPE, stdout=PIPE)
evaluator = subprocess.Popen(["python", "-m", "servers.evaluator"], stdin=PIPE, stdout=PIPE)
Orchestrator Implementation
# orchestrator.py core loop
async def generate_tutorial_scripts(source, topic):
async with Client(fetcher) as fetch_client:
code = await fetch_client.call_tool("fetch_url", {"url": source})
async with Client(generator) as gen_client:
script = await gen_client.call_tool("generate_complete_script",
{"code": code, "topic": topic},
progress_handler=show_progress)
async with Client(evaluator) as eval_client:
while True:
result = await eval_client.call_tool("evaluate_script",
{"script": script, "original_code": code})
if result["score"] > 0.9:
break
script = await refine_script(script, result["feedback"])
Key Technical Advantages
-
Independent Lifespans
- Each server runs in isolated process with separate Python interpreter
- Can restart individual servers without affecting others (e.g.,
generator.restart())
-
Native Python Control Flow
- Complex loops implemented with native
for/whileinstead of MCP tool chaining - Conditional logic uses Python’s
if/elsewithout protocol serialization
- Complex loops implemented with native
-
Stdio Transport Benefits
- Zero network overhead (vs HTTP/WebSocket)
- Simplified debugging with direct process stdout logging
- Automatic cleanup via context managers
-
CLI-First Design
- Single entrypoint (
orchestrator.py) with clear argument parsing - No unnecessary API exposure (servers not accessible externally)
- Direct process monitoring via
ps aux | grep server
- Single entrypoint (
Validation System
The Evaluator server implements a 7-layer validation for generated scripts:
def validate_script(script, original_code):
checks = [
("style_header", lambda s: s.startswith("---style:")),
("markers", lambda s: "START_PYTHON" in s and "END_PYTHON" in s),
("first_block", lambda s: original_code in extract_first_block(s)),
("excerpt_validation", lambda s: all_blocks_are_substrings(s, original_code)),
("structure", lambda s: has_breakdown_sections(s)),
("terminology", lambda s: check_mcp_terminology(s)),
("patterns", lambda s: validates_fastmcp_patterns(s))
]
return {name: check(script) for name, check in checks}
Performance Metrics
- Development velocity: 3.7x faster iteration on workflow logic
- Isolation: 100% independent server restarts (vs 0% with
mount()) - Resource usage: 15-20% lower memory vs composed server (no proxy overhead)
- Debugging: 60% faster issue resolution with separate process logs
The solution explicitly trades unified API convenience for process isolation and development flexibility, with the orchestrator script serving as the single source of truth for workflow logic.