mcpspan 0.3.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.3.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. */
@@ -546,7 +546,7 @@ function definitionHash(tool) {
546
546
  return;
547
547
  }
548
548
  }
549
- /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. */
549
+ /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. Throws on what JSON cannot hold. */
550
550
  function canonical(value) {
551
551
  if (value === null) return "null";
552
552
  if (typeof value === "boolean") return String(value);
@@ -856,6 +856,7 @@ function track(toolName, handler) {
856
856
  sdkVersion: SDK_VERSION,
857
857
  ...session !== void 0 && { sessionId: session },
858
858
  ...definition(toolName),
859
+ ...call?.repeated === true && { repeated: true },
859
860
  ...outcome
860
861
  });
861
862
  } catch {}
@@ -964,7 +965,8 @@ function recordRefusedCall(refused) {
964
965
  timestamp: refused.timestamp,
965
966
  sdkVersion: SDK_VERSION,
966
967
  ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
967
- ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName)
968
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName),
969
+ ...refused.repeated === true && { repeated: true }
968
970
  });
969
971
  } catch {}
970
972
  }
@@ -1369,6 +1371,40 @@ function watchPrimitive(handler, server) {
1369
1371
  };
1370
1372
  }
1371
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
1372
1408
  //#region src/instrument.ts
1373
1409
  /**
1374
1410
  * Methods an MCP server registers tools through.
@@ -1397,6 +1433,28 @@ const registries = /* @__PURE__ */ new WeakMap();
1397
1433
  * it on its own. A weak set, so contexts leave with their requests.
1398
1434
  */
1399
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
+ }
1400
1458
  /**
1401
1459
  * Request contexts whose handler last answered with an interim
1402
1460
  * `input_required` result (the 2026-07-28 protocol's way to ask the client for
@@ -1546,10 +1604,12 @@ function noteReached(handler, server) {
1546
1604
  const sessionId = hasContext ? sessionFor(server, context) : void 0;
1547
1605
  const client = clientFor(server, hasContext ? context : void 0);
1548
1606
  const serverVersion = serverVersionOf(server);
1607
+ const repeated = hasContext && repeatedCalls.has(context);
1549
1608
  const result = withCall({
1550
1609
  ...sessionId !== void 0 && { sessionId },
1551
1610
  ...client !== void 0 && { client },
1552
- ...serverVersion !== void 0 && { serverVersion }
1611
+ ...serverVersion !== void 0 && { serverVersion },
1612
+ ...repeated && { repeated }
1553
1613
  }, run);
1554
1614
  if (hasContext) noteInterim(context, result);
1555
1615
  return result;
@@ -1622,6 +1682,7 @@ function watchToolCalls(handler, server, registry) {
1622
1682
  if (!isRecording() || request?.method !== "tools/call") return call();
1623
1683
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1624
1684
  const startedAt = performance.now();
1685
+ const repeated = noteRepeat(server, request, context);
1625
1686
  const noteRefusal = (message) => {
1626
1687
  try {
1627
1688
  const reached = typeof context === "object" && context !== null && reachedHandler.has(context);
@@ -1637,7 +1698,8 @@ function watchToolCalls(handler, server, registry) {
1637
1698
  durationMs: performance.now() - startedAt,
1638
1699
  sessionId: sessionFor(server, context),
1639
1700
  client: clientFor(server, context),
1640
- serverVersion: serverVersionOf(server)
1701
+ serverVersion: serverVersionOf(server),
1702
+ repeated
1641
1703
  });
1642
1704
  return;
1643
1705
  }
@@ -1651,7 +1713,8 @@ function watchToolCalls(handler, server, registry) {
1651
1713
  durationMs: performance.now() - startedAt,
1652
1714
  sessionId: sessionFor(server, context),
1653
1715
  client: clientFor(server, context),
1654
- serverVersion: serverVersionOf(server)
1716
+ serverVersion: serverVersionOf(server),
1717
+ repeated
1655
1718
  });
1656
1719
  } catch {}
1657
1720
  };
package/dist/index.d.cts CHANGED
@@ -201,6 +201,8 @@ interface ToolCallEvent {
201
201
  responseBytes?: number;
202
202
  /** The tool's definition as last listed, fingerprinted (contract, 3.8). */
203
203
  definitionHash?: string;
204
+ /** The arguments are the previous call's to the same tool in this session (contract, 3.9). */
205
+ repeated?: boolean;
204
206
  /** When the call started, as an ISO 8601 timestamp. */
205
207
  timestamp: string;
206
208
  /** Version of the mcpspan package that produced the event. */
