@graphorin/mcp 0.6.1 → 0.7.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +64 -3
  3. package/dist/client/adapt-result.d.ts +9 -1
  4. package/dist/client/adapt-result.d.ts.map +1 -1
  5. package/dist/client/adapt-result.js +28 -10
  6. package/dist/client/adapt-result.js.map +1 -1
  7. package/dist/client/client-handlers.js +7 -1
  8. package/dist/client/client-handlers.js.map +1 -1
  9. package/dist/client/client.d.ts.map +1 -1
  10. package/dist/client/client.js +45 -70
  11. package/dist/client/client.js.map +1 -1
  12. package/dist/client/inbound-filters.js +101 -1
  13. package/dist/client/inbound-filters.js.map +1 -1
  14. package/dist/client/index.d.ts +2 -1
  15. package/dist/client/index.js +2 -1
  16. package/dist/client/managed.d.ts +35 -0
  17. package/dist/client/managed.d.ts.map +1 -0
  18. package/dist/client/managed.js +136 -0
  19. package/dist/client/managed.js.map +1 -0
  20. package/dist/client/mcp-resource-reader.js +1 -1
  21. package/dist/client/mcp-resource-reader.js.map +1 -1
  22. package/dist/client/to-tools-run.js +119 -0
  23. package/dist/client/to-tools-run.js.map +1 -0
  24. package/dist/client/to-tools.d.ts +8 -0
  25. package/dist/client/to-tools.d.ts.map +1 -1
  26. package/dist/client/to-tools.js +27 -4
  27. package/dist/client/to-tools.js.map +1 -1
  28. package/dist/client/types.d.ts +12 -3
  29. package/dist/client/types.d.ts.map +1 -1
  30. package/dist/errors/index.d.ts +3 -3
  31. package/dist/errors/index.js +1 -1
  32. package/dist/errors/index.js.map +1 -1
  33. package/dist/helpers/identity.d.ts +11 -0
  34. package/dist/helpers/identity.d.ts.map +1 -1
  35. package/dist/helpers/identity.js +13 -2
  36. package/dist/helpers/identity.js.map +1 -1
  37. package/dist/index.d.ts +3 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +3 -4
  40. package/dist/index.js.map +1 -1
  41. package/dist/package.js +1 -1
  42. package/dist/package.js.map +1 -1
  43. package/dist/registry/json-schema.js +26 -8
  44. package/dist/registry/json-schema.js.map +1 -1
  45. package/dist/transport/types.d.ts +9 -0
  46. package/package.json +13 -12
  47. package/src/client/adapt-result.ts +302 -0
  48. package/src/client/client-handlers.ts +215 -0
  49. package/src/client/client.ts +725 -0
  50. package/src/client/defer-loading.ts +108 -0
  51. package/src/client/inbound-filters.ts +246 -0
  52. package/src/client/index.ts +42 -0
  53. package/src/client/managed.ts +222 -0
  54. package/src/client/mcp-resource-reader.ts +183 -0
  55. package/src/client/pinning.ts +48 -0
  56. package/src/client/to-tools-run.ts +178 -0
  57. package/src/client/to-tools.ts +294 -0
  58. package/src/client/transport-factory.ts +117 -0
  59. package/src/client/types.ts +422 -0
  60. package/src/errors/index.ts +170 -0
  61. package/src/helpers/identity.ts +128 -0
  62. package/src/helpers/index.ts +8 -0
  63. package/src/helpers/validate-config.ts +95 -0
  64. package/src/index.ts +49 -0
  65. package/src/oauth/bridge.ts +143 -0
  66. package/src/oauth/index.ts +18 -0
  67. package/src/oauth/library.ts +61 -0
  68. package/src/registry/json-schema.ts +402 -0
  69. package/src/transport/index.ts +15 -0
  70. package/src/transport/types.ts +108 -0
@@ -0,0 +1,725 @@
1
+ import pkg from '../../package.json' with { type: 'json' };
2
+ /**
3
+ * `createMCPClient(...)` - the entry point for opening a typed MCP
4
+ * client connection.
5
+ *
6
+ * The returned {@link MCPClient}:
7
+ *
8
+ * - Wraps the `@modelcontextprotocol/sdk` `Client` instance and the
9
+ * selected SDK transport.
10
+ * - Exposes `listTools` / `listResources` / `listPrompts` /
11
+ * `callTool` / `readResource` / `getPrompt` / `close` plus the
12
+ * strategy-aware `toTools(...)` adapter.
13
+ * - Emits one INFO-log per server when the connected transport is
14
+ * the deprecated SSE transport, on the resolved resumable
15
+ * capability, and when the structured-content + outputSchema
16
+ * round-trip first succeeds.
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+
21
+ import type { Tool } from '@graphorin/core';
22
+ import { incrementCounter } from '@graphorin/tools/audit';
23
+ import type { CollisionStrategy } from '@graphorin/tools/registry';
24
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
25
+ import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
26
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
27
+ import {
28
+ ErrorCode,
29
+ McpError,
30
+ ToolListChangedNotificationSchema,
31
+ } from '@modelcontextprotocol/sdk/types.js';
32
+ import {
33
+ MCPCallTimeoutError,
34
+ MCPCancelledError,
35
+ MCPConnectionError,
36
+ MCPInvalidConfigError,
37
+ MCPProtocolError,
38
+ MCPToolNotFoundError,
39
+ } from '../errors/index.js';
40
+ import { deriveServerIdentity } from '../helpers/identity.js';
41
+ import { validateMCPServerConfig } from '../helpers/validate-config.js';
42
+ import type { ServerIdentity } from '../transport/types.js';
43
+ import { computeClientCapabilities, registerClientRequestHandlers } from './client-handlers.js';
44
+ import { runToTools, type ToolFingerprintRef } from './to-tools-run.js';
45
+ import { buildTransport, type TransportAuthSource } from './transport-factory.js';
46
+ import type {
47
+ CreateMCPClientOptions,
48
+ MCPCallToolResult,
49
+ MCPClient,
50
+ MCPContentPart,
51
+ MCPPromptDefinition,
52
+ MCPPromptMessage,
53
+ MCPResourceContent,
54
+ MCPResourceDefinition,
55
+ MCPToolDefinition,
56
+ MCPToToolsOptions,
57
+ } from './types.js';
58
+
59
+ const DEFAULT_CLIENT_NAME = 'graphorin-mcp-client';
60
+ const DEFAULT_CLIENT_VERSION: string = pkg.version;
61
+
62
+ /**
63
+ * Process-scoped dedup flag for the deprecated SSE transport WARN.
64
+ * Once set, subsequent {@link createMCPClient} calls with the SSE
65
+ * transport do not re-emit the warning. Tests reset via
66
+ * {@link _resetSseWarnDedupForTesting}.
67
+ */
68
+ let sseWarnEmittedThisProcess = false;
69
+
70
+ /**
71
+ * Reset the SSE WARN dedup flag. Used by tests.
72
+ *
73
+ * @experimental
74
+ */
75
+ export function _resetSseWarnDedupForTesting(): void {
76
+ sseWarnEmittedThisProcess = false;
77
+ }
78
+
79
+ /**
80
+ * Open a typed MCP client connection.
81
+ *
82
+ * @stable
83
+ */
84
+ export async function createMCPClient(options: CreateMCPClientOptions): Promise<MCPClient> {
85
+ validateMCPServerConfig({ transport: options.transport });
86
+
87
+ // Mutually exclusive (documented on `CreateMCPClientOptions`): a live
88
+ // OAuth provider and a static pre-shared token cannot both drive the
89
+ // outbound `Authorization` header.
90
+ if (options.authProvider !== undefined && options.bearerToken !== undefined) {
91
+ throw new MCPInvalidConfigError(
92
+ '`authProvider` and `bearerToken` are mutually exclusive; supply at most one.',
93
+ { metadata: { transport: options.transport.kind } },
94
+ );
95
+ }
96
+ const auth = resolveTransportAuth(options);
97
+ if (auth !== undefined && options.transport.kind === 'stdio') {
98
+ throw new MCPInvalidConfigError(
99
+ 'authProvider / bearerToken require an HTTP transport (streamable-http or sse); the stdio transport carries no Authorization header.',
100
+ { metadata: { transport: 'stdio' } },
101
+ );
102
+ }
103
+
104
+ if (
105
+ options.transport.kind === 'sse' &&
106
+ options.suppressDeprecatedTransportWarning !== true &&
107
+ !sseWarnEmittedThisProcess
108
+ ) {
109
+ sseWarnEmittedThisProcess = true;
110
+ if (options.logger !== undefined) {
111
+ options.logger(
112
+ 'warn',
113
+ 'MCP SSE transport is deprecated; migrate the server to the streamable-http transport when possible.',
114
+ );
115
+ }
116
+ incrementCounter('mcp.transport.deprecated.warn.total', { transport: 'sse' });
117
+ }
118
+
119
+ const built = buildTransport(options.transport, auth === undefined ? undefined : { auth });
120
+ return createMCPClientFromSdkTransport({
121
+ transport: built.transport,
122
+ transportConfig: options.transport,
123
+ ...(options.collisionStrategy === undefined
124
+ ? {}
125
+ : { collisionStrategy: options.collisionStrategy }),
126
+ ...(options.priority === undefined ? {} : { priority: options.priority }),
127
+ ...(options.serverInfoName === undefined ? {} : { serverInfoName: options.serverInfoName }),
128
+ ...(options.logger === undefined ? {} : { logger: options.logger }),
129
+ ...(options.clientName === undefined ? {} : { clientName: options.clientName }),
130
+ ...(options.clientVersion === undefined ? {} : { clientVersion: options.clientVersion }),
131
+ ...(options.elicitation === undefined ? {} : { elicitation: options.elicitation }),
132
+ ...(options.sampling === undefined ? {} : { sampling: options.sampling }),
133
+ ...(options.onTransportClose === undefined
134
+ ? {}
135
+ : { onTransportClose: options.onTransportClose }),
136
+ ...(options.onTransportError === undefined
137
+ ? {}
138
+ : { onTransportError: options.onTransportError }),
139
+ });
140
+ }
141
+
142
+ /**
143
+ * Internal factory that takes a pre-built SDK `Transport`. Exposed
144
+ * for the test seam - production code uses {@link createMCPClient}.
145
+ *
146
+ * @internal
147
+ */
148
+ export interface CreateMCPClientFromSdkTransportOptions {
149
+ readonly transport: Transport;
150
+ readonly transportConfig: import('../transport/types.js').MCPTransportConfig;
151
+ readonly collisionStrategy?: CollisionStrategy;
152
+ readonly priority?: number;
153
+ readonly serverInfoName?: string;
154
+ readonly logger?: CreateMCPClientOptions['logger'];
155
+ readonly clientName?: string;
156
+ readonly clientVersion?: string;
157
+ readonly elicitation?: CreateMCPClientOptions['elicitation'];
158
+ readonly sampling?: CreateMCPClientOptions['sampling'];
159
+ readonly onTransportClose?: CreateMCPClientOptions['onTransportClose'];
160
+ readonly onTransportError?: CreateMCPClientOptions['onTransportError'];
161
+ }
162
+
163
+ /**
164
+ * Build an {@link MCPClient} from a pre-built SDK transport. The
165
+ * production {@link createMCPClient} delegates here after building
166
+ * the SDK transport from a {@link MCPTransportConfig}.
167
+ *
168
+ * @internal
169
+ */
170
+ export async function createMCPClientFromSdkTransport(
171
+ options: CreateMCPClientFromSdkTransportOptions,
172
+ ): Promise<MCPClient> {
173
+ const sdkClient = new Client({
174
+ name: options.clientName ?? DEFAULT_CLIENT_NAME,
175
+ version: options.clientVersion ?? DEFAULT_CLIENT_VERSION,
176
+ });
177
+
178
+ // WI-13 (P2-2): advertise + register the server-initiated request
179
+ // handlers (elicitation / sampling) before connecting, so they are in
180
+ // place when the session starts. Both are gated - capabilities are
181
+ // advertised only when the operator supplied the matching callback.
182
+ const clientCapabilities = computeClientCapabilities({
183
+ elicitation: options.elicitation,
184
+ sampling: options.sampling,
185
+ });
186
+ if (clientCapabilities !== undefined) {
187
+ sdkClient.registerCapabilities(clientCapabilities);
188
+ }
189
+ const serverIdRef = { current: 'unknown' };
190
+ registerClientRequestHandlers(sdkClient, {
191
+ ...(options.elicitation === undefined ? {} : { elicitation: options.elicitation }),
192
+ ...(options.sampling === undefined ? {} : { sampling: options.sampling }),
193
+ serverIdRef,
194
+ });
195
+ // MC-6: surface server-side catalogue churn - at minimum an audit
196
+ // counter + log line; operators re-run toTools() to refresh and
197
+ // re-sanitize the catalogue (which also re-runs the drift diff).
198
+ sdkClient.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
199
+ incrementCounter('mcp.tools.list-changed.total', {
200
+ server: serverIdRef.current ?? 'unknown',
201
+ });
202
+ options.logger?.(
203
+ 'warn',
204
+ 'mcp.tools.list_changed received - re-run toTools() to refresh + re-sanitize the catalogue',
205
+ { server: serverIdRef.current },
206
+ );
207
+ });
208
+
209
+ try {
210
+ await sdkClient.connect(options.transport);
211
+ } catch (cause) {
212
+ throw new MCPConnectionError(
213
+ `MCP transport could not be established: ${(cause as Error).message ?? String(cause)}`,
214
+ {
215
+ metadata: { transport: options.transportConfig.kind },
216
+ cause,
217
+ },
218
+ );
219
+ }
220
+
221
+ const serverInfo = sdkClient.getServerVersion() ?? {
222
+ name: 'unknown',
223
+ version: '0.0.0',
224
+ };
225
+ // W-016: the identity derives from the TRANSPORT config (operator-
226
+ // controlled) plus the explicit operator `serverInfoName` override -
227
+ // never from the name the remote server self-reports on initialize.
228
+ // TOFU pins, handle scoping and taint labels all key off this id; a
229
+ // server-controlled id defeats every one of them (rename = fresh pin,
230
+ // claim a trusted name = inherit its scope). The self-reported name
231
+ // survives for display only.
232
+ const derivedIdentity = deriveServerIdentity(options.transportConfig, options.serverInfoName);
233
+ const serverIdentity: typeof derivedIdentity = Object.freeze({
234
+ ...derivedIdentity,
235
+ ...(typeof serverInfo.name === 'string' && serverInfo.name.length > 0
236
+ ? { reportedServerName: serverInfo.name }
237
+ : {}),
238
+ });
239
+ // Backfill the server id so the client-side request handlers
240
+ // (registered before connect) label their counters with it.
241
+ serverIdRef.current = serverIdentity.id;
242
+ // mcp-skills-10: surface transport lifecycle. Without these a dead
243
+ // stdio child / dropped HTTP session is observable only as
244
+ // MCPProtocolErrors on subsequent calls. The SDK Protocol base
245
+ // exposes assignable onclose/onerror callbacks (the transport's own
246
+ // handlers are managed by the SDK - never overwrite those).
247
+ sdkClient.onclose = () => {
248
+ incrementCounter('mcp.transport.closed.total', { server: serverIdentity.id });
249
+ options.logger?.('warn', 'MCP transport closed - rebuild the client to reconnect', {
250
+ server: serverIdentity.id,
251
+ });
252
+ options.onTransportClose?.({ server: serverIdentity.id });
253
+ };
254
+ sdkClient.onerror = (error: Error) => {
255
+ incrementCounter('mcp.transport.error.total', { server: serverIdentity.id });
256
+ options.onTransportError?.(error, { server: serverIdentity.id });
257
+ };
258
+ const collisionStrategy: CollisionStrategy = options.collisionStrategy ?? 'auto-prefix';
259
+
260
+ // MC-1: the client-side eventStore option was removed - per the
261
+ // Streamable HTTP spec event replay is the SERVER's responsibility,
262
+ // and the SDK transport auto-reconnects with Last-Event-ID on its
263
+ // own. Warn legacy callers instead of silently ignoring the option.
264
+ if ((options as { readonly eventStore?: unknown }).eventStore !== undefined) {
265
+ options.logger?.(
266
+ 'warn',
267
+ "the client 'eventStore' option was removed (MC-1): event replay is a server responsibility; the SDK transport auto-reconnects with Last-Event-ID. Remove the option.",
268
+ { server: serverIdentity.id },
269
+ );
270
+ }
271
+ // MC-9: a session id means stateful routing, NOT a replay guarantee.
272
+ const sessionIdPresent =
273
+ isStreamableHttp(options.transport) &&
274
+ (options.transport as StreamableHTTPClientTransport).sessionId !== undefined;
275
+ const resumable = sessionIdPresent;
276
+ if (options.logger !== undefined) {
277
+ options.logger('info', 'mcp.session.session-id.resolved', {
278
+ server: serverIdentity.id,
279
+ value: sessionIdPresent,
280
+ source: sessionIdPresent ? 'session-id-present' : 'transport-default',
281
+ });
282
+ }
283
+ let structuredContentSeenLogged = false;
284
+
285
+ /**
286
+ * mcp-skills-02: MCP list operations are cursor-paginated (protocol
287
+ * 2024-11-05+) and the SDK does NOT auto-paginate - a single call
288
+ * returns page 1 only. Ignoring `nextCursor` silently truncated the
289
+ * catalogue: tools beyond the first page never reached `toTools()`,
290
+ * defer-loading thresholds counted a partial catalogue, pin
291
+ * fingerprints covered a partial catalogue, and resources/prompts were
292
+ * under-listed. Every list surface now drains the cursor chain, with a
293
+ * defensive page cap against buggy/adversarial servers that never
294
+ * terminate it.
295
+ */
296
+ const MAX_LIST_PAGES = 100;
297
+
298
+ function warnListPageCap(what: 'tools' | 'resources' | 'prompts', collected: number): void {
299
+ options.logger?.(
300
+ 'warn',
301
+ `mcp.list.${what}.page-cap-reached: server kept returning nextCursor after ${MAX_LIST_PAGES} pages; the ${what} catalogue is truncated at ${collected} entries.`,
302
+ { server: serverIdentity.id },
303
+ );
304
+ }
305
+
306
+ async function listTools(opts?: {
307
+ signal?: AbortSignal;
308
+ }): Promise<ReadonlyArray<MCPToolDefinition>> {
309
+ const requestOptions = opts?.signal === undefined ? {} : { signal: opts.signal };
310
+ type SdkTool = {
311
+ name: string;
312
+ description?: string;
313
+ inputSchema: Readonly<Record<string, unknown>>;
314
+ outputSchema?: Readonly<Record<string, unknown>>;
315
+ title?: string;
316
+ };
317
+ const tools: SdkTool[] = [];
318
+ let cursor: string | undefined;
319
+ for (let page = 0; ; page++) {
320
+ if (page >= MAX_LIST_PAGES) {
321
+ warnListPageCap('tools', tools.length);
322
+ break;
323
+ }
324
+ let result: Awaited<ReturnType<typeof sdkClient.listTools>>;
325
+ try {
326
+ result = await sdkClient.listTools(cursor === undefined ? {} : { cursor }, requestOptions);
327
+ } catch (cause) {
328
+ throw mapSdkError(cause, { aborted: opts?.signal?.aborted === true });
329
+ }
330
+ tools.push(...((result.tools ?? []) as ReadonlyArray<SdkTool>));
331
+ cursor = (result as { nextCursor?: string }).nextCursor;
332
+ if (cursor === undefined) break;
333
+ }
334
+ if (!structuredContentSeenLogged && tools.some((t) => t.outputSchema !== undefined)) {
335
+ structuredContentSeenLogged = true;
336
+ if (options.logger !== undefined) {
337
+ options.logger('info', 'mcp.server.structured-content.detected', {
338
+ server: serverIdentity.id,
339
+ });
340
+ }
341
+ }
342
+ return Object.freeze(
343
+ tools.map((t) =>
344
+ Object.freeze({
345
+ name: t.name,
346
+ description: t.description ?? '',
347
+ inputSchema: t.inputSchema,
348
+ ...(t.outputSchema === undefined ? {} : { outputSchema: t.outputSchema }),
349
+ ...(t.title === undefined ? {} : { title: t.title }),
350
+ }),
351
+ ),
352
+ );
353
+ }
354
+
355
+ async function listResources(opts?: {
356
+ signal?: AbortSignal;
357
+ }): Promise<ReadonlyArray<MCPResourceDefinition>> {
358
+ const requestOptions = opts?.signal === undefined ? {} : { signal: opts.signal };
359
+ type SdkResource = {
360
+ uri: string;
361
+ name?: string;
362
+ description?: string;
363
+ mimeType?: string;
364
+ };
365
+ const items: SdkResource[] = [];
366
+ let cursor: string | undefined;
367
+ for (let page = 0; ; page++) {
368
+ if (page >= MAX_LIST_PAGES) {
369
+ warnListPageCap('resources', items.length);
370
+ break;
371
+ }
372
+ let result: Awaited<ReturnType<typeof sdkClient.listResources>>;
373
+ try {
374
+ result = await sdkClient.listResources(
375
+ cursor === undefined ? {} : { cursor },
376
+ requestOptions,
377
+ );
378
+ } catch (cause) {
379
+ throw mapSdkError(cause, { aborted: opts?.signal?.aborted === true });
380
+ }
381
+ items.push(...((result.resources ?? []) as ReadonlyArray<SdkResource>));
382
+ cursor = (result as { nextCursor?: string }).nextCursor;
383
+ if (cursor === undefined) break;
384
+ }
385
+ return Object.freeze(
386
+ items.map((r) =>
387
+ Object.freeze({
388
+ uri: r.uri,
389
+ ...(r.name === undefined ? {} : { name: r.name }),
390
+ ...(r.description === undefined ? {} : { description: r.description }),
391
+ ...(r.mimeType === undefined ? {} : { mimeType: r.mimeType }),
392
+ }),
393
+ ),
394
+ );
395
+ }
396
+
397
+ async function listPrompts(opts?: {
398
+ signal?: AbortSignal;
399
+ }): Promise<ReadonlyArray<MCPPromptDefinition>> {
400
+ const requestOptions = opts?.signal === undefined ? {} : { signal: opts.signal };
401
+ type SdkPrompt = {
402
+ name: string;
403
+ description?: string;
404
+ arguments?: ReadonlyArray<{ name: string; description?: string; required?: boolean }>;
405
+ };
406
+ const items: SdkPrompt[] = [];
407
+ let cursor: string | undefined;
408
+ for (let page = 0; ; page++) {
409
+ if (page >= MAX_LIST_PAGES) {
410
+ warnListPageCap('prompts', items.length);
411
+ break;
412
+ }
413
+ let result: Awaited<ReturnType<typeof sdkClient.listPrompts>>;
414
+ try {
415
+ result = await sdkClient.listPrompts(
416
+ cursor === undefined ? {} : { cursor },
417
+ requestOptions,
418
+ );
419
+ } catch (cause) {
420
+ throw mapSdkError(cause, { aborted: opts?.signal?.aborted === true });
421
+ }
422
+ items.push(...((result.prompts ?? []) as ReadonlyArray<SdkPrompt>));
423
+ cursor = (result as { nextCursor?: string }).nextCursor;
424
+ if (cursor === undefined) break;
425
+ }
426
+ return Object.freeze(
427
+ items.map((p) =>
428
+ Object.freeze({
429
+ name: p.name,
430
+ ...(p.description === undefined ? {} : { description: p.description }),
431
+ ...(p.arguments === undefined
432
+ ? {}
433
+ : {
434
+ arguments: Object.freeze(
435
+ p.arguments.map((a) =>
436
+ Object.freeze({
437
+ name: a.name,
438
+ ...(a.description === undefined ? {} : { description: a.description }),
439
+ ...(a.required === undefined ? {} : { required: a.required }),
440
+ }),
441
+ ),
442
+ ),
443
+ }),
444
+ }),
445
+ ),
446
+ );
447
+ }
448
+
449
+ async function callTool(
450
+ name: string,
451
+ args: unknown,
452
+ opts?: { signal?: AbortSignal; timeoutMs?: number },
453
+ ): Promise<MCPCallToolResult> {
454
+ // MC-3: `timeoutMs` maps onto the SDK's RequestOptions - both the
455
+ // per-attempt and the total ceiling, so progress notifications
456
+ // cannot extend past the caller's budget.
457
+ const requestOptions = {
458
+ ...(opts?.signal === undefined ? {} : { signal: opts.signal }),
459
+ ...(opts?.timeoutMs === undefined
460
+ ? {}
461
+ : { timeout: opts.timeoutMs, maxTotalTimeout: opts.timeoutMs }),
462
+ };
463
+ incrementCounter('mcp.call.invoked.total', { server: serverIdentity.id, tool: name });
464
+ let result: Awaited<ReturnType<typeof sdkClient.callTool>>;
465
+ try {
466
+ result = await sdkClient.callTool(
467
+ { name, arguments: args as Record<string, unknown> },
468
+ undefined,
469
+ requestOptions,
470
+ );
471
+ } catch (cause) {
472
+ const mapped = mapSdkError(cause, {
473
+ tool: name,
474
+ aborted: opts?.signal?.aborted === true,
475
+ });
476
+ if (mapped instanceof MCPCancelledError) {
477
+ incrementCounter('mcp.call.cancelled.total', { server: serverIdentity.id, tool: name });
478
+ } else {
479
+ incrementCounter('mcp.call.failed.total', { server: serverIdentity.id, tool: name });
480
+ }
481
+ throw mapped;
482
+ }
483
+ const content = (result.content ?? []) as ReadonlyArray<MCPContentPart>;
484
+ return Object.freeze({
485
+ content: Object.freeze([...content]),
486
+ ...(result.structuredContent === undefined
487
+ ? {}
488
+ : { structuredContent: result.structuredContent as Readonly<Record<string, unknown>> }),
489
+ ...(result.isError === undefined ? {} : { isError: Boolean(result.isError) }),
490
+ });
491
+ }
492
+
493
+ async function readResourceContents(
494
+ uri: string,
495
+ opts?: { signal?: AbortSignal },
496
+ ): Promise<ReadonlyArray<MCPResourceContent>> {
497
+ const requestOptions = opts?.signal === undefined ? {} : { signal: opts.signal };
498
+ let result: Awaited<ReturnType<typeof sdkClient.readResource>>;
499
+ try {
500
+ result = await sdkClient.readResource({ uri }, requestOptions);
501
+ } catch (cause) {
502
+ throw mapSdkError(cause, { aborted: opts?.signal?.aborted === true });
503
+ }
504
+ const contents = (result.contents ?? []) as ReadonlyArray<MCPResourceContent>;
505
+ if (contents.length === 0) {
506
+ throw new MCPProtocolError(`MCP server returned no contents for resource '${uri}'.`, {
507
+ metadata: { server: serverIdentity.id },
508
+ });
509
+ }
510
+ return Object.freeze(contents.map((c) => Object.freeze(c)));
511
+ }
512
+
513
+ async function readResource(
514
+ uri: string,
515
+ opts?: { signal?: AbortSignal },
516
+ ): Promise<MCPResourceContent> {
517
+ const contents = await readResourceContents(uri, opts);
518
+ // mcp-skills-11: the `resources/read` result is an ARRAY precisely
519
+ // because one URI can yield multiple contents. The single-content
520
+ // convenience keeps its shape, but a silent truncation is now a
521
+ // visible one.
522
+ if (contents.length > 1) {
523
+ incrementCounter('mcp.resource.multi-content-truncated.total', {
524
+ server: serverIdentity.id,
525
+ });
526
+ options.logger?.(
527
+ 'warn',
528
+ `resource '${uri}' returned ${contents.length} content items; readResource() surfaces only the first - use readResourceContents() for all of them`,
529
+ { server: serverIdentity.id },
530
+ );
531
+ }
532
+ return contents[0] as MCPResourceContent;
533
+ }
534
+
535
+ async function getPrompt(
536
+ name: string,
537
+ args?: unknown,
538
+ opts?: { signal?: AbortSignal },
539
+ ): Promise<{ readonly messages: ReadonlyArray<MCPPromptMessage> }> {
540
+ const requestOptions = opts?.signal === undefined ? {} : { signal: opts.signal };
541
+ let result: Awaited<ReturnType<typeof sdkClient.getPrompt>>;
542
+ try {
543
+ result = await sdkClient.getPrompt(
544
+ {
545
+ name,
546
+ ...(args === undefined ? {} : { arguments: args as Record<string, string> }),
547
+ },
548
+ requestOptions,
549
+ );
550
+ } catch (cause) {
551
+ throw mapSdkError(cause, { aborted: opts?.signal?.aborted === true });
552
+ }
553
+ const messages = (result.messages ?? []).map(
554
+ (m): MCPPromptMessage =>
555
+ Object.freeze({
556
+ role: m.role,
557
+ content: m.content as MCPContentPart,
558
+ }),
559
+ );
560
+ return Object.freeze({ messages: Object.freeze(messages) });
561
+ }
562
+
563
+ // W-080: cross-snapshot drift tracking spans this client's lifetime;
564
+ // the pipeline itself lives in `runToTools` (shared with the managed
565
+ // wrapper, which passes ITSELF as the client so adapted tools survive
566
+ // an inner-client swap on reconnect).
567
+ const toolFingerprintRef: ToolFingerprintRef = { current: undefined };
568
+
569
+ async function toTools(toolsOpts?: MCPToToolsOptions): Promise<ReadonlyArray<Tool>> {
570
+ return runToTools({
571
+ client: clientApi,
572
+ fingerprintRef: toolFingerprintRef,
573
+ ...(options.logger === undefined ? {} : { logger: options.logger }),
574
+ ...(toolsOpts === undefined ? {} : { toolsOpts }),
575
+ });
576
+ }
577
+
578
+ async function close(): Promise<void> {
579
+ try {
580
+ await sdkClient.close();
581
+ } catch (cause) {
582
+ // Treat double-close as a no-op; surface other failures.
583
+ if (cause instanceof Error && cause.message.toLowerCase().includes('already')) return;
584
+ throw new MCPConnectionError('MCP transport could not be closed cleanly.', {
585
+ metadata: { transport: options.transportConfig.kind, server: serverIdentity.id },
586
+ cause,
587
+ });
588
+ }
589
+ }
590
+
591
+ const clientApi: MCPClient = Object.freeze({
592
+ id: serverIdentity.id,
593
+ serverInfo,
594
+ serverIdentity,
595
+ collisionStrategy,
596
+ ...(options.priority === undefined ? {} : { priority: options.priority }),
597
+ sessionIdPresent,
598
+ resumable,
599
+ listTools,
600
+ listResources,
601
+ listPrompts,
602
+ callTool,
603
+ readResource,
604
+ readResourceContents,
605
+ getPrompt,
606
+ toTools,
607
+ close,
608
+ });
609
+ return clientApi;
610
+ }
611
+
612
+ /**
613
+ * Resolve the live {@link TransportAuthSource} for the outbound
614
+ * `Authorization` header from the mutually-exclusive `authProvider` /
615
+ * `bearerToken` options. `authProvider.resolveHeader()` already returns
616
+ * the full header value (`Bearer …`); a static `bearerToken` is wrapped
617
+ * into a constant `Bearer`-prefixed resolver. Returns `undefined` when
618
+ * neither is supplied (no header injection).
619
+ */
620
+ function resolveTransportAuth(options: CreateMCPClientOptions): TransportAuthSource | undefined {
621
+ const provider = options.authProvider;
622
+ if (provider !== undefined) {
623
+ return { resolveHeader: () => provider.resolveHeader() };
624
+ }
625
+ if (options.bearerToken !== undefined) {
626
+ const token = options.bearerToken;
627
+ const header = /^bearer\s/i.test(token) ? token : `Bearer ${token}`;
628
+ return { resolveHeader: () => header };
629
+ }
630
+ return undefined;
631
+ }
632
+
633
+ function mapSdkError(
634
+ cause: unknown,
635
+ ctx: { readonly tool?: string; readonly aborted?: boolean },
636
+ ): Error {
637
+ const metadata = ctx.tool === undefined ? {} : { tool: ctx.tool };
638
+ // W-141: cancellation is classified from OUR OWN AbortSignal state
639
+ // (ctx.aborted), never from error text. SDK 1.29 wraps a local abort
640
+ // as McpError(RequestTimeout, String(reason)) (protocol.js cancel()),
641
+ // so neither the code nor the message distinguishes it - but the
642
+ // caller's signal does, and it cannot be forged by a server.
643
+ if (ctx.aborted === true) {
644
+ return new MCPCancelledError('MCP request was cancelled.', { metadata, cause });
645
+ }
646
+ // Classify McpError by JSON-RPC CODE. The message is server-
647
+ // controlled text: matching it would let a malicious server forge
648
+ // the typed timeout/cancelled classes (and the operator counters
649
+ // keyed on them) by phrasing an ordinary error as 'request timed
650
+ // out'. Codes are the protocol's typed channel; the SDK raises its
651
+ // own client-side timeout as -32001 (RequestTimeout).
652
+ if (cause instanceof McpError) {
653
+ if (cause.code === ErrorCode.RequestTimeout) {
654
+ return new MCPCallTimeoutError(
655
+ `MCP request timed out${ctx.tool ? ` (tool: ${ctx.tool})` : ''}.`,
656
+ { metadata, cause },
657
+ );
658
+ }
659
+ if (
660
+ cause.code === ErrorCode.MethodNotFound ||
661
+ // Tool-not-found stays message-matched as a fallback: real
662
+ // servers signal an unknown tool via InvalidParams/InternalError
663
+ // text at least as often as via MethodNotFound. This class is
664
+ // benign to forge - it feeds no cancelled/timeout counter and
665
+ // triggers no retry semantics, so DX wins over strictness here.
666
+ /unknown\s+tool|tool\s+not\s+found|method\s+not\s+found/i.test(cause.message)
667
+ ) {
668
+ return new MCPToolNotFoundError(`MCP tool not found${ctx.tool ? `: ${ctx.tool}` : ''}.`, {
669
+ metadata,
670
+ cause,
671
+ });
672
+ }
673
+ return new MCPProtocolError(cause.message.length === 0 ? cause.toString() : cause.message, {
674
+ metadata,
675
+ cause,
676
+ });
677
+ }
678
+ if (cause instanceof Error) {
679
+ const name = cause.name;
680
+ const message = cause.message ?? '';
681
+ // Local cancellation can also surface as an AbortError (transport
682
+ // paths); the name check is trustworthy for plain errors.
683
+ if (name === 'AbortError') {
684
+ return new MCPCancelledError('MCP request was cancelled.', { metadata, cause });
685
+ }
686
+ // Last-resort message fallbacks for PLAIN Errors thrown by
687
+ // transports or SDK paths outside JSON-RPC (no code to go by).
688
+ // Server-authored error text cannot reach this branch: RPC-level
689
+ // failures arrive as McpError and are classified above.
690
+ if (/aborted|cancell/i.test(message)) {
691
+ return new MCPCancelledError('MCP request was cancelled.', { metadata, cause });
692
+ }
693
+ if (/request timed out|timed out/i.test(message)) {
694
+ return new MCPCallTimeoutError(
695
+ `MCP request timed out${ctx.tool ? ` (tool: ${ctx.tool})` : ''}.`,
696
+ {
697
+ metadata,
698
+ cause,
699
+ },
700
+ );
701
+ }
702
+ if (/unknown\s+tool|tool\s+not\s+found|method\s+not\s+found/i.test(message)) {
703
+ return new MCPToolNotFoundError(`MCP tool not found${ctx.tool ? `: ${ctx.tool}` : ''}.`, {
704
+ metadata,
705
+ cause,
706
+ });
707
+ }
708
+ return new MCPProtocolError(message.length === 0 ? cause.toString() : message, {
709
+ metadata,
710
+ cause,
711
+ });
712
+ }
713
+ return new MCPProtocolError(`MCP request failed: ${String(cause)}`, { metadata, cause });
714
+ }
715
+
716
+ function isStreamableHttp(transport: unknown): transport is StreamableHTTPClientTransport {
717
+ return (
718
+ typeof transport === 'object' &&
719
+ transport !== null &&
720
+ transport.constructor !== undefined &&
721
+ transport.constructor.name === 'StreamableHTTPClientTransport'
722
+ );
723
+ }
724
+
725
+ export type { ServerIdentity };