@getmarrow/mcp 3.9.80 → 3.9.82

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.
@@ -3,18 +3,26 @@ const { spawn } = require('node:child_process');
3
3
  const { randomUUID } = require('node:crypto');
4
4
  const { performance } = require('node:perf_hooks');
5
5
  const { resolve } = require('node:path');
6
- const { version: packageVersion } = require('../package.json');
7
- const { MARROW_AUTO_RESPONSE_BUDGET_MAX_MS } = require('../dist/index.js');
6
+ let packageVersion;
7
+ let MARROW_AUTO_RESPONSE_BUDGET_MAX_MS;
8
+ let configurationFailed = false;
9
+ try {
10
+ ({ version: packageVersion } = require('../package.json'));
11
+ ({ MARROW_AUTO_RESPONSE_BUDGET_MAX_MS } = require('../dist/index.js'));
12
+ } catch {
13
+ // A missing/broken build must still reach the CLI's bounded failure handler.
14
+ configurationFailed = true;
15
+ }
8
16
 
9
17
  const CANARY_TOOL_TIMEOUT_MARGIN_MS = 2_000;
10
18
  const CANARY_TOOL_TIMEOUT_MAX_MS = 10_000;
11
19
  const CANARY_DEFAULT_TOOL_TIMEOUT_MS = Math.min(
12
20
  CANARY_TOOL_TIMEOUT_MAX_MS,
13
- MARROW_AUTO_RESPONSE_BUDGET_MAX_MS + CANARY_TOOL_TIMEOUT_MARGIN_MS,
21
+ (MARROW_AUTO_RESPONSE_BUDGET_MAX_MS || 8_000) + CANARY_TOOL_TIMEOUT_MARGIN_MS,
14
22
  );
15
23
 
16
24
  if (CANARY_DEFAULT_TOOL_TIMEOUT_MS < MARROW_AUTO_RESPONSE_BUDGET_MAX_MS + CANARY_TOOL_TIMEOUT_MARGIN_MS) {
17
- throw new Error('MCP canary timeout ceiling cannot contain the bounded marrow_auto budget');
25
+ configurationFailed = true;
18
26
  }
19
27
 
