@solongate/proxy 0.83.67 → 0.83.69

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.
@@ -36,12 +36,20 @@
36
36
  * turn and must never be the reason one fails: it records something ABOUT the
37
37
  * work, and the work matters more.
38
38
  */
39
- import { readFileSync, existsSync } from 'node:fs';
39
+ import { readFileSync, existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
40
40
  import { resolve } from 'node:path';
41
41
  import { homedir } from 'node:os';
42
+ import { createRequire } from 'node:module';
43
+
44
+ const require = createRequire(import.meta.url);
42
45
 
43
46
  // Bump on every change to this file, alongside the other hooks.
44
- const HOOK_VERSION = 3;
47
+ //
48
+ // 4 adds the token report: what a turn COST, sent beside the record of what was
49
+ // said. It rides on this hook because the events are the same events — a turn
50
+ // ends once, and a second registration for the same moment is a second thing to
51
+ // install and a second thing that can be missing.
52
+ const HOOK_VERSION = 4;
45
53
 
46
54
  const AGENT_ID = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
47
55
 
@@ -180,6 +188,371 @@ async function sendAntigravity(common, stop) {
180
188
  if (String(reply).trim()) await send(sessionId, 'reply', reply, name);
181
189
  }
182
190
 
191
+ // ── what a turn COST ───────────────────────────────────────────────────────
192
+ //
193
+ // Inlined rather than imported from a sibling, and that is not tidiness. Hooks
194
+ // are installed as individual files by name and refreshed over the wire the
195
+ // same way; a conversation.mjs that imports ./tokens.mjs would MODULE_NOT_FOUND
196
+ // on any machine whose installer predates the second file — and an import that
197
+ // throws takes the whole hook with it, losing the conversation record as well
198
+ // as the figure. One file cannot be half-installed.
199
+ // How much of a transcript to read. A turn is a few kilobytes; two megabytes is
200
+ // a long turn with large tool results in it, and reading a 50MB transcript on
201
+ // every Stop is the cost this bound exists to refuse.
202
+ const TAIL_BYTES = 2 * 1024 * 1024;
203
+
204
+ // One request per Stop carries at most this many turns.
205
+ //
206
+ // Forty rather than everything in the tail. The server keys on the turn id and
207
+ // ignores a repeat, so re-sending is free of consequence but not of bytes —
208
+ // forty covers a missed Stop or two while keeping the request small, and the
209
+ // ones before that were sent when they happened.
210
+ const MAX_TURNS = 40;
211
+
212
+ /** Read the last bytes of a file, dropping the first partial line. */
213
+ function tailLines(path, bytes = TAIL_BYTES) {
214
+ let fd;
215
+ try {
216
+ const size = statSync(path).size;
217
+ const from = Math.max(0, size - bytes);
218
+ const len = size - from;
219
+ if (len <= 0) return [];
220
+ const buf = Buffer.allocUnsafe(len);
221
+ fd = openSync(path, 'r');
222
+ readSync(fd, buf, 0, len, from);
223
+ const text = buf.toString('utf-8');
224
+ const lines = text.split('\n');
225
+ // A read that started mid-file starts mid-line. That first fragment is not
226
+ // JSON and would be the one line that throws on every single turn.
227
+ if (from > 0) lines.shift();
228
+ return lines;
229
+ } catch {
230
+ return [];
231
+ } finally {
232
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
233
+ }
234
+ }
235
+
236
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
237
+ const stamp = (v) => {
238
+ const t = Date.parse(v || '');
239
+ return Number.isFinite(t) ? t : Date.now();
240
+ };
241
+
242
+ // ── Claude Code ────────────────────────────────────────────────────────────
243
+
244
+ /**
245
+ * One row per API REQUEST, deduped.
246
+ *
247
+ * The dedupe is the whole correctness of this reader. Usage is reported per API
248
+ * request but the transcript writes one record per content block, so the same
249
+ * usage object appears up to four times with the same requestId. Summing
250
+ * records rather than requests overcounts by roughly half.
251
+ */
252
+ function readClaudeCode(transcriptPath) {
253
+ const seen = new Set();
254
+ const out = [];
255
+ for (const line of tailLines(transcriptPath)) {
256
+ if (!line || line[0] !== '{') continue;
257
+ let r;
258
+ try { r = JSON.parse(line); } catch { continue; }
259
+ if (r.type !== 'assistant') continue;
260
+ const u = r.message && r.message.usage;
261
+ if (!u) continue;
262
+
263
+ // requestId is the API request's identity. A few synthetic records carry
264
+ // none — API-error placeholders with all-zero usage — so the message id is
265
+ // the fallback rather than letting them collide on null.
266
+ const key = r.requestId || (r.message && r.message.id) || '';
267
+ if (!key || seen.has(key)) continue;
268
+ seen.add(key);
269
+
270
+ const input = num(u.input_tokens);
271
+ const cacheWrite = num(u.cache_creation_input_tokens);
272
+ const cacheRead = num(u.cache_read_input_tokens);
273
+ const output = num(u.output_tokens);
274
+ const total = input + cacheWrite + cacheRead + output;
275
+ if (total <= 0) continue;
276
+
277
+ out.push({
278
+ turn_key: key,
279
+ input, output, cache_read: cacheRead, cache_write: cacheWrite,
280
+ reasoning: num(u.output_tokens_details && u.output_tokens_details.thinking_tokens),
281
+ // The three prompt buckets are DISJOINT — proven on real data, where
282
+ // cache_read[n+1] == cache_read[n] + cache_creation[n] holds to the
283
+ // token — so the sum is the whole prompt plus the completion.
284
+ total,
285
+ at: stamp(r.timestamp),
286
+ });
287
+ }
288
+ return out;
289
+ }
290
+
291
+ // ── Codex ──────────────────────────────────────────────────────────────────
292
+
293
+ /**
294
+ * One row per model request, from the token_count events.
295
+ *
296
+ * `last_token_usage` rather than `total_token_usage`: the second is cumulative
297
+ * for the session, and sending cumulative values to a store that adds rows
298
+ * would count the whole session again on every turn.
299
+ */
300
+ function readCodex(transcriptPath, sessionId) {
301
+ const out = [];
302
+ for (const line of tailLines(transcriptPath)) {
303
+ if (!line || line[0] !== '{') continue;
304
+ let r;
305
+ try { r = JSON.parse(line); } catch { continue; }
306
+ if (r.type !== 'event_msg') continue;
307
+ const p = r.payload;
308
+ if (!p || p.type !== 'token_count') continue;
309
+
310
+ const last = p.info && p.info.last_token_usage;
311
+ if (!last) continue;
312
+
313
+ const total = num(last.total_tokens) || (num(last.input_tokens) + num(last.output_tokens));
314
+ if (total <= 0) continue;
315
+
316
+ // The record's OWN ordinal, never a count of what this read happened to
317
+ // see. Counting within the tail renumbers every event the moment the file
318
+ // grows past the window — the same turn gets a new key, and a new key is a
319
+ // second row. Older rollouts carry no ordinal, so those fall back to the
320
+ // timestamp paired with the running total, which together do not repeat.
321
+ const id = typeof r.ordinal === 'number'
322
+ ? 'o:' + r.ordinal
323
+ : 't:' + (r.timestamp || '') + ':' + num((p.info.total_token_usage || {}).total_tokens);
324
+
325
+ out.push({
326
+ // Prefixed with the session, because an ordinal is only unique WITHIN a
327
+ // rollout. The server's key does not include the session — resuming a
328
+ // conversation would otherwise double every turn it copies forward — so
329
+ // a positional id has to carry its own scope.
330
+ turn_key: (sessionId || 'codex') + ':' + id,
331
+ // input_tokens INCLUDES cached here, and output INCLUDES reasoning —
332
+ // the opposite of Claude Code's disjoint buckets. The total is the
333
+ // provider's own, so the difference never has to be reconciled.
334
+ input: num(last.input_tokens),
335
+ output: num(last.output_tokens),
336
+ cache_read: num(last.cached_input_tokens),
337
+ cache_write: num(last.cache_write_input_tokens),
338
+ reasoning: num(last.reasoning_output_tokens),
339
+ total,
340
+ at: stamp(r.timestamp),
341
+ });
342
+ }
343
+ return out;
344
+ }
345
+
346
+ // ── Antigravity ────────────────────────────────────────────────────────────
347
+
348
+ /** Read a protobuf varint. Returns [value, nextIndex]. */
349
+ function varint(buf, i) {
350
+ let result = 0n;
351
+ let shift = 0n;
352
+ while (i < buf.length) {
353
+ const b = buf[i++];
354
+ result |= BigInt(b & 0x7f) << shift;
355
+ if (!(b & 0x80)) return [result, i];
356
+ shift += 7n;
357
+ if (shift > 70n) break;
358
+ }
359
+ return [result, i];
360
+ }
361
+
362
+ /**
363
+ * Pull the varints at one dotted protobuf path out of a blob.
364
+ *
365
+ * A tiny hand-rolled walker rather than a schema, because there is no schema on
366
+ * disk: Antigravity stores an opaque message and the field numbers below were
367
+ * established by decoding real generations and testing an identity that held
368
+ * every time.
369
+ */
370
+ function pbFields(buf, want) {
371
+ const found = {};
372
+ const walk = (b, path, depth) => {
373
+ let i = 0;
374
+ while (i < b.length) {
375
+ let key;
376
+ [key, i] = varint(b, i);
377
+ const field = Number(key >> 3n);
378
+ const wire = Number(key & 7n);
379
+ const here = path ? path + '.' + field : String(field);
380
+ if (wire === 0) {
381
+ let v;
382
+ [v, i] = varint(b, i);
383
+ if (want.has(here) && found[here] === undefined) found[here] = Number(v);
384
+ } else if (wire === 2) {
385
+ let len;
386
+ [len, i] = varint(b, i);
387
+ const end = i + Number(len);
388
+ if (end > b.length) return;
389
+ if (depth < 6) walk(b.subarray(i, end), here, depth + 1);
390
+ i = end;
391
+ } else if (wire === 5) { i += 4; }
392
+ else if (wire === 1) { i += 8; }
393
+ else { return; }
394
+ }
395
+ };
396
+ try { walk(buf, '', 0); } catch { /* a blob we cannot read is a blob we skip */ }
397
+ return found;
398
+ }
399
+
400
+ // The field numbers, and what each one is. See the file header for how they
401
+ // were established — this is the one client whose mapping is not published, so
402
+ // it is the one where the evidence matters most.
403
+ const AGY = {
404
+ IN_UNCACHED: '1.4.2',
405
+ IN_CACHED: '1.4.5',
406
+ OUT: '1.4.3',
407
+ THINKING: '1.4.9',
408
+ // The generation's own clock: a google.protobuf.Timestamp, whose field 1 is
409
+ // seconds. Without it every generation in the tail would be stamped with the
410
+ // moment it was READ, which puts a week of work on today's bar.
411
+ AT_SECONDS: '1.9.4.1',
412
+ };
413
+ const AGY_WANT = new Set(Object.values(AGY));
414
+
415
+ function readAntigravity(conversationId) {
416
+ if (!conversationId) return [];
417
+ const db = resolve(homedir(), '.gemini', 'antigravity-cli', 'conversations', conversationId + '.db');
418
+ if (!existsSync(db)) return [];
419
+
420
+ let DatabaseSync;
421
+ try {
422
+ // Node 22+. On an older runtime there is no way to read this without a
423
+ // native dependency, and adding one to a security tool to report a number
424
+ // is the wrong trade — the client simply reports nothing.
425
+ ({ DatabaseSync } = require('node:sqlite'));
426
+ } catch {
427
+ return [];
428
+ }
429
+
430
+ let handle;
431
+ try {
432
+ // Read-only: this is the client's own live database and nothing here has
433
+ // any business writing to it.
434
+ handle = new DatabaseSync(db, { readOnly: true });
435
+ const rows = handle.prepare('SELECT idx, data FROM gen_metadata ORDER BY idx').all();
436
+ const out = [];
437
+ for (const row of rows) {
438
+ const blob = row.data;
439
+ if (!blob || !blob.length) continue;
440
+ const f = pbFields(Buffer.from(blob), AGY_WANT);
441
+
442
+ const input = (f[AGY.IN_UNCACHED] || 0) + (f[AGY.IN_CACHED] || 0);
443
+ const output = f[AGY.OUT] || 0;
444
+ if (input + output <= 0) continue;
445
+
446
+ out.push({
447
+ // The generation index, prefixed with the conversation it counts
448
+ // within: an index is only unique inside one database, and the
449
+ // server's key does not include the session.
450
+ turn_key: conversationId + ':gen:' + row.idx,
451
+ input,
452
+ output,
453
+ // The cached half of the prompt, which Antigravity reports separately
454
+ // and is a SUBSET of the input above.
455
+ cache_read: f[AGY.IN_CACHED] || 0,
456
+ cache_write: 0,
457
+ reasoning: f[AGY.THINKING] || 0,
458
+ total: input + output,
459
+ at: (f[AGY.AT_SECONDS] || 0) * 1000,
460
+ });
461
+ }
462
+ return out;
463
+ } catch {
464
+ return [];
465
+ } finally {
466
+ if (handle) { try { handle.close(); } catch { /* already closed */ } }
467
+ }
468
+ }
469
+
470
+
471
+ // ── sending ────────────────────────────────────────────────────────────────
472
+
473
+ /**
474
+ * collectTokens works out which client this payload is from and reads its
475
+ * numbers. Returns [] when the client cannot report, which is a normal answer
476
+ * and never a zero.
477
+ */
478
+ function collectTokens(data, source) {
479
+ const transcript = data.transcript_path || data.transcriptPath ||
480
+ (data.common && (data.common.transcriptPath || data.common.transcript_path)) || '';
481
+
482
+ if (source === 'antigravity') {
483
+ const id = (data.common && (data.common.conversationId || data.common.conversation_id)) || '';
484
+ return readAntigravity(id);
485
+ }
486
+ if (!transcript || !existsSync(transcript)) return [];
487
+ const sessionId = data.session_id || data.sessionId || '';
488
+ if (source === 'codex') return readCodex(transcript, sessionId);
489
+ // Claude Code needs no prefix: requestId is provider-issued and unique
490
+ // across every transcript, which is exactly what makes a resumed session
491
+ // report the same turn once rather than twice.
492
+ return readClaudeCode(transcript);
493
+ }
494
+
495
+ /**
496
+ * sendTokens posts a batch. One request per Stop whatever the count.
497
+ *
498
+ * Silent on every failure, including a missing credential: a machine that is
499
+ * not logged in has nowhere to report and nothing to say about it.
500
+ */
501
+ async function sendTokens({ apiUrl, apiKey, sessionId, agentId, agentName, source, turns }) {
502
+ if (!apiKey || !turns || !turns.length) return;
503
+ const body = turns.slice(-MAX_TURNS).map((t) => ({
504
+ ...t,
505
+ session_id: sessionId || '',
506
+ agent_id: agentId || '',
507
+ agent_name: agentName || '',
508
+ source: source || '',
509
+ }));
510
+ try {
511
+ await fetch(`${apiUrl.replace(/\/$/, '')}/api/v1/token-usage`, {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
514
+ body: JSON.stringify({ turns: body }),
515
+ });
516
+ } catch { /* silent: a figure that could not be sent must not fail a turn */ }
517
+ }
518
+
519
+ // reportTokens sends what the turn cost, beside the record of what was said.
520
+ //
521
+ // It rides on THIS hook rather than on one of its own because the events are
522
+ // the same events: a turn ends once, and registering a second hook for the same
523
+ // moment is a second thing to install, a second thing to keep current, and a
524
+ // second thing that can be missing on a machine where the first is fine.
525
+ //
526
+ // Everything it needs is already on the payload. It is awaited so a short-lived
527
+ // hook process does not exit before the request goes out, and it can only
528
+ // fail silently — see tokens.mjs.
529
+ async function reportTokens(data, source, sessionId, agentName) {
530
+ try {
531
+ const turns = collectTokens(data, source);
532
+ if (!turns.length) return;
533
+ const cfg = loadGlobalCloudConfig();
534
+ await sendTokens({
535
+ apiUrl: cfg.apiUrl || 'https://api.solongate.com',
536
+ apiKey: cfg.apiKey || '',
537
+ sessionId,
538
+ agentId: AGENT_ID,
539
+ agentName: agentName || '',
540
+ source,
541
+ turns,
542
+ });
543
+ } catch { /* silent: spend is a note about the work, not the work */ }
544
+ }
545
+
546
+ // tokenSourceOf names the client from the payload's SHAPE rather than from the
547
+ // argv the hook was installed with, for the reason the Antigravity branch below
548
+ // gives: a payload is a fact and an argument is a claim.
549
+ function tokenSourceOf(data) {
550
+ if (data.common || (data.hookArgs && data.hookArgs.common)) return 'antigravity';
551
+ const path = String(data.transcript_path || data.transcriptPath || '');
552
+ if (path.includes('/.codex/') || path.includes('\\.codex\\')) return 'codex';
553
+ return 'claude-code';
554
+ }
555
+
183
556
  (async () => {
184
557
  // Whatever happens below, this hook allows the turn. It has no opinion about
185
558
  // whether the work should proceed; it is a record of it.
@@ -206,6 +579,30 @@ async function sendAntigravity(common, stop) {
206
579
  // chat.message hook for the prompt, streamed text parts for the reply), so
207
580
  // the plugin does the deciding and this file does the sending.
208
581
  if (data.opencode === true) {
582
+ // Its spend arrives already counted, in process, rather than being read
583
+ // back off a transcript the way the other three are.
584
+ if (data.tokens === true) {
585
+ const cfg = loadGlobalCloudConfig();
586
+ await sendTokens({
587
+ apiUrl: cfg.apiUrl || 'https://api.solongate.com',
588
+ apiKey: cfg.apiKey || '',
589
+ sessionId: data.session_id || '',
590
+ agentId: AGENT_ID,
591
+ agentName: 'OpenCode',
592
+ source: 'opencode',
593
+ turns: [{
594
+ turn_key: data.turn_key || '',
595
+ input: data.input || 0,
596
+ output: data.output || 0,
597
+ cache_read: data.cache_read || 0,
598
+ cache_write: data.cache_write || 0,
599
+ reasoning: data.reasoning || 0,
600
+ total: data.total || 0,
601
+ at: data.at || 0,
602
+ }],
603
+ });
604
+ return;
605
+ }
209
606
  await send(data.session_id || '', data.role === 'reply' ? 'reply' : 'prompt',
210
607
  data.body || '', 'OpenCode');
211
608
  return;
@@ -216,6 +613,14 @@ async function sendAntigravity(common, stop) {
216
613
  (data.hookArgs && data.hookArgs.stopHookArgs) || null;
217
614
  if (agCommon && (agStop || agCommon.lastUserInput)) {
218
615
  await sendAntigravity(agCommon, agStop);
616
+ // Only at the END of an exchange: this hook fires on several events and the
617
+ // generations are already on disk, so reading them on every one would be
618
+ // the same rows read many times for nothing.
619
+ if (agStop) {
620
+ await reportTokens(data, 'antigravity',
621
+ agCommon.conversationId || agCommon.conversation_id || '',
622
+ agCommon.agentName || agCommon.agent_name || 'Antigravity');
623
+ }
219
624
  return;
220
625
  }
221
626
 
@@ -235,10 +640,16 @@ async function sendAntigravity(common, stop) {
235
640
  } else {
236
641
  return;
237
642
  }
238
- if (!String(body).trim()) return;
239
-
240
643
  const sessionId = data.session_id || data.sessionId || data.conversation_id || '';
241
644
  if (!sessionId) return;
242
645
 
646
+ // The cost is read at Stop, whether or not there were words to record. A turn
647
+ // that ended with a tool call and no prose still cost something, and gating
648
+ // the figure on the transcript would lose exactly the turns that ran longest.
649
+ if (role === 'reply') {
650
+ await reportTokens(data, tokenSourceOf(data), sessionId, data.agent_name || '');
651
+ }
652
+
653
+ if (!String(body).trim()) return;
243
654
  await send(sessionId, role, body, data.agent_name || '');
244
655
  })();
@@ -202,6 +202,11 @@ export const SolonGate = async ({ directory, worktree } = {}) => {
202
202
 
203
203
  // A text part of a message. `text` is the WHOLE text so far rather than a
204
204
  // delta, so the last one wins and nothing is concatenated.
205
+ // numberOr keeps a missing or nonsense count out of a spend figure. A
206
+ // field this SDK version does not declare — info.tokens.total is one —
207
+ // arrives undefined rather than zero, and Number(undefined) is NaN.
208
+ const numberOr = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
209
+
205
210
  if (event.type === 'message.part.updated') {
206
211
  const part = event.properties?.part;
207
212
  if (part?.type === 'text' && part.messageID && typeof part.text === 'string') {
@@ -216,6 +221,42 @@ export const SolonGate = async ({ directory, worktree } = {}) => {
216
221
  if (event.type === 'message.updated') {
217
222
  const info = event.properties?.info;
218
223
  if (!info || info.role !== 'assistant' || !info.time?.completed) return;
224
+
225
+ // WHAT THE MESSAGE COST, which OpenCode is alone among the four clients
226
+ // in handing over directly: the other three have to be read back off a
227
+ // transcript or a database, and this arrives in process, already
228
+ // counted by the provider. It was in `info` all along and thrown away.
229
+ //
230
+ // Sent whether or not there was text to record. A message that produced
231
+ // only tool calls still cost something, and gating the figure on the
232
+ // words would lose exactly the turns that ran longest.
233
+ const t = info.tokens;
234
+ if (t) {
235
+ const input = numberOr(t.input);
236
+ const output = numberOr(t.output);
237
+ const reasoning = numberOr(t.reasoning);
238
+ const cacheRead = numberOr(t.cache?.read);
239
+ const cacheWrite = numberOr(t.cache?.write);
240
+ // OpenCode's own total when it sends one — it counts reasoning
241
+ // ALONGSIDE output rather than inside it, which is its convention and
242
+ // not ours to reconcile.
243
+ const total = numberOr(t.total) || input + output + reasoning + cacheRead + cacheWrite;
244
+ if (total > 0) {
245
+ void runHook(CONVERSATION, {
246
+ opencode: true,
247
+ tokens: true,
248
+ session_id: info.sessionID || '',
249
+ turn_key: info.id || '',
250
+ agent_name: 'OpenCode',
251
+ input, output, reasoning,
252
+ cache_read: cacheRead,
253
+ cache_write: cacheWrite,
254
+ total,
255
+ at: info.time?.completed ? Number(info.time.completed) : 0,
256
+ }, GUARD_TIMEOUT_MS);
257
+ }
258
+ }
259
+
219
260
  const held = pending.get(info.id);
220
261
  pending.delete(info.id);
221
262
  if (!held || !held.text.trim()) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.83.67",
3
+ "version": "0.83.69",
4
4
  "description": "AI tool security proxy: protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. No code changes required.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,12 +61,12 @@
61
61
  "node": ">=20.0.0"
62
62
  },
63
63
  "optionalDependencies": {
64
- "@solongate/guard-linux-x64": "0.83.67",
65
- "@solongate/guard-linux-arm64": "0.83.67",
66
- "@solongate/guard-darwin-x64": "0.83.67",
67
- "@solongate/guard-darwin-arm64": "0.83.67",
68
- "@solongate/guard-win32-x64": "0.83.67",
69
- "@solongate/guard-win32-arm64": "0.83.67"
64
+ "@solongate/guard-linux-x64": "0.83.69",
65
+ "@solongate/guard-linux-arm64": "0.83.69",
66
+ "@solongate/guard-darwin-x64": "0.83.69",
67
+ "@solongate/guard-darwin-arm64": "0.83.69",
68
+ "@solongate/guard-win32-x64": "0.83.69",
69
+ "@solongate/guard-win32-arm64": "0.83.69"
70
70
  },
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "^1.26.0",