Skip to content

Feature request: WebSocket-bridged MCP client for sharing remote MCP servers across AIRI instances #1882

@sebastian-zm

Description

@sebastian-zm

Note: This issue was drafted by Claude Opus (AI assistant) and reviewed by a human contributor.

Clear and concise description of the problem

AIRI currently has no way to share an MCP server across instances, and apps/stage-web has no MCP access at all because it cannot spawn local subprocesses. Both problems collapse into the same shape: tunnel raw MCP JSON-RPC sessions through the existing AIRI WebSocket plugin protocol, with a bridge service on the remote machine that owns the subprocesses.

Each client opens a dedicated session through the bridge; the bridge maintains one MCP subprocess session per client, so capability negotiation, subscriptions, and state are all per-client and fully isolated.

sequenceDiagram
    participant E as Electron app / stage-web
    participant W as WebSocket server
    participant B as MCP bridge
    participant M as MCP server

    E->>W: mcp:session:open {serverName}
    note over W: consumer-group route
    W->>B: mcp:session:open {serverName}
    B->>M: initialize
    M-->>B: initialized
    B-->>W: mcp:session:open:result {sessionId}
    W-->>E: mcp:session:open:result {sessionId}

    E->>W: mcp:message {sessionId, jsonrpc}
    note over W: sticky selection by sessionId
    W->>B: mcp:message {sessionId, jsonrpc}
    B->>M: JSON-RPC request
    M-->>B: JSON-RPC response
    note over B,W: direct to originating peer via destinations
    B-->>W: mcp:message {sessionId, jsonrpc}
    W-->>E: mcp:message {sessionId, jsonrpc}

    E->>W: mcp:session:close {sessionId}
    note over W: sticky selection by sessionId
    W->>B: mcp:session:close {sessionId}
    B->>M: close
Loading

This makes it trivial to deploy specialised MCP servers (browser automation, code execution sandboxes, database access) on a single VPS and have every AIRI instance transparently use them, with no per-client SSH configuration. It also unblocks MCP for apps/stage-web, which currently has no path to tool use at all. The session-tunneling design keeps the AIRI event layer stable as MCP evolves and correctly handles MCP notifications, elicitation, sampling, roots, cancellation, and progress.

Suggested solution / Ideas

Shared McpManager contract in packages/server-shared

Define a transport-agnostic McpManager interface in packages/server-shared/src/types/mcp-manager.ts, re-exported from packages/server-shared/src/types/index.ts. Both apps/stage-tamagotchi and apps/stage-web import from there.

Transport-agnostic methods (drawn from the existing stdio implementation at apps/stage-tamagotchi/src/main/services/airi/mcp-servers/index.ts):

  • listTools()
  • callTool(payload)
  • getRuntimeStatus()
  • applyAndRestart()
  • stopAll()

Stdio-specific methods (ensureConfigFile, openConfigFile, readConfigText, writeConfigText, testServer) stay on the stdio implementation only and are not part of the shared contract.

The exported type is McpManager, unqualified. Two implementations exist — stdio (Electron main only) and a WebSocket-tunneled one (Electron main and web) — but neither encodes the transport into the public type name.

Protocol events in packages/plugin-protocol

Three new Eventa events:

// Client → bridge (consumer-group delivery; any available bridge peer accepts)
'mcp:session:open'    // payload: { serverName: string }
                      // result:  { sessionId: string } | { error: string }

// Client → bridge (sticky selection by sessionId; same peer for the life of the session)
'mcp:session:close'   // payload: { sessionId: string }
'mcp:message'         // payload: { sessionId: string; jsonrpc: JsonRpcMessage }

// Bridge → client (direct to originating peer via route.destinations)
'mcp:message'         // same shape; direction encoded in metadata.source

Routing maps onto fields that already exist in packages/plugin-protocol/src/types/events.ts:

  • mcp:session:open uses DeliveryMode: 'consumer-group' so any available bridge peer can accept it.
  • Session-bound traffic (mcp:session:close, mcp:message) uses DeliverySelectionStrategy: 'sticky' with stickyKey set to sessionId, guaranteeing every message reaches the bridge peer that owns the subprocess.
  • Bridge→client replies use route.destinations to address the originating peer, the same pattern other plugin events already follow.

mcp:message is bidirectional; metadata.source distinguishes direction.

mcp.json schema extension