20
28
  function canaryCases(operationId) {
@@ -49,6 +57,108 @@ const HOT_PATH_TOOLS = new Set([
49
57
  'marrow_auto',
50
58
  ]);
51
59
  const OUTPUT_LIMIT_BYTES = 256 * 1024;
60
+ const TOOL_NAMES = new Set(canaryCases('').map(([name]) => name));
61
+ const errorEvidence = new WeakMap();
62
+ const failureRecords = new WeakMap();
63
+
64
+ function canaryError(errorClass, message = errorClass, httpStatus) {
65
+ const error = new Error(message);
66
+ errorEvidence.set(error, { error_class: errorClass, http_status: httpStatus });
67
+ return error;
68
+ }
69
+
70
+ function upstreamError(payload, fallback, message) {
71
+ const statuses = [payload?.http_status, payload?.status, payload?.status_code,
72
+ payload?.error?.http_status, payload?.error?.status, payload?.error?.status_code];
73
+ const status = statuses.find((value) => Number.isInteger(value) && value >= 100 && value <= 599);
74
+ const code = payload?.error?.code ?? payload?.error_code ?? payload?.code;
75
+ let errorClass = fallback;
76
+ if (status === 401 || ['UNAUTHORIZED', 'AUTH_REQUIRED', 'AUTHENTICATION_REQUIRED', 'INVALID_API_KEY', 'API_KEY_REQUIRED', 'unauthorized', 'invalid_api_key', 'authentication_required'].includes(code)) {
77
+ errorClass = 'authentication';
78
+ } else if (status === 403 || ['FORBIDDEN', 'PERMISSION_DENIED', 'INSUFFICIENT_PERMISSIONS', 'PLAN_REQUIRED', 'UPGRADE_REQUIRED', 'forbidden', 'permission_denied', 'plan_required'].includes(code)) {
79
+ errorClass = 'authorization';
80
+ } else if (status === 503 || code === 'HTTP_503' || code === 'http_503') {
81
+ errorClass = 'unavailable503';
82
+ }
83
+ return canaryError(errorClass, message, status);
84
+ }
85
+
86
+ function safeVersion(value) {
87
+ return typeof value === 'string' && value.length <= 64
88
+ && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) ? value : null;
89
+ }
90
+
91
+ function safeMs(value) {
92
+ return Number.isFinite(value) ? Math.max(0, Math.min(60_000, Math.round(value))) : 0;
93
+ }
94
+
95
+ function autoTimings(payload) {
96
+ const result = {};
97
+ for (const [group, fields] of [
98
+ ['phase_timings_ms', ['runtime', 'think', 'commit', 'total']],
99
+ ['response_timings_ms', ['core', 'durable_enqueue', 'full_response']],
100
+ ]) {
101
+ const source = payload?.[group];
102
+ if (!source || typeof source !== 'object' || Array.isArray(source)) continue;
103
+ const values = {};
104
+ for (const field of fields) {
105
+ const value = source[field];
106
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 60000) {
107
+ values[field] = Math.round(value);
108
+ }
109
+ }
110
+ if (Object.keys(values).length) result[group] = values;
111
+ }
112
+ return result;
113
+ }
114
+
115
+ function autoWaitClass(payload) {
116
+ if (payload?.live_delivery?.committed !== false
117
+ || typeof payload.live_delivery.accepted !== 'boolean'
118
+ || typeof payload.resumable !== 'boolean') return null;
119
+ if (['runtime_pending', 'think_pending', 'commit_pending'].includes(payload.phase)
120
+ && payload.completion_state === 'delivery_pending' && payload.resumable === true) return 'completion_pending';
121
+ if ((payload.phase === 'owner_approval_required' && payload.completion_state === 'pending_owner_approval'
122
+ || payload.phase === 'review_required' && payload.completion_state === 'review_required_terminal')
123
+ && payload.resumable === false) return 'approval_required';
124
+ if (payload.phase === 'proof_required' && payload.completion_state === 'pending_required_proof') return 'proof_required';
125
+ return null;
126
+ }
127
+
128
+ function failureRecord(error, context = {}) {
129
+ const evidence = errorEvidence.get(error) || { error_class: 'internal' };
130
+ const results = (context.results || []).slice(0, 11).map((row) => Object.freeze({
131
+ tool: row.tool, ok: true, live: true, latency_ms: safeMs(row.latency_ms),
132
+ ...(row.tool === 'marrow_auto' ? {
133
+ ...autoTimings(row), attempts: row.attempts, retry_wait_ms: safeMs(row.retry_wait_ms),
134
+ } : {}),
135
+ }));
136
+ return Object.freeze({
137
+ schema_version: 1,
138
+ ok: false,
139
+ stage: context.stage || 'setup',
140
+ tool: TOOL_NAMES.has(context.tool) ? context.tool : null,
141
+ error_class: evidence.error_class,
142
+ package_version: safeVersion(context.version),
143
+ expected_version: safeVersion(context.expectedVersion),
144
+ attempt: Math.max(0, Math.min(3, context.attempt || 0)),
145
+ latency_ms: safeMs(performance.now() - (context.stageStarted ?? performance.now())),
146
+ total_latency_ms: safeMs(performance.now() - (context.started ?? performance.now())),
147
+ process_count: context.processCount === 1 ? 1 : 0,
148
+ initialization_ms: context.initializationMs == null ? null : safeMs(context.initializationMs),
149
+ tools_checked: results.length,
150
+ results: Object.freeze(results),
151
+ ...(context.tool === 'marrow_auto' ? {
152
+ ...autoTimings(context.autoMetrics), retry_wait_ms: safeMs(context.retryWaitMs),
153
+ ...(context.autoPhase ? { auto_phase: context.autoPhase } : {}),
154
+ } : {}),
155
+ ...(evidence.http_status === undefined ? {} : { http_status: evidence.http_status }),
156
+ });
157
+ }
158
+
159
+ function serializeFailure(error) {
160
+ return JSON.stringify(failureRecords.get(error) || failureRecord(error));
161
+ }
52
162
 
