Skip to content

MCP Aggregator

The MCP aggregator (capsem-mcp-aggregator) is a low-privilege subprocess that manages connections to external MCP servers. It runs in an isolated process with only network access — no VM, no session database, no filesystem, no service IPC.

External MCP servers require network access, broker-resolved auth material, and custom HTTP headers. The main per-VM process (capsem-process) has extensive privileges: VM control, session database, VirtioFS workspace, service IPC. Running external server connections inside capsem-process would expose all of those privileges to any vulnerability in an MCP server connection or the HTTP/SSE transport layer.

The aggregator subprocess enforces a hard privilege boundary:

capsem-processcapsem-mcp-aggregator
VM control (vsock)YesNo
Session databaseYesNo
VirtioFS workspaceYesNo
Service IPCYesNo
Network (external MCP servers)NoYes
Broker-resolved auth materialNoYes

If the aggregator is compromised, the attacker has network access and short-lived MCP auth material resolved by the broker — but cannot reach the VM, read telemetry, or modify files.

The aggregator sits between the host MITM MCP endpoint (which handles guest VM requests) and external MCP servers (which provide tools like GitHub, Slack, etc.).

graph LR
    subgraph "Guest VM"
        AGENT["AI agent"]
    end

    subgraph "capsem-process"
        EP["MITM MCP Endpoint<br/>(framed vsock:5002)"]
        CLIENT["AggregatorClient<br/>(mpsc channel)"]
        WRITER["Writer task<br/>(stdin)"]
        READER["Reader task<br/>(stdout)"]
    end

    subgraph "capsem-mcp-aggregator"
        MAIN["NDJSON loop"]
        MGR["McpServerManager"]
    end

    subgraph "External"
        EXT1["GitHub MCP"]
        EXT2["Slack MCP"]
    end

    AGENT -->|"framed MCP<br/>vsock:5002"| EP
    EP --> CLIENT
    CLIENT --> WRITER
    WRITER -->|"stdin<br/>NDJSON"| MAIN
    MAIN --> MGR
    MGR -->|"HTTP/SSE"| EXT1
    MGR -->|"HTTP/SSE"| EXT2
    MAIN -->|"stdout<br/>NDJSON"| READER
    READER --> CLIENT

The policy boundary is the MITM MCP endpoint, not the aggregator. External MCP tool calls are inspected, allowed, asked, blocked, or rewritten before the aggregator receives them. Network traffic that an external MCP server performs from the host is outside the guest MITM path and does not create guest net_events rows.

Four layers handle the flow:

  1. AggregatorClient (in capsem-process) — typed async API wrapping an mpsc channel. Multiple endpoint sessions share one client via Arc.
  2. Driver tasks (in capsem-process) — writer task serializes requests to subprocess stdin; reader task deserializes responses from stdout and routes them to pending callers via oneshot channels.
  3. NDJSON loop (in capsem-mcp-aggregator) — reads requests from stdin, dispatches to McpServerManager, writes responses to stdout.
  4. McpServerManager (in capsem-core) — manages rmcp HTTP connections to external servers, builds unified tool/resource/prompt catalogs with namespacing.

capsem-process spawns the aggregator during VM startup, after loading MCP server definitions from user and corp config files.

sequenceDiagram
    participant Proc as capsem-process
    participant Agg as capsem-mcp-aggregator
    participant Ext as External MCP servers

    Proc->>Agg: spawn (stdin/stdout piped, stderr inherited)
    Proc->>Agg: [{"name":"github","url":"...","auth":{"kind":"oauth","credential_ref":"credential:blake3:..."}}]\n (first line)
    Agg->>Ext: HTTP MCP initialize (per enabled server)
    Ext-->>Agg: tools/list, resources/list, prompts/list
    Note over Agg: Build unified catalogs
    Note over Agg: Enter NDJSON request loop

The binary is located next to capsem-process in ~/.capsem/bin/. If not found (dev builds without a full install), capsem-process falls back to an in-process mock that returns empty results for catalog queries and errors for tool calls.

The subprocess runs for the lifetime of the VM. Requests arrive on stdin, responses go to stdout, logs go to stderr (inherited by the parent).

Two paths:

  1. Normal: capsem-process sends a shutdown request. The aggregator disconnects all servers and exits.
  2. Parent exit: capsem-process closes stdin (process exit, crash, or signal). The aggregator detects EOF, calls shutdown_all(), and exits.

If the aggregator crashes, the reader and writer driver tasks in capsem-process exit (broken pipe / EOF). Subsequent requests from the endpoint receive a channel-closed error. The endpoint returns a JSON-RPC error to the guest — the VM continues running, only external MCP tools become unavailable.

Communication uses newline-delimited JSON over stdin/stdout. Each message is a single JSON object terminated by \n. Maximum line length is 1 MB.

The first line on stdin is a JSON array of server definitions:

