The Atlas Lavern's documentation, bound to its code
111 documents

Integrating & operating

The system's edges: providers and tools, the API surface, the remote bridge, datasets, and the two live migration roadmaps.

src/mcp/remote-bridge/server.ts208 lines · registerBridgeServer L174–207
Outline 16 symbols
1/**
2 * Remote MCP Bridge — JSON-RPC 2.0 Server.
3 *
4 * Speaks the MCP JSON-RPC 2.0 protocol over HTTP so the Anthropic Managed
5 * Agents runtime can treat our server as a remote MCP server. The bridge
6 * exposes the Counsel-workflow tool subset only (see `tool-allowlist.ts`).
7 *
8 * Stage 1 scope (this file):
9 * - JSON-RPC envelope: `initialize`, `tools/list`, `tools/call`
10 * - Auth: static shared secret + X-Lavern-Session-Id header (session-auth.ts)
11 * - Allowlist enforcement: anything outside COUNSEL_REMOTE_TOOLS is denied
12 * - `tools/call` is **not yet wired to live execution** — it returns a
13 * structured `not_yet_wired` error. Stage 2 replaces the stub with an
14 * actual dispatch into the in-process MCP tool handlers.
15 *
16 * Why a stub in Stage 1?
17 * The in-process MCP tools close over `SessionState`. Wiring live execution
18 * here requires reflecting the tool registry out of `createShemMcpServer`
19 * (which is built per-session, per-template) and replaying calls against
20 * the right session. That's an invasive change — keeping it separate from
21 * the transport/auth/allowlist surface lets us review each concern
22 * independently and ship the bridge behind its feature flag before we
23 * depend on it.
24 */
25
26import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
27import type { SessionManager } from '../../session/session-manager.js';
28import type { SessionState } from '../../session/session-state.js';
29import { authenticateBridgeRequest } from './session-auth.js';
30import { isRemoteToolAllowed } from './tool-allowlist.js';
31import { buildCounselToolsListing, dispatchCounselTool } from './dispatcher.js';
32import { createLogger } from '../../utils/logger.js';
33import { config } from '../../config.js';
34
35const logger = createLogger('MCP-BRIDGE');
36
37/** MCP protocol version we advertise during `initialize`. Managed Agents
38 * documentation pins this in the `managed-agents-2026-04-01` beta header. */
39const MCP_PROTOCOL_VERSION = '2025-06-18';
40
41// ── JSON-RPC 2.0 envelope types ────────────────────────────────────────
42
43interface JsonRpcRequest {
44 jsonrpc: '2.0';
45 id?: string | number | null;
46 method: string;
47 params?: Record<string, unknown>;
48}
49
50type JsonRpcId = string | number | null;
51
52interface JsonRpcSuccessResponse {
53 jsonrpc: '2.0';
54 id: JsonRpcId;
55 result: unknown;
56}
57
58interface JsonRpcErrorResponse {
59 jsonrpc: '2.0';
60 id: JsonRpcId;
61 error: { code: number; message: string; data?: unknown };
62}
63
64type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
65
66/** MCP/JSON-RPC error codes we use. -32000..-32099 is reserved for
67 * implementation-defined server errors per the JSON-RPC 2.0 spec. */
68const RPC_ERR = {
69 PARSE: -32700,
70 INVALID_REQUEST: -32600,
71 METHOD_NOT_FOUND: -32601,
72 INVALID_PARAMS: -32602,
73 INTERNAL: -32603,
74 // Implementation-defined
75 TOOL_NOT_ALLOWED: -32001,
76 TOOL_NOT_WIRED: -32002,
77 TOOL_NOT_FOUND: -32003,
78 HANDLER_ERROR: -32004,
79} as const;
80
81function rpcError(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcErrorResponse {
82 return { jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } };
83}
84
85function rpcSuccess(id: JsonRpcId, result: unknown): JsonRpcSuccessResponse {
86 return { jsonrpc: '2.0', id, result };
87}
88
89// ── Method handlers ────────────────────────────────────────────────────
90
91function handleInitialize(id: JsonRpcId): JsonRpcResponse {
92 return rpcSuccess(id, {
93 protocolVersion: MCP_PROTOCOL_VERSION,
94 capabilities: {
95 // We only expose tools; no prompts, resources, or sampling.
96 tools: { listChanged: false },
97 },
98 serverInfo: {
99 name: 'lavern-mcp-bridge',
100 version: config.version,
101 },
102 });
103}
104
105function handleToolsList(id: JsonRpcId, session: SessionState): JsonRpcResponse {
106 // Stage 2: real names + JSON-Schema'd input schemas derived from the
107 // in-process tool registry (see dispatcher.ts).
108 return rpcSuccess(id, { tools: buildCounselToolsListing(session) });
109}
110
111async function handleToolsCall(
112 id: JsonRpcId,
113 session: SessionState,
114 params: Record<string, unknown> | undefined,
115): Promise<JsonRpcResponse> {
116 const toolName = typeof params?.name === 'string' ? params.name : null;
117 if (!toolName) {
118 return rpcError(id, RPC_ERR.INVALID_PARAMS, 'Missing required param "name"');
119 }
120 if (!isRemoteToolAllowed(toolName)) {
121 return rpcError(id, RPC_ERR.TOOL_NOT_ALLOWED, `Tool "${toolName}" is not exposed by the remote bridge`);
122 }
123
124 const rawArgs = params?.arguments;
125 const outcome = await dispatchCounselTool(session, toolName, rawArgs);
126
127 switch (outcome.kind) {
128 case 'ok':
129 return rpcSuccess(id, outcome.result);
130 case 'not_allowed':
131 // Defense-in-depth — should've been caught above, but if the allowlist
132 // was mutated between checks we still return a clean refusal.
133 return rpcError(id, RPC_ERR.TOOL_NOT_ALLOWED, `Tool "${outcome.toolName}" is not exposed by the remote bridge`);
134 case 'not_found':
135 return rpcError(id, RPC_ERR.TOOL_NOT_FOUND, `Tool "${outcome.toolName}" is allow-listed but not registered for this session`);
136 case 'invalid_args':
137 return rpcError(id, RPC_ERR.INVALID_PARAMS, `Invalid arguments for "${outcome.toolName}": ${outcome.message}`);
138 case 'handler_error':
139 return rpcError(id, RPC_ERR.HANDLER_ERROR, `Tool "${outcome.toolName}" failed: ${outcome.message}`);
140 }
141}
142
143// ── Request dispatch ───────────────────────────────────────────────────
144
145async function dispatch(rpc: JsonRpcRequest, session: SessionState): Promise<JsonRpcResponse> {
146 const id = rpc.id ?? null;
147 switch (rpc.method) {
148 case 'initialize':
149 return handleInitialize(id);
150 case 'tools/list':
151 return handleToolsList(id, session);
152 case 'tools/call':
153 return await handleToolsCall(id, session, rpc.params);
154 case 'notifications/initialized':
155 // JSON-RPC notifications have no `id` and expect no response. Fastify
156 // needs something to send; we return a 204-equivalent empty success.
157 return rpcSuccess(id, {});
158 default:
159 return rpcError(id, RPC_ERR.METHOD_NOT_FOUND, `Method "${rpc.method}" is not supported by this bridge`);
160 }
161}
162
163function isJsonRpcRequest(body: unknown): body is JsonRpcRequest {
164 if (!body || typeof body !== 'object') return false;
165 const b = body as Record<string, unknown>;
166 return b.jsonrpc === '2.0' && typeof b.method === 'string';
167}
168
169/**
170 * Register the bridge endpoint on an already-configured Fastify instance.
171 * Caller is responsible for gating registration behind the feature flag
172 * (see `index.ts`).
173 */
174export function registerBridgeServer(
175 fastify: FastifyInstance,
176 sessionManager: SessionManager,
177): void {
178 fastify.post('/api/mcp/bridge', async (request: FastifyRequest, reply: FastifyReply) => {
179 const auth = authenticateBridgeRequest(request, sessionManager);
180 if (!auth.ok) {
181 logger.warn('bridge auth failed', { code: auth.code, status: auth.status });
182 return reply.status(auth.status).send({
183 jsonrpc: '2.0',
184 id: null,
185 error: { code: RPC_ERR.INVALID_REQUEST, message: auth.error, data: { code: auth.code } },
186 });
187 }
188
189 const body = request.body;
190 if (!isJsonRpcRequest(body)) {
191 return reply.status(400).send(rpcError(null, RPC_ERR.INVALID_REQUEST, 'Request is not a valid JSON-RPC 2.0 envelope'));
192 }
193
194 try {
195 const response = await dispatch(body, auth.session);
196 logger.info('bridge call', {
197 method: body.method,
198 sessionId: auth.session.id,
199 tool: typeof body.params?.name === 'string' ? body.params.name : undefined,
200 });
201 return reply.send(response);
202 } catch (err) {
203 logger.error('bridge dispatch error', { error: err });
204 return reply.status(500).send(rpcError(body.id ?? null, RPC_ERR.INTERNAL, 'Bridge dispatch error'));
205 }
206 });
207}
208