53
163
  function boundedMs(name, fallback, minimum = 100, maximum = 10_000, env = process.env) {
54
164
  const parsed = Number(env[name] || fallback);
@@ -73,26 +183,25 @@ function latencyGroup(rows) {
73
183
  }
74
184
 
75
185
  function toolPayload(message, name) {
76
- if (!message) throw new Error(`${name} returned no MCP tool response`);
77
- if (message.error) throw new Error(`${name} returned JSON-RPC error ${message.error.code}`);
186
+ if (!message) throw canaryError('contract', 'Missing MCP tool response');
187
+ if (message.error) throw upstreamError(message, 'protocol', 'MCP JSON-RPC error');
78
188
  const result = message.result || {};
79
189
  const text = result.content?.[0]?.text;
80
- if (typeof text !== 'string' || !text.trim()) throw new Error(`${name} returned no MCP tool payload`);
190
+ if (typeof text !== 'string' || !text.trim()) throw canaryError('contract', 'Missing MCP tool payload');
81
191
  let payload;
82
- try { payload = JSON.parse(text); } catch { throw new Error(`${name} returned invalid tool JSON`); }
192
+ try { payload = JSON.parse(text); } catch { throw canaryError('contract', 'Invalid tool JSON'); }
83
193
  if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).length === 0) {
84
- throw new Error(`${name} returned an empty or invalid tool payload`);
194
+ throw canaryError('contract', 'Empty or invalid tool payload');
85
195
  }
86
196
  if (result.isError || payload.ok === false || payload.available === false) {
87
- const code = payload.error?.code || payload.error_code || 'tool_unavailable';
88
- throw new Error(`${name} unavailable (${code})`);
197
+ throw upstreamError(payload, 'tool_unavailable', 'MCP tool unavailable');
89
198
  }
90
199
  return payload;
91
200
  }
92
201
 
