MCP Architecture¶
Overview¶
The MCP (Model Context Protocol) layer in tg-note follows a clean separation of concerns between the MCP Hub Service and the Bot Client.
Core Principles¶
- Single Source of Truth: MCP Hub service owns ALL MCP-related logic
- Pure Client Pattern: Bot is a pure client that connects to MCP Hub
- No Config Duplication: Only MCP Hub creates configuration files
- Mode-Agnostic Bot: Bot behavior is the same in Docker and standalone modes
Architecture Diagram¶
┌─────────────────────────────────────────────────────────────────┐
│ Bot Service │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ MCPServerManager (Subprocess Manager) │ │
│ │ │ │
│ │ - Docker Mode: Does nothing (pure client) │ │
│ │ - Standalone Mode: Launches MCP Hub subprocess │ │
│ │ │ │
│ │ NOT responsible for: │ │
│ │ ✗ Config generation │ │
│ │ ✗ MCP tool registration │ │
│ │ ✗ Server registry management │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ │ HTTP/SSE │
│ ▼ │
└─────────────────────────────────────────────────────────────────┘
│
│
┌────────────────────────────▼────────────────────────────────────┐
│ MCP Hub Service │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Unified MCP Gateway │ │
│ │ │ │
│ │ ✓ Built-in MCP Tools (memory, etc.) │ │
│ │ ✓ MCP Server Registry │ │
│ │ ✓ Configuration Generation │ │
│ │ ✓ HTTP/SSE API │ │
│ │ ✓ Per-user isolation │ │
│ │ │ │
│ │ Endpoints: │ │
│ │ - /health (includes builtin tools & servers) │ │
│ │ - /sse (MCP protocol) │ │
│ │ - /registry/servers (CRUD) │ │
│ │ - /config/client/{type} (config generation) │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Deployment Modes¶
Docker Mode¶
# docker-compose.yml
services:
mcp-hub:
# MCP Hub runs as standalone service
# Generates configs on startup
# Owns all MCP logic
bot:
# Bot is pure client
# Connects via MCP_HUB_URL
# No subprocess, no config generation
environment:
- MCP_HUB_URL=http://mcp-hub:8765/sse
Flow:
1. MCP Hub container starts
2. MCP Hub generates client configs (~/.qwen/settings.json)
3. Bot container starts
4. Bot connects to MCP Hub via MCP_HUB_URL
5. Bot uses MCP tools through HTTP/SSE
Standalone Mode¶
Flow:
1. Bot starts
2. Bot detects no MCP_HUB_URL (standalone mode)
3. Bot launches MCP Hub as subprocess
4. MCP Hub subprocess generates client configs
5. Bot connects to MCP Hub at http://127.0.0.1:8765/sse
6. Bot uses MCP tools through HTTP/SSE
Responsibilities¶
MCP Hub Service (src/mcp/mcp_hub_server.py)¶
Owns: - ✅ Built-in MCP tools (memory, etc.) - ✅ MCP server registry - ✅ Configuration file generation - ✅ HTTP/SSE API endpoints - ✅ Per-user storage isolation
On Startup:
1. Initialize FastMCP server
2. Register built-in tools
3. Initialize registry from data/mcp_servers/*.json
4. Ensure Docling MCP spec is up to date (if Docling is enabled)
5. Generate client configurations:
- ~/.qwen/settings.json (Qwen CLI) - includes Docling if enabled
- data/mcp_servers/mcp-hub.json (standalone mode only)
6. Start HTTP/SSE server
Configuration Generation:
# Automatic on startup
python -m src.mcp.mcp_hub_server
# Skip config generation
python -m src.mcp.mcp_hub_server --skip-config-gen
Bot (src/mcp/server_manager.py)¶
MCPServerManager Responsibilities: - ✅ Subprocess lifecycle (standalone mode only) - ✅ Health monitoring - ✅ Start/stop subprocess
Does NOT: - ❌ Create configuration files - ❌ Register MCP tools - ❌ Manage MCP server registry
Code:
class MCPServerManager:
"""
Subprocess Lifecycle Manager (Standalone Mode Only)
Docker mode: Does nothing (bot is pure client)
Standalone mode: Launches MCP Hub subprocess
"""
def setup_default_servers(self):
mcp_hub_url = os.getenv("MCP_HUB_URL")
if mcp_hub_url:
# Docker mode: pure client, no action needed
logger.info(f"Docker mode: connecting to {mcp_hub_url}")
else:
# Standalone mode: launch subprocess
self._setup_memory_subprocess()
Health Check Endpoint¶
The /health endpoint provides comprehensive information about the MCP Hub state:
Response:
{
"status": "ok",
"service": "mcp-hub",
"version": "1.0.0",
"builtin_tools": {
"total": 3,
"names": [
"store_memory",
"retrieve_memory",
"list_categories"
]
},
"registry": {
"servers_total": 0,
"servers_enabled": 0
},
"storage": {
"active_users": 0
},
"ready": true
}
Fields:
- builtin_tools - MCP tools provided by the hub itself (always available)
- registry.servers_total - External MCP servers registered (user-added servers)
- registry.servers_enabled - Number of enabled external servers
- storage.active_users - Number of users with active storage sessions
Configuration Files¶
Who Creates What¶
| File | Created By | When | Mode |
|---|---|---|---|
~/.qwen/settings.json |
MCP Hub | On startup | Both |
data/mcp_servers/mcp-hub.json |
MCP Hub | On startup | Standalone only |
data/mcp_servers/*.json |
User/Admin | Manual | Both |
Configuration API¶
MCP Hub provides a dynamic configuration API:
# Get standard config (Cursor, Claude Desktop, Qwen CLI)
curl http://localhost:8765/config/client/standard
# Get LM Studio config
curl http://localhost:8765/config/client/lmstudio
# Download as file
curl http://localhost:8765/config/client/standard?format=raw \
-o mcp-hub.json
Migration Notes¶
What Changed¶
Before (❌ Wrong):
- Bot created configs in both modes
- MCPServerManager._create_qwen_config() ran in Docker mode
- Config generation logic scattered across bot codebase
- Duplication between bot and MCP Hub
After (✅ Correct): - MCP Hub owns ALL config generation - Bot is pure client (no config creation) - Single source of truth for MCP logic - Clear separation of concerns
Migration Checklist¶
- Remove
_create_qwen_config()fromMCPServerManager - Remove
_setup_mcp_hub_connection()fromMCPServerManager - Remove config creation from
_setup_memory_subprocess() - Add config generation to MCP Hub startup
- Add
/config/client/{type}API endpoint - Update
MCPServerManagerdocstring - Update
setup_default_servers()logic
Testing¶
Docker Mode Test¶
# Start services
docker-compose up
# Check MCP Hub health
curl http://localhost:8765/health
# Verify no configs created by bot
# (only by MCP Hub service)
Standalone Mode Test¶
# Start bot
python -m main
# Verify MCP Hub subprocess started
ps aux | grep mcp_hub_server
# Verify configs created by MCP Hub
ls -la ~/.qwen/settings.json
ls -la data/mcp_servers/mcp-hub.json
Config Generation Test¶
# Test dynamic config API
curl http://localhost:8765/config/client/standard | jq
# Expected output:
{
"success": true,
"client_type": "standard",
"config": {
"mcpServers": {
"mcp-hub": {
"url": "http://127.0.0.1:8765/sse",
...
}
}
}
}
Best Practices¶
For Developers¶
- Never create configs in bot code
- All config generation belongs in MCP Hub
-
Bot is a pure client
-
Use environment detection correctly
-
Add new MCP features in MCP Hub
- New tools → Add to
mcp_hub_server.py - New configs → Add to
_generate_client_configs() - New registry features → Add to registry module
For DevOps¶
- Docker deployments
- Set
MCP_HUB_URLenvironment variable -
Bot will automatically be pure client
-
Standalone deployments
- Don't set
MCP_HUB_URL -
Bot will launch MCP Hub subprocess
-
Configuration management
- Configs are generated on MCP Hub startup
- To regenerate: restart MCP Hub service
Error Handling¶
Connection Error Handling¶
The MCP client (src/mcp/client.py) includes comprehensive error handling for connection issues:
Empty SSE Data:
- Checks if SSE data is empty before parsing as JSON
- Logs debug message and continues reading next event
- Prevents JSONDecodeError on empty data fields
Connection Timeout: - 10-second timeout for SSE connection establishment - Detailed error message with diagnostic checklist: - Verify MCP Hub server is running - Check URL configuration - Verify network connectivity - Check firewall settings
Session ID Extraction:
- Reads up to 100 lines from SSE stream
- Supports multiple session ID formats:
- From uri query parameter: ?session_id=abc123
- From data directly: {"session_id": "abc123"}
- Provides detailed error if session ID not found
Import Error Handling:
- qwen_config_generator.py handles missing FastMCP dependencies gracefully
- Falls back to basic memory tools if mcp_hub_server can't be imported
- Logs helpful message suggesting to install with pip install fastmcp
Logging Strategy¶
Client-side logging:
- DEBUG: SSE events, parsed data, connection details
- INFO: Connection established, session ID, available tools
- WARNING: Empty data, parse errors (non-critical)
- ERROR: Connection failures, timeouts, missing session ID
Example successful connection log:
[MCPClient] Connecting to MCP server (SSE): http://mcp-hub:8765/sse
[MCPClient] Opening SSE connection to http://mcp-hub:8765/sse/
[MCPClient] SSE event: endpoint
[MCPClient] SSE endpoint data: {'uri': 'http://mcp-hub:8765/messages/?session_id=abc123'}
[MCPClient] ✓ SSE session established: abc123
[MCPClient] Using RPC endpoint: http://mcp-hub:8765/messages/
[MCPClient] ✓ Connected. Available tools: ['store_memory', 'retrieve_memory', 'list_categories']
Example connection error log:
[MCPClient] Connecting to MCP server (SSE): http://mcp-hub:8765/sse
[MCPClient] Opening SSE connection to http://mcp-hub:8765/sse/
[MCPClient] SSE connection timeout - server at http://mcp-hub:8765/sse/ did not respond within 10 seconds.
Verify that:
1. MCP Hub server is running
2. URL is correct (currently: http://mcp-hub:8765/sse)
3. Network connectivity is available
4. Firewall allows the connection
Troubleshooting¶
Issue: Logs show config generation in Docker mode¶
Symptom:
[MCPServerManager] Creating MCP configurations for various clients...
[MCPServerManager] Creating Qwen CLI config (HTTP/SSE mode)
Cause: Old code running (pre-refactor)
Solution:
1. Verify you're on latest code
2. Check MCPServerManager.setup_default_servers() doesn't call _create_qwen_config()
3. Rebuild Docker images
Issue: MCP Hub not creating configs¶
Symptom: ~/.qwen/settings.json doesn't exist
Possible causes:
1. MCP Hub started with --skip-config-gen flag
2. Config generation failed (check logs)
3. Permission issues with home directory
Solution:
# Check MCP Hub logs
docker logs mcp-hub | grep "Generating client configurations"
# Manually trigger config generation via API
curl http://localhost:8765/config/client/standard
FastMCP SSE Protocol¶
The MCP Hub uses FastMCP library which implements the MCP protocol over HTTP Server-Sent Events (SSE). Understanding this protocol is crucial for proper client implementation.
Connection Flow¶
Correct Connection Sequence:
-
Establish SSE Connection (GET request)
-
Receive Session ID (SSE event)
-
Keep SSE Connection Open (CRITICAL)
- The SSE connection MUST remain open to receive responses
- Responses are sent as SSE 'message' events, not in POST response body
-
Closing the connection causes
anyio.ClosedResourceErroron server -
Send JSON-RPC Requests (POST with session_id)
-
Receive Responses via SSE (SSE 'message' event)
IMPORTANT: The POST request typically returns 202 Accepted without a response body. The actual JSON-RPC response is sent via the SSE stream as a 'message' event. The client must match responses to requests by the id field.
Common Connection Errors¶
Error: anyio.ClosedResourceError¶
Symptom:
ERROR: Exception in ASGI application
Traceback (most recent call last):
...
File "/usr/local/lib/python3.11/site-packages/mcp/server/sse.py", line 202, in handle_post_message
await writer.send(session_message)
...
anyio.ClosedResourceError
Client Error:
ERROR | [MCPClient] HTTP request exception: 202, message='Attempt to decode JSON with unexpected mimetype: '
Cause: Client closed the SSE connection after receiving the session_id. FastMCP needs the SSE stream to remain open to send JSON-RPC responses.
Solution: The SSE connection must remain open throughout the session:
1. Client opens SSE connection (GET /sse/)
2. Client reads session_id from 'endpoint' event
3. Client keeps SSE connection open (do NOT call response.close())
4. Client starts background task to read 'message' events from SSE stream
5. Client sends requests via POST (returns 202 Accepted)
6. Server sends responses via SSE 'message' events
7. Background task matches responses to requests by ID
Fixed in: src/mcp/client.py - The MCPClient class now:
- Keeps SSE response open in _sse_response attribute
- Runs _sse_reader() background task to read responses
- Matches responses to pending requests by request ID
- Only closes SSE connection on explicit disconnect()
Error: 307 Temporary Redirect¶
Symptom:
INFO: 172.24.0.3:51288 - "POST /sse HTTP/1.1" 307 Temporary Redirect
INFO: 172.24.0.3:51288 - "POST /sse/ HTTP/1.1" 405 Method Not Allowed
Cause: Missing trailing slash in URL
Solution: Always use trailing slashes for FastMCP endpoints:
- ✅ http://mcp-hub:8765/sse/
- ✅ http://mcp-hub:8765/messages/
- ❌ http://mcp-hub:8765/sse
- ❌ http://mcp-hub:8765/messages
Error: 400 Bad Request - "Received request without session_id"¶
Symptom:
INFO: 172.24.0.3:51288 - "POST /messages/ HTTP/1.1" 400 Bad Request
Received request without session_id
Cause: Client didn't establish SSE connection first or didn't include session_id in POST requests
Solution: Follow the correct connection flow:
1. GET /sse/ to establish connection
2. Parse SSE events to extract session_id
3. Include session_id as query parameter in all POST requests
Client Implementation¶
The MCPClient class in src/mcp/client.py implements the FastMCP SSE protocol correctly:
Key Components:
- Session Establishment (
_connect_sse()): - Opens GET connection to
/sse/ - Parses SSE events to extract
session_id - Stores
session_idfor future requests -
Derives JSON-RPC endpoint URL (
/messages/) -
Request Sending (
_send_request_http()): - Adds
session_idas query parameter - POSTs to
/messages/?session_id=<id> -
Accepts both 200 OK and 202 Accepted status codes
-
URL Normalization:
- Ensures trailing slashes to avoid redirects
- Handles various URL formats automatically
Example Usage:
from src.mcp.client import MCPClient, MCPServerConfig
# Configure SSE transport
config = MCPServerConfig(
transport="sse",
url="http://mcp-hub:8765/sse" # Trailing slash added automatically
)
# Connect and use
client = MCPClient(config)
await client.connect() # Establishes SSE session
result = await client.call_tool("store_memory", {...}) # Uses session_id
Debugging Connection Issues¶
Enable Debug Logging:
Check Server Logs:
# Docker mode
docker logs mcp-hub | grep -E "sse|messages|session"
# Standalone mode
tail -f logs/mcp_hub.log | grep -E "sse|messages|session"
Expected Successful Flow:
[MCPClient] Connecting to MCP server (SSE): http://mcp-hub:8765/sse
[MCPClient] Opening SSE connection to http://mcp-hub:8765/sse/
[MCPClient] SSE event: endpoint
[MCPClient] SSE endpoint data: {'uri': '...?session_id=abc123'}
[MCPClient] ✓ SSE session established: abc123
[MCPClient] Using RPC endpoint: http://mcp-hub:8765/messages/
[MCPClient] ✓ Connected. Available tools: [...]
Docling MCP Integration¶
The Docling MCP server is automatically registered and configured:
Registration:
- Docling MCP spec is created in data/mcp_servers/docling.json on startup
- Uses ensure_docling_mcp_spec() from src.mcp.docling_integration
- Automatically included in Qwen CLI configuration when enabled
Architecture:
- Uses original docling-mcp==1.3.2 package
- Minimal wrappers for tg-note integration:
- convert_document_from_content - base64 file transfer (Docker mode)
- sync_docling_models - model synchronization via MCP
- Custom converter configuration for OCR settings
File Transfer: - Files sent via base64 encoding (no shared filesystem needed) - Uses MCP protocol over HTTP/SSE - Original docling-mcp conversion pipeline
For more details, see File Format Recognition.