package/dist/index.d.mts CHANGED
@@ -201,6 +201,8 @@ interface ToolCallEvent {
201
201
  responseBytes?: number;
202
202
  /** The tool's definition as last listed, fingerprinted (contract, 3.8). */
203
203
  definitionHash?: string;
204
+ /** The arguments are the previous call's to the same tool in this session (contract, 3.9). */
205
+ repeated?: boolean;
204
206
  /** When the call started, as an ISO 8601 timestamp. */
205
207
  timestamp: string;
206
208
  /** Version of the mcpspan package that produced the event. */
package/dist/index.mjs CHANGED
@@ -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.3.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. */
@@ -545,7 +545,7 @@ function definitionHash(tool) {
545
545
  return;
546
546
  }
547
547
  }
548
- /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. */
548
+ /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. Throws on what JSON cannot hold. */
549
549
  function canonical(value) {
550
550
  if (value === null) return "null";
551
551
  if (typeof value === "boolean") return String(value);
@@ -855,6 +855,7 @@ function track(toolName, handler) {
855
855
  sdkVersion: SDK_VERSION,
856
856
  ...session !== void 0 && { sessionId: session },
857
857
  ...definition(toolName),
858
+ ...call?.repeated === true && { repeated: true },
858
859
  ...outcome
859
860
  });
860
861
  } catch {}
@@ -963,7 +964,8 @@ function recordRefusedCall(refused) {
963
964
  timestamp: refused.timestamp,
964
965
  sdkVersion: SDK_VERSION,
965
966
  ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
966
- ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName)
967
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName),
968
+ ...refused.repeated === true && { repeated: true }
967
969
  });
968
970
  } catch {}
969
971
  }
@@ -1368,6 +1370,40 @@ function watchPrimitive(handler, server) {
1368
1370
  };
1369
1371
  }
1370
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
1371
1407
  //#region src/instrument.ts
1372
1408
  /**
1373
1409
  * Methods an MCP server registers tools through.
@@ -1396,6 +1432,28 @@ const registries = /* @__PURE__ */ new WeakMap();
1396
1432
  * it on its own. A weak set, so contexts leave with their requests.
1397
1433
  */
1398
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
+ }
1399
1457
  /**
1400
1458
  * Request contexts whose handler last answered with an interim
1401
1459
  * `input_required` result (the 2026-07-28 protocol's way to ask the client for
@@ -1545,10 +1603,12 @@ function noteReached(handler, server) {
1545
1603
  const sessionId = hasContext ? sessionFor(server, context) : void 0;
1546
1604
  const client = clientFor(server, hasContext ? context : void 0);
1547
1605
  const serverVersion = serverVersionOf(server);
1606
+ const repeated = hasContext && repeatedCalls.has(context);
1548
1607
  const result = withCall({
1549
1608
  ...sessionId !== void 0 && { sessionId },
1550
1609
  ...client !== void 0 && { client },
1551
- ...serverVersion !== void 0 && { serverVersion }
1610
+ ...serverVersion !== void 0 && { serverVersion },
1611
+ ...repeated && { repeated }
1552
1612
  }, run);
1553
1613
  if (hasContext) noteInterim(context, result);
1554
1614
  return result;
@@ -1621,6 +1681,7 @@ function watchToolCalls(handler, server, registry) {
1621
1681
  if (!isRecording() || request?.method !== "tools/call") return call();
1622
1682
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1623
1683
  const startedAt = performance.now();
1684
+ const repeated = noteRepeat(server, request, context);
1624
1685
  const noteRefusal = (message) => {
1625
1686
  try {
1626
1687
  const reached = typeof context === "object" && context !== null && reachedHandler.has(context);
@@ -1636,7 +1697,8 @@ function watchToolCalls(handler, server, registry) {
1636
1697
  durationMs: performance.now() - startedAt,
1637
1698
  sessionId: sessionFor(server, context),
1638
1699
  client: clientFor(server, context),
1639
- serverVersion: serverVersionOf(server)
1700
+ serverVersion: serverVersionOf(server),
1701
+ repeated
1640
1702
  });
1641
1703
  return;
1642
1704
  }
@@ -1650,7 +1712,8 @@ function watchToolCalls(handler, server, registry) {
1650
1712
  durationMs: performance.now() - startedAt,
1651
1713
  sessionId: sessionFor(server, context),
1652
1714
  client: clientFor(server, context),
1653
- serverVersion: serverVersionOf(server)
1715
+ serverVersion: serverVersionOf(server),
1716
+ repeated
1654
1717
  });
1655
1718
  } catch {}
1656
1719
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpspan",
3
- "version": "0.3.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",