@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.
- package/CHANGELOG.md +55 -0
- package/README.md +73 -5
- package/dist/client/adapt-result.d.ts +9 -1
- package/dist/client/adapt-result.d.ts.map +1 -1
- package/dist/client/adapt-result.js +28 -10
- package/dist/client/adapt-result.js.map +1 -1
- package/dist/client/client-handlers.js +7 -1
- package/dist/client/client-handlers.js.map +1 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +47 -71
- package/dist/client/client.js.map +1 -1
- package/dist/client/inbound-filters.js +101 -1
- package/dist/client/inbound-filters.js.map +1 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +2 -1
- package/dist/client/managed.d.ts +35 -0
- package/dist/client/managed.d.ts.map +1 -0
- package/dist/client/managed.js +136 -0
- package/dist/client/managed.js.map +1 -0
- package/dist/client/mcp-resource-reader.js +1 -1
- package/dist/client/mcp-resource-reader.js.map +1 -1
- package/dist/client/to-tools-run.js +119 -0
- package/dist/client/to-tools-run.js.map +1 -0
- package/dist/client/to-tools.d.ts +8 -0
- package/dist/client/to-tools.d.ts.map +1 -1
- package/dist/client/to-tools.js +27 -4
- package/dist/client/to-tools.js.map +1 -1
- package/dist/client/types.d.ts +12 -3
- package/dist/client/types.d.ts.map +1 -1
- package/dist/errors/index.d.ts +3 -3
- package/dist/errors/index.js +1 -1
- package/dist/errors/index.js.map +1 -1
- package/dist/helpers/identity.d.ts +11 -0
- package/dist/helpers/identity.d.ts.map +1 -1
- package/dist/helpers/identity.js +13 -2
- package/dist/helpers/identity.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/registry/json-schema.js +26 -8
- package/dist/registry/json-schema.js.map +1 -1
- package/dist/transport/types.d.ts +9 -0
- package/package.json +13 -12
- package/src/client/adapt-result.ts +302 -0
- package/src/client/client-handlers.ts +215 -0
- package/src/client/client.ts +725 -0
- package/src/client/defer-loading.ts +108 -0
- package/src/client/inbound-filters.ts +246 -0
- package/src/client/index.ts +42 -0
- package/src/client/managed.ts +222 -0
- package/src/client/mcp-resource-reader.ts +183 -0
- package/src/client/pinning.ts +48 -0
- package/src/client/to-tools-run.ts +178 -0
- package/src/client/to-tools.ts +294 -0
- package/src/client/transport-factory.ts +117 -0
- package/src/client/types.ts +422 -0
- package/src/errors/index.ts +170 -0
- package/src/helpers/identity.ts +128 -0
- package/src/helpers/index.ts +8 -0
- package/src/helpers/validate-config.ts +95 -0
- package/src/index.ts +49 -0
- package/src/oauth/bridge.ts +143 -0
- package/src/oauth/index.ts +18 -0
- package/src/oauth/library.ts +61 -0
- package/src/registry/json-schema.ts +402 -0
- package/src/transport/index.ts +15 -0
- 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
|
+
}
|