mcpspan 0.3.0 → 0.5.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
@@ -202,6 +202,14 @@ names and types of the arguments instead, which is what shows the agent wrote
202
202
  `dest` where the schema says `destination`. Tools passed through `exclude` stay
203
203
  out of this as well.
204
204
 
205
+ For a refusal of arguments, the SDK also says which ones. It checks what the
206
+ agent sent against the input schema your server listed, in your process, and
207
+ records the top-level arguments that did not match, by the names the schema
208
+ declares, so the tool's page can show that 29 refusals were all `passengers`.
209
+ Values are never sent, and neither is a name the agent made up. It works from
210
+ the latest `tools/list` your server answered in the same process; a refusal
211
+ before any listing names nothing.
212
+
205
213
  ## Privacy
206
214
 
207
215
  **Parameter values never leave your process.** Not by default, not in any
@@ -209,8 +217,14 @@ mode, not in debug.
209
217
 
210
218
  What is collected: the tool name, how long it took, whether it succeeded, the
211
219
  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
220
+ in bytes (its size only, never its content), whether it repeated the previous
221
+ call's arguments to the same tool in its session (compared in your process;
222
+ the arguments, or any digest of them, never leave it), a fingerprint of the
223
+ tool's definition as your server lists it (its name, title, description and
224
+ input schema, hashed, so the dashboard can mark when you changed it), for a call your
225
+ server refused for its arguments the names of those that did not match the
226
+ tool's schema (never what was sent), which
227
+ client called, and the SDK version. For a resource or a prompt, the same, under the name it was
214
228
  registered with: never the address a client read, only its template or, for
215
229
  an address the server does not have, its scheme.
216
230
 
@@ -229,6 +243,21 @@ That records `{ destination: 'string', passengers: 'number' }`. Knowing
229
243
  destination tells you nothing you needed, and puts your users' data somewhere
230
244
  it does not belong.
231
245
 
246
+ Error messages are sent, cut short, because they are usually what says why a
247
+ call failed. If your tools can fail with text you would not send anywhere, as
248
+ a tool that runs commands or reads files might quote a path or a token, turn
249
+ them off:
250
+
251
+ ```ts
252
+ instrument(server, {
253
+ apiKey: process.env.MCPSPAN_API_KEY,
254
+ captureErrorMessages: false,
255
+ });
256
+ ```
257
+
258
+ Every failure is still recorded, with where it came from and the exception's
259
+ type. Only the text is left out.
260
+
232
261
  Types stay coarse and carry no length, because the distance between "a 34
233
262
  character string" and "a credit card number" is shorter than it looks. Nested
234
263
  objects are named but not opened.
@@ -280,6 +309,7 @@ That is the first rule, and everything below follows from it.
280
309
  | `apiKey` | `MCPSPAN_API_KEY` | Identifies your server. Without it, nothing is collected. |
281
310
  | `endpoint` | `MCPSPAN_ENDPOINT`; none | Your mcpspan installation. Nothing is collected without it. |
282
311
  | `captureParameterNames` | `false` | Records parameter names and types, never values. |
312
+ | `captureErrorMessages` | `true` | Sends the text of a failure, cut short. Off sends that it failed and how, without the text. |
283
313
  | `serverVersion` | `MCPSPAN_SERVER_VERSION`, then the server's own | The version to record calls under: a release, a tag, a commit. |
284
314
  | `debug` | `false` | Writes delivery diagnostics to stderr. |
285
315
  | `onDiagnostic` | - | Receives diagnostics instead of stderr. Implies `debug`. |
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.5.0";
150
150
  //#endregion
151
151
  //#region src/transport.ts
152
152
  /** Path the ingest API accepts batches on, appended to the configured endpoint. */
@@ -510,10 +510,16 @@ function currentCall() {
510
510
  * the same tools as on any other.
511
511
  */
512
512
  const listed = /* @__PURE__ */ new Map();
513
+ /** Each tool's input schema as last listed, to tell which arguments a refusal was over (contract, 3.10). */
514
+ const schemas = /* @__PURE__ */ new Map();
513
515
  /** The latest fingerprint listed for a tool, if any listing in this process named it. */
514
516
  function definitionOf(toolName) {
515
517
  return listed.get(toolName);
516
518
  }
519
+ /** The latest input schema listed for a tool, if any listing in this process named it. */
520
+ function schemaOf(toolName) {
521
+ return schemas.get(toolName);
522
+ }
517
523
  /** Notes every tool in an answer to `tools/list`. Never throws. */
518
524
  function noteListing(result) {
519
525
  try {
@@ -524,6 +530,7 @@ function noteListing(result) {
524
530
  if (typeof name !== "string") continue;
525
531
  const hash = definitionHash(tool);
526
532
  if (hash !== void 0) listed.set(name, hash);
533
+ schemas.set(name, tool.inputSchema);
527
534
  }
528
535
  } catch {}
529
536
  }
@@ -546,7 +553,7 @@ function definitionHash(tool) {
546
553
  return;
547
554
  }
548
555
  }
549
- /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. */
556
+ /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. Throws on what JSON cannot hold. */
550
557
  function canonical(value) {
551
558
  if (value === null) return "null";
552
559
  if (typeof value === "boolean") return String(value);
@@ -580,6 +587,99 @@ function text(value) {
580
587
  return `${out}"`;
581
588
  }
582
589
  //#endregion
590
+ //#region src/arguments.ts
591
+ /**
592
+ * Which top-level arguments of a refused call did not match the tool's input
593
+ * schema (contract, 3.10).
594
+ *
595
+ * The server's own refusal is not read: each validation library words it
596
+ * differently, and some quote the value the agent sent. The arguments are
597
+ * checked here instead, against the schema the server listed, by a small set
598
+ * of rules that never fail what they do not understand. Only names the schema
599
+ * declares come out, so nothing the client made up, and no value, is sent.
600
+ */
601
+ /** Names sent at most, per call. */
602
+ const MAX_NAMES = 20;
603
+ /** The declared names whose arguments fail the schema, sorted, at most twenty. Never throws. */
604
+ function invalidArguments(schema, args) {
605
+ try {
606
+ if (!isObject(schema)) return [];
607
+ const values = args ?? {};
608
+ if (!isObject(values)) return [];
609
+ const names = /* @__PURE__ */ new Set();
610
+ if (Array.isArray(schema["required"])) {
611
+ for (const name of schema["required"]) if (typeof name === "string" && !Object.hasOwn(values, name)) names.add(name);
612
+ }
613
+ const properties = schema["properties"];
614
+ if (isObject(properties)) {
615
+ for (const [name, property] of Object.entries(properties)) if (Object.hasOwn(values, name) && !matches(property, values[name])) names.add(name);
616
+ }
617
+ return [...names].sort((a, b) => a < b ? -1 : a > b ? 1 : 0).slice(0, MAX_NAMES);
618
+ } catch {
619
+ return [];
620
+ }
621
+ }
622
+ /** Whether a value passes a schema under the checks the contract lists, and only those. */
623
+ function matches(schema, value) {
624
+ if (schema === false) return false;
625
+ if (!isObject(schema)) return true;
626
+ const type = schema["type"];
627
+ if (typeof type === "string" && !isType(type, value)) return false;
628
+ if (Array.isArray(type) && type.every((name) => typeof name === "string") && !type.some((name) => isType(name, value))) return false;
629
+ if (Array.isArray(schema["enum"])) {
630
+ const sent = canonical(value);
631
+ if (!schema["enum"].some((allowed) => canonical(allowed) === sent)) return false;
632
+ }
633
+ if (Object.hasOwn(schema, "const") && canonical(schema["const"]) !== canonical(value)) return false;
634
+ if (typeof value === "number") {
635
+ if (isNumber(schema["minimum"]) && value < schema["minimum"]) return false;
636
+ if (isNumber(schema["maximum"]) && value > schema["maximum"]) return false;
637
+ if (isNumber(schema["exclusiveMinimum"]) && value <= schema["exclusiveMinimum"]) return false;
638
+ if (isNumber(schema["exclusiveMaximum"]) && value >= schema["exclusiveMaximum"]) return false;
639
+ }
640
+ if (typeof value === "string") {
641
+ const length = [...value].length;
642
+ if (isNumber(schema["minLength"]) && length < schema["minLength"]) return false;
643
+ if (isNumber(schema["maxLength"]) && length > schema["maxLength"]) return false;
644
+ }
645
+ if (Array.isArray(value)) {
646
+ if (isNumber(schema["minItems"]) && value.length < schema["minItems"]) return false;
647
+ if (isNumber(schema["maxItems"]) && value.length > schema["maxItems"]) return false;
648
+ const items = schema["items"];
649
+ if (isObject(items) || typeof items === "boolean") {
650
+ if (!value.every((item) => matches(items, item))) return false;
651
+ }
652
+ }
653
+ if (isObject(value)) {
654
+ if (Array.isArray(schema["required"])) {
655
+ for (const name of schema["required"]) if (typeof name === "string" && !Object.hasOwn(value, name)) return false;
656
+ }
657
+ const properties = schema["properties"];
658
+ if (isObject(properties)) {
659
+ for (const [name, property] of Object.entries(properties)) if (Object.hasOwn(value, name) && !matches(property, value[name])) return false;
660
+ }
661
+ }
662
+ return true;
663
+ }
664
+ function isType(type, value) {
665
+ switch (type) {
666
+ case "string": return typeof value === "string";
667
+ case "number": return typeof value === "number";
668
+ case "integer": return typeof value === "number" && Number.isInteger(value);
669
+ case "boolean": return typeof value === "boolean";
670
+ case "object": return isObject(value);
671
+ case "array": return Array.isArray(value);
672
+ case "null": return value === null;
673
+ default: return true;
674
+ }
675
+ }
676
+ function isObject(value) {
677
+ return typeof value === "object" && value !== null && !Array.isArray(value);
678
+ }
679
+ function isNumber(value) {
680
+ return typeof value === "number" && Number.isFinite(value);
681
+ }
682
+ //#endregion
583
683
  //#region src/client.ts
584
684
  /**
585
685
  * Names we recognise, matched as substrings of what a client reports.
@@ -856,6 +956,7 @@ function track(toolName, handler) {
856
956
  sdkVersion: SDK_VERSION,
857
957
  ...session !== void 0 && { sessionId: session },
858
958
  ...definition(toolName),
959
+ ...call?.repeated === true && { repeated: true },
859
960
  ...outcome
860
961
  });
861
962
  } catch {}
@@ -964,7 +1065,9 @@ function recordRefusedCall(refused) {
964
1065
  timestamp: refused.timestamp,
965
1066
  sdkVersion: SDK_VERSION,
966
1067
  ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
967
- ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName)
1068
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName),
1069
+ ...refused.repeated === true && { repeated: true },
1070
+ ...refused.errorSource === "arguments" ? refusedNames(refused.toolName, refused.arguments) : {}
968
1071
  });
969
1072
  } catch {}
970
1073
  }
@@ -1002,6 +1105,11 @@ function recordPrimitiveCall(call) {
1002
1105
  function isRecording() {
1003
1106
  return sink !== void 0;
1004
1107
  }
1108
+ /** Which declared arguments a refusal was over (contract, 3.10), as event fields. */
1109
+ function refusedNames(toolName, args) {
1110
+ const names = invalidArguments(schemaOf(toolName), args).map((name) => truncate(name, MAX_TOOL_NAME_LENGTH));
1111
+ return names.length === 0 ? {} : { invalidArguments: names };
1112
+ }
1005
1113
  /** The fingerprint of a tool as last listed (contract, 3.8), as event fields. */
1006
1114
  function definition(toolName) {
1007
1115
  const hash = definitionOf(toolName);
@@ -1098,7 +1206,8 @@ function configure(config = {}) {
1098
1206
  };
1099
1207
  setCaptureParameterNames(config.captureParameterNames ?? false);
1100
1208
  setServerVersion(firstNonEmpty(config.serverVersion, process.env["MCPSPAN_SERVER_VERSION"]));
1101
- setEventSink((event) => reporter?.record(event));
1209
+ const captureErrorMessages = config.captureErrorMessages ?? true;
1210
+ setEventSink((event) => reporter?.record(captureErrorMessages ? event : withoutErrorMessage(event)));
1102
1211
  if (config.flushOnExit ?? true) installExitHook();
1103
1212
  reporter.announce();
1104
1213
  }
@@ -1160,9 +1269,16 @@ function describeSettings(config) {
1160
1269
  config.maxBatchSize ?? null,
1161
1270
  config.maxQueueSize ?? null,
1162
1271
  config.captureParameterNames ?? false,
1272
+ config.captureErrorMessages ?? true,
1163
1273
  firstNonEmpty(config.serverVersion, process.env["MCPSPAN_SERVER_VERSION"]) ?? null
1164
1274
  ]);
1165
1275
  }
1276
+ /** The event as it is, less the text of its failure (contract, 5). */
1277
+ function withoutErrorMessage(event) {
1278
+ if (event.errorMessage === void 0) return event;
1279
+ const { errorMessage: _left, ...rest } = event;
1280
+ return rest;
1281
+ }
1166
1282
  function firstNonEmpty(...values) {
1167
1283
  for (const value of values) {
1168
1284
  const trimmed = value?.trim();
@@ -1369,6 +1485,40 @@ function watchPrimitive(handler, server) {
1369
1485
  };
1370
1486
  }
1371
1487
  //#endregion
1488
+ //#region src/repeats.ts
1489
+ /**
1490
+ * Whether a call repeats the previous call to the same tool in the same
1491
+ * session (contract, 3.9): an agent stuck in a loop.
1492
+ *
1493
+ * Only the answer leaves the process. Kept here is a SHA-256 of the canonical
1494
+ * arguments of the latest call per session and tool, never sent: a digest of
1495
+ * a short identifier or an enumerated value is found by trying every one.
1496
+ */
1497
+ /** Session and tool pairs kept, the oldest forgotten first. */
1498
+ const MAX_KEPT = 1e4;
1499
+ const latest = /* @__PURE__ */ new Map();
1500
+ /**
1501
+ * Notes a call's arguments, as the client sent them, and says whether they are
1502
+ * the previous call's to the same tool in the same session. Never throws: an
1503
+ * argument object that cannot be written down is never a repeat.
1504
+ */
1505
+ function noteArguments(sessionId, toolName, args) {
1506
+ try {
1507
+ const digest = (0, node_crypto.createHash)("sha256").update(canonical(args ?? {}), "utf8").digest("hex");
1508
+ const key = `${sessionId}\u0000${toolName}`;
1509
+ const previous = latest.get(key);
1510
+ latest.delete(key);
1511
+ latest.set(key, digest);
1512
+ if (latest.size > MAX_KEPT) {
1513
+ const oldest = latest.keys().next().value;
1514
+ if (oldest !== void 0) latest.delete(oldest);
1515
+ }
1516
+ return previous === digest;
1517
+ } catch {
1518
+ return false;
1519
+ }
1520
+ }
1521
+ //#endregion
1372
1522
  //#region src/instrument.ts
1373
1523
  /**
1374
1524
  * Methods an MCP server registers tools through.
@@ -1397,6 +1547,28 @@ const registries = /* @__PURE__ */ new WeakMap();
1397
1547
  * it on its own. A weak set, so contexts leave with their requests.
1398
1548
  */
1399
1549
  const reachedHandler = /* @__PURE__ */ new WeakSet();
1550
+ /** Requests whose arguments repeat the previous call's to the same tool in the session (contract, 3.9). */
1551
+ const repeatedCalls = /* @__PURE__ */ new WeakSet();
1552
+ /**
1553
+ * Compares a call's arguments, as the client sent them, with the previous call's to the same tool in the same
1554
+ * session, as it arrives, before any validation; and marks the request so the handler side knows too. A call
1555
+ * without a session is never compared.
1556
+ */
1557
+ function noteRepeat(server, request, context) {
1558
+ try {
1559
+ const name = request.params?.name;
1560
+ if (typeof name !== "string" || typeof context !== "object" || context === null) return false;
1561
+ const params = request.params;
1562
+ if (params["inputResponses"] !== void 0 || params["requestState"] !== void 0) return false;
1563
+ const sessionId = sessionFor(server, context);
1564
+ if (sessionId === void 0) return false;
1565
+ const repeated = noteArguments(sessionId, name, request.params?.arguments);
1566
+ if (repeated) repeatedCalls.add(context);
1567
+ return repeated;
1568
+ } catch {
1569
+ return false;
1570
+ }
1571
+ }
1400
1572
  /**
1401
1573
  * Request contexts whose handler last answered with an interim
1402
1574
  * `input_required` result (the 2026-07-28 protocol's way to ask the client for
@@ -1546,10 +1718,12 @@ function noteReached(handler, server) {
1546
1718
  const sessionId = hasContext ? sessionFor(server, context) : void 0;
1547
1719
  const client = clientFor(server, hasContext ? context : void 0);
1548
1720
  const serverVersion = serverVersionOf(server);
1721
+ const repeated = hasContext && repeatedCalls.has(context);
1549
1722
  const result = withCall({
1550
1723
  ...sessionId !== void 0 && { sessionId },
1551
1724
  ...client !== void 0 && { client },
1552
- ...serverVersion !== void 0 && { serverVersion }
1725
+ ...serverVersion !== void 0 && { serverVersion },
1726
+ ...repeated && { repeated }
1553
1727
  }, run);
1554
1728
  if (hasContext) noteInterim(context, result);
1555
1729
  return result;
@@ -1622,6 +1796,7 @@ function watchToolCalls(handler, server, registry) {
1622
1796
  if (!isRecording() || request?.method !== "tools/call") return call();
1623
1797
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1624
1798
  const startedAt = performance.now();
1799
+ const repeated = noteRepeat(server, request, context);
1625
1800
  const noteRefusal = (message) => {
1626
1801
  try {
1627
1802
  const reached = typeof context === "object" && context !== null && reachedHandler.has(context);
@@ -1637,7 +1812,8 @@ function watchToolCalls(handler, server, registry) {
1637
1812
  durationMs: performance.now() - startedAt,
1638
1813
  sessionId: sessionFor(server, context),
1639
1814
  client: clientFor(server, context),
1640
- serverVersion: serverVersionOf(server)
1815
+ serverVersion: serverVersionOf(server),
1816
+ repeated
1641
1817
  });
1642
1818
  return;
1643
1819
  }
@@ -1651,7 +1827,8 @@ function watchToolCalls(handler, server, registry) {
1651
1827
  durationMs: performance.now() - startedAt,
1652
1828
  sessionId: sessionFor(server, context),
1653
1829
  client: clientFor(server, context),
1654
- serverVersion: serverVersionOf(server)
1830
+ serverVersion: serverVersionOf(server),
1831
+ repeated
1655
1832
  });
1656
1833
  } catch {}
1657
1834
  };
package/dist/index.d.cts CHANGED
@@ -60,6 +60,17 @@ interface McpspanConfig {
60
60
  * `departureDate`, which usually means a tool description is not landing.
61
61
  */
62
62
  captureParameterNames?: boolean;
63
+ /**
64
+ * Sends the text of a failure: what a tool returned with `isError`, cut to
65
+ * 200 characters, or an exception's message, cut to 500.
66
+ *
67
+ * On by default, since that text is usually what says why a call failed.
68
+ * Turn it off when your tools can fail with something you would not send
69
+ * anywhere, as one that runs commands or reads files might quote a path or
70
+ * a token. Every failure is still recorded, with where it came from and
71
+ * the exception's type; only the text is left out.
72
+ */
73
+ captureErrorMessages?: boolean;
63
74
  }
64
75
  /**
65
76
  * Starts collecting, or stops if there is nothing to collect with.
@@ -201,6 +212,10 @@ interface ToolCallEvent {
201
212
  responseBytes?: number;
202
213
  /** The tool's definition as last listed, fingerprinted (contract, 3.8). */
203
214
  definitionHash?: string;
215
+ /** For refused arguments: which ones did not match the tool's schema, by declared name (contract, 3.10). */
216
+ invalidArguments?: string[];
217
+ /** The arguments are the previous call's to the same tool in this session (contract, 3.9). */
218
+ repeated?: boolean;
204
219
  /** When the call started, as an ISO 8601 timestamp. */
205
220
  timestamp: string;
206
221
  /** Version of the mcpspan package that produced the event. */
package/dist/index.d.mts CHANGED
@@ -60,6 +60,17 @@ interface McpspanConfig {
60
60
  * `departureDate`, which usually means a tool description is not landing.
61
61
  */
62
62
  captureParameterNames?: boolean;
63
+ /**
64
+ * Sends the text of a failure: what a tool returned with `isError`, cut to
65
+ * 200 characters, or an exception's message, cut to 500.
66
+ *
67
+ * On by default, since that text is usually what says why a call failed.
68
+ * Turn it off when your tools can fail with something you would not send
69
+ * anywhere, as one that runs commands or reads files might quote a path or
70
+ * a token. Every failure is still recorded, with where it came from and
71
+ * the exception's type; only the text is left out.
72
+ */
73
+ captureErrorMessages?: boolean;
63
74
  }
64
75
  /**
65
76
  * Starts collecting, or stops if there is nothing to collect with.
@@ -201,6 +212,10 @@ interface ToolCallEvent {
201
212
  responseBytes?: number;
202
213
  /** The tool's definition as last listed, fingerprinted (contract, 3.8). */
203
214
  definitionHash?: string;
215
+ /** For refused arguments: which ones did not match the tool's schema, by declared name (contract, 3.10). */
216
+ invalidArguments?: string[];
217
+ /** The arguments are the previous call's to the same tool in this session (contract, 3.9). */
218
+ repeated?: boolean;
204
219
  /** When the call started, as an ISO 8601 timestamp. */
205
220
  timestamp: string;
206
221
  /** 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.5.0";
149
149
  //#endregion
150
150
  //#region src/transport.ts
151
151
  /** Path the ingest API accepts batches on, appended to the configured endpoint. */
@@ -509,10 +509,16 @@ function currentCall() {
509
509
  * the same tools as on any other.
510
510
  */
511
511
  const listed = /* @__PURE__ */ new Map();
512
+ /** Each tool's input schema as last listed, to tell which arguments a refusal was over (contract, 3.10). */
513
+ const schemas = /* @__PURE__ */ new Map();
512
514
  /** The latest fingerprint listed for a tool, if any listing in this process named it. */
513
515
  function definitionOf(toolName) {
514
516
  return listed.get(toolName);
515
517
  }
518
+ /** The latest input schema listed for a tool, if any listing in this process named it. */
519
+ function schemaOf(toolName) {
520
+ return schemas.get(toolName);
521
+ }
516
522
  /** Notes every tool in an answer to `tools/list`. Never throws. */
517
523
  function noteListing(result) {
518
524
  try {
@@ -523,6 +529,7 @@ function noteListing(result) {
523
529
  if (typeof name !== "string") continue;
524
530
  const hash = definitionHash(tool);
525
531
  if (hash !== void 0) listed.set(name, hash);
532
+ schemas.set(name, tool.inputSchema);
526
533
  }
527
534
  } catch {}
528
535
  }
@@ -545,7 +552,7 @@ function definitionHash(tool) {
545
552
  return;
546
553
  }
547
554
  }
548
- /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. */
555
+ /** Sorted keys, no whitespace, minimal escaping: the same text in every SDK. Throws on what JSON cannot hold. */
549
556
  function canonical(value) {
550
557
  if (value === null) return "null";
551
558
  if (typeof value === "boolean") return String(value);
@@ -579,6 +586,99 @@ function text(value) {
579
586
  return `${out}"`;
580
587
  }
581
588
  //#endregion
589
+ //#region src/arguments.ts
590
+ /**
591
+ * Which top-level arguments of a refused call did not match the tool's input
592
+ * schema (contract, 3.10).
593
+ *
594
+ * The server's own refusal is not read: each validation library words it
595
+ * differently, and some quote the value the agent sent. The arguments are
596
+ * checked here instead, against the schema the server listed, by a small set
597
+ * of rules that never fail what they do not understand. Only names the schema
598
+ * declares come out, so nothing the client made up, and no value, is sent.
599
+ */
600
+ /** Names sent at most, per call. */
601
+ const MAX_NAMES = 20;
602
+ /** The declared names whose arguments fail the schema, sorted, at most twenty. Never throws. */
603
+ function invalidArguments(schema, args) {
604
+ try {
605
+ if (!isObject(schema)) return [];
606
+ const values = args ?? {};
607
+ if (!isObject(values)) return [];
608
+ const names = /* @__PURE__ */ new Set();
609
+ if (Array.isArray(schema["required"])) {
610
+ for (const name of schema["required"]) if (typeof name === "string" && !Object.hasOwn(values, name)) names.add(name);
611
+ }
612
+ const properties = schema["properties"];
613
+ if (isObject(properties)) {
614
+ for (const [name, property] of Object.entries(properties)) if (Object.hasOwn(values, name) && !matches(property, values[name])) names.add(name);
615
+ }
616
+ return [...names].sort((a, b) => a < b ? -1 : a > b ? 1 : 0).slice(0, MAX_NAMES);
617
+ } catch {
618
+ return [];
619
+ }
620
+ }
621
+ /** Whether a value passes a schema under the checks the contract lists, and only those. */
622
+ function matches(schema, value) {
623
+ if (schema === false) return false;
624
+ if (!isObject(schema)) return true;
625
+ const type = schema["type"];
626
+ if (typeof type === "string" && !isType(type, value)) return false;
627
+ if (Array.isArray(type) && type.every((name) => typeof name === "string") && !type.some((name) => isType(name, value))) return false;
628
+ if (Array.isArray(schema["enum"])) {
629
+ const sent = canonical(value);
630
+ if (!schema["enum"].some((allowed) => canonical(allowed) === sent)) return false;
631
+ }
632
+ if (Object.hasOwn(schema, "const") && canonical(schema["const"]) !== canonical(value)) return false;
633
+ if (typeof value === "number") {
634
+ if (isNumber(schema["minimum"]) && value < schema["minimum"]) return false;
635
+ if (isNumber(schema["maximum"]) && value > schema["maximum"]) return false;
636
+ if (isNumber(schema["exclusiveMinimum"]) && value <= schema["exclusiveMinimum"]) return false;
637
+ if (isNumber(schema["exclusiveMaximum"]) && value >= schema["exclusiveMaximum"]) return false;
638
+ }
639
+ if (typeof value === "string") {
640
+ const length = [...value].length;
641
+ if (isNumber(schema["minLength"]) && length < schema["minLength"]) return false;
642
+ if (isNumber(schema["maxLength"]) && length > schema["maxLength"]) return false;
643
+ }
644
+ if (Array.isArray(value)) {
645
+ if (isNumber(schema["minItems"]) && value.length < schema["minItems"]) return false;
646
+ if (isNumber(schema["maxItems"]) && value.length > schema["maxItems"]) return false;
647
+ const items = schema["items"];
648
+ if (isObject(items) || typeof items === "boolean") {
649
+ if (!value.every((item) => matches(items, item))) return false;
650
+ }
651
+ }
652
+ if (isObject(value)) {
653
+ if (Array.isArray(schema["required"])) {
654
+ for (const name of schema["required"]) if (typeof name === "string" && !Object.hasOwn(value, name)) return false;
655
+ }
656
+ const properties = schema["properties"];
657
+ if (isObject(properties)) {
658
+ for (const [name, property] of Object.entries(properties)) if (Object.hasOwn(value, name) && !matches(property, value[name])) return false;
659
+ }
660
+ }
661
+ return true;
662
+ }
663
+ function isType(type, value) {
664
+ switch (type) {
665
+ case "string": return typeof value === "string";
666
+ case "number": return typeof value === "number";
667
+ case "integer": return typeof value === "number" && Number.isInteger(value);
668
+ case "boolean": return typeof value === "boolean";
669
+ case "object": return isObject(value);
670
+ case "array": return Array.isArray(value);
671
+ case "null": return value === null;
672
+ default: return true;
673
+ }
674
+ }
675
+ function isObject(value) {
676
+ return typeof value === "object" && value !== null && !Array.isArray(value);
677
+ }
678
+ function isNumber(value) {
679
+ return typeof value === "number" && Number.isFinite(value);
680
+ }
681
+ //#endregion
582
682
  //#region src/client.ts
583
683
  /**
584
684
  * Names we recognise, matched as substrings of what a client reports.
@@ -855,6 +955,7 @@ function track(toolName, handler) {
855
955
  sdkVersion: SDK_VERSION,
856
956
  ...session !== void 0 && { sessionId: session },
857
957
  ...definition(toolName),
958
+ ...call?.repeated === true && { repeated: true },
858
959
  ...outcome
859
960
  });
860
961
  } catch {}
@@ -963,7 +1064,9 @@ function recordRefusedCall(refused) {
963
1064
  timestamp: refused.timestamp,
964
1065
  sdkVersion: SDK_VERSION,
965
1066
  ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
966
- ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName)
1067
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName),
1068
+ ...refused.repeated === true && { repeated: true },
1069
+ ...refused.errorSource === "arguments" ? refusedNames(refused.toolName, refused.arguments) : {}
967
1070
  });
968
1071
  } catch {}
969
1072
  }
@@ -1001,6 +1104,11 @@ function recordPrimitiveCall(call) {
1001
1104
  function isRecording() {
1002
1105
  return sink !== void 0;
1003
1106
  }
1107
+ /** Which declared arguments a refusal was over (contract, 3.10), as event fields. */
1108
+ function refusedNames(toolName, args) {
1109
+ const names = invalidArguments(schemaOf(toolName), args).map((name) => truncate(name, MAX_TOOL_NAME_LENGTH));
1110
+ return names.length === 0 ? {} : { invalidArguments: names };
1111
+ }
1004
1112
  /** The fingerprint of a tool as last listed (contract, 3.8), as event fields. */
1005
1113
  function definition(toolName) {
1006
1114
  const hash = definitionOf(toolName);
@@ -1097,7 +1205,8 @@ function configure(config = {}) {
1097
1205
  };
1098
1206
  setCaptureParameterNames(config.captureParameterNames ?? false);
1099
1207
  setServerVersion(firstNonEmpty(config.serverVersion, process.env["MCPSPAN_SERVER_VERSION"]));
1100
- setEventSink((event) => reporter?.record(event));
1208
+ const captureErrorMessages = config.captureErrorMessages ?? true;
1209
+ setEventSink((event) => reporter?.record(captureErrorMessages ? event : withoutErrorMessage(event)));
1101
1210
  if (config.flushOnExit ?? true) installExitHook();
1102
1211
  reporter.announce();
1103
1212
  }
@@ -1159,9 +1268,16 @@ function describeSettings(config) {
1159
1268
  config.maxBatchSize ?? null,
1160
1269
  config.maxQueueSize ?? null,
1161
1270
  config.captureParameterNames ?? false,
1271
+ config.captureErrorMessages ?? true,
1162
1272
  firstNonEmpty(config.serverVersion, process.env["MCPSPAN_SERVER_VERSION"]) ?? null
1163
1273
  ]);
1164
1274
  }
1275
+ /** The event as it is, less the text of its failure (contract, 5). */
1276
+ function withoutErrorMessage(event) {
1277
+ if (event.errorMessage === void 0) return event;
1278
+ const { errorMessage: _left, ...rest } = event;
1279
+ return rest;
1280
+ }
1165
1281
  function firstNonEmpty(...values) {
1166
1282
  for (const value of values) {
1167
1283
  const trimmed = value?.trim();
@@ -1368,6 +1484,40 @@ function watchPrimitive(handler, server) {
1368
1484
  };
1369
1485
  }
1370
1486
  //#endregion
1487
+ //#region src/repeats.ts
1488
+ /**
1489
+ * Whether a call repeats the previous call to the same tool in the same
1490
+ * session (contract, 3.9): an agent stuck in a loop.
1491
+ *
1492
+ * Only the answer leaves the process. Kept here is a SHA-256 of the canonical
1493
+ * arguments of the latest call per session and tool, never sent: a digest of
1494
+ * a short identifier or an enumerated value is found by trying every one.
1495
+ */
1496
+ /** Session and tool pairs kept, the oldest forgotten first. */
1497
+ const MAX_KEPT = 1e4;
1498
+ const latest = /* @__PURE__ */ new Map();
1499
+ /**
1500
+ * Notes a call's arguments, as the client sent them, and says whether they are
1501
+ * the previous call's to the same tool in the same session. Never throws: an
1502
+ * argument object that cannot be written down is never a repeat.
1503
+ */
1504
+ function noteArguments(sessionId, toolName, args) {
1505
+ try {
1506
+ const digest = createHash("sha256").update(canonical(args ?? {}), "utf8").digest("hex");
1507
+ const key = `${sessionId}\u0000${toolName}`;
1508
+ const previous = latest.get(key);
1509
+ latest.delete(key);
1510
+ latest.set(key, digest);
1511
+ if (latest.size > MAX_KEPT) {
1512
+ const oldest = latest.keys().next().value;
1513
+ if (oldest !== void 0) latest.delete(oldest);
1514
+ }
1515
+ return previous === digest;
1516
+ } catch {
1517
+ return false;
1518
+ }
1519
+ }
1520
+ //#endregion
1371
1521
  //#region src/instrument.ts
1372
1522
  /**
1373
1523
  * Methods an MCP server registers tools through.
@@ -1396,6 +1546,28 @@ const registries = /* @__PURE__ */ new WeakMap();
1396
1546
  * it on its own. A weak set, so contexts leave with their requests.
1397
1547
  */
1398
1548
  const reachedHandler = /* @__PURE__ */ new WeakSet();
1549
+ /** Requests whose arguments repeat the previous call's to the same tool in the session (contract, 3.9). */
1550
+ const repeatedCalls = /* @__PURE__ */ new WeakSet();
1551
+ /**
1552
+ * Compares a call's arguments, as the client sent them, with the previous call's to the same tool in the same
1553
+ * session, as it arrives, before any validation; and marks the request so the handler side knows too. A call
1554
+ * without a session is never compared.
1555
+ */
1556
+ function noteRepeat(server, request, context) {
1557
+ try {
1558
+ const name = request.params?.name;
1559
+ if (typeof name !== "string" || typeof context !== "object" || context === null) return false;
1560
+ const params = request.params;
1561
+ if (params["inputResponses"] !== void 0 || params["requestState"] !== void 0) return false;
1562
+ const sessionId = sessionFor(server, context);
1563
+ if (sessionId === void 0) return false;
1564
+ const repeated = noteArguments(sessionId, name, request.params?.arguments);
1565
+ if (repeated) repeatedCalls.add(context);
1566
+ return repeated;
1567
+ } catch {
1568
+ return false;
1569
+ }
1570
+ }
1399
1571
  /**
1400
1572
  * Request contexts whose handler last answered with an interim
1401
1573
  * `input_required` result (the 2026-07-28 protocol's way to ask the client for
@@ -1545,10 +1717,12 @@ function noteReached(handler, server) {
1545
1717
  const sessionId = hasContext ? sessionFor(server, context) : void 0;
1546
1718
  const client = clientFor(server, hasContext ? context : void 0);
1547
1719
  const serverVersion = serverVersionOf(server);
1720
+ const repeated = hasContext && repeatedCalls.has(context);
1548
1721
  const result = withCall({
1549
1722
  ...sessionId !== void 0 && { sessionId },
1550
1723
  ...client !== void 0 && { client },
1551
- ...serverVersion !== void 0 && { serverVersion }
1724
+ ...serverVersion !== void 0 && { serverVersion },
1725
+ ...repeated && { repeated }
1552
1726
  }, run);
1553
1727
  if (hasContext) noteInterim(context, result);
1554
1728
  return result;
@@ -1621,6 +1795,7 @@ function watchToolCalls(handler, server, registry) {
1621
1795
  if (!isRecording() || request?.method !== "tools/call") return call();
1622
1796
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1623
1797
  const startedAt = performance.now();
1798
+ const repeated = noteRepeat(server, request, context);
1624
1799
  const noteRefusal = (message) => {
1625
1800
  try {
1626
1801
  const reached = typeof context === "object" && context !== null && reachedHandler.has(context);
@@ -1636,7 +1811,8 @@ function watchToolCalls(handler, server, registry) {
1636
1811
  durationMs: performance.now() - startedAt,
1637
1812
  sessionId: sessionFor(server, context),
1638
1813
  client: clientFor(server, context),
1639
- serverVersion: serverVersionOf(server)
1814
+ serverVersion: serverVersionOf(server),
1815
+ repeated
1640
1816
  });
1641
1817
  return;
1642
1818
  }
@@ -1650,7 +1826,8 @@ function watchToolCalls(handler, server, registry) {
1650
1826
  durationMs: performance.now() - startedAt,
1651
1827
  sessionId: sessionFor(server, context),
1652
1828
  client: clientFor(server, context),
1653
- serverVersion: serverVersionOf(server)
1829
+ serverVersion: serverVersionOf(server),
1830
+ repeated
1654
1831
  });
1655
1832
  } catch {}
1656
1833
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpspan",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",