@getmarrow/mcp 3.0.6 → 3.0.8

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.
package/dist/cli.js CHANGED
@@ -25,7 +25,9 @@ function parseArgs() {
25
25
  }
26
26
  const cliArgs = parseArgs();
27
27
  const API_KEY = cliArgs.apiKey || process.env.MARROW_API_KEY || '';
28
- const BASE_URL = process.env.MARROW_BASE_URL || 'https://api.getmarrow.ai';
28
+ // [SECURITY #3] Validate BASE_URL require HTTPS to prevent SSRF / credential leakage
29
+ const rawBaseUrl = process.env.MARROW_BASE_URL || 'https://api.getmarrow.ai';
30
+ const BASE_URL = (0, index_1.validateBaseUrl)(rawBaseUrl);
29
31
  const SESSION_ID = process.env.MARROW_SESSION_ID || undefined;
30
32
  const AUTO_ENROLL = process.env.MARROW_AUTO_ENROLL === 'true';
31
33
  const AGENT_ID = process.env.MARROW_AGENT_ID || `${require('os').hostname()}-${Date.now().toString(36)}`;
@@ -35,16 +37,19 @@ if (!API_KEY) {
35
37
  process.stderr.write(' or: npx @getmarrow/mcp --key mrw_yourkey\n');
36
38
  process.exit(1);
37
39
  }
40
+ // [SECURITY #12] Warn if API key is visible in process args
41
+ if (cliArgs.apiKey) {
42
+ process.stderr.write('[marrow] Warning: --key flag exposes API key in process list. Use MARROW_API_KEY env var for production.\n');
43
+ }
38
44
  // Auto-orient on startup — cache warnings, inject into EVERY marrow_think response
39
45
  let cachedOrientWarnings = [];
40
46
  let thinkCallCount = 0;
41
- let orientCallCount = 0; // Track if this is first-time orient (for autoEnroll)
42
- let initialized = false; // Track if initialize() has been called
47
+ let orientCallCount = 0;
48
+ let initialized = false;
43
49
  const pendingDecisions = new Map();
44
50
  const PENDING_TTL_MS = 30 * 60 * 1000; // 30 min TTL
