@graphorin/mcp 0.6.0 → 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 +55 -0
  2. package/README.md +73 -5
  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 +47 -71
  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 +4 -4
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +6 -6
  40. package/dist/index.js.map +1 -1
  41. package/dist/package.js +6 -0
  42. package/dist/package.js.map +1 -0
  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,108 @@
1
+ /**
2
+ * Deferred-loading resolution for the `toTools()` adapter (extracted
3
+ * from `to-tools.ts` per F-MCP-001).
4
+ *
5
+ * Computes the per-server effective `defer_loading` flag: when the
6
+ * operator does not pass an explicit `defer_loading`, the auto-default
7
+ * fires once when `listTools().length > deferLoadingThreshold` (default
8
+ * `10`) and flips deferral on for every tool from the server. Emits the
9
+ * retrieval counters and the once-per-server INFO log.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import { incrementCounter } from '@graphorin/tools/audit';
15
+ import type { ServerIdentity } from '../transport/types.js';
16
+
17
+ /** Default auto-deferral threshold per the operator-facing convention. */
18
+ export const DEFAULT_DEFER_LOADING_THRESHOLD = 10;
19
+
20
+ /** Operator-supplied structured logger (mirrors the client logger shape). */
21
+ type AdapterLogger = (
22
+ level: 'debug' | 'info' | 'warn' | 'error',
23
+ message: string,
24
+ fields?: Record<string, unknown>,
25
+ ) => void;
26
+
27
+ /**
28
+ * Process-scoped dedup keys for the auto-default INFO-log. Each
29
+ * `(serverIdentity, threshold)` pair triggers the log once across all
30
+ * `MCPClient.toTools(...)` invocations in the process so re-running
31
+ * `toTools()` on the same client does not double-log.
32
+ */
33
+ const autoDeferralInfoSeen = new Set<string>();
34
+
35
+ /**
36
+ * Process-scoped dedup keys for the explicit `defer_loading: true`
37
+ * INFO-log. Mirrors the auto-default discipline.
38
+ */
39
+ const explicitDeferralInfoSeen = new Set<string>();
40
+
41
+ /**
42
+ * Reset the deferral dedup sets. Used by
43
+ * {@link import('./to-tools.js')._resetMcpAdapterDedupForTesting}.
44
+ *
45
+ * @experimental
46
+ */
47
+ export function _resetDeferLoadingDedupForTesting(): void {
48
+ autoDeferralInfoSeen.clear();
49
+ explicitDeferralInfoSeen.clear();
50
+ }
51
+
52
+ /** Outcome of {@link resolveDeferLoading}. */
53
+ export interface DeferralResolution {
54
+ readonly autoDeferralFired: boolean;
55
+ readonly resolvedDeferLoading: boolean;
56
+ }
57
+
58
+ /**
59
+ * Resolve the effective `defer_loading` flag for a server's tool
60
+ * catalogue and emit the retrieval counters + once-per-server INFO log.
61
+ */
62
+ export function resolveDeferLoading(args: {
63
+ readonly serverIdentity: ServerIdentity;
64
+ readonly toolNames: ReadonlyArray<string>;
65
+ readonly explicitDefer: boolean | undefined;
66
+ readonly threshold: number;
67
+ readonly logger?: AdapterLogger;
68
+ }): DeferralResolution {
69
+ const total = args.toolNames.length;
70
+ const autoDeferralFired = args.explicitDefer === undefined && total > args.threshold;
71
+ const resolvedDeferLoading = args.explicitDefer ?? autoDeferralFired;
72
+
73
+ if (autoDeferralFired) {
74
+ for (let i = 0; i < total; i++) {
75
+ incrementCounter('tool.retrieval.deferred.total', { source: 'mcp-server-default' });
76
+ }
77
+ const dedupKey = `${args.serverIdentity.id}:${args.threshold}`;
78
+ if (!autoDeferralInfoSeen.has(dedupKey) && args.logger !== undefined) {
79
+ autoDeferralInfoSeen.add(dedupKey);
80
+ args.logger('info', 'mcp.tools.defer_loading.auto-default fired', {
81
+ server: args.serverIdentity.id,
82
+ thresholdValue: args.threshold,
83
+ toolCount: total,
84
+ toolNames: [...args.toolNames],
85
+ source: 'mcp-server-default',
86
+ });
87
+ }
88
+ } else if (args.explicitDefer === true) {
89
+ for (let i = 0; i < total; i++) {
90
+ incrementCounter('tool.retrieval.deferred.total', { source: 'explicit' });
91
+ }
92
+ const dedupKey = `${args.serverIdentity.id}:explicit`;
93
+ if (!explicitDeferralInfoSeen.has(dedupKey) && args.logger !== undefined) {
94
+ explicitDeferralInfoSeen.add(dedupKey);
95
+ args.logger('info', 'mcp.tools.defer_loading.explicit fired', {
96
+ server: args.serverIdentity.id,
97
+ toolCount: total,
98
+ source: 'explicit',
99
+ });
100
+ }
101
+ } else if (args.explicitDefer === false) {
102
+ for (let i = 0; i < total; i++) {
103
+ incrementCounter('tool.retrieval.eager.total', { source: 'mcp-explicit-eager' });
104
+ }
105
+ }
106
+
107
+ return { autoDeferralFired, resolvedDeferLoading };
108
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Inbound prompt-injection filtering for the `toTools()` adapter
3
+ * (extracted from `to-tools.ts` per F-MCP-001).
4
+ *
5
+ * Owns: the per-server inbound-sanitization default, the once-per-server
6
+ * `pass-through` override WARN, and the tool-description strip applied at
7
+ * registration time. The trust class is pinned to `'mcp-derived'` for
8
+ * every produced tool so the agent runtime's per-step preamble fires
9
+ * regardless of the body-level policy chosen here.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import type { InboundSanitizationPolicy } from '@graphorin/core';
15
+ import { incrementCounter } from '@graphorin/tools/audit';
16
+ import { applyInboundSanitization } from '@graphorin/tools/inbound';
17
+ import type { JsonSchemaLike } from '../registry/json-schema.js';
18
+ import type { ServerIdentity } from '../transport/types.js';
19
+
20
+ /** Operator-supplied structured logger (mirrors the client logger shape). */
21
+ type AdapterLogger = (
22
+ level: 'debug' | 'info' | 'warn' | 'error',
23
+ message: string,
24
+ fields?: Record<string, unknown>,
25
+ ) => void;
26
+
27
+ /**
28
+ * Process-scoped dedup keys for the `pass-through` override WARN. The
29
+ * spec mandates exactly-one WARN per server identity per process - the
30
+ * Set retains the keys for the lifetime of the process. Tests reset via
31
+ * {@link import('./to-tools.js')._resetMcpAdapterDedupForTesting}.
32
+ */
33
+ const passthroughWarnSeen = new Set<string>();
34
+
35
+ /**
36
+ * Reset the inbound-filter dedup set. Used by
37
+ * {@link import('./to-tools.js')._resetMcpAdapterDedupForTesting}.
38
+ *
39
+ * @experimental
40
+ */
41
+ export function _resetInboundFiltersDedupForTesting(): void {
42
+ passthroughWarnSeen.clear();
43
+ }
44
+
45
+ /**
46
+ * Resolve the effective per-server inbound-sanitization policy. MCP
47
+ * tools default to the strictest body-level policy.
48
+ */
49
+ export function resolveInboundPolicy(
50
+ override: InboundSanitizationPolicy | undefined,
51
+ ): InboundSanitizationPolicy {
52
+ return override ?? 'detect-and-strip-and-wrap';
53
+ }
54
+
55
+ /**
56
+ * WARN-once per server when the operator opts out of body-level
57
+ * sanitization. The trust class stays `'mcp-derived'` regardless so the
58
+ * per-step preamble in the agent runtime still fires; the WARN exists so
59
+ * the audit log records the operator's deliberate choice.
60
+ */
61
+ export function warnOnPassthroughOverride(args: {
62
+ readonly resolvedInbound: InboundSanitizationPolicy;
63
+ readonly serverIdentity: ServerIdentity;
64
+ readonly logger?: AdapterLogger;
65
+ }): void {
66
+ if (args.resolvedInbound !== 'pass-through') return;
67
+ if (passthroughWarnSeen.has(args.serverIdentity.id)) return;
68
+ passthroughWarnSeen.add(args.serverIdentity.id);
69
+ incrementCounter('mcp.inbound.sanitization.passthrough-override.warn.total', {
70
+ server: args.serverIdentity.id,
71
+ });
72
+ if (args.logger !== undefined) {
73
+ args.logger('warn', 'mcp.inbound.sanitization.passthrough-override', {
74
+ server: args.serverIdentity.id,
75
+ policy: 'pass-through',
76
+ note: "Body-level prompt-injection sanitization is disabled for this MCP server; the trust class remains 'mcp-derived' so the per-step preamble still fires. The WARN cannot be silenced (deliberate operator-friction).",
77
+ });
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Strip imperative payloads from a tool description before it enters the
83
+ * per-step tool catalogue. The description is never wrapped in the
84
+ * `<<<untrusted_content>>>` envelope (the wrap is reserved for tool
85
+ * result bodies emitted into the conversation history).
86
+ */
87
+ export function sanitizeDescription(args: {
88
+ readonly description: string;
89
+ readonly inboundSanitization: InboundSanitizationPolicy;
90
+ readonly toolName: string;
91
+ readonly serverIdentity: ServerIdentity;
92
+ }): string {
93
+ const stripPolicy: InboundSanitizationPolicy =
94
+ args.inboundSanitization === 'pass-through' ? 'pass-through' : 'detect-and-strip';
95
+ const outcome = applyInboundSanitization({
96
+ body: args.description,
97
+ policy: stripPolicy,
98
+ trustClass: 'mcp-derived',
99
+ toolName: args.toolName,
100
+ contentOrigin: `mcp:tool-description:${args.serverIdentity.id}`,
101
+ failClosed: false,
102
+ });
103
+ // C6: tool-description poisoning is a registration-time SIGNAL, not
104
+ // just a silent strip - count it so operators see which server ships
105
+ // imperative-laden descriptions (Invariant Labs tool-poisoning class).
106
+ if (outcome.patternsHit.length > 0) {
107
+ incrementCounter('mcp.tool-description.injection-flagged.total', {
108
+ server: args.serverIdentity.id,
109
+ tool: args.toolName,
110
+ });
111
+ }
112
+ return outcome.body;
113
+ }
114
+
115
+ /**
116
+ * Result of {@link sanitizeSchemaAnnotations}: the sanitized COPY of
117
+ * the schema (the input document is never mutated) plus the pattern
118
+ * names that fired anywhere inside it.
119
+ */
120
+ export interface SanitizedSchemaResult {
121
+ readonly schema: JsonSchemaLike;
122
+ readonly patternsHit: ReadonlyArray<string>;
123
+ }
124
+
125
+ // JSON Schema keys whose STRING values are pure annotations shown to
126
+ // the model - the exact hiding place of the published Invariant Labs
127
+ // tool-poisoning attacks. Only these are sanitized; semantic keywords
128
+ // (`enum`, `const`, `pattern`, `required`, property names) participate
129
+ // in input validation and MUST stay byte-identical.
130
+ const ANNOTATION_STRING_KEYS = new Set(['description', 'title', '$comment']);
131
+ // Keys whose value is a single sub-schema (or, for `items`, a tuple of
132
+ // sub-schemas).
133
+ const SCHEMA_VALUE_KEYS = new Set([
134
+ 'items',
135
+ 'additionalProperties',
136
+ 'not',
137
+ 'if',
138
+ 'then',
139
+ 'else',
140
+ 'contains',
141
+ 'propertyNames',
142
+ ]);
143
+ // Keys whose value is a list of sub-schemas.
144
+ const SCHEMA_LIST_KEYS = new Set(['oneOf', 'anyOf', 'allOf', 'prefixItems']);
145
+ // Keys whose value is a record of named sub-schemas.
146
+ const SCHEMA_RECORD_KEYS = new Set([
147
+ 'properties',
148
+ '$defs',
149
+ 'definitions',
150
+ 'patternProperties',
151
+ 'dependentSchemas',
152
+ ]);
153
+
154
+ /**
155
+ * Strip imperative payloads from the ANNOTATION strings of an MCP tool
156
+ * JSON Schema (`description` / `title` / `$comment` / string
157
+ * `examples`) at any nesting depth, before the schema reaches the
158
+ * provider wire and the `tool_search` projection (W-018, the Invariant
159
+ * Labs tool-poisoning vector `properties.query.description`).
160
+ *
161
+ * Design choices:
162
+ *
163
+ * - Strip, never wrap: the result must remain a valid JSON Schema
164
+ * document; an `<<<untrusted_content>>>` envelope would break the
165
+ * structure.
166
+ * - Returns a recursively cloned COPY and never mutates the input.
167
+ * The caller keeps hashing the RAW definition
168
+ * (`computeToolDefinitionHash`), so existing TOFU pins stay valid
169
+ * and drift detection still sees the original bytes - two
170
+ * differently-poisoned schemas must not collapse into one redacted
171
+ * hash.
172
+ * - Semantic keywords and unknown vocabulary are cloned byte-identical
173
+ * (annotation keys are a whitelist, not a blacklist), so input
174
+ * validation behaves exactly as with the raw schema.
175
+ * - `'pass-through'` returns the input as-is (operator override, same
176
+ * contract as {@link sanitizeDescription}).
177
+ */
178
+ export function sanitizeSchemaAnnotations(args: {
179
+ readonly schema: JsonSchemaLike;
180
+ readonly inboundSanitization: InboundSanitizationPolicy;
181
+ readonly toolName: string;
182
+ readonly serverIdentity: ServerIdentity;
183
+ }): SanitizedSchemaResult {
184
+ if (args.inboundSanitization === 'pass-through') {
185
+ return { schema: args.schema, patternsHit: Object.freeze<string[]>([]) };
186
+ }
187
+ const hits = new Set<string>();
188
+ const sanitizeString = (body: string): string => {
189
+ const outcome = applyInboundSanitization({
190
+ body,
191
+ policy: 'detect-and-strip',
192
+ trustClass: 'mcp-derived',
193
+ toolName: args.toolName,
194
+ contentOrigin: `mcp:tool-schema:${args.serverIdentity.id}`,
195
+ failClosed: false,
196
+ });
197
+ for (const hit of outcome.patternsHit) hits.add(hit);
198
+ return outcome.body;
199
+ };
200
+ const schema = walkSchema(args.schema, sanitizeString) as JsonSchemaLike;
201
+ if (hits.size > 0) {
202
+ // Registration-time signal, mirroring the description counter (C6).
203
+ incrementCounter('mcp.tool-schema.injection-flagged.total', {
204
+ server: args.serverIdentity.id,
205
+ tool: args.toolName,
206
+ });
207
+ }
208
+ return { schema, patternsHit: Object.freeze([...hits]) };
209
+ }
210
+
211
+ function walkSchema(node: unknown, sanitizeString: (body: string) => string): unknown {
212
+ if (node === null || typeof node !== 'object') return node;
213
+ if (Array.isArray(node)) return node.map((el) => walkSchema(el, sanitizeString));
214
+ const out: Record<string, unknown> = {};
215
+ for (const [key, value] of Object.entries(node)) {
216
+ if (ANNOTATION_STRING_KEYS.has(key) && typeof value === 'string') {
217
+ out[key] = sanitizeString(value);
218
+ } else if (key === 'examples' && Array.isArray(value)) {
219
+ out[key] = value.map((el) => (typeof el === 'string' ? sanitizeString(el) : cloneJson(el)));
220
+ } else if (SCHEMA_VALUE_KEYS.has(key)) {
221
+ out[key] = walkSchema(value, sanitizeString);
222
+ } else if (SCHEMA_LIST_KEYS.has(key) && Array.isArray(value)) {
223
+ out[key] = value.map((el) => walkSchema(el, sanitizeString));
224
+ } else if (
225
+ SCHEMA_RECORD_KEYS.has(key) &&
226
+ typeof value === 'object' &&
227
+ value !== null &&
228
+ !Array.isArray(value)
229
+ ) {
230
+ const record: Record<string, unknown> = {};
231
+ for (const [name, sub] of Object.entries(value)) {
232
+ record[name] = walkSchema(sub, sanitizeString);
233
+ }
234
+ out[key] = record;
235
+ } else {
236
+ out[key] = cloneJson(value);
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /** Deep JSON clone that keeps semantic keyword values byte-identical. */
243
+ function cloneJson(value: unknown): unknown {
244
+ if (value === null || typeof value !== 'object') return value;
245
+ return structuredClone(value);
246
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Public surface for the MCP client.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ export { _resetSseWarnDedupForTesting, createMCPClient } from './client.js';
8
+ export {
9
+ type CreateManagedMCPClientOptions,
10
+ createManagedMCPClient,
11
+ type ManagedReconnectOptions,
12
+ } from './managed.js';
13
+ export {
14
+ createMcpResourceReader,
15
+ type McpResourceReaderOptions,
16
+ } from './mcp-resource-reader.js';
17
+ export {
18
+ _resetMcpAdapterDedupForTesting,
19
+ adaptCallResult,
20
+ adaptMCPTools,
21
+ DEFAULT_DEFER_LOADING_THRESHOLD,
22
+ } from './to-tools.js';
23
+ export type {
24
+ CreateMCPClientOptions,
25
+ MCPCallToolResult,
26
+ MCPClient,
27
+ MCPContentPart,
28
+ MCPElicitationHandler,
29
+ MCPElicitationRequest,
30
+ MCPElicitationResult,
31
+ MCPPromptDefinition,
32
+ MCPPromptMessage,
33
+ MCPResourceContent,
34
+ MCPResourceDefinition,
35
+ MCPSamplingContent,
36
+ MCPSamplingHandler,
37
+ MCPSamplingMessage,
38
+ MCPSamplingRequest,
39
+ MCPSamplingResult,
40
+ MCPToolDefinition,
41
+ MCPToToolsOptions,
42
+ } from './types.js';
@@ -0,0 +1,222 @@
1
+ /**
2
+ * W-080: opt-in managed MCP client with automatic reconnection.
3
+ *
4
+ * `createMCPClient` connections are deliberately one-shot: transient
5
+ * Streamable-HTTP hiccups are healed by the SDK itself (Last-Event-ID),
6
+ * but a dead stdio child or a lost HTTP session kills the client for
7
+ * good, and every `Tool` adapted from it closes over the corpse. The
8
+ * managed wrapper fixes the long-running-agent story:
9
+ *
10
+ * - it holds the current inner client in a mutable ref and implements
11
+ * {@link MCPClient} by delegation, so ALL consumers (including every
12
+ * adapted `Tool.execute`, which the wrapper's `toTools()` binds to the
13
+ * WRAPPER, not the inner client) transparently follow a swap;
14
+ * - on transport close it rebuilds the inner client with exponential
15
+ * backoff + jitter (`mcp.reconnect.attempt/success/gave-up.total`
16
+ * counters), then re-runs `toTools()` with the last-used options so
17
+ * the pin comparison (TOFU store / explicit pins) re-screens the
18
+ * post-reconnect catalogue - a rug-pull across a reconnect is caught;
19
+ * - it NEVER retries an in-flight call: a call that died with the
20
+ * transport stays failed (retry policy belongs to the executor /
21
+ * model), only the CONNECTION heals;
22
+ * - the operator's `onTransportClose` fires once, on FINAL failure
23
+ * (reconnect exhausted) - intermediate closes are the wrapper's job.
24
+ *
25
+ * The plain `createMCPClient` contract is unchanged; this wrapper is a
26
+ * separate, additive entry point.
27
+ *
28
+ * @packageDocumentation
29
+ */
30
+
31
+ import type { Tool } from '@graphorin/core';
32
+ import { incrementCounter } from '@graphorin/tools/audit';
33
+ import { createMCPClient } from './client.js';
34
+ import { runToTools, type ToolFingerprintRef } from './to-tools-run.js';
35
+ import type { CreateMCPClientOptions, MCPClient, MCPToToolsOptions } from './types.js';
36
+
37
+ /** Reconnection tuning for {@link createManagedMCPClient}. @stable */
38
+ export interface ManagedReconnectOptions {
39
+ /** Attempts per outage before giving up. Default `5`. */
40
+ readonly maxAttempts?: number;
41
+ /** First backoff delay (doubles per attempt, jittered). Default `500` ms. */
42
+ readonly initialDelayMs?: number;
43
+ /** Backoff ceiling. Default `30_000` ms. */
44
+ readonly maxDelayMs?: number;
45
+ }
46
+
47
+ /** Options for {@link createManagedMCPClient}. @stable */
48
+ export type CreateManagedMCPClientOptions = CreateMCPClientOptions & {
49
+ readonly reconnect?: ManagedReconnectOptions;
50
+ /**
51
+ * Client factory seam - tests inject fake inner clients; production
52
+ * uses {@link createMCPClient}.
53
+ *
54
+ * @internal
55
+ */
56
+ readonly _clientFactory?: (options: CreateMCPClientOptions) => Promise<MCPClient>;
57
+ };
58
+
59
+ /**
60
+ * Open a managed (auto-reconnecting) MCP client. See the module doc for
61
+ * the exact semantics. `close()` is terminal: it stops any in-progress
62
+ * backoff and no further reconnects happen.
63
+ *
64
+ * @stable
65
+ */
66
+ export async function createManagedMCPClient(
67
+ options: CreateManagedMCPClientOptions,
68
+ ): Promise<MCPClient> {
69
+ const { reconnect, _clientFactory, ...clientOptions } = options;
70
+ const factory = _clientFactory ?? createMCPClient;
71
+ const maxAttempts = reconnect?.maxAttempts ?? 5;
72
+ const initialDelayMs = reconnect?.initialDelayMs ?? 500;
73
+ const maxDelayMs = reconnect?.maxDelayMs ?? 30_000;
74
+
75
+ let closed = false;
76
+ let reconnecting: Promise<void> | null = null;
77
+ let toolsEverListed = false;
78
+ let lastToToolsOpts: MCPToToolsOptions | undefined;
79
+ // Drift tracking spans reconnects on purpose: a definition swapped
80
+ // behind a reconnect still diffs against the pre-outage snapshot.
81
+ const fingerprintRef: ToolFingerprintRef = { current: undefined };
82
+
83
+ const innerOptions: CreateMCPClientOptions = {
84
+ ...clientOptions,
85
+ // The wrapper owns transport-close handling; the operator callback
86
+ // fires once, on final (gave-up) failure - see the module doc.
87
+ onTransportClose: (info) => {
88
+ if (closed) return;
89
+ void reconnectLoop(info);
90
+ },
91
+ };
92
+
93
+ let current = await factory(innerOptions);
94
+
95
+ async function reconnectLoop(info: { readonly server: string }): Promise<void> {
96
+ if (closed || reconnecting !== null) return;
97
+ const task = (async () => {
98
+ const dead = current;
99
+ let delay = initialDelayMs;
100
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
101
+ if (closed) return;
102
+ incrementCounter('mcp.reconnect.attempt.total', { server: info.server });
103
+ try {
104
+ const next = await factory(innerOptions);
105
+ if (closed) {
106
+ // close() raced the rebuild - do not resurrect.
107
+ void next.close().catch(() => {});
108
+ return;
109
+ }
110
+ current = next;
111
+ incrementCounter('mcp.reconnect.success.total', { server: info.server });
112
+ options.logger?.('info', 'mcp.reconnect.success: inner client rebuilt', {
113
+ server: info.server,
114
+ attempt,
115
+ });
116
+ // Best-effort close of the dead client (idempotent by contract).
117
+ void dead.close().catch(() => {});
118
+ // Re-screen the post-reconnect catalogue with the SAME options
119
+ // the operator last used, so pin comparison + drift diff run
120
+ // against it. A pin rejection here must not kill the reconnect
121
+ // (the connection is healthy) - it is logged and counted by
122
+ // the pipeline itself.
123
+ if (toolsEverListed) {
124
+ try {
125
+ await managedToTools(lastToToolsOpts);
126
+ } catch (cause) {
127
+ options.logger?.(
128
+ 'warn',
129
+ 'mcp.reconnect.catalogue-rescreen-failed: post-reconnect toTools() rejected (pin mismatch or listing failure) - the previously adapted tools remain registered; investigate before trusting this server',
130
+ {
131
+ server: info.server,
132
+ error: cause instanceof Error ? cause.message : String(cause),
133
+ },
134
+ );
135
+ }
136
+ }
137
+ return;
138
+ } catch (cause) {
139
+ if (attempt >= maxAttempts) {
140
+ incrementCounter('mcp.reconnect.gave-up.total', { server: info.server });
141
+ options.logger?.('error', 'mcp.reconnect.gave-up: reconnect attempts exhausted', {
142
+ server: info.server,
143
+ attempts: attempt,
144
+ error: cause instanceof Error ? cause.message : String(cause),
145
+ });
146
+ options.onTransportClose?.(info);
147
+ return;
148
+ }
149
+ // Full jitter on an exponential ladder, capped at maxDelayMs.
150
+ const jittered = Math.min(delay, maxDelayMs) * (0.5 + Math.random() * 0.5);
151
+ await sleep(jittered);
152
+ delay = Math.min(delay * 2, maxDelayMs);
153
+ }
154
+ }
155
+ })().finally(() => {
156
+ reconnecting = null;
157
+ });
158
+ reconnecting = task;
159
+ await task;
160
+ }
161
+
162
+ async function managedToTools(toolsOpts?: MCPToToolsOptions): Promise<ReadonlyArray<Tool>> {
163
+ toolsEverListed = true;
164
+ lastToToolsOpts = toolsOpts;
165
+ return runToTools({
166
+ // THE point of the wrapper: adapted tools close over `managed`,
167
+ // so their `execute` transparently follows an inner-client swap.
168
+ client: managed,
169
+ fingerprintRef,
170
+ ...(options.logger === undefined ? {} : { logger: options.logger }),
171
+ ...(toolsOpts === undefined ? {} : { toolsOpts }),
172
+ });
173
+ }
174
+
175
+ // Data fields delegate through getters so a swap is instantly visible;
176
+ // the cast bridges `priority?: number` vs an always-present getter
177
+ // returning `number | undefined` (exactOptionalPropertyTypes).
178
+ const managed = {
179
+ get id() {
180
+ return current.id;
181
+ },
182
+ get serverInfo() {
183
+ return current.serverInfo;
184
+ },
185
+ get serverIdentity() {
186
+ return current.serverIdentity;
187
+ },
188
+ get collisionStrategy() {
189
+ return current.collisionStrategy;
190
+ },
191
+ get priority() {
192
+ return current.priority;
193
+ },
194
+ get sessionIdPresent() {
195
+ return current.sessionIdPresent;
196
+ },
197
+ get resumable() {
198
+ return current.resumable;
199
+ },
200
+ listTools: (opts?: { signal?: AbortSignal }) => current.listTools(opts),
201
+ listResources: (opts?: { signal?: AbortSignal }) => current.listResources(opts),
202
+ listPrompts: (opts?: { signal?: AbortSignal }) => current.listPrompts(opts),
203
+ callTool: (name: string, args: unknown, opts?: { signal?: AbortSignal; timeoutMs?: number }) =>
204
+ current.callTool(name, args, opts),
205
+ readResource: (uri: string, opts?: { signal?: AbortSignal }) => current.readResource(uri, opts),
206
+ readResourceContents: (uri: string, opts?: { signal?: AbortSignal }) =>
207
+ current.readResourceContents(uri, opts),
208
+ getPrompt: (name: string, args?: unknown, opts?: { signal?: AbortSignal }) =>
209
+ current.getPrompt(name, args, opts),
210
+ toTools: managedToTools,
211
+ close: async (): Promise<void> => {
212
+ closed = true;
213
+ await current.close();
214
+ },
215
+ } as MCPClient;
216
+
217
+ return managed;
218
+ }
219
+
220
+ function sleep(ms: number): Promise<void> {
221
+ return new Promise((resolve) => setTimeout(resolve, ms));
222
+ }