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/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;