@getmarrow/mcp 3.9.80 → 3.9.81

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/README.md CHANGED
@@ -116,7 +116,11 @@ npx -y --package=@getmarrow/mcp@latest marrow-mcp ping
116
116
 
117
117
  Detection and notification are automatic. After explicit installer activation, the local controller may restore only Marrow-managed hooks/configuration. Package upgrades, owner policy, credentials, and unrelated configuration remain explicit and subject to the operator's normal change policy.
118
118
 
119
- ## What's New in v3.9.80
119
+ ## What's New in v3.9.81
120
+
121
+ v3.9.81 adds bounded structured failure evidence to the authenticated eleven-tool control-path canary. Failed runs identify the observed stage, tool, error class, timing, and completed checks without retaining credentials, customer payloads, or arbitrary error text. Protocol and write failures observed after the final response or during shutdown now fail closed; the canary's own bounded cleanup remains compatible with a successful run. The eleven live-tool requirements, client deadlines, package identity checks, and retry limits are unchanged.
122
+
123
+ ### Previous release: v3.9.80
120
124
 
121
125
  v3.9.80 is a reliability patch for direct `marrow_think` and `marrow_commit` calls. Each invocation now carries one stable bounded idempotency key. Only the backend's documented pending-persistence states are reconciled, using the byte-identical request and key after a fixed one-second delay for at most three total attempts. A 202 response is never reported as successful completion; unknown, malformed, correlation-drifted, or exhausted pending responses fail closed with a structured error. Explicit caller-supplied idempotency keys remain unchanged, and durable `observed_unverified` outcomes retain their terminal, non-authorizing semantics.
122
126
 
@@ -1,8 +1,8 @@
1
1
  import { resolveMarrowEnv, type ResolvedMarrowEnv } from './env';
2
- export declare const MCP_ADAPTER_VERSION = "3.9.80";
2
+ export declare const MCP_ADAPTER_VERSION = "3.9.81";
3
3
  export declare const NATIVE_HOOK_MATCHER = "Bash|Edit|Write|MultiEdit|mcp__(?!marrow__marrow_).*";
4
4
  export declare const GROK_NATIVE_HOOK_MATCHER = "run_terminal_command|search_replace|write|spawn_subagent|use_tool|workflow|image_gen|image_edit|image_to_video|reference_to_video";
5
- export declare const MCP_PACKAGE_SPEC = "@getmarrow/mcp@3.9.80";
5
+ export declare const MCP_PACKAGE_SPEC = "@getmarrow/mcp@3.9.81";
6
6
  export declare const CONTEXT_HOOK_COMMAND: string;
7
7
  export declare const PRE_ACTION_HOOK_COMMAND: string;
8
8
  export declare const ACTION_RESULT_HOOK_COMMAND: string;
@@ -20,7 +20,7 @@ const node_fs_1 = require("node:fs");
20
20
  const node_os_1 = require("node:os");
21
21
  const node_path_1 = require("node:path");
22
22
  const env_1 = require("./env");
23
- exports.MCP_ADAPTER_VERSION = '3.9.80';
23
+ exports.MCP_ADAPTER_VERSION = '3.9.81';
24
24
  exports.NATIVE_HOOK_MATCHER = 'Bash|Edit|Write|MultiEdit|mcp__(?!marrow__marrow_).*';
25
25
  exports.GROK_NATIVE_HOOK_MATCHER = 'run_terminal_command|search_replace|write|spawn_subagent|use_tool|workflow|image_gen|image_edit|image_to_video|reference_to_video';
26
26
  exports.MCP_PACKAGE_SPEC = `@getmarrow/mcp@${exports.MCP_ADAPTER_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmarrow/mcp",
3
- "version": "3.9.80",
3
+ "version": "3.9.81",
4
4
  "mcpName": "io.github.getmarrow/marrow",
5
5
  "description": "Runtime control, proof, and fleet intelligence for MCP-compatible AI agents.",
6
6
  "bin": {
@@ -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,68 @@ 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 failureRecord(error, context = {}) {
96
+ const evidence = errorEvidence.get(error) || { error_class: 'internal' };
97
+ const results = (context.results || []).slice(0, 11).map((row) => Object.freeze({
98
+ tool: row.tool, ok: true, live: true, latency_ms: safeMs(row.latency_ms),
99
+ }));
100
+ return Object.freeze({
101
+ schema_version: 1,
102
+ ok: false,
103
+ stage: context.stage || 'setup',
104
+ tool: TOOL_NAMES.has(context.tool) ? context.tool : null,
105
+ error_class: evidence.error_class,
106
+ package_version: safeVersion(context.version),
107
+ expected_version: safeVersion(context.expectedVersion),
108
+ attempt: Math.max(0, Math.min(3, context.attempt || 0)),
109
+ latency_ms: safeMs(performance.now() - (context.stageStarted ?? performance.now())),
110
+ total_latency_ms: safeMs(performance.now() - (context.started ?? performance.now())),
111
+ process_count: context.processCount === 1 ? 1 : 0,
112
+ initialization_ms: context.initializationMs == null ? null : safeMs(context.initializationMs),
113
+ tools_checked: results.length,
114
+ results: Object.freeze(results),
115
+ ...(evidence.http_status === undefined ? {} : { http_status: evidence.http_status }),
116
+ });
117
+ }
118
+
119
+ function serializeFailure(error) {
120
+ return JSON.stringify(failureRecords.get(error) || failureRecord(error));
121
+ }
52
122
 
53
123
  function boundedMs(name, fallback, minimum = 100, maximum = 10_000, env = process.env) {
54
124
  const parsed = Number(env[name] || fallback);
@@ -73,26 +143,25 @@ function latencyGroup(rows) {
73
143
  }
74
144
 
75
145
  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}`);
146
+ if (!message) throw canaryError('contract', 'Missing MCP tool response');
147
+ if (message.error) throw upstreamError(message, 'protocol', 'MCP JSON-RPC error');
78
148
  const result = message.result || {};
79
149
  const text = result.content?.[0]?.text;
80
- if (typeof text !== 'string' || !text.trim()) throw new Error(`${name} returned no MCP tool payload`);
150
+ if (typeof text !== 'string' || !text.trim()) throw canaryError('contract', 'Missing MCP tool payload');
81
151
  let payload;
82
- try { payload = JSON.parse(text); } catch { throw new Error(`${name} returned invalid tool JSON`); }
152
+ try { payload = JSON.parse(text); } catch { throw canaryError('contract', 'Invalid tool JSON'); }
83
153
  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`);
154
+ throw canaryError('contract', 'Empty or invalid tool payload');
85
155
  }
86
156
  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})`);
157
+ throw upstreamError(payload, 'tool_unavailable', 'MCP tool unavailable');
89
158
  }
90
159
  return payload;
91
160
  }
92
161
 
93
162
  function validatePayload(name, payload) {
94
163
  const requireField = (valid, field) => {
95
- if (!valid) throw new Error(`${name} returned an invalid ${field} contract`);
164
+ if (!valid) throw canaryError('contract', `${name} returned an invalid ${field} contract`);
96
165
  };
97
166
  if (name === 'marrow_status' || name === 'marrow_runtime_status') {
98
167
  requireField(
@@ -160,27 +229,34 @@ class PersistentMcpClient {
160
229
 
161
230
  start() {
162
231
  if (this.child) return;
163
- this.child = this.spawnProcess(this.command, this.args, {
164
- env: this.env,
165
- stdio: ['pipe', 'pipe', 'pipe'],
166
- });
232
+ try {
233
+ this.child = this.spawnProcess(this.command, this.args, {
234
+ env: this.env,
235
+ stdio: ['pipe', 'pipe', 'pipe'],
236
+ });
237
+ } catch { throw canaryError('process_spawn', 'MCP child spawn failed'); }
167
238
  this.child.stdout.setEncoding('utf8');
168
239
  this.child.stdout.on('data', (chunk) => this.onStdout(chunk));
169
240
  this.child.stderr.on('data', (chunk) => {
170
241
  this.stderrBytes += Buffer.byteLength(chunk);
171
- if (this.stderrBytes > this.maxOutputBytes) this.abort(new Error('MCP child exceeded stderr limit'));
242
+ if (this.stderrBytes > this.maxOutputBytes) this.abort(canaryError('output_limit', 'MCP child exceeded stderr limit'));
172
243
  });
173
- this.child.once('error', (error) => this.abort(new Error(`MCP child failed: ${error.code || 'process_error'}`)));
244
+ this.child.stdin.on?.('error', () => this.abort(canaryError('process_write', 'MCP child write failed')));
245
+ this.child.once('error', () => this.abort(canaryError('process_spawn', 'MCP child spawn failed')));
174
246
  this.child.once('exit', (code, signal) => {
175
247
  this.closed = true;
176
- if (this.pending.size > 0) this.rejectAll(new Error(`MCP child exited ${code ?? signal ?? 'unknown'}`));
248
+ const expectedExit = (code === 0 && !signal)
249
+ || (this.cleanupKillRequested && code === null && signal === 'SIGKILL');
250
+ if (this.stopping && expectedExit && this.pending.size === 0 && !this.fatalError) return;
251
+ this.fatalError ||= canaryError('process_exit', 'MCP child exited');
252
+ this.rejectAll(this.fatalError);
177
253
  });
178
254
  }
179
255
 
180
256
  onStdout(chunk) {
181
257
  this.stdoutBuffer += chunk;
182
258
  if (Buffer.byteLength(this.stdoutBuffer) > this.maxOutputBytes) {
183
- this.abort(new Error('MCP child exceeded stdout limit'));
259
+ this.abort(canaryError('output_limit', 'MCP child exceeded stdout limit'));
184
260
  return;
185
261
  }
186
262
  for (;;) {
@@ -191,13 +267,19 @@ class PersistentMcpClient {
191
267
  if (!line) continue;
192
268
  let message;
193
269
  try { message = JSON.parse(line); } catch {
194
- this.abort(new Error('MCP child emitted malformed JSON'));
270
+ this.abort(canaryError('protocol', 'MCP child emitted malformed JSON'));
271
+ return;
272
+ }
273
+ if (!message || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== '2.0'
274
+ || (message.id !== undefined && (!Number.isSafeInteger(message.id)
275
+ || (Object.hasOwn(message, 'result') === Object.hasOwn(message, 'error'))))) {
276
+ this.abort(canaryError('protocol', 'MCP child emitted malformed JSON-RPC'));
195
277
  return;
196
278
  }
197
279
  if (!Number.isSafeInteger(message.id)) continue;
198
280
  const pending = this.pending.get(message.id);
199
281
  if (!pending) {
200
- this.abort(new Error(`MCP child returned unexpected response id ${message.id}`));
282
+ this.abort(canaryError('protocol', 'MCP child returned unexpected response id'));
201
283
  return;
202
284
  }
203
285
  clearTimeout(pending.timer);
@@ -207,25 +289,30 @@ class PersistentMcpClient {
207
289
  }
208
290
 
209
291
  request(method, params = {}, timeoutMs = this.timeoutMs) {
292
+ if (this.fatalError) return Promise.reject(this.fatalError);
210
293
  if (!this.child || this.closed || !this.child.stdin.writable) {
211
- return Promise.reject(new Error('MCP child is not writable'));
294
+ return Promise.reject(canaryError('process_write', 'MCP child is not writable'));
212
295
  }
213
296
  const id = this.nextId++;
214
297
  return new Promise((resolveRequest, rejectRequest) => {
215
298
  const timer = setTimeout(() => {
216
299
  this.pending.delete(id);
217
- const error = new Error(`${method} timed out`);
300
+ const error = canaryError('request_timeout', `${method} timed out`);
218
301
  rejectRequest(error);
219
302
  this.abort(error);
220
303
  }, timeoutMs);
221
304
  this.pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer });
222
- this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, (error) => {
305
+ const onWrite = (error) => {
223
306
  if (!error) return;
224
307
  clearTimeout(timer);
225
308
  this.pending.delete(id);
226
- rejectRequest(new Error(`${method} write failed`));
227
- this.abort(error);
228
- });
309
+ const failure = canaryError('process_write', `${method} write failed`);
310
+ rejectRequest(failure);
311
+ this.abort(failure);
312
+ };
313
+ try {
314
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, onWrite);
315
+ } catch (error) { onWrite(error); }
229
316
  });
230
317
  }
231
318
 
@@ -238,16 +325,28 @@ class PersistentMcpClient {
238
325
  }
239
326
 
240
327
  abort(error) {
241
- this.rejectAll(error instanceof Error ? error : new Error('MCP child aborted'));
242
- if (this.child && !this.closed) this.child.kill('SIGKILL');
328
+ this.fatalError ||= error instanceof Error ? error : canaryError('internal');
329
+ this.rejectAll(this.fatalError);
330
+ if (this.child && !this.closed) {
331
+ try { this.child.kill('SIGKILL'); } catch { /* Preserve the original failure. */ }
332
+ }
243
333
  }
244
334
 
245
335
  async stop() {
246
- if (!this.child || this.closed) return;
336
+ if (!this.child || this.closed) {
337
+ if (this.fatalError) throw this.fatalError;
338
+ return;
339
+ }
340
+ this.stopping = true;
247
341
  this.child.stdin.end();
248
342
  await new Promise((resolveExit) => {
249
343
  const timer = setTimeout(() => {
250
- if (!this.closed) this.child.kill('SIGKILL');
344
+ if (!this.closed) {
345
+ // The bounded cleanup deadline intentionally ends an otherwise healthy
346
+ // child. This signal is ours; actual protocol/write errors remain fatal.
347
+ this.cleanupKillRequested = true;
348
+ try { this.child.kill('SIGKILL'); } catch { this.cleanupKillRequested = false; }
349
+ }
251
350
  resolveExit();
252
351
  }, 500);
253
352
  this.child.once('exit', () => {
@@ -255,12 +354,28 @@ class PersistentMcpClient {
255
354
  resolveExit();
256
355
  });
257
356
  });
357
+ if (this.fatalError) throw this.fatalError;
258
358
  }
259
359
  }
260
360
 
261
361
  async function runCanary(env = process.env, options = {}) {
362
+ const started = performance.now();
363
+ const context = { started, stageStarted: started, stage: 'setup', results: [],
364
+ expectedVersion: env.MARROW_EXPECTED_MCP_VERSION || packageVersion };
365
+ try {
366
+ return await executeCanary(env, options, context);
367
+ } catch (caught) {
368
+ const error = errorEvidence.has(caught) ? caught : canaryError('internal');
369
+ error.failure = failureRecord(error, context);
370
+ failureRecords.set(error, error.failure);
371
+ throw error;
372
+ }
373
+ }
374
+
375
+ async function executeCanary(env, options, context) {
376
+ if (configurationFailed) throw canaryError('configuration', 'MCP canary build configuration failed');
262
377
  const key = env.MARROW_API_KEY || '';
263
- if (!key) throw new Error('MARROW_API_KEY is required for the authenticated MCP control-path canary');
378
+ if (!key) throw canaryError('authentication', 'MARROW_API_KEY is required for the authenticated MCP control-path canary');
264
379
  const toolTimeoutMs = boundedMs(
265
380
  'MARROW_MCP_CANARY_TOOL_TIMEOUT_MS',
266
381
  CANARY_DEFAULT_TOOL_TIMEOUT_MS,
@@ -291,30 +406,47 @@ async function runCanary(env = process.env, options = {}) {
291
406
  env: childEnv,
292
407
  spawnProcess: options.spawnProcess,
293
408
  });
294
- const totalTimer = setTimeout(() => client.abort(new Error('MCP canary total timeout')), totalTimeoutMs);
409
+ const totalTimer = setTimeout(() => client.abort(canaryError('total_timeout', 'MCP canary total timeout')), totalTimeoutMs);
295
410
  const processStarted = performance.now();
296
411
  const cases = canaryCases(`canary_${randomUUID()}`);
297
- client.start();
412
+ const stage = (value, tool = null, attempt = 0) => {
413
+ Object.assign(context, { stage: value, tool, attempt, stageStarted: performance.now() });
414
+ };
415
+ let failed = false;
298
416
  try {
417
+ stage('spawn');
418
+ client.start();
419
+ context.processCount = 1;
420
+ stage('initialize', null, 1);
299
421
  const initialized = await client.request('initialize', {}, toolTimeoutMs + 3_000);
300
- if (initialized.error) throw new Error(`MCP initialize failed ${initialized.error.code}`);
422
+ if (client.fatalError) throw client.fatalError;
423
+ if (initialized.error) throw upstreamError(initialized, 'protocol', 'MCP initialize failed');
301
424
  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}`);
425
+ context.version = version;
426
+ if (typeof version !== 'string' || !version) throw canaryError('contract', 'MCP initialize produced no version');
427
+ if (version !== expectedVersion) throw canaryError('package_mismatch', 'MCP canary package version mismatch');
428
+ stage('tools_list', null, 1);
304
429
  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));
430
+ if (client.fatalError) throw client.fatalError;
431
+ if (listed.error) throw upstreamError(listed, 'protocol', 'MCP tools/list failed');
432
+ if (!Array.isArray(listed.result?.tools)) throw canaryError('contract', 'MCP tools/list missing tools');
433
+ const names = new Set(listed.result.tools.map((tool) => tool?.name));
307
434
  for (const [name] of cases) {
308
- if (!names.has(name)) throw new Error(`${name} is missing from the full MCP contract`);
435
+ if (!names.has(name)) throw canaryError('contract', `${name} is missing from the full MCP contract`);
309
436
  }
310
437
  const initializationMs = Math.round(performance.now() - processStarted);
311
- const results = [];
438
+ context.initializationMs = initializationMs;
439
+ const results = context.results;
312
440
  for (const [name, args] of cases) {
313
441
  const callStarted = performance.now();
314
442
  let called;
315
443
  let payload;
316
444
  for (let attempt = 0; attempt < 3; attempt += 1) {
445
+ if (client.fatalError) throw client.fatalError;
446
+ stage('tool_call', name, attempt + 1);
317
447
  called = await client.request('tools/call', { name, arguments: args });
448
+ if (client.fatalError) throw client.fatalError;
449
+ stage('tool_validate', name, attempt + 1);
318
450
  payload = toolPayload(called, name);
319
451
  if (name !== 'marrow_auto' || payload.live_delivery?.committed === true || payload.resumable !== true) break;
320
452
  if (attempt < 2) {
@@ -326,11 +458,12 @@ async function runCanary(env = process.env, options = {}) {
326
458
  const latencyMs = Math.round(performance.now() - callStarted);
327
459
  validatePayload(name, payload);
328
460
  const live = payload.stale !== true && payload.source !== 'last_known' && payload.available !== false;
329
- if (!live) throw new Error(`${name} returned cached or unavailable guidance`);
461
+ if (!live) throw canaryError('contract', `${name} returned cached or unavailable guidance`);
330
462
  results.push({ tool: name, ok: true, live: true, latency_ms: latencyMs });
331
463
  }
332
464
  const hotPath = results.filter((row) => HOT_PATH_TOOLS.has(row.tool));
333
465
  const reports = results.filter((row) => !HOT_PATH_TOOLS.has(row.tool));
466
+ stage('shutdown');
334
467
  return {
335
468
  ok: true,
336
469
  package_version: version,
@@ -344,9 +477,14 @@ async function runCanary(env = process.env, options = {}) {
344
477
  },
345
478
  results,
346
479
  };
480
+ } catch (error) {
481
+ failed = true;
482
+ throw error;
347
483
  } finally {
348
484
  clearTimeout(totalTimer);
349
- await client.stop();
485
+ try { await client.stop(); } catch (error) {
486
+ if (!failed) throw errorEvidence.has(error) ? error : canaryError('process_write', 'MCP child shutdown failed');
487
+ }
350
488
  }
351
489
  }
352
490
 
@@ -354,7 +492,8 @@ if (require.main === module) {
354
492
  runCanary().then((result) => {
355
493
  process.stdout.write(`${JSON.stringify(result)}\n`);
356
494
  }).catch((error) => {
357
- process.stderr.write(`MCP_CONTROL_PATH_CANARY=FAIL ${error instanceof Error ? error.message : 'unknown_error'}\n`);
495
+ process.stdout.write(`${serializeFailure(error)}\n`);
496
+ process.stderr.write('MCP_CONTROL_PATH_CANARY=FAIL\n');
358
497
  process.exitCode = 1;
359
498
  });
360
499
  }
@@ -367,4 +506,5 @@ module.exports = {
367
506
  latencyGroup,
368
507
  percentile,
369
508
  runCanary,
509
+ serializeFailure,
370
510
  };
@@ -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.81",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@getmarrow/mcp",
14
- "version": "3.9.80",
14
+ "version": "3.9.81",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },