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 +4 -2
- package/dist/index.cjs +171 -7
- package/dist/index.d.cts +4 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +172 -8
- package/package.json +1 -1
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),
|
|
213
|
-
|
|
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.
|
|
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 [
|
|
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.
|
|
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 [
|
|
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