mcpspan 0.2.0 → 0.3.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/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.3.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. */
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,7 @@ function track(toolName, handler) {
773
855
  timestamp,
774
856
  sdkVersion: SDK_VERSION,
775
857
  ...session !== void 0 && { sessionId: session },
858
+ ...definition(toolName),
776
859
  ...outcome
777
860
  });
778
861
  } catch {}
@@ -880,7 +963,8 @@ function recordRefusedCall(refused) {
880
963
  ...parameters !== void 0 && { parameters },
881
964
  timestamp: refused.timestamp,
882
965
  sdkVersion: SDK_VERSION,
883
- ...refused.sessionId !== void 0 && { sessionId: refused.sessionId }
966
+ ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
967
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName)
884
968
  });
885
969
  } catch {}
886
970
  }
@@ -918,6 +1002,11 @@ function recordPrimitiveCall(call) {
918
1002
  function isRecording() {
919
1003
  return sink !== void 0;
920
1004
  }
1005
+ /** The fingerprint of a tool as last listed (contract, 3.8), as event fields. */
1006
+ function definition(toolName) {
1007
+ const hash = definitionOf(toolName);
1008
+ return hash === void 0 ? {} : { definitionHash: hash };
1009
+ }
921
1010
  /** The largest size an event carries; anything larger is sent as this (contract, 3.7). */
922
1011
  const MAX_RESPONSE_BYTES = 2147483647;
923
1012
  /**
@@ -1495,7 +1584,11 @@ function interceptToolCalls(server, registry) {
1495
1584
  if (intercepted.has(inner)) return;
1496
1585
  intercepted.add(inner);
1497
1586
  const handlers = inner._requestHandlers;
1498
- if (handlers instanceof Map) for (const method of ["tools/call", ...PRIMITIVE_METHODS]) {
1587
+ if (handlers instanceof Map) for (const method of [
1588
+ "tools/call",
1589
+ "tools/list",
1590
+ ...PRIMITIVE_METHODS
1591
+ ]) {
1499
1592
  const installed = handlers.get(method);
1500
1593
  if (typeof installed === "function") handlers.set(method, watch(installed, server, registry));
1501
1594
  }
@@ -1513,7 +1606,15 @@ function interceptToolCalls(server, registry) {
1513
1606
  * so the handler for any other method runs exactly as it did.
1514
1607
  */
1515
1608
  function watch(handler, server, registry) {
1516
- return watchPrimitive(watchToolCalls(handler, server, registry), server);
1609
+ return watchListing(watchPrimitive(watchToolCalls(handler, server, registry), server));
1610
+ }
1611
+ /** Notes the tools a `tools/list` answer describes, for the fingerprint each call carries (contract, 3.8). */
1612
+ function watchListing(handler) {
1613
+ return async function watchedListing(request, ...rest) {
1614
+ const result = await handler.call(this, request, ...rest);
1615
+ if (request?.method === "tools/list" && isRecording()) noteListing(result);
1616
+ return result;
1617
+ };
1517
1618
  }
1518
1619
  function watchToolCalls(handler, server, registry) {
1519
1620
  return async function watchedRequestHandler(request, context) {
package/dist/index.d.cts CHANGED
@@ -199,6 +199,8 @@ 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;
202
204
  /** When the call started, as an ISO 8601 timestamp. */
203
205
  timestamp: string;
204
206
  /** Version of the mcpspan package that produced the event. */
package/dist/index.d.mts CHANGED
@@ -199,6 +199,8 @@ 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;
202
204
  /** When the call started, as an ISO 8601 timestamp. */
203
205
  timestamp: string;
204
206
  /** 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.3.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. */
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,7 @@ function track(toolName, handler) {
772
854
  timestamp,
773
855
  sdkVersion: SDK_VERSION,
774
856
  ...session !== void 0 && { sessionId: session },
857
+ ...definition(toolName),
775
858
  ...outcome
776
859
  });
777
860
  } catch {}
@@ -879,7 +962,8 @@ function recordRefusedCall(refused) {
879
962
  ...parameters !== void 0 && { parameters },
880
963
  timestamp: refused.timestamp,
881
964
  sdkVersion: SDK_VERSION,
882
- ...refused.sessionId !== void 0 && { sessionId: refused.sessionId }
965
+ ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
966
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName)
883
967
  });
884
968
  } catch {}
885
969
  }
@@ -917,6 +1001,11 @@ function recordPrimitiveCall(call) {
917
1001
  function isRecording() {
918
1002
  return sink !== void 0;
919
1003
  }
1004
+ /** The fingerprint of a tool as last listed (contract, 3.8), as event fields. */
1005
+ function definition(toolName) {
1006
+ const hash = definitionOf(toolName);
1007
+ return hash === void 0 ? {} : { definitionHash: hash };
1008
+ }
920
1009
  /** The largest size an event carries; anything larger is sent as this (contract, 3.7). */
921
1010
  const MAX_RESPONSE_BYTES = 2147483647;
922
1011
  /**
@@ -1494,7 +1583,11 @@ function interceptToolCalls(server, registry) {
1494
1583
  if (intercepted.has(inner)) return;
1495
1584
  intercepted.add(inner);
1496
1585
  const handlers = inner._requestHandlers;
1497
- if (handlers instanceof Map) for (const method of ["tools/call", ...PRIMITIVE_METHODS]) {
1586
+ if (handlers instanceof Map) for (const method of [
1587
+ "tools/call",
1588
+ "tools/list",
1589
+ ...PRIMITIVE_METHODS
1590
+ ]) {
1498
1591
  const installed = handlers.get(method);
1499
1592
  if (typeof installed === "function") handlers.set(method, watch(installed, server, registry));
1500
1593
  }
@@ -1512,7 +1605,15 @@ function interceptToolCalls(server, registry) {
1512
1605
  * so the handler for any other method runs exactly as it did.
1513
1606
  */
1514
1607
  function watch(handler, server, registry) {
1515
- return watchPrimitive(watchToolCalls(handler, server, registry), server);
1608
+ return watchListing(watchPrimitive(watchToolCalls(handler, server, registry), server));
1609
+ }
1610
+ /** Notes the tools a `tools/list` answer describes, for the fingerprint each call carries (contract, 3.8). */
1611
+ function watchListing(handler) {
1612
+ return async function watchedListing(request, ...rest) {
1613
+ const result = await handler.call(this, request, ...rest);
1614
+ if (request?.method === "tools/list" && isRecording()) noteListing(result);
1615
+ return result;
1616
+ };
1516
1617
  }
1517
1618
  function watchToolCalls(handler, server, registry) {
1518
1619
  return async function watchedRequestHandler(request, context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpspan",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",