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 +32 -2
- package/dist/index.cjs +184 -7
- package/dist/index.d.cts +15 -0
- package/dist/index.d.mts +15 -0
- package/dist/index.mjs +184 -7
- package/package.json +1 -1
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),
|
|
213
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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