[
{
"name": "github",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {},
"auth": {
"kind": "oauth",
"credential_ref": "credential:blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"enabled": true,
"source": "claude",
"unsupported_stdio": false
}
]

Raw API keys, OAuth access tokens, refresh tokens, and Authorization headers are never serialized into MCP config. Remote MCP auth is broker-owned: the server definition carries only an opaque credential:blake3:* reference and the connector resolves it at the HTTP transport boundary.

Servers marked unsupported_stdio: true are stdio-only servers that cannot be connected over HTTP — the aggregator skips them. Disabled servers are also skipped.

{"id": 1, "method": "list_servers"}
{"id": 2, "method": "list_tools"}
{"id": 3, "method": "list_resources"}
{"id": 4, "method": "list_prompts"}
{"id": 5, "method": "call_tool", "params": {"name": "github__search_repos", "arguments": {"query": "rust"}}}
{"id": 6, "method": "read_resource", "params": {"uri": "capsem://github/repo://owner/repo"}}
{"id": 7, "method": "get_prompt", "params": {"name": "github__review_pr", "arguments": {}}}
{"id": 8, "method": "refresh", "params": {"servers": [...]}}
{"id": 9, "method": "shutdown"}
{"id": 1, "servers": [{"name": "github", "connected": true, "tool_count": 5, ...}]}
{"id": 2, "tools": [{"namespaced_name": "github__search_repos", "server_name": "github", ...}]}
{"id": 5, "result": {"content": [{"type": "text", "text": "..."}]}}
{"id": 8, "ok": true}
{"id": 9, "ok": true}

Error responses:

{"id": 5, "error": "server not found: github"}

Each request carries an id (monotonically increasing AtomicU64). The response echoes the same id. The driver’s reader task uses a HashMap<u64, oneshot::Sender> to route responses back to the correct caller.

MethodPurposeResponse
list_serversServer definitions with connection statusservers: [...]
list_toolsAll discovered tools across connected serverstools: [...]
list_resourcesAll discovered resourcesresources: [...]
list_promptsAll discovered promptsprompts: [...]
call_toolCall a namespaced tool on an external serverresult: {...}
read_resourceRead a namespaced resource from an external serverresult: {...}
get_promptGet a namespaced prompt from an external serverresult: {...}
refreshDisconnect all servers, replace definitions, reconnectok: true
shutdownDisconnect all servers and exitok: true

External tools are namespaced with __ (double underscore) to prevent collisions across servers:

github__search_repos (server "github", tool "search_repos")
slack__send_message (server "slack", tool "send_message")

Resources use URI-based namespacing:

capsem://github/repo://owner/repo

The aggregator splits on the first __ when routing, so tool names containing __ are supported (e.g., github__my__tool routes to server github, tool my__tool).

MCP server definitions are profile-owned and filtered by corp constraints. The list is processed in trust order so corp constraints cannot be shadowed by a profile entry:

  1. Corp constraints from corp config and referenced rule files
  2. Profile MCP servers from the active profile
  3. Registry/builtin server descriptors owned by Capsem

Names containing __ or matching builtin are rejected. Empty names are rejected.

The refresh operation allows live reconfiguration without restarting the VM:

  1. Service receives POST /profiles/{profile_id}/mcp/servers/{server_id}/refresh
  2. Service sends McpRefreshTools IPC to capsem-process
  3. capsem-process reads fresh profile/corp MCP config
  4. Client sends refresh with new definitions to the aggregator
  5. Aggregator disconnects all servers, replaces definitions, reconnects

This supports adding, removing, or reconfiguring MCP servers while a VM is running.

The service exposes MCP operations through its HTTP API, which capsem-process handles by delegating to the aggregator:

Service IPC messagecapsem-process action
McpListServersaggregator.list_servers()
McpListToolsaggregator.list_tools()
McpRefreshToolsRead settings, aggregator.refresh(new_servers)
McpCallToolaggregator.call_tool(name, args)

These IPC messages let the CLI, gateway, and frontend query and control MCP servers through the standard service API path.

The aggregator is designed for graceful degradation:

ScenarioBehavior
Some servers fail to connect at startupWarning logged, continue with working servers
Tool call to disconnected serverError response to caller, other tools unaffected
Malformed request lineLogged, skipped, loop continues
Subprocess crashEndpoint returns JSON-RPC errors, VM keeps running
Serialization failureJSON-RPC error response written to stdout
Stdin EOFGraceful shutdown (all servers disconnected)
FilePurpose
capsem-mcp-aggregator/src/main.rsSubprocess binary: init, NDJSON loop, request dispatch
capsem-core/src/mcp/aggregator.rsProtocol types (AggregatorRequest/Response) and AggregatorClient
capsem-core/src/mcp/server_manager.rsMcpServerManager: rmcp connections, tool catalog, namespacing
capsem-core/src/mcp/mod.rsbuild_profile_server_list(): profile-owned MCP servers plus the local builtin server
capsem-process/src/main.rsspawn_mcp_aggregator(): launch and driver tasks for the selected profile’s MCP contract
capsem-core/src/net/mitm_proxy/mcp_endpoint.rsMITM MCP endpoint: policy, telemetry, and dispatch through the aggregator
capsem-proto/src/ipc.rsService-process IPC messages for MCP operations