45
51
  function actionHash(action) {
46
52
  const normalized = action.toLowerCase().trim().replace(/\s+/g, ' ');
47
- // djb2 hash to prevent decision_id mismatches from normalization-only collisions
48
53
  let h = 5381;
49
54
  for (let i = 0; i < normalized.length; i++) {
50
55
  h = ((h << 5) + h) ^ normalized.charCodeAt(i);
@@ -52,6 +57,7 @@ function actionHash(action) {
52
57
  }
53
58
  return h.toString(36) + '_' + normalized.slice(0, 32);
54
59
  }
60
+ // [FIX #11] Actually call cleanupPending to prevent unbounded map growth
55
61
  function cleanupPending() {
56
62
  const now = Date.now();
57
63
  for (const [key, val] of pendingDecisions) {
@@ -64,13 +70,15 @@ function formatWarningActionably(w) {
64
70
  const pct = Math.round(w.failureRate * 100);
65
71
  return `⚠️ ${w.type} has ${pct}% failure rate — check what went wrong last time before proceeding`;
66
72
  }
73
+ // [FIX #4] Log orient refresh failures instead of silently ignoring
67
74
  async function refreshOrientWarnings() {
68
75
  try {
69
76
  const r = await (0, index_1.marrowOrient)(API_KEY, BASE_URL, undefined, SESSION_ID);
70
77
  cachedOrientWarnings = r.warnings;
71
78
  }
72
- catch {
73
- // ignore
79
+ catch (err) {
80
+ const msg = err instanceof Error ? err.message : String(err);
81
+ process.stderr.write(`[marrow] Warning: failed to refresh orient warnings: ${msg}\n`);
74
82
  }
75
83
  }
76
84
  // Initial orient
@@ -82,29 +90,31 @@ refreshOrientWarnings().then(() => {
82
90
  // Auto-commit tracking for session close
83
91
  let lastDecisionId = null;
84
92
  let lastCommitted = false;
93
+ // [FIX #5] Log auto-commit failures instead of silently ignoring; remove broken AbortController
85
94
  async function autoCommitOnClose() {
86
95
  if (lastDecisionId && !lastCommitted) {
87
96
  try {
88
- const controller = new AbortController();
89
- const timeout = setTimeout(() => controller.abort(), 3000);
90
97
  await (0, index_1.marrowCommit)(API_KEY, BASE_URL, {
91
98
  decision_id: lastDecisionId,
92
99
  success: false,
93
100
  outcome: 'Session ended without explicit commit',
94
101
  }, SESSION_ID);
95
- clearTimeout(timeout);
96
102
  }
97
- catch {
98
- // ignore
103
+ catch (err) {
104
+ const msg = err instanceof Error ? err.message : String(err);
105
+ process.stderr.write(`[marrow] Warning: auto-commit on close failed: ${msg}\n`);
99
106
  }
100
107
  }
101
108
  }
102
- process.on('SIGTERM', async () => {
109
+ // [FIX #10] Handle both SIGTERM and SIGINT for clean shutdown
110
+ async function gracefulShutdown() {
103
111
  const forceExit = setTimeout(() => process.exit(0), 5000);
104
112
  forceExit.unref();
105
113
  await autoCommitOnClose();
106
114
  process.exit(0);
107
- });
115
+ }
116
+ process.on('SIGTERM', gracefulShutdown);
117
+ process.on('SIGINT', gracefulShutdown);
108
118
  function send(response) {
109
119
  process.stdout.write(JSON.stringify(response) + '\n');
110
120
  }
@@ -114,7 +124,31 @@ function success(id, result) {
114
124
  function error(id, code, message) {
115
125
  send({ jsonrpc: '2.0', id, error: { code, message } });
116
126
  }
117
- // Memory API functions
127
+ // [FIX #9] Runtime validation helper for required string params
128
+ function requireString(args, name) {
129
+ const val = args[name];
130
+ if (typeof val !== 'string' || !val.trim()) {
131
+ throw new Error(`"${name}" is required and must be a non-empty string`);
132
+ }
133
+ return val;
134
+ }
135
+ // [FIX #6 & #7] Safe JSON response helper for memory API functions
136
+ async function safeMemoryResponse(res) {
137
+ if (!res.ok) {
138
+ let detail = '';
139
+ try {
140
+ detail = await res.text();
141
+ }
142
+ catch { /* ignore */ }
143
+ throw new Error(`API error ${res.status}: ${detail.slice(0, 200)}`);
144
+ }
145
+ const json = await res.json();
146
+ if (json.error) {
147
+ throw new Error(json.error);
148
+ }
149
+ return json;
150
+ }
151
+ // Memory API functions — all patched with safeMemoryResponse and validatePathParam
118
152
  async function marrowListMemories(apiKey, baseUrl, params, sessionId) {
119
153
  const qs = new URLSearchParams();
120
154
  if (params?.status)
@@ -131,21 +165,23 @@ async function marrowListMemories(apiKey, baseUrl, params, sessionId) {
131
165
  ...(sessionId ? { 'X-Marrow-Session-Id': sessionId } : {}),
132
166
  },
133
167
  });
134
- const json = await res.json();
168
+ const json = await safeMemoryResponse(res);
135
169
  return json.data?.memories || [];
136
170
  }
137
171
  async function marrowGetMemory(apiKey, baseUrl, id, sessionId) {
138
- const res = await fetch(`${baseUrl}/v1/memories/${id}`, {
172
+ const safeId = (0, index_1.validatePathParam)(id, 'id');
173
+ const res = await fetch(`${baseUrl}/v1/memories/${safeId}`, {
139
174
  headers: {
140
175
  Authorization: `Bearer ${apiKey}`,
141
176
  ...(sessionId ? { 'X-Marrow-Session-Id': sessionId } : {}),
142
177
  },
143
178
  });
144
- const json = await res.json();
179
+ const json = await safeMemoryResponse(res);
145
180
  return json.data?.memory || null;
146
181
  }
147
182
  async function marrowUpdateMemory(apiKey, baseUrl, id, patch, sessionId) {
148
- const res = await fetch(`${baseUrl}/v1/memories/${id}`, {
183
+ const safeId = (0, index_1.validatePathParam)(id, 'id');
184
+ const res = await fetch(`${baseUrl}/v1/memories/${safeId}`, {
149
185
  method: 'PATCH',
150
186
  headers: {
151
187
  Authorization: `Bearer ${apiKey}`,
@@ -154,11 +190,12 @@ async function marrowUpdateMemory(apiKey, baseUrl, id, patch, sessionId) {
154
190
  },
155
191
  body: JSON.stringify(patch),
156
192
  });
157
- const json = await res.json();
158
- return json.data.memory;
193
+ const json = await safeMemoryResponse(res);
194
+ return json.data?.memory;
159
195
  }
160
196
  async function marrowDeleteMemory(apiKey, baseUrl, id, meta, sessionId) {
161
- const res = await fetch(`${baseUrl}/v1/memories/${id}`, {
197
+ const safeId = (0, index_1.validatePathParam)(id, 'id');
198
+ const res = await fetch(`${baseUrl}/v1/memories/${safeId}`, {
162
199
  method: 'DELETE',
163
200
  headers: {
164
201
  Authorization: `Bearer ${apiKey}`,
@@ -167,11 +204,12 @@ async function marrowDeleteMemory(apiKey, baseUrl, id, meta, sessionId) {
167
204
  },
168
205
  body: JSON.stringify(meta || {}),
169
206
  });
170
- const json = await res.json();
171
- return json.data.memory;
207
+ const json = await safeMemoryResponse(res);
208
+ return json.data?.memory;
172
209
  }
173
210
  async function marrowMarkOutdated(apiKey, baseUrl, id, meta, sessionId) {
174
- const res = await fetch(`${baseUrl}/v1/memories/${id}/outdated`, {
211
+ const safeId = (0, index_1.validatePathParam)(id, 'id');
212
+ const res = await fetch(`${baseUrl}/v1/memories/${safeId}/outdated`, {
175
213
  method: 'POST',
176
214
  headers: {
177
215
  Authorization: `Bearer ${apiKey}`,
@@ -180,11 +218,12 @@ async function marrowMarkOutdated(apiKey, baseUrl, id, meta, sessionId) {
180
218
  },
181
219
  body: JSON.stringify(meta || {}),
182
220
  });
183
- const json = await res.json();
184
- return json.data.memory;
221
+ const json = await safeMemoryResponse(res);
222
+ return json.data?.memory;
185
223
  }
186
224
  async function marrowSupersedeMemory(apiKey, baseUrl, id, replacement, sessionId) {
187
- const res = await fetch(`${baseUrl}/v1/memories/${id}/supersede`, {
225
+ const safeId = (0, index_1.validatePathParam)(id, 'id');
226
+ const res = await fetch(`${baseUrl}/v1/memories/${safeId}/supersede`, {
188
227
  method: 'POST',
189
228
  headers: {
190
229
  Authorization: `Bearer ${apiKey}`,
@@ -193,11 +232,12 @@ async function marrowSupersedeMemory(apiKey, baseUrl, id, replacement, sessionId
193
232
  },
194
233
  body: JSON.stringify(replacement),
195
234
  });
196
- const json = await res.json();
235
+ const json = await safeMemoryResponse(res);
197
236
  return json.data;
198
237
  }
199
238
  async function marrowShareMemory(apiKey, baseUrl, id, agentIds, actor, sessionId) {
200
- const res = await fetch(`${baseUrl}/v1/memories/${id}/share`, {
239
+ const safeId = (0, index_1.validatePathParam)(id, 'id');
240
+ const res = await fetch(`${baseUrl}/v1/memories/${safeId}/share`, {
201
241
  method: 'POST',
202
242
  headers: {
203
243
  Authorization: `Bearer ${apiKey}`,
@@ -206,8 +246,8 @@ async function marrowShareMemory(apiKey, baseUrl, id, agentIds, actor, sessionId
206
246
  },
207
247
  body: JSON.stringify({ agent_ids: agentIds, actor }),
208
248
  });
209
- const json = await res.json();
210
- return json.data.memory;
249
+ const json = await safeMemoryResponse(res);
250
+ return json.data?.memory;
211
251
  }
212
252
  async function marrowExportMemories(apiKey, baseUrl, params, sessionId) {
213
253
  const qs = new URLSearchParams();
@@ -223,7 +263,7 @@ async function marrowExportMemories(apiKey, baseUrl, params, sessionId) {
223
263
  ...(sessionId ? { 'X-Marrow-Session-Id': sessionId } : {}),
224
264
  },
225
265
  });
226
- const json = await res.json();
266
+ const json = await safeMemoryResponse(res);
227
267
  return json.data;
228
268
  }
229
269
  async function marrowImportMemories(apiKey, baseUrl, memories, mode, sessionId) {
@@ -236,7 +276,7 @@ async function marrowImportMemories(apiKey, baseUrl, memories, mode, sessionId)
236
276
  },
237
277
  body: JSON.stringify({ memories, mode }),
238
278
  });
239
- const json = await res.json();
279
+ const json = await safeMemoryResponse(res);
240
280
  return json.data;
241
281
  }
242
282
  async function marrowRetrieveMemories(apiKey, baseUrl, query, params, sessionId) {
@@ -262,10 +302,10 @@ async function marrowRetrieveMemories(apiKey, baseUrl, query, params, sessionId)
262
302
  ...(sessionId ? { 'X-Marrow-Session-Id': sessionId } : {}),
263
303
  },
264
304
  });
265
- const json = await res.json();
305
+ const json = await safeMemoryResponse(res);
266
306
  return json.data;
267
307
  }
268
- // Tool definitions
308
+ // Tool definitions (unchanged)
269
309
  const TOOLS = [
270
310
  {
271
311
  name: 'marrow_orient',
@@ -298,35 +338,17 @@ const TOOLS = [
298
338
  inputSchema: {
299
339
  type: 'object',
300
340
  properties: {
301
- action: {
302
- type: 'string',
303
- description: 'What the agent is about to do',
304
- },
341
+ action: { type: 'string', description: 'What the agent is about to do' },
305
342
  type: {
306
343
  type: 'string',
307
344
  enum: ['implementation', 'security', 'architecture', 'process', 'general'],
308
345
  description: 'Type of action (default: general)',
309
346
  },
310
- context: {
311
- type: 'object',
312
- description: 'Optional metadata about the current situation',
313
- },
314
- previous_decision_id: {
315
- type: 'string',
316
- description: 'decision_id from previous think() call — auto-commits that session',
317
- },
318
- previous_success: {
319
- type: 'boolean',
320
- description: 'Did the previous action succeed?',
321
- },
322
- previous_outcome: {
323
- type: 'string',
324
- description: 'What happened in the previous action (required if previous_decision_id provided)',
325
- },
326
- checkLoop: {
327
- type: 'boolean',
328
- description: 'Enable loop detection: warns if you are about to retry a failed approach. Recommended: true.',
329
- },
347
+ context: { type: 'object', description: 'Optional metadata about the current situation' },
348
+ previous_decision_id: { type: 'string', description: 'decision_id from previous think() call — auto-commits that session' },
349
+ previous_success: { type: 'boolean', description: 'Did the previous action succeed?' },
350
+ previous_outcome: { type: 'string', description: 'What happened in the previous action (required if previous_decision_id provided)' },
351
+ checkLoop: { type: 'boolean', description: 'Enable loop detection: warns if you are about to retry a failed approach. Recommended: true.' },
330
352
  },
331
353
  required: ['action'],
332
354
  },
@@ -339,22 +361,10 @@ const TOOLS = [
339
361
  inputSchema: {
340
362
  type: 'object',
341
363
  properties: {
342
- decision_id: {
343
- type: 'string',
344
- description: 'decision_id from the marrow_think call',
345
- },
346
- success: {
347
- type: 'boolean',
348
- description: 'Did the action succeed?',
349
- },
350
- outcome: {
351
- type: 'string',
352
- description: 'What happened — be specific, this trains the hive',
353
- },
354
- caused_by: {
355
- type: 'string',
356
- description: 'Optional: what caused this action',
357
- },
364
+ decision_id: { type: 'string', description: 'decision_id from the marrow_think call' },
365
+ success: { type: 'boolean', description: 'Did the action succeed?' },
366
+ outcome: { type: 'string', description: 'What happened — be specific, this trains the hive' },
367
+ caused_by: { type: 'string', description: 'Optional: what caused this action' },
358
368
  },
359
369
  required: ['decision_id', 'success', 'outcome'],
360
370
  },
@@ -366,18 +376,9 @@ const TOOLS = [
366
376
  inputSchema: {
367
377
  type: 'object',
368
378
  properties: {
369
- description: {
370
- type: 'string',
371
- description: 'What the agent did',
372
- },
373
- success: {
374
- type: 'boolean',
375
- description: 'Whether it succeeded',
376
- },
377
- outcome: {
378
- type: 'string',
379
- description: 'One-line summary of what happened',
380
- },
379
+ description: { type: 'string', description: 'What the agent did' },
380
+ success: { type: 'boolean', description: 'Whether it succeeded' },
381
+ outcome: { type: 'string', description: 'One-line summary of what happened' },
381
382
  type: {
382
383
  type: 'string',
383
384
  enum: ['implementation', 'security', 'architecture', 'process', 'general'],
@@ -396,18 +397,9 @@ const TOOLS = [
396
397
  inputSchema: {
397
398
  type: 'object',
398
399
  properties: {
399
- action: {
400
- type: 'string',
401
- description: 'What you are about to do or just did',
402
- },
403
- outcome: {
404
- type: 'string',
405
- description: 'What happened (if already done). Omit to log intent only.',
406
- },
407
- success: {
408
- type: 'boolean',
409
- description: 'Did it succeed (default: true)',
410
- },
400
+ action: { type: 'string', description: 'What you are about to do or just did' },
401
+ outcome: { type: 'string', description: 'What happened (if already done). Omit to log intent only.' },
402
+ success: { type: 'boolean', description: 'Did it succeed (default: true)' },
411
403
  type: {
412
404
  type: 'string',
413
405
  enum: ['implementation', 'security', 'architecture', 'process', 'general'],
@@ -425,10 +417,7 @@ const TOOLS = [
425
417
  inputSchema: {
426
418
  type: 'object',
427
419
  properties: {
428
- query: {
429
- type: 'string',
430
- description: 'Plain English question about your decision history',
431
- },
420
+ query: { type: 'string', description: 'Plain English question about your decision history' },
432
421
  },
433
422
  required: ['query'],
434
423
  },
@@ -436,11 +425,7 @@ const TOOLS = [
436
425
  {
437
426
  name: 'marrow_status',
438
427
  description: 'Check Marrow platform health and status.',
439
- inputSchema: {
440
- type: 'object',
441
- properties: {},
442
- required: [],
443
- },
428
+ inputSchema: { type: 'object', properties: {}, required: [] },
444
429
  },
445
430
  {
446
431
  name: 'marrow_list_memories',
@@ -459,13 +444,7 @@ const TOOLS = [
459
444
  {
460
445
  name: 'marrow_get_memory',
461
446
  description: 'Get a single memory by ID.',
462
- inputSchema: {
463
- type: 'object',
464
- properties: {
465
- id: { type: 'string', description: 'Memory ID' },
466
- },
467
- required: ['id'],
468
- },
447
+ inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Memory ID' } }, required: ['id'] },
469
448
  },
470
449
  {
471
450
  name: 'marrow_update_memory',
@@ -589,29 +568,12 @@ const TOOLS = [
589
568
  inputSchema: {
590
569
  type: 'object',
591
570
  properties: {
592
- action: {
593
- type: 'string',
594
- enum: ['register', 'list', 'get', 'update', 'start', 'advance', 'instances'],
595
- description: 'Workflow action to perform',
596
- },
571
+ action: { type: 'string', enum: ['register', 'list', 'get', 'update', 'start', 'advance', 'instances'], description: 'Workflow action to perform' },
597
572
  workflowId: { type: 'string', description: 'Workflow ID (required for get/start/advance/instances)' },
598
573
  instanceId: { type: 'string', description: 'Instance ID (required for advance)' },
599
574
  name: { type: 'string', description: 'Workflow name (for register)' },
600
575
  description: { type: 'string', description: 'Workflow description (for register/update)' },
601
- steps: {
602
- type: 'array',
603
- description: 'Step definitions (for register)',
604
- items: {
605
- type: 'object',
606
- properties: {
607
- step: { type: 'number', description: 'Step order (1, 2, 3...)' },
608
- agent_role: { type: 'string', description: 'Expected agent role (e.g., "builder", "auditor")' },
609
- action_type: { type: 'string', description: 'Action type (e.g., "build", "audit", "patch")' },
610
- description: { type: 'string', description: 'Step description' },
611
- },
612
- required: ['step', 'description'],
613
- },
614
- },
576
+ steps: { type: 'array', description: 'Step definitions (for register)', items: { type: 'object', properties: { step: { type: 'number', description: 'Step order (1, 2, 3...)' }, agent_role: { type: 'string', description: 'Expected agent role (e.g., "builder", "auditor")' }, action_type: { type: 'string', description: 'Action type (e.g., "build", "audit", "patch")' }, description: { type: 'string', description: 'Step description' } }, required: ['step', 'description'] } },
615
577
  tags: { type: 'array', items: { type: 'string' }, description: 'Tags (for register)' },
616
578
  agentId: { type: 'string', description: 'Agent ID starting the workflow (for start)' },
617
579
  context: { type: 'object', description: 'Workflow context (for start)' },
@@ -629,13 +591,18 @@ const TOOLS = [
629
591
  // Request handler
630
592
  async function handleRequest(req) {
631
593
  const { id, method, params } = req;
594
+ // [FIX #15] Enforce initialize-first per MCP spec
595
+ if (!initialized && method !== 'initialize') {
596
+ error(id, -32002, 'Server not initialized. Send initialize first.');
597
+ return;
598
+ }
632
599
  try {
633
600
  if (method === 'initialize') {
634
601
  initialized = true;
635
602
  success(id, {
636
603
  protocolVersion: '2024-11-05',
637
604
  capabilities: { tools: {}, prompts: {} },
638
- serverInfo: { name: 'marrow', version: '2.8.0' },
605
+ serverInfo: { name: 'marrow', version: '3.0.8' },
639
606
  });
640
607
  // Auto-enroll: emit enrollment notification on connection
641
608
  if (AUTO_ENROLL) {
@@ -656,7 +623,6 @@ async function handleRequest(req) {
656
623
  return;
657
624
  }
658
625
  if (method === 'prompts/list') {
659
- // Auto-enroll: only expose marrow-always-on prompt when MARROW_AUTO_ENROLL is set
660
626
  if (AUTO_ENROLL) {
661
627
  success(id, {
662
628
  prompts: [
@@ -675,7 +641,6 @@ async function handleRequest(req) {
675
641
  }
676
642
  if (method === 'prompts/get') {
677
643
  const promptName = params?.name;
678
- // Auto-enroll gate: only serve marrow-always-on when enabled
679
644
  if (promptName !== 'marrow-always-on' || !AUTO_ENROLL) {
680
645
  error(id, -32602, 'Unknown prompt');
681
646
  return;
@@ -744,7 +709,6 @@ This is not optional overhead — it's how you stop repeating the same failures.
744
709
  if (toolName === 'marrow_orient') {
745
710
  orientCallCount++;
746
711
  const result = await (0, index_1.marrowOrient)(API_KEY, BASE_URL, { taskType: args.taskType, autoWarn: args.autoWarn ?? true }, SESSION_ID);
747
- // Auto-enroll: on first orient call, prepend enrollment instructions
748
712
  if (AUTO_ENROLL && orientCallCount === 1) {
749
713
  const enrollmentText = `\n\n📋 **Marrow Auto-Enroll Active**\n\n` +
750
714
  `You have Marrow — a collective memory that tells you what went wrong last time before you do it again.\n\n` +
@@ -771,8 +735,10 @@ This is not optional overhead — it's how you stop repeating the same failures.
771
735
  return;
772
736
  }
773
737
  if (toolName === 'marrow_think') {
738
+ // [FIX #9] Validate required param
739
+ const action = requireString(args, 'action');
774
740
  const result = await (0, index_1.marrowThink)(API_KEY, BASE_URL, {
775
- action: args.action,
741
+ action,
776
742
  type: args.type,
777
743
  context: args.context,
778
744
  previous_decision_id: args.previous_decision_id,
@@ -799,7 +765,6 @@ This is not optional overhead — it's how you stop repeating the same failures.
799
765
  ...existingInsights,
800
766
  ];
801
767
  }
802
- // Track for auto-commit
803
768
  lastDecisionId = result.decision_id;
804
769
  lastCommitted = false;
805
770
  success(id, {
@@ -808,10 +773,16 @@ This is not optional overhead — it's how you stop repeating the same failures.
808
773
  return;
809
774
  }
810
775
  if (toolName === 'marrow_commit') {
776
+ // [FIX #9] Validate required params
777
+ const decision_id = requireString(args, 'decision_id');
778
+ const outcome = requireString(args, 'outcome');
779
+ if (typeof args.success !== 'boolean') {
780
+ throw new Error('"success" is required and must be a boolean');
781
+ }
811
782
  const result = await (0, index_1.marrowCommit)(API_KEY, BASE_URL, {
812
- decision_id: args.decision_id,
783
+ decision_id,
813
784
  success: args.success,
814
- outcome: args.outcome,
785
+ outcome,
815
786
  caused_by: args.caused_by,
816
787
  }, SESSION_ID);
817
788
  lastCommitted = true;
@@ -822,61 +793,83 @@ This is not optional overhead — it's how you stop repeating the same failures.
822
793
  return;
823
794
  }
824
795
  if (toolName === 'marrow_run') {
825
- // marrow_run = orient + think + commit in one call
826
- await (0, index_1.marrowOrient)(API_KEY, BASE_URL, undefined, SESSION_ID);
827
- const thinkResult = await (0, index_1.marrowThink)(API_KEY, BASE_URL, {
828
- action: args.description,
796
+ // [FIX #9] Validate required params
797
+ const description = requireString(args, 'description');
798
+ const outcome = requireString(args, 'outcome');
799
+ // [FIX #16] Handle partial failures — return think result even if commit fails
800
+ let thinkResult = null;
801
+ try {
802
+ await (0, index_1.marrowOrient)(API_KEY, BASE_URL, undefined, SESSION_ID);
803
+ }
804
+ catch (err) {
805
+ const msg = err instanceof Error ? err.message : String(err);
806
+ process.stderr.write(`[marrow] marrow_run orient failed (continuing): ${msg}\n`);
807
+ }
808
+ thinkResult = await (0, index_1.marrowThink)(API_KEY, BASE_URL, {
809
+ action: description,
829
810
  type: args.type || 'general',
830
811
  }, SESSION_ID);
831
- const commitResult = await (0, index_1.marrowCommit)(API_KEY, BASE_URL, {
832
- decision_id: thinkResult.decision_id,
833
- success: args.success ?? true,
834
- outcome: args.outcome,
835
- }, SESSION_ID);
812
+ let commitResult = null;
813
+ try {
814
+ commitResult = await (0, index_1.marrowCommit)(API_KEY, BASE_URL, {
815
+ decision_id: thinkResult.decision_id,
816
+ success: args.success ?? true,
817
+ outcome,
818
+ }, SESSION_ID);
819
+ }
820
+ catch (err) {
821
+ const msg = err instanceof Error ? err.message : String(err);
822
+ process.stderr.write(`[marrow] marrow_run commit failed: ${msg}\n`);
823
+ success(id, {
824
+ content: [{
825
+ type: 'text',
826
+ text: JSON.stringify({
827
+ think: thinkResult,
828
+ commit: null,
829
+ commit_error: msg,
830
+ decision_id: thinkResult.decision_id,
831
+ }, null, 2),
832
+ }],
833
+ });
834
+ return;
835
+ }
836
836
  success(id, {
837
- content: [
838
- {
837
+ content: [{
839
838
  type: 'text',
840
839
  text: JSON.stringify({ think: thinkResult, commit: commitResult }, null, 2),
841
- },
842
- ],
840
+ }],
843
841
  });
844
842
  return;
845
843
  }
846
844
  if (toolName === 'marrow_auto') {
847
- // marrow_auto = fire-and-forget background logging
848
- // Return immediately with cached orient warnings, API calls happen in background
849
- const action = args.action;
845
+ // [FIX #9] Validate required param
846
+ const action = requireString(args, 'action');
850
847
  const outcome = args.outcome;
851
848
  const outcomeSuccess = args.success ?? true;
852
849
  const type = args.type || 'general';
853
- // Return cached warnings immediately
850
+ // [FIX #11] Cleanup pending decisions on each auto call
851
+ cleanupPending();
852
+ // [FIX #8] Include pending flag so agent knows logging is deferred
854
853
  const response = {
855
854
  action,
856
855
  outcome: outcome || 'pending',
857
856
  warnings: cachedOrientWarnings.map(formatWarningActionably),
857
+ logging: 'deferred',
858
858
  };
859
859
  // Fire-and-forget the actual API calls
860
860
  (async () => {
861
861
  try {
862
862
  if (!outcome) {
863
- // Intent only
864
863
  await (0, index_1.marrowThink)(API_KEY, BASE_URL, { action, type }, SESSION_ID);
865
864
  }
866
865
  else {
867
- // Full loop
868
866
  const thinkResult = await (0, index_1.marrowThink)(API_KEY, BASE_URL, { action, type }, SESSION_ID);
869
- await (0, index_1.marrowCommit)(API_KEY, BASE_URL, {
870
- decision_id: thinkResult.decision_id,
871
- success: outcomeSuccess,
872
- outcome,
873
- }, SESSION_ID);
867
+ await (0, index_1.marrowCommit)(API_KEY, BASE_URL, { decision_id: thinkResult.decision_id, success: outcomeSuccess, outcome }, SESSION_ID);
874
868
  }
875
869
  }
876
870
  catch (err) {
877
- // Log to stderr so agent can see it in logs
878
871
  const msg = err instanceof Error ? err.message : String(err);
879
- process.stderr.write(`[marrow] marrow_auto failed: ${msg}\n`);
872
+ process.stderr.write(`[marrow] marrow_auto background logging failed: ${msg}\n`);
880
873
  }
881
874
  })();
882
875
  success(id, {
@@ -885,7 +878,8 @@ This is not optional overhead — it's how you stop repeating the same failures.
885
878
  return;
886
879
  }
887
880
  if (toolName === 'marrow_ask') {
888
- const result = await (0, index_1.marrowAsk)(API_KEY, BASE_URL, { query: args.query }, SESSION_ID);
881
+ const query = requireString(args, 'query');
882
+ const result = await (0, index_1.marrowAsk)(API_KEY, BASE_URL, { query }, SESSION_ID);
889
883
  success(id, {
890
884
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
891
885
  });
@@ -898,104 +892,63 @@ This is not optional overhead — it's how you stop repeating the same failures.
898
892
  });
899
893
  return;
900
894
  }
901
- // Memory control tools
895
+ // Memory control tools — all use requireString for id validation
902
896
  if (toolName === 'marrow_list_memories') {
903
- const result = await marrowListMemories(API_KEY, BASE_URL, {
904
- status: args.status,
905
- query: args.query,
906
- limit: args.limit,
907
- agentId: args.agentId,
908
- }, SESSION_ID);
909
- success(id, {
910
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
911
- });
897
+ const result = await marrowListMemories(API_KEY, BASE_URL, { status: args.status, query: args.query, limit: args.limit, agentId: args.agentId }, SESSION_ID);
898
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
912
899
  return;
913
900
  }
914
901
  if (toolName === 'marrow_get_memory') {
915
- const result = await marrowGetMemory(API_KEY, BASE_URL, args.id, SESSION_ID);
916
- success(id, {
917
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
918
- });
902
+ const memId = requireString(args, 'id');
903
+ const result = await marrowGetMemory(API_KEY, BASE_URL, memId, SESSION_ID);
904
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
919
905
  return;
920
906
  }
921
907
  if (toolName === 'marrow_update_memory') {
922
- const result = await marrowUpdateMemory(API_KEY, BASE_URL, args.id, {
923
- text: args.text,
924
- source: args.source,
925
- tags: args.tags,
926
- actor: args.actor,
927
- note: args.note,
928
- }, SESSION_ID);
929
- success(id, {
930
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
931
- });
908
+ const memId = requireString(args, 'id');
909
+ const result = await marrowUpdateMemory(API_KEY, BASE_URL, memId, { text: args.text, source: args.source, tags: args.tags, actor: args.actor, note: args.note }, SESSION_ID);
910
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
932
911
  return;
933
912
  }
934
913
  if (toolName === 'marrow_delete_memory') {
935
- const result = await marrowDeleteMemory(API_KEY, BASE_URL, args.id, { actor: args.actor, note: args.note }, SESSION_ID);
936
- success(id, {
937
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
938
- });
914
+ const memId = requireString(args, 'id');
915
+ const result = await marrowDeleteMemory(API_KEY, BASE_URL, memId, { actor: args.actor, note: args.note }, SESSION_ID);
916
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
939
917
  return;
940
918
  }
941
919
  if (toolName === 'marrow_mark_outdated') {
942
- const result = await marrowMarkOutdated(API_KEY, BASE_URL, args.id, { actor: args.actor, note: args.note }, SESSION_ID);
943
- success(id, {
944
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
945
- });
920
+ const memId = requireString(args, 'id');
921
+ const result = await marrowMarkOutdated(API_KEY, BASE_URL, memId, { actor: args.actor, note: args.note }, SESSION_ID);
922
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
946
923
  return;
947
924
  }
948
925
  if (toolName === 'marrow_supersede_memory') {
949
- const result = await marrowSupersedeMemory(API_KEY, BASE_URL, args.id, {
950
- text: args.text,
951
- source: args.source,
952
- tags: args.tags,
953
- actor: args.actor,
954
- note: args.note,
955
- }, SESSION_ID);
956
- success(id, {
957
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
958
- });
926
+ const memId = requireString(args, 'id');
927
+ const newText = requireString(args, 'text');
928
+ const result = await marrowSupersedeMemory(API_KEY, BASE_URL, memId, { text: newText, source: args.source, tags: args.tags, actor: args.actor, note: args.note }, SESSION_ID);
929
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
959
930
  return;
960
931
  }
961
932
  if (toolName === 'marrow_share_memory') {
962
- const result = await marrowShareMemory(API_KEY, BASE_URL, args.id, args.agentIds || [], args.actor, SESSION_ID);
963
- success(id, {
964
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
965
- });
933
+ const memId = requireString(args, 'id');
934
+ const result = await marrowShareMemory(API_KEY, BASE_URL, memId, args.agentIds || [], args.actor, SESSION_ID);
935
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
966
936
  return;
967
937
  }
968
938
  if (toolName === 'marrow_export_memories') {
969
- const result = await marrowExportMemories(API_KEY, BASE_URL, {
970
- format: args.format,
971
- status: args.status,
972
- tags: args.tags,
973
- }, SESSION_ID);
974
- success(id, {
975
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
976
- });
939
+ const result = await marrowExportMemories(API_KEY, BASE_URL, { format: args.format, status: args.status, tags: args.tags }, SESSION_ID);
940
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
977
941
  return;
978
942
  }
979
943
  if (toolName === 'marrow_import_memories') {
980
944
  const result = await marrowImportMemories(API_KEY, BASE_URL, args.memories || [], args.mode || 'merge', SESSION_ID);
981
- success(id, {
982
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
983
- });
945
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
984
946
  return;
985
947
  }
986
948
  if (toolName === 'marrow_retrieve_memories') {
987
- const result = await marrowRetrieveMemories(API_KEY, BASE_URL, args.query, {
988
- limit: args.limit,
989
- from: args.from,
990
- to: args.to,
991
- tags: args.tags,
992
- source: args.source,
993
- status: args.status,
994
- shared: args.shared,
995
- }, SESSION_ID);
996
- success(id, {
997
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
998
- });
949
+ const query = requireString(args, 'query');
950
+ const result = await marrowRetrieveMemories(API_KEY, BASE_URL, query, { limit: args.limit, from: args.from, to: args.to, tags: args.tags, source: args.source, status: args.status, shared: args.shared }, SESSION_ID);
951
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
999
952
  return;
1000
953
  }
1001
954
  if (toolName === 'marrow_workflow') {
@@ -1016,9 +969,7 @@ This is not optional overhead — it's how you stop repeating the same failures.
1016
969
  contextUpdate: args.contextUpdate,
1017
970
  status: args.status,
1018
971
  }, SESSION_ID);
1019
- success(id, {
1020
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
1021
- });
972
+ success(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
1022
973
  return;
1023
974
  }
1024
975
  error(id, -32601, `Method not found: ${toolName}`);
@@ -1033,6 +984,13 @@ This is not optional overhead — it's how you stop repeating the same failures.
1033
984
  }
1034
985
  // MCP stdio loop — raw stdin, no readline (readline writes prompts to stdout which breaks MCP)
1035
986
  let buffer = '';
987
+ let pendingRequests = 0;
988
+ let stdinEnded = false;
989
+ function checkExit() {
990
+ if (stdinEnded && pendingRequests === 0) {
991
+ autoCommitOnClose().then(() => process.exit(0));
992
+ }
993
+ }
1036
994
  process.stdin.setEncoding('utf8');
1037
995
  process.stdin.on('data', (chunk) => {
1038
996
  buffer += chunk;
@@ -1042,23 +1000,59 @@ process.stdin.on('data', (chunk) => {
1042
1000
  const trimmed = line.trim();
1043
1001
  if (!trimmed)
1044
1002
  continue;
1045
- handleRequest(JSON.parse(trimmed)).catch((err) => {
1003
+ // [FIX #1] Wrap JSON.parse in try-catch to prevent crash on malformed input
1004
+ let msg;
1005
+ try {
1006
+ msg = JSON.parse(trimmed);
1007
+ }
1008
+ catch (parseErr) {
1009
+ process.stderr.write(`[marrow] JSON parse error: ${parseErr}\n`);
1010
+ send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
1011
+ continue;
1012
+ }
1013
+ // MCP notifications (no id) must be silently ignored per spec
1014
+ if (msg.id === undefined || msg.id === null)
1015
+ continue;
1016
+ pendingRequests++;
1017
+ handleRequest(msg)
1018
+ .catch((err) => {
1046
1019
  process.stderr.write(`[marrow] Handler error: ${err}\n`);
1020
+ })
1021
+ .finally(() => {
1022
+ pendingRequests--;
1023
+ checkExit();
1047
1024
  });
1048
1025
  }
1049
1026
  });
1050
- process.stdin.on('end', async () => {
1051
- // Process any remaining buffered line
1027
+ process.stdin.on('end', () => {
1028
+ stdinEnded = true;
1052
1029
  if (buffer.trim()) {
1030
+ let msg;
1053
1031
  try {
1054
- await handleRequest(JSON.parse(buffer.trim()));
1032
+ msg = JSON.parse(buffer.trim());
1055
1033
  }
1056
1034
  catch (err) {
1057
- process.stderr.write(`[marrow] Parse error on remaining: ${err}\n`);
1035
+ process.stderr.write(`[marrow] JSON parse error on remaining buffer: ${err}\n`);
1036
+ checkExit();
1037
+ return;
1038
+ }
1039
+ if (msg.id === undefined || msg.id === null) {
1040
+ checkExit();
1041
+ return;
1058
1042
  }
1043
+ pendingRequests++;
1044
+ handleRequest(msg)
1045
+ .catch((err) => {
1046
+ process.stderr.write(`[marrow] Handler error on remaining: ${err}\n`);
1047
+ })
1048
+ .finally(() => {
1049
+ pendingRequests--;
1050
+ checkExit();
1051
+ });
1052
+ }
1053
+ else {
1054
+ checkExit();
1059
1055
  }
1060
- await autoCommitOnClose();
1061
- process.exit(0);
1062
1056
  });
1063
1057
  process.stdin.on('error', (err) => {
1064
1058
  process.stderr.write(`[marrow] stdin error: ${err}\n`);