You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.tsmain() 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:
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.
Maintain sessionId → { mcpClient, originPeerId }.
On inbound mcp:message: forward the jsonrpc payload to the correct subprocess.
On any JSON-RPC message from a subprocess: wrap in mcp:message and send to originPeerId via route.destinations.
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.
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:
Extend the existing Zod schema with a transport discriminator. Smaller diff; defers the Valibot migration to a separate PR.
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.
Clear and concise description of the problem
AIRI currently has no way to share an MCP server across instances, and
apps/stage-webhas 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: closeThis 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
McpManagercontract inpackages/server-sharedDefine a transport-agnostic
McpManagerinterface inpackages/server-shared/src/types/mcp-manager.ts, re-exported frompackages/server-shared/src/types/index.ts. Bothapps/stage-tamagotchiandapps/stage-webimport 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-protocolThree new Eventa events:
Routing maps onto fields that already exist in
packages/plugin-protocol/src/types/events.ts:mcp:session:openusesDeliveryMode: 'consumer-group'so any available bridge peer can accept it.mcp:session:close,mcp:message) usesDeliverySelectionStrategy: 'sticky'withstickyKeyset tosessionId, guaranteeing every message reaches the bridge peer that owns the subprocess.route.destinationsto address the originating peer, the same pattern other plugin events already follow.mcp:messageis bidirectional;metadata.sourcedistinguishes direction.mcp.jsonschema extensionAdd a
transportdiscriminator field. Default"stdio"(current behaviour). New value"websocket"carriesserverNameonly — 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-bridgeFollows the existing
services/discord-bot/services/telegram-botpattern: a leansrc/index.tsmain()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 ownmcp.jsonenumerating the stdio servers it manages.Per-session flow:
mcp:session:open: spawn (or check out from a pool, if the server supports concurrent sessions) an MCP subprocess forserverName, runinitializewith negotiated capabilities, return asessionId.sessionId → { mcpClient, originPeerId }.mcp:message: forward thejsonrpcpayload to the correct subprocess.mcp:messageand send tooriginPeerIdviaroute.destinations.mcp:session:closeor peer disconnect: tear down the subprocess and clean up the session map.Per
AGENTS.md: structuredREADME.md(what / how / when / when not),Call stack:JSDoc block on the runner entrypoint and session manager, anderrorMessageFromfrom@moeru/stdfor all error paths.Electron and web wiring
apps/stage-tamagotchi: existing stdioMcpManagerimplementation is unchanged. A secondMcpManagerimplementation (WebSocket-tunneled) is composed in viainjecaat the main entrypoint.createMcpServersService()already resolves whichever manager is active — no transport awareness needed in the Eventa handlers.apps/stage-web: wires the WebSocket-tunneledMcpManageronly. This is the first MCP path available to the web app. Entrypoint stays thin perAGENTS.md.Scope summary
packages/plugin-protocolmcp:session:open,mcp:session:close,mcp:messageevent definitions using existingDeliveryMode/DeliverySelectionStrategy/stickyKeypackages/server-sharedMcpManagercontractapps/stage-tamagotchimcp.jsonschema withtransportdiscriminator; add WebSocket-tunneledMcpManager; wire viainjecaapps/stage-webMcpManager; first MCP access for the web appservices/mcp-bridgeserver-sdkpeer, per-session subprocess managementAlternative
apps/stage-web, and duplicates subprocess state across clients with no isolation guarantee.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.mdprefers Valibot for new schema work. Two reasonable paths:transportdiscriminator. Smaller diff; defers the Valibot migration to a separate PR.mcp-config.tsto Valibot as part of this change. Aligns withAGENTS.md; wider blast radius.Picking before implementation begins keeps the PR scoped. Reviewers' call.
Trade-offs.
mcp:session:openvia 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