mcpspan 0.1.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/README.md CHANGED
@@ -208,8 +208,9 @@ out of this as well.
208
208
  mode, not in debug.
209
209
 
210
210
  What is collected: the tool name, how long it took, whether it succeeded, the
211
- error type and a truncated message when it did not, which client called, and
212
- the SDK version. For a resource or a prompt, the same, under the name it was
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
213
214
  registered with: never the address a client read, only its template or, for
214
215
  an address the server does not have, its scheme.
215
216
 
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.1.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 {}
@@ -782,15 +865,21 @@ function track(toolName, handler) {
782
865
  recorded = true;
783
866
  return;
784
867
  }
868
+ const size = responseBytes(value);
869
+ const measured = size === void 0 ? {} : { responseBytes: size };
785
870
  if (!isErrorResult(value)) {
786
- emit({ success: true });
871
+ emit({
872
+ success: true,
873
+ ...measured
874
+ });
787
875
  return;
788
876
  }
789
877
  const errorMessage = describeErrorResult(value);
790
878
  emit({
791
879
  success: false,
792
880
  errorSource: "result",
793
- ...errorMessage !== void 0 && { errorMessage }
881
+ ...errorMessage !== void 0 && { errorMessage },
882
+ ...measured
794
883
  });
795
884
  };
796
885
  const fail = (error) => {
@@ -874,7 +963,8 @@ function recordRefusedCall(refused) {
874
963
  ...parameters !== void 0 && { parameters },
875
964
  timestamp: refused.timestamp,
876
965
  sdkVersion: SDK_VERSION,
877
- ...refused.sessionId !== void 0 && { sessionId: refused.sessionId }
966
+ ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
967
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName)
878
968
  });
879
969
  } catch {}
880
970
  }
@@ -903,7 +993,8 @@ function recordPrimitiveCall(call) {
903
993
  ...parameters !== void 0 && { parameters },
904
994
  timestamp: call.timestamp,
905
995
  sdkVersion: SDK_VERSION,
906
- ...call.sessionId !== void 0 && { sessionId: call.sessionId }
996
+ ...call.sessionId !== void 0 && { sessionId: call.sessionId },
997
+ ...call.responseBytes !== void 0 && { responseBytes: call.responseBytes }
907
998
  });
908
999
  } catch {}
909
1000
  }
@@ -911,6 +1002,26 @@ function recordPrimitiveCall(call) {
911
1002
  function isRecording() {
912
1003
  return sink !== void 0;
913
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
+ }
1010
+ /** The largest size an event carries; anything larger is sent as this (contract, 3.7). */
1011
+ const MAX_RESPONSE_BYTES = 2147483647;
1012
+ /**
1013
+ * Size of an answer, in bytes of its compact JSON (contract, 3.7), or undefined
1014
+ * when it cannot be encoded. The JSON is counted and dropped: nothing of it is
1015
+ * kept or sent.
1016
+ */
1017
+ function responseBytes(value) {
1018
+ try {
1019
+ const json = JSON.stringify(value);
1020
+ return json === void 0 ? void 0 : Math.min(Buffer.byteLength(json, "utf8"), MAX_RESPONSE_BYTES);
1021
+ } catch {
1022
+ return;
1023
+ }
1024
+ }
914
1025
  /** A result the 2026-07-28 protocol calls interim: the tool needs more input first. */
915
1026
  function isInputRequired(value) {
916
1027
  return typeof value === "object" && value !== null && value.resultType === "input_required";
@@ -1226,7 +1337,8 @@ function watchPrimitive(handler, server) {
1226
1337
  durationMs: performance.now() - startedAt,
1227
1338
  sessionId: typeof context === "object" && context !== null ? sessionFor(server, context) : void 0,
1228
1339
  client: clientFor(server, typeof context === "object" && context !== null ? context : void 0),
1229
- serverVersion: serverVersionOf(server)
1340
+ serverVersion: serverVersionOf(server),
1341
+ responseBytes: outcome.responseBytes
1230
1342
  });
1231
1343
  } catch {}
