mcpspan 0.2.0 → 0.4.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.
package/README.md CHANGED
@@ -209,8 +209,10 @@ mode, not in debug.
209
209
 
210
210
  What is collected: the tool name, how long it took, whether it succeeded, the
211
211
  error type and a truncated message when it did not, how large the answer was
212
- in bytes (its size only, never its content), which client called, and the SDK
213
- version. For a resource or a prompt, the same, under the name it was
212
+ in bytes (its size only, never its content), whether it repeated the previous
213
+ call's arguments to the same tool in its session (compared in your process;
214
+ the arguments, or any digest of them, never leave it), which client called,
215
+ and the SDK version. For a resource or a prompt, the same, under the name it was
214
216
  registered with: never the address a client read, only its template or, for
215
217
  an address the server does not have, its scheme.
216
218
 
package/dist/index.cjs CHANGED
@@ -146,7 +146,7 @@ var EventQueue = class {
146
146
  * Single source of truth: `package.json` follows this constant, not the other
147
147
  * way round, and a unit test fails if the two ever drift apart.
148
148
  */
149
- const SDK_VERSION = "0.2.0";
149
+ const SDK_VERSION = "0.4.0";
150
150
  //#endregion
151
151
  //#region src/transport.ts
152
152
  /** Path the ingest API accepts batches on, appended to the configured endpoint. */
@@ -498,6 +498,88 @@ function currentCall() {
498
498
  return current;
499
499
  }
500
500
  //#endregion
501
+ //#region src/definition.ts
502
+ /**
503
+ * Tool definitions as the server lists them, fingerprinted (contract, 3.8).
504
+ *
505
+ * Rewording a description can change how agents use a tool more than a change
506
+ * to its code. The fingerprint is taken from the answer to `tools/list`, what
507
+ * an agent actually read, and sent with every call to the tool, so the
508
+ * dashboard can mark when a definition changed. Kept for the process: one
509
+ * process reports to one server, and a listing on one connection describes
510
+ * the same tools as on any other.
511
+ */
512
+ const listed = /* @__PURE__ */ new Map();
513
+ /** The latest fingerprint listed for a tool, if any listing in this process named it. */
514
+ function definitionOf(toolName) {
515
+ return listed.get(toolName);
516
+ }
517
+ /** Notes every tool in an answer to `tools/list`. Never throws. */
518
+ function noteListing(result) {
519
+ try {
520
+ const tools = result?.tools;
521
+ if (!Array.isArray(tools)) return;
522
+ for (const tool of tools) {
523
+ const name = tool?.name;
524
+ if (typeof name !== "string") continue;
525
+ const hash = definitionHash(tool);
526
+ if (hash !== void 0) listed.set(name, hash);
527
+ }
528
+ } catch {}
529
+ }
530
+ /**
531
+ * The first 16 hex characters of the SHA-256 of the tool's name, title,
532
+ * description and input schema, as canonical JSON. Undefined for a definition
533
+ * that cannot be written so, which is then sent without one.
534
+ */
535
+ function definitionHash(tool) {
536
+ const hashed = {};
537
+ for (const field of [
538
+ "name",
539
+ "title",
540
+ "description",
541
+ "inputSchema"
542
+ ]) if (tool[field] !== void 0) hashed[field] = tool[field];
543
+ try {
544
+ return (0, node_crypto.createHash)("sha256").update(canonical(hashed), "utf8").digest("hex").slice(0, 16);
545
+ } catch {
546
+ return;
547
+ }
548
+ }
549
+ /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. Throws on what JSON cannot hold. */
550
+ function canonical(value) {
551
+ if (value === null) return "null";
552
+ if (typeof value === "boolean") return String(value);
553
+ if (typeof value === "number") {
554
+ if (!Number.isFinite(value)) throw new TypeError("not a JSON number");
555
+ return String(value);
556
+ }
557
+ if (typeof value === "string") return text(value);
558
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
559
+ if (typeof value === "object") {
560
+ const object = value;
561
+ return `{${Object.keys(object).filter((key) => object[key] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((key) => `${text(key)}:${canonical(object[key])}`).join(",")}}`;
562
+ }
563
+ throw new TypeError(`cannot fingerprint ${typeof value}`);
564
+ }
565
+ const ESCAPES = {
566
+ "\"": "\\\"",
567
+ "\\": "\\\\",
568
+ "\b": "\\b",
569
+ "\f": "\\f",
570
+ "\n": "\\n",
571
+ "\r": "\\r",
572
+ " ": "\\t"
573
+ };
574
+ function text(value) {
575
+ let out = "\"";
576
+ for (const character of value) {
577
+ const code = character.codePointAt(0) ?? 0;
578
+ out += ESCAPES[character] ?? (code < 32 ? `\\u${code.toString(16).padStart(4, "0")}` : character);
579
+ }
580
+ return `${out}"`;
581
+ }
582
+ //#endregion
501
583
  //#region src/client.ts
502
584
  /**
503
585
  * Names we recognise, matched as substrings of what a client reports.
@@ -773,6 +855,8 @@ function track(toolName, handler) {
773
855
  timestamp,
774
856
  sdkVersion: SDK_VERSION,
775
857
  ...session !== void 0 && { sessionId: session },
858
+ ...definition(toolName),
859
+ ...call?.repeated === true && { repeated: true },
776
860
  ...outcome
777
861
  });
778
862
  } catch {}
@@ -880,7 +964,9 @@ function recordRefusedCall(refused) {
880
964
  ...parameters !== void 0 && { parameters },
881
965
  timestamp: refused.timestamp,
882
966
  sdkVersion: SDK_VERSION,
883
- ...refused.sessionId !== void 0 && { sessionId: refused.sessionId }
967
+ ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
968
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName),
969
+ ...refused.repeated === true && { repeated: true }
884
970
  });
885
971
  } catch {}
886
972
  }
@@ -918,6 +1004,11 @@ function recordPrimitiveCall(call) {
918
1004
  function isRecording() {
919
1005
  return sink !== void 0;
920
1006
  }
1007
+ /** The fingerprint of a tool as last listed (contract, 3.8), as event fields. */
1008
+ function definition(toolName) {
1009
+ const hash = definitionOf(toolName);
1010
+ return hash === void 0 ? {} : { definitionHash: hash };
1011
+ }
921
1012
  /** The largest size an event carries; anything larger is sent as this (contract, 3.7). */
922
1013
  const MAX_RESPONSE_BYTES = 2147483647;
923
1014
  /**
@@ -1280,6 +1371,40 @@ function watchPrimitive(handler, server) {
1280
1371
  };
1281
1372
  }
1282
1373
  //#endregion
1374
+ //#region src/repeats.ts
1375
+ /**
1376
+ * Whether a call repeats the previous call to the same tool in the same
1377
+ * session (contract, 3.9): an agent stuck in a loop.
1378
+ *
1379
+ * Only the answer leaves the process. Kept here is a SHA-256 of the canonical
1380
+ * arguments of the latest call per session and tool, never sent: a digest of
1381
+ * a short identifier or an enumerated value is found by trying every one.
1382
+ */
1383
+ /** Session and tool pairs kept, the oldest forgotten first. */
1384
+ const MAX_KEPT = 1e4;
1385
+ const latest = /* @__PURE__ */ new Map();
1386
+ /**
1387
+ * Notes a call's arguments, as the client sent them, and says whether they are
1388
+ * the previous call's to the same tool in the same session. Never throws: an
1389
+ * argument object that cannot be written down is never a repeat.
1390
+ */
1391
+ function noteArguments(sessionId, toolName, args) {
1392
+ try {
1393
+ const digest = (0, node_crypto.createHash)("sha256").update(canonical(args ?? {}), "utf8").digest("hex");
1394
+ const key = `${sessionId}\u0000${toolName}`;
1395
+ const previous = latest.get(key);
1396
+ latest.delete(key);
1397
+ latest.set(key, digest);
1398
+ if (latest.size > MAX_KEPT) {
1399
+ const oldest = latest.keys().next().value;
1400
+ if (oldest !== void 0) latest.delete(oldest);
1401
+ }
1402
+ return previous === digest;
1403
+ } catch {
1404
+ return false;
1405
+ }
1406
+ }
1407
+ //#endregion
1283
1408
  //#region src/instrument.ts
1284
1409
  /**
1285
1410
  * Methods an MCP server registers tools through.
@@ -1308,6 +1433,28 @@ const registries = /* @__PURE__ */ new WeakMap();
1308
1433
  * it on its own. A weak set, so contexts leave with their requests.
1309
1434
  */
1310
1435
  const reachedHandler = /* @__PURE__ */ new WeakSet();
1436
+ /** Requests whose arguments repeat the previous call's to the same tool in the session (contract, 3.9). */
1437
+ const repeatedCalls = /* @__PURE__ */ new WeakSet();
1438
+ /**
1439
+ * Compares a call's arguments, as the client sent them, with the previous call's to the same tool in the same
1440
+ * session, as it arrives, before any validation; and marks the request so the handler side knows too. A call
1441
+ * without a session is never compared.
1442
+ */
1443
+ function noteRepeat(server, request, context) {
1444
+ try {
1445
+ const name = request.params?.name;
1446
+ if (typeof name !== "string" || typeof context !== "object" || context === null) return false;
1447
+ const params = request.params;
1448
+ if (params["inputResponses"] !== void 0 || params["requestState"] !== void 0) return false;
1449
+ const sessionId = sessionFor(server, context);
1450
+ if (sessionId === void 0) return false;
1451
+ const repeated = noteArguments(sessionId, name, request.params?.arguments);
1452
+ if (repeated) repeatedCalls.add(context);
1453
+ return repeated;
1454
+ } catch {
1455
+ return false;
1456
+ }
1457
+ }
1311
1458
  /**
1312
1459
  * Request contexts whose handler last answered with an interim
1313
1460
  * `input_required` result (the 2026-07-28 protocol's way to ask the client for
@@ -1457,10 +1604,12 @@ function noteReached(handler, server) {
1457
1604
  const sessionId = hasContext ? sessionFor(server, context) : void 0;
1458
1605
  const client = clientFor(server, hasContext ? context : void 0);
1459
1606
  const serverVersion = serverVersionOf(server);
1607
+ const repeated = hasContext && repeatedCalls.has(context);
1460
1608
  const result = withCall({
1461
1609
  ...sessionId !== void 0 && { sessionId },
1462
1610
  ...client !== void 0 && { client },
1463
- ...serverVersion !== void 0 && { serverVersion }
1611
+ ...serverVersion !== void 0 && { serverVersion },
1612
+ ...repeated && { repeated }
1464
1613
  }, run);
1465
1614
  if (hasContext) noteInterim(context, result);
1466
1615
  return result;
@@ -1495,7 +1644,11 @@ function interceptToolCalls(server, registry) {
1495
1644
  if (intercepted.has(inner)) return;
1496
1645
  intercepted.add(inner);
1497
1646
  const handlers = inner._requestHandlers;
1498
- if (handlers instanceof Map) for (const method of ["tools/call", ...PRIMITIVE_METHODS]) {
1647
+ if (handlers instanceof Map) for (const method of [
1648
+ "tools/call",
1649
+ "tools/list",
1650
+ ...PRIMITIVE_METHODS
1651
+ ]) {
1499
1652
  const installed = handlers.get(method);
1500
1653
  if (typeof installed === "function") handlers.set(method, watch(installed, server, registry));
1501
1654
  }
@@ -1513,7 +1666,15 @@ function interceptToolCalls(server, registry) {
1513
1666
  * so the handler for any other method runs exactly as it did.
1514
1667
  */
1515
1668
  function watch(handler, server, registry) {
1516
- return watchPrimitive(watchToolCalls(handler, server, registry), server);
1669
+ return watchListing(watchPrimitive(watchToolCalls(handler, server, registry), server));
1670
+ }
1671
+ /** Notes the tools a `tools/list` answer describes, for the fingerprint each call carries (contract, 3.8). */
1672
+ function watchListing(handler) {
1673
+ return async function watchedListing(request, ...rest) {
1674
+ const result = await handler.call(this, request, ...rest);
1675
+ if (request?.method === "tools/list" && isRecording()) noteListing(result);
1676
+ return result;
1677
+ };
1517
1678
  }
1518
1679
  function watchToolCalls(handler, server, registry) {
1519
1680
  return async function watchedRequestHandler(request, context) {
@@ -1521,6 +1682,7 @@ function watchToolCalls(handler, server, registry) {
1521
1682
  if (!isRecording() || request?.method !== "tools/call") return call();
1522
1683
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1523
1684
  const startedAt = performance.now();
1685
+ const repeated = noteRepeat(server, request, context);
1524
1686
  const noteRefusal = (message) => {
1525
1687
  try {
1526
1688
  const reached = typeof context === "object" && context !== null && reachedHandler.has(context);
@@ -1536,7 +1698,8 @@ function watchToolCalls(handler, server, registry) {
1536
1698
  durationMs: performance.now() - startedAt,
1537
1699
  sessionId: sessionFor(server, context),
1538
1700
  client: clientFor(server, context),
1539
- serverVersion: serverVersionOf(server)
1701
+ serverVersion: serverVersionOf(server),
1702
+ repeated
1540
1703
  });
1541
1704
  return;
1542
1705
  }
@@ -1550,7 +1713,8 @@ function watchToolCalls(handler, server, registry) {
1550
1713
  durationMs: performance.now() - startedAt,
1551
1714
  sessionId: sessionFor(server, context),
1552
1715
  client: clientFor(server, context),
1553
- serverVersion: serverVersionOf(server)
1716
+ serverVersion: serverVersionOf(server),
1717
+ repeated
1554
1718
  });
1555
1719
  } catch {}
1556
1720
  };
package/dist/index.d.cts CHANGED
@@ -199,6 +199,10 @@ interface ToolCallEvent {
199
199
  * call returned one; the content is counted, never kept.
200
200
  */
201
201
  responseBytes?: number;
202
+ /** The tool's definition as last listed, fingerprinted (contract, 3.8). */
203
+ definitionHash?: string;
204
+ /** The arguments are the previous call's to the same tool in this session (contract, 3.9). */
205
+ repeated?: boolean;
202
206
  /** When the call started, as an ISO 8601 timestamp. */
203
207
  timestamp: string;
204
208
  /** Version of the mcpspan package that produced the event. */
package/dist/index.d.mts CHANGED
@@ -199,6 +199,10 @@ interface ToolCallEvent {
199
199
  * call returned one; the content is counted, never kept.
200
200
  */
201
201
  responseBytes?: number;
202
+ /** The tool's definition as last listed, fingerprinted (contract, 3.8). */
203
+ definitionHash?: string;
204
+ /** The arguments are the previous call's to the same tool in this session (contract, 3.9). */
205
+ repeated?: boolean;
202
206
  /** When the call started, as an ISO 8601 timestamp. */
203
207
  timestamp: string;
204
208
  /** Version of the mcpspan package that produced the event. */
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  /** Renders any thrown value as one readable line, for diagnostics. */
3
3
  function formatError(error) {
4
4
  return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
@@ -145,7 +145,7 @@ var EventQueue = class {
145
145
  * Single source of truth: `package.json` follows this constant, not the other
146
146
  * way round, and a unit test fails if the two ever drift apart.
147
147
  */
148
- const SDK_VERSION = "0.2.0";
148
+ const SDK_VERSION = "0.4.0";
149
149
  //#endregion
150
150
  //#region src/transport.ts
151
151
  /** Path the ingest API accepts batches on, appended to the configured endpoint. */
@@ -497,6 +497,88 @@ function currentCall() {
497
497
  return current;
498
498
  }
499
499
  //#endregion
500
+ //#region src/definition.ts
501
+ /**
502
+ * Tool definitions as the server lists them, fingerprinted (contract, 3.8).
503
+ *
504
+ * Rewording a description can change how agents use a tool more than a change
505
+ * to its code. The fingerprint is taken from the answer to `tools/list`, what
506
+ * an agent actually read, and sent with every call to the tool, so the
507
+ * dashboard can mark when a definition changed. Kept for the process: one
508
+ * process reports to one server, and a listing on one connection describes
509
+ * the same tools as on any other.
510
+ */
511
+ const listed = /* @__PURE__ */ new Map();
512
+ /** The latest fingerprint listed for a tool, if any listing in this process named it. */
513
+ function definitionOf(toolName) {
514
+ return listed.get(toolName);
515
+ }
516
+ /** Notes every tool in an answer to `tools/list`. Never throws. */
517
+ function noteListing(result) {
518
+ try {
519
+ const tools = result?.tools;
520
+ if (!Array.isArray(tools)) return;
521
+ for (const tool of tools) {
522
+ const name = tool?.name;
523
+ if (typeof name !== "string") continue;
524
+ const hash = definitionHash(tool);
525
+ if (hash !== void 0) listed.set(name, hash);
526
+ }
527
+ } catch {}
528
+ }
529
+ /**
530
+ * The first 16 hex characters of the SHA-256 of the tool's name, title,
531
+ * description and input schema, as canonical JSON. Undefined for a definition
532
+ * that cannot be written so, which is then sent without one.
533
+ */
534
+ function definitionHash(tool) {
535
+ const hashed = {};
536
+ for (const field of [
537
+ "name",
538
+ "title",
539
+ "description",
540
+ "inputSchema"
541
+ ]) if (tool[field] !== void 0) hashed[field] = tool[field];
542
+ try {
543
+ return createHash("sha256").update(canonical(hashed), "utf8").digest("hex").slice(0, 16);
544
+ } catch {
545
+ return;
546
+ }
547
+ }
548
+ /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. Throws on what JSON cannot hold. */
549
+ function canonical(value) {
550
+ if (value === null) return "null";
551
+ if (typeof value === "boolean") return String(value);
552
+ if (typeof value === "number") {
553
+ if (!Number.isFinite(value)) throw new TypeError("not a JSON number");
554
+ return String(value);
555
+ }
556
+ if (typeof value === "string") return text(value);
557
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
558
+ if (typeof value === "object") {
559
+ const object = value;
560
+ return `{${Object.keys(object).filter((key) => object[key] !== void 0).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((key) => `${text(key)}:${canonical(object[key])}`).join(",")}}`;
561
+ }
562
+ throw new TypeError(`cannot fingerprint ${typeof value}`);
563
+ }
564
+ const ESCAPES = {
565
+ "\"": "\\\"",
566
+ "\\": "\\\\",
567
+ "\b": "\\b",
568
+ "\f": "\\f",
569
+ "\n": "\\n",
570
+ "\r": "\\r",
571
+ " ": "\\t"
572
+ };
573
+ function text(value) {
574
+ let out = "\"";
575
+ for (const character of value) {
576
+ const code = character.codePointAt(0) ?? 0;
577
+ out += ESCAPES[character] ?? (code < 32 ? `\\u${code.toString(16).padStart(4, "0")}` : character);
578
+ }
579
+ return `${out}"`;
580
+ }
581
+ //#endregion
500
582
  //#region src/client.ts
501
583
  /**
502
584
  * Names we recognise, matched as substrings of what a client reports.
@@ -772,6 +854,8 @@ function track(toolName, handler) {
772
854
  timestamp,
773
855
  sdkVersion: SDK_VERSION,
774
856
  ...session !== void 0 && { sessionId: session },
857
+ ...definition(toolName),
858
+ ...call?.repeated === true && { repeated: true },
775
859
  ...outcome
776
860
  });
777
861
  } catch {}
@@ -879,7 +963,9 @@ function recordRefusedCall(refused) {
879
963
  ...parameters !== void 0 && { parameters },
880
964
  timestamp: refused.timestamp,
881
965
  sdkVersion: SDK_VERSION,
882
- ...refused.sessionId !== void 0 && { sessionId: refused.sessionId }
966
+ ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
967
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName),
968
+ ...refused.repeated === true && { repeated: true }
883
969
  });
884
970
  } catch {}
885
971
  }
@@ -917,6 +1003,11 @@ function recordPrimitiveCall(call) {
917
1003
  function isRecording() {
918
1004
  return sink !== void 0;
919
1005
  }
1006
+ /** The fingerprint of a tool as last listed (contract, 3.8), as event fields. */
1007
+ function definition(toolName) {
1008
+ const hash = definitionOf(toolName);
1009
+ return hash === void 0 ? {} : { definitionHash: hash };
1010
+ }
920
1011
  /** The largest size an event carries; anything larger is sent as this (contract, 3.7). */
921
1012
  const MAX_RESPONSE_BYTES = 2147483647;
922
1013
  /**
@@ -1279,6 +1370,40 @@ function watchPrimitive(handler, server) {
1279
1370
  };
1280
1371
  }
1281
1372
  //#endregion
1373
+ //#region src/repeats.ts
1374
+ /**
1375
+ * Whether a call repeats the previous call to the same tool in the same
1376
+ * session (contract, 3.9): an agent stuck in a loop.
1377
+ *
1378
+ * Only the answer leaves the process. Kept here is a SHA-256 of the canonical
1379
+ * arguments of the latest call per session and tool, never sent: a digest of
1380
+ * a short identifier or an enumerated value is found by trying every one.
1381
+ */
1382
+ /** Session and tool pairs kept, the oldest forgotten first. */
1383
+ const MAX_KEPT = 1e4;
1384
+ const latest = /* @__PURE__ */ new Map();
1385
+ /**
1386
+ * Notes a call's arguments, as the client sent them, and says whether they are
1387
+ * the previous call's to the same tool in the same session. Never throws: an
1388
+ * argument object that cannot be written down is never a repeat.
1389
+ */
1390
+ function noteArguments(sessionId, toolName, args) {
1391
+ try {
1392
+ const digest = createHash("sha256").update(canonical(args ?? {}), "utf8").digest("hex");
1393
+ const key = `${sessionId}\u0000${toolName}`;
1394
+ const previous = latest.get(key);
1395
+ latest.delete(key);
1396
+ latest.set(key, digest);
1397
+ if (latest.size > MAX_KEPT) {
1398
+ const oldest = latest.keys().next().value;
1399
+ if (oldest !== void 0) latest.delete(oldest);
1400
+ }
1401
+ return previous === digest;
1402
+ } catch {
1403
+ return false;
1404
+ }
1405
+ }
1406
+ //#endregion
1282
1407
  //#region src/instrument.ts
1283
1408
  /**
1284
1409
  * Methods an MCP server registers tools through.
@@ -1307,6 +1432,28 @@ const registries = /* @__PURE__ */ new WeakMap();
1307
1432
  * it on its own. A weak set, so contexts leave with their requests.
1308
1433
  */
1309
1434
  const reachedHandler = /* @__PURE__ */ new WeakSet();
1435
+ /** Requests whose arguments repeat the previous call's to the same tool in the session (contract, 3.9). */
1436
+ const repeatedCalls = /* @__PURE__ */ new WeakSet();
1437
+ /**
1438
+ * Compares a call's arguments, as the client sent them, with the previous call's to the same tool in the same
1439
+ * session, as it arrives, before any validation; and marks the request so the handler side knows too. A call
1440
+ * without a session is never compared.
1441
+ */
1442
+ function noteRepeat(server, request, context) {
1443
+ try {
1444
+ const name = request.params?.name;
1445
+ if (typeof name !== "string" || typeof context !== "object" || context === null) return false;
1446
+ const params = request.params;
1447
+ if (params["inputResponses"] !== void 0 || params["requestState"] !== void 0) return false;
1448
+ const sessionId = sessionFor(server, context);
1449
+ if (sessionId === void 0) return false;
1450
+ const repeated = noteArguments(sessionId, name, request.params?.arguments);
1451
+ if (repeated) repeatedCalls.add(context);
1452
+ return repeated;
1453
+ } catch {
1454
+ return false;
1455
+ }
1456
+ }
1310
1457
  /**
1311
1458
  * Request contexts whose handler last answered with an interim
1312
1459
  * `input_required` result (the 2026-07-28 protocol's way to ask the client for
@@ -1456,10 +1603,12 @@ function noteReached(handler, server) {
1456
1603
  const sessionId = hasContext ? sessionFor(server, context) : void 0;
1457
1604
  const client = clientFor(server, hasContext ? context : void 0);
1458
1605
  const serverVersion = serverVersionOf(server);
1606
+ const repeated = hasContext && repeatedCalls.has(context);
1459
1607
  const result = withCall({
1460
1608
  ...sessionId !== void 0 && { sessionId },
1461
1609
  ...client !== void 0 && { client },
1462
- ...serverVersion !== void 0 && { serverVersion }
1610
+ ...serverVersion !== void 0 && { serverVersion },
1611
+ ...repeated && { repeated }
1463
1612
  }, run);
1464
1613
  if (hasContext) noteInterim(context, result);
1465
1614
  return result;
@@ -1494,7 +1643,11 @@ function interceptToolCalls(server, registry) {
1494
1643
  if (intercepted.has(inner)) return;
1495
1644
  intercepted.add(inner);
1496
1645
  const handlers = inner._requestHandlers;
1497
- if (handlers instanceof Map) for (const method of ["tools/call", ...PRIMITIVE_METHODS]) {
1646
+ if (handlers instanceof Map) for (const method of [
1647
+ "tools/call",
1648
+ "tools/list",
1649
+ ...PRIMITIVE_METHODS
1650
+ ]) {
1498
1651
  const installed = handlers.get(method);
1499
1652
  if (typeof installed === "function") handlers.set(method, watch(installed, server, registry));
1500
1653
  }
@@ -1512,7 +1665,15 @@ function interceptToolCalls(server, registry) {
1512
1665
  * so the handler for any other method runs exactly as it did.
1513
1666
  */
1514
1667
  function watch(handler, server, registry) {
1515
- return watchPrimitive(watchToolCalls(handler, server, registry), server);
1668
+ return watchListing(watchPrimitive(watchToolCalls(handler, server, registry), server));
1669
+ }
1670
+ /** Notes the tools a `tools/list` answer describes, for the fingerprint each call carries (contract, 3.8). */
1671
+ function watchListing(handler) {
1672
+ return async function watchedListing(request, ...rest) {
1673
+ const result = await handler.call(this, request, ...rest);
1674
+ if (request?.method === "tools/list" && isRecording()) noteListing(result);
1675
+ return result;
1676
+ };
1516
1677
  }
1517
1678
  function watchToolCalls(handler, server, registry) {
1518
1679
  return async function watchedRequestHandler(request, context) {
@@ -1520,6 +1681,7 @@ function watchToolCalls(handler, server, registry) {
1520
1681
  if (!isRecording() || request?.method !== "tools/call") return call();
1521
1682
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1522
1683
  const startedAt = performance.now();
1684
+ const repeated = noteRepeat(server, request, context);
1523
1685
  const noteRefusal = (message) => {
1524
1686
  try {
1525
1687
  const reached = typeof context === "object" && context !== null && reachedHandler.has(context);
@@ -1535,7 +1697,8 @@ function watchToolCalls(handler, server, registry) {
1535
1697
  durationMs: performance.now() - startedAt,
1536
1698
  sessionId: sessionFor(server, context),
1537
1699
  client: clientFor(server, context),
1538
- serverVersion: serverVersionOf(server)
1700
+ serverVersion: serverVersionOf(server),
1701
+ repeated
1539
1702
  });
1540
1703
  return;
1541
1704
  }
@@ -1549,7 +1712,8 @@ function watchToolCalls(handler, server, registry) {
1549
1712
  durationMs: performance.now() - startedAt,
1550
1713
  sessionId: sessionFor(server, context),
1551
1714
  client: clientFor(server, context),
1552
- serverVersion: serverVersionOf(server)
1715
+ serverVersion: serverVersionOf(server),
1716
+ repeated
1553
1717
  });
1554
1718
  } catch {}
1555
1719
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpspan",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Self-hosted analytics for MCP servers: which tools, resources and prompts get used, by which client, how fast, and why they fail.",
5
5
  "keywords": [
6
6
  "mcp",