@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,183 @@
1
+ /**
2
+ * {@link createMcpResourceReader} - resolve MCP `resource_link` handles
3
+ * on demand (WI-13 / P2-2, ties to WI-10 result handles).
4
+ *
5
+ * The `toTools()` adapter surfaces an MCP `resource_link` as a preview +
6
+ * the resource `uri` (the {@link import('@graphorin/tools/result').ResultReader}
7
+ * handle) instead of inlining the body. This reader resolves that handle
8
+ * via {@link MCPClient.readResource} when the model later calls the
9
+ * built-in `read_result` tool - so a large MCP resource never inflates
10
+ * context until the model actually asks for it.
11
+ *
12
+ * Compose it with the agent's spill-file reader (the agent tries each
13
+ * configured reader in order): the spill reader claims
14
+ * `graphorin-spill:` handles, this reader resolves the rest. With more
15
+ * than one MCP client, the reader tries each until one server resolves
16
+ * the URI.
17
+ *
18
+ * Network note (R4): this reader performs no I/O until `read(...)` is
19
+ * called, and then only over a connection the operator already opened.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ import { incrementCounter } from '@graphorin/tools/audit';
25
+ import type { ResultReader, ResultReadOutcome, ResultReadRange } from '@graphorin/tools/result';
26
+ import type { MCPClient, MCPResourceContent } from './types.js';
27
+
28
+ /** Configuration for {@link createMcpResourceReader}. */
29
+ export interface McpResourceReaderOptions {
30
+ /** Clients consulted (in order) to resolve a resource URI. */
31
+ readonly clients: ReadonlyArray<MCPClient>;
32
+ /** Default `maxBytes` when `read(...)` is called without one. Default `65536`. */
33
+ readonly defaultMaxBytes?: number;
34
+ /**
35
+ * mcp-skills-06: allow a BARE (unscoped) resource URI to be tried
36
+ * against every configured client. Default `false` - handles minted
37
+ * by the adapter are scoped (`mcp:<serverId>:<uri>`) and resolve
38
+ * ONLY against their originating server, so a malicious server's
39
+ * link (or a prompt-injected model) cannot fetch a resource from a
40
+ * different, more-trusted server (the cross-server confused-deputy
41
+ * hop). Enable only when you accept that risk for legacy handles.
42
+ */
43
+ readonly allowCrossServer?: boolean;
44
+ }
45
+
46
+ /** Scoped-handle grammar minted by the tool adapter: `mcp:<serverId>:<uri>`. */
47
+ const SCOPED_HANDLE = /^mcp:([^:]+):(.+)$/;
48
+
49
+ /**
50
+ * Build a {@link ResultReader} that resolves MCP resource URIs through
51
+ * one or more connected {@link MCPClient}s.
52
+ *
53
+ * @stable
54
+ */
55
+ export function createMcpResourceReader(opts: McpResourceReaderOptions): ResultReader {
56
+ const clients = [...opts.clients];
57
+ const defaultMaxBytes = opts.defaultMaxBytes ?? 65_536;
58
+ return {
59
+ async read(handle, range): Promise<ResultReadOutcome> {
60
+ if (clients.length === 0) {
61
+ throw new Error(
62
+ 'createMcpResourceReader: no MCP clients configured to resolve resource handles.',
63
+ );
64
+ }
65
+ // mcp-skills-06: a scoped handle (`mcp:<serverId>:<uri>`) resolves
66
+ // ONLY against its originating server. The try-every-client loop
67
+ // survives solely for bare URIs behind the explicit
68
+ // `allowCrossServer` opt-in.
69
+ const scoped = SCOPED_HANDLE.exec(handle);
70
+ let uri = handle;
71
+ let candidates = clients;
72
+ if (scoped !== null) {
73
+ // W-140: the id segment is percent-encoded at mint time (ids
74
+ // carry ':' since W-016 made ports part of the identity).
75
+ const serverId = decodeURIComponent(scoped[1] ?? '');
76
+ uri = scoped[2] ?? '';
77
+ candidates = clients.filter((c) => c.serverIdentity.id === serverId);
78
+ if (candidates.length === 0) {
79
+ throw new Error(
80
+ `createMcpResourceReader: no configured client matches the handle's server ` +
81
+ `'${serverId}' - a handle only resolves against its originating server.`,
82
+ );
83
+ }
84
+ } else if (opts.allowCrossServer !== true) {
85
+ throw new Error(
86
+ `createMcpResourceReader: refusing to resolve the unscoped resource URI ` +
87
+ `${JSON.stringify(handle)} against every configured server (cross-server ` +
88
+ `confused-deputy risk). Use the scoped handle from the tool result ` +
89
+ `('mcp:<serverId>:<uri>'), or opt in with allowCrossServer: true.`,
90
+ );
91
+ }
92
+ let lastError: unknown;
93
+ for (const client of candidates) {
94
+ let content: MCPResourceContent;
95
+ try {
96
+ content = await client.readResource(uri);
97
+ } catch (err) {
98
+ lastError = err;
99
+ continue;
100
+ }
101
+ incrementCounter('mcp.resource-link.resolved.total', {
102
+ server: client.serverIdentity.id,
103
+ });
104
+ return sliceResource(content, range, defaultMaxBytes);
105
+ }
106
+ const suffix = lastError instanceof Error ? ` Last error: ${lastError.message}` : '';
107
+ throw new Error(
108
+ `createMcpResourceReader: no configured MCP server resolved resource ${JSON.stringify(uri)}.${suffix}`,
109
+ );
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Raw resource bytes (MC-10): text resources as UTF-8, blob resources
116
+ * DECODED from base64 - slicing/totalBytes operate on real payload
117
+ * bytes, never on the ~33%-inflated base64 string (whose arbitrary
118
+ * cuts also break base64 quads).
119
+ */
120
+ function resourceBytes(content: MCPResourceContent): Buffer {
121
+ if (content.text !== undefined) return Buffer.from(content.text, 'utf8');
122
+ if (content.blob !== undefined) return Buffer.from(content.blob, 'base64');
123
+ return Buffer.alloc(0);
124
+ }
125
+
126
+ /**
127
+ * Apply a {@link ResultReadRange} to the fully-fetched resource body,
128
+ * mirroring the spill-file reader's slicing semantics (line mode wins;
129
+ * `maxBytes` caps the returned slice) so `read_result` pages uniformly.
130
+ */
131
+ function sliceResource(
132
+ content: MCPResourceContent,
133
+ range: ResultReadRange | undefined,
134
+ defaultMaxBytes: number,
135
+ ): ResultReadOutcome {
136
+ const buf = resourceBytes(content);
137
+ const full = buf.toString('utf8');
138
+ const totalBytes = buf.byteLength;
139
+ const cap = Math.max(0, range?.maxBytes ?? defaultMaxBytes);
140
+
141
+ if (range?.startLine !== undefined || range?.endLine !== undefined) {
142
+ const lines = full.split('\n');
143
+ const start = Math.max(1, range.startLine ?? 1);
144
+ const end = Math.min(lines.length, range.endLine ?? lines.length);
145
+ const selected = start <= end ? lines.slice(start - 1, end).join('\n') : '';
146
+ const capped = Buffer.byteLength(selected, 'utf8') > cap;
147
+ const out = capBytes(selected, cap);
148
+ return Object.freeze({
149
+ // TL-6: MCP resource content is mcp-derived by definition - the
150
+ // executor re-applies inbound sanitization + dataflow provenance
151
+ // by this class when read_result relays it.
152
+ producerTrustClass: 'mcp-derived' as const,
153
+ content: out,
154
+ bytes: Buffer.byteLength(out, 'utf8'),
155
+ totalBytes,
156
+ eof: end >= lines.length && !capped,
157
+ });
158
+ }
159
+
160
+ const offset = clamp(range?.offset ?? 0, 0, totalBytes);
161
+ const requested = range?.length ?? totalBytes - offset;
162
+ const rawEnd = clamp(offset + Math.max(0, requested), offset, totalBytes);
163
+ const end = Math.min(rawEnd, offset + cap);
164
+ const slice = buf.subarray(offset, end);
165
+ return Object.freeze({
166
+ // TL-6: MCP resource content is mcp-derived by definition.
167
+ producerTrustClass: 'mcp-derived' as const,
168
+ content: slice.toString('utf8'),
169
+ bytes: slice.byteLength,
170
+ totalBytes,
171
+ eof: end >= totalBytes,
172
+ });
173
+ }
174
+
175
+ function clamp(value: number, lo: number, hi: number): number {
176
+ return Math.min(hi, Math.max(lo, value));
177
+ }
178
+
179
+ /** Truncate `s` to at most `maxBytes` UTF-8 bytes (tolerating a split trailing char). */
180
+ function capBytes(s: string, maxBytes: number): string {
181
+ if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
182
+ return Buffer.from(s, 'utf8').subarray(0, maxBytes).toString('utf8');
183
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tool-definition pinning (MC-6): stable fingerprints over MCP tool
3
+ * definitions so approve-then-swap rug-pulls - a server changing a
4
+ * tool's description/schema behind an already-approved name - are
5
+ * detectable across snapshots and process restarts.
6
+ *
7
+ * The fingerprint is a sha256 over a key-sorted canonical render of
8
+ * `{ name, description, inputSchema, outputSchema, title }`. Operators
9
+ * persist the stamped `__definitionHash` (or read it from the audit
10
+ * trail) and pass it back via `toTools({ pinnedFingerprints })`.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import { createHash } from 'node:crypto';
16
+
17
+ import type { MCPToolDefinition } from './types.js';
18
+
19
+ /** Deterministic JSON render: object keys sorted recursively. */
20
+ function stableStringify(value: unknown): string {
21
+ if (Array.isArray(value)) {
22
+ return `[${value.map((v) => stableStringify(v)).join(',')}]`;
23
+ }
24
+ if (typeof value === 'object' && value !== null) {
25
+ const entries = Object.entries(value as Record<string, unknown>)
26
+ .filter(([, v]) => v !== undefined)
27
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
28
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
29
+ return `{${entries.join(',')}}`;
30
+ }
31
+ return JSON.stringify(value) ?? 'null';
32
+ }
33
+
34
+ /**
35
+ * Stable sha256 fingerprint of one MCP tool definition (MC-6).
36
+ *
37
+ * @stable
38
+ */
39
+ export function computeToolDefinitionHash(def: MCPToolDefinition): string {
40
+ const canonical = stableStringify({
41
+ name: def.name,
42
+ description: def.description,
43
+ inputSchema: def.inputSchema,
44
+ ...(def.outputSchema !== undefined ? { outputSchema: def.outputSchema } : {}),
45
+ ...(def.title !== undefined ? { title: def.title } : {}),
46
+ });
47
+ return createHash('sha256').update(canonical).digest('hex');
48
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * W-080: the full `toTools()` pipeline (list -> adapt -> drift diff ->
3
+ * pin comparison / TOFU store legs) extracted from `client.ts` and
4
+ * parameterized by an {@link MCPClient}. Two callers:
5
+ *
6
+ * - the plain client's own `toTools()` (client = itself, the pre-W-080
7
+ * behaviour byte-for-byte), and
8
+ * - the managed wrapper's `toTools()` (client = THE WRAPPER), so every
9
+ * adapted `Tool.execute` closes over the wrapper and keeps working
10
+ * after the wrapper swaps its inner client on reconnect - no
11
+ * re-registration required.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ import type { Tool } from '@graphorin/core';
17
+ import { incrementCounter } from '@graphorin/tools/audit';
18
+ import { MCPToolPinningError } from '../errors/index.js';
19
+ import { adaptMCPTools } from './to-tools.js';
20
+ import type { CreateMCPClientOptions, MCPClient, MCPToToolsOptions } from './types.js';
21
+
22
+ /** Mutable cross-snapshot fingerprint cell (MC-6 drift tracking). */
23
+ export interface ToolFingerprintRef {
24
+ current: ReadonlyMap<string, string> | undefined;
25
+ }
26
+
27
+ /** Arguments for {@link runToTools}. @internal */
28
+ export interface RunToToolsArgs {
29
+ /** The client the adapted tools CLOSE OVER (wrapper for managed). */
30
+ readonly client: MCPClient;
31
+ /**
32
+ * Cross-snapshot fingerprint cell. Owned by the caller so drift
33
+ * tracking spans the caller's lifetime (for the managed wrapper that
34
+ * includes reconnects - a rug-pull across a reconnect still diffs).
35
+ */
36
+ readonly fingerprintRef: ToolFingerprintRef;
37
+ readonly logger?: CreateMCPClientOptions['logger'];
38
+ readonly toolsOpts?: MCPToToolsOptions;
39
+ }
40
+
41
+ /**
42
+ * Run the list -> adapt -> drift -> pin pipeline against `args.client`.
43
+ *
44
+ * @internal
45
+ */
46
+ export async function runToTools(args: RunToToolsArgs): Promise<ReadonlyArray<Tool>> {
47
+ const { client, fingerprintRef, logger, toolsOpts } = args;
48
+ const serverIdentity = client.serverIdentity;
49
+ const catalogue = await client.listTools();
50
+ const adapted = adaptMCPTools({
51
+ client,
52
+ serverIdentity,
53
+ catalogue,
54
+ ...(toolsOpts === undefined ? {} : { options: toolsOpts }),
55
+ ...(logger === undefined ? {} : { logger }),
56
+ });
57
+ // MC-6: cross-snapshot drift - a definition changing behind an
58
+ // already-seen name within this client's lifetime is audited.
59
+ if (fingerprintRef.current !== undefined) {
60
+ for (const [name, hash] of adapted.fingerprints) {
61
+ const previous = fingerprintRef.current.get(name);
62
+ if (previous !== undefined && previous !== hash) {
63
+ incrementCounter('mcp.tools.changed.total', {
64
+ server: serverIdentity.id,
65
+ tool: name,
66
+ });
67
+ logger?.('warn', 'mcp.tools.changed: definition drifted between snapshots', {
68
+ server: serverIdentity.id,
69
+ tool: name,
70
+ previous,
71
+ current: hash,
72
+ });
73
+ }
74
+ }
75
+ }
76
+ fingerprintRef.current = adapted.fingerprints;
77
+ // MC-6: operator pins from a previously approved snapshot - the
78
+ // rug-pull (approve-then-swap across restarts) posture. C6 extends it
79
+ // with durable trust-on-first-use via `pinStore`: the first snapshot
80
+ // is RECORDED, later snapshots are COMPARED, and a store-backed
81
+ // mismatch defaults to 'reject' (a persisted first approval is an
82
+ // explicit trust decision).
83
+ let pins = toolsOpts?.pinnedFingerprints;
84
+ let mismatchAction = toolsOpts?.onPinMismatch ?? 'warn';
85
+ // W-079: the added/removed lifecycle legs only apply to STORE pins -
86
+ // a store snapshot covers the full catalogue by construction, while
87
+ // explicit pinnedFingerprints may deliberately pin a subset.
88
+ let pinsFromStore = false;
89
+ const pinStore = toolsOpts?.pinStore;
90
+ if (pins === undefined && pinStore !== undefined) {
91
+ const stored = await pinStore.get(serverIdentity.id);
92
+ if (stored === undefined) {
93
+ const recorded: Record<string, string> = {};
94
+ for (const [name, hash] of adapted.fingerprints) recorded[name] = hash;
95
+ await pinStore.set(serverIdentity.id, recorded);
96
+ incrementCounter('mcp.tools.pins-recorded.total', { server: serverIdentity.id });
97
+ logger?.('info', 'mcp.tools.pins-recorded: first-use fingerprints stored', {
98
+ server: serverIdentity.id,
99
+ tools: Object.keys(recorded).length,
100
+ });
101
+ } else {
102
+ pins = stored;
103
+ pinsFromStore = true;
104
+ mismatchAction = toolsOpts?.onPinMismatch ?? 'reject';
105
+ }
106
+ }
107
+ if (pins !== undefined) {
108
+ for (const [name, pinned] of Object.entries(pins)) {
109
+ const current = adapted.fingerprints.get(name);
110
+ if (current !== undefined && current !== pinned) {
111
+ if (mismatchAction === 'reject') {
112
+ throw new MCPToolPinningError(
113
+ `MCP tool '${name}' no longer matches its pinned definition fingerprint - the server changed the definition behind an approved name.`,
114
+ { metadata: { server: serverIdentity.id, tool: name } },
115
+ );
116
+ }
117
+ incrementCounter('mcp.tools.pin-mismatch.total', {
118
+ server: serverIdentity.id,
119
+ tool: name,
120
+ });
121
+ logger?.('warn', 'mcp.tools.pin-mismatch: pinned fingerprint diverged', {
122
+ server: serverIdentity.id,
123
+ tool: name,
124
+ });
125
+ }
126
+ }
127
+ // W-079: the comparison loop above only sees names that were
128
+ // pinned. A server that passed its first-use recording can later
129
+ // ADD a poisoned tool (or rename one) - without this leg it would
130
+ // enter the catalogue with no counter and no rejection.
131
+ const pinnedNames = new Set(Object.keys(pins));
132
+ for (const name of pinsFromStore ? adapted.fingerprints.keys() : []) {
133
+ if (pinnedNames.has(name)) continue;
134
+ if (mismatchAction === 'reject') {
135
+ throw new MCPToolPinningError(
136
+ `MCP server added tool '${name}' after its catalogue was pinned - a post-approval addition is rejected until the operator re-pins (onPinMismatch: 'accept-and-update').`,
137
+ { metadata: { server: serverIdentity.id, tool: name } },
138
+ );
139
+ }
140
+ incrementCounter('mcp.tools.pin-added.total', {
141
+ server: serverIdentity.id,
142
+ tool: name,
143
+ });
144
+ logger?.('warn', 'mcp.tools.pin-added: tool added after the catalogue was pinned', {
145
+ server: serverIdentity.id,
146
+ tool: name,
147
+ });
148
+ }
149
+ // W-079: removals are not an injection by themselves, but they can
150
+ // hide a rename (remove + add) - keep them observable.
151
+ for (const name of pinsFromStore ? pinnedNames : []) {
152
+ if (adapted.fingerprints.has(name)) continue;
153
+ incrementCounter('mcp.tools.pin-removed.total', {
154
+ server: serverIdentity.id,
155
+ tool: name,
156
+ });
157
+ logger?.('info', 'mcp.tools.pin-removed: pinned tool disappeared from the catalogue', {
158
+ server: serverIdentity.id,
159
+ tool: name,
160
+ });
161
+ }
162
+ // W-079: the explicit operator path to accept a changed catalogue -
163
+ // overwrite the store with the CURRENT snapshot so subsequent
164
+ // toTools() calls are clean. Explicit pinnedFingerprints stay
165
+ // read-only (they are config, not a store).
166
+ if (mismatchAction === 'accept-and-update' && pinsFromStore && pinStore !== undefined) {
167
+ const refreshed: Record<string, string> = {};
168
+ for (const [name, hash] of adapted.fingerprints) refreshed[name] = hash;
169
+ await pinStore.set(serverIdentity.id, refreshed);
170
+ incrementCounter('mcp.tools.pins-updated.total', { server: serverIdentity.id });
171
+ logger?.('info', 'mcp.tools.pins-updated: operator accepted the current catalogue', {
172
+ server: serverIdentity.id,
173
+ tools: Object.keys(refreshed).length,
174
+ });
175
+ }
176
+ }
177
+ return adapted.tools;
178
+ }