mcpspan 0.1.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/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/index.cjs +1566 -0
- package/dist/index.d.cts +264 -0
- package/dist/index.d.mts +264 -0
- package/dist/index.mjs +1561 -0
- package/package.json +75 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1566 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_crypto = require("node:crypto");
|
|
3
|
+
/** Renders any thrown value as one readable line, for diagnostics. */
|
|
4
|
+
function formatError(error) {
|
|
5
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
6
|
+
}
|
|
7
|
+
/** Cuts text to a limit, leaving a visible sign that something was removed. */
|
|
8
|
+
function truncate(text, limit) {
|
|
9
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Whether a tool reported its own failure through the result.
|
|
13
|
+
*
|
|
14
|
+
* MCP asks tools to answer with `isError` rather than throwing, so that the
|
|
15
|
+
* model can see what went wrong and react. A wrapper that only watched for
|
|
16
|
+
* exceptions would record a correctly written server as having no errors at
|
|
17
|
+
* all.
|
|
18
|
+
*/
|
|
19
|
+
function isErrorResult(result) {
|
|
20
|
+
return typeof result === "object" && result !== null && result.isError === true;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Pulls a short description out of a tool result that reported an error.
|
|
24
|
+
*
|
|
25
|
+
* Reads only text blocks. Images and binary attachments carry no message
|
|
26
|
+
* worth storing, and copying them anywhere would be indefensible.
|
|
27
|
+
*/
|
|
28
|
+
function describeErrorResult(result) {
|
|
29
|
+
const content = result?.content;
|
|
30
|
+
if (!Array.isArray(content)) return void 0;
|
|
31
|
+
const text = content.filter((block) => typeof block === "object" && block !== null && block.type === "text" && typeof block.text === "string").map((block) => block.text).join(" ").trim();
|
|
32
|
+
return text.length > 0 ? truncate(text, 200) : void 0;
|
|
33
|
+
}
|
|
34
|
+
/** Names and summarises a thrown value, whatever it turned out to be. */
|
|
35
|
+
function describeException(error) {
|
|
36
|
+
if (error instanceof Error) return {
|
|
37
|
+
errorType: truncate(error.name, 200),
|
|
38
|
+
errorMessage: error.message.length > 0 ? truncate(error.message, 500) : void 0
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
errorType: typeof error,
|
|
42
|
+
errorMessage: truncate(String(error), 500)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/queue.ts
|
|
47
|
+
/**
|
|
48
|
+
* How many events the buffer holds before it starts discarding.
|
|
49
|
+
*
|
|
50
|
+
* The buffer has to be bounded: if the ingest endpoint is unreachable while
|
|
51
|
+
* tools keep being called, an unbounded buffer grows until the host process
|
|
52
|
+
* runs out of memory, and taking down the developer's server is the one thing
|
|
53
|
+
* this SDK must never do.
|
|
54
|
+
*
|
|
55
|
+
* The bound is a ceiling rather than a reservation. While ingest is healthy
|
|
56
|
+
* the queue drains every few seconds and holds a few dozen events, so the
|
|
57
|
+
* figure below only matters during an outage - which is exactly when we want
|
|
58
|
+
* the headroom. A measured event costs 233 B when it succeeds and 595 B when
|
|
59
|
+
* it carries a unique error message, putting the worst case here under 6 MB.
|
|
60
|
+
*
|
|
61
|
+
* At ten calls per second that covers roughly seventeen minutes of downtime,
|
|
62
|
+
* which is long enough to survive a backend restart or a network blip. A
|
|
63
|
+
* thousand would cover a hundred seconds, and lose data during an ordinary
|
|
64
|
+
* deploy.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_MAX_QUEUE_SIZE = 1e4;
|
|
67
|
+
/**
|
|
68
|
+
* In-memory buffer of events waiting to be sent.
|
|
69
|
+
*
|
|
70
|
+
* This holds events and nothing else. Deciding when to send them, and sending
|
|
71
|
+
* them, belongs to the flush mechanism that drains the queue.
|
|
72
|
+
*/
|
|
73
|
+
var EventQueue = class {
|
|
74
|
+
/** Capacity at which the oldest buffered event starts being discarded. */
|
|
75
|
+
maxSize;
|
|
76
|
+
events = [];
|
|
77
|
+
dropped = 0;
|
|
78
|
+
constructor(maxSize = DEFAULT_MAX_QUEUE_SIZE) {
|
|
79
|
+
if (!Number.isInteger(maxSize) || maxSize < 1) throw new TypeError(`maxSize must be a positive integer, received ${maxSize}`);
|
|
80
|
+
this.maxSize = maxSize;
|
|
81
|
+
}
|
|
82
|
+
/** How many events are currently buffered. */
|
|
83
|
+
get size() {
|
|
84
|
+
return this.events.length;
|
|
85
|
+
}
|
|
86
|
+
/** How many events have been discarded because the buffer was full. */
|
|
87
|
+
get droppedCount() {
|
|
88
|
+
return this.dropped;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Buffers an event, discarding the oldest one when the buffer is full.
|
|
92
|
+
*
|
|
93
|
+
* Newest wins on purpose: once the backend comes back, a developer wants to
|
|
94
|
+
* see what their server is doing now, not a snapshot frozen at the moment
|
|
95
|
+
* the outage started.
|
|
96
|
+
*/
|
|
97
|
+
add(event) {
|
|
98
|
+
if (this.events.length >= this.maxSize) {
|
|
99
|
+
this.events.shift();
|
|
100
|
+
this.dropped += 1;
|
|
101
|
+
}
|
|
102
|
+
this.events.push(event);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Removes and returns buffered events, oldest first.
|
|
106
|
+
*
|
|
107
|
+
* Without a limit this empties the queue. With one it takes at most that
|
|
108
|
+
* many, which is what keeps a backlog from being posted as a single huge
|
|
109
|
+
* request: after an outage the queue can hold thousands of events, and one
|
|
110
|
+
* request carrying all of them would be refused for size and thrown away
|
|
111
|
+
* whole.
|
|
112
|
+
*/
|
|
113
|
+
drain(limit) {
|
|
114
|
+
if (limit === void 0 || limit >= this.events.length) {
|
|
115
|
+
const drained = this.events;
|
|
116
|
+
this.events = [];
|
|
117
|
+
return drained;
|
|
118
|
+
}
|
|
119
|
+
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`limit must be a positive integer, received ${limit}`);
|
|
120
|
+
return this.events.splice(0, limit);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Puts a batch that failed to deliver back at the front of the queue.
|
|
124
|
+
*
|
|
125
|
+
* These events are older than anything queued since, so they go ahead of it
|
|
126
|
+
* and keep the stream in order. If that pushes the queue past capacity the
|
|
127
|
+
* usual rule still applies and the oldest go - which may well be the ones
|
|
128
|
+
* just restored, because an outage long enough to overflow the queue has
|
|
129
|
+
* already made them the least interesting events we hold.
|
|
130
|
+
*/
|
|
131
|
+
restore(events) {
|
|
132
|
+
if (events.length === 0) return;
|
|
133
|
+
this.events = events.concat(this.events);
|
|
134
|
+
const overflow = this.events.length - this.maxSize;
|
|
135
|
+
if (overflow > 0) {
|
|
136
|
+
this.events.splice(0, overflow);
|
|
137
|
+
this.dropped += overflow;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/version.ts
|
|
143
|
+
/**
|
|
144
|
+
* Version this build of the SDK reports, in telemetry and in its User-Agent.
|
|
145
|
+
*
|
|
146
|
+
* Single source of truth: `package.json` follows this constant, not the other
|
|
147
|
+
* way round, and a unit test fails if the two ever drift apart.
|
|
148
|
+
*/
|
|
149
|
+
const SDK_VERSION = "0.1.0";
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/transport.ts
|
|
152
|
+
/** Path the ingest API accepts batches on, appended to the configured endpoint. */
|
|
153
|
+
const EVENTS_PATH = "/v1/events";
|
|
154
|
+
/**
|
|
155
|
+
* A delivery attempt that did not succeed.
|
|
156
|
+
*
|
|
157
|
+
* `retryable` says whether sending the same batch again could plausibly work.
|
|
158
|
+
* A refused API key or a malformed payload will be refused identically every
|
|
159
|
+
* time, so repeating those attempts only burns the developer's bandwidth.
|
|
160
|
+
*/
|
|
161
|
+
var TransportError = class extends Error {
|
|
162
|
+
name = "TransportError";
|
|
163
|
+
/** HTTP status the ingest API answered with, absent when the request never completed. */
|
|
164
|
+
status;
|
|
165
|
+
retryable;
|
|
166
|
+
/** How long the ingest API asked to be left alone, from `Retry-After`, when it said. */
|
|
167
|
+
retryAfterMs;
|
|
168
|
+
constructor(message, options) {
|
|
169
|
+
super(message, { cause: options.cause });
|
|
170
|
+
this.status = options.status;
|
|
171
|
+
this.retryable = options.retryable;
|
|
172
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Whether a batch rejected with this status is worth sending again.
|
|
177
|
+
*
|
|
178
|
+
* Retry on the statuses that describe a passing condition: the server asked us
|
|
179
|
+
* to slow down, timed the request out, or failed on its own side. Anything else
|
|
180
|
+
* in the 4xx range is a verdict on the request itself and will not change.
|
|
181
|
+
*/
|
|
182
|
+
function isRetryableStatus(status) {
|
|
183
|
+
return status === 408 || status === 429 || status >= 500;
|
|
184
|
+
}
|
|
185
|
+
/** Joins the configured endpoint with the events path, tolerating a trailing slash. */
|
|
186
|
+
function buildEventsUrl(endpoint) {
|
|
187
|
+
return `${endpoint.replace(/\/+$/, "")}${EVENTS_PATH}`;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Delivers one batch of events to the ingest API.
|
|
191
|
+
*
|
|
192
|
+
* Throws {@link TransportError} on any outcome that is not an accepted batch.
|
|
193
|
+
* Callers are expected to decide what to do about that; this function neither
|
|
194
|
+
* retries nor swallows.
|
|
195
|
+
*/
|
|
196
|
+
async function sendEvents(events, config) {
|
|
197
|
+
const url = buildEventsUrl(config.endpoint);
|
|
198
|
+
const timeoutMs = config.timeoutMs ?? 1e4;
|
|
199
|
+
let response;
|
|
200
|
+
try {
|
|
201
|
+
response = await fetch(url, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: {
|
|
204
|
+
"content-type": "application/json",
|
|
205
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
206
|
+
"user-agent": `mcpspan/${SDK_VERSION} (typescript)`
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify({ events }),
|
|
209
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
210
|
+
});
|
|
211
|
+
} catch (cause) {
|
|
212
|
+
throw new TransportError(`Failed to reach ${url}`, {
|
|
213
|
+
retryable: true,
|
|
214
|
+
cause
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
219
|
+
throw new TransportError(`Ingest API rejected the batch with ${response.status}`, {
|
|
220
|
+
status: response.status,
|
|
221
|
+
retryable: isRetryableStatus(response.status),
|
|
222
|
+
...retryAfterMs !== void 0 && { retryAfterMs }
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Longest wait a `Retry-After` is followed to.
|
|
228
|
+
*
|
|
229
|
+
* A server asking for longer is either wrong or unwell, and a telemetry queue
|
|
230
|
+
* that stops for a day on its word loses the day. Past this the SDK waits
|
|
231
|
+
* this long and asks again.
|
|
232
|
+
*/
|
|
233
|
+
const MAX_RETRY_AFTER_MS = 3e5;
|
|
234
|
+
/**
|
|
235
|
+
* Reads `Retry-After`, in either of its forms: whole seconds, or a date.
|
|
236
|
+
*
|
|
237
|
+
* Undefined when absent or unreadable, which leaves the SDK's own backoff to
|
|
238
|
+
* decide.
|
|
239
|
+
*/
|
|
240
|
+
function parseRetryAfter(header, now = Date.now()) {
|
|
241
|
+
if (header === null) return void 0;
|
|
242
|
+
const trimmed = header.trim();
|
|
243
|
+
let ms;
|
|
244
|
+
if (/^\d+$/.test(trimmed)) ms = Number(trimmed) * 1e3;
|
|
245
|
+
else {
|
|
246
|
+
const at = Date.parse(trimmed);
|
|
247
|
+
if (Number.isNaN(at)) return void 0;
|
|
248
|
+
ms = at - now;
|
|
249
|
+
}
|
|
250
|
+
return Math.min(Math.max(ms, 0), MAX_RETRY_AFTER_MS);
|
|
251
|
+
}
|
|
252
|
+
/** Delay before the first retry, doubling with each further failure. */
|
|
253
|
+
const INITIAL_RETRY_DELAY_MS = 1e3;
|
|
254
|
+
/** Ceiling for the retry delay, so a long outage settles into steady polling. */
|
|
255
|
+
const MAX_RETRY_DELAY_MS = 6e4;
|
|
256
|
+
/**
|
|
257
|
+
* How long to wait before attempting delivery again.
|
|
258
|
+
*
|
|
259
|
+
* Doubles per consecutive failure up to a ceiling, then spreads each client's
|
|
260
|
+
* attempt across the second half of that window. The spread matters once many
|
|
261
|
+
* servers report to the same endpoint: without it they would all have failed
|
|
262
|
+
* at the same moment, and would all come back at the same moment, turning one
|
|
263
|
+
* outage into a second one at the point of recovery.
|
|
264
|
+
*/
|
|
265
|
+
function computeBackoffMs(consecutiveFailures, random = Math.random) {
|
|
266
|
+
const ceiling = Math.min(MAX_RETRY_DELAY_MS, INITIAL_RETRY_DELAY_MS * 2 ** (consecutiveFailures - 1));
|
|
267
|
+
return Math.round(ceiling / 2 + random() * (ceiling / 2));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Collects events and delivers them in the background.
|
|
271
|
+
*
|
|
272
|
+
* `record` is the only method a tool call touches, and it does nothing beyond
|
|
273
|
+
* appending to an in-memory queue: the calling handler returns to the agent
|
|
274
|
+
* without waiting on the network. Delivery happens on an interval, or as soon
|
|
275
|
+
* as a full batch has accumulated, whichever comes first.
|
|
276
|
+
*
|
|
277
|
+
* Nothing that happens during delivery is allowed to surface anywhere near the
|
|
278
|
+
* developer's code. A failure is contained, logged when asked for, and either
|
|
279
|
+
* retried or abandoned depending on whether repeating it could ever work.
|
|
280
|
+
*/
|
|
281
|
+
var EventReporter = class {
|
|
282
|
+
queue;
|
|
283
|
+
transport;
|
|
284
|
+
flushIntervalMs;
|
|
285
|
+
maxBatchSize;
|
|
286
|
+
debug;
|
|
287
|
+
onDiagnostic;
|
|
288
|
+
timer;
|
|
289
|
+
inFlight;
|
|
290
|
+
stopped = false;
|
|
291
|
+
consecutiveFailures = 0;
|
|
292
|
+
nextAttemptAt = 0;
|
|
293
|
+
/** Discards already mentioned, so the same loss is not reported twice. */
|
|
294
|
+
reportedDrops = 0;
|
|
295
|
+
/** Set when the endpoint rejected our credentials, which no retry can fix. */
|
|
296
|
+
rejected = false;
|
|
297
|
+
constructor(options) {
|
|
298
|
+
this.queue = new EventQueue(options.maxQueueSize ?? 1e4);
|
|
299
|
+
this.transport = {
|
|
300
|
+
endpoint: options.endpoint,
|
|
301
|
+
apiKey: options.apiKey,
|
|
302
|
+
...options.timeoutMs !== void 0 && { timeoutMs: options.timeoutMs }
|
|
303
|
+
};
|
|
304
|
+
this.flushIntervalMs = options.flushIntervalMs ?? 5e3;
|
|
305
|
+
this.maxBatchSize = options.maxBatchSize ?? 100;
|
|
306
|
+
this.debug = options.debug ?? false;
|
|
307
|
+
this.onDiagnostic = options.onDiagnostic;
|
|
308
|
+
}
|
|
309
|
+
/** How many events are waiting to be delivered. */
|
|
310
|
+
get queueSize() {
|
|
311
|
+
return this.queue.size;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Queues an event for delivery and returns immediately.
|
|
315
|
+
*
|
|
316
|
+
* Synchronous and non-blocking by contract: this sits on the path of every
|
|
317
|
+
* tool call, so anything slower would show up as latency in the developer's
|
|
318
|
+
* own product.
|
|
319
|
+
*/
|
|
320
|
+
record(event) {
|
|
321
|
+
if (this.stopped || this.rejected) return;
|
|
322
|
+
this.queue.add(event);
|
|
323
|
+
this.startTimer();
|
|
324
|
+
if (this.queue.size >= this.maxBatchSize) this.flush();
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Tells the ingest API this process has started, before any tool is called.
|
|
328
|
+
*
|
|
329
|
+
* An empty batch, sent once. It carries nothing, but it proves the endpoint
|
|
330
|
+
* and the key work, which is otherwise unknowable until the first tool call:
|
|
331
|
+
* a server nobody has used yet and a server pointed at the wrong address
|
|
332
|
+
* look identical from the dashboard. It also means a wrong key is reported
|
|
333
|
+
* when the server starts, rather than whenever somebody first calls a tool.
|
|
334
|
+
*
|
|
335
|
+
* An empty batch rather than a new endpoint, because every version of the
|
|
336
|
+
* ingest API has accepted one, so this works against an installation older
|
|
337
|
+
* than the SDK talking to it.
|
|
338
|
+
*
|
|
339
|
+
* Never retried and never rejects. Missing the announcement costs a line on
|
|
340
|
+
* a status page, and the first real batch says the same thing anyway.
|
|
341
|
+
*/
|
|
342
|
+
async announce() {
|
|
343
|
+
if (this.stopped || this.rejected) return;
|
|
344
|
+
try {
|
|
345
|
+
await sendEvents([], this.transport);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
const status = error instanceof TransportError ? error.status : void 0;
|
|
348
|
+
if (status === 401 || status === 403) {
|
|
349
|
+
this.reject(status);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
this.log(`mcpspan: could not announce this server to ${this.transport.endpoint} (${formatError(error)}). Events will still be delivered once it answers.`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Delivers everything queued, in batches.
|
|
357
|
+
*
|
|
358
|
+
* Does nothing while a retry delay is still running. Concurrent calls join
|
|
359
|
+
* the flush already in progress rather than starting a second one, so the
|
|
360
|
+
* same events are never posted twice.
|
|
361
|
+
*/
|
|
362
|
+
flush() {
|
|
363
|
+
return this.runFlush(false);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Stops background delivery and makes a final attempt at whatever is queued.
|
|
367
|
+
*
|
|
368
|
+
* Ignores any pending retry delay: this is the last chance these events get,
|
|
369
|
+
* and without it the final partly filled batch dies with the process - which
|
|
370
|
+
* on a short-lived stdio server can mean most of a session.
|
|
371
|
+
*/
|
|
372
|
+
async stop() {
|
|
373
|
+
this.stopped = true;
|
|
374
|
+
this.clearTimer();
|
|
375
|
+
await this.runFlush(true);
|
|
376
|
+
}
|
|
377
|
+
runFlush(force) {
|
|
378
|
+
if (this.rejected) return Promise.resolve();
|
|
379
|
+
if (!force && Date.now() < this.nextAttemptAt) return Promise.resolve();
|
|
380
|
+
this.inFlight ??= this.drainQueue().finally(() => {
|
|
381
|
+
this.inFlight = void 0;
|
|
382
|
+
});
|
|
383
|
+
return this.inFlight;
|
|
384
|
+
}
|
|
385
|
+
async drainQueue() {
|
|
386
|
+
this.reportDrops();
|
|
387
|
+
while (this.queue.size > 0) {
|
|
388
|
+
const batch = this.queue.drain(this.maxBatchSize);
|
|
389
|
+
try {
|
|
390
|
+
await sendEvents(batch, this.transport);
|
|
391
|
+
this.onDelivered();
|
|
392
|
+
} catch (error) {
|
|
393
|
+
this.onFailed(batch, error);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Mentions events the queue had to throw away since the last time we looked.
|
|
400
|
+
*
|
|
401
|
+
* A full queue means the dashboard is about to under-report, and a developer
|
|
402
|
+
* chasing a discrepancy has no other way to find that out. Counting drops
|
|
403
|
+
* without ever saying so would make the count a decoration.
|
|
404
|
+
*/
|
|
405
|
+
reportDrops() {
|
|
406
|
+
const dropped = this.queue.droppedCount - this.reportedDrops;
|
|
407
|
+
if (dropped <= 0) return;
|
|
408
|
+
this.reportedDrops = this.queue.droppedCount;
|
|
409
|
+
this.log(`mcpspan: discarded ${dropped} events, the queue was full`);
|
|
410
|
+
}
|
|
411
|
+
onDelivered() {
|
|
412
|
+
this.consecutiveFailures = 0;
|
|
413
|
+
this.nextAttemptAt = 0;
|
|
414
|
+
}
|
|
415
|
+
onFailed(batch, error) {
|
|
416
|
+
const retryable = error instanceof TransportError ? error.retryable : true;
|
|
417
|
+
const status = error instanceof TransportError ? error.status : void 0;
|
|
418
|
+
if (status === 401 || status === 403) {
|
|
419
|
+
this.reject(status);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (retryable) this.queue.restore(batch);
|
|
423
|
+
else this.log(`mcpspan: dropped ${batch.length} events, rejected as ${String(status)}`);
|
|
424
|
+
this.consecutiveFailures += 1;
|
|
425
|
+
const asked = error instanceof TransportError ? error.retryAfterMs ?? 0 : 0;
|
|
426
|
+
const backoff = computeBackoffMs(this.consecutiveFailures);
|
|
427
|
+
this.nextAttemptAt = Date.now() + Math.max(backoff, asked);
|
|
428
|
+
this.log(`mcpspan: delivery failed (${formatError(error)}), attempt ${this.consecutiveFailures}`);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Gives up on a key the endpoint refused.
|
|
432
|
+
*
|
|
433
|
+
* The key will be refused identically until the developer changes it and
|
|
434
|
+
* restarts, so keeping events for it only wastes their memory. This one
|
|
435
|
+
* warns without being asked: a silent SDK that collects nothing because of a
|
|
436
|
+
* mistyped key is the worst way for someone to spend an afternoon.
|
|
437
|
+
*/
|
|
438
|
+
reject(status) {
|
|
439
|
+
if (this.rejected) return;
|
|
440
|
+
this.rejected = true;
|
|
441
|
+
this.clearTimer();
|
|
442
|
+
this.queue.drain();
|
|
443
|
+
this.warn(`mcpspan: the ingest endpoint rejected the API key (HTTP ${status}). Telemetry is now disabled for this process.`);
|
|
444
|
+
}
|
|
445
|
+
startTimer() {
|
|
446
|
+
if (this.timer !== void 0 || this.stopped || this.rejected) return;
|
|
447
|
+
this.timer = setInterval(() => {
|
|
448
|
+
this.flush();
|
|
449
|
+
}, this.flushIntervalMs);
|
|
450
|
+
this.timer.unref?.();
|
|
451
|
+
}
|
|
452
|
+
clearTimer() {
|
|
453
|
+
if (this.timer === void 0) return;
|
|
454
|
+
clearInterval(this.timer);
|
|
455
|
+
this.timer = void 0;
|
|
456
|
+
}
|
|
457
|
+
log(message) {
|
|
458
|
+
if (this.debug) this.warn(message);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The developer's own logger if they gave us one, otherwise stderr.
|
|
462
|
+
*
|
|
463
|
+
* Never stdout. On a stdio transport stdout carries the MCP protocol itself,
|
|
464
|
+
* so a stray line printed there does not just look untidy - it corrupts the
|
|
465
|
+
* stream and breaks the developer's server.
|
|
466
|
+
*/
|
|
467
|
+
warn(message) {
|
|
468
|
+
try {
|
|
469
|
+
if (this.onDiagnostic) {
|
|
470
|
+
this.onDiagnostic(message);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
console.error(message);
|
|
474
|
+
} catch {}
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/call.ts
|
|
479
|
+
let current;
|
|
480
|
+
/**
|
|
481
|
+
* Runs a handler with its call context known to `track`.
|
|
482
|
+
*
|
|
483
|
+
* `track` reads the context synchronously, as the call begins, before anything
|
|
484
|
+
* the handler does could start another call, so a plain variable is exact here
|
|
485
|
+
* and needs nothing like async context tracking.
|
|
486
|
+
*/
|
|
487
|
+
function withCall(call, run) {
|
|
488
|
+
const previous = current;
|
|
489
|
+
current = call;
|
|
490
|
+
try {
|
|
491
|
+
return run();
|
|
492
|
+
} finally {
|
|
493
|
+
current = previous;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** The context of the call now starting, if it arrived through `instrument`. */
|
|
497
|
+
function currentCall() {
|
|
498
|
+
return current;
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/client.ts
|
|
502
|
+
/**
|
|
503
|
+
* Names we recognise, matched as substrings of what a client reports.
|
|
504
|
+
*
|
|
505
|
+
* Substring rather than exact: clients append platform and channel suffixes to
|
|
506
|
+
* their names, and an exact list would quietly rot into a table of `other`
|
|
507
|
+
* with every release someone else ships. That is not a hypothetical - the
|
|
508
|
+
* official Inspector reports itself as `inspector-cli`, which an exact list
|
|
509
|
+
* of the obvious names would have missed on the first try.
|
|
510
|
+
*
|
|
511
|
+
* Order matters. More specific entries come first, so a client calling itself
|
|
512
|
+
* "claude-code" is not read as plain Claude.
|
|
513
|
+
*/
|
|
514
|
+
const KNOWN_CLIENTS = [
|
|
515
|
+
["claude-code", "claude-code"],
|
|
516
|
+
["claude code", "claude-code"],
|
|
517
|
+
["claude", "claude"],
|
|
518
|
+
["cursor", "cursor"],
|
|
519
|
+
["chatgpt", "chatgpt"],
|
|
520
|
+
["openai", "chatgpt"],
|
|
521
|
+
["inspector", "mcp-inspector"]
|
|
522
|
+
];
|
|
523
|
+
/**
|
|
524
|
+
* Works out which application a tool call came from.
|
|
525
|
+
*
|
|
526
|
+
* Returns `unknown` when nothing identified itself: a call not made through
|
|
527
|
+
* `instrument`, or a request that named no client. A name we
|
|
528
|
+
* do not have in the table gives `other` - the client is there, we just have
|
|
529
|
+
* not met it.
|
|
530
|
+
*/
|
|
531
|
+
function detectClient(info) {
|
|
532
|
+
const name = info?.name?.trim().toLowerCase();
|
|
533
|
+
if (!name) return "unknown";
|
|
534
|
+
for (const [pattern, type] of KNOWN_CLIENTS) if (name.includes(pattern)) return type;
|
|
535
|
+
return "other";
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* The name a client reported, as it reported it.
|
|
539
|
+
*
|
|
540
|
+
* Kept alongside the recognised type so an unfamiliar client is a lead rather
|
|
541
|
+
* than a dead end: a dashboard showing forty percent `other` is useless if
|
|
542
|
+
* nobody can find out what `other` was. This is an application's own
|
|
543
|
+
* self-description, the MCP equivalent of a User-Agent, and carries nothing
|
|
544
|
+
* about whoever is using it.
|
|
545
|
+
*/
|
|
546
|
+
function clientName(info) {
|
|
547
|
+
const name = info?.name?.trim();
|
|
548
|
+
return name && name.length > 0 ? truncate(name, 200) : void 0;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Where a request on the 2026-07-28 protocol names its client. There is no
|
|
552
|
+
* `initialize` handshake on that revision; a client identifies itself on every
|
|
553
|
+
* request instead, in the request's `_meta`.
|
|
554
|
+
*/
|
|
555
|
+
const CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo";
|
|
556
|
+
/**
|
|
557
|
+
* The client that sent one call, from what the MCP server knows about it.
|
|
558
|
+
*
|
|
559
|
+
* On the 2026-07-28 protocol the request says so itself, and that is read
|
|
560
|
+
* first. Otherwise it comes from the `initialize` handshake, held by the
|
|
561
|
+
* server instance the call arrived on - that instance, not whichever server
|
|
562
|
+
* was instrumented last, because one process can serve several clients at
|
|
563
|
+
* once.
|
|
564
|
+
*
|
|
565
|
+
* A stateless HTTP server on the 2025 protocol may build a fresh instance for
|
|
566
|
+
* each request, one that never saw the handshake, and there nothing names the
|
|
567
|
+
* client at all. The call is recorded as coming from an unknown client rather
|
|
568
|
+
* than from a guess.
|
|
569
|
+
*/
|
|
570
|
+
function clientFor(server, context) {
|
|
571
|
+
const request = context ?? {};
|
|
572
|
+
const declared = request.mcpReq?.envelope?.[CLIENT_INFO_META_KEY] ?? request.mcpReq?._meta?.[CLIENT_INFO_META_KEY] ?? request._meta?.[CLIENT_INFO_META_KEY];
|
|
573
|
+
if (isClientInfo(declared)) return declared;
|
|
574
|
+
try {
|
|
575
|
+
return server.server?.getClientVersion?.();
|
|
576
|
+
} catch {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* The version an MCP server gives itself, as it was built: `new McpServer({
|
|
582
|
+
* name, version })`. Both major versions of the official SDK keep it in the
|
|
583
|
+
* same private field of the protocol-level server; if a version moves it, calls
|
|
584
|
+
* go without one rather than with a guess.
|
|
585
|
+
*/
|
|
586
|
+
function serverVersionOf(server) {
|
|
587
|
+
try {
|
|
588
|
+
const inner = server.server;
|
|
589
|
+
const own = server._serverInfo;
|
|
590
|
+
const version = inner?._serverInfo?.version ?? own?.version;
|
|
591
|
+
return typeof version === "string" && version.trim().length > 0 ? version.trim() : void 0;
|
|
592
|
+
} catch {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function isClientInfo(value) {
|
|
597
|
+
return typeof value === "object" && value !== null && (typeof value.name === "string" || typeof value.version === "string");
|
|
598
|
+
}
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/marks.ts
|
|
601
|
+
/**
|
|
602
|
+
* Handlers that instrumentation should leave alone.
|
|
603
|
+
*
|
|
604
|
+
* Two different situations land here. A handler already wrapped by `track` is
|
|
605
|
+
* marked so that wrapping a whole server afterwards does not count every call
|
|
606
|
+
* twice. A handler passed through `exclude` is marked so that it is never
|
|
607
|
+
* counted at all.
|
|
608
|
+
*
|
|
609
|
+
* A weak set rather than a property on the function itself: these are the
|
|
610
|
+
* developer's own functions, and a telemetry library has no business writing
|
|
611
|
+
* anything onto them. Entries disappear with the handlers they refer to.
|
|
612
|
+
*/
|
|
613
|
+
const marked = /* @__PURE__ */ new WeakSet();
|
|
614
|
+
/** Records that this function should not be wrapped again. */
|
|
615
|
+
function markHandler(handler) {
|
|
616
|
+
marked.add(handler);
|
|
617
|
+
return handler;
|
|
618
|
+
}
|
|
619
|
+
/** Whether this function has already been spoken for. */
|
|
620
|
+
function isMarked(handler) {
|
|
621
|
+
return typeof handler === "function" && marked.has(handler);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Handlers passed through `exclude`, as opposed to already tracked.
|
|
625
|
+
*
|
|
626
|
+
* The difference only matters for calls the server refuses before any handler
|
|
627
|
+
* runs: a call to a tracked tool with bad arguments is counted, while one to
|
|
628
|
+
* an excluded tool is not, in that form or any other.
|
|
629
|
+
*/
|
|
630
|
+
const excluded = /* @__PURE__ */ new WeakSet();
|
|
631
|
+
function markExcluded(handler) {
|
|
632
|
+
excluded.add(handler);
|
|
633
|
+
return markHandler(handler);
|
|
634
|
+
}
|
|
635
|
+
function isExcluded(handler) {
|
|
636
|
+
return typeof handler === "function" && excluded.has(handler);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Names the shape of a value without touching what is in it.
|
|
640
|
+
*
|
|
641
|
+
* Deliberately coarse. Anything finer starts describing content, and the
|
|
642
|
+
* distance between "this is a 34 character string" and "this is a credit card
|
|
643
|
+
* number" is shorter than it looks.
|
|
644
|
+
*/
|
|
645
|
+
function describeType(value) {
|
|
646
|
+
if (value === null) return "null";
|
|
647
|
+
if (Array.isArray(value)) return "array";
|
|
648
|
+
return typeof value;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Lists the parameters a tool was called with, by name and type only.
|
|
652
|
+
*
|
|
653
|
+
* Values never leave the handler. This exists so a developer can see that
|
|
654
|
+
* `search_flights` is being called with `destination` but never with
|
|
655
|
+
* `departureDate`, which is a real debugging need, without any of the answers
|
|
656
|
+
* to those parameters reaching a server.
|
|
657
|
+
*
|
|
658
|
+
* Only the first argument is read, which is where MCP puts a tool's parameter
|
|
659
|
+
* object. Anything else in the signature belongs to the protocol, not the
|
|
660
|
+
* tool.
|
|
661
|
+
*/
|
|
662
|
+
function describeParameters(args) {
|
|
663
|
+
const [params] = args;
|
|
664
|
+
if (typeof params !== "object" || params === null || Array.isArray(params)) return;
|
|
665
|
+
const described = {};
|
|
666
|
+
let count = 0;
|
|
667
|
+
for (const [name, value] of Object.entries(params)) {
|
|
668
|
+
if (count >= 50) break;
|
|
669
|
+
described[truncate(name, 200)] = describeType(value);
|
|
670
|
+
count += 1;
|
|
671
|
+
}
|
|
672
|
+
return count > 0 ? described : void 0;
|
|
673
|
+
}
|
|
674
|
+
//#endregion
|
|
675
|
+
//#region src/track.ts
|
|
676
|
+
let sink;
|
|
677
|
+
let captureParameterNames = false;
|
|
678
|
+
let configuredServerVersion;
|
|
679
|
+
/** The API takes a version of at most this many characters; longer is cut rather than lose the batch. */
|
|
680
|
+
const MAX_VERSION_LENGTH = 100;
|
|
681
|
+
/**
|
|
682
|
+
* Records every call under this version, whatever the server gives itself.
|
|
683
|
+
*
|
|
684
|
+
* Internal: set by configuration from its `serverVersion` setting.
|
|
685
|
+
*/
|
|
686
|
+
function setServerVersion(version) {
|
|
687
|
+
configuredServerVersion = version;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Who called and which version answered, as every kind of event carries it.
|
|
691
|
+
* In one place, so a tool call, a refusal and a resource read cannot drift.
|
|
692
|
+
*/
|
|
693
|
+
function identity(client, serverVersion) {
|
|
694
|
+
const name = clientName(client);
|
|
695
|
+
const clientVersion = client?.version?.trim();
|
|
696
|
+
const version = configuredServerVersion ?? serverVersion;
|
|
697
|
+
return {
|
|
698
|
+
clientType: detectClient(client),
|
|
699
|
+
...name !== void 0 && { clientName: name },
|
|
700
|
+
...clientVersion ? { clientVersion: truncate(clientVersion, MAX_VERSION_LENGTH) } : {},
|
|
701
|
+
...version ? { serverVersion: truncate(version, MAX_VERSION_LENGTH) } : {}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Turns on recording of parameter names and types.
|
|
706
|
+
*
|
|
707
|
+
* Off unless a developer asks for it, and even then values are never read.
|
|
708
|
+
*
|
|
709
|
+
* Internal, not part of the package's public API.
|
|
710
|
+
*/
|
|
711
|
+
function setCaptureParameterNames(enabled) {
|
|
712
|
+
captureParameterNames = enabled;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Points recorded events somewhere, or nowhere.
|
|
716
|
+
*
|
|
717
|
+
* Passing `undefined` turns collection off: wrapped handlers then run through
|
|
718
|
+
* an early return, without a timestamp, an identifier or an event ever being
|
|
719
|
+
* built. That is the state an unconfigured SDK sits in, and it has to cost
|
|
720
|
+
* nothing.
|
|
721
|
+
*
|
|
722
|
+
* Internal: this is how configuration attaches a reporter to the wrapper, and
|
|
723
|
+
* how tests observe what a wrapped call produced. It is not part of the
|
|
724
|
+
* package's public API.
|
|
725
|
+
*/
|
|
726
|
+
function setEventSink(next) {
|
|
727
|
+
sink = next;
|
|
728
|
+
}
|
|
729
|
+
function isPromiseLike(value) {
|
|
730
|
+
return typeof value === "object" && value !== null && typeof value.then === "function";
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Wraps a tool handler so its calls are recorded.
|
|
734
|
+
*
|
|
735
|
+
* The returned function keeps the handler's exact signature, including its
|
|
736
|
+
* `this`, and passes the result back untouched. Whatever the handler is to the
|
|
737
|
+
* code around it, the wrapper has to be indistinguishable - the moment using
|
|
738
|
+
* this changes how a tool behaves, it stops being an observability library and
|
|
739
|
+
* becomes a liability.
|
|
740
|
+
*
|
|
741
|
+
* Synchronous handlers are measured as they return. Asynchronous ones are
|
|
742
|
+
* measured when their promise settles, since the time a tool takes is the time
|
|
743
|
+
* the agent waits for it, not the time spent building the promise.
|
|
744
|
+
*
|
|
745
|
+
* Failure is recognised in both of its MCP forms: a result the tool marked
|
|
746
|
+
* with `isError`, which the specification treats as the ordinary way to report
|
|
747
|
+
* a problem, and a thrown exception, which usually means the handler broke.
|
|
748
|
+
*
|
|
749
|
+
* Arguments are never read. Nothing a caller passes to a tool reaches an
|
|
750
|
+
* event.
|
|
751
|
+
*/
|
|
752
|
+
function track(toolName, handler) {
|
|
753
|
+
const recordedName = truncate(toolName, MAX_TOOL_NAME_LENGTH);
|
|
754
|
+
return markHandler(function tracked(...args) {
|
|
755
|
+
const active = sink;
|
|
756
|
+
if (active === void 0) return handler.apply(this, args);
|
|
757
|
+
const call = currentCall();
|
|
758
|
+
const session = call?.sessionId;
|
|
759
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
760
|
+
const startedAt = performance.now();
|
|
761
|
+
let recorded = false;
|
|
762
|
+
const emit = (outcome) => {
|
|
763
|
+
if (recorded) return;
|
|
764
|
+
recorded = true;
|
|
765
|
+
const parameters = captureParameterNames ? describeParameters(args) : void 0;
|
|
766
|
+
try {
|
|
767
|
+
active({
|
|
768
|
+
id: (0, node_crypto.randomUUID)(),
|
|
769
|
+
toolName: recordedName,
|
|
770
|
+
durationMs: performance.now() - startedAt,
|
|
771
|
+
...identity(call?.client, call?.serverVersion),
|
|
772
|
+
...parameters !== void 0 && { parameters },
|
|
773
|
+
timestamp,
|
|
774
|
+
sdkVersion: SDK_VERSION,
|
|
775
|
+
...session !== void 0 && { sessionId: session },
|
|
776
|
+
...outcome
|
|
777
|
+
});
|
|
778
|
+
} catch {}
|
|
779
|
+
};
|
|
780
|
+
const settle = (value) => {
|
|
781
|
+
if (isInputRequired(value)) {
|
|
782
|
+
recorded = true;
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (!isErrorResult(value)) {
|
|
786
|
+
emit({ success: true });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const errorMessage = describeErrorResult(value);
|
|
790
|
+
emit({
|
|
791
|
+
success: false,
|
|
792
|
+
errorSource: "result",
|
|
793
|
+
...errorMessage !== void 0 && { errorMessage }
|
|
794
|
+
});
|
|
795
|
+
};
|
|
796
|
+
const fail = (error) => {
|
|
797
|
+
const { errorType, errorMessage } = describeException(error);
|
|
798
|
+
emit({
|
|
799
|
+
success: false,
|
|
800
|
+
errorSource: "exception",
|
|
801
|
+
errorType,
|
|
802
|
+
...errorMessage !== void 0 && { errorMessage }
|
|
803
|
+
});
|
|
804
|
+
};
|
|
805
|
+
let result;
|
|
806
|
+
try {
|
|
807
|
+
result = handler.apply(this, args);
|
|
808
|
+
} catch (error) {
|
|
809
|
+
fail(error);
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
if (isPromiseLike(result)) return result.then((value) => {
|
|
813
|
+
settle(value);
|
|
814
|
+
return value;
|
|
815
|
+
}, (error) => {
|
|
816
|
+
fail(error);
|
|
817
|
+
throw error;
|
|
818
|
+
});
|
|
819
|
+
settle(result);
|
|
820
|
+
return result;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Keeps a tool out of the numbers entirely.
|
|
825
|
+
*
|
|
826
|
+
* The counterpart to {@link track}: where that one records a handler, this one
|
|
827
|
+
* marks it so that wrapping the server never touches it.
|
|
828
|
+
*
|
|
829
|
+
* ```ts
|
|
830
|
+
* server.registerTool('health_check', schema, exclude(handler));
|
|
831
|
+
* ```
|
|
832
|
+
*
|
|
833
|
+
* Meant for tools that are called by machinery rather than by an agent - a
|
|
834
|
+
* health check polled every few seconds would outnumber everything a person
|
|
835
|
+
* actually did, and would drag the error rate and response time of the whole
|
|
836
|
+
* server towards its own.
|
|
837
|
+
*
|
|
838
|
+
* Takes no tool name on purpose. A name repeated here could drift from the
|
|
839
|
+
* real one during a rename, and the exclusion would quietly stop applying.
|
|
840
|
+
*
|
|
841
|
+
* Does nothing on its own: without instrumentation there was nothing about to
|
|
842
|
+
* record this handler anyway. The handler is returned exactly as given.
|
|
843
|
+
*/
|
|
844
|
+
function exclude(handler) {
|
|
845
|
+
return markExcluded(handler);
|
|
846
|
+
}
|
|
847
|
+
/** Longest tool name recorded, for handlers and refused calls alike. */
|
|
848
|
+
const MAX_TOOL_NAME_LENGTH = 200;
|
|
849
|
+
/**
|
|
850
|
+
* Records a call the MCP server answered without a handler's own result: one
|
|
851
|
+
* it turned away before any handler ran, or one whose handler asked the client
|
|
852
|
+
* for more when the server had no way to ask.
|
|
853
|
+
*
|
|
854
|
+
* A refusal carries no message on purpose. The server's text for a refusal is written
|
|
855
|
+
* by a validation library, not by the developer, and some versions quote the
|
|
856
|
+
* offending argument back - "received 'xyz'" - which would put a parameter
|
|
857
|
+
* value into an event. Names and types of what was sent are recorded instead,
|
|
858
|
+
* when the developer asked for them, since those are what say which argument
|
|
859
|
+
* the agent got wrong.
|
|
860
|
+
*/
|
|
861
|
+
function recordRefusedCall(refused) {
|
|
862
|
+
const active = sink;
|
|
863
|
+
if (active === void 0) return;
|
|
864
|
+
try {
|
|
865
|
+
const parameters = captureParameterNames ? describeParameters([refused.arguments]) : void 0;
|
|
866
|
+
active({
|
|
867
|
+
id: (0, node_crypto.randomUUID)(),
|
|
868
|
+
toolName: truncate(refused.toolName, MAX_TOOL_NAME_LENGTH),
|
|
869
|
+
durationMs: refused.durationMs,
|
|
870
|
+
success: false,
|
|
871
|
+
errorSource: refused.errorSource,
|
|
872
|
+
...refused.errorMessage !== void 0 && { errorMessage: refused.errorMessage },
|
|
873
|
+
...identity(refused.client, refused.serverVersion),
|
|
874
|
+
...parameters !== void 0 && { parameters },
|
|
875
|
+
timestamp: refused.timestamp,
|
|
876
|
+
sdkVersion: SDK_VERSION,
|
|
877
|
+
...refused.sessionId !== void 0 && { sessionId: refused.sessionId }
|
|
878
|
+
});
|
|
879
|
+
} catch {}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Records a resource read or a prompt got (contract, 3.5), however it ended.
|
|
883
|
+
*
|
|
884
|
+
* Built here beside tool calls so the two cannot drift: the same client, the
|
|
885
|
+
* same session, the same limits, the same rule that arguments are described by
|
|
886
|
+
* name and type and never kept.
|
|
887
|
+
*/
|
|
888
|
+
function recordPrimitiveCall(call) {
|
|
889
|
+
const active = sink;
|
|
890
|
+
if (active === void 0) return;
|
|
891
|
+
try {
|
|
892
|
+
const parameters = captureParameterNames ? describeParameters([call.arguments]) : void 0;
|
|
893
|
+
active({
|
|
894
|
+
id: (0, node_crypto.randomUUID)(),
|
|
895
|
+
kind: call.kind,
|
|
896
|
+
toolName: truncate(call.name, MAX_TOOL_NAME_LENGTH),
|
|
897
|
+
durationMs: call.durationMs,
|
|
898
|
+
success: call.success,
|
|
899
|
+
...call.errorSource !== void 0 && { errorSource: call.errorSource },
|
|
900
|
+
...call.errorType !== void 0 && { errorType: call.errorType },
|
|
901
|
+
...call.errorMessage !== void 0 && { errorMessage: call.errorMessage },
|
|
902
|
+
...identity(call.client, call.serverVersion),
|
|
903
|
+
...parameters !== void 0 && { parameters },
|
|
904
|
+
timestamp: call.timestamp,
|
|
905
|
+
sdkVersion: SDK_VERSION,
|
|
906
|
+
...call.sessionId !== void 0 && { sessionId: call.sessionId }
|
|
907
|
+
});
|
|
908
|
+
} catch {}
|
|
909
|
+
}
|
|
910
|
+
/** Whether anything is currently collecting, so callers can skip the work entirely. */
|
|
911
|
+
function isRecording() {
|
|
912
|
+
return sink !== void 0;
|
|
913
|
+
}
|
|
914
|
+
/** A result the 2026-07-28 protocol calls interim: the tool needs more input first. */
|
|
915
|
+
function isInputRequired(value) {
|
|
916
|
+
return typeof value === "object" && value !== null && value.resultType === "input_required";
|
|
917
|
+
}
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region src/config.ts
|
|
920
|
+
/**
|
|
921
|
+
* Said when there is a key and nowhere to send: somebody meant to collect.
|
|
922
|
+
* There is no default endpoint, since mcpspan runs wherever its user runs it,
|
|
923
|
+
* and a default would send their data somewhere they did not choose.
|
|
924
|
+
*/
|
|
925
|
+
const NO_ENDPOINT = "mcpspan: an API key is set but no endpoint, so nothing is collected. Set MCPSPAN_ENDPOINT (or the endpoint option) to your mcpspan installation, for example http://localhost:6271.";
|
|
926
|
+
/** Whether NO_ENDPOINT has been said in this process: once is enough. */
|
|
927
|
+
let saidNoEndpoint = false;
|
|
928
|
+
let reporter;
|
|
929
|
+
let exitHook;
|
|
930
|
+
/**
|
|
931
|
+
* What the running reporter was configured with, to recognise the same
|
|
932
|
+
* configuration arriving again.
|
|
933
|
+
*/
|
|
934
|
+
let active;
|
|
935
|
+
/**
|
|
936
|
+
* Starts collecting, or stops if there is nothing to collect with.
|
|
937
|
+
*
|
|
938
|
+
* Calling this again with a different configuration replaces the previous
|
|
939
|
+
* one, sending whatever the old one still held. Calling it again with the same
|
|
940
|
+
* configuration changes nothing. That is the common case, not an edge: an HTTP
|
|
941
|
+
* server that builds a fresh MCP server for every request - the stateless
|
|
942
|
+
* pattern, and how v2 of the official SDK serves the 2026-07-28 protocol -
|
|
943
|
+
* calls instrument() on each of them, and starting over each time would
|
|
944
|
+
* announce the server once per request and throw away the batching.
|
|
945
|
+
*
|
|
946
|
+
* **Never throws.** This runs inside a developer's server during startup, so a
|
|
947
|
+
* mistyped option must not be the reason their server fails to boot. Bad
|
|
948
|
+
* values are reported on stderr and replaced with defaults.
|
|
949
|
+
*/
|
|
950
|
+
function configure(config = {}) {
|
|
951
|
+
const settings = describeSettings(config);
|
|
952
|
+
if (reporter !== void 0 && active !== void 0 && active.settings === settings && active.onDiagnostic === config.onDiagnostic) return;
|
|
953
|
+
active = void 0;
|
|
954
|
+
const previous = reporter;
|
|
955
|
+
reporter = void 0;
|
|
956
|
+
setEventSink(void 0);
|
|
957
|
+
setCaptureParameterNames(false);
|
|
958
|
+
setServerVersion(void 0);
|
|
959
|
+
removeExitHook();
|
|
960
|
+
if (previous) previous.stop();
|
|
961
|
+
const debug = config.debug ?? config.onDiagnostic !== void 0;
|
|
962
|
+
const apiKey = firstNonEmpty(config.apiKey, process.env["MCPSPAN_API_KEY"]);
|
|
963
|
+
if (apiKey === void 0) return;
|
|
964
|
+
const endpoint = firstNonEmpty(config.endpoint, process.env["MCPSPAN_ENDPOINT"]);
|
|
965
|
+
if (endpoint === void 0) {
|
|
966
|
+
if (!saidNoEndpoint) {
|
|
967
|
+
saidNoEndpoint = true;
|
|
968
|
+
try {
|
|
969
|
+
(config.onDiagnostic ?? console.error)(NO_ENDPOINT);
|
|
970
|
+
} catch {}
|
|
971
|
+
}
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const options = {
|
|
975
|
+
apiKey,
|
|
976
|
+
endpoint,
|
|
977
|
+
debug,
|
|
978
|
+
...config.onDiagnostic !== void 0 && { onDiagnostic: config.onDiagnostic }
|
|
979
|
+
};
|
|
980
|
+
assignPositive(options, "flushIntervalMs", config.flushIntervalMs, debug);
|
|
981
|
+
assignPositive(options, "maxBatchSize", config.maxBatchSize, debug);
|
|
982
|
+
assignPositive(options, "maxQueueSize", config.maxQueueSize, debug);
|
|
983
|
+
reporter = new EventReporter(options);
|
|
984
|
+
active = {
|
|
985
|
+
settings,
|
|
986
|
+
onDiagnostic: config.onDiagnostic
|
|
987
|
+
};
|
|
988
|
+
setCaptureParameterNames(config.captureParameterNames ?? false);
|
|
989
|
+
setServerVersion(firstNonEmpty(config.serverVersion, process.env["MCPSPAN_SERVER_VERSION"]));
|
|
990
|
+
setEventSink((event) => reporter?.record(event));
|
|
991
|
+
if (config.flushOnExit ?? true) installExitHook();
|
|
992
|
+
reporter.announce();
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Arranges one last delivery attempt as the process winds down.
|
|
996
|
+
*
|
|
997
|
+
* `beforeExit` fires when the event loop has emptied and Node is about to
|
|
998
|
+
* leave. Scheduling work there is allowed and keeps the process alive just
|
|
999
|
+
* long enough to finish it. Signals and explicit exits are deliberately not
|
|
1000
|
+
* intercepted: those belong to the server, and a telemetry library reaching
|
|
1001
|
+
* into them would be overstepping.
|
|
1002
|
+
*/
|
|
1003
|
+
function installExitHook() {
|
|
1004
|
+
if (exitHook !== void 0) return;
|
|
1005
|
+
exitHook = () => {
|
|
1006
|
+
shutdown();
|
|
1007
|
+
};
|
|
1008
|
+
process.once("beforeExit", exitHook);
|
|
1009
|
+
}
|
|
1010
|
+
function removeExitHook() {
|
|
1011
|
+
if (exitHook === void 0) return;
|
|
1012
|
+
process.removeListener("beforeExit", exitHook);
|
|
1013
|
+
exitHook = void 0;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Stops collecting and makes a final attempt to deliver what is queued.
|
|
1017
|
+
*
|
|
1018
|
+
* Worth calling from a server's own shutdown path. Without it the last partly
|
|
1019
|
+
* filled batch dies with the process, which on a short-lived stdio server can
|
|
1020
|
+
* be most of a session.
|
|
1021
|
+
*/
|
|
1022
|
+
async function shutdown() {
|
|
1023
|
+
const current = reporter;
|
|
1024
|
+
reporter = void 0;
|
|
1025
|
+
active = void 0;
|
|
1026
|
+
setEventSink(void 0);
|
|
1027
|
+
setCaptureParameterNames(false);
|
|
1028
|
+
setServerVersion(void 0);
|
|
1029
|
+
removeExitHook();
|
|
1030
|
+
await current?.stop();
|
|
1031
|
+
}
|
|
1032
|
+
/** Whether the SDK is currently recording tool calls. */
|
|
1033
|
+
function isCollecting() {
|
|
1034
|
+
return reporter !== void 0;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Everything a configuration decides, resolved the way configure() resolves
|
|
1038
|
+
* it, as one comparable string. The key and endpoint are read through the
|
|
1039
|
+
* environment fallbacks, so the same effective setup compares equal however it
|
|
1040
|
+
* was spelt. The diagnostic callback is compared separately, by identity.
|
|
1041
|
+
*/
|
|
1042
|
+
function describeSettings(config) {
|
|
1043
|
+
return JSON.stringify([
|
|
1044
|
+
firstNonEmpty(config.apiKey, process.env["MCPSPAN_API_KEY"]) ?? null,
|
|
1045
|
+
firstNonEmpty(config.endpoint, process.env["MCPSPAN_ENDPOINT"]) ?? null,
|
|
1046
|
+
config.debug ?? null,
|
|
1047
|
+
config.flushOnExit ?? true,
|
|
1048
|
+
config.flushIntervalMs ?? null,
|
|
1049
|
+
config.maxBatchSize ?? null,
|
|
1050
|
+
config.maxQueueSize ?? null,
|
|
1051
|
+
config.captureParameterNames ?? false,
|
|
1052
|
+
firstNonEmpty(config.serverVersion, process.env["MCPSPAN_SERVER_VERSION"]) ?? null
|
|
1053
|
+
]);
|
|
1054
|
+
}
|
|
1055
|
+
function firstNonEmpty(...values) {
|
|
1056
|
+
for (const value of values) {
|
|
1057
|
+
const trimmed = value?.trim();
|
|
1058
|
+
if (trimmed) return trimmed;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function assignPositive(options, name, value, debug) {
|
|
1062
|
+
if (value === void 0) return;
|
|
1063
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
1064
|
+
if (debug) console.error(`mcpspan: ignoring ${name}=${String(value)}, expected a positive integer`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
options[name] = value;
|
|
1068
|
+
}
|
|
1069
|
+
//#endregion
|
|
1070
|
+
//#region src/session.ts
|
|
1071
|
+
/**
|
|
1072
|
+
* Which conversation a tool call belongs to.
|
|
1073
|
+
*
|
|
1074
|
+
* A single call says little; the order of them says how an agent actually uses
|
|
1075
|
+
* a server - that it always searches before it books, or pages through a list
|
|
1076
|
+
* three times, or retries the same call after an error. That needs calls
|
|
1077
|
+
* grouped by the connection they arrived on.
|
|
1078
|
+
*
|
|
1079
|
+
* The identifier is ours, random, and made fresh for each connection. It is
|
|
1080
|
+
* deliberately not the transport's own session identifier, which travels in
|
|
1081
|
+
* HTTP headers and would let anyone holding the server's logs join our events
|
|
1082
|
+
* to them. Nothing in it says anything about who is on the other end.
|
|
1083
|
+
*/
|
|
1084
|
+
/** Connections remembered per server. Past this the oldest is forgotten. */
|
|
1085
|
+
const MAX_SESSIONS_PER_SERVER = 1e3;
|
|
1086
|
+
/** Per server instance, the transport's session key mapped to our identifier. */
|
|
1087
|
+
const sessions = /* @__PURE__ */ new WeakMap();
|
|
1088
|
+
/**
|
|
1089
|
+
* Our identifier for the connection a request arrived on, or none.
|
|
1090
|
+
*
|
|
1091
|
+
* - The transport names a session (HTTP with sessions, on the 2025 protocol):
|
|
1092
|
+
* one identifier per transport session.
|
|
1093
|
+
* - HTTP without one (a stateless server, and every server on the 2026-07-28
|
|
1094
|
+
* protocol, which removed sessions): none. Each request there may reach a
|
|
1095
|
+
* fresh server instance, and calling each call its own session would fill
|
|
1096
|
+
* the session views with sessions of one call that no agent had.
|
|
1097
|
+
* - Anything else - stdio, an in-process transport - is one connection for
|
|
1098
|
+
* the life of the server instance, so the instance is the session. On the
|
|
1099
|
+
* 2026 protocol over stdio the official SDK pins one instance per
|
|
1100
|
+
* connection, which keeps this true.
|
|
1101
|
+
*
|
|
1102
|
+
* The request context is the MCP SDK's own: `extra` in v1, `ctx` in v2. Both
|
|
1103
|
+
* put the transport session at `sessionId`; v1 marks an HTTP request with
|
|
1104
|
+
* `requestInfo`, v2 with `http`.
|
|
1105
|
+
*/
|
|
1106
|
+
function sessionFor(server, context) {
|
|
1107
|
+
const request = context ?? {};
|
|
1108
|
+
const transportSession = request.sessionId;
|
|
1109
|
+
const overHttp = request.requestInfo !== void 0 || request.http !== void 0;
|
|
1110
|
+
if (typeof transportSession !== "string" && overHttp) return void 0;
|
|
1111
|
+
const key = typeof transportSession === "string" ? transportSession : "";
|
|
1112
|
+
let known = sessions.get(server);
|
|
1113
|
+
if (known === void 0) {
|
|
1114
|
+
known = /* @__PURE__ */ new Map();
|
|
1115
|
+
sessions.set(server, known);
|
|
1116
|
+
}
|
|
1117
|
+
const existing = known.get(key);
|
|
1118
|
+
if (existing !== void 0) {
|
|
1119
|
+
known.delete(key);
|
|
1120
|
+
known.set(key, existing);
|
|
1121
|
+
return existing;
|
|
1122
|
+
}
|
|
1123
|
+
const created = (0, node_crypto.randomUUID)();
|
|
1124
|
+
known.set(key, created);
|
|
1125
|
+
if (known.size > MAX_SESSIONS_PER_SERVER) {
|
|
1126
|
+
const oldest = known.keys().next().value;
|
|
1127
|
+
if (oldest !== void 0) known.delete(oldest);
|
|
1128
|
+
}
|
|
1129
|
+
return created;
|
|
1130
|
+
}
|
|
1131
|
+
//#endregion
|
|
1132
|
+
//#region src/primitives.ts
|
|
1133
|
+
const PRIMITIVE_METHODS = ["resources/read", "prompts/get"];
|
|
1134
|
+
function isPrimitiveMethod(method) {
|
|
1135
|
+
return method === "resources/read" || method === "prompts/get";
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Whether an MCP SDK refused a prompt's arguments before its callback ran:
|
|
1139
|
+
* invalid params, in the words both major versions use (v1 prefixes them with
|
|
1140
|
+
* `MCP error -32602: `). A callback's own error with that code and other words
|
|
1141
|
+
* is left as the exception it is.
|
|
1142
|
+
*/
|
|
1143
|
+
function refusedArguments(error) {
|
|
1144
|
+
const { code, message } = error ?? {};
|
|
1145
|
+
return code === -32602 && typeof message === "string" && message.includes("Invalid arguments for prompt");
|
|
1146
|
+
}
|
|
1147
|
+
/** The scheme of an address, which is all of an unknown one that may be kept: `db://`. */
|
|
1148
|
+
function schemeOf(uri) {
|
|
1149
|
+
const colon = uri.indexOf(":");
|
|
1150
|
+
const scheme = colon > 0 ? uri.slice(0, colon) : "";
|
|
1151
|
+
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? `${scheme}://` : "unknown://";
|
|
1152
|
+
}
|
|
1153
|
+
function resolve(server, request) {
|
|
1154
|
+
const registry = server;
|
|
1155
|
+
if (request.method === "prompts/get") {
|
|
1156
|
+
const name = request.params?.name;
|
|
1157
|
+
if (typeof name !== "string") return void 0;
|
|
1158
|
+
const prompt = registry._registeredPrompts?.[name];
|
|
1159
|
+
return {
|
|
1160
|
+
kind: "prompt",
|
|
1161
|
+
name,
|
|
1162
|
+
exists: prompt !== void 0 && prompt.enabled !== false,
|
|
1163
|
+
arguments: request.params?.arguments
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
const uri = request.params?.uri;
|
|
1167
|
+
if (typeof uri !== "string") return void 0;
|
|
1168
|
+
let normalised = uri;
|
|
1169
|
+
try {
|
|
1170
|
+
normalised = new URL(uri).toString();
|
|
1171
|
+
} catch {}
|
|
1172
|
+
const fixed = registry._registeredResources?.[normalised];
|
|
1173
|
+
if (fixed !== void 0) return {
|
|
1174
|
+
kind: "resource",
|
|
1175
|
+
name: normalised,
|
|
1176
|
+
exists: fixed.enabled !== false,
|
|
1177
|
+
arguments: void 0
|
|
1178
|
+
};
|
|
1179
|
+
for (const template of Object.values(registry._registeredResourceTemplates ?? {})) {
|
|
1180
|
+
const uriTemplate = template.resourceTemplate?.uriTemplate;
|
|
1181
|
+
const variables = uriTemplate?.match?.(normalised);
|
|
1182
|
+
if (variables !== null && variables !== void 0) return {
|
|
1183
|
+
kind: "resource",
|
|
1184
|
+
name: String(uriTemplate),
|
|
1185
|
+
exists: template.enabled !== false,
|
|
1186
|
+
arguments: variables
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
return {
|
|
1190
|
+
kind: "resource",
|
|
1191
|
+
name: schemeOf(uri),
|
|
1192
|
+
exists: false,
|
|
1193
|
+
arguments: void 0
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Wraps the handler the server installed for `resources/read` or
|
|
1198
|
+
* `prompts/get`. What it answers or throws goes back unchanged; this only
|
|
1199
|
+
* looks at it on its way past.
|
|
1200
|
+
*/
|
|
1201
|
+
function watchPrimitive(handler, server) {
|
|
1202
|
+
return async function watchedPrimitiveHandler(request, context) {
|
|
1203
|
+
const call = handler.bind(this, request, context);
|
|
1204
|
+
if (!isRecording() || !isPrimitiveMethod(request?.method)) return call();
|
|
1205
|
+
let resolved;
|
|
1206
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1207
|
+
const startedAt = performance.now();
|
|
1208
|
+
try {
|
|
1209
|
+
resolved = resolve(server, request);
|
|
1210
|
+
} catch {
|
|
1211
|
+
resolved = void 0;
|
|
1212
|
+
}
|
|
1213
|
+
if (resolved === void 0) return call();
|
|
1214
|
+
const record = (outcome) => {
|
|
1215
|
+
try {
|
|
1216
|
+
const exception = outcome.errorSource === "exception" ? describeException(outcome.error) : void 0;
|
|
1217
|
+
recordPrimitiveCall({
|
|
1218
|
+
kind: resolved.kind,
|
|
1219
|
+
name: resolved.name,
|
|
1220
|
+
success: outcome.success,
|
|
1221
|
+
...outcome.errorSource !== void 0 && { errorSource: outcome.errorSource },
|
|
1222
|
+
...exception !== void 0 && { errorType: exception.errorType },
|
|
1223
|
+
...exception?.errorMessage !== void 0 && { errorMessage: exception.errorMessage },
|
|
1224
|
+
arguments: resolved.arguments,
|
|
1225
|
+
timestamp,
|
|
1226
|
+
durationMs: performance.now() - startedAt,
|
|
1227
|
+
sessionId: typeof context === "object" && context !== null ? sessionFor(server, context) : void 0,
|
|
1228
|
+
client: clientFor(server, typeof context === "object" && context !== null ? context : void 0),
|
|
1229
|
+
serverVersion: serverVersionOf(server)
|
|
1230
|
+
});
|
|
1231
|
+
} catch {}
|
|
1232
|
+
};
|
|
1233
|
+
let result;
|
|
1234
|
+
try {
|
|
1235
|
+
result = await call();
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
if (!resolved.exists) record({
|
|
1238
|
+
success: false,
|
|
1239
|
+
errorSource: resolved.kind === "prompt" ? "unknown_prompt" : "unknown_resource"
|
|
1240
|
+
});
|
|
1241
|
+
else if (resolved.kind === "prompt" && refusedArguments(error)) record({
|
|
1242
|
+
success: false,
|
|
1243
|
+
errorSource: "arguments"
|
|
1244
|
+
});
|
|
1245
|
+
else record({
|
|
1246
|
+
success: false,
|
|
1247
|
+
errorSource: "exception",
|
|
1248
|
+
error
|
|
1249
|
+
});
|
|
1250
|
+
throw error;
|
|
1251
|
+
}
|
|
1252
|
+
if (!(typeof result === "object" && result !== null && result.resultType === "input_required")) record({ success: true });
|
|
1253
|
+
return result;
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
//#endregion
|
|
1257
|
+
//#region src/instrument.ts
|
|
1258
|
+
/**
|
|
1259
|
+
* Methods an MCP server registers tools through.
|
|
1260
|
+
*
|
|
1261
|
+
* `registerTool` is the current one, in both major versions of the official
|
|
1262
|
+
* SDK. `tool` is deprecated in v1 and gone from v2, but it is what most v1
|
|
1263
|
+
* servers written so far actually call, and instrumenting only the modern
|
|
1264
|
+
* name would silently miss them.
|
|
1265
|
+
*/
|
|
1266
|
+
const REGISTRATION_METHODS = ["registerTool", "tool"];
|
|
1267
|
+
/**
|
|
1268
|
+
* The inner servers whose `tools/call` handler is already watched, so a
|
|
1269
|
+
* second instrument() on the same server does not watch it twice.
|
|
1270
|
+
*/
|
|
1271
|
+
const intercepted = /* @__PURE__ */ new WeakSet();
|
|
1272
|
+
/** Marks a wrapped method, so instrumenting the same server twice is harmless. */
|
|
1273
|
+
const INSTRUMENTED = Symbol.for("mcpspan.instrumented");
|
|
1274
|
+
/** The tools each instrumented server registered, by name. */
|
|
1275
|
+
const registries = /* @__PURE__ */ new WeakMap();
|
|
1276
|
+
/**
|
|
1277
|
+
* Request contexts whose call reached a tool handler.
|
|
1278
|
+
*
|
|
1279
|
+
* The server hands the same context object to the request handler and to the
|
|
1280
|
+
* tool handler it calls, so a call whose context is not in here by the time
|
|
1281
|
+
* the request is answered never got as far as the handler: the server refused
|
|
1282
|
+
* it on its own. A weak set, so contexts leave with their requests.
|
|
1283
|
+
*/
|
|
1284
|
+
const reachedHandler = /* @__PURE__ */ new WeakSet();
|
|
1285
|
+
/**
|
|
1286
|
+
* Request contexts whose handler last answered with an interim
|
|
1287
|
+
* `input_required` result (the 2026-07-28 protocol's way to ask the client for
|
|
1288
|
+
* more). `track` does not count that answer, because the retry that follows is
|
|
1289
|
+
* the call that completes. But where the SDK cannot serve the interim answer -
|
|
1290
|
+
* a stateless 2025-era endpoint has no way to put a question to the client -
|
|
1291
|
+
* it turns it into an error for the client instead, after the handler has
|
|
1292
|
+
* returned, and without this the call would be counted nowhere at all.
|
|
1293
|
+
*/
|
|
1294
|
+
const endedInterim = /* @__PURE__ */ new WeakSet();
|
|
1295
|
+
/**
|
|
1296
|
+
* Records every tool a server registers from this point on.
|
|
1297
|
+
*
|
|
1298
|
+
* This is the whole integration:
|
|
1299
|
+
*
|
|
1300
|
+
* ```ts
|
|
1301
|
+
* const server = new McpServer({ name: 'flights', version: '1.0.0' });
|
|
1302
|
+
* instrument(server, { apiKey: process.env.MCPSPAN_API_KEY });
|
|
1303
|
+
* ```
|
|
1304
|
+
*
|
|
1305
|
+
* Tools registered afterwards are wrapped as they are registered, and tools
|
|
1306
|
+
* registered before it are wrapped where they already are, so nothing about
|
|
1307
|
+
* how they are declared, or where this line sits, has to change.
|
|
1308
|
+
*
|
|
1309
|
+
* **Never throws.** An unfamiliar server object, or one with no registration
|
|
1310
|
+
* method at all, leaves the server exactly as it was and collects nothing. A
|
|
1311
|
+
* telemetry library that can stop somebody's server from starting has failed
|
|
1312
|
+
* at the only thing it truly must not do.
|
|
1313
|
+
*/
|
|
1314
|
+
function instrument(server, config) {
|
|
1315
|
+
const debug = config?.debug ?? false;
|
|
1316
|
+
try {
|
|
1317
|
+
if (config !== void 0 || !isCollecting()) configure(config ?? {});
|
|
1318
|
+
const registry = registries.get(server) ?? /* @__PURE__ */ new Map();
|
|
1319
|
+
registries.set(server, registry);
|
|
1320
|
+
wrapRegistration(server, registry, debug);
|
|
1321
|
+
wrapRegistered(server, registry);
|
|
1322
|
+
interceptToolCalls(server, registry);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
if (debug) console.error(`mcpspan: could not instrument this server (${formatError(error)})`);
|
|
1325
|
+
}
|
|
1326
|
+
return server;
|
|
1327
|
+
}
|
|
1328
|
+
function wrapRegistration(server, registry, debug) {
|
|
1329
|
+
const target = server;
|
|
1330
|
+
let wrapped = false;
|
|
1331
|
+
for (const method of REGISTRATION_METHODS) {
|
|
1332
|
+
const original = target[method];
|
|
1333
|
+
if (typeof original !== "function") continue;
|
|
1334
|
+
if (original[INSTRUMENTED] === true) {
|
|
1335
|
+
wrapped = true;
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
target[method] = createWrapper(original, server, registry);
|
|
1339
|
+
wrapped = true;
|
|
1340
|
+
}
|
|
1341
|
+
if (!wrapped && debug) console.error("mcpspan: this server has no registerTool or tool method, so nothing was instrumented");
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Measures tools the server already had when instrument() ran.
|
|
1345
|
+
*
|
|
1346
|
+
* Both major versions of the official SDK keep registered tools by name, and
|
|
1347
|
+
* give each an `update` that swaps its handler: `update({ callback })` is
|
|
1348
|
+
* public API, and in v2 it also rebuilds what actually calls the handler, so
|
|
1349
|
+
* replacing the field alone would change nothing there. Only finding the
|
|
1350
|
+
* tools reads the SDK's private registry; if a version moves it, tools
|
|
1351
|
+
* registered before instrument() go unmeasured, as they did before.
|
|
1352
|
+
*
|
|
1353
|
+
* Swapping a handler on a connected server makes the SDK tell the client
|
|
1354
|
+
* the tool list changed. It did not, and a client lists the same tools again.
|
|
1355
|
+
*/
|
|
1356
|
+
function wrapRegistered(server, registry) {
|
|
1357
|
+
const tools = server._registeredTools;
|
|
1358
|
+
if (typeof tools !== "object" || tools === null) return;
|
|
1359
|
+
for (const [name, tool] of Object.entries(tools)) {
|
|
1360
|
+
if (registry.has(name)) continue;
|
|
1361
|
+
const entry = tool;
|
|
1362
|
+
const handler = entry?.handler;
|
|
1363
|
+
if (typeof handler !== "function" || typeof entry?.update !== "function") continue;
|
|
1364
|
+
try {
|
|
1365
|
+
if (isExcluded(handler)) {
|
|
1366
|
+
registry.set(name, {
|
|
1367
|
+
excluded: true,
|
|
1368
|
+
registered: tool
|
|
1369
|
+
});
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
const tracked = isMarked(handler) ? handler : track(name, handler);
|
|
1373
|
+
entry.update.call(entry, { callback: noteReached(tracked, server) });
|
|
1374
|
+
registry.set(name, {
|
|
1375
|
+
excluded: false,
|
|
1376
|
+
registered: tool
|
|
1377
|
+
});
|
|
1378
|
+
} catch {}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Wraps a registration method without needing to know its overloads.
|
|
1383
|
+
*
|
|
1384
|
+
* Both `tool` and `registerTool` have several shapes - with a description,
|
|
1385
|
+
* with a schema, with annotations - and across every one of them the name is
|
|
1386
|
+
* the first argument and the handler is the last. Leaning on that is far
|
|
1387
|
+
* sturdier than trying to work out which overload was called, and it survives
|
|
1388
|
+
* the SDK adding another.
|
|
1389
|
+
*/
|
|
1390
|
+
function createWrapper(original, server, registry) {
|
|
1391
|
+
const wrapper = function instrumentedRegistration(...args) {
|
|
1392
|
+
const name = args[0];
|
|
1393
|
+
const lastIndex = args.length - 1;
|
|
1394
|
+
const handler = args[lastIndex];
|
|
1395
|
+
if (typeof name !== "string" || typeof handler !== "function" || lastIndex < 1) return original.apply(this, args);
|
|
1396
|
+
if (isExcluded(handler)) {
|
|
1397
|
+
const registered = original.apply(this, args);
|
|
1398
|
+
registry.set(name, {
|
|
1399
|
+
excluded: true,
|
|
1400
|
+
registered
|
|
1401
|
+
});
|
|
1402
|
+
return registered;
|
|
1403
|
+
}
|
|
1404
|
+
const tracked = isMarked(handler) ? handler : track(name, handler);
|
|
1405
|
+
const instrumented = [...args];
|
|
1406
|
+
instrumented[lastIndex] = noteReached(tracked, server);
|
|
1407
|
+
const registered = original.apply(this, instrumented);
|
|
1408
|
+
registry.set(name, {
|
|
1409
|
+
excluded: false,
|
|
1410
|
+
registered
|
|
1411
|
+
});
|
|
1412
|
+
return registered;
|
|
1413
|
+
};
|
|
1414
|
+
Object.defineProperty(wrapper, INSTRUMENTED, { value: true });
|
|
1415
|
+
return wrapper;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Remembers that a call got as far as its handler, and tells `track` which
|
|
1419
|
+
* connection and client it came from.
|
|
1420
|
+
*
|
|
1421
|
+
* The context is the last argument whatever the tool's shape: `(args, extra)`
|
|
1422
|
+
* for a tool with a schema, `(extra)` for one without; `ctx` in place of
|
|
1423
|
+
* `extra` in v2 of the official SDK.
|
|
1424
|
+
*/
|
|
1425
|
+
function noteReached(handler, server) {
|
|
1426
|
+
return function reachedToolHandler(...args) {
|
|
1427
|
+
const context = args.at(-1);
|
|
1428
|
+
const run = () => handler.apply(this, args);
|
|
1429
|
+
const hasContext = typeof context === "object" && context !== null;
|
|
1430
|
+
if (hasContext) reachedHandler.add(context);
|
|
1431
|
+
const sessionId = hasContext ? sessionFor(server, context) : void 0;
|
|
1432
|
+
const client = clientFor(server, hasContext ? context : void 0);
|
|
1433
|
+
const serverVersion = serverVersionOf(server);
|
|
1434
|
+
const result = withCall({
|
|
1435
|
+
...sessionId !== void 0 && { sessionId },
|
|
1436
|
+
...client !== void 0 && { client },
|
|
1437
|
+
...serverVersion !== void 0 && { serverVersion }
|
|
1438
|
+
}, run);
|
|
1439
|
+
if (hasContext) noteInterim(context, result);
|
|
1440
|
+
return result;
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/** Keeps `endedInterim` in step with how the handler's latest answer ended. */
|
|
1444
|
+
function noteInterim(context, result) {
|
|
1445
|
+
const settle = (value) => {
|
|
1446
|
+
if (typeof value === "object" && value !== null && value.resultType === "input_required") endedInterim.add(context);
|
|
1447
|
+
else endedInterim.delete(context);
|
|
1448
|
+
};
|
|
1449
|
+
if (result instanceof Promise) result.then(settle, () => endedInterim.delete(context));
|
|
1450
|
+
else settle(result);
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Sees the calls a server refuses before any handler runs.
|
|
1454
|
+
*
|
|
1455
|
+
* Wrapping handlers only sees calls that reach them. An MCP server validates
|
|
1456
|
+
* arguments against the tool's schema first, and answers a call to a tool it
|
|
1457
|
+
* does not have, and both come back to the model as ordinary error results.
|
|
1458
|
+
* Bad arguments are the commonest way an agent fails, and none of it was
|
|
1459
|
+
* counted: a server's error rate read lower than what agents experienced.
|
|
1460
|
+
*
|
|
1461
|
+
* Hooks the request handler the server installs for `tools/call`, the layer
|
|
1462
|
+
* that answers the client. Every handler set on the connection passes through
|
|
1463
|
+
* here, and all but that one are handed over untouched.
|
|
1464
|
+
*/
|
|
1465
|
+
function interceptToolCalls(server, registry) {
|
|
1466
|
+
const inner = server.server;
|
|
1467
|
+
const original = inner?.setRequestHandler;
|
|
1468
|
+
if (inner === void 0 || typeof original !== "function") return;
|
|
1469
|
+
if (intercepted.has(inner)) return;
|
|
1470
|
+
intercepted.add(inner);
|
|
1471
|
+
const handlers = inner._requestHandlers;
|
|
1472
|
+
if (handlers instanceof Map) for (const method of ["tools/call", ...PRIMITIVE_METHODS]) {
|
|
1473
|
+
const installed = handlers.get(method);
|
|
1474
|
+
if (typeof installed === "function") handlers.set(method, watch(installed, server, registry));
|
|
1475
|
+
}
|
|
1476
|
+
const wrapped = function instrumentedSetRequestHandler(schema, handler) {
|
|
1477
|
+
const replacement = typeof handler === "function" ? watch(handler, server, registry) : handler;
|
|
1478
|
+
return original.call(this, schema, replacement);
|
|
1479
|
+
};
|
|
1480
|
+
Object.defineProperty(wrapped, INSTRUMENTED, { value: true });
|
|
1481
|
+
inner.setRequestHandler = wrapped;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Watches a request handler for the calls this SDK records: tool calls, and
|
|
1485
|
+
* resource reads and prompt gets (see primitives.ts). Each watcher looks at
|
|
1486
|
+
* the request's method when it arrives and passes anything else straight on,
|
|
1487
|
+
* so the handler for any other method runs exactly as it did.
|
|
1488
|
+
*/
|
|
1489
|
+
function watch(handler, server, registry) {
|
|
1490
|
+
return watchPrimitive(watchToolCalls(handler, server, registry), server);
|
|
1491
|
+
}
|
|
1492
|
+
function watchToolCalls(handler, server, registry) {
|
|
1493
|
+
return async function watchedRequestHandler(request, context) {
|
|
1494
|
+
const call = handler.bind(this, request, context);
|
|
1495
|
+
if (!isRecording() || request?.method !== "tools/call") return call();
|
|
1496
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1497
|
+
const startedAt = performance.now();
|
|
1498
|
+
const noteRefusal = (message) => {
|
|
1499
|
+
try {
|
|
1500
|
+
const reached = typeof context === "object" && context !== null && reachedHandler.has(context);
|
|
1501
|
+
const name = request.params?.name;
|
|
1502
|
+
if (typeof name !== "string") return;
|
|
1503
|
+
if (reached) {
|
|
1504
|
+
if (endedInterim.has(context)) recordRefusedCall({
|
|
1505
|
+
toolName: name,
|
|
1506
|
+
errorSource: "result",
|
|
1507
|
+
...message !== void 0 && { errorMessage: message },
|
|
1508
|
+
arguments: request.params?.arguments,
|
|
1509
|
+
timestamp,
|
|
1510
|
+
durationMs: performance.now() - startedAt,
|
|
1511
|
+
sessionId: sessionFor(server, context),
|
|
1512
|
+
client: clientFor(server, context),
|
|
1513
|
+
serverVersion: serverVersionOf(server)
|
|
1514
|
+
});
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const errorSource = classifyRefusal(registry.get(name), message);
|
|
1518
|
+
if (errorSource === void 0) return;
|
|
1519
|
+
recordRefusedCall({
|
|
1520
|
+
toolName: name,
|
|
1521
|
+
errorSource,
|
|
1522
|
+
arguments: request.params?.arguments,
|
|
1523
|
+
timestamp,
|
|
1524
|
+
durationMs: performance.now() - startedAt,
|
|
1525
|
+
sessionId: sessionFor(server, context),
|
|
1526
|
+
client: clientFor(server, context),
|
|
1527
|
+
serverVersion: serverVersionOf(server)
|
|
1528
|
+
});
|
|
1529
|
+
} catch {}
|
|
1530
|
+
};
|
|
1531
|
+
let result;
|
|
1532
|
+
try {
|
|
1533
|
+
result = await call();
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
noteRefusal(error instanceof Error ? error.message : void 0);
|
|
1536
|
+
throw error;
|
|
1537
|
+
}
|
|
1538
|
+
if (isErrorResult(result)) noteRefusal(describeErrorResult(result));
|
|
1539
|
+
return result;
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Names the reason a call never reached its handler, or declines to guess.
|
|
1544
|
+
*
|
|
1545
|
+
* Which tool it was decides most of it: one we registered and is enabled was
|
|
1546
|
+
* refused over its arguments; one we never saw, or that is disabled, does not
|
|
1547
|
+
* exist as far as the client is concerned. The server's wording is checked as
|
|
1548
|
+
* well, so that anything else refused on the way - a misconfigured task tool,
|
|
1549
|
+
* a tool renamed after registration - is left out rather than miscounted. If
|
|
1550
|
+
* that wording ever changes, the effect is that fewer refusals are recorded,
|
|
1551
|
+
* never that the wrong ones are.
|
|
1552
|
+
*/
|
|
1553
|
+
function classifyRefusal(entry, message) {
|
|
1554
|
+
if (entry?.excluded === true) return void 0;
|
|
1555
|
+
const registered = entry?.registered;
|
|
1556
|
+
const enabled = entry !== void 0 && registered?.enabled !== false;
|
|
1557
|
+
const text = message ?? "";
|
|
1558
|
+
if (enabled) return /validation/i.test(text) ? "arguments" : void 0;
|
|
1559
|
+
return /not found|disabled/i.test(text) ? "unknown_tool" : void 0;
|
|
1560
|
+
}
|
|
1561
|
+
//#endregion
|
|
1562
|
+
exports.configure = configure;
|
|
1563
|
+
exports.exclude = exclude;
|
|
1564
|
+
exports.instrument = instrument;
|
|
1565
|
+
exports.shutdown = shutdown;
|
|
1566
|
+
exports.track = track;
|