1232
1344
  };
@@ -1249,7 +1361,10 @@ function watchPrimitive(handler, server) {
1249
1361
  });
1250
1362
  throw error;
1251
1363
  }
1252
- if (!(typeof result === "object" && result !== null && result.resultType === "input_required")) record({ success: true });
1364
+ if (!(typeof result === "object" && result !== null && result.resultType === "input_required")) record({
1365
+ success: true,
1366
+ responseBytes: responseBytes(result)
1367
+ });
1253
1368
  return result;
1254
1369
  };
1255
1370
  }
@@ -1469,7 +1584,11 @@ function interceptToolCalls(server, registry) {
1469
1584
  if (intercepted.has(inner)) return;
1470
1585
  intercepted.add(inner);
1471
1586
  const handlers = inner._requestHandlers;
1472
- 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
+ ]) {
1473
1592
  const installed = handlers.get(method);
1474
1593
  if (typeof installed === "function") handlers.set(method, watch(installed, server, registry));
1475
1594
  }
@@ -1487,7 +1606,15 @@ function interceptToolCalls(server, registry) {
1487
1606
  * so the handler for any other method runs exactly as it did.
1488
1607
  */
1489
1608
  function watch(handler, server, registry) {
1490
- 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
+ };
1491
1618
  }
1492
1619
  function watchToolCalls(handler, server, registry) {
1493
1620
  return async function watchedRequestHandler(request, context) {
package/dist/index.d.cts CHANGED
@@ -194,6 +194,13 @@ interface ToolCallEvent {
194
194
  * the version the MCP server gives itself.
195
195
  */
196
196
  serverVersion?: string;
197
+ /**
198
+ * Size of the answer, in bytes of compact JSON (contract, 3.7). Only when the
199
+ * call returned one; the content is counted, never kept.
200
+ */
201
+ responseBytes?: number;
202
+ /** The tool's definition as last listed, fingerprinted (contract, 3.8). */
203
+ definitionHash?: string;
197
204
  /** When the call started, as an ISO 8601 timestamp. */
198
205
  timestamp: string;
199
206
  /** Version of the mcpspan package that produced the event. */
package/dist/index.d.mts CHANGED
@@ -194,6 +194,13 @@ interface ToolCallEvent {
194
194
  * the version the MCP server gives itself.
195
195
  */
196
196
  serverVersion?: string;
197
+ /**
198
+ * Size of the answer, in bytes of compact JSON (contract, 3.7). Only when the
199
+ * call returned one; the content is counted, never kept.
200
+ */
201
+ responseBytes?: number;
202
+ /** The tool's definition as last listed, fingerprinted (contract, 3.8). */
203
+ definitionHash?: string;
197
204
  /** When the call started, as an ISO 8601 timestamp. */
198
205
  timestamp: string;
199
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.1.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 {}
@@ -781,15 +864,21 @@ function track(toolName, handler) {
781
864
  recorded = true;
782
865
  return;
783
866
  }
867
+ const size = responseBytes(value);
868
+ const measured = size === void 0 ? {} : { responseBytes: size };
784
869
  if (!isErrorResult(value)) {
785
- emit({ success: true });
870
+ emit({
871
+ success: true,
872
+ ...measured
873
+ });
786
874
  return;
787
875
  }
788
876
  const errorMessage = describeErrorResult(value);
789
877
  emit({
790
878
  success: false,
791
879
  errorSource: "result",
792
- ...errorMessage !== void 0 && { errorMessage }
880
+ ...errorMessage !== void 0 && { errorMessage },
881
+ ...measured
793
882
  });
794
883
  };
795
884
  const fail = (error) => {
@@ -873,7 +962,8 @@ function recordRefusedCall(refused) {
873
962
  ...parameters !== void 0 && { parameters },
874
963
  timestamp: refused.timestamp,
875
964
  sdkVersion: SDK_VERSION,
876
- ...refused.sessionId !== void 0 && { sessionId: refused.sessionId }
965
+ ...refused.sessionId !== void 0 && { sessionId: refused.sessionId },
966
+ ...refused.errorSource === "unknown_tool" ? {} : definition(refused.toolName)
877
967
  });
878
968
  } catch {}
879
969
  }
@@ -902,7 +992,8 @@ function recordPrimitiveCall(call) {
902
992
  ...parameters !== void 0 && { parameters },
903
993
  timestamp: call.timestamp,
904
994
  sdkVersion: SDK_VERSION,
905
- ...call.sessionId !== void 0 && { sessionId: call.sessionId }
995
+ ...call.sessionId !== void 0 && { sessionId: call.sessionId },
996
+ ...call.responseBytes !== void 0 && { responseBytes: call.responseBytes }
906
997
  });