93
202
  function validatePayload(name, payload) {
94
203
  const requireField = (valid, field) => {
95
- if (!valid) throw new Error(`${name} returned an invalid ${field} contract`);
204
+ if (!valid) throw canaryError('contract', `${name} returned an invalid ${field} contract`);
96
205
  };
97
206
  if (name === 'marrow_status' || name === 'marrow_runtime_status') {
98
207
  requireField(
@@ -117,6 +226,26 @@ function validatePayload(name, payload) {
117
226
  'runtime gate',
118
227
  );
119
228
  } else if (name === 'marrow_auto') {
229
+ const failure = payload.live_delivery?.failure;
230
+ if (failure) {
231
+ requireField(payload.live_delivery.committed === false && payload.phase == null
232
+ && failure.ok === false, 'failed auto delivery');
233
+ // Transport/auth status takes precedence over a caller-facing proof label.
234
+ const category = failure.error?.category;
235
+ const categories = {
236
+ authentication_required: 'authentication', permission_denied: 'authorization',
237
+ proof_required: 'proof_required', request_timeout: 'request_timeout',
238
+ dns_unavailable: 'tool_unavailable', connection_reset: 'tool_unavailable',
239
+ tls_failure: 'tool_unavailable', edge_access_denied: 'tool_unavailable',
240
+ service_unavailable: 'tool_unavailable', rate_limited: 'tool_unavailable',
241
+ request_failed: 'tool_unavailable', invalid_response: 'contract',
242
+ };
243
+ const classification = typeof category === 'string' && Object.hasOwn(categories, category)
244
+ ? categories[category] : 'contract';
245
+ throw upstreamError(failure, classification, 'Auto delivery failed');
246
+ }
247
+ const waitClass = autoWaitClass(payload);
248
+ if (waitClass) throw canaryError(waitClass, 'Auto completion is awaiting its declared next step');
120
249
  requireField(
121
250
  payload.live_delivery
122
251
  && payload.live_delivery.accepted === true
@@ -160,27 +289,34 @@ class PersistentMcpClient {
160
289
 
161
290
  start() {
162
291
  if (this.child) return;
163
- this.child = this.spawnProcess(this.command, this.args, {
164
- env: this.env,
165
- stdio: ['pipe', 'pipe', 'pipe'],
166
- });
292
+ try {
293
+ this.child = this.spawnProcess(this.command, this.args, {
294
+ env: this.env,
295
+ stdio: ['pipe', 'pipe', 'pipe'],
296
+ });
297
+ } catch { throw canaryError('process_spawn', 'MCP child spawn failed'); }
167
298
  this.child.stdout.setEncoding('utf8');
168
299
  this.child.stdout.on('data', (chunk) => this.onStdout(chunk));
169
300
  this.child.stderr.on('data', (chunk) => {
170
301
  this.stderrBytes += Buffer.byteLength(chunk);
171
- if (this.stderrBytes > this.maxOutputBytes) this.abort(new Error('MCP child exceeded stderr limit'));
302
+ if (this.stderrBytes > this.maxOutputBytes) this.abort(canaryError('output_limit', 'MCP child exceeded stderr limit'));
172
303
  });
173
- this.child.once('error', (error) => this.abort(new Error(`MCP child failed: ${error.code || 'process_error'}`)));
304
+ this.child.stdin.on?.('error', () => this.abort(canaryError('process_write', 'MCP child write failed')));
305
+ this.child.once('error', () => this.abort(canaryError('process_spawn', 'MCP child spawn failed')));
174
306
  this.child.once('exit', (code, signal) => {
175
307
  this.closed = true;
176
- if (this.pending.size > 0) this.rejectAll(new Error(`MCP child exited ${code ?? signal ?? 'unknown'}`));
308
+ const expectedExit = (code === 0 && !signal)
309
+ || (this.cleanupKillRequested && code === null && signal === 'SIGKILL');
310
+ if (this.stopping && expectedExit && this.pending.size === 0 && !this.fatalError) return;
311
+ this.fatalError ||= canaryError('process_exit', 'MCP child exited');
312
+ this.rejectAll(this.fatalError);
177
313
  });
178
314
  }
179
315
 
180
316
  onStdout(chunk) {
181
317
  this.stdoutBuffer += chunk;
182
318
  if (Buffer.byteLength(this.stdoutBuffer) > this.maxOutputBytes) {
183
- this.abort(new Error('MCP child exceeded stdout limit'));
319
+ this.abort(canaryError('output_limit', 'MCP child exceeded stdout limit'));
184
320
  return;
185
321
  }
186
322
  for (;;) {
@@ -191,13 +327,19 @@ class PersistentMcpClient {
191
327
  if (!line) continue;
192
328
  let message;
193
329
  try { message = JSON.parse(line); } catch {
194
- this.abort(new Error('MCP child emitted malformed JSON'));
330
+ this.abort(canaryError('protocol', 'MCP child emitted malformed JSON'));
331
+ return;
332
+ }
333
+ if (!message || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== '2.0'
334
+ || (message.id !== undefined && (!Number.isSafeInteger(message.id)
335
+ || (Object.hasOwn(message, 'result') === Object.hasOwn(message, 'error'))))) {
336
+ this.abort(canaryError('protocol', 'MCP child emitted malformed JSON-RPC'));
195
337
  return;
196
338
  }
197
339
  if (!Number.isSafeInteger(message.id)) continue;
198
340
  const pending = this.pending.get(message.id);
199
341
  if (!pending) {
200
- this.abort(new Error(`MCP child returned unexpected response id ${message.id}`));
342
+ this.abort(canaryError('protocol', 'MCP child returned unexpected response id'));
201
343
  return;
202
344
  }
203
345
  clearTimeout(pending.timer);
@@ -207,25 +349,30 @@ class PersistentMcpClient {
207
349
  }
208
350
 
209
351
  request(method, params = {}, timeoutMs = this.timeoutMs) {
352
+ if (this.fatalError) return Promise.reject(this.fatalError);
210
353
  if (!this.child || this.closed || !this.child.stdin.writable) {
211
- return Promise.reject(new Error('MCP child is not writable'));
354
+ return Promise.reject(canaryError('process_write', 'MCP child is not writable'));
212
355
  }
213
356
  const id = this.nextId++;
214
357
  return new Promise((resolveRequest, rejectRequest) => {
215
358
  const timer = setTimeout(() => {
216
359
  this.pending.delete(id);
217
- const error = new Error(`${method} timed out`);
360
+ const error = canaryError('request_timeout', `${method} timed out`);
218
361
  rejectRequest(error);
219
362
  this.abort(error);
220
363
  }, timeoutMs);
221
364
  this.pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer });
222
- this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, (error) => {
365
+ const onWrite = (error) => {
223
366
  if (!error) return;
224
367
  clearTimeout(timer);
225
368
  this.pending.delete(id);
226
- rejectRequest(new Error(`${method} write failed`));
227
- this.abort(error);
228
- });
369
+ const failure = canaryError('process_write', `${method} write failed`);
370
+ rejectRequest(failure);
371
+ this.abort(failure);
372
+ };
373
+ try {
374
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, onWrite);
375
+ } catch (error) { onWrite(error); }
229
376
  });
230
377
  }
231
378
 
@@ -238,16 +385,28 @@ class PersistentMcpClient {
238
385
  }
239
386
 
240
387
  abort(error) {
241
- this.rejectAll(error instanceof Error ? error : new Error('MCP child aborted'));
242
- if (this.child && !this.closed) this.child.kill('SIGKILL');
388
+ this.fatalError ||= error instanceof Error ? error : canaryError('internal');
389
+ this.rejectAll(this.fatalError);
390
+ if (this.child && !this.closed) {
391
+ try { this.child.kill('SIGKILL'); } catch { /* Preserve the original failure. */ }
392
+ }
243
393
  }
244
394
 
245
395
  async stop() {
246
- if (!this.child || this.closed) return;
396
+ if (!this.child || this.closed) {
397
+ if (this.fatalError) throw this.fatalError;
398
+ return;
399
+ }
400
+ this.stopping = true;
247
401
  this.child.stdin.end();
248
402
  await new Promise((resolveExit) => {
249
403
  const timer = setTimeout(() => {
250
- if (!this.closed) this.child.kill('SIGKILL');
404
+ if (!this.closed) {
405
+ // The bounded cleanup deadline intentionally ends an otherwise healthy
406
+ // child. This signal is ours; actual protocol/write errors remain fatal.
407
+ this.cleanupKillRequested = true;
408
+ try { this.child.kill('SIGKILL'); } catch { this.cleanupKillRequested = false; }
409
+ }
251
410
  resolveExit();
252
411
  }, 500);
253
412
  this.child.once('exit', () => {
@@ -255,12 +414,28 @@ class PersistentMcpClient {
255
414
  resolveExit();
256
415
  });
257
416
  });
417
+ if (this.fatalError) throw this.fatalError;
258
418
  }
259
419
  }
260
420
 
261
421
  async function runCanary(env = process.env, options = {}) {
422
+ const started = performance.now();
423
+ const context = { started, stageStarted: started, stage: 'setup', results: [],
424
+ expectedVersion: env.MARROW_EXPECTED_MCP_VERSION || packageVersion };
425
+ try {
426
+ return await executeCanary(env, options, context);
427
+ } catch (caught) {
428
+ const error = errorEvidence.has(caught) ? caught : canaryError('internal');
429
+ error.failure = failureRecord(error, context);
430
+ failureRecords.set(error, error.failure);
431
+ throw error;
432
+ }
433
+ }
434
+
435
+ async function executeCanary(env, options, context) {
436
+ if (configurationFailed) throw canaryError('configuration', 'MCP canary build configuration failed');
262
437
  const key = env.MARROW_API_KEY || '';
263
- if (!key) throw new Error('MARROW_API_KEY is required for the authenticated MCP control-path canary');
438
+ if (!key) throw canaryError('authentication', 'MARROW_API_KEY is required for the authenticated MCP control-path canary');
264
439
  const toolTimeoutMs = boundedMs(
265
440
  'MARROW_MCP_CANARY_TOOL_TIMEOUT_MS',
266
441
  CANARY_DEFAULT_TOOL_TIMEOUT_MS,
@@ -291,46 +466,80 @@ async function runCanary(env = process.env, options = {}) {
291
466
  env: childEnv,
292
467
  spawnProcess: options.spawnProcess,
293
468
  });
294
- const totalTimer = setTimeout(() => client.abort(new Error('MCP canary total timeout')), totalTimeoutMs);
469
+ const totalTimer = setTimeout(() => client.abort(canaryError('total_timeout', 'MCP canary total timeout')), totalTimeoutMs);
295
470
  const processStarted = performance.now();
296
471
  const cases = canaryCases(`canary_${randomUUID()}`);
297
- client.start();
472
+ const stage = (value, tool = null, attempt = 0) => {
473
+ Object.assign(context, { stage: value, tool, attempt, stageStarted: performance.now() });
474
+ };
475
+ let failed = false;
298
476
  try {
477
+ stage('spawn');
478
+ client.start();
479
+ context.processCount = 1;
480
+ stage('initialize', null, 1);
299
481
  const initialized = await client.request('initialize', {}, toolTimeoutMs + 3_000);
300
- if (initialized.error) throw new Error(`MCP initialize failed ${initialized.error.code}`);
482
+ if (client.fatalError) throw client.fatalError;
483
+ if (initialized.error) throw upstreamError(initialized, 'protocol', 'MCP initialize failed');
301
484
  const version = initialized.result?.serverInfo?.version;
302
- if (!version) throw new Error('MCP initialize produced no version');
303
- if (version !== expectedVersion) throw new Error(`MCP canary loaded ${version} instead of expected ${expectedVersion}`);
485
+ context.version = version;
486
+ if (typeof version !== 'string' || !version) throw canaryError('contract', 'MCP initialize produced no version');
487
+ if (version !== expectedVersion) throw canaryError('package_mismatch', 'MCP canary package version mismatch');
488
+ stage('tools_list', null, 1);
304
489
  const listed = await client.request('tools/list', {});
305
- if (listed.error) throw new Error(`MCP tools/list failed ${listed.error.code}`);
306
- const names = new Set((listed.result?.tools || []).map((tool) => tool.name));
490
+ if (client.fatalError) throw client.fatalError;
491
+ if (listed.error) throw upstreamError(listed, 'protocol', 'MCP tools/list failed');
492
+ if (!Array.isArray(listed.result?.tools)) throw canaryError('contract', 'MCP tools/list missing tools');
493
+ const names = new Set(listed.result.tools.map((tool) => tool?.name));
307
494
  for (const [name] of cases) {
308
- if (!names.has(name)) throw new Error(`${name} is missing from the full MCP contract`);
495
+ if (!names.has(name)) throw canaryError('contract', `${name} is missing from the full MCP contract`);
309
496
  }
310
497
  const initializationMs = Math.round(performance.now() - processStarted);
311
- const results = [];
498
+ context.initializationMs = initializationMs;
499
+ const results = context.results;
312
500
  for (const [name, args] of cases) {
313
501
  const callStarted = performance.now();
314
502
  let called;
315
503
  let payload;
504
+ let attempts = 0;
505
+ context.retryWaitMs = 0;
506
+ context.autoMetrics = undefined;
507
+ context.autoPhase = undefined;
316
508
  for (let attempt = 0; attempt < 3; attempt += 1) {
509
+ if (client.fatalError) throw client.fatalError;
510
+ stage('tool_call', name, attempt + 1);
511
+ attempts += 1;
317
512
  called = await client.request('tools/call', { name, arguments: args });
513
+ if (client.fatalError) throw client.fatalError;
514
+ stage('tool_validate', name, attempt + 1);
318
515
  payload = toolPayload(called, name);
319
- if (name !== 'marrow_auto' || payload.live_delivery?.committed === true || payload.resumable !== true) break;
516
+ if (name !== 'marrow_auto') break;
517
+ context.autoMetrics = autoTimings(payload);
518
+ const waitClass = autoWaitClass(payload);
519
+ context.autoPhase = waitClass ? payload.phase : undefined;
520
+ if (waitClass !== 'completion_pending') break;
320
521
  if (attempt < 2) {
321
- const retryAfterMs = Number(payload.retry_after_ms);
322
- const delayMs = Number.isFinite(retryAfterMs) ? Math.max(0, Math.min(1_000, retryAfterMs)) : 250;
522
+ const requested = payload.retry_after_ms;
523
+ const delayMs = typeof requested === 'number' && Number.isFinite(requested) && requested >= 0
524
+ ? Math.ceil(requested) : 250;
525
+ const remaining = totalTimeoutMs - (performance.now() - context.started);
526
+ if (delayMs >= remaining) break;
527
+ const waitStarted = performance.now();
323
528
  await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
529
+ context.retryWaitMs += Math.round(performance.now() - waitStarted);
324
530
  }
325
531
  }
326
532
  const latencyMs = Math.round(performance.now() - callStarted);
327
533
  validatePayload(name, payload);
328
534
  const live = payload.stale !== true && payload.source !== 'last_known' && payload.available !== false;
329
- if (!live) throw new Error(`${name} returned cached or unavailable guidance`);
330
- results.push({ tool: name, ok: true, live: true, latency_ms: latencyMs });
535
+ if (!live) throw canaryError('contract', `${name} returned cached or unavailable guidance`);
536
+ results.push({ tool: name, ok: true, live: true, latency_ms: latencyMs,
537
+ ...(name === 'marrow_auto' ? { ...autoTimings(payload), attempts, retry_wait_ms: context.retryWaitMs } : {}),
538
+ });
331
539
  }
332
540
  const hotPath = results.filter((row) => HOT_PATH_TOOLS.has(row.tool));
333
541
  const reports = results.filter((row) => !HOT_PATH_TOOLS.has(row.tool));
542
+ stage('shutdown');
334
543
  return {
335
544
  ok: true,
336
545
  package_version: version,
@@ -344,9 +553,14 @@ async function runCanary(env = process.env, options = {}) {
344
553
  },
345
554
  results,
346
555
  };
556
+ } catch (error) {
557
+ failed = true;
558
+ throw error;
347
559
  } finally {
348
560
  clearTimeout(totalTimer);
349
- await client.stop();
561
+ try { await client.stop(); } catch (error) {
562
+ if (!failed) throw errorEvidence.has(error) ? error : canaryError('process_write', 'MCP child shutdown failed');
563
+ }
350
564
  }
351
565
  }
352
566
 
@@ -354,7 +568,8 @@ if (require.main === module) {
354
568
  runCanary().then((result) => {
355
569
  process.stdout.write(`${JSON.stringify(result)}\n`);
356
570
  }).catch((error) => {
357
- process.stderr.write(`MCP_CONTROL_PATH_CANARY=FAIL ${error instanceof Error ? error.message : 'unknown_error'}\n`);
571
+ process.stdout.write(`${serializeFailure(error)}\n`);
572
+ process.stderr.write('MCP_CONTROL_PATH_CANARY=FAIL\n');
358
573
  process.exitCode = 1;
359
574
  });
360
575
  }
@@ -367,4 +582,5 @@ module.exports = {
367
582
  latencyGroup,
368
583
  percentile,
369
584
  runCanary,
585
+ serializeFailure,
370
586
  };
@@ -5,32 +5,52 @@ const path = require('node:path')
5
5
 
6
6
  const root = path.resolve(__dirname, '..')
7
7
  const server = JSON.parse(fs.readFileSync(path.join(root, 'server.json'), 'utf8'))
8
- const endpoint = new URL('https://registry.modelcontextprotocol.io/v0.1/servers')
9
- endpoint.searchParams.set('search', server.name)
8
+ const endpoint = new URL(
9
+ `https://registry.modelcontextprotocol.io/v0.1/servers/${encodeURIComponent(server.name)}/versions/${encodeURIComponent(server.version)}`,
10
+ )
10
11
 
11
12
  function identity(entry) {
12
13
  const candidate = entry && typeof entry === 'object' && entry.server && typeof entry.server === 'object'
13
14
  ? entry.server
14
15
  : entry
15
- return {
16
- name: candidate && typeof candidate.name === 'string' ? candidate.name : '',
17
- version: candidate && typeof candidate.version === 'string' ? candidate.version : '',
18
- }
16
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)
17
+ || typeof candidate.name !== 'string' || candidate.name.length === 0
18
+ || typeof candidate.version !== 'string' || candidate.version.length === 0) return null
19
+ return { name: candidate.name, version: candidate.version }
19
20
  }
20
21
 
21
- async function main() {
22
- const response = await fetch(endpoint, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
22
+ async function verifyRegistry(fetchImpl = globalThis.fetch) {
23
+ let response
24
+ try {
25
+ response = await fetchImpl(endpoint, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
26
+ } catch {
27
+ throw new Error('MCP Registry query is unavailable')
28
+ }
23
29
  if (!response.ok) throw new Error(`MCP Registry query failed with HTTP ${response.status}`)
24
- const body = await response.json()
25
- const entries = Array.isArray(body?.servers) ? body.servers : []
26
- const match = entries.map(identity).find((entry) => entry.name === server.name && entry.version === server.version)
27
- if (!match) {
28
- throw new Error(`MCP Registry is missing ${server.name}@${server.version}`)
30
+ let body
31
+ try {
32
+ body = await response.json()
33
+ } catch {
34
+ throw new Error('MCP Registry response is malformed')
29
35
  }
36
+ const match = identity(body)
37
+ if (!match) throw new Error('MCP Registry response is malformed')
38
+ if (match.name !== server.name || match.version !== server.version) {
39
+ throw new Error('MCP Registry identity mismatch')
40
+ }
41
+ return match
42
+ }
43
+
44
+ async function main() {
45
+ const match = await verifyRegistry()
30
46
  console.log(`MCP_REGISTRY_VERIFY=PASS name=${match.name} version=${match.version}`)
31
47
  }
32
48
 
33
- main().catch((error) => {
34
- console.error(`MCP_REGISTRY_VERIFY=FAIL reason=${error instanceof Error ? error.message : String(error)}`)
35
- process.exitCode = 1
36
- })
49
+ if (require.main === module) {
50
+ main().catch((error) => {
51
+ console.error(`MCP_REGISTRY_VERIFY=FAIL reason=${error instanceof Error ? error.message : String(error)}`)
52
+ process.exitCode = 1
53
+ })
54
+ }
55
+
56
+ module.exports = { endpoint, identity, verifyRegistry }
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/getmarrow/marrow-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.9.80",
9
+ "version": "3.9.82",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@getmarrow/mcp",
14
- "version": "3.9.80",
14
+ "version": "3.9.82",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },