Skip to content

Commit d6eafc9

Browse files
committed
feat(satellite): implement instance router for path-based MCP access
1 parent 50f4a62 commit d6eafc9

File tree

2 files changed

+423
-0
lines changed

2 files changed

+423
-0
lines changed
Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3+
import {
4+
ListToolsRequestSchema,
5+
CallToolRequestSchema
6+
} from '@modelcontextprotocol/sdk/types.js';
7+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8+
import { FastifyInstance, FastifyRequest, FastifyReply, FastifyBaseLogger } from 'fastify';
9+
import { createHash } from 'crypto';
10+
import { McpToolExecutor } from '../lib/mcp-tool-executor';
11+
import { McpSessionManager } from '../lib/mcp-session-manager';
12+
import { UnifiedToolDiscoveryManager } from '../services/unified-tool-discovery-manager';
13+
import { DynamicConfigManager } from '../services/dynamic-config-manager';
14+
import { ProcessManager } from '../process';
15+
import { McpServerConfig } from '../services/command-polling-service';
16+
17+
interface InstanceContext {
18+
processId: string;
19+
serverConfig: McpServerConfig;
20+
instancePath: string;
21+
}
22+
23+
/**
24+
* Instance Router
25+
*
26+
* Provides direct MCP endpoint for individual instances via path-based routing.
27+
* Unlike the hierarchical router which uses meta-tools for discovery/execution,
28+
* this router exposes ALL tools from a specific instance directly.
29+
*
30+
* Routes: /i/:instancePath/mcp?token=<instance_token>
31+
*
32+
* Authentication: SHA-256 hash comparison of instance token
33+
* Session Management: Separate session namespace from hierarchical router
34+
* Tool Execution: Shared McpToolExecutor for consistent behavior
35+
*/
36+
export class InstanceRouter {
37+
private logger: FastifyBaseLogger;
38+
private toolExecutor: McpToolExecutor;
39+
private sessionManager: McpSessionManager;
40+
private configManager: DynamicConfigManager;
41+
private toolDiscoveryManager: UnifiedToolDiscoveryManager;
42+
private processManager: ProcessManager;
43+
44+
constructor(deps: {
45+
logger: FastifyBaseLogger;
46+
toolExecutor: McpToolExecutor;
47+
sessionManager: McpSessionManager;
48+
configManager: DynamicConfigManager;
49+
toolDiscoveryManager: UnifiedToolDiscoveryManager;
50+
processManager: ProcessManager;
51+
}) {
52+
this.logger = deps.logger.child({ component: 'InstanceRouter' });
53+
this.toolExecutor = deps.toolExecutor;
54+
this.sessionManager = deps.sessionManager;
55+
this.configManager = deps.configManager;
56+
this.toolDiscoveryManager = deps.toolDiscoveryManager;
57+
this.processManager = deps.processManager;
58+
}
59+
60+
/**
61+
* Setup MCP server for a specific instance
62+
* Returns ALL tools from this instance directly (not meta-tools)
63+
*/
64+
setupInstanceMcpServer(server: Server, processId: string): void {
65+
// Register tools/list handler - return ALL tools from this instance
66+
server.setRequestHandler(ListToolsRequestSchema, async () => {
67+
const allTools = this.toolDiscoveryManager.getAllTools();
68+
69+
// Filter to only tools from THIS instance's process
70+
const instanceTools = allTools.filter(tool => tool.serverName === processId);
71+
72+
this.logger.info({
73+
operation: 'instance_tools_list',
74+
process_id: processId,
75+
tool_count: instanceTools.length
76+
}, `Listing ${instanceTools.length} tools for instance ${processId}`);
77+
78+
// Return actual tool definitions (NOT meta-tools, NO namespacing)
79+
return {
80+
tools: instanceTools.map(tool => ({
81+
name: tool.originalName, // Original tool name (NOT namespaced)
82+
description: tool.description,
83+
inputSchema: tool.inputSchema,
84+
})),
85+
};
86+
});
87+
88+
// Register tools/call handler - execute on THIS instance
89+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
90+
const { name: toolName, arguments: toolArgs } = request.params;
91+
92+
this.logger.info({
93+
operation: 'instance_tool_call',
94+
process_id: processId,
95+
tool_name: toolName
96+
}, `Executing tool ${toolName} on instance ${processId}`);
97+
98+
// Execute tool on this specific instance
99+
// Need to convert tool name to namespaced format for executor
100+
const namespacedToolName = `${processId}:${toolName}`;
101+
102+
const result = await this.toolExecutor.executeToolCall(
103+
namespacedToolName,
104+
toolArgs || {},
105+
processId // Force routing to this specific process
106+
);
107+
108+
return result;
109+
});
110+
111+
this.logger.info({
112+
operation: 'instance_mcp_server_setup',
113+
process_id: processId
114+
}, `Instance MCP server setup complete for ${processId}`);
115+
}
116+
117+
/**
118+
* Find instance by path slug
119+
*/
120+
private findInstanceByPath(instancePath: string): { processId: string; config: McpServerConfig } | null {
121+
const allConfigs = this.configManager.getEnabledMcpServers();
122+
123+
for (const [processId, config] of Object.entries(allConfigs)) {
124+
if (config.instance_path === instancePath) {
125+
return { processId, config };
126+
}
127+
}
128+
129+
return null;
130+
}
131+
132+
/**
133+
* Validate instance token
134+
*/
135+
private validateInstanceToken(
136+
token: string,
137+
expectedHash: string
138+
): boolean {
139+
// Hash the incoming token
140+
const tokenHash = createHash('sha256').update(token).digest('hex');
141+
142+
// Compare with stored hash
143+
return tokenHash === expectedHash;
144+
}
145+
146+
/**
147+
* Create JSON-RPC error response
148+
*/
149+
private createJsonRpcError(code: number, message: string) {
150+
return {
151+
jsonrpc: '2.0' as const,
152+
error: { code, message },
153+
id: null,
154+
};
155+
}
156+
157+
/**
158+
* Respawn dormant process if needed
159+
*/
160+
private async ensureProcessActive(processId: string, serverConfig: McpServerConfig): Promise<void> {
161+
// Only relevant for stdio processes
162+
if (serverConfig.transport_type !== 'stdio' && serverConfig.type !== 'stdio') {
163+
return;
164+
}
165+
166+
try {
167+
await this.processManager.getOrRespawnProcess(processId);
168+
169+
this.logger.debug({
170+
operation: 'instance_process_ensured_active',
171+
process_id: processId
172+
}, `Ensured process ${processId} is active for instance request`);
173+
} catch (error) {
174+
this.logger.warn({
175+
operation: 'instance_process_respawn_failed',
176+
process_id: processId,
177+
error: error instanceof Error ? error.message : String(error)
178+
}, `Failed to ensure process ${processId} is active, continuing anyway`);
179+
}
180+
}
181+
182+
/**
183+
* Register Fastify routes for path-based instance access
184+
*/
185+
setupRoutes(fastify: FastifyInstance): void {
186+
// Shared authentication middleware for all instance routes
187+
const authenticateInstance = async (request: FastifyRequest, reply: FastifyReply) => {
188+
const { instancePath } = request.params as { instancePath: string };
189+
const { token } = request.query as { token?: string };
190+
191+
// 1. Find instance by path
192+
const instanceConfig = this.findInstanceByPath(instancePath);
193+
if (!instanceConfig) {
194+
return reply.status(404).type('application/json').send(
195+
JSON.stringify(this.createJsonRpcError(-32004, 'Instance not found'))
196+
);
197+
}
198+
199+
// 2. Validate token exists
200+
if (!token || !token.startsWith('ds_inst_')) {
201+
return reply.status(401).type('application/json').send(
202+
JSON.stringify(this.createJsonRpcError(-32001, 'Missing or invalid token format'))
203+
);
204+
}
205+
206+
// 3. Validate token hash
207+
if (!instanceConfig.config.instance_token_hash) {
208+
this.logger.error({
209+
operation: 'instance_auth_missing_hash',
210+
instance_path: instancePath,
211+
process_id: instanceConfig.processId
212+
}, 'Instance config missing token hash');
213+
214+
return reply.status(500).type('application/json').send(
215+
JSON.stringify(this.createJsonRpcError(-32000, 'Instance authentication not configured'))
216+
);
217+
}
218+
219+
const isValid = this.validateInstanceToken(token, instanceConfig.config.instance_token_hash);
220+
if (!isValid) {
221+
this.logger.warn({
222+
operation: 'instance_auth_failed',
223+
instance_path: instancePath,
224+
process_id: instanceConfig.processId
225+
}, 'Invalid instance token');
226+
227+
return reply.status(401).type('application/json').send(
228+
JSON.stringify(this.createJsonRpcError(-32001, 'Invalid token'))
229+
);
230+
}
231+
232+
// 4. Store authenticated context on request
233+
(request as any).instanceAuth = {
234+
processId: instanceConfig.processId,
235+
serverConfig: instanceConfig.config,
236+
instancePath: instancePath
237+
} as InstanceContext;
238+
239+
this.logger.debug({
240+
operation: 'instance_auth_success',
241+
instance_path: instancePath,
242+
process_id: instanceConfig.processId
243+
}, 'Instance authentication successful');
244+
};
245+
246+
// POST /i/:instancePath/mcp - Client-to-server MCP messages
247+
fastify.post('/i/:instancePath/mcp', {
248+
preValidation: authenticateInstance
249+
}, async (request: FastifyRequest, reply: FastifyReply) => {
250+
const { processId, serverConfig } = (request as any).instanceAuth as InstanceContext;
251+
const sessionId = request.headers['mcp-session-id'] as string | undefined;
252+
const requestBody = request.body as any;
253+
const isInitRequest = requestBody?.method === 'initialize';
254+
255+
this.logger.info({
256+
operation: 'instance_mcp_request',
257+
process_id: processId,
258+
session_id: sessionId,
259+
has_session: sessionId ? this.sessionManager.hasSession(sessionId) : false,
260+
is_initialize: isInitRequest,
261+
method: requestBody?.method || 'unknown'
262+
}, 'Processing instance MCP request');
263+
264+
let transport: StreamableHTTPServerTransport;
265+
let server: Server;
266+
267+
// Ensure process is active before creating session
268+
if (isInitRequest) {
269+
await this.ensureProcessActive(processId, serverConfig);
270+
}
271+
272+
if (sessionId && this.sessionManager.hasSession(sessionId)) {
273+
// Reuse existing session
274+
const session = this.sessionManager.getSession(sessionId)!;
275+
transport = session.transport;
276+
server = session.server;
277+
} else if (isInitRequest) {
278+
// New session
279+
if (sessionId) {
280+
this.logger.info({
281+
operation: 'instance_session_recreate',
282+
session_id: sessionId,
283+
process_id: processId
284+
}, 'Recreating instance session after restart');
285+
}
286+
287+
const sessionEntry = this.sessionManager.createSession((server) => {
288+
this.setupInstanceMcpServer(server, processId);
289+
});
290+
291+
transport = sessionEntry.transport;
292+
server = sessionEntry.server;
293+
await server.connect(transport);
294+
} else if (sessionId) {
295+
// Resurrect stale session
296+
this.logger.info({
297+
operation: 'instance_session_resurrection',
298+
session_id: sessionId,
299+
process_id: processId
300+
}, 'Resurrecting instance session');
301+
302+
await this.ensureProcessActive(processId, serverConfig);
303+
304+
const sessionEntry = this.sessionManager.createSessionWithId(
305+
sessionId,
306+
(server) => {
307+
this.setupInstanceMcpServer(server, processId);
308+
}
309+
);
310+
311+
transport = sessionEntry.transport;
312+
server = sessionEntry.server;
313+
await server.connect(transport);
314+
315+
// Bootstrap transport with synthetic initialize
316+
const syntheticInitRequest = {
317+
jsonrpc: '2.0' as const,
318+
id: 0,
319+
method: 'initialize',
320+
params: {
321+
protocolVersion: '2024-11-05',
322+
capabilities: {},
323+
clientInfo: {
324+
name: 'resurrected-session',
325+
version: '1.0.0'
326+
}
327+
}
328+
};
329+
330+
const mockRes = {
331+
writeHead: () => mockRes,
332+
write: () => true,
333+
end: () => mockRes,
334+
setHeader: () => mockRes,
335+
socket: request.raw.socket,
336+
statusCode: 200,
337+
statusMessage: 'OK',
338+
headersSent: false,
339+
};
340+
341+
await transport.handleRequest(request.raw as any, mockRes as any, syntheticInitRequest);
342+
} else {
343+
// No session ID - reject
344+
reply.code(400).send(this.createJsonRpcError(-32000, 'No session ID provided'));
345+
return;
346+
}
347+
348+
// Handle the request
349+
await transport.handleRequest(request.raw, reply.raw, request.body);
350+
});
351+
352+
// GET /i/:instancePath/mcp - SSE for server-to-client notifications
353+
fastify.get('/i/:instancePath/mcp', {
354+
preValidation: authenticateInstance
355+
}, async (request: FastifyRequest, reply: FastifyReply) => {
356+
const sessionId = request.headers['mcp-session-id'] as string | undefined;
357+
358+
if (!sessionId || !this.sessionManager.hasSession(sessionId)) {
359+
reply.code(400).send('Invalid or missing session ID');
360+
return;
361+
}
362+
363+
const session = this.sessionManager.getSession(sessionId)!;
364+
await session.transport.handleRequest(request.raw, reply.raw);
365+
});
366+
367+
// DELETE /i/:instancePath/mcp - Session termination
368+
fastify.delete('/i/:instancePath/mcp', {
369+
preValidation: authenticateInstance
370+
}, async (request: FastifyRequest, reply: FastifyReply) => {
371+
const sessionId = request.headers['mcp-session-id'] as string | undefined;
372+
373+
if (!sessionId || !this.sessionManager.hasSession(sessionId)) {
374+
reply.code(400).send('Invalid or missing session ID');
375+
return;
376+
}
377+
378+
const session = this.sessionManager.getSession(sessionId)!;
379+
await session.transport.handleRequest(request.raw, reply.raw);
380+
});
381+
382+
this.logger.info({
383+
operation: 'instance_routes_registered'
384+
}, 'Instance router routes registered at /i/:instancePath/mcp');
385+
}
386+
}

0 commit comments

Comments
 (0)