pi-mcp-adapter 3.1.0 → 3.2.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 +25 -0
- package/OAUTH.md +7 -7
- package/README.md +52 -4
- package/dist/mcp-callback-server.js +1 -2
- package/dist/mcp-callback-server.js.map +1 -1
- package/dist/mcp-oauth-provider.d.ts +2 -1
- package/dist/mcp-oauth-provider.js +5 -2
- package/dist/mcp-oauth-provider.js.map +1 -1
- package/dist/state.d.ts +2 -1
- package/host-managed.ts +547 -0
- package/index.ts +19 -22
- package/init.ts +15 -9
- package/mcp-callback-server.ts +1 -1
- package/mcp-code.ts +2 -2
- package/mcp-oauth-provider.ts +5 -2
- package/mcp-script-wasm.ts +18 -1
- package/mcp-script-worker.mjs +9 -2
- package/namespace-tools.ts +22 -2
- package/package.json +8 -1
- package/state.ts +2 -1
- package/tool-approval.ts +11 -6
- package/tool-parameters.ts +11 -0
package/host-managed.ts
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// host-managed.ts - MCP tools for an embedding application that owns the
|
|
2
|
+
// transport, credentials, approval, dispatch record, and instance lifetime.
|
|
3
|
+
//
|
|
4
|
+
// Unlike the ordinary extension, this profile reads no config files, never
|
|
5
|
+
// touches OAuth or the keyring, registers no commands, UI, or proxy tools, and
|
|
6
|
+
// never reconnects, recovers a session, or resends a tools/call request.
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import type { AgentToolResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
Client,
|
|
11
|
+
ProtocolError,
|
|
12
|
+
SdkError,
|
|
13
|
+
SdkErrorCode,
|
|
14
|
+
type CallToolResult,
|
|
15
|
+
type JsonSchemaType,
|
|
16
|
+
type JsonSchemaValidator,
|
|
17
|
+
type ReadResourceResult,
|
|
18
|
+
type Tool,
|
|
19
|
+
type Transport,
|
|
20
|
+
} from "@modelcontextprotocol/client";
|
|
21
|
+
import { createJsonSchemaValidator } from "./json-schema-validator.ts";
|
|
22
|
+
import { guardMcpOutput, guardedMcpDetails, resolveMcpOutputGuardOptions } from "./mcp-output-guard.ts";
|
|
23
|
+
import { combineAbortSignals } from "./runtime-owner.ts";
|
|
24
|
+
import { abortable } from "./abort.ts";
|
|
25
|
+
import { requestBrokerApproval } from "./tool-approval.ts";
|
|
26
|
+
import { toToolParameters } from "./tool-parameters.ts";
|
|
27
|
+
import { cleanupMaterializedBinaryResources, resolveMcpResultContent } from "./tool-registrar.ts";
|
|
28
|
+
import { formatToolName } from "./types.ts";
|
|
29
|
+
import { normalizeDirectToolInputSchema, normalizeToolArguments, stableStringify, truncateAtWord, withToolCallIdMeta } from "./utils.ts";
|
|
30
|
+
|
|
31
|
+
const CLOSE_TIMEOUT_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
export interface HostManagedMcpServer {
|
|
34
|
+
/**
|
|
35
|
+
* Called once by ready(). Return an unconnected transport. It must not carry
|
|
36
|
+
* an auth provider or any behavior that replays a request; the adapter sends
|
|
37
|
+
* each tools/call at most once on its own layer only.
|
|
38
|
+
*/
|
|
39
|
+
createTransport(context: { server: string; signal: AbortSignal }): Transport | Promise<Transport>;
|
|
40
|
+
/** MCP tool names to expose. Omit to expose every tool the server lists. */
|
|
41
|
+
tools?: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HostManagedMcpToolCall {
|
|
45
|
+
readonly server: string;
|
|
46
|
+
/** MCP tool name. */
|
|
47
|
+
readonly tool: string;
|
|
48
|
+
/** Pi tool name the model called. */
|
|
49
|
+
readonly toolName: string;
|
|
50
|
+
readonly toolCallId: string;
|
|
51
|
+
/** Opaque identity of the connection this call is bound to. */
|
|
52
|
+
readonly connectionId: string;
|
|
53
|
+
/** Deep-frozen arguments; exactly what dispatch() sends. */
|
|
54
|
+
readonly arguments: Readonly<Record<string, unknown>>;
|
|
55
|
+
/** Deep-frozen input schema that was registered with Pi. */
|
|
56
|
+
readonly inputSchema: Tool["inputSchema"];
|
|
57
|
+
/** Aborted by the Pi call or by close(). */
|
|
58
|
+
readonly signal: AbortSignal;
|
|
59
|
+
/**
|
|
60
|
+
* Send the tools/call request once and return the validated raw result.
|
|
61
|
+
* Rejects with HostManagedMcpError. Calling it twice throws.
|
|
62
|
+
*/
|
|
63
|
+
dispatch(): Promise<CallToolResult>;
|
|
64
|
+
/**
|
|
65
|
+
* Approve and prepare a read of a `resource_link` URI from this call's
|
|
66
|
+
* dispatched result, on the same connection. Throws unless dispatch()
|
|
67
|
+
* resolved with that link. Rejects with HostManagedMcpError (`not_sent`)
|
|
68
|
+
* when the broker does not allow the read or the call has settled.
|
|
69
|
+
*/
|
|
70
|
+
linkedResource(uri: string): Promise<HostManagedMcpResourceRead>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface HostManagedMcpResourceRead {
|
|
74
|
+
readonly uri: string;
|
|
75
|
+
readonly readId: string;
|
|
76
|
+
readonly connectionId: string;
|
|
77
|
+
readonly parentToolCallId: string;
|
|
78
|
+
/**
|
|
79
|
+
* Send the resources/read request once. Rejects with HostManagedMcpError,
|
|
80
|
+
* including `not_sent` after the parent tool call settles. Calling it twice throws.
|
|
81
|
+
*/
|
|
82
|
+
dispatch(): Promise<ReadResourceResult>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface HostManagedMcpAdapterOptions {
|
|
86
|
+
servers: Record<string, HostManagedMcpServer>;
|
|
87
|
+
/**
|
|
88
|
+
* Runs after approval for every tool call. Record the call, invoke
|
|
89
|
+
* `call.dispatch()` at most once, and return the result to present to the
|
|
90
|
+
* model through the normal output guard.
|
|
91
|
+
*/
|
|
92
|
+
onToolCall(call: HostManagedMcpToolCall): Promise<CallToolResult>;
|
|
93
|
+
/** Per-request timeout in milliseconds. The MCP SDK default applies when omitted. */
|
|
94
|
+
requestTimeoutMs?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface HostManagedMcpAdapter {
|
|
98
|
+
/** Connect every server and freeze the tool catalog. Memoized; rejects and closes the adapter on any failure. */
|
|
99
|
+
ready(): Promise<void>;
|
|
100
|
+
/** Refuse new calls, abort in-flight calls, and close every transport, all within 5 s. Idempotent. */
|
|
101
|
+
close(): Promise<void>;
|
|
102
|
+
/** Pi extension factory. Throws before ready() resolves or after close(). */
|
|
103
|
+
extensionFactory(pi: ExtensionAPI): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* - `not_sent`: the request never left the adapter; the tool did not run.
|
|
108
|
+
* - `may_have_run`: the request was sent and its outcome is unknown.
|
|
109
|
+
* - `server_error`: the server answered with a JSON-RPC error.
|
|
110
|
+
* - `invalid_result`: the server answered, but the result failed validation.
|
|
111
|
+
*/
|
|
112
|
+
export type HostManagedMcpDelivery = "not_sent" | "may_have_run" | "server_error" | "invalid_result";
|
|
113
|
+
|
|
114
|
+
const DELIVERY_MESSAGES: Record<HostManagedMcpDelivery, string> = {
|
|
115
|
+
not_sent: "the request was not sent",
|
|
116
|
+
may_have_run: "the request failed after it was sent; the tool may have run",
|
|
117
|
+
server_error: "the server returned an error response",
|
|
118
|
+
invalid_result: "the server returned an invalid result; the tool may have run",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Fixed-message dispatch failure. The raw error is kept only as `cause`. */
|
|
122
|
+
export class HostManagedMcpError extends Error {
|
|
123
|
+
readonly delivery: HostManagedMcpDelivery;
|
|
124
|
+
readonly server: string;
|
|
125
|
+
readonly protocolCode?: number;
|
|
126
|
+
|
|
127
|
+
constructor(delivery: HostManagedMcpDelivery, server: string, options: { cause?: unknown; protocolCode?: number } = {}) {
|
|
128
|
+
super(`MCP server "${server}": ${DELIVERY_MESSAGES[delivery]}`, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
129
|
+
this.name = "HostManagedMcpError";
|
|
130
|
+
this.delivery = delivery;
|
|
131
|
+
this.server = server;
|
|
132
|
+
if (options.protocolCode !== undefined) this.protocolCode = options.protocolCode;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface Connection {
|
|
137
|
+
readonly id: string;
|
|
138
|
+
readonly client: Client;
|
|
139
|
+
readonly transport: Transport;
|
|
140
|
+
open: boolean;
|
|
141
|
+
selected: readonly CatalogTool[] | undefined;
|
|
142
|
+
/** Never cleared: a retired tool stays retired for the adapter's lifetime. */
|
|
143
|
+
readonly retired: Set<string>;
|
|
144
|
+
refresh: Promise<void> | undefined;
|
|
145
|
+
refreshRequested: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface CatalogTool {
|
|
149
|
+
readonly server: string;
|
|
150
|
+
readonly toolName: string;
|
|
151
|
+
readonly definition: Tool;
|
|
152
|
+
readonly fingerprint: string;
|
|
153
|
+
readonly sdkDefinition: Tool;
|
|
154
|
+
readonly validateOutput: JsonSchemaValidator<unknown> | undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type ToolResult = AgentToolResult<Record<string, unknown>>;
|
|
158
|
+
|
|
159
|
+
function deepFreeze<T>(value: T): T {
|
|
160
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
161
|
+
Object.freeze(value);
|
|
162
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function classifyDispatchError(server: string, cause: unknown): HostManagedMcpError {
|
|
168
|
+
if (cause instanceof HostManagedMcpError) return cause;
|
|
169
|
+
// Output-schema validation runs in this module, so a ProtocolError here is a
|
|
170
|
+
// JSON-RPC error response from the server.
|
|
171
|
+
if (cause instanceof ProtocolError) return new HostManagedMcpError("server_error", server, { cause, protocolCode: cause.code });
|
|
172
|
+
if (cause instanceof SdkError && cause.code === SdkErrorCode.InvalidResult) {
|
|
173
|
+
return new HostManagedMcpError("invalid_result", server, { cause });
|
|
174
|
+
}
|
|
175
|
+
if (cause instanceof SdkError && cause.code === SdkErrorCode.NotConnected) {
|
|
176
|
+
return new HostManagedMcpError("not_sent", server, { cause });
|
|
177
|
+
}
|
|
178
|
+
return new HostManagedMcpError("may_have_run", server, { cause });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function textResult(text: string, details: Record<string, unknown>): ToolResult {
|
|
182
|
+
return { content: [{ type: "text", text }], details };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isAllowed(decision: string): boolean {
|
|
186
|
+
return decision === "allow_once" || decision === "allow_for_session";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function dispatchFailureResult(tool: CatalogTool, error: HostManagedMcpError, catalogChanged = false): ToolResult {
|
|
190
|
+
const name = tool.definition.name;
|
|
191
|
+
if (catalogChanged && error.delivery === "not_sent") {
|
|
192
|
+
return textResult(
|
|
193
|
+
`MCP tool "${name}" on server "${tool.server}" was not run: its definition changed after it was loaded, so the call was not sent. Start a new session to use the new definition.`,
|
|
194
|
+
{ error: "catalog_changed", server: tool.server, tool: name },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const details: Record<string, unknown> = { error: error.delivery, server: tool.server, tool: name };
|
|
198
|
+
const unknownOutcome = "Do not retry automatically; check its effects first.";
|
|
199
|
+
switch (error.delivery) {
|
|
200
|
+
case "not_sent":
|
|
201
|
+
return textResult(`MCP tool "${name}" on server "${tool.server}" was not run: the request was not sent because the call was cancelled or the MCP connection is closed.`, details);
|
|
202
|
+
case "may_have_run":
|
|
203
|
+
return textResult(`MCP tool "${name}" on server "${tool.server}" failed after the request was sent, so it may have run. ${unknownOutcome}`, details);
|
|
204
|
+
case "server_error": {
|
|
205
|
+
const code = error.protocolCode !== undefined ? ` (JSON-RPC code ${error.protocolCode})` : "";
|
|
206
|
+
if (error.protocolCode !== undefined) details.protocolCode = error.protocolCode;
|
|
207
|
+
return textResult(`MCP server "${tool.server}" returned an error for tool "${name}"${code}.`, details);
|
|
208
|
+
}
|
|
209
|
+
case "invalid_result":
|
|
210
|
+
return textResult(`MCP tool "${name}" on server "${tool.server}" returned an invalid result, so it may have run. ${unknownOutcome}`, details);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function createHostManagedMcpAdapter(options: HostManagedMcpAdapterOptions): HostManagedMcpAdapter {
|
|
215
|
+
const servers = Object.entries(options.servers);
|
|
216
|
+
const timeout = options.requestTimeoutMs !== undefined && options.requestTimeoutMs > 0 ? options.requestTimeoutMs : undefined;
|
|
217
|
+
const guardOptions = resolveMcpOutputGuardOptions(undefined);
|
|
218
|
+
const lifetime = new AbortController();
|
|
219
|
+
const connections = new Map<string, Connection>();
|
|
220
|
+
const inFlight = new Set<Promise<ToolResult>>();
|
|
221
|
+
let catalog: readonly CatalogTool[] | undefined;
|
|
222
|
+
let readyPromise: Promise<void> | undefined;
|
|
223
|
+
let closePromise: Promise<void> | undefined;
|
|
224
|
+
let closed = false;
|
|
225
|
+
|
|
226
|
+
const requestOptions = (signal: AbortSignal) => (timeout !== undefined ? { signal, timeout } : { signal });
|
|
227
|
+
|
|
228
|
+
// Re-list after tools/list_changed, one request at a time; a notification
|
|
229
|
+
// during a re-list schedules one more. A selected tool whose definition
|
|
230
|
+
// changed or disappeared, or any re-list failure, retires it for good.
|
|
231
|
+
function requestCatalogRefresh(connection: Connection): void {
|
|
232
|
+
connection.refreshRequested = true;
|
|
233
|
+
const selected = connection.selected;
|
|
234
|
+
if (!selected || connection.refresh) return;
|
|
235
|
+
connection.refresh = (async () => {
|
|
236
|
+
while (connection.refreshRequested) {
|
|
237
|
+
connection.refreshRequested = false;
|
|
238
|
+
try {
|
|
239
|
+
const { tools } = await connection.client.listTools(undefined, { ...requestOptions(lifetime.signal), cacheMode: "refresh" });
|
|
240
|
+
const current = new Map(tools.map((tool) => [tool.name, stableStringify(tool)]));
|
|
241
|
+
for (const tool of selected) {
|
|
242
|
+
if (current.get(tool.definition.name) !== tool.fingerprint) connection.retired.add(tool.definition.name);
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
for (const tool of selected) connection.retired.add(tool.definition.name);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
connection.refresh = undefined;
|
|
249
|
+
})();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function isRetired(connection: Connection, name: string): Promise<boolean> {
|
|
253
|
+
while (connection.refresh) await connection.refresh;
|
|
254
|
+
return connection.retired.has(name);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function connectServer(
|
|
258
|
+
server: string,
|
|
259
|
+
definition: HostManagedMcpServer,
|
|
260
|
+
validators: ReturnType<typeof createJsonSchemaValidator>,
|
|
261
|
+
): Promise<CatalogTool[]> {
|
|
262
|
+
let connection: Connection;
|
|
263
|
+
let listed: Tool[];
|
|
264
|
+
try {
|
|
265
|
+
const pending = Promise.resolve().then(() => definition.createTransport({ server, signal: lifetime.signal }));
|
|
266
|
+
// A factory that ignores the abort must not hold ready() open after
|
|
267
|
+
// close(); a transport it delivers late is closed, never adopted.
|
|
268
|
+
void pending.then((late) => { if (closed) void late.close().catch(() => {}); }, () => {});
|
|
269
|
+
const transport = await abortable(pending, lifetime.signal);
|
|
270
|
+
const client = new Client(
|
|
271
|
+
{ name: `pi-mcp-${server}`, version: "1.0.0" },
|
|
272
|
+
// No client capabilities, and no multi-round-trip auto-fulfilment: that
|
|
273
|
+
// driver would resend the original tools/call with collected input.
|
|
274
|
+
{ capabilities: {}, jsonSchemaValidator: validators, inputRequired: { autoFulfill: false } },
|
|
275
|
+
);
|
|
276
|
+
// Raw transport errors can carry credentials; the adapter never logs them.
|
|
277
|
+
client.onerror = () => {};
|
|
278
|
+
const created: Connection = {
|
|
279
|
+
id: randomUUID(), client, transport, open: true,
|
|
280
|
+
selected: undefined, retired: new Set(), refresh: undefined, refreshRequested: false,
|
|
281
|
+
};
|
|
282
|
+
connection = created;
|
|
283
|
+
client.onclose = () => { created.open = false; };
|
|
284
|
+
// Registered directly rather than through ClientOptions.listChanged so
|
|
285
|
+
// the change is known synchronously (no debounce) even when the server
|
|
286
|
+
// does not advertise tools.listChanged.
|
|
287
|
+
client.setNotificationHandler("notifications/tools/list_changed", () => requestCatalogRefresh(created));
|
|
288
|
+
connections.set(server, created);
|
|
289
|
+
if (closed) {
|
|
290
|
+
// close() already swept the connections it could see.
|
|
291
|
+
await transport.close().catch(() => {});
|
|
292
|
+
throw new HostManagedMcpError("not_sent", server);
|
|
293
|
+
}
|
|
294
|
+
await client.connect(transport, requestOptions(lifetime.signal));
|
|
295
|
+
listed = (await client.listTools(undefined, requestOptions(lifetime.signal))).tools;
|
|
296
|
+
} catch (cause) {
|
|
297
|
+
throw new Error(`Host-managed MCP server "${server}" failed to start`, { cause });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const byName = new Map(listed.map((tool) => [tool.name, tool]));
|
|
301
|
+
const selected = definition.tools ? [...new Set(definition.tools)] : [...byName.keys()];
|
|
302
|
+
const tools = selected.map((name): CatalogTool => {
|
|
303
|
+
const tool = byName.get(name);
|
|
304
|
+
if (!tool) throw new Error(`Host-managed MCP server "${server}" does not list tool "${name}"`);
|
|
305
|
+
const frozen = deepFreeze(structuredClone(tool));
|
|
306
|
+
const { outputSchema, ...withoutOutputSchema } = frozen;
|
|
307
|
+
let validateOutput: JsonSchemaValidator<unknown> | undefined;
|
|
308
|
+
if (outputSchema) {
|
|
309
|
+
try {
|
|
310
|
+
validateOutput = validators.getValidator(structuredClone(outputSchema) as JsonSchemaType);
|
|
311
|
+
} catch (cause) {
|
|
312
|
+
throw new Error(`Host-managed MCP tool "${name}" on server "${server}" has an invalid outputSchema`, { cause });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
server,
|
|
317
|
+
toolName: formatToolName(name, server, "server"),
|
|
318
|
+
definition: frozen,
|
|
319
|
+
fingerprint: stableStringify(frozen),
|
|
320
|
+
// The SDK reports its own output-schema failures as ProtocolError,
|
|
321
|
+
// indistinguishable from a server error response; validate here instead.
|
|
322
|
+
sdkDefinition: Object.freeze(withoutOutputSchema),
|
|
323
|
+
validateOutput,
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
connection.selected = tools;
|
|
327
|
+
// A notification that arrived before selection still gets its re-list.
|
|
328
|
+
if (connection.refreshRequested) requestCatalogRefresh(connection);
|
|
329
|
+
return tools;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function start(): Promise<void> {
|
|
333
|
+
try {
|
|
334
|
+
const validators = createJsonSchemaValidator();
|
|
335
|
+
const perServer = await Promise.all(servers.map(([server, definition]) => connectServer(server, definition, validators)));
|
|
336
|
+
const tools = perServer.flat();
|
|
337
|
+
const owners = new Map<string, CatalogTool>();
|
|
338
|
+
for (const tool of tools) {
|
|
339
|
+
const owner = owners.get(tool.toolName);
|
|
340
|
+
if (owner) {
|
|
341
|
+
throw new Error(`Host-managed MCP tools "${owner.server}/${owner.definition.name}" and "${tool.server}/${tool.definition.name}" both map to Pi tool "${tool.toolName}"`);
|
|
342
|
+
}
|
|
343
|
+
owners.set(tool.toolName, tool);
|
|
344
|
+
}
|
|
345
|
+
if (closed) throw new Error("Host-managed MCP adapter closed before it was ready");
|
|
346
|
+
catalog = Object.freeze(tools);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
await close();
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function executeTool(
|
|
354
|
+
pi: ExtensionAPI,
|
|
355
|
+
tool: CatalogTool,
|
|
356
|
+
toolCallId: string,
|
|
357
|
+
params: unknown,
|
|
358
|
+
piSignal: AbortSignal | undefined,
|
|
359
|
+
): Promise<ToolResult> {
|
|
360
|
+
const name = tool.definition.name;
|
|
361
|
+
const connection = connections.get(tool.server);
|
|
362
|
+
if (closed || !connection?.open) return dispatchFailureResult(tool, new HostManagedMcpError("not_sent", tool.server));
|
|
363
|
+
|
|
364
|
+
let args: Readonly<Record<string, unknown>>;
|
|
365
|
+
try {
|
|
366
|
+
args = deepFreeze(normalizeToolArguments(params));
|
|
367
|
+
} catch (error) {
|
|
368
|
+
return textResult(error instanceof Error ? error.message : String(error), { error: "invalid_arguments", server: tool.server, tool: name });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const signal = combineAbortSignals(lifetime.signal, piSignal) ?? lifetime.signal;
|
|
372
|
+
let decision: string;
|
|
373
|
+
try {
|
|
374
|
+
decision = await requestBrokerApproval(pi.events, tool.server, { name: tool.toolName, originalName: name }, args, "direct", signal);
|
|
375
|
+
} catch {
|
|
376
|
+
return dispatchFailureResult(tool, new HostManagedMcpError("not_sent", tool.server));
|
|
377
|
+
}
|
|
378
|
+
if (decision === "deny") {
|
|
379
|
+
return textResult(`The user declined approval to run MCP tool "${name}" on server "${tool.server}".`, { error: "approval_denied", server: tool.server, tool: name });
|
|
380
|
+
}
|
|
381
|
+
if (!isAllowed(decision)) {
|
|
382
|
+
return textResult(`MCP tool "${name}" on server "${tool.server}" was not run: no approval handler allowed it.`, { error: "approval_required", server: tool.server, tool: name });
|
|
383
|
+
}
|
|
384
|
+
if (closed || !connection.open || signal.aborted) return dispatchFailureResult(tool, new HostManagedMcpError("not_sent", tool.server));
|
|
385
|
+
if (await isRetired(connection, name)) return dispatchFailureResult(tool, new HostManagedMcpError("not_sent", tool.server), true);
|
|
386
|
+
|
|
387
|
+
let dispatched = false;
|
|
388
|
+
let sent = false;
|
|
389
|
+
let settled = false;
|
|
390
|
+
let dispatchedResult: CallToolResult | undefined;
|
|
391
|
+
const unavailable = () => settled || closed || !connection.open || signal.aborted;
|
|
392
|
+
const send = async (): Promise<CallToolResult> => {
|
|
393
|
+
const retired = await isRetired(connection, name);
|
|
394
|
+
if (unavailable() || retired) throw new HostManagedMcpError("not_sent", tool.server);
|
|
395
|
+
sent = true;
|
|
396
|
+
let result: CallToolResult;
|
|
397
|
+
try {
|
|
398
|
+
const meta = withToolCallIdMeta(undefined, toolCallId);
|
|
399
|
+
result = await connection.client.callTool(
|
|
400
|
+
{ name, arguments: args as Record<string, unknown>, ...(meta ? { _meta: meta } : {}) },
|
|
401
|
+
{ ...requestOptions(signal), toolDefinition: tool.sdkDefinition },
|
|
402
|
+
);
|
|
403
|
+
} catch (cause) {
|
|
404
|
+
throw classifyDispatchError(tool.server, cause);
|
|
405
|
+
}
|
|
406
|
+
if (tool.validateOutput && !result.isError) {
|
|
407
|
+
let valid = false;
|
|
408
|
+
try {
|
|
409
|
+
valid = result.structuredContent !== undefined && tool.validateOutput(result.structuredContent).valid;
|
|
410
|
+
} catch {
|
|
411
|
+
valid = false;
|
|
412
|
+
}
|
|
413
|
+
if (!valid) throw new HostManagedMcpError("invalid_result", tool.server);
|
|
414
|
+
}
|
|
415
|
+
dispatchedResult = result;
|
|
416
|
+
return result;
|
|
417
|
+
};
|
|
418
|
+
const readLinkedResource = async (uri: string): Promise<HostManagedMcpResourceRead> => {
|
|
419
|
+
if (unavailable()) throw new HostManagedMcpError("not_sent", tool.server);
|
|
420
|
+
let decision: string;
|
|
421
|
+
try {
|
|
422
|
+
decision = await requestBrokerApproval(pi.events, tool.server, { name: tool.toolName, originalName: name }, Object.freeze({ uri }), "resource", signal);
|
|
423
|
+
} catch {
|
|
424
|
+
decision = "abstain";
|
|
425
|
+
}
|
|
426
|
+
if (!isAllowed(decision) || unavailable()) throw new HostManagedMcpError("not_sent", tool.server);
|
|
427
|
+
let readDispatched = false;
|
|
428
|
+
const sendRead = async (): Promise<ReadResourceResult> => {
|
|
429
|
+
if (unavailable()) throw new HostManagedMcpError("not_sent", tool.server);
|
|
430
|
+
try {
|
|
431
|
+
return await connection.client.readResource({ uri }, { ...requestOptions(signal), cacheMode: "bypass" });
|
|
432
|
+
} catch (cause) {
|
|
433
|
+
throw classifyDispatchError(tool.server, cause);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
return Object.freeze({
|
|
437
|
+
uri,
|
|
438
|
+
readId: randomUUID(),
|
|
439
|
+
connectionId: connection.id,
|
|
440
|
+
parentToolCallId: toolCallId,
|
|
441
|
+
dispatch() {
|
|
442
|
+
if (readDispatched) throw new Error(`dispatch() was already called for MCP resource read "${uri}"`);
|
|
443
|
+
readDispatched = true;
|
|
444
|
+
return sendRead();
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
};
|
|
448
|
+
const call: HostManagedMcpToolCall = Object.freeze({
|
|
449
|
+
server: tool.server,
|
|
450
|
+
tool: name,
|
|
451
|
+
toolName: tool.toolName,
|
|
452
|
+
toolCallId,
|
|
453
|
+
connectionId: connection.id,
|
|
454
|
+
arguments: args,
|
|
455
|
+
inputSchema: tool.definition.inputSchema,
|
|
456
|
+
signal,
|
|
457
|
+
dispatch() {
|
|
458
|
+
if (dispatched) throw new Error(`dispatch() was already called for MCP tool call "${toolCallId}"`);
|
|
459
|
+
dispatched = true;
|
|
460
|
+
return send();
|
|
461
|
+
},
|
|
462
|
+
linkedResource(uri: string) {
|
|
463
|
+
const linked = dispatchedResult?.content.some((block) => block.type === "resource_link" && block.uri === uri);
|
|
464
|
+
if (!linked) throw new Error(`"${uri}" is not a resource_link in the dispatched result of MCP tool call "${toolCallId}"`);
|
|
465
|
+
return readLinkedResource(uri);
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
let result: CallToolResult;
|
|
470
|
+
try {
|
|
471
|
+
result = await options.onToolCall(call);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
// After a successful dispatch, a HostManagedMcpError comes from other
|
|
474
|
+
// host work (such as a linked read), and the tool itself may have run.
|
|
475
|
+
if (error instanceof HostManagedMcpError && dispatchedResult === undefined) {
|
|
476
|
+
return dispatchFailureResult(tool, error, connection.retired.has(name));
|
|
477
|
+
}
|
|
478
|
+
const details = { error: "host_error", server: tool.server, tool: name, sent };
|
|
479
|
+
return sent
|
|
480
|
+
? textResult(`The host failed while handling MCP tool "${name}" on server "${tool.server}" after the request was sent, so it may have run. Do not retry automatically; check its effects first.`, details)
|
|
481
|
+
: textResult(`The host rejected MCP tool "${name}" on server "${tool.server}"; it was not run.`, details);
|
|
482
|
+
} finally {
|
|
483
|
+
settled = true;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const content = resolveMcpResultContent(result as unknown as Record<string, unknown>, lifetime.signal);
|
|
487
|
+
const outputContent = content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }];
|
|
488
|
+
if (result.isError) {
|
|
489
|
+
const guarded = await guardMcpOutput(outputContent, { ...guardOptions, prefix: "Error: ", emptyTextFallback: "Tool execution failed" });
|
|
490
|
+
return { content: guarded.content, details: { error: "tool_error", server: tool.server, ...guardedMcpDetails(guarded) } };
|
|
491
|
+
}
|
|
492
|
+
const guarded = await guardMcpOutput(outputContent, guardOptions);
|
|
493
|
+
return { content: guarded.content, details: { server: tool.server, tool: name, ...guardedMcpDetails(guarded) } };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function track(execution: Promise<ToolResult>): Promise<ToolResult> {
|
|
497
|
+
inFlight.add(execution);
|
|
498
|
+
void execution.finally(() => inFlight.delete(execution)).catch(() => {});
|
|
499
|
+
return execution;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function shutdown(): Promise<void> {
|
|
503
|
+
closed = true;
|
|
504
|
+
lifetime.abort();
|
|
505
|
+
// One deadline bounds both the drain and the host's transport.close() calls.
|
|
506
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
507
|
+
const deadline = new Promise<void>((resolve) => { timer = setTimeout(resolve, CLOSE_TIMEOUT_MS); });
|
|
508
|
+
await Promise.race([Promise.allSettled([...inFlight]), deadline]);
|
|
509
|
+
const closing = Promise.allSettled([...connections.values()].map(async (connection) => {
|
|
510
|
+
connection.open = false;
|
|
511
|
+
await connection.transport.close();
|
|
512
|
+
}));
|
|
513
|
+
await Promise.race([closing, deadline]);
|
|
514
|
+
clearTimeout(timer);
|
|
515
|
+
connections.clear();
|
|
516
|
+
cleanupMaterializedBinaryResources(lifetime.signal);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function close(): Promise<void> {
|
|
520
|
+
closePromise ??= shutdown();
|
|
521
|
+
return closePromise;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
ready() {
|
|
526
|
+
readyPromise ??= closed ? Promise.reject(new Error("Host-managed MCP adapter is closed")) : start();
|
|
527
|
+
return readyPromise;
|
|
528
|
+
},
|
|
529
|
+
close,
|
|
530
|
+
extensionFactory(pi: ExtensionAPI) {
|
|
531
|
+
if (closed) throw new Error("Host-managed MCP adapter is closed");
|
|
532
|
+
if (!catalog) throw new Error("Host-managed MCP adapter is not ready; await ready() before loading extensionFactory");
|
|
533
|
+
for (const tool of catalog) {
|
|
534
|
+
const description = tool.definition.description ?? "";
|
|
535
|
+
(pi.registerTool as (tool: unknown) => unknown)({
|
|
536
|
+
name: tool.toolName,
|
|
537
|
+
label: `MCP: ${tool.definition.name}`,
|
|
538
|
+
description: description || "(no description)",
|
|
539
|
+
promptSnippet: truncateAtWord(description, 100) || `MCP tool from ${tool.server}`,
|
|
540
|
+
parameters: toToolParameters(normalizeDirectToolInputSchema(structuredClone(tool.definition.inputSchema))),
|
|
541
|
+
execute: (toolCallId: string, params: unknown, signal: AbortSignal | undefined) =>
|
|
542
|
+
track(executeTool(pi, tool, toolCallId, params, signal)),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
package/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { publishMcpStatusShutdown } from "./mcp-status.ts";
|
|
|
22
22
|
import { syncNamespaceProxyTools } from "./namespace-tools.ts";
|
|
23
23
|
import { restoreSessionApprovalState } from "./session-approvals.ts";
|
|
24
24
|
import { createRetryableLoader } from "./lazy-loader.ts";
|
|
25
|
+
import { toToolParameters } from "./tool-parameters.ts";
|
|
25
26
|
|
|
26
27
|
export type { McpAdapterOptions } from "./types.ts";
|
|
27
28
|
export type { ServerEntry } from "./types.ts";
|
|
@@ -375,15 +376,6 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
375
376
|
if (lifecycleMode === "keep-alive") targetState.lifecycle.markKeepAlive(name, definition);
|
|
376
377
|
}
|
|
377
378
|
|
|
378
|
-
// OMP remaps `typebox` to a host shim that historically lacked Type.Unsafe.
|
|
379
|
-
// Prefer Unsafe when present (real TypeBox / fixed OMP shim); otherwise pass
|
|
380
|
-
// the normalized JSON Schema through as a plain object so toolWireSchema and
|
|
381
|
-
// validateToolArguments still treat it as JSON Schema.
|
|
382
|
-
const toToolParameters = (schema: Record<string, unknown>) =>
|
|
383
|
-
typeof (Type as { Unsafe?: (value: never) => unknown }).Unsafe === "function"
|
|
384
|
-
? (Type as { Unsafe: (value: never) => unknown }).Unsafe(schema as never)
|
|
385
|
-
: schema;
|
|
386
|
-
|
|
387
379
|
function directToolFingerprint(spec: DirectToolSpec): string {
|
|
388
380
|
return JSON.stringify({
|
|
389
381
|
serverName: spec.serverName,
|
|
@@ -599,19 +591,16 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
599
591
|
function loadToolSurfaceCache(config: McpConfig): MetadataCache | null {
|
|
600
592
|
const cache = loadMetadataCache();
|
|
601
593
|
const currentState = state;
|
|
602
|
-
if (!currentState
|
|
603
|
-
const servers = { ...cache
|
|
604
|
-
const
|
|
605
|
-
for (const [serverName, connection] of connections) {
|
|
594
|
+
if (!currentState?.sessionMetadata?.size) return cache;
|
|
595
|
+
const servers = { ...cache?.servers };
|
|
596
|
+
for (const [serverName, entry] of currentState.sessionMetadata) {
|
|
606
597
|
const definition = config.mcpServers[serverName];
|
|
607
|
-
|
|
608
|
-
if (
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
const { ttlMs: _liveTtl, ...liveEntry } = entry;
|
|
612
|
-
servers[serverName] = liveEntry;
|
|
598
|
+
if (!definition || isServerDisabled(definition)) continue;
|
|
599
|
+
if (entry.configHash !== computeServerHash(definition)) continue;
|
|
600
|
+
const { ttlMs: _diskTtl, ...sessionEntry } = entry;
|
|
601
|
+
servers[serverName] = { ...sessionEntry, cachedAt: Date.now() };
|
|
613
602
|
}
|
|
614
|
-
return {
|
|
603
|
+
return { version: 1, servers };
|
|
615
604
|
}
|
|
616
605
|
|
|
617
606
|
function syncToolSurface(ctx?: ExtensionContext): void {
|
|
@@ -700,6 +689,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
700
689
|
activeDirectNames,
|
|
701
690
|
existingNamespaceNames: registeredNamespaceProxyTools,
|
|
702
691
|
unavailableServers: activeFailureServers(),
|
|
692
|
+
fallbackDeactivatedNames: fallbackDeactivatedTools,
|
|
703
693
|
pi,
|
|
704
694
|
getState: () => state,
|
|
705
695
|
getInitPromise: () => initPromise,
|
|
@@ -858,7 +848,13 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
858
848
|
type: "string",
|
|
859
849
|
});
|
|
860
850
|
|
|
861
|
-
function startInitialization(
|
|
851
|
+
function startInitialization(
|
|
852
|
+
ctx: ExtensionContext,
|
|
853
|
+
owner: McpRuntimeOwner,
|
|
854
|
+
generation: number,
|
|
855
|
+
staleReason: string,
|
|
856
|
+
excludeProjectServers = false,
|
|
857
|
+
): Promise<void> {
|
|
862
858
|
let oauthRuntime: McpOAuthRuntime | null = null;
|
|
863
859
|
let markStarted!: () => void;
|
|
864
860
|
initStartedPromise = new Promise<void>((resolve) => {
|
|
@@ -899,6 +895,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
899
895
|
}
|
|
900
896
|
: {}),
|
|
901
897
|
oauthRuntime,
|
|
898
|
+
excludeProjectServers,
|
|
902
899
|
// Pi awaits session_start handlers in order. Returning before the approval
|
|
903
900
|
// dialog settles lets /reload or a later extension replace the editor and
|
|
904
901
|
// orphan the dialog, leaving initialization pending forever.
|
|
@@ -1065,7 +1062,7 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
|
|
|
1065
1062
|
model: undefined,
|
|
1066
1063
|
modelRegistry: undefined,
|
|
1067
1064
|
signal: undefined,
|
|
1068
|
-
} as unknown as ExtensionContext, owner, generation, "stale_load_time_initialization");
|
|
1065
|
+
} as unknown as ExtensionContext, owner, generation, "stale_load_time_initialization", true);
|
|
1069
1066
|
});
|
|
1070
1067
|
}
|
|
1071
1068
|
|