Add a transport discriminator field. Default "stdio" (current behaviour). New value "websocket" carries serverName only — the bridge owns the subprocess.

{
  "mcpServers": {
    "my-remote-server": {
      "transport": "websocket",
      "serverName": "my-remote-server"
      // no command/args — the bridge owns the subprocess
    }
  }
}

Schema file: apps/stage-tamagotchi/src/shared/mcp-config.ts. Clean migration, no backward-compatibility branches.

New service services/mcp-bridge

Follows the existing services/discord-bot / services/telegram-bot pattern: a lean src/index.ts main() that boots an adapter, connects to the AIRI WebSocket server as an authenticated plugin peer via @proj-airi/server-sdk, and handles SIGINT/SIGTERM. Reads its own mcp.json enumerating the stdio servers it manages.

Per-session flow:

  1. On mcp:session:open: spawn (or check out from a pool, if the server supports concurrent sessions) an MCP subprocess for serverName, run initialize with negotiated capabilities, return a sessionId.
  2. Maintain sessionId → { mcpClient, originPeerId }.
  3. On inbound mcp:message: forward the jsonrpc payload to the correct subprocess.
  4. On any JSON-RPC message from a subprocess: wrap in mcp:message and send to originPeerId via route.destinations.
  5. On mcp:session:close or peer disconnect: tear down the subprocess and clean up the session map.

Per AGENTS.md: structured README.md (what / how / when / when not), Call stack: JSDoc block on the runner entrypoint and session manager, and errorMessageFrom from @moeru/std for all error paths.

Electron and web wiring

  • apps/stage-tamagotchi: existing stdio McpManager implementation is unchanged. A second McpManager implementation (WebSocket-tunneled) is composed in via injeca at the main entrypoint. createMcpServersService() already resolves whichever manager is active — no transport awareness needed in the Eventa handlers.
  • apps/stage-web: wires the WebSocket-tunneled McpManager only. This is the first MCP path available to the web app. Entrypoint stays thin per AGENTS.md.

Scope summary

Area Change
packages/plugin-protocol Add mcp:session:open, mcp:session:close, mcp:message event definitions using existing DeliveryMode / DeliverySelectionStrategy / stickyKey
packages/server-shared Add transport-agnostic McpManager contract
apps/stage-tamagotchi Extend mcp.json schema with transport discriminator; add WebSocket-tunneled McpManager; wire via injeca
apps/stage-web Wire WebSocket-tunneled McpManager; first MCP access for the web app
services/mcp-bridge New service with structured README, server-sdk peer, per-session subprocess management

Alternative

  • SSH-based remote stdio. Each client SSHes into the VPS and runs the MCP server over a tunneled stdio. Rejected: requires per-client SSH setup, does not solve apps/stage-web, and duplicates subprocess state across clients with no isolation guarantee.
  • MCP-native HTTP+SSE transport. Each client speaks MCP's own HTTP+SSE transport directly to the remote server. Rejected for this proposal because it bypasses AIRI's plugin protocol entirely — auth, routing, observability, and reconnection would have to be solved per server rather than inherited. Worth revisiting if AIRI ever needs to consume third-party MCP servers it does not deploy itself.

Additional context

Open question — Zod vs Valibot for mcp-config.ts. The existing schema (apps/stage-tamagotchi/src/shared/mcp-config.ts) uses Zod; AGENTS.md prefers Valibot for new schema work. Two reasonable paths:

  1. Extend the existing Zod schema with a transport discriminator. Smaller diff; defers the Valibot migration to a separate PR.
  2. Migrate mcp-config.ts to Valibot as part of this change. Aligns with AGENTS.md; wider blast radius.

Picking before implementation begins keeps the PR scoped. Reviewers' call.

Trade-offs.

  • Latency: every JSON-RPC round-trip incurs a WebSocket hop. Fine for interactive use; avoid for tight loops.
  • Single point of failure: if the WebSocket server is down, remote MCP tools are unavailable — same risk as all other inter-instance communication.
  • One bridge per VPS is the simple case. Multiple bridge peers for the same server set can load-balance mcp:session:open via consumer-group routing, but session affinity means failover requires session re-establishment by the client.

Testing. Vitest unit coverage for the session map and JSON-RPC framing in the WebSocket-tunneled McpManager. Mock-based integration test for the bridge driving a fake MCP subprocess. No smoke-only tests.

Validations

Metadata

Metadata

Assignees

No one assigned

    Labels

    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions