MCP Proxy Hub
Module: src-tauri/src/mcp_proxy/
The MCP Proxy Hub turns TUICommander into a universal MCP aggregator. TUIC acts simultaneously as an MCP server (serving downstream clients such as Claude Code or Cursor) and as an MCP client (connecting to upstream MCP servers). Tools from all connected upstreams are merged into the single /mcp endpoint that TUIC already exposes, with each upstream’s tools namespaced as {upstream_name}__{tool_name}.
Architecture
Claude Code ──┐
Cursor ───────┼──▶ POST /mcp ──┬──▶ GitHub MCP (HTTP)
VS Code ──────┘ (TUIC server) ├──▶ Filesystem MCP (stdio)
├──▶ Database MCP (HTTP)
└──▶ Custom MCP (HTTP/stdio)
The entry point for all MCP traffic is POST /mcp (Streamable HTTP transport, spec 2025-03-26). When a tools/call request arrives with a name containing __, the transport layer routes it to the upstream registry instead of the native tool handler.
Module Layout
| File | Purpose |
|---|---|
mcp_proxy/mod.rs | Module declaration |
mcp_proxy/registry.rs | Central registry — connection lifecycle, tool aggregation, routing, circuit breaker |
mcp_proxy/http_client.rs | MCP client over Streamable HTTP |
mcp_proxy/stdio_client.rs | MCP client over stdio (spawned process) |
mcp_upstream_config.rs | Config schema, validation, persistence (mcp-upstreams.json) |
mcp_upstream_credentials.rs | OS keyring credential management |
mcp_http/mcp_transport.rs | Routing logic inside the /mcp handler |
Tool Namespace
All proxied tools are exposed with the prefix {upstream_name}__{tool_name}. The double underscore (__) is the routing discriminator — native TUIC tools never contain it. The separator splits only on the first occurrence, so tool names with internal underscores work correctly (e.g. upstream__tool__with__underscores routes to upstream upstream, tool tool__with__underscores). The namespace identifies the origin; the upstream description is preserved byte-for-byte so TUIC instructions are not duplicated across every discovered tool.
UpstreamRegistry
UpstreamRegistry (registry.rs) is the central hub stored in AppState as Arc<UpstreamRegistry>. It is thread-safe — all internal maps use DashMap (lock-free concurrent HashMap) and per-entry state is protected by parking_lot RwLocks and Mutexes.
Entry Lifecycle
connect_upstream(config)
│
├── Validate: no duplicate name, no circular URL
├── Build client (Http or Stdio)
├── Insert UpstreamEntry into DashMap
│
├── If disabled → status = Disabled (done)
│
└── Spawn async task:
initialize_entry()
├── Run MCP handshake
├── Fetch tools/list
├── On success → status = Ready, cache tools
└── On failure → circuit breaker records failure
→ status = CircuitOpen or Failed
Statuses
| Status | Meaning |
|---|---|
Connecting | Handshake in progress (initial state for enabled entries) |
Ready | Handshake complete, tools available |
CircuitOpen | Too many failures, backoff timer active |
Disabled | Disabled by user in config (enabled: false) |
Failed | Permanently failed after max retries exceeded |
NeedsAuth | Upstream returned 401/challenge (or its OAuth token was rejected and could not be refreshed) — awaiting the user to click “Authorize”. Tool calls are rejected with -32001 until a user-initiated OAuth flow succeeds |
Authenticating | OAuth flow in progress (user clicked “Authorize”) — tool calls rejected with -32001 |
Boot-Time Auto-Connect & tools/list Readiness
On startup, auto_connect_saved_upstreams() (mcp_upstream_config.rs) registers every saved upstream. It is spawned, not awaited, on both boot paths (desktop lib.rs and headless run_headless): mcp_http::start_server parks on the shutdown signal and never returns, so any auto-connect placed after it would be dead code — leaving every upstream unconnected until the user touches the UI. Registration is fast (the per-upstream async initialize is itself spawned), so it never delays IPC socket binding.
To avoid serving a stale tool list, the registry exposes a one-shot settle gate:
mark_initial_connect_complete()— set byauto_connect_saved_upstreamsonce every upstream is registered (at both exits, including the empty-config early return). Asyncinitializemay still be in flight.await_initial_settle(timeout)— the firsttools/listcalls this beforemerged_tool_definitions(). It blocks (≤timeout, default 3s) until auto-connect is complete and no entry is stillConnecting, then serves. A globalinitial_settle_donelatch makes every later call a no-op, so steady-statetools/listnever blocks. On timeout it logs a warning and serves a possibly-partial list rather than hanging.
This also protects clients and older client versions that fetch tools/list during
their handshake but do not apply a later notifications/tools/list_changed.
Compatible current clients can refresh live; clients that ignore the notification
still receive the complete settled list at connection time.
Tool Aggregation
aggregated_tools() collects tools from all Ready upstreams, applies per-upstream tool filters, prefixes names, and annotates descriptions. Non-Ready upstreams are silently omitted. The merged list is returned as the tools array in tools/list responses alongside native TUIC tools.
Tool Routing
proxy_tool_call(prefixed_name, args) parses the __ separator, looks up the upstream by name, checks the circuit breaker, dispatches the call to the correct client, and records metrics and circuit breaker outcomes.
Circuit Breaker
Each upstream has an independent circuit breaker with the following thresholds:
| Parameter | Value |
|---|---|
| Failures before circuit opens | 3 |
| Initial backoff on open | 1 second |
| Maximum backoff cap | 60 seconds |
| Backoff growth | Exponential (1000ms × 2^excess) |
| Maximum retries before permanent failure | 10 |
State transitions:
- Closed → CircuitOpen: 3 consecutive failures trigger the circuit. Backoff starts at 1s and doubles with each additional failure, capped at 60s.
- CircuitOpen → Ready: A successful tool call or health check resets the failure count and closes the circuit.
- CircuitOpen → Failed: After 10 total circuit re-opens without recovery, the entry is marked Failed and requires manual reconnect (
reconnect_mcp_upstream).
Health Checks
A background task (spawn_health_checker) runs every 60 seconds and probes all Ready upstreams via tools/list (HTTP) or is_alive() process check (stdio). CircuitOpen upstreams whose backoff has expired are also probed for recovery.
HTTP Client (http_client.rs)
Implements the MCP Streamable HTTP transport (spec 2025-03-26):
initialize()— Reads Bearer token from OS keyring (if any), sendsinitializerequest, cachesmcp-session-idheader, sendsnotifications/initialized(fire-and-forget), fetchestools/list.call_tool(name, args)— Sendstools/callwith the cached session ID and auth token.call_tool_with_reconnect(name, args)— Callscall_tool, and on HTTP 400 (session expired) or connection error, re-initializes once and retries.health_check()— Pings viatools/list. Used by the background health checker.shutdown()— SendsDELETE /mcpwith the session ID to cleanly terminate the upstream session.
The User-Agent header is set to tuicommander-mcp-proxy/{version}.
OAuth Refresh Failure → Re-Authorization
When a request needs a fresh token, refresh_token_if_needed() runs the refresh and routes any failure through classify_refresh_error():
- Fatal (
invalid_grant, no refresh token available, HTTP 400/401) →UpstreamError::AuthFailed. The refresh token is expired/revoked and cannot recover automatically. - Transient (network, 5xx) →
UpstreamError::Other(retryable) so the circuit breaker + health checks keep trying.
In initialize_entry_with_oauth, an AuthFailed on an OAuth2-configured upstream deletes the dead keyring token and re-enters NeedsAuth (UI shows “Authorize”) instead of parking the upstream in a silent red Failed/CircuitOpen the user can’t act on. Non-OAuth upstreams with a bad static token still go red (the arm is guarded by matches!(auth, Some(OAuth2 { .. }))). The health checker skips NeedsAuth entries, so there is no delete/init loop.
Stdio Client (stdio_client.rs)
Spawns a local process and communicates via newline-delimited JSON-RPC on stdin/stdout.
Process Lifecycle
spawn_and_initialize()— Rate-limited (minimum 5s between spawns), clears any existing process, spawns a new child with a sanitized environment, runs the MCP handshake.call_tool(name, args)— Sendstools/callJSON-RPC via stdin, reads response from stdout.is_alive()— Non-blockingtry_wait()check on the child process.shutdown()— Closes stdin (signals EOF), waits up to 2s for voluntary exit, then kills.
Environment Sanitization
The parent environment is cleared before spawning to prevent credential leakage (ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, etc.) to potentially untrusted MCP server processes. A safe allowlist is re-applied:
PATH, HOME, USER, LANG, LC_ALL, TMPDIR, TEMP, TMP, SHELL, TERM
User-configured env overrides from the upstream config are then applied on top of the safe set.
Respawn Rate Limit
To prevent tight loops when an MCP server crashes, the client enforces a minimum 5-second interval between spawn attempts. A premature respawn call returns an error immediately without spawning.
Config Schema
Configuration is persisted to mcp-upstreams.json in the platform config directory, separate from the main AppConfig.
UpstreamMcpConfig (top-level)
{
"servers": [ /* array of UpstreamMcpServer */ ]
}
UpstreamMcpServer
| Field | Type | Default | Description |
|---|---|---|---|
id | String | required | Unique UUID for config diff tracking |
name | String | required | Human-readable name, also the namespace prefix. Must match [a-z0-9_-]+ |
transport | UpstreamTransport | required | Connection type (http or stdio) |
enabled | bool | true | If false, the entry is registered but never connected |
timeout_secs | u32 | 30 | Per-request timeout (0 = no timeout, HTTP only) |
tool_filter | ToolFilter? | null | Optional allow/deny filter |
UpstreamTransport
HTTP variant:
{
"type": "http",
"url": "https://example.com/mcp"
}
Stdio variant:
{
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env": { "ALLOWED_PATHS": "/home/user/projects" }
}
ToolFilter
| Field | Type | Description |
|---|---|---|
mode | "allow" or "deny" | Allow only matching tools, or deny matching tools |
patterns | Vec<String> | Exact names or glob patterns (trailing * = prefix match) |
Filter examples:
- Allow only read tools:
{ "mode": "allow", "patterns": ["read_*", "list_*"] } - Block dangerous tools:
{ "mode": "deny", "patterns": ["delete_*", "rm", "exec_*"] }
Validation
validate_upstream_config() runs before every save_mcp_upstreams call and collects all errors (not just the first):
| Error | Cause |
|---|---|
EmptyName | Server name field is empty |
InvalidName | Name contains characters outside [a-z0-9_-] |
DuplicateName | Two servers share the same name |
EmptyUrl | HTTP transport has an empty URL |
InvalidUrlScheme | HTTP URL does not start with http:// or https:// |
SelfReferentialUrl | HTTP URL points to TUIC’s own MCP port (circular proxy guard) |
EmptyCommand | Stdio transport has an empty command |
The self-referential check compares the URL’s host (localhost, 127.0.0.1, ::1, 0.0.0.0) and port against TUIC’s own running port.
Credential Management
Credentials (Bearer tokens for HTTP upstreams) are stored in the platform OS keyring, never in config files:
| Platform | Backend |
|---|---|
| macOS | Keychain |
| Windows | Credential Manager |
| Linux | keyutils / Secret Service |
The keyring service name is tuicommander-mcp. The account name is the upstream name. Credential names follow the same [a-z0-9_-]+ validation as upstream names.
read_upstream_credential(name) returns None (not an error) when no credential exists.
Hot-Reload
apply_config_diff(old, new) compares two configs using server id as the stable identifier:
- Removed servers →
disconnect_upstream(name)(stdio: graceful shutdown, HTTP: no-op) - Added servers →
connect_upstream(config) - Changed servers (same
id, any field changed) → disconnect + reconnect - Unchanged servers → left running, no interruption
This is called automatically by save_mcp_upstreams after writing the config file, so adding or reconfiguring upstreams takes effect immediately without restarting TUIC.
SSE Events
Status changes emit UpstreamStatusChanged events via the app event bus, which surfaces as Server-Sent Events on GET /events. The event payload is:
{
"type": "upstream_status_changed",
"name": "github-mcp",
"status": "ready"
}
Valid status values: connecting, ready, circuit_open, disabled, failed, needs_auth, authenticating.
Metrics
Each upstream tracks lock-free atomic counters:
| Metric | Type | Description |
|---|---|---|
call_count | AtomicU32 | Total tool calls routed |
error_count | AtomicU32 | Total failed tool calls |
last_latency_ms | AtomicU32 | Last observed round-trip time |
Available via status_snapshot() which returns a JSON snapshot of all upstreams including status, transport info, tool count, and metrics.
Integration with /mcp Transport
In mcp_transport.rs, the tools/call handler checks the tool name for __:
#![allow(unused)]
fn main() {
if tool_name.contains("__") {
// Route to upstream registry (async)
state.mcp_upstream_registry.proxy_tool_call(&tool_name, args).await
} else {
// Handle natively (sync, via spawn_blocking)
handle_mcp_tool_call(&state, addr, &tool_name, &args)
}
}
The tools/list response merges native tools with upstream tools via merged_tool_definitions().
The build_mcp_instructions() function supplies TUIC protocol and orchestration context once in the initialize response. Proxied upstream descriptions remain upstream-owned and do not repeat that preamble.
Successful proxied tools/call responses that are valid MCP CallToolResult objects
(an object with a content array) pass through as the downstream JSON-RPC result.
TUIC does not re-wrap or mutate their content, isError, structuredContent, or
extension fields. The same passthrough is used when collapse_tools routes an upstream
call through the call_tool meta-tool. If an upstream returns a malformed result, TUIC
falls back to a compact JSON text content envelope; native TUIC tools always use that
compact envelope.