A production-grade reference project demonstrating how to compose multiple MCP servers into a multi-server architecture orchestrated by a LangGraph agent.
Built by Chandra Mohan Busam | Principal Engineer and AI Engineer
Architecture Notes: docs/ARCHITECTURE.md Design decisions behind the mount pattern, why LangGraph instead of LangChain ReAct, and how servers coordinate without calling each other.
| Pattern | Implementation |
|---|---|
| FastMCP Mount Pattern | Four sub-servers on one port via main.mount() |
| LangGraph Orchestration | Four nodes, one per server, guaranteed execution order |
| Redis Session Memory | Mock for local dev, real Redis interface for production |
| TTL Caching | Config cached 60s, transparent to orchestrator |
| Structured JSON Logging | Stdout + rotating file, shared across all servers |
| Bearer Token Auth | Single token for all mounted sub-servers |
| ngrok Public Access | One tunnel exposes all four servers |
| Session Isolation | session:{id}:{server}:{key} pattern prevents data leaks |
User Request
|
v
LangGraph Orchestrator (orchestrator/agent.py)
|
+-- Node 1 --> /config DeploymentConfigServer (get_deployment_config)
| Redis cache: 60s TTL
| Writes config to LangGraph State
|
+-- Node 2 --> /agent DeploymentAgentServer (download, transfer, deploy, restart)
| Reads config from LangGraph State
| Retries transfer up to 3x on checksum failure
|
+-- Node 3 --> /audit DeploymentAuditServer (record_start, steps, complete)
| Runs whether deployment succeeds or fails
| Writes audit_id to session memory
|
+-- Node 4 --> /notify NotificationServer (send_deployment_notification)
Reads audit_id from session memory
Posts to Slack and Teams
Key principle: Servers never call other servers. LangGraph State is the coordination layer. Redis is the caching and session layer inside individual servers.
mcp-composition-demo/
│
├── shared/ # Shared infrastructure across all servers
│ ├── __init__.py
│ ├── logger.py # Structured JSON + rotating file logger
│ ├── session_manager.py # Redis session memory (mock + real)
│ ├── cache.py # TTL caching (mock + real)
│ └── auth.py # Bearer token validation
│
├── servers/ # Four MCP sub-servers
│ ├── config_server.py # DeploymentConfigServer (4 tools)
│ ├── agent_server.py # DeploymentAgentServer (5 tools)
│ ├── audit_server.py # DeploymentAuditServer (4 tools)
│ └── notify_server.py # NotificationServer (3 tools)
│
├── orchestrator/
│ └── agent.py # LangGraph graph, nodes, state, run helper
│
├── docs/
│ └── ARCHITECTURE.md # Design decisions and reasoning
│
├── main.py # Mounts all 4 servers, runs on port 8000
├── .env.example # Copy to .env and fill in your values
├── requirements.txt
├── LICENSE
└── README.md
# Clone the repo
git clone https://github.com/ChandraMohanBusam/mcp-composition-demo.git
cd mcp-composition-demo
# Create a virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Set up environment variables
cp .env.example .env
# Edit .env with your values (see sections below)python main.pyExpected output:
MCP Composition Demo
==================================================
Transport: SSE
Port: 8000
Auth: enabled
Session: mock backend
Cache: mock backend
Mounted servers:
/config -> DeploymentConfigServer
/agent -> DeploymentAgentServer
/audit -> DeploymentAuditServer
/notify -> NotificationServer
SSE endpoint: http://localhost:8000/sse
==================================================
python orchestrator/agent.pyThis runs a full deployment pipeline:
- Fetches config for staging environment
- Resolves latest build version
- Downloads, transfers, deploys, restarts
- Records audit trail
- Sends Slack and Teams notification
| Tool | Description |
|---|---|
get_deployment_config(environment, app_name, session_id) |
Returns server IP, deploy path, service config. Cached 60s. |
get_environment_list() |
Lists available environments with health status. |
validate_environment(environment) |
Checks if environment is healthy before deployment. |
get_health_check_url(environment, app_name) |
Returns URL to verify deployment succeeded. |
| Tool | Description |
|---|---|
get_latest_build(pipeline_id, session_id) |
Resolves latest successful build version. |
download_build(version, environment, session_id) |
Downloads build artifact. |
transfer_to_server(version, server_ip, deploy_path, session_id) |
SCP transfer with checksum verification. |
deploy_on_server(version, server_ip, deploy_path, service_name, session_id) |
Unpacks and deploys. |
restart_services(server_ip, service_name, session_id) |
Restarts application services. |
| Tool | Description |
|---|---|
record_deployment_start(version, environment, triggered_by, session_id) |
Creates audit record, returns deployment_id. |
record_deployment_step(deployment_id, step_name, step_status, details, session_id) |
Records a single step. |
record_deployment_complete(deployment_id, final_status, duration_seconds, session_id) |
Closes the audit record. |
get_last_successful_deployment(environment, session_id) |
Returns rollback target version. |
| Tool | Description |
|---|---|
send_deployment_notification(message, status, deployment_id, environment, version, session_id) |
Posts to Slack and Teams. |
send_slack_alert(message, severity, session_id) |
Direct Slack alert. |
send_teams_alert(message, severity, session_id) |
Direct Teams alert. |
Controls short-term context within a deployment run. Set in .env:
SESSION_BACKEND=mock # in-memory, no Redis needed (default)
SESSION_BACKEND=redis # real Redis, requires Redis runningKey pattern for session isolation:
session:{session_id}:{server_name}:{data_type}
Every session key expires after 1 hour automatically.
Controls config cache TTL. Set in .env:
CACHE_BACKEND=mock # in-memory (default)
CACHE_BACKEND=redis # real RedisCache TTL values:
- Deployment config: 60 seconds
- Environment list: 300 seconds
- Health check: 30 seconds
# .env
SESSION_BACKEND=redis
CACHE_BACKEND=redis
REDIS_HOST=localhost
REDIS_PORT=6379Start Redis locally:
# Windows (WSL or Docker)
docker run -p 6379:6379 redis:latest
# Mac
brew install redis && brew services start redisAll four sub-servers share one Bearer token.
Generate a secure token:
python -c "import secrets; print(secrets.token_hex(32))"Add to .env:
MCP_BEARER_TOKEN=your_generated_token_hereInclude in Claude Desktop config (claude_desktop_config.json):
{
"mcpServers": {
"mcp-composition-demo": {
"url": "http://localhost:8000/sse",
"headers": {
"Authorization": "Bearer YOUR_TOKEN_HERE"
}
}
}
}ngrok creates a secure tunnel from a public URL to your local server. This allows Claude Desktop, Claude Code, or claude.ai to connect to your locally running MCP server over the internet.
Why one tunnel works for four servers: The mount pattern runs all four sub-servers on a single port (8000). One ngrok tunnel exposes all four servers simultaneously. This is the main advantage of the mount pattern for local development.
Download from ngrok.com/download and authenticate:
ngrok config add-authtoken YOUR_NGROK_AUTH_TOKEN# Terminal 1
python main.py# Terminal 2
ngrok http 8000Copy the public URL shown by ngrok:
Forwarding https://abc123.ngrok-free.app -> http://localhost:8000
{
"mcpServers": {
"mcp-composition-demo": {
"url": "https://abc123.ngrok-free.app/sse",
"headers": {
"Authorization": "Bearer YOUR_BEARER_TOKEN_HERE"
}
}
}
}Fully quit Claude Desktop (right-click system tray, Quit) and reopen.
claude mcp add mcp-composition-demo \
--url https://abc123.ngrok-free.app/sse \
--header "Authorization: Bearer YOUR_BEARER_TOKEN_HERE"| Issue | Cause | Fix |
|---|---|---|
| 401 Unauthorized | Token mismatch | Check Bearer token in .env matches Claude Desktop config |
| Tunnel not found | Session expired | Restart ngrok, update URL in Claude Desktop config |
| URL changes each restart | Free plan limitation | Upgrade ngrok for static subdomain, or use paid plan |
| Server not responding | MCP server not running | Start python main.py before starting ngrok |
Free plan note: The free ngrok plan generates a new URL on every restart. Update your Claude Desktop config with the new URL each time you restart ngrok. Upgrade to a paid plan for a fixed static subdomain.
Three logging layers are active by default:
Layer 1: Structured JSON to stdout Every log line is a parseable JSON object. AWS ECS and Azure Container Apps pick this up automatically for CloudWatch and Azure Monitor.
Layer 2: Rotating file logger
Writes to logs/{server_name}.log. Rotates daily at midnight. Keeps 7 days.
Useful log queries after a run:
# All tool calls for a session
grep "sess_abc123" logs/orchestrator.log
# All errors across all servers
grep '"level":"ERROR"' logs/*.log
# Config cache hits
grep "cache_hit" logs/config_server.log
# Notification delivery results
grep "send_deployment_notification" logs/notify_server.logThis project demonstrates two debugging approaches side by side.
Code-level debugging (this repo):
- Structured JSON logs per server
- session_id traces a full deployment run across all four servers
- LoopDetectionHandler equivalent: transfer retry is explicit in orchestrator code
LangGraph-specific debugging:
stream_mode="debug"inbuild_deployment_graph()for real-time node eventsget_state()after run for full State snapshotget_state_history()for Time Travel: replay from any checkpoint
See github.com/ChandraMohanBusam/langchain-debug-demo for the companion project covering LangChain and LangGraph debugging in depth.
This project is part of a connected MCP portfolio:
| Project | Transport | Role |
|---|---|---|
| ai-deployment-agent | HTTP/SSE | Original deployment agent (LangChain, V1 + V2 with ADO) |
| mcp-notification-server | HTTP/SSE | Shared notification server (Slack + Teams) |
| mcp-queue-monitor | stdio | Local database monitoring (Claude Desktop) |
| langchain-debug-demo | N/A | LangChain + LangGraph debugging reference |
| mcp-composition-demo (this) | HTTP/SSE | Multi-server composition with LangGraph orchestration |
Chandra Mohan Busam Principal Engineer | AI Engineer GitHub | LinkedIn