@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.
- package/README.md +20 -4
- package/dist/cli.js +20 -5
- package/dist/cli.js.map +1 -1
- package/dist/hook-contract.d.ts +2 -2
- package/dist/hook-contract.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +54 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime-contract.d.ts +10 -0
- package/dist/runtime-contract.d.ts.map +1 -1
- package/dist/runtime-contract.js +69 -0
- package/dist/runtime-contract.js.map +1 -1
- package/package.json +1 -1
- package/scripts/control-path-canary.cjs +265 -49
- package/scripts/mcp-registry-verify.cjs +37 -17
- package/server.json +2 -2
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
if (message.error) throw
|
|
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
|
|
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
|
|
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
|
|
194
|
+
throw canaryError('contract', 'Empty or invalid tool payload');
|
|
85
195
|
}
|
|
86
196
|
if (result.isError || payload.ok === false || payload.available === false) {
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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(
|
|
302
|
+
if (this.stderrBytes > this.maxOutputBytes) this.abort(canaryError('output_limit', 'MCP child exceeded stderr limit'));
|
|
172
303
|
});
|
|
173
|
-
this.child.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
365
|
+
const onWrite = (error) => {
|
|
223
366
|
if (!error) return;
|
|
224
367
|
clearTimeout(timer);
|
|
225
368
|
this.pending.delete(id);
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
242
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
303
|
-
if (version !==
|
|
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 (
|
|
306
|
-
|
|
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
|
|
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
|
-
|
|
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'
|
|
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
|
|
322
|
-
const delayMs = Number.isFinite(
|
|
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
|
|
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.
|
|
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(
|
|
9
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
throw new Error(
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
9
|
+
"version": "3.9.82",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@getmarrow/mcp",
|
|
14
|
-
"version": "3.9.
|
|
14
|
+
"version": "3.9.82",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|