907
998
  } catch {}
908
999
  }
@@ -910,6 +1001,26 @@ function recordPrimitiveCall(call) {
910
1001
  function isRecording() {
911
1002
  return sink !== void 0;
912
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
+ }
1009
+ /** The largest size an event carries; anything larger is sent as this (contract, 3.7). */
1010
+ const MAX_RESPONSE_BYTES = 2147483647;
1011
+ /**
1012
+ * Size of an answer, in bytes of its compact JSON (contract, 3.7), or undefined
1013
+ * when it cannot be encoded. The JSON is counted and dropped: nothing of it is
1014
+ * kept or sent.
1015
+ */
1016
+ function responseBytes(value) {
1017
+ try {
1018
+ const json = JSON.stringify(value);
1019
+ return json === void 0 ? void 0 : Math.min(Buffer.byteLength(json, "utf8"), MAX_RESPONSE_BYTES);
1020
+ } catch {
1021
+ return;
1022
+ }
1023
+ }
913
1024
  /** A result the 2026-07-28 protocol calls interim: the tool needs more input first. */
914
1025
  function isInputRequired(value) {
915
1026
  return typeof value === "object" && value !== null && value.resultType === "input_required";
@@ -1225,7 +1336,8 @@ function watchPrimitive(handler, server) {
1225
1336
  durationMs: performance.now() - startedAt,
1226
1337
  sessionId: typeof context === "object" && context !== null ? sessionFor(server, context) : void 0,
1227
1338
  client: clientFor(server, typeof context === "object" && context !== null ? context : void 0),
1228
- serverVersion: serverVersionOf(server)
1339
+ serverVersion: serverVersionOf(server),
1340
+ responseBytes: outcome.responseBytes
1229
1341
  });
1230
1342
  } catch {}
1231
1343
  };
@@ -1248,7 +1360,10 @@ function watchPrimitive(handler, server) {
1248
1360
  });
1249
1361
  throw error;
1250
1362
  }
1251
- if (!(typeof result === "object" && result !== null && result.resultType === "input_required")) record({ success: true });
1363
+ if (!(typeof result === "object" && result !== null && result.resultType === "input_required")) record({
1364
+ success: true,
1365
+ responseBytes: responseBytes(result)
1366
+ });
1252
1367
  return result;
1253
1368
  };
1254
1369
  }
@@ -1468,7 +1583,11 @@ function interceptToolCalls(server, registry) {
1468
1583
  if (intercepted.has(inner)) return;
1469
1584
  intercepted.add(inner);
1470
1585
  const handlers = inner._requestHandlers;
1471
- 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
+ ]) {
1472
1591
  const installed = handlers.get(method);
1473
1592
  if (typeof installed === "function") handlers.set(method, watch(installed, server, registry));
1474
1593
  }
@@ -1486,7 +1605,15 @@ function interceptToolCalls(server, registry) {
1486
1605
  * so the handler for any other method runs exactly as it did.
1487
1606
  */
1488
1607
  function watch(handler, server, registry) {
1489
- 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
+ };
1490
1617
  }
1491
1618
  function watchToolCalls(handler, server, registry) {
1492
1619
  return async function watchedRequestHandler(request, context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpspan",
3
- "version": "0.1.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",