@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 +5 -1
- package/dist/hook-contract.d.ts +2 -2
- package/dist/hook-contract.js +1 -1
- package/package.json +1 -1
- package/scripts/control-path-canary.cjs +185 -45
- package/scripts/mcp-registry-verify.cjs +37 -17
- package/server.json +2 -2
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.
|
|
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
|
|
package/dist/hook-contract.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { resolveMarrowEnv, type ResolvedMarrowEnv } from './env';
|
|
2
|
-
export declare const MCP_ADAPTER_VERSION = "3.9.
|
|
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.
|
|
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;
|
package/dist/hook-contract.js
CHANGED
|
@@ -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.
|
|
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
|
@@ -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,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
|
|
77
|
-
if (message.error) throw
|
|
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
|
|
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
|
|
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
|
|
154
|
+
throw canaryError('contract', 'Empty or invalid tool payload');
|
|
85
155
|
}
|
|
86
156
|
if (result.isError || payload.ok === false || payload.available === false) {
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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(
|
|
242
|
+
if (this.stderrBytes > this.maxOutputBytes) this.abort(canaryError('output_limit', 'MCP child exceeded stderr limit'));
|
|
172
243
|
});
|
|
173
|
-
this.child.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
305
|
+
const onWrite = (error) => {
|
|
223
306
|
if (!error) return;
|
|
224
307
|
clearTimeout(timer);
|
|
225
308
|
this.pending.delete(id);
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
242
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
303
|
-
if (version !==
|
|
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 (
|
|
306
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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.81",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@getmarrow/mcp",
|
|
14
|
-
"version": "3.9.
|
|
14
|
+
"version": "3.9.81",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|