Architecture
This document describes the high-level architecture of Open MCP Gateway.
System Overview
┌─────────────────────────────────────────────────┐
│ Open MCP Gateway │
┌─────────┐ │ ┌─────────────────────────────────────────────┐│ ┌─────────────┐
│ Claude │◀──────▶│ │ HTTP Transport (Axum) ││ │ Local MCP │
│ Desktop │ stdio │ │ GET /servers POST /mcp POST /admin/... ││ ┌───▶│ Server │
└─────────┘ │ └───────────────────┬─────────────────────────┘│ │ └─────────────┘
│ │ │ │
┌─────────┐ │ ┌───────────────────▼─────────────────────────┐│ │ ┌─────────────┐
│ Web │◀──────▶│ │ Server Manager ││───┼───▶│ Remote SSE │
│ App │ HTTP │ │ • Auto-start • Pooling • Idle timeout ││ │ │ Server │
└─────────┘ │ └───────────────────┬─────────────────────────┘│ │ └─────────────┘
│ │ │ │
┌─────────┐ │ ┌───────────────────▼─────────────────────────┐│ │ ┌─────────────┐
│ Cline │◀──────▶│ │ Runtime Layer ││───┴───▶│ Docker │
│Extension│ stdio │ │ LocalProcess | RemoteSSE | Docker | K8s ││ │ Container │
└─────────┘ │ └─────────────────────────────────────────────┘│ └─────────────┘
│ │
│ ┌─────────────────────────────────────────────┐│
│ │ Server Registry ││
│ │ catalog.yaml → HashMap<String, Server> ││
│ └─────────────────────────────────────────────┘│
│ ▲ │
│ ┌───────────────────┴─────────────────────────┐│
│ │ Catalog Watcher ││
│ │ File watch → Debounce → Atomic swap ││
│ └─────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
Component Details
HTTP Transport Layer
File: gateway-http/src/main.rs, gateway-http/src/routes.rs
The HTTP layer uses Axum to provide:
let app = Router::new()
.route("/health", get(health))
.route("/servers", get(list_servers))
.route("/servers/:id", get(get_server))
.route("/servers/:id/start", post(start_server))
.route("/servers/:id/stop", post(stop_server))
.route("/mcp", post(mcp_request))
.route("/rpc", post(rpc_request))
.route("/stats", get(stats))
.route("/admin/reload", post(reload_catalog))
.layer(auth_layer)
.layer(TraceLayer::new_for_http());
Features:
- RESTful endpoints for server management
- JSON-RPC endpoint for MCP communication
- Optional API key authentication
- Request tracing and logging
Stdio Transport Layer
File: gateway-stdio/src/main.rs
The stdio wrapper:
loop {
// Read JSON-RPC from stdin
let message = read_line_from_stdin()?;
// Forward to MCP server
let response = backend.send(message).await?;
// Write response to stdout
write_line_to_stdout(response)?;
}
Used by:
- Claude Desktop
- Cline VS Code extension
- Continue.dev
- Any stdin/stdout MCP client
Server Manager
File: gateway-core/src/manager.rs
Responsibilities:
pub struct ServerManager {
registry: Arc<ServerRegistry>,
connections: RwLock<HashMap<String, ConnectionPool>>,
status: RwLock<HashMap<String, ServerStatus>>,
}
impl ServerManager {
// Auto-start server if not running
pub async fn get_connection(&self, server_id: &str) -> Result<Connection>;
// Explicit lifecycle control
pub async fn start_server(&self, server_id: &str) -> Result<()>;
pub async fn stop_server(&self, server_id: &str) -> Result<()>;
// Background tasks
async fn idle_timeout_checker(&self);
async fn health_checker(&self);
}
States:
Stopped → Starting → Running → ShuttingDown → Stopped
↓
Unhealthy
Runtime Layer
File: gateway-core/src/runtime/
The runtime abstraction:
#[async_trait]
pub trait Runtime: Send + Sync {
async fn connect(&self) -> Result<Box<dyn BackendConnection>>;
}
pub enum RuntimeConfig {
LocalProcess { command, args, working_dir },
RemoteSse { url, headers },
Docker { image, volumes, ... }, // v0.2
K8sJob { namespace, image, ... }, // v0.3
K8sService { namespace, service }, // v0.3
}
Each runtime implements connect() to establish an BackendConnection.
Server Registry
File: gateway-core/src/registry.rs
Immutable server catalog:
pub struct ServerRegistry {
servers: HashMap<String, ServerDefinition>,
}
impl ServerRegistry {
pub fn get(&self, id: &str) -> Option<&ServerDefinition>;
pub fn all(&self) -> impl Iterator<Item = &ServerDefinition>;
pub fn by_tag(&self, tag: &str) -> Vec<&ServerDefinition>;
}
Thread-safe via Arc<ServerRegistry>. Replaced atomically on reload.
Catalog Watcher
File: gateway-core/src/catalog_watcher.rs
Hot reload implementation:
pub struct CatalogWatcher {
catalog_path: PathBuf,
registry: Arc<RwLock<Arc<ServerRegistry>>>,
debounce_tx: mpsc::Sender<()>,
}
impl CatalogWatcher {
pub async fn start(&self) -> Result<()> {
let watcher = notify::recommended_watcher(|event| {
// Trigger debounced reload
self.debounce_tx.send(()).await;
})?;
watcher.watch(&self.catalog_path, RecursiveMode::NonRecursive)?;
}
async fn reload(&self) -> Result<()> {
let new_registry = load_catalog(&self.catalog_path)?;
let mut registry = self.registry.write().await;
*registry = Arc::new(new_registry);
}
}
Data Flow
MCP Request Flow
1. Client sends HTTP POST /mcp
{"server_id": "postgres", "method": "tools/list", ...}
2. Auth middleware validates API key (if configured)
3. Route handler extracts server_id and message
4. ServerManager.get_connection("postgres")
- If stopped: Start server via runtime
- Get connection from pool
5. Connection.send(message)
- LocalProcess: Write to stdin
- RemoteSSE: HTTP POST
6. Connection.recv()
- LocalProcess: Read from stdout
- RemoteSSE: Read HTTP response
7. Return response to client
{"result": {"tools": [...]}}
8. Update last_activity timestamp
9. (Background) Check idle timeout
- If idle > timeout: Stop server
Catalog Reload Flow
1. User edits catalog.yaml
2. File watcher detects change
3. Debounce timer starts (500ms)
4. If more changes: Reset timer
5. After debounce: Parse YAML
6. Validate all server definitions
7. If valid:
- Create new ServerRegistry
- Atomic swap: registry = new_registry
- Log: "Loaded N servers"
8. If invalid:
- Log error
- Keep previous registry
Thread Model
Main Thread
│
├── HTTP Server (Tokio runtime)
│ ├── Accept connections
│ └── Handle requests (spawn per request)
│
├── Catalog Watcher
│ ├── File watch events
│ └── Debounce timer
│
├── Idle Timeout Checker
│ └── Periodic scan of server activity
│
└── Health Checker
└── Periodic health checks
All components use Tokio's async runtime for non-blocking I/O.
Error Handling Strategy
// Gateway-level errors (gateway-core/src/error.rs)
#[derive(Error, Debug)]
pub enum GatewayError {
#[error("Server not found: {0}")]
ServerNotFound(String),
#[error("Runtime error: {0}")]
RuntimeError(#[from] RuntimeError),
#[error("Configuration error: {0}")]
ConfigError(String),
}
// Errors are converted to HTTP responses (gateway-http/src/routes.rs)
impl IntoResponse for GatewayError {
fn into_response(self) -> Response {
match self {
GatewayError::ServerNotFound(_) => StatusCode::NOT_FOUND,
GatewayError::RuntimeError(_) => StatusCode::BAD_GATEWAY,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}