@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,294 @@
1
+ /**
2
+ * `toTools()` adapter - bridges MCP tool descriptors into the
3
+ * Graphorin tool registry.
4
+ *
5
+ * The adapter orchestrates three extracted concerns (F-MCP-001):
6
+ *
7
+ * 1. Filters / namespaces the `listTools()` output, then resolves the
8
+ * per-server effective `defer_loading` flag - see
9
+ * {@link import('./defer-loading.js').resolveDeferLoading}.
10
+ * 2. Resolves the per-server inbound prompt-injection policy and strips
11
+ * imperative payloads from each description - see
12
+ * {@link import('./inbound-filters.js')}.
13
+ * 3. Converts each MCP tool into a strongly-typed Graphorin `Tool` whose
14
+ * `execute(...)` calls back into {@link MCPClient.callTool} and adapts
15
+ * the result - see {@link import('./adapt-result.js').adaptCallResult}.
16
+ *
17
+ * The trust class is pinned to `'mcp-derived'` so the agent runtime's
18
+ * per-step preamble fires regardless of the body-level policy.
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+
23
+ import type { ContentChunk, InboundSanitizationPolicy, Tool, ToolReturn } from '@graphorin/core';
24
+ import { buildJsonSchemaValidator, type JsonSchemaLike } from '../registry/json-schema.js';
25
+ import type { ServerIdentity } from '../transport/types.js';
26
+ import { adaptCallResult } from './adapt-result.js';
27
+ import {
28
+ _resetDeferLoadingDedupForTesting,
29
+ DEFAULT_DEFER_LOADING_THRESHOLD,
30
+ resolveDeferLoading,
31
+ } from './defer-loading.js';
32
+ import {
33
+ _resetInboundFiltersDedupForTesting,
34
+ resolveInboundPolicy,
35
+ sanitizeDescription,
36
+ sanitizeSchemaAnnotations,
37
+ warnOnPassthroughOverride,
38
+ } from './inbound-filters.js';
39
+ import { computeToolDefinitionHash } from './pinning.js';
40
+ import type { MCPClient, MCPToolDefinition, MCPToToolsOptions } from './types.js';
41
+
42
+ // Re-exported for backward compatibility: callers (and tests) import
43
+ // these symbols directly from `./to-tools.js` and via the client barrel.
44
+ export { adaptCallResult } from './adapt-result.js';
45
+ export { DEFAULT_DEFER_LOADING_THRESHOLD } from './defer-loading.js';
46
+
47
+ /**
48
+ * Reset every process-scoped dedup set owned by the adapter modules.
49
+ * Used by tests.
50
+ *
51
+ * @experimental
52
+ */
53
+ export function _resetMcpAdapterDedupForTesting(): void {
54
+ _resetDeferLoadingDedupForTesting();
55
+ _resetInboundFiltersDedupForTesting();
56
+ }
57
+
58
+ /** Result returned by {@link adaptMCPTools}. */
59
+ export interface AdaptedToolsResult {
60
+ readonly tools: ReadonlyArray<Tool>;
61
+ /** MC-6: sha256 definition fingerprint per MCP tool name. */
62
+ readonly fingerprints: ReadonlyMap<string, string>;
63
+ readonly autoDeferralFired: boolean;
64
+ readonly resolvedDeferLoading: boolean;
65
+ readonly resolvedInboundSanitization: InboundSanitizationPolicy;
66
+ readonly toolCount: number;
67
+ readonly deferralThreshold: number;
68
+ /**
69
+ * W-105: tool names the operator downgraded below the sink classes
70
+ * via `sideEffectClassByTool` (`'read-only'` / `'pure'`). Each such
71
+ * tool leaves EVERY sink check - the dataflow gate, the Rule-of-Two
72
+ * writer forbid, the read-only capability gate - so operator audits
73
+ * should review this list. Empty when no override downgrades.
74
+ */
75
+ readonly downgradedTools: ReadonlyArray<string>;
76
+ }
77
+
78
+ /**
79
+ * Build the {@link Tool} array for the supplied MCP tool catalogue.
80
+ *
81
+ * @stable
82
+ */
83
+ export function adaptMCPTools(args: {
84
+ readonly client: MCPClient;
85
+ readonly serverIdentity: ServerIdentity;
86
+ readonly catalogue: ReadonlyArray<MCPToolDefinition>;
87
+ readonly options?: MCPToToolsOptions;
88
+ readonly logger?: (
89
+ level: 'debug' | 'info' | 'warn' | 'error',
90
+ message: string,
91
+ fields?: Record<string, unknown>,
92
+ ) => void;
93
+ }): AdaptedToolsResult {
94
+ const opts = args.options ?? {};
95
+ const fingerprints = new Map<string, string>();
96
+ const filter = opts.filter;
97
+ const namespace = (opts.namespace ?? '').trim();
98
+ const filtered = filter === undefined ? args.catalogue : args.catalogue.filter((t) => filter(t));
99
+ const total = filtered.length;
100
+ const threshold = opts.deferLoadingThreshold ?? DEFAULT_DEFER_LOADING_THRESHOLD;
101
+
102
+ const { autoDeferralFired, resolvedDeferLoading } = resolveDeferLoading({
103
+ serverIdentity: args.serverIdentity,
104
+ toolNames: filtered.map((t) => t.name),
105
+ explicitDefer: opts.defer_loading,
106
+ threshold,
107
+ ...(args.logger === undefined ? {} : { logger: args.logger }),
108
+ });
109
+
110
+ const resolvedInbound = resolveInboundPolicy(opts.inboundSanitization);
111
+ warnOnPassthroughOverride({
112
+ resolvedInbound,
113
+ serverIdentity: args.serverIdentity,
114
+ ...(args.logger === undefined ? {} : { logger: args.logger }),
115
+ });
116
+
117
+ const tools: Tool[] = [];
118
+ const downgradedTools: string[] = [];
119
+ for (const definition of filtered) {
120
+ const namespacedName =
121
+ namespace.length === 0 ? definition.name : `${namespace}.${definition.name}`;
122
+ const sideEffectClass = opts.sideEffectClassByTool?.[namespacedName] ?? 'external-stateful';
123
+ // W-105: a downgrade below the sink classes is an explicit operator
124
+ // trust decision with wide consequences - the tool leaves every sink
125
+ // check (dataflow gate, Rule-of-Two writer forbid, read-only
126
+ // capability gate). One WARN per tool at adaptation time + a result
127
+ // field so audits can pick it up; the server's own readOnlyHint is
128
+ // deliberately never trusted for this.
129
+ if (sideEffectClass === 'read-only' || sideEffectClass === 'pure') {
130
+ downgradedTools.push(namespacedName);
131
+ args.logger?.(
132
+ 'warn',
133
+ `mcp.tools.side-effect-downgraded: operator override classifies '${namespacedName}' as '${sideEffectClass}' - the tool leaves every sink check (dataflow gate, Rule-of-Two writer forbid, read-only capability gate)`,
134
+ {
135
+ server: args.serverIdentity.id,
136
+ tool: namespacedName,
137
+ sideEffectClass,
138
+ },
139
+ );
140
+ }
141
+ const preferredModel = opts.preferredModelByTool?.[namespacedName];
142
+ // W-018: strip imperative payloads from schema ANNOTATIONS
143
+ // (description / title / $comment / string examples at any depth -
144
+ // the Invariant Labs tool-poisoning hiding place) before the
145
+ // schema reaches the validator, whose `toJSON()` is what the
146
+ // provider wire and `tool_search` re-expose.
147
+ const sanitizedInput = sanitizeSchemaAnnotations({
148
+ schema: definition.inputSchema as JsonSchemaLike,
149
+ inboundSanitization: resolvedInbound,
150
+ toolName: namespacedName,
151
+ serverIdentity: args.serverIdentity,
152
+ });
153
+ const sanitizedOutput =
154
+ definition.outputSchema === undefined
155
+ ? undefined
156
+ : sanitizeSchemaAnnotations({
157
+ schema: definition.outputSchema as JsonSchemaLike,
158
+ inboundSanitization: resolvedInbound,
159
+ toolName: namespacedName,
160
+ serverIdentity: args.serverIdentity,
161
+ });
162
+ const inputValidator = buildJsonSchemaValidator(sanitizedInput.schema);
163
+ const outputValidator =
164
+ sanitizedOutput === undefined ? undefined : buildJsonSchemaValidator(sanitizedOutput.schema);
165
+ // TOFU invariant (MC-6): the fingerprint hashes the RAW definition,
166
+ // never the sanitized copy. Hashing redacted bytes would invalidate
167
+ // the pins of existing deployments AND collapse two
168
+ // differently-poisoned schemas into one redacted hash, hiding drift.
169
+ // `sanitizeSchemaAnnotations` returns a clone and never mutates
170
+ // `definition`, so the order here is belt-and-braces.
171
+ const definitionHash = computeToolDefinitionHash(definition);
172
+ fingerprints.set(definition.name, definitionHash);
173
+ tools.push(
174
+ buildAdaptedTool({
175
+ client: args.client,
176
+ serverIdentity: args.serverIdentity,
177
+ definitionHash,
178
+ ...(args.logger !== undefined ? { logger: args.logger } : {}),
179
+ mcpToolName: definition.name,
180
+ graphorinToolName: namespacedName,
181
+ description:
182
+ definition.description.length === 0 ? `${definition.name} (MCP)` : definition.description,
183
+ inputSchema: inputValidator,
184
+ outputSchema: outputValidator,
185
+ defer_loading: resolvedDeferLoading,
186
+ inboundSanitization: resolvedInbound,
187
+ sideEffectClass,
188
+ ...(opts.maxResultTokens === undefined ? {} : { maxResultTokens: opts.maxResultTokens }),
189
+ ...(opts.truncationStrategy === undefined
190
+ ? {}
191
+ : { truncationStrategy: opts.truncationStrategy }),
192
+ ...(opts.callTimeoutMs === undefined ? {} : { callTimeoutMs: opts.callTimeoutMs }),
193
+ ...(preferredModel === undefined ? {} : { preferredModel }),
194
+ }),
195
+ );
196
+ }
197
+
198
+ return Object.freeze({
199
+ tools: Object.freeze(tools),
200
+ fingerprints,
201
+ autoDeferralFired,
202
+ resolvedDeferLoading,
203
+ resolvedInboundSanitization: resolvedInbound,
204
+ toolCount: total,
205
+ deferralThreshold: threshold,
206
+ downgradedTools: Object.freeze(downgradedTools),
207
+ });
208
+ }
209
+
210
+ interface BuildAdaptedToolArgs {
211
+ readonly client: MCPClient;
212
+ readonly serverIdentity: ServerIdentity;
213
+ readonly mcpToolName: string;
214
+ readonly graphorinToolName: string;
215
+ readonly description: string;
216
+ readonly inputSchema: import('@graphorin/core').ZodLikeSchema<unknown>;
217
+ readonly outputSchema?: import('@graphorin/core').ZodLikeSchema<unknown> | undefined;
218
+ readonly defer_loading: boolean;
219
+ readonly inboundSanitization: InboundSanitizationPolicy;
220
+ readonly sideEffectClass: import('@graphorin/core').SideEffectClass;
221
+ readonly maxResultTokens?: number;
222
+ readonly truncationStrategy?: import('@graphorin/core').TruncationStrategy;
223
+ /** Per-call timeout forwarded to `client.callTool` (MC-3/MC-5). */
224
+ readonly callTimeoutMs?: number;
225
+ /** MC-6: sha256 fingerprint of the producing MCP definition. */
226
+ readonly definitionHash: string;
227
+ readonly logger?: (
228
+ level: 'debug' | 'info' | 'warn' | 'error',
229
+ message: string,
230
+ fields?: Record<string, unknown>,
231
+ ) => void;
232
+ readonly preferredModel?:
233
+ | import('@graphorin/core').ModelHint
234
+ | import('@graphorin/core').ModelSpec;
235
+ }
236
+
237
+ function buildAdaptedTool(args: BuildAdaptedToolArgs): Tool<unknown, unknown, unknown> {
238
+ const sanitizedDescription = sanitizeDescription({
239
+ description: args.description,
240
+ inboundSanitization: args.inboundSanitization,
241
+ toolName: args.graphorinToolName,
242
+ serverIdentity: args.serverIdentity,
243
+ });
244
+
245
+ const tool = {
246
+ name: args.graphorinToolName,
247
+ description: sanitizedDescription,
248
+ inputSchema: args.inputSchema,
249
+ ...(args.outputSchema === undefined ? {} : { outputSchema: args.outputSchema }),
250
+ defer_loading: args.defer_loading,
251
+ inboundSanitization: args.inboundSanitization,
252
+ sideEffectClass: args.sideEffectClass,
253
+ sandboxPolicy: 'sandboxed' as const,
254
+ ...(args.maxResultTokens === undefined ? {} : { maxResultTokens: args.maxResultTokens }),
255
+ ...(args.truncationStrategy === undefined
256
+ ? {}
257
+ : { truncationStrategy: args.truncationStrategy }),
258
+ ...(args.preferredModel === undefined ? {} : { preferredModel: args.preferredModel }),
259
+ async execute(
260
+ input: unknown,
261
+ ctx?: import('@graphorin/core').ToolExecutionContext<unknown>,
262
+ ): Promise<ToolReturn<unknown>> {
263
+ // MC-5: the agent's per-call AbortSignal reaches the wire - an
264
+ // aborted run sends `notifications/cancelled` instead of orphaning
265
+ // the JSON-RPC request on the server. MC-3: the per-server call
266
+ // timeout rides along.
267
+ const result = await args.client.callTool(args.mcpToolName, input, {
268
+ ...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
269
+ ...(args.callTimeoutMs !== undefined ? { timeoutMs: args.callTimeoutMs } : {}),
270
+ });
271
+ return adaptCallResult({
272
+ result,
273
+ outputSchema: args.outputSchema,
274
+ serverIdentity: args.serverIdentity,
275
+ toolName: args.graphorinToolName,
276
+ inboundSanitization: args.inboundSanitization,
277
+ ...(args.logger !== undefined ? { logger: args.logger } : {}),
278
+ });
279
+ },
280
+ } satisfies Tool<unknown, unknown, unknown>;
281
+ // MC-7: pre-stamp the provenance so the agent's inferToolSource -
282
+ // which honours an existing stamp - classifies tools passed via
283
+ // `config.tools` as 'mcp-derived' (untrusted for the WI-12 dataflow
284
+ // policy) instead of first-party. Zero operator boilerplate.
285
+ return Object.assign(tool, {
286
+ __source: { kind: 'mcp', serverIdentity: args.serverIdentity.id } as const,
287
+ // MC-6: operators persist this fingerprint to pin the approved
288
+ // definition (`toTools({ pinnedFingerprints })`).
289
+ __definitionHash: args.definitionHash,
290
+ });
291
+ }
292
+
293
+ /** Unused export kept for ergonomic test access. */
294
+ export type AdaptedToolChunkBuffer = ReadonlyArray<ContentChunk>;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Factory that materialises a `@modelcontextprotocol/sdk` `Transport`
3
+ * instance from a {@link MCPTransportConfig}.
4
+ *
5
+ * The factory is small on purpose: every MCP-spec evolution (new
6
+ * transport, transport-specific option, transport-level header
7
+ * convention) lands here so the higher-level {@link createMCPClient}
8
+ * stays untouched.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
14
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
15
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
16
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
17
+
18
+ import type { MCPTransportConfig } from '../transport/types.js';
19
+
20
+ /**
21
+ * Outcome of the transport build, including the SDK transport
22
+ * instance and the helper hooks the client needs to forward to the
23
+ * SDK.
24
+ */
25
+ export interface BuiltTransport {
26
+ readonly transport: Transport;
27
+ readonly disposeBeforeStart?: () => Promise<void>;
28
+ }
29
+
30
+ /**
31
+ * Live source of the outbound `Authorization` header for the HTTP
32
+ * transports. The bridge's `OAuthAuthorizationProvider` satisfies this
33
+ * structurally (its `resolveHeader()` already returns the full
34
+ * `Bearer <token>` value); a static `bearerToken` is wrapped into a
35
+ * constant resolver by {@link createMCPClient}.
36
+ *
37
+ * @internal
38
+ */
39
+ export interface TransportAuthSource {
40
+ /** Resolve the full `Authorization` header value (e.g. `Bearer abc`). */
41
+ resolveHeader(): Promise<string> | string;
42
+ }
43
+
44
+ /**
45
+ * Wrap a `fetch` implementation so the live `Authorization` header is
46
+ * re-resolved and injected on **every** outgoing request. The wrapper
47
+ * always overwrites a caller-supplied `Authorization` header so the
48
+ * provider stays the single source of truth (refresh-ahead from
49
+ * {@link createOAuthAuthorizationProvider} fires per request).
50
+ */
51
+ function authWrappedFetch(baseFetch: typeof fetch, auth: TransportAuthSource): typeof fetch {
52
+ return (async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
53
+ const header = await auth.resolveHeader();
54
+ const headers = new Headers(init?.headers);
55
+ headers.set('Authorization', header);
56
+ return baseFetch(input, { ...init, headers });
57
+ }) as typeof fetch;
58
+ }
59
+
60
+ /**
61
+ * Build a SDK `Transport` from a {@link MCPTransportConfig}.
62
+ *
63
+ * When an `auth` source is supplied (HTTP transports only) the factory
64
+ * installs a per-request fetch-wrapper that re-resolves the
65
+ * `Authorization` header on every outgoing call - this is the seam that
66
+ * makes a long-lived agent session survive OAuth token expiry without
67
+ * re-creating the client.
68
+ *
69
+ * @stable
70
+ */
71
+ export function buildTransport(
72
+ config: MCPTransportConfig,
73
+ options?: { readonly auth?: TransportAuthSource },
74
+ ): BuiltTransport {
75
+ switch (config.kind) {
76
+ case 'stdio': {
77
+ const stdio: Transport = new StdioClientTransport({
78
+ command: config.command,
79
+ ...(config.args === undefined ? {} : { args: [...config.args] }),
80
+ ...(config.env === undefined ? {} : { env: { ...config.env } }),
81
+ ...(config.cwd === undefined ? {} : { cwd: config.cwd }),
82
+ ...(config.stderr === undefined ? {} : { stderr: config.stderr }),
83
+ });
84
+ return Object.freeze({ transport: stdio });
85
+ }
86
+ case 'streamable-http': {
87
+ const url = typeof config.url === 'string' ? new URL(config.url) : config.url;
88
+ const headers = { ...(config.headers ?? {}) };
89
+ const baseFetch = config.fetch ?? fetch;
90
+ const fetchImpl =
91
+ options?.auth === undefined ? config.fetch : authWrappedFetch(baseFetch, options.auth);
92
+ // The SDK class's `sessionId: string | undefined` getter is
93
+ // structurally narrower than `Transport.sessionId?: string` in
94
+ // strict mode; route through `unknown` to keep the public
95
+ // surface clean.
96
+ const sdkTransport = new StreamableHTTPClientTransport(url, {
97
+ ...(fetchImpl === undefined ? {} : { fetch: fetchImpl }),
98
+ requestInit: { headers },
99
+ ...(config.sessionId === undefined ? {} : { sessionId: config.sessionId }),
100
+ });
101
+ const transport = sdkTransport as unknown as Transport;
102
+ return Object.freeze({ transport });
103
+ }
104
+ case 'sse': {
105
+ const url = typeof config.url === 'string' ? new URL(config.url) : config.url;
106
+ const headers = { ...(config.headers ?? {}) };
107
+ const baseFetch = config.fetch ?? fetch;
108
+ const fetchImpl =
109
+ options?.auth === undefined ? config.fetch : authWrappedFetch(baseFetch, options.auth);
110
+ const transport: Transport = new SSEClientTransport(url, {
111
+ ...(fetchImpl === undefined ? {} : { fetch: fetchImpl }),
112
+ requestInit: { headers },
113
+ });
114
+ return Object.freeze({ transport });
115
+ }
116
+ }
117
+ }