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