@ikuma.cloud/pix-mcp 0.0.1

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/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # @ikuma.cloud/pix-mcp
2
+
3
+ A small Pi MCP adapter: discover tools, load their schemas on demand, then call
4
+ those tools natively. No scripting engine or model-provider-specific API is
5
+ required.
6
+
7
+ **Read [CONTRIBUTING.md](../../CONTRIBUTING.md) before making changes.**
8
+
9
+ ## Development
10
+
11
+ From the repository root:
12
+
13
+ ```sh
14
+ mise run mcp:dev
15
+ mise run test --project pix-mcp
16
+ mise run check
17
+ ```
18
+
19
+ The development task runs from `packages/mcp` and disables other extensions so
20
+ another MCP adapter cannot collide with the `mcp` tool or flags. Pass arguments
21
+ through the task, for example:
22
+
23
+ ```sh
24
+ mise run mcp:dev --mcp-config /absolute/path/to/mcp.json
25
+ ```
26
+
27
+ For an existing Pi installation, load this package with
28
+ `pi --no-extensions -e /absolute/path/to/pix/packages/mcp`.
29
+
30
+ ## Configuration and trust
31
+
32
+ By default, read only `.mcp.json` in Pi's working directory. There is no ancestor
33
+ search, global config merge, automatic import, or persistent metadata cache.
34
+ Relative `--mcp-config` paths resolve from that working directory; a stdio
35
+ server's `cwd` resolves from the config directory and defaults to that directory.
36
+
37
+ A bare `.mcp.json` is not protected by Pi's project-trust mechanism. This adapter
38
+ asks before using the default file. In a headless session, it remains disabled
39
+ unless explicitly trusted. Either of these authorizes the file for one session:
40
+
41
+ - `--mcp-config <path>`: select **and trust** a file.
42
+ - `--mcp-trust-config`: trust the default `.mcp.json`.
43
+
44
+ Review the file first: trusting it can launch arbitrary local programs and
45
+ contact remote services. Configuration trust is not an OS sandbox.
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "local": {
51
+ "command": "node",
52
+ "args": ["server.js"],
53
+ "cwd": "./service",
54
+ "env": { "SERVICE_TOKEN": "${SERVICE_TOKEN}" },
55
+ "description": "Local project documentation tools"
56
+ },
57
+ "remote": {
58
+ "type": "http",
59
+ "url": "https://mcp.example.com/mcp",
60
+ "headers": { "Authorization": "Bearer ${SERVICE_TOKEN}" },
61
+ "description": "Issue tracking tools",
62
+ "timeoutMs": 30000,
63
+ "approve": true
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ Supported fields:
70
+
71
+ | Field | Behavior |
72
+ | --- | --- |
73
+ | `type` | `stdio` or `http`; inferred from `command` or `url` when omitted |
74
+ | `command`, `args`, `env`, `cwd` | Stdio only; executable and argument array, not a shell command |
75
+ | `url`, `headers` | Streamable HTTP only; no SSE fallback or redirect following |
76
+ | `description` | Optional short capability summary for discovery |
77
+ | `timeoutMs` | Request/discovery timeout, default 30000; allowed range 100–120000 |
78
+ | `approve` | Require confirmation for every invocation, default `true` |
79
+ | `disabled` | Skip this server when `true` |
80
+
81
+ Only `env` and `headers` values expand `${VARIABLE}` references. Missing
82
+ variables fail without echoing their values. Stdio inherits the SDK's minimal
83
+ platform environment plus explicit `env`, not the entire Pi environment.
84
+ Credentials in HTTP URLs are rejected; use headers instead. Unknown fields fail
85
+ rather than silently accepting unsupported configuration.
86
+
87
+ Tool invocation requires confirmation independently of config trust. In headless
88
+ mode, calls fail closed unless the reviewed configuration explicitly sets
89
+ `"approve": false` for that server. Native calls still pass through Pi's normal
90
+ tool hooks. Server annotations never grant permission. Child stderr and raw SDK
91
+ errors are not printed because they can contain credentials; debug a failing
92
+ server separately in a trusted environment.
93
+
94
+ ## Discovery and execution
95
+
96
+ At session startup, the adapter connects to trusted servers and fetches their
97
+ paginated tool catalogs. One failure does not hide tools from other servers.
98
+ Full schemas stay out of model context until selected. **Schema exposure is lazy;
99
+ initial connections and metadata discovery are not.**
100
+
101
+ The agent uses the `mcp` tool:
102
+
103
+ ```js
104
+ mcp({ action: "list", server: "local", limit: 20, offset: 0 })
105
+ mcp({ action: "search", query: "documentation", limit: 5 })
106
+ mcp({ action: "load", names: ["exact_name_from_discovery"] })
107
+ ```
108
+
109
+ `list` returns paginated summaries without loading tools. `search` uses local,
110
+ deterministic name/description matching and activates up to 10 matches. `load`
111
+ activates up to 10 exact names. A result's `active` field reports whether Pi
112
+ allowed activation. No separate describe call is required: the full tool schema
113
+ is available on the next model request. Loading never executes the tool.
114
+ Selection is tied to the current schema fingerprint: use `search`/`load`, not
115
+ Pi's generic tool-name toggles, to enable a native MCP tool.
116
+
117
+ Native names include a readable server/tool prefix and a deterministic hash to
118
+ avoid normalization collisions. Independent, preapproved native calls can run
119
+ concurrently. Calls requiring confirmation run sequentially to avoid overlapping
120
+ approval dialogs. Discovered tools stay active until session shutdown or a server
121
+ catalog change.
122
+ Changed and removed definitions are withdrawn; changed tools require loading
123
+ again. Unsupported input schemas or metadata, name collisions, and task-only
124
+ tools are counted as `unsupportedTools` in discovery results. An unsupported
125
+ output schema fails that server's discovery. Losing an established HTTP
126
+ notification stream also withdraws tools rather than silently keeping a stale
127
+ catalog; servers that decline the optional stream with HTTP 405 remain usable.
128
+ Reload Pi to reconnect a failed server or reread configuration.
129
+
130
+ Pi handles provider compatibility. Some providers support transcript-anchored
131
+ schema additions; others rebuild the tool set and may invalidate prompt caches.
132
+ For very small catalogs, eager loading would avoid a discovery round trip, but
133
+ v1 intentionally offers only the deferred mode.
134
+
135
+ ## Output and limits
136
+
137
+ Text, supported images, and structured content are retained. Long text gets a
138
+ 24 KiB / 1000-line preview; structured details are bounded to 16 KiB. At most four
139
+ PNG/JPEG/GIF/WebP images of up to 4 MiB each are shown. Unsupported or oversized
140
+ content is explicitly omitted from the preview, not silently discarded. Pi's
141
+ error-result path is text-only, so images in MCP errors are preserved in a
142
+ full-result artifact rather than displayed inline.
143
+
144
+ When necessary, the full MCP result is written to `pix-mcp-*/result.json` under
145
+ the system temp directory (directory mode 0700, file mode 0600). Pi can inspect it
146
+ with `read`. These artifacts may contain sensitive data and are **not deleted at
147
+ session shutdown**; remove them when no longer needed. Output limits are not a
148
+ complete memory or security sandbox.
149
+
150
+ Configuration is limited to 256 KiB and 32 servers. Startup connects at most four
151
+ servers concurrently. Each catalog is limited to 1000 tools, 100 pagination
152
+ cursors, and 2 MiB of metadata; individual input/output schemas are limited to
153
+ 64 KiB. Tool names must use 1–128 ASCII letters, digits, underscores, hyphens, or
154
+ periods; descriptions are limited to 16 KiB. Stdio messages are limited to 16 MiB.
155
+ Schemas are syntax-checked before compilation. Only the default MCP dialect,
156
+ JSON Schema 2020-12, is supported; explicit legacy dialects (including embedded
157
+ resources) are rejected rather than interpreted with incorrect reference
158
+ semantics. External schema references are unsupported, but literal `$ref` fields
159
+ inside instance data are allowed. Requests and the entire initialization
160
+ handshake use fixed deadlines, not progress-extended timeouts.
161
+
162
+ The adapter never automatically retries `tools/call`: a timeout or lost response
163
+ may occur after a mutating operation took effect. Successful output is validated
164
+ against the schema captured when the call began; MCP error results are exempt
165
+ from that success schema. Cancellation is best-effort at the server and does not
166
+ roll back effects.
167
+
168
+ ## Deliberately out of scope
169
+
170
+ OAuth, legacy SSE transport, MCP prompts/resources APIs, sampling, elicitation,
171
+ MCP apps, task execution, semantic search, scripting, config UI, and persistent
172
+ catalog caching. Use a fuller adapter when those capabilities are required.
173
+
174
+ Tests use local stdio/HTTP fixture servers and isolated Pi configuration, without
175
+ model requests or personal credentials.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ikuma.cloud/pix-mcp",
3
+ "version": "0.0.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "description": "MCP discovery and lazily activated native tools for Pi Coding Agent",
9
+ "keywords": [
10
+ "pi-package"
11
+ ],
12
+ "files": [
13
+ "src",
14
+ "!src/**/*.test.ts",
15
+ "!src/fixtures"
16
+ ],
17
+ "pi": {
18
+ "extensions": [
19
+ "./src/index.ts"
20
+ ]
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "1.30.0",
24
+ "ajv": "8.20.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-ai": "*",
28
+ "@earendil-works/pi-coding-agent": "*",
29
+ "typebox": "*"
30
+ },
31
+ "devDependencies": {
32
+ "@earendil-works/pi-ai": "0.87.1",
33
+ "@earendil-works/pi-coding-agent": "0.87.1",
34
+ "typebox": "1.3.27"
35
+ }
36
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
3
+
4
+ export interface Entry {
5
+ name: string;
6
+ server: string;
7
+ tool: Tool;
8
+ fingerprint: string;
9
+ }
10
+
11
+ export function toolName(server: string, original: string): string {
12
+ const suffix = createHash("sha256")
13
+ .update(JSON.stringify([server, original]))
14
+ .digest("hex")
15
+ .slice(0, 12);
16
+ const readable = `${server}_${original}`
17
+ .replace(/[^A-Za-z0-9_]/g, "_")
18
+ .slice(0, 47);
19
+ return `mcp_${readable}_${suffix}`;
20
+ }
21
+
22
+ export function entry(server: string, tool: Tool): Entry {
23
+ return {
24
+ name: toolName(server, tool.name),
25
+ server,
26
+ tool,
27
+ fingerprint: createHash("sha256")
28
+ .update(JSON.stringify(tool))
29
+ .digest("hex"),
30
+ };
31
+ }
32
+
33
+ export function search(
34
+ entries: Entry[],
35
+ query: string,
36
+ server?: string,
37
+ ): Entry[] {
38
+ const words = [
39
+ ...new Set(
40
+ query
41
+ .toLowerCase()
42
+ .split(/[^\p{L}\p{N}_-]+/u)
43
+ .filter(Boolean),
44
+ ),
45
+ ];
46
+ if (!words.length) return [];
47
+ return entries
48
+ .filter((item) => !server || item.server === server)
49
+ .map((item) => {
50
+ const name =
51
+ `${item.server} ${item.tool.name} ${item.name}`.toLowerCase();
52
+ const description = (item.tool.description ?? "").toLowerCase();
53
+ const score = words.reduce(
54
+ (sum, word) =>
55
+ sum +
56
+ (name.includes(word) ? 4 : 0) +
57
+ (description.includes(word) ? 1 : 0),
58
+ 0,
59
+ );
60
+ return { item, score };
61
+ })
62
+ .filter((match) => match.score > 0)
63
+ .sort(
64
+ (a, b) =>
65
+ b.score - a.score || a.item.name.localeCompare(b.item.name, "en"),
66
+ )
67
+ .map((match) => match.item);
68
+ }
69
+
70
+ export function summary(item: Entry) {
71
+ return {
72
+ name: item.name,
73
+ server: item.server,
74
+ tool: item.tool.name,
75
+ description: compact(item.tool.description ?? "", 200),
76
+ };
77
+ }
78
+
79
+ export function compact(value: string, limit: number): string {
80
+ const text = value
81
+ .replace(/\p{Cc}/gu, " ")
82
+ .replace(/\s+/g, " ")
83
+ .trim();
84
+ return text.length <= limit ? text : `${text.slice(0, limit)}…`;
85
+ }
package/src/client.ts ADDED
@@ -0,0 +1,343 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5
+ import { mediaTypeEssence } from "@modelcontextprotocol/sdk/shared/mediaType.js";
6
+ import {
7
+ CallToolResultSchema,
8
+ ListToolsResultSchema,
9
+ ToolSchema,
10
+ ToolListChangedNotificationSchema,
11
+ type Tool,
12
+ } from "@modelcontextprotocol/sdk/types.js";
13
+ import type { ServerConfig } from "./config.ts";
14
+ import { compileSchema, schemaValidator } from "./schema.ts";
15
+
16
+ // SDK 1.30's property decoder incorrectly requires every property schema to be
17
+ // an object. JSON Schema 2020-12 also permits true/false. Preserve those values
18
+ // through the wire decoder; our meta-schema checks validate their actual syntax.
19
+ const catalogResultSchema = ListToolsResultSchema.extend({
20
+ tools: ToolSchema.extend({
21
+ inputSchema: ToolSchema.shape.inputSchema.omit({ properties: true }),
22
+ outputSchema: ToolSchema.shape.outputSchema
23
+ .unwrap()
24
+ .omit({ properties: true })
25
+ .optional(),
26
+ }).array(),
27
+ });
28
+
29
+ export class Connection {
30
+ readonly client: Client;
31
+ readonly config: ServerConfig;
32
+ status = "Not connected";
33
+ instructions = "";
34
+ #transport: StdioClientTransport | StreamableHTTPClientTransport;
35
+ #lifetime = new AbortController();
36
+ #initializing: Promise<void> | undefined;
37
+ #refreshing: Promise<void> | undefined;
38
+ #closing: Promise<void> | undefined;
39
+ #dirty = false;
40
+ #stopped = false;
41
+ #changed: (tools: Tool[]) => void;
42
+ #outputValidators = new Map<string, ReturnType<typeof compileSchema>>();
43
+
44
+ constructor(config: ServerConfig, changed: (tools: Tool[]) => void) {
45
+ this.config = config;
46
+ this.#changed = changed;
47
+ this.client = new Client(
48
+ { name: "pix-mcp", version: "0.0.0" },
49
+ { capabilities: {}, jsonSchemaValidator: schemaValidator },
50
+ );
51
+ this.#transport =
52
+ config.type === "stdio"
53
+ ? new StdioClientTransport({
54
+ command: config.command,
55
+ args: config.args,
56
+ cwd: config.cwd,
57
+ env: config.env,
58
+ stderr: "ignore",
59
+ maxBufferSize: 16 * 1024 * 1024,
60
+ })
61
+ : new StreamableHTTPClientTransport(new URL(config.url), {
62
+ requestInit: { headers: config.headers, redirect: "error" },
63
+ reconnectionOptions: {
64
+ maxRetries: 0,
65
+ initialReconnectionDelay: 1000,
66
+ maxReconnectionDelay: 1000,
67
+ reconnectionDelayGrowFactor: 1,
68
+ },
69
+ fetch: (url, init) => this.#fetch(url, init),
70
+ });
71
+ this.client.onerror = () => {
72
+ /* Request failures are reported without leaking SDK error payloads. */
73
+ };
74
+ this.client.onclose = () => this.#disconnect();
75
+ this.client.setNotificationHandler(
76
+ ToolListChangedNotificationSchema,
77
+ async () => {
78
+ if (this.#stopped) return;
79
+ try {
80
+ await this.refresh();
81
+ } catch {
82
+ /* refresh already clears the stale catalog */
83
+ }
84
+ },
85
+ );
86
+ }
87
+
88
+ #disconnect() {
89
+ if (this.#stopped || this.#lifetime.signal.aborted) return;
90
+ this.status = "Disconnected; reload Pi to reconnect";
91
+ this.#lifetime.abort(
92
+ new Error("MCP connection is unavailable; reload Pi to reconnect."),
93
+ );
94
+ this.#outputValidators.clear();
95
+ this.#changed([]);
96
+ void this.client.close().catch(() => {});
97
+ }
98
+
99
+ async #fetch(
100
+ url: Parameters<typeof fetch>[0],
101
+ init: Parameters<typeof fetch>[1],
102
+ ) {
103
+ // Teardown must still send DELETE after the connection lifetime is aborted.
104
+ if (init?.method === "DELETE")
105
+ return globalThis.fetch(url, {
106
+ ...init,
107
+ redirect: "error",
108
+ signal: AbortSignal.timeout(2000),
109
+ });
110
+ const signals = [
111
+ this.#lifetime.signal,
112
+ ...(init?.signal ? [init.signal] : []),
113
+ ];
114
+ if (init?.method !== "GET") {
115
+ return globalThis.fetch(url, {
116
+ ...init,
117
+ redirect: "error",
118
+ signal: AbortSignal.any([
119
+ ...signals,
120
+ AbortSignal.timeout(this.config.timeoutMs),
121
+ ]),
122
+ });
123
+ }
124
+ // GET is optional (405 is valid), but an established notification stream
125
+ // cannot disappear silently: with retries disabled its catalog is stale.
126
+ const headersDeadline = new AbortController();
127
+ const timer = setTimeout(
128
+ () => headersDeadline.abort(),
129
+ this.config.timeoutMs,
130
+ );
131
+ try {
132
+ const response = await globalThis.fetch(url, {
133
+ ...init,
134
+ redirect: "error",
135
+ signal: AbortSignal.any([...signals, headersDeadline.signal]),
136
+ });
137
+ if (response.status === 405) return response;
138
+ if (
139
+ !response.ok ||
140
+ !response.body ||
141
+ mediaTypeEssence(response.headers.get("content-type")) !==
142
+ "text/event-stream"
143
+ ) {
144
+ await response.body?.cancel();
145
+ throw new Error("MCP notification stream unavailable.");
146
+ }
147
+ const stream = new TransformStream<Uint8Array, Uint8Array>();
148
+ void response.body.pipeTo(stream.writable).then(
149
+ () => this.#disconnect(),
150
+ () => this.#disconnect(),
151
+ );
152
+ return new Response(stream.readable, {
153
+ status: response.status,
154
+ statusText: response.statusText,
155
+ headers: response.headers,
156
+ });
157
+ } catch (error) {
158
+ this.#disconnect();
159
+ throw error;
160
+ } finally {
161
+ clearTimeout(timer);
162
+ }
163
+ }
164
+
165
+ start(): Promise<void> {
166
+ this.#initializing ??= this.#start();
167
+ return this.#initializing;
168
+ }
169
+
170
+ async #start(): Promise<void> {
171
+ try {
172
+ this.#lifetime.signal.throwIfAborted();
173
+ this.status = "Connecting";
174
+ // SDK transport getters include undefined while its optional Transport field
175
+ // does not under exactOptionalPropertyTypes; the runtime contract is identical.
176
+ // SDK request timeouts do not cover the awaited notifications/initialized
177
+ // send. Bound the whole handshake and close the transport to release it.
178
+ const handshakeTimer = setTimeout(
179
+ () => this.#disconnect(),
180
+ this.config.timeoutMs,
181
+ );
182
+ try {
183
+ await this.client.connect(this.#transport as Transport, {
184
+ signal: this.#lifetime.signal,
185
+ timeout: this.config.timeoutMs,
186
+ });
187
+ } finally {
188
+ clearTimeout(handshakeTimer);
189
+ }
190
+ this.#lifetime.signal.throwIfAborted();
191
+ this.instructions = this.client.getInstructions() ?? "";
192
+ await this.refresh();
193
+ } catch {
194
+ if (!this.#stopped) {
195
+ this.status =
196
+ "Connection or discovery failed; check configuration/authentication and reload Pi";
197
+ this.#changed([]);
198
+ }
199
+ await this.client.close().catch(() => {});
200
+ throw new Error(
201
+ `MCP server ${this.config.name}: connection or discovery failed.`,
202
+ );
203
+ }
204
+ }
205
+
206
+ refresh(): Promise<void> {
207
+ this.#dirty = true;
208
+ this.#refreshing ??= this.#refresh().finally(() => {
209
+ this.#refreshing = undefined;
210
+ });
211
+ return this.#refreshing;
212
+ }
213
+
214
+ async #refresh(): Promise<void> {
215
+ try {
216
+ while (this.#dirty && !this.#stopped) {
217
+ this.#dirty = false;
218
+ const tools: Tool[] = [];
219
+ const cursors = new Set<string>();
220
+ const names = new Set<string>();
221
+ let cursor: string | undefined;
222
+ let bytes = 0;
223
+ const signal = AbortSignal.any([
224
+ this.#lifetime.signal,
225
+ AbortSignal.timeout(this.config.timeoutMs),
226
+ ]);
227
+ if (this.client.getServerCapabilities()?.tools) {
228
+ do {
229
+ const page = await this.client.request(
230
+ {
231
+ method: "tools/list",
232
+ params: cursor === undefined ? {} : { cursor },
233
+ },
234
+ catalogResultSchema,
235
+ {
236
+ signal,
237
+ timeout: this.config.timeoutMs,
238
+ },
239
+ );
240
+ bytes += Buffer.byteLength(JSON.stringify(page));
241
+ if (
242
+ bytes > 2 * 1024 * 1024 ||
243
+ tools.length + page.tools.length > 1000
244
+ )
245
+ throw new Error("Catalog limit");
246
+ for (const tool of page.tools) {
247
+ if (names.has(tool.name)) throw new Error("Duplicate tool name");
248
+ names.add(tool.name);
249
+ tools.push(tool);
250
+ }
251
+ cursor = page.nextCursor;
252
+ if (cursor !== undefined) {
253
+ if (cursors.has(cursor) || cursors.size >= 100)
254
+ throw new Error("Invalid pagination");
255
+ cursors.add(cursor);
256
+ }
257
+ } while (cursor !== undefined);
258
+ }
259
+ // Keep a complete, atomic validator snapshot rather than the SDK's
260
+ // per-page mutable cache, including when a catalog changes during a call.
261
+ const validators = new Map<string, ReturnType<typeof compileSchema>>();
262
+ for (const tool of tools) {
263
+ if (tool.outputSchema)
264
+ validators.set(tool.name, compileSchema(tool.outputSchema));
265
+ }
266
+ signal.throwIfAborted();
267
+ if (!this.#stopped) {
268
+ this.#outputValidators = validators;
269
+ this.status = "Connected";
270
+ this.#changed(tools);
271
+ }
272
+ }
273
+ } catch {
274
+ if (!this.#stopped) {
275
+ this.status = "Discovery failed; reload Pi to retry";
276
+ this.#changed([]);
277
+ }
278
+ throw new Error(`MCP server ${this.config.name}: discovery failed.`);
279
+ }
280
+ }
281
+
282
+ async call(
283
+ name: string,
284
+ args: Record<string, unknown>,
285
+ signal?: AbortSignal,
286
+ ) {
287
+ await this.start();
288
+ const combined = signal
289
+ ? AbortSignal.any([signal, this.#lifetime.signal])
290
+ : this.#lifetime.signal;
291
+ combined.throwIfAborted();
292
+ const outputValidator = this.#outputValidators.get(name);
293
+ try {
294
+ // SDK callTool consults a mutable per-page validator after the response and
295
+ // validates error payloads against success schemas. Use one typed request,
296
+ // then our captured validator; task-only tools are excluded during discovery.
297
+ // Never retry: a lost response does not prove the operation did not run.
298
+ const result = await this.client.request(
299
+ { method: "tools/call", params: { name, arguments: args } },
300
+ CallToolResultSchema,
301
+ {
302
+ signal: combined,
303
+ timeout: this.config.timeoutMs,
304
+ resetTimeoutOnProgress: false,
305
+ },
306
+ );
307
+ const parsed = CallToolResultSchema.parse(result);
308
+ if (
309
+ !parsed.isError &&
310
+ outputValidator &&
311
+ !outputValidator.Check(parsed.structuredContent)
312
+ ) {
313
+ throw new Error("MCP output does not match the advertised schema.");
314
+ }
315
+ return parsed;
316
+ } catch {
317
+ combined.throwIfAborted();
318
+ throw new Error(
319
+ `MCP server ${this.config.name}: call failed or timed out. It may already have taken effect; do not retry blindly.`,
320
+ );
321
+ }
322
+ }
323
+
324
+ close(): Promise<void> {
325
+ this.#closing ??= this.#close();
326
+ return this.#closing;
327
+ }
328
+
329
+ async #close(): Promise<void> {
330
+ this.#stopped = true;
331
+ this.#lifetime.abort();
332
+ this.status = "Closed";
333
+ if (
334
+ this.#transport instanceof StreamableHTTPClientTransport &&
335
+ this.#transport.sessionId
336
+ ) {
337
+ await this.#transport.terminateSession().catch(() => {});
338
+ }
339
+ await this.client.close().catch(() => {});
340
+ await this.#initializing?.catch(() => {});
341
+ await this.#refreshing?.catch(() => {});
342
+ }
343
+ }