indra-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/build/index.js +443 -0
  2. package/package.json +24 -0
package/build/index.js ADDED
@@ -0,0 +1,443 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'child_process';
3
+ import readline from 'readline';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import os from 'os';
7
+ import crypto from 'crypto';
8
+ // Setup debug logging directory and file
9
+ const homeDir = os.homedir();
10
+ const indraDir = path.join(homeDir, '.indra');
11
+ if (!fs.existsSync(indraDir)) {
12
+ fs.mkdirSync(indraDir, { recursive: true });
13
+ }
14
+ const logFilePath = path.join(indraDir, 'mcp-debug.log');
15
+ function logDebug(message) {
16
+ const timestamp = new Date().toISOString();
17
+ try {
18
+ fs.appendFileSync(logFilePath, `[${timestamp}] ${message}\n`, 'utf8');
19
+ }
20
+ catch (err) {
21
+ console.error(`Failed to write to debug log: ${err}`);
22
+ }
23
+ }
24
+ logDebug('--------------------------------------------------');
25
+ logDebug('Indra MCP Broker initializing Step 3 Edge client...');
26
+ // Resolve Edge worker URL
27
+ const edgeUrl = process.env.INDRA_EDGE_URL || 'http://127.0.0.1:8787';
28
+ logDebug(`[EDGE] Targeting Edge Worker at: ${edgeUrl}`);
29
+ // Parse --config argument
30
+ const cliArgs = process.argv.slice(2);
31
+ let configPath = '';
32
+ for (let i = 0; i < cliArgs.length; i++) {
33
+ if (cliArgs[i] === '--config' && i + 1 < cliArgs.length) {
34
+ configPath = cliArgs[i + 1];
35
+ break;
36
+ }
37
+ }
38
+ if (!configPath) {
39
+ logDebug('[FATAL] No --config path provided in command-line arguments.');
40
+ console.error('Error: --config <path> argument is required.');
41
+ process.exit(1);
42
+ }
43
+ logDebug(`[CONFIG] Loading tools configuration from: ${configPath}`);
44
+ let serversConfig = {};
45
+ try {
46
+ const configContent = fs.readFileSync(configPath, 'utf8');
47
+ const parsedConfig = JSON.parse(configContent);
48
+ serversConfig = parsedConfig.mcpServers || {};
49
+ }
50
+ catch (err) {
51
+ logDebug(`[FATAL] Failed to read or parse configuration file: ${err.message}`);
52
+ console.error(`Error loading config file: ${err.message}`);
53
+ process.exit(1);
54
+ }
55
+ let activeTask = process.env.INDRA_TASK || "Initialize Session";
56
+ let missionToken = "";
57
+ const agentName = process.env.INDRA_AGENT_NAME || "dev-agent";
58
+ // Perform cryptographic Edge session handshake during startup
59
+ async function startSession() {
60
+ logDebug('[HANDSHAKE] Preparing cryptographic session handshake...');
61
+ // 1. Generate local ephemeral session key
62
+ const sessionKeyPair = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
63
+ const publicJwk = sessionKeyPair.publicKey.export({ format: 'jwk' });
64
+ const privateKeyPath = path.join(homeDir, '.indra', 'private.pem');
65
+ let fidoKey = 'dev-fido-key';
66
+ let signature = 'dev-signature';
67
+ let idToken = 'dev-session';
68
+ let derivedFidoID = 'dev-fido-key';
69
+ const useMockDev = process.env.INDRA_DEV_MODE === 'true';
70
+ // 2. If trust anchor private key exists and dev mode is disabled, execute asymmetric signature
71
+ if (fs.existsSync(privateKeyPath) && !useMockDev) {
72
+ logDebug(`[HANDSHAKE] Found local private key at: ${privateKeyPath}`);
73
+ try {
74
+ const privateKeyPem = fs.readFileSync(privateKeyPath, 'utf8');
75
+ const privateKey = crypto.createPrivateKey(privateKeyPem);
76
+ // Load corresponding public key in SPKI base64 format (FidoKey)
77
+ const publicKey = crypto.createPublicKey(privateKey);
78
+ const pubKeyDer = publicKey.export({ format: 'der', type: 'spki' });
79
+ fidoKey = pubKeyDer.toString('base64');
80
+ // Calculate SHA-256 derived FIDO ID
81
+ const hashHex = crypto.createHash('sha256').update(pubKeyDer).digest().slice(0, 8).toString('hex');
82
+ derivedFidoID = `indra_fido_${hashHex}`;
83
+ // Payload signed is: verifyMissionName|derivedFidoID|idToken|jwkStr
84
+ const payloadStr = `${agentName || activeTask}|${derivedFidoID}|${idToken}|${JSON.stringify(publicJwk)}`;
85
+ const signer = crypto.createSign('SHA256');
86
+ signer.update(payloadStr);
87
+ signature = signer.sign(privateKey, 'base64');
88
+ logDebug(`[HANDSHAKE-SIGN] Signature generated successfully for derived ID: ${derivedFidoID}`);
89
+ }
90
+ catch (err) {
91
+ logDebug(`[HANDSHAKE-ERROR] Failed to sign handshake: ${err.message}. Bypassing signature...`);
92
+ }
93
+ }
94
+ else {
95
+ if (useMockDev) {
96
+ logDebug(`[HANDSHAKE] INDRA_DEV_MODE is enabled. Starting in Development Mock Handshake mode.`);
97
+ }
98
+ else {
99
+ logDebug(`[HANDSHAKE] No private key found at ${privateKeyPath}. Starting in Development Mock Handshake mode.`);
100
+ }
101
+ }
102
+ // 3. Make POST request to start session
103
+ try {
104
+ const startUrl = `${edgeUrl}/api/mission/start`;
105
+ logDebug(`[HANDSHAKE-POST] Calling ${startUrl} ...`);
106
+ const resp = await fetch(startUrl, {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify({
110
+ fidoKey,
111
+ mission: activeTask,
112
+ idToken,
113
+ signature,
114
+ publicJwk,
115
+ agentName
116
+ })
117
+ });
118
+ if (!resp.ok) {
119
+ const errText = await resp.text();
120
+ throw new Error(`Edge Worker returned status ${resp.status}: ${errText}`);
121
+ }
122
+ const resJson = await resp.json();
123
+ missionToken = resJson.missionToken;
124
+ logDebug(`[HANDSHAKE-SUCCESS] Session registered successfully on Edge. Session Token: ${missionToken}`);
125
+ }
126
+ catch (err) {
127
+ logDebug(`[FATAL-HANDSHAKE-FAILED] Handshake process failed: ${err.message}`);
128
+ console.error(`Indra Handshake Failed: ${err.message}`);
129
+ process.exit(1);
130
+ }
131
+ }
132
+ // Sync the dynamic task description back to the Edge Worker
133
+ async function updateTaskOnEdge(task) {
134
+ logDebug(`[SYNC-TASK] Syncing active task description: "${task}"`);
135
+ try {
136
+ const resp = await fetch(`${edgeUrl}/api/mission/update-task`, {
137
+ method: 'POST',
138
+ headers: {
139
+ 'Content-Type': 'application/json',
140
+ 'X-Indra-Mission-Token': missionToken
141
+ },
142
+ body: JSON.stringify({ task })
143
+ });
144
+ if (!resp.ok) {
145
+ const errText = await resp.text();
146
+ logDebug(`[SYNC-TASK-ERROR] Edge Worker returned error code ${resp.status}: ${errText}`);
147
+ }
148
+ else {
149
+ activeTask = task;
150
+ logDebug(`[SYNC-TASK-SUCCESS] Task successfully updated on Edge to: "${task}"`);
151
+ }
152
+ }
153
+ catch (err) {
154
+ logDebug(`[SYNC-TASK-ERROR] Failed HTTP request to Edge Worker: ${err.message}`);
155
+ }
156
+ }
157
+ // Request validation from the Edge Worker for a tool call
158
+ async function validateToolCallOnEdge(tool, args) {
159
+ logDebug(`[VALIDATION] Requesting validation from Edge for tool: "${tool}"`);
160
+ try {
161
+ const resp = await fetch(`${edgeUrl}/api/mission/validate`, {
162
+ method: 'POST',
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ 'X-Indra-Mission-Token': missionToken
166
+ },
167
+ body: JSON.stringify({
168
+ tool,
169
+ arguments: args
170
+ })
171
+ });
172
+ if (!resp.ok) {
173
+ const errText = await resp.text();
174
+ logDebug(`[VALIDATION-ERROR] Edge validation request failed with status ${resp.status}: ${errText}`);
175
+ return { allow: true, reason: `Allowed (Edge Worker server error: ${resp.status})` };
176
+ }
177
+ const decision = await resp.json();
178
+ return {
179
+ allow: decision.allow,
180
+ reason: decision.reason || "Approved by Edge"
181
+ };
182
+ }
183
+ catch (err) {
184
+ logDebug(`[VALIDATION-ERROR] Failed to connect to Edge Worker: ${err.message}`);
185
+ return { allow: true, reason: `Allowed (Edge Worker connection failure)` };
186
+ }
187
+ }
188
+ // Revoke session token on shutdown
189
+ async function stopSession() {
190
+ if (!missionToken)
191
+ return;
192
+ logDebug(`[SHUTDOWN] Revoking session token: ${missionToken}`);
193
+ try {
194
+ await fetch(`${edgeUrl}/api/mission/stop`, {
195
+ method: 'POST',
196
+ headers: {
197
+ 'X-Indra-Mission-Token': missionToken
198
+ }
199
+ });
200
+ logDebug('[SHUTDOWN] Session revoked successfully.');
201
+ }
202
+ catch (err) {
203
+ logDebug(`[SHUTDOWN-ERROR] Failed to revoke session: ${err.message}`);
204
+ }
205
+ }
206
+ const childServers = {};
207
+ const toolToChildMap = new Map();
208
+ let pendingListId = null;
209
+ const pendingListResponses = new Map();
210
+ let pendingInitId = null;
211
+ const pendingInitResponses = new Map();
212
+ // Define our custom task management tools
213
+ const indraCustomTools = [
214
+ {
215
+ name: "set_active_task",
216
+ description: "Sets or updates the current active task description in the Indra security firewall. Call this tool immediately whenever the developer gives you a new goal, prompt, or redirects your target.",
217
+ inputSchema: {
218
+ type: "object",
219
+ properties: {
220
+ task: {
221
+ type: "string",
222
+ description: "The description of the new task or developer instruction."
223
+ }
224
+ },
225
+ required: ["task"]
226
+ }
227
+ }
228
+ ];
229
+ // Initialize and start session
230
+ await startSession();
231
+ // Spawn child servers from configuration
232
+ for (const [name, server] of Object.entries(serversConfig)) {
233
+ logDebug(`[SPAWN] Starting child tool server: ${name} (command: ${server.command}, args: ${JSON.stringify(server.args)})`);
234
+ try {
235
+ const childProcess = spawn(server.command, server.args || [], {
236
+ env: { ...process.env, ...(server.env || {}) },
237
+ shell: false
238
+ });
239
+ const childObj = {
240
+ name,
241
+ process: childProcess,
242
+ buffer: '',
243
+ tools: []
244
+ };
245
+ childServers[name] = childObj;
246
+ childProcess.stdout.on('data', (data) => {
247
+ childObj.buffer += data.toString();
248
+ let newlineIdx;
249
+ while ((newlineIdx = childObj.buffer.indexOf('\n')) !== -1) {
250
+ const line = childObj.buffer.slice(0, newlineIdx).trim();
251
+ childObj.buffer = childObj.buffer.slice(newlineIdx + 1);
252
+ if (line) {
253
+ handleChildMessage(name, line);
254
+ }
255
+ }
256
+ });
257
+ childProcess.stderr.on('data', (data) => {
258
+ logDebug(`[${name} - STDERR] ${data.toString().trim()}`);
259
+ process.stderr.write(data);
260
+ });
261
+ childProcess.on('close', (code) => {
262
+ logDebug(`[PROCESS] Child server ${name} exited with code ${code}`);
263
+ });
264
+ }
265
+ catch (err) {
266
+ logDebug(`[SPAWN-ERROR] Failed to spawn child server ${name}: ${err.message}`);
267
+ }
268
+ }
269
+ const rl = readline.createInterface({
270
+ input: process.stdin,
271
+ output: process.stdout,
272
+ terminal: false
273
+ });
274
+ rl.on('line', (line) => {
275
+ const trimmed = line.trim();
276
+ if (!trimmed)
277
+ return;
278
+ try {
279
+ const msg = JSON.parse(trimmed);
280
+ handleClientMessage(msg, trimmed);
281
+ }
282
+ catch (err) {
283
+ logDebug(`[ERROR] Failed to parse JSON-RPC line from client: ${trimmed}. Error: ${err.message}`);
284
+ }
285
+ });
286
+ async function handleClientMessage(msg, rawLine) {
287
+ const method = msg.method;
288
+ const id = msg.id;
289
+ logDebug(`[CLIENT-IN] Received JSON-RPC method="${method}" id="${id}"`);
290
+ if (method === 'initialize') {
291
+ logDebug('[CLIENT-INITIALIZE] Initiating handshake...');
292
+ pendingInitId = id;
293
+ for (const [name, child] of Object.entries(childServers)) {
294
+ const initPayload = { ...msg, id: `init:${name}` };
295
+ child.process.stdin.write(JSON.stringify(initPayload) + '\n');
296
+ }
297
+ return;
298
+ }
299
+ if (method === 'tools/list') {
300
+ logDebug('[CLIENT-DISCOVERY] Tools list requested.');
301
+ pendingListId = id;
302
+ pendingListResponses.clear();
303
+ for (const [name, child] of Object.entries(childServers)) {
304
+ const listPayload = { ...msg, id: `list:${name}` };
305
+ child.process.stdin.write(JSON.stringify(listPayload) + '\n');
306
+ }
307
+ return;
308
+ }
309
+ if (method === 'tools/call') {
310
+ const toolName = msg.params?.name;
311
+ const toolArgs = msg.params?.arguments || {};
312
+ logDebug(`[CLIENT-TOOL-CALL] Tool call: "${toolName}" with args: ${JSON.stringify(toolArgs)}`);
313
+ // Intercept and handle dynamic task updates
314
+ if (toolName === 'set_active_task') {
315
+ const newTask = toolArgs.task || '';
316
+ logDebug(`[CLIENT-SET-TASK] Dynamic task update intercepted: "${newTask}"`);
317
+ await updateTaskOnEdge(newTask);
318
+ const successResponse = {
319
+ jsonrpc: "2.0",
320
+ id,
321
+ result: {
322
+ content: [
323
+ {
324
+ type: "text",
325
+ text: `Indra security firewall task updated successfully to: "${newTask}"`
326
+ }
327
+ ]
328
+ }
329
+ };
330
+ process.stdout.write(JSON.stringify(successResponse) + '\n');
331
+ return;
332
+ }
333
+ // Direct Edge validate call
334
+ const decision = await validateToolCallOnEdge(toolName, toolArgs);
335
+ if (!decision.allow) {
336
+ logDebug(`[EVALUATION-DECISION] ❌ BLOCKED: ${decision.reason}`);
337
+ const errorResponse = {
338
+ jsonrpc: "2.0",
339
+ id,
340
+ result: {
341
+ content: [
342
+ {
343
+ type: "text",
344
+ text: `Blocked by Indra Firewall: ${decision.reason}`
345
+ }
346
+ ],
347
+ isError: true
348
+ }
349
+ };
350
+ process.stdout.write(JSON.stringify(errorResponse) + '\n');
351
+ return;
352
+ }
353
+ logDebug(`[EVALUATION-DECISION] ✅ ALLOWED: ${decision.reason}`);
354
+ const targetServerName = toolToChildMap.get(toolName);
355
+ if (!targetServerName || !childServers[targetServerName]) {
356
+ logDebug(`[ERROR] Tool "${toolName}" not registered on any child server.`);
357
+ const errorResponse = {
358
+ jsonrpc: "2.0",
359
+ id,
360
+ error: {
361
+ code: -32601,
362
+ message: `Method not found: Tool "${toolName}" is not registered on any active server.`
363
+ }
364
+ };
365
+ process.stdout.write(JSON.stringify(errorResponse) + '\n');
366
+ return;
367
+ }
368
+ childServers[targetServerName].process.stdin.write(rawLine + '\n');
369
+ return;
370
+ }
371
+ if (method && method.startsWith('notifications/')) {
372
+ for (const child of Object.values(childServers)) {
373
+ child.process.stdin.write(rawLine + '\n');
374
+ }
375
+ return;
376
+ }
377
+ for (const child of Object.values(childServers)) {
378
+ child.process.stdin.write(rawLine + '\n');
379
+ }
380
+ }
381
+ function handleChildMessage(serverName, line) {
382
+ try {
383
+ const msg = JSON.parse(line);
384
+ const id = msg.id;
385
+ if (id && String(id).startsWith('init:')) {
386
+ pendingInitResponses.set(serverName, msg);
387
+ if (pendingInitResponses.size === Object.keys(childServers).length) {
388
+ let mergedCapabilities = {};
389
+ for (const resp of pendingInitResponses.values()) {
390
+ mergedCapabilities = { ...mergedCapabilities, ...(resp.result?.capabilities || {}) };
391
+ }
392
+ const finalResponse = {
393
+ jsonrpc: "2.0",
394
+ id: pendingInitId,
395
+ result: {
396
+ protocolVersion: "2024-11-05",
397
+ capabilities: mergedCapabilities,
398
+ serverInfo: { name: "indra-mcp-broker", version: "0.1.1" }
399
+ }
400
+ };
401
+ process.stdout.write(JSON.stringify(finalResponse) + '\n');
402
+ pendingInitId = null;
403
+ }
404
+ return;
405
+ }
406
+ if (id && String(id).startsWith('list:')) {
407
+ const tools = msg.result?.tools || [];
408
+ childServers[serverName].tools = tools;
409
+ for (const t of tools) {
410
+ toolToChildMap.set(t.name, serverName);
411
+ }
412
+ pendingListResponses.set(serverName, tools);
413
+ if (pendingListResponses.size === Object.keys(childServers).length) {
414
+ const allTools = [...indraCustomTools]; // inject our set_active_task tool
415
+ for (const list of pendingListResponses.values()) {
416
+ allTools.push(...list);
417
+ }
418
+ const finalResponse = {
419
+ jsonrpc: "2.0",
420
+ id: pendingListId,
421
+ result: { tools: allTools }
422
+ };
423
+ process.stdout.write(JSON.stringify(finalResponse) + '\n');
424
+ pendingListId = null;
425
+ }
426
+ return;
427
+ }
428
+ process.stdout.write(line + '\n');
429
+ }
430
+ catch (err) {
431
+ logDebug(`[CHILD-ERROR] Failed to parse response from child ${serverName}: ${line}. Error: ${err.message}`);
432
+ }
433
+ }
434
+ // Graceful exit handlers
435
+ async function cleanupAndExit() {
436
+ await stopSession();
437
+ for (const child of Object.values(childServers)) {
438
+ child.process.kill('SIGINT');
439
+ }
440
+ process.exit(0);
441
+ }
442
+ process.on('SIGINT', cleanupAndExit);
443
+ process.on('SIGTERM', cleanupAndExit);
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "indra-mcp",
3
+ "version": "0.1.0",
4
+ "main": "build/index.js",
5
+ "bin": {
6
+ "indra-mcp": "build/index.js"
7
+ },
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "node build/index.js",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "files": [
15
+ "build"
16
+ ],
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.29.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^20.0.0",
22
+ "typescript": "^5.0.4"
23
+ }
24
+ }