@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.
- package/CHANGELOG.md +43 -0
- package/README.md +64 -3
- 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 +45 -70
- 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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- 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,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
|
+
}
|