attesso 0.4.1 → 0.6.0

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.
@@ -0,0 +1,605 @@
1
+ #!/usr/bin/env node
2
+ // Experimental stdio MCP proxy: the only place that sees both the tool
3
+ // definitions a client trusts and the call that follows. It is not a general
4
+ // MCP gateway; it records the pinned surface in the same signed ledger as every
5
+ // other decision.
6
+ import { spawn } from 'node:child_process';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { createInterface } from 'node:readline';
9
+ import { classifyTool, loadTools } from './classify.mjs';
10
+ import { argsFingerprint } from './hook.mjs';
11
+ import { append, readToolLock } from './store.mjs';
12
+ import { comparePin, definitionHash, manifestDigest } from './toolpin.mjs';
13
+
14
+ const REQUEST_TIMEOUT_MS = Number(process.env.MCP_PROXY_TIMEOUT_MS ?? 30000);
15
+ const PROTOCOL = '2026-07-28';
16
+ const PROXY_NAME = 'attesso-mcp-proxy';
17
+ const PROXY_VERSION = '0.0.0';
18
+ const SERVER_PROBE_TIMEOUT_MS = 3000;
19
+ const SERVER_REQUESTS = new Set(['sampling/createMessage', 'elicitation/create', 'roots/list']);
20
+ const SURFACES = {
21
+ 'prompts/list': { key: 'prompts', field: 'prompts' },
22
+ 'resources/list': { key: 'resources', field: 'resources' },
23
+ 'resources/templates/list': { key: 'resourceTemplates', field: 'resource_templates' },
24
+ };
25
+
26
+ function usage() {
27
+ return [
28
+ 'usage: node experimental/mcp-proxy.mjs --dir <ledger> [--task <name>] [--] <server> [args...]',
29
+ ' node experimental/mcp-proxy.mjs --print-manifest [--era auto|modern|legacy] [--] <server> [args...]',
30
+ '',
31
+ 'The proxy refuses to expose a tools/list result whose definitions do not match',
32
+ '.attesso/tools.lock.json, and refuses a tools/call before forwarding it when the',
33
+ 'tool is changed, unpinned, or the local policy denies it.',
34
+ ].join('\n');
35
+ }
36
+
37
+ function parseArgs(argv) {
38
+ const options = { dir: '.attesso', task: 'mcp', print: false, era: 'auto', command: null, args: [] };
39
+ for (let index = 0; index < argv.length; index += 1) {
40
+ const token = argv[index];
41
+ if (token === '--') {
42
+ options.command = argv[index + 1] ?? null;
43
+ options.args = argv.slice(index + 2);
44
+ return options;
45
+ }
46
+ if (token === '--help' || token === '-h') {
47
+ options.help = true;
48
+ return options;
49
+ }
50
+ if (token === '--print-manifest') {
51
+ options.print = true;
52
+ continue;
53
+ }
54
+ if (token === '--dir' || token === '--task' || token === '--era') {
55
+ const value = argv[index + 1];
56
+ if (!value || value.startsWith('--')) throw new Error(`${token} needs a value`);
57
+ if (token === '--era' && !['auto', 'modern', 'legacy'].includes(value)) {
58
+ throw new Error('--era takes auto, modern or legacy');
59
+ }
60
+ options[token.slice(2)] = value;
61
+ index += 1;
62
+ continue;
63
+ }
64
+ throw new Error(`unknown argument ${token}; put server arguments after --`);
65
+ }
66
+ return options;
67
+ }
68
+
69
+ function keyOf(id) {
70
+ return `${typeof id}:${JSON.stringify(id)}`;
71
+ }
72
+
73
+ function write(stream, message) {
74
+ try {
75
+ stream.write(`${JSON.stringify(message)}\n`);
76
+ } catch {
77
+ // The peer closed; the child or the client is about to exit.
78
+ }
79
+ }
80
+
81
+ function errorResponse(id, code, detail, data = {}) {
82
+ return {
83
+ jsonrpc: '2.0',
84
+ id,
85
+ error: {
86
+ code: -32000,
87
+ message: `${code}: ${detail}`,
88
+ data: { code, ...data },
89
+ },
90
+ };
91
+ }
92
+
93
+ function valuesOf(result, key, label) {
94
+ if (!result || !Array.isArray(result[key])) {
95
+ throw new Error(`TOOL_LIST_INVALID: ${label} did not return a ${key} array`);
96
+ }
97
+ return result[key];
98
+ }
99
+
100
+ function listOf(result, key = 'tools', label = 'tools/list') {
101
+ const values = valuesOf(result, key, label);
102
+ if (result.nextCursor) {
103
+ throw new Error(`TOOL_LIST_PAGINATED: ${label} returned a paginated list`);
104
+ }
105
+ return values;
106
+ }
107
+
108
+ function modernMeta() {
109
+ return {
110
+ 'io.modelcontextprotocol/protocolVersion': PROTOCOL,
111
+ 'io.modelcontextprotocol/clientInfo': { name: PROXY_NAME, version: PROXY_VERSION },
112
+ 'io.modelcontextprotocol/clientCapabilities': {},
113
+ };
114
+ }
115
+
116
+ function modernParams(params = {}) {
117
+ return { ...params, _meta: { ...(params._meta ?? {}), ...modernMeta() } };
118
+ }
119
+
120
+ function serverSurface(result, era) {
121
+ if (!result || typeof result !== 'object') throw new Error('SERVER_RESPONSE_INVALID');
122
+ const capabilities = result.capabilities && typeof result.capabilities === 'object' ? result.capabilities : {};
123
+ if (era === 'modern') {
124
+ if (!Array.isArray(result.supportedVersions)) throw new Error('SERVER_RESPONSE_INVALID: no supportedVersions');
125
+ return {
126
+ protocol: PROTOCOL,
127
+ supportedVersions: result.supportedVersions,
128
+ capabilities,
129
+ serverInfo: result._meta?.['io.modelcontextprotocol/serverInfo'] ?? null,
130
+ instructions: result.instructions ?? null,
131
+ };
132
+ }
133
+ if (typeof result.protocolVersion !== 'string') throw new Error('SERVER_RESPONSE_INVALID: no protocolVersion');
134
+ return {
135
+ protocolVersion: result.protocolVersion,
136
+ capabilities,
137
+ serverInfo: result.serverInfo ?? null,
138
+ instructions: result.instructions ?? null,
139
+ };
140
+ }
141
+
142
+ async function probeServer(rpc, era = 'auto') {
143
+ let modern = null;
144
+ if (era !== 'legacy') {
145
+ try {
146
+ modern = await rpc.request('server/discover', modernParams({}), { timeout: 3000 });
147
+ } catch {
148
+ modern = null;
149
+ }
150
+ if (modern?.result?.supportedVersions) return { era: 'modern', result: modern.result };
151
+ if (era === 'modern') throw new Error('the server did not answer server/discover as a modern MCP server');
152
+ }
153
+ const legacy = await rpc.request('initialize', {
154
+ protocolVersion: '2025-11-25',
155
+ capabilities: {},
156
+ clientInfo: { name: PROXY_NAME, version: PROXY_VERSION },
157
+ });
158
+ if (legacy.error) throw new Error(legacy.error.message ?? 'initialize failed');
159
+ return { era: 'legacy', result: legacy.result };
160
+ }
161
+
162
+ async function listAll(rpc, method, key, era, required = true) {
163
+ const values = [];
164
+ let cursor = null;
165
+ let pages = 0;
166
+ for (;;) {
167
+ pages += 1;
168
+ if (pages > 100) throw new Error(`TOOL_LIST_PAGINATED: ${method} exceeded 100 pages`);
169
+ const params = era === 'modern' ? modernParams(cursor ? { cursor } : {}) : cursor ? { cursor } : {};
170
+ const response = await rpc.request(method, params);
171
+ if (response.error) {
172
+ if (!required) return null;
173
+ throw new Error(`${method}: ${response.error.message ?? 'request failed'}`);
174
+ }
175
+ const page = valuesOf(response.result, key, method);
176
+ values.push(...page);
177
+ cursor = response.result.nextCursor ?? null;
178
+ if (!cursor) return values;
179
+ }
180
+ }
181
+
182
+ class Proxy {
183
+ constructor(child, options) {
184
+ this.child = child;
185
+ this.options = options;
186
+ this.pending = new Map();
187
+ this.internal = new Map();
188
+ this.held = [];
189
+ this.tools = new Map();
190
+ this.server = null;
191
+ this.serverEra = null;
192
+ this.toolLock = readToolLock(options.dir);
193
+ this.toolsConfig = loadTools(options.dir);
194
+ this.stale = true;
195
+ this.probing = false;
196
+ this.closed = false;
197
+ }
198
+
199
+ attach() {
200
+ this.client = createInterface({ input: process.stdin });
201
+ this.client.on('line', (line) => this.fromClient(line));
202
+ this.server = createInterface({ input: this.child.stdout });
203
+ this.server.on('line', (line) => this.fromServer(line));
204
+ process.stdin.on('end', () => {
205
+ try {
206
+ this.child.stdin.end();
207
+ } catch {
208
+ // The upstream is already gone.
209
+ }
210
+ });
211
+ this.child.on('error', (error) => {
212
+ process.stderr.write(`mcp-proxy: ${error.message}\n`);
213
+ process.exit(1);
214
+ });
215
+ this.child.on('exit', (code, signal) => {
216
+ if (this.closed) return;
217
+ this.closed = true;
218
+ process.stderr.write(`mcp-proxy: upstream exited (${signal ?? code})\n`);
219
+ process.exit(code ?? 1);
220
+ });
221
+ }
222
+
223
+ fromClient(line) {
224
+ let message;
225
+ try {
226
+ message = JSON.parse(line);
227
+ } catch {
228
+ return;
229
+ }
230
+ if (Array.isArray(message)) {
231
+ process.stderr.write('mcp-proxy: refusing a JSON-RPC batch; it cannot be checked tool by tool\n');
232
+ return;
233
+ }
234
+ this.handleClient(message);
235
+ }
236
+
237
+ handleClient(message) {
238
+ if (this.probing) {
239
+ this.held.push(message);
240
+ return;
241
+ }
242
+ if (
243
+ this.toolLock?.server_digest &&
244
+ !this.server &&
245
+ ['tools/list', 'tools/call', 'prompts/list', 'resources/list', 'resources/templates/list'].includes(message.method)
246
+ ) {
247
+ this.held.push(message);
248
+ this.ensureServer();
249
+ return;
250
+ }
251
+ const id = message.id;
252
+ if (message.method === 'tools/call') {
253
+ if (id === undefined) {
254
+ process.stderr.write('mcp-proxy: refusing a tools/call notification\n');
255
+ return;
256
+ }
257
+ if (!this.prepareCall(id, message.params)) return;
258
+ }
259
+ if (id !== undefined) this.pending.set(keyOf(id), { id, method: message.method });
260
+ write(this.child.stdin, message);
261
+ }
262
+
263
+ ensureServer() {
264
+ if (this.probing || this.server) return;
265
+ this.probing = true;
266
+ const id = `attesso-discover-${randomUUID()}`;
267
+ this.internal.set(keyOf(id), { id, method: 'server/discover' });
268
+ write(this.child.stdin, { jsonrpc: '2.0', id, method: 'server/discover', params: modernParams({}) });
269
+ this.probeTimer = setTimeout(() => {
270
+ this.probing = false;
271
+ this.probeTimer = null;
272
+ this.failHeld('SERVER_PIN_REQUIRED', 'the server did not answer discovery');
273
+ }, SERVER_PROBE_TIMEOUT_MS);
274
+ }
275
+
276
+ failHeld(code, detail = code) {
277
+ const held = this.held;
278
+ this.held = [];
279
+ for (const message of held) {
280
+ if (message.id !== undefined) write(process.stdout, errorResponse(message.id, code, detail));
281
+ }
282
+ }
283
+
284
+ prepareCall(id, params) {
285
+ if (this.toolLock?.server_digest && !this.server) {
286
+ write(process.stdout, errorResponse(id, 'SERVER_PIN_REQUIRED', 'discover and pin the server before calling tools'));
287
+ return false;
288
+ }
289
+ if (this.stale || !this.toolLock) {
290
+ write(process.stdout, errorResponse(id, 'TOOL_LIST_REQUIRED', 'list and pin the tools before calling them'));
291
+ return false;
292
+ }
293
+ const name = typeof params?.name === 'string' ? params.name : '';
294
+ if (name === '') {
295
+ write(process.stdout, errorResponse(id, 'TOOL_NAME_MISSING', 'tools/call needs a tool name'));
296
+ return false;
297
+ }
298
+ const tool = this.tools.get(name);
299
+ if (!tool) {
300
+ write(process.stdout, errorResponse(id, 'TOOL_UNPINNED', `${name} is not in the pinned list`, { tool: name }));
301
+ return false;
302
+ }
303
+ const hash = definitionHash(tool);
304
+ if (this.toolLock.tools[name] !== hash) {
305
+ write(process.stdout, errorResponse(id, 'TOOL_CHANGED', `${name} differs from its pin`, { tool: name }));
306
+ return false;
307
+ }
308
+ const classified = classifyTool(name, this.toolsConfig);
309
+ let entry;
310
+ try {
311
+ entry = append(this.options.dir, {
312
+ action: classified.action,
313
+ subject: name,
314
+ task: this.options.task,
315
+ trust: classified.trust,
316
+ capability: classified.capability,
317
+ args_hash: argsFingerprint(params.arguments),
318
+ tool_hash: hash,
319
+ mcp_digest: this.toolLock.server_digest ? this.toolLock.manifest_digest : undefined,
320
+ });
321
+ } catch (error) {
322
+ write(process.stdout, errorResponse(id, 'PROXY_ERROR', error.message));
323
+ return false;
324
+ }
325
+ if (entry.decision !== 'ALLOW') {
326
+ write(
327
+ process.stdout,
328
+ errorResponse(id, 'TOOL_CALL_DENIED', `${name} was refused by the local policy`, {
329
+ decision: entry.decision,
330
+ policy_code: entry.code,
331
+ entry: entry.seq,
332
+ }),
333
+ );
334
+ return false;
335
+ }
336
+ return true;
337
+ }
338
+
339
+ fromServer(line) {
340
+ let message;
341
+ try {
342
+ message = JSON.parse(line);
343
+ } catch {
344
+ return;
345
+ }
346
+ if (Array.isArray(message)) {
347
+ process.stderr.write('mcp-proxy: refusing a JSON-RPC batch from the server\n');
348
+ return;
349
+ }
350
+ // MCP 2026-07-28 forbids server-to-client requests; older servers still
351
+ // send them. Fail closed for both eras.
352
+ if (message.method && message.id !== undefined && SERVER_REQUESTS.has(message.method)) {
353
+ process.stderr.write(`mcp-proxy: refused server request ${message.method}\n`);
354
+ write(this.child.stdin, {
355
+ jsonrpc: '2.0',
356
+ id: message.id,
357
+ error: { code: -32601, message: `attesso refused server request ${message.method}` },
358
+ });
359
+ return;
360
+ }
361
+ if (message.method === 'notifications/tools/list_changed') {
362
+ this.stale = true;
363
+ this.tools.clear();
364
+ write(process.stdout, message);
365
+ return;
366
+ }
367
+ if (message.id !== undefined) {
368
+ const id = keyOf(message.id);
369
+ if (this.internal.has(id)) {
370
+ const request = this.internal.get(id);
371
+ this.internal.delete(id);
372
+ clearTimeout(this.probeTimer);
373
+ this.probeTimer = null;
374
+ this.probing = false;
375
+ this.handleServer(message, request.method, false);
376
+ return;
377
+ }
378
+ const request = this.pending.get(id);
379
+ if (request?.method === 'initialize' || request?.method === 'server/discover') {
380
+ this.pending.delete(id);
381
+ this.handleServer(message, request.method, true);
382
+ return;
383
+ }
384
+ if (request?.method === 'tools/list') {
385
+ this.pending.delete(id);
386
+ this.handleList(message);
387
+ return;
388
+ }
389
+ if (request && SURFACES[request.method]) {
390
+ this.pending.delete(id);
391
+ this.handleSurface(message, request.method);
392
+ return;
393
+ }
394
+ if (request) this.pending.delete(id);
395
+ }
396
+ write(process.stdout, message);
397
+ }
398
+
399
+ flushHeld() {
400
+ const held = this.held;
401
+ this.held = [];
402
+ for (const message of held) this.handleClient(message);
403
+ }
404
+
405
+ handleServer(message, method, forward) {
406
+ const era = method === 'server/discover' ? 'modern' : 'legacy';
407
+ if (message.error) {
408
+ if (forward) {
409
+ write(process.stdout, message);
410
+ } else {
411
+ this.failHeld('SERVER_PIN_REQUIRED', message.error.message ?? 'discovery failed');
412
+ }
413
+ return;
414
+ }
415
+ let surface;
416
+ try {
417
+ surface = serverSurface(message.result, era);
418
+ } catch (error) {
419
+ if (forward) write(process.stdout, errorResponse(message.id, 'SERVER_RESPONSE_INVALID', error.message));
420
+ else this.failHeld('SERVER_RESPONSE_INVALID', error.message);
421
+ return;
422
+ }
423
+ if (this.toolLock?.server_digest && manifestDigest(surface) !== this.toolLock.server_digest) {
424
+ const detail = 'server identity, instructions or capabilities changed; pin the same protocol era as the client';
425
+ if (forward) write(process.stdout, errorResponse(message.id, 'SERVER_CHANGED', detail));
426
+ else this.failHeld('SERVER_CHANGED', detail);
427
+ return;
428
+ }
429
+ this.server = surface;
430
+ this.serverEra = era;
431
+ this.probing = false;
432
+ if (this.probeTimer) clearTimeout(this.probeTimer);
433
+ this.probeTimer = null;
434
+ if (forward) write(process.stdout, message);
435
+ this.flushHeld();
436
+ }
437
+
438
+ handleSurface(message, method) {
439
+ if (message.error) {
440
+ write(process.stdout, message);
441
+ return;
442
+ }
443
+ const surface = SURFACES[method];
444
+ try {
445
+ const values = listOf(message.result, surface.key, method);
446
+ if (this.toolLock?.server_digest) {
447
+ const field = `${surface.field}_digest`;
448
+ if (this.toolLock[field] === undefined) {
449
+ throw new Error(`${surface.field.toUpperCase()}_NOT_PINNED: ${method} was not pinned`);
450
+ }
451
+ if (manifestDigest(values) !== this.toolLock[field]) {
452
+ throw new Error(`${surface.field.toUpperCase()}_CHANGED: ${method} differs from its pin`);
453
+ }
454
+ }
455
+ } catch (error) {
456
+ const code = /^[A-Z_]+:/.test(error.message) ? error.message.split(':')[0] : 'SURFACE_INVALID';
457
+ const detail = error.message.startsWith(`${code}:`) ? error.message.slice(code.length + 1).trim() : error.message;
458
+ write(process.stdout, errorResponse(message.id, code, detail));
459
+ return;
460
+ }
461
+ write(process.stdout, message);
462
+ }
463
+
464
+ handleList(message) {
465
+ if (message.error) {
466
+ write(process.stdout, message);
467
+ return;
468
+ }
469
+ let tools;
470
+ try {
471
+ if (this.toolLock?.server_digest && !this.server) {
472
+ throw new Error('SERVER_PIN_REQUIRED: discover and pin the server before listing tools');
473
+ }
474
+ tools = listOf(message.result);
475
+ if (!this.toolLock) throw new Error('TOOL_PIN_MISSING: run attesso tools pin before starting the proxy');
476
+ const compared = comparePin(this.toolLock, { tools }, this.toolsConfig);
477
+ if (compared.digestChanged || compared.changed.length > 0 || compared.added.length > 0 || compared.removed.length > 0) {
478
+ throw new Error(
479
+ `TOOL_CHANGED: ${[...compared.changed, ...compared.added, ...compared.removed].join(', ') || 'tools digest changed'}`,
480
+ );
481
+ }
482
+ this.tools = new Map(tools.map((tool) => [tool.name, tool]));
483
+ this.stale = false;
484
+ } catch (error) {
485
+ const code = /^TOOL_[A-Z_]+:/.test(error.message) ? error.message.split(':')[0] : 'TOOL_LIST_INVALID';
486
+ const detail = error.message.startsWith(`${code}:`) ? error.message.slice(code.length + 1).trim() : error.message;
487
+ write(process.stdout, errorResponse(message.id, code, detail));
488
+ return;
489
+ }
490
+ write(process.stdout, message);
491
+ }
492
+ }
493
+
494
+ class Rpc {
495
+ constructor(child) {
496
+ this.child = child;
497
+ this.nextId = 1;
498
+ this.pending = new Map();
499
+ this.closed = false;
500
+ this.lines = createInterface({ input: child.stdout });
501
+ this.lines.on('line', (line) => {
502
+ let message;
503
+ try {
504
+ message = JSON.parse(line);
505
+ } catch {
506
+ return;
507
+ }
508
+ if (message.id !== undefined && this.pending.has(keyOf(message.id))) {
509
+ const pending = this.pending.get(keyOf(message.id));
510
+ this.pending.delete(keyOf(message.id));
511
+ clearTimeout(pending.timer);
512
+ pending.resolve(message);
513
+ }
514
+ });
515
+ child.on('error', (error) => this.rejectAll(error));
516
+ child.on('exit', (code, signal) => {
517
+ if (!this.closed) this.rejectAll(new Error(`upstream exited (${signal ?? code})`));
518
+ });
519
+ }
520
+
521
+ request(method, params = {}, { timeout = REQUEST_TIMEOUT_MS } = {}) {
522
+ const id = this.nextId;
523
+ this.nextId += 1;
524
+ return new Promise((resolve, reject) => {
525
+ const timer = setTimeout(() => {
526
+ this.pending.delete(keyOf(id));
527
+ reject(new Error(`upstream did not answer ${method}`));
528
+ }, timeout);
529
+ this.pending.set(keyOf(id), { resolve, timer });
530
+ write(this.child.stdin, { jsonrpc: '2.0', id, method, params });
531
+ });
532
+ }
533
+
534
+ notify(method, params = {}) {
535
+ write(this.child.stdin, { jsonrpc: '2.0', method, params });
536
+ }
537
+
538
+ rejectAll(error) {
539
+ for (const pending of this.pending.values()) {
540
+ clearTimeout(pending.timer);
541
+ pending.resolve({ error: { message: error.message } });
542
+ }
543
+ this.pending.clear();
544
+ }
545
+
546
+ close() {
547
+ this.closed = true;
548
+ for (const pending of this.pending.values()) clearTimeout(pending.timer);
549
+ this.pending.clear();
550
+ this.lines.close();
551
+ try {
552
+ this.child.stdin.end();
553
+ } catch {
554
+ // Already closed.
555
+ }
556
+ this.child.kill();
557
+ }
558
+ }
559
+
560
+ async function printManifest(command, args, era) {
561
+ const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
562
+ const rpc = new Rpc(child);
563
+ try {
564
+ const { era: detected, result } = await probeServer(rpc, era);
565
+ if (detected === 'legacy') rpc.notify('notifications/initialized');
566
+ const server = serverSurface(result, detected);
567
+ const capabilities = server.capabilities;
568
+ const tools = await listAll(rpc, 'tools/list', 'tools', detected);
569
+ const manifest = { server, tools };
570
+ if (capabilities.prompts) manifest.prompts = await listAll(rpc, 'prompts/list', 'prompts', detected);
571
+ if (capabilities.resources) {
572
+ manifest.resources = await listAll(rpc, 'resources/list', 'resources', detected);
573
+ const templates = await listAll(rpc, 'resources/templates/list', 'resourceTemplates', detected, false);
574
+ if (templates) manifest.resourceTemplates = templates;
575
+ }
576
+ process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
577
+ } finally {
578
+ rpc.close();
579
+ }
580
+ }
581
+
582
+ function start(command, args) {
583
+ return spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
584
+ }
585
+
586
+ async function main() {
587
+ const options = parseArgs(process.argv.slice(2));
588
+ if (options.help) {
589
+ process.stdout.write(`${usage()}\n`);
590
+ return;
591
+ }
592
+ if (!options.command) throw new Error(`a server command is required\n${usage()}`);
593
+ if (options.print) {
594
+ await printManifest(options.command, options.args, options.era);
595
+ return;
596
+ }
597
+ const child = start(options.command, options.args);
598
+ const proxy = new Proxy(child, options);
599
+ proxy.attach();
600
+ }
601
+
602
+ main().catch((error) => {
603
+ process.stderr.write(`mcp-proxy: ${error.message}\n`);
604
+ process.exitCode = 1;
605
+ });
package/src/merkle.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // RFC 6962 Merkle tree over the canonical entry bytes. The chain already proves
2
+ // order; this proves one entry without shipping the whole log.
3
+ import { createHash } from 'node:crypto';
4
+ import { canonical } from './canonical.mjs';
5
+
6
+ const LEAF = Buffer.from([0]);
7
+ const NODE = Buffer.from([1]);
8
+
9
+ export function leafHash(entry) {
10
+ return createHash('sha256').update(LEAF).update(canonical(entry)).digest();
11
+ }
12
+
13
+ function nodeHash(left, right) {
14
+ return createHash('sha256').update(NODE).update(left).update(right).digest();
15
+ }
16
+
17
+ function levelsOf(leaves) {
18
+ if (leaves.length === 0) return [];
19
+ const levels = [leaves.map((leaf) => Buffer.from(leaf))];
20
+ while (levels[levels.length - 1].length > 1) {
21
+ const below = levels[levels.length - 1];
22
+ const next = [];
23
+ for (let index = 0; index < below.length; index += 2) {
24
+ next.push(index + 1 < below.length ? nodeHash(below[index], below[index + 1]) : below[index]);
25
+ }
26
+ levels.push(next);
27
+ }
28
+ return levels;
29
+ }
30
+
31
+ export function merkleRoot(leaves) {
32
+ if (leaves.length === 0) return createHash('sha256').digest();
33
+ const levels = levelsOf(leaves);
34
+ return levels[levels.length - 1][0];
35
+ }
36
+
37
+ export function inclusionPath(leaves, index) {
38
+ if (!Array.isArray(leaves) || !Number.isInteger(index) || index < 0 || index >= leaves.length) {
39
+ throw new RangeError('inclusionPath needs an index inside the tree');
40
+ }
41
+ const levels = levelsOf(leaves);
42
+ const path = [];
43
+ let at = index;
44
+ for (let level = 0; level < levels.length - 1; level += 1) {
45
+ const row = levels[level];
46
+ const sibling = at % 2 === 0 ? at + 1 : at - 1;
47
+ if (sibling < row.length) path.push(row[sibling]);
48
+ at = Math.floor(at / 2);
49
+ }
50
+ return path;
51
+ }
52
+
53
+ export function verifyInclusion(leaf, index, size, path, root) {
54
+ if (!Number.isInteger(index) || index < 0 || index >= size || !Array.isArray(path)) return false;
55
+ if (!Buffer.isBuffer(leaf) || leaf.length !== 32) return false;
56
+ if (!Buffer.isBuffer(root) || root.length !== 32) return false;
57
+ if (!path.every((hash) => Buffer.isBuffer(hash) && hash.length === 32)) return false;
58
+ let fn = index;
59
+ let sn = size - 1;
60
+ let hash = leaf;
61
+ for (const sibling of path) {
62
+ if (sn === 0) return false;
63
+ if (fn % 2 === 1 || fn === sn) {
64
+ hash = nodeHash(sibling, hash);
65
+ while (fn % 2 === 0 && fn !== 0) {
66
+ fn = Math.floor(fn / 2);
67
+ sn = Math.floor(sn / 2);
68
+ }
69
+ } else {
70
+ hash = nodeHash(hash, sibling);
71
+ }
72
+ fn = Math.floor(fn / 2);
73
+ sn = Math.floor(sn / 2);
74
+ }
75
+ return sn === 0 && hash.equals(root);
76
+ }
77
+
78
+ export function prefixRoot(entries, size) {
79
+ if (!Array.isArray(entries) || !Number.isInteger(size) || size < 0 || size > entries.length) {
80
+ throw new RangeError('prefixRoot needs a size inside the log');
81
+ }
82
+ return merkleRoot(entries.slice(0, size).map(leafHash));
83
+ }