pi-mcp-adapter 5.0.0 → 5.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 +48 -0
- package/README.md +1 -1
- package/cli.js +51 -3
- package/config.ts +11 -0
- package/direct-tools.ts +2 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/jev-key-store.js +11 -3
- package/dist/jev-key-store.js.map +1 -1
- package/dist/mcp-trace.d.ts +2 -0
- package/dist/mcp-trace.js +6 -0
- package/dist/mcp-trace.js.map +1 -1
- package/dist/server-manager.d.ts +4 -0
- package/dist/server-manager.js +39 -6
- package/dist/server-manager.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.js.map +1 -1
- package/docs/configuration.md +3 -3
- package/docs/extension-api.md +2 -0
- package/docs/pi-builtin-comparison.md +1 -1
- package/docs/prompts-and-ui.md +2 -0
- package/docs/protocol-extensions.md +47 -0
- package/docs/scripting.md +2 -1
- package/docs/servers.md +1 -0
- package/docs/tools.md +2 -0
- package/index.ts +24 -1
- package/jev-key-store.ts +12 -2
- package/mcp-trace.ts +8 -0
- package/package.json +9 -14
- package/proxy-modes.ts +32 -15
- package/runtime-protocol.ts +501 -0
- package/server-manager.ts +50 -6
- package/types.ts +2 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/** Mediated protocol extensions. Raw SDK clients and transports stay inside the adapter. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Transport } from "@modelcontextprotocol/client";
|
|
5
|
+
import type { ServerConnection } from "./server-manager.ts";
|
|
6
|
+
import { untracedMessageHandler } from "./mcp-trace.ts";
|
|
7
|
+
|
|
8
|
+
export const MCP_PROTOCOL_EVENT = "pi-mcp-adapter:protocol:v1";
|
|
9
|
+
export interface McpProtocolDefinition {
|
|
10
|
+
namespace: string;
|
|
11
|
+
requests: string[];
|
|
12
|
+
streams: string[];
|
|
13
|
+
notifications: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface McpProtocolEnd {
|
|
16
|
+
reason: "cancelled" | "ended" | "error" | "disconnected";
|
|
17
|
+
error?: string;
|
|
18
|
+
/** Preserve JSON-RPC error details for capability-specific recovery decisions. */
|
|
19
|
+
protocolError?: { code: number; message: string; data?: unknown };
|
|
20
|
+
}
|
|
21
|
+
export interface McpProtocolWatch {
|
|
22
|
+
readonly closed: Promise<McpProtocolEnd>;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
export interface McpProtocolStream {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly sent: Promise<void>;
|
|
28
|
+
readonly closed: Promise<McpProtocolEnd>;
|
|
29
|
+
cancel(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface McpProtocolSession {
|
|
32
|
+
readonly signal: AbortSignal;
|
|
33
|
+
/** Observe declared notifications outside this adapter's extension streams. */
|
|
34
|
+
watchNotifications(
|
|
35
|
+
methods: string[],
|
|
36
|
+
onNotification: (method: string, params: Record<string, unknown>) => void,
|
|
37
|
+
): McpProtocolWatch;
|
|
38
|
+
request(
|
|
39
|
+
method: string,
|
|
40
|
+
params?: Record<string, unknown>,
|
|
41
|
+
signal?: AbortSignal,
|
|
42
|
+
): Promise<unknown>;
|
|
43
|
+
openStream(
|
|
44
|
+
method: string,
|
|
45
|
+
params: Record<string, unknown>,
|
|
46
|
+
onNotification: (method: string, params: Record<string, unknown>) => void,
|
|
47
|
+
): McpProtocolStream;
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
export interface McpProtocol {
|
|
51
|
+
connect(server: string): Promise<McpProtocolSession>;
|
|
52
|
+
dispose(): void;
|
|
53
|
+
}
|
|
54
|
+
// Core methods must continue through the adapter's existing APIs and approval paths.
|
|
55
|
+
const reserved = new Set([
|
|
56
|
+
"tools",
|
|
57
|
+
"resources",
|
|
58
|
+
"prompts",
|
|
59
|
+
"sampling",
|
|
60
|
+
"elicitation",
|
|
61
|
+
"logging",
|
|
62
|
+
"roots",
|
|
63
|
+
"completion",
|
|
64
|
+
"completions",
|
|
65
|
+
"tasks",
|
|
66
|
+
"subscriptions",
|
|
67
|
+
"server",
|
|
68
|
+
"client",
|
|
69
|
+
"notifications",
|
|
70
|
+
"rpc",
|
|
71
|
+
"initialize",
|
|
72
|
+
"ping",
|
|
73
|
+
]);
|
|
74
|
+
const record = (value: unknown): value is Record<string, any> =>
|
|
75
|
+
!!value && typeof value === "object" && !Array.isArray(value);
|
|
76
|
+
function validate(raw: McpProtocolDefinition): McpProtocolDefinition {
|
|
77
|
+
if (
|
|
78
|
+
!record(raw) ||
|
|
79
|
+
typeof raw.namespace !== "string" ||
|
|
80
|
+
!/^[a-z][a-z0-9_-]*$/.test(raw.namespace) ||
|
|
81
|
+
reserved.has(raw.namespace)
|
|
82
|
+
)
|
|
83
|
+
throw new Error("Invalid or reserved MCP protocol namespace");
|
|
84
|
+
const names = new Set<string>();
|
|
85
|
+
for (const field of ["requests", "streams", "notifications"] as const) {
|
|
86
|
+
if (!Array.isArray(raw[field]) || raw[field].length > 64)
|
|
87
|
+
throw new Error("Invalid MCP protocol methods");
|
|
88
|
+
const prefix =
|
|
89
|
+
field === "notifications"
|
|
90
|
+
? `notifications/${raw.namespace}/`
|
|
91
|
+
: `${raw.namespace}/`;
|
|
92
|
+
for (const name of raw[field]) {
|
|
93
|
+
if (
|
|
94
|
+
typeof name !== "string" ||
|
|
95
|
+
!name.startsWith(prefix) ||
|
|
96
|
+
!/^[a-zA-Z0-9_/-]+$/.test(name) ||
|
|
97
|
+
name.length <= prefix.length ||
|
|
98
|
+
names.has(name)
|
|
99
|
+
)
|
|
100
|
+
throw new Error("Invalid or duplicate MCP protocol method");
|
|
101
|
+
names.add(name);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
namespace: raw.namespace,
|
|
106
|
+
requests: [...raw.requests],
|
|
107
|
+
streams: [...raw.streams],
|
|
108
|
+
notifications: [...raw.notifications],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function paramsCopy(params: Record<string, unknown>) {
|
|
112
|
+
if (!record(params) || Object.hasOwn(params, "_meta"))
|
|
113
|
+
throw new Error(
|
|
114
|
+
"Protocol parameters must be an object without reserved _meta",
|
|
115
|
+
);
|
|
116
|
+
return structuredClone(params);
|
|
117
|
+
}
|
|
118
|
+
const message = (error: unknown) =>
|
|
119
|
+
error instanceof Error ? error.message : String(error);
|
|
120
|
+
interface Entry {
|
|
121
|
+
methods: Set<string>;
|
|
122
|
+
notify(method: string, params: Record<string, unknown>): void;
|
|
123
|
+
finish(end: McpProtocolEnd): void;
|
|
124
|
+
}
|
|
125
|
+
interface Router {
|
|
126
|
+
prefix: string;
|
|
127
|
+
next: number;
|
|
128
|
+
entries: Map<string, Entry>;
|
|
129
|
+
disconnect: Set<() => void>;
|
|
130
|
+
closed: boolean;
|
|
131
|
+
watchers: Set<Entry>;
|
|
132
|
+
}
|
|
133
|
+
const routers = new WeakMap<Transport, Router>();
|
|
134
|
+
function routerFor(connection: ServerConnection): Router {
|
|
135
|
+
const transport = connection.transport;
|
|
136
|
+
const existing = routers.get(transport);
|
|
137
|
+
if (existing) return existing;
|
|
138
|
+
const router: Router = {
|
|
139
|
+
prefix: `mcp-extension-${randomUUID()}-`,
|
|
140
|
+
next: 0,
|
|
141
|
+
entries: new Map(),
|
|
142
|
+
disconnect: new Set(),
|
|
143
|
+
closed: false,
|
|
144
|
+
watchers: new Set(),
|
|
145
|
+
};
|
|
146
|
+
routers.set(transport, router);
|
|
147
|
+
// The router is the outermost handler, so tracing wraps it once; forward to the raw SDK handler.
|
|
148
|
+
const previousMessage = untracedMessageHandler(transport);
|
|
149
|
+
const previousClose = transport.onclose;
|
|
150
|
+
transport.onmessage = (frame, extra) => {
|
|
151
|
+
const raw = frame as any;
|
|
152
|
+
const id =
|
|
153
|
+
typeof raw.id === "string"
|
|
154
|
+
? raw.id
|
|
155
|
+
: raw.params?._meta?.["io.modelcontextprotocol/subscriptionId"];
|
|
156
|
+
const entry = typeof id === "string" ? router.entries.get(id) : undefined;
|
|
157
|
+
if (entry && !raw.method && ("result" in raw || "error" in raw)) {
|
|
158
|
+
entry.finish(
|
|
159
|
+
"error" in raw
|
|
160
|
+
? {
|
|
161
|
+
reason: "error",
|
|
162
|
+
error:
|
|
163
|
+
typeof raw.error?.message === "string"
|
|
164
|
+
? raw.error.message
|
|
165
|
+
: "MCP stream error",
|
|
166
|
+
...(typeof raw.error?.code === "number"
|
|
167
|
+
? { protocolError: structuredClone(raw.error) }
|
|
168
|
+
: {}),
|
|
169
|
+
}
|
|
170
|
+
: { reason: "ended" },
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const cancelled =
|
|
175
|
+
raw.method === "notifications/cancelled" &&
|
|
176
|
+
typeof raw.params?.requestId === "string"
|
|
177
|
+
? router.entries.get(raw.params.requestId)
|
|
178
|
+
: undefined;
|
|
179
|
+
if (cancelled) {
|
|
180
|
+
cancelled.finish({
|
|
181
|
+
reason: "ended",
|
|
182
|
+
error: "MCP server cancelled the stream",
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
entry &&
|
|
188
|
+
!("id" in raw) &&
|
|
189
|
+
entry.methods.has(raw.method) &&
|
|
190
|
+
record(raw.params)
|
|
191
|
+
) {
|
|
192
|
+
try {
|
|
193
|
+
void Promise.resolve(entry.notify(raw.method, raw.params)).catch(
|
|
194
|
+
(error) =>
|
|
195
|
+
entry.finish({
|
|
196
|
+
reason: "error",
|
|
197
|
+
error: `Protocol handler failed: ${message(error)}`,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
entry.finish({
|
|
202
|
+
reason: "error",
|
|
203
|
+
error: `Protocol handler failed: ${message(error)}`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Ignore late replies/notifications belonging to this router, never unrelated traffic.
|
|
209
|
+
if (!entry && typeof id === "string" && id.startsWith(router.prefix))
|
|
210
|
+
return;
|
|
211
|
+
if (
|
|
212
|
+
!entry &&
|
|
213
|
+
!("id" in raw) &&
|
|
214
|
+
typeof raw.method === "string" &&
|
|
215
|
+
(raw.params === undefined || record(raw.params))
|
|
216
|
+
) {
|
|
217
|
+
for (const watcher of [...router.watchers]) {
|
|
218
|
+
if (!router.watchers.has(watcher) || !watcher.methods.has(raw.method))
|
|
219
|
+
continue;
|
|
220
|
+
try {
|
|
221
|
+
void Promise.resolve(
|
|
222
|
+
watcher.notify(raw.method, structuredClone(raw.params ?? {})),
|
|
223
|
+
).catch((error) =>
|
|
224
|
+
watcher.finish({
|
|
225
|
+
reason: "error",
|
|
226
|
+
error: `Protocol handler failed: ${message(error)}`,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
watcher.finish({
|
|
231
|
+
reason: "error",
|
|
232
|
+
error: `Protocol handler failed: ${message(error)}`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
previousMessage?.(frame, extra);
|
|
238
|
+
};
|
|
239
|
+
transport.onclose = () => {
|
|
240
|
+
router.closed = true;
|
|
241
|
+
try {
|
|
242
|
+
previousClose?.();
|
|
243
|
+
} finally {
|
|
244
|
+
for (const close of [...router.disconnect]) close();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
return router;
|
|
248
|
+
}
|
|
249
|
+
function pin(connection: ServerConnection) {
|
|
250
|
+
connection.activeProtocolOperations =
|
|
251
|
+
(connection.activeProtocolOperations ?? 0) + 1;
|
|
252
|
+
let done = false;
|
|
253
|
+
return () => {
|
|
254
|
+
if (done) return;
|
|
255
|
+
done = true;
|
|
256
|
+
connection.activeProtocolOperations!--;
|
|
257
|
+
connection.lastUsedAt = Date.now();
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Internal implementation; only the mediated session is returned to extensions. */
|
|
262
|
+
export function createProtocolSession(
|
|
263
|
+
connection: ServerConnection,
|
|
264
|
+
owner: AbortSignal,
|
|
265
|
+
definition: McpProtocolDefinition,
|
|
266
|
+
): McpProtocolSession {
|
|
267
|
+
const spec = validate(definition);
|
|
268
|
+
if (owner.aborted || connection.status !== "connected")
|
|
269
|
+
throw new Error("MCP connection is unavailable");
|
|
270
|
+
const router = routerFor(connection);
|
|
271
|
+
if (router.closed) throw new Error("MCP connection is unavailable");
|
|
272
|
+
const controller = new AbortController();
|
|
273
|
+
const active = new Set<(end: McpProtocolEnd) => void>();
|
|
274
|
+
const ensure = (method: string, allowed: string[]) => {
|
|
275
|
+
if (controller.signal.aborted)
|
|
276
|
+
throw new Error("MCP protocol session ended");
|
|
277
|
+
if (!allowed.includes(method))
|
|
278
|
+
throw new Error(`Undeclared MCP protocol method: ${method}`);
|
|
279
|
+
};
|
|
280
|
+
const close = (reason: McpProtocolEnd) => {
|
|
281
|
+
if (controller.signal.aborted) return;
|
|
282
|
+
for (const finish of [...active]) finish(reason);
|
|
283
|
+
controller.abort(new Error(reason.error ?? "MCP protocol session ended"));
|
|
284
|
+
owner.removeEventListener("abort", disconnected);
|
|
285
|
+
router.disconnect.delete(disconnected);
|
|
286
|
+
};
|
|
287
|
+
const disconnected = () =>
|
|
288
|
+
close({ reason: "disconnected", error: "MCP connection or session ended" });
|
|
289
|
+
owner.addEventListener("abort", disconnected, { once: true });
|
|
290
|
+
router.disconnect.add(disconnected);
|
|
291
|
+
return {
|
|
292
|
+
signal: controller.signal,
|
|
293
|
+
watchNotifications(methods, onNotification) {
|
|
294
|
+
if (
|
|
295
|
+
!Array.isArray(methods) ||
|
|
296
|
+
methods.length === 0 ||
|
|
297
|
+
methods.length > 64
|
|
298
|
+
)
|
|
299
|
+
throw new Error("Declared notification methods are required");
|
|
300
|
+
for (const method of methods) ensure(method, spec.notifications);
|
|
301
|
+
if (typeof onNotification !== "function")
|
|
302
|
+
throw new Error("A protocol notification handler is required");
|
|
303
|
+
const release = pin(connection);
|
|
304
|
+
let settle!: (end: McpProtocolEnd) => void;
|
|
305
|
+
const closed = new Promise<McpProtocolEnd>((resolve) => {
|
|
306
|
+
settle = resolve;
|
|
307
|
+
});
|
|
308
|
+
let finished = false;
|
|
309
|
+
const finish = (end: McpProtocolEnd) => {
|
|
310
|
+
if (finished) return;
|
|
311
|
+
finished = true;
|
|
312
|
+
router.watchers.delete(entry);
|
|
313
|
+
active.delete(finish);
|
|
314
|
+
release();
|
|
315
|
+
settle(end);
|
|
316
|
+
};
|
|
317
|
+
const entry: Entry = {
|
|
318
|
+
methods: new Set(methods),
|
|
319
|
+
notify: onNotification,
|
|
320
|
+
finish,
|
|
321
|
+
};
|
|
322
|
+
router.watchers.add(entry);
|
|
323
|
+
active.add(finish);
|
|
324
|
+
return { closed, close: () => finish({ reason: "cancelled" }) };
|
|
325
|
+
},
|
|
326
|
+
async request(method, params = {}, signal) {
|
|
327
|
+
ensure(method, spec.requests);
|
|
328
|
+
const copy = paramsCopy(params);
|
|
329
|
+
const release = pin(connection);
|
|
330
|
+
try {
|
|
331
|
+
const z = await import("zod/v4");
|
|
332
|
+
return await connection.client.request(
|
|
333
|
+
{ method, params: copy },
|
|
334
|
+
z.record(z.string(), z.unknown()),
|
|
335
|
+
{
|
|
336
|
+
signal: signal
|
|
337
|
+
? AbortSignal.any([signal, controller.signal])
|
|
338
|
+
: controller.signal,
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
} finally {
|
|
342
|
+
release();
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
openStream(method, params, onNotification) {
|
|
346
|
+
ensure(method, spec.streams);
|
|
347
|
+
const copy = paramsCopy(params);
|
|
348
|
+
if (typeof onNotification !== "function")
|
|
349
|
+
throw new Error("A protocol notification handler is required");
|
|
350
|
+
const id = `${router.prefix}${router.next++}`;
|
|
351
|
+
const release = pin(connection);
|
|
352
|
+
let settle!: (end: McpProtocolEnd) => void;
|
|
353
|
+
const closed = new Promise<McpProtocolEnd>((resolve) => {
|
|
354
|
+
settle = resolve;
|
|
355
|
+
});
|
|
356
|
+
let finished = false;
|
|
357
|
+
let dispatched = false;
|
|
358
|
+
const requestController = new AbortController();
|
|
359
|
+
let cancellation: Promise<void> | undefined;
|
|
360
|
+
const cancelWire = () =>
|
|
361
|
+
(cancellation ??=
|
|
362
|
+
router.closed ||
|
|
363
|
+
!dispatched ||
|
|
364
|
+
connection.transport.hasPerRequestStream
|
|
365
|
+
? Promise.resolve()
|
|
366
|
+
: connection.client
|
|
367
|
+
.notification({
|
|
368
|
+
method: "notifications/cancelled",
|
|
369
|
+
params: { requestId: id },
|
|
370
|
+
})
|
|
371
|
+
.catch(() => {}));
|
|
372
|
+
const finish = (end: McpProtocolEnd) => {
|
|
373
|
+
if (finished) return;
|
|
374
|
+
finished = true;
|
|
375
|
+
router.entries.delete(id);
|
|
376
|
+
active.delete(finish);
|
|
377
|
+
release();
|
|
378
|
+
settle(end);
|
|
379
|
+
requestController.abort();
|
|
380
|
+
if (end.reason !== "ended") void cancelWire();
|
|
381
|
+
};
|
|
382
|
+
router.entries.set(id, {
|
|
383
|
+
methods: new Set(spec.notifications),
|
|
384
|
+
notify: onNotification,
|
|
385
|
+
finish,
|
|
386
|
+
});
|
|
387
|
+
active.add(finish);
|
|
388
|
+
// Defer sending until the caller has received the handle and installed its state.
|
|
389
|
+
const sent = Promise.resolve().then(async () => {
|
|
390
|
+
if (finished) return;
|
|
391
|
+
const envelope = connection.requestMetadata?.();
|
|
392
|
+
if (connection.client.getProtocolEra() === "modern" && !envelope)
|
|
393
|
+
throw new Error("MCP client request metadata is unavailable");
|
|
394
|
+
const meta = envelope ? { _meta: envelope } : {};
|
|
395
|
+
dispatched = true;
|
|
396
|
+
await connection.transport.send(
|
|
397
|
+
{
|
|
398
|
+
jsonrpc: "2.0",
|
|
399
|
+
id,
|
|
400
|
+
method,
|
|
401
|
+
params: { ...copy, ...meta },
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
requestSignal: requestController.signal,
|
|
405
|
+
onRequestStreamEnd: () =>
|
|
406
|
+
finish({
|
|
407
|
+
reason: "disconnected",
|
|
408
|
+
error: "MCP request stream ended without a final response",
|
|
409
|
+
}),
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
});
|
|
413
|
+
void sent.catch((error) =>
|
|
414
|
+
finish({ reason: "error", error: message(error) }),
|
|
415
|
+
);
|
|
416
|
+
return {
|
|
417
|
+
id,
|
|
418
|
+
sent,
|
|
419
|
+
closed,
|
|
420
|
+
async cancel() {
|
|
421
|
+
finish({ reason: "cancelled" });
|
|
422
|
+
await cancelWire();
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
close: () => close({ reason: "cancelled" }),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
interface ProtocolRequest {
|
|
431
|
+
version: 1;
|
|
432
|
+
definition: McpProtocolDefinition;
|
|
433
|
+
result?: McpProtocol;
|
|
434
|
+
error?: Error;
|
|
435
|
+
}
|
|
436
|
+
export function registerProtocolBridge(
|
|
437
|
+
pi: ExtensionAPI,
|
|
438
|
+
resolve: (
|
|
439
|
+
name: string,
|
|
440
|
+
) => Promise<{ connection: ServerConnection; signal: AbortSignal }>,
|
|
441
|
+
): void {
|
|
442
|
+
const registrations = new Set<string>();
|
|
443
|
+
pi.events.on(MCP_PROTOCOL_EVENT, (raw: unknown) => {
|
|
444
|
+
if (!record(raw) || raw.result !== undefined || raw.error !== undefined)
|
|
445
|
+
return;
|
|
446
|
+
const request = raw as ProtocolRequest;
|
|
447
|
+
try {
|
|
448
|
+
if (request.version !== 1)
|
|
449
|
+
throw new Error("Invalid MCP protocol registration version");
|
|
450
|
+
const definition = validate(request.definition);
|
|
451
|
+
if (registrations.has(definition.namespace))
|
|
452
|
+
throw new Error(
|
|
453
|
+
`MCP protocol already registered: ${definition.namespace}`,
|
|
454
|
+
);
|
|
455
|
+
registrations.add(definition.namespace);
|
|
456
|
+
let disposed = false;
|
|
457
|
+
const sessions = new Set<McpProtocolSession>();
|
|
458
|
+
request.result = {
|
|
459
|
+
async connect(name) {
|
|
460
|
+
if (disposed) throw new Error("MCP protocol registration disposed");
|
|
461
|
+
if (typeof name !== "string" || !name.trim())
|
|
462
|
+
throw new Error("MCP server name is required");
|
|
463
|
+
const target = await resolve(name);
|
|
464
|
+
if (disposed) throw new Error("MCP protocol registration disposed");
|
|
465
|
+
const session = createProtocolSession(
|
|
466
|
+
target.connection,
|
|
467
|
+
target.signal,
|
|
468
|
+
definition,
|
|
469
|
+
);
|
|
470
|
+
sessions.add(session);
|
|
471
|
+
session.signal.addEventListener(
|
|
472
|
+
"abort",
|
|
473
|
+
() => sessions.delete(session),
|
|
474
|
+
{ once: true },
|
|
475
|
+
);
|
|
476
|
+
return session;
|
|
477
|
+
},
|
|
478
|
+
dispose() {
|
|
479
|
+
if (disposed) return;
|
|
480
|
+
disposed = true;
|
|
481
|
+
registrations.delete(definition.namespace);
|
|
482
|
+
for (const session of sessions) session.close();
|
|
483
|
+
sessions.clear();
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
} catch (error) {
|
|
487
|
+
request.error = error instanceof Error ? error : new Error(String(error));
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
export function registerMcpProtocol(
|
|
492
|
+
pi: ExtensionAPI,
|
|
493
|
+
definition: McpProtocolDefinition,
|
|
494
|
+
): McpProtocol {
|
|
495
|
+
const request: ProtocolRequest = { version: 1, definition };
|
|
496
|
+
pi.events.emit(MCP_PROTOCOL_EVENT, request);
|
|
497
|
+
if (request.error) throw request.error;
|
|
498
|
+
if (!request.result)
|
|
499
|
+
throw new Error("pi-mcp-adapter does not expose protocol extensions");
|
|
500
|
+
return request.result;
|
|
501
|
+
}
|
package/server-manager.ts
CHANGED
|
@@ -190,9 +190,20 @@ function appendStderrTail(tail: Buffer, chunk: Buffer | string): Buffer {
|
|
|
190
190
|
: combined;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// Use the SDK's own envelope builder so extension streams match ordinary requests,
|
|
194
|
+
// including capability changes and negotiated protocol versions.
|
|
195
|
+
class AdapterClient extends Client {
|
|
196
|
+
requestMetadata(): Readonly<Record<string, unknown>> | undefined {
|
|
197
|
+
return this._outboundMetaEnvelope();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const clientMetadata = new WeakMap<Client, () => Readonly<Record<string, unknown>> | undefined>();
|
|
201
|
+
|
|
193
202
|
export interface ServerConnection {
|
|
194
203
|
client: Client;
|
|
195
204
|
transport: Transport;
|
|
205
|
+
/** Internal SDK envelope accessor; never exposed through the protocol extension API. */
|
|
206
|
+
requestMetadata?: () => Readonly<Record<string, unknown>> | undefined;
|
|
196
207
|
definition: ServerDefinition;
|
|
197
208
|
tools: McpTool[];
|
|
198
209
|
/** Cache hints from the server's aggregated tools/list result. */
|
|
@@ -208,6 +219,8 @@ export interface ServerConnection {
|
|
|
208
219
|
instructions?: string;
|
|
209
220
|
lastUsedAt: number;
|
|
210
221
|
inFlight: number;
|
|
222
|
+
/** Active protocol requests/streams on this transport; never inherited by reconnects. */
|
|
223
|
+
activeProtocolOperations?: number;
|
|
211
224
|
status: "connected" | "closed" | "needs-auth";
|
|
212
225
|
/** Catalog subscription health, tracked independently from transport health. */
|
|
213
226
|
listenState: McpListenState;
|
|
@@ -255,6 +268,36 @@ export function isTransientHttpConnectError(error: unknown): boolean {
|
|
|
255
268
|
return false;
|
|
256
269
|
}
|
|
257
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Forward as url+init with the caller's own signal, never as a Request object: undici links a
|
|
273
|
+
* Request's signal to its source weakly, so once the wrapper Request is garbage-collected an
|
|
274
|
+
* abort no longer reaches the fetch and long-lived SSE GETs keep their socket (pi never exits).
|
|
275
|
+
*/
|
|
276
|
+
function forwardInit(
|
|
277
|
+
input: URL | RequestInfo,
|
|
278
|
+
init: RequestInit | undefined,
|
|
279
|
+
request: Request,
|
|
280
|
+
overrides: RequestInit = {},
|
|
281
|
+
): [string, RequestInit] {
|
|
282
|
+
const signal = init?.signal ?? (input instanceof Request ? input.signal : request.signal);
|
|
283
|
+
return [request.url, {
|
|
284
|
+
method: request.method,
|
|
285
|
+
headers: request.headers,
|
|
286
|
+
body: request.body,
|
|
287
|
+
cache: request.cache,
|
|
288
|
+
credentials: request.credentials,
|
|
289
|
+
integrity: request.integrity,
|
|
290
|
+
keepalive: request.keepalive,
|
|
291
|
+
mode: request.mode,
|
|
292
|
+
redirect: request.redirect,
|
|
293
|
+
referrer: request.referrer,
|
|
294
|
+
referrerPolicy: request.referrerPolicy,
|
|
295
|
+
signal,
|
|
296
|
+
...(request.body ? { duplex: "half" } : {}),
|
|
297
|
+
...overrides,
|
|
298
|
+
} as RequestInit];
|
|
299
|
+
}
|
|
300
|
+
|
|
258
301
|
/** Wrap a FetchLike so each request re-resolves the bearer token via the resolver. */
|
|
259
302
|
function createBearerCommandFetch(
|
|
260
303
|
resolver: BearerCommandResolver,
|
|
@@ -273,8 +316,7 @@ function createBearerCommandFetch(
|
|
|
273
316
|
// The Headers error quotes the value, so it must not surface.
|
|
274
317
|
throw new TypeError("bearerTokenCommand returned a token that is not a valid header value");
|
|
275
318
|
}
|
|
276
|
-
|
|
277
|
-
return innerFetch(new Request(request, { headers }));
|
|
319
|
+
return innerFetch(...forwardInit(input, init, request, { headers }));
|
|
278
320
|
};
|
|
279
321
|
}
|
|
280
322
|
|
|
@@ -294,7 +336,7 @@ function createProviderTokenFetch(
|
|
|
294
336
|
: (input: URL | RequestInfo, init?: RequestInit) => globalThis.fetch(input, init);
|
|
295
337
|
return async (input, init) => {
|
|
296
338
|
const request = new Request(input, init);
|
|
297
|
-
if (new URL(request.url).origin !== origin) return innerFetch(request);
|
|
339
|
+
if (new URL(request.url).origin !== origin) return innerFetch(...forwardInit(input, init, request));
|
|
298
340
|
const token = await providerToken(provider);
|
|
299
341
|
// Without a token the request is not sent; the 401 marks the server as needing sign-in.
|
|
300
342
|
if (!token) return new Response(null, { status: 401 });
|
|
@@ -305,7 +347,7 @@ function createProviderTokenFetch(
|
|
|
305
347
|
// The Headers error quotes the value, so it must not surface.
|
|
306
348
|
throw new TypeError(`Pi provider "${provider}" returned a token that is not a valid header value`);
|
|
307
349
|
}
|
|
308
|
-
return innerFetch(
|
|
350
|
+
return innerFetch(...forwardInit(input, init, request, { headers, redirect: "error" }));
|
|
309
351
|
};
|
|
310
352
|
}
|
|
311
353
|
|
|
@@ -1208,6 +1250,7 @@ export class McpServerManager {
|
|
|
1208
1250
|
const connection: ServerConnection = {
|
|
1209
1251
|
client,
|
|
1210
1252
|
transport,
|
|
1253
|
+
requestMetadata: () => clientMetadata.get(client)?.(),
|
|
1211
1254
|
definition,
|
|
1212
1255
|
tools: [],
|
|
1213
1256
|
toolsRevision: 0,
|
|
@@ -1429,7 +1472,7 @@ export class McpServerManager {
|
|
|
1429
1472
|
const capabilities = this.buildClientCapabilities();
|
|
1430
1473
|
const versionNegotiation = resolveVersionNegotiation(definition);
|
|
1431
1474
|
let client: Client;
|
|
1432
|
-
client = new
|
|
1475
|
+
client = new AdapterClient(
|
|
1433
1476
|
{ name: `pi-mcp-${serverName}`, version: "1.0.0" },
|
|
1434
1477
|
{
|
|
1435
1478
|
jsonSchemaValidator: createJsonSchemaValidator(),
|
|
@@ -1454,6 +1497,7 @@ export class McpServerManager {
|
|
|
1454
1497
|
},
|
|
1455
1498
|
},
|
|
1456
1499
|
);
|
|
1500
|
+
clientMetadata.set(client, () => (client as AdapterClient).requestMetadata());
|
|
1457
1501
|
if (this.samplingConfig) {
|
|
1458
1502
|
registerSamplingHandler(client, { ...this.samplingConfig, serverName });
|
|
1459
1503
|
}
|
|
@@ -2129,7 +2173,7 @@ export class McpServerManager {
|
|
|
2129
2173
|
isIdle(name: string, timeoutMs: number): boolean {
|
|
2130
2174
|
const connection = this.connections.get(name);
|
|
2131
2175
|
if (!connection || connection.status !== "connected") return false;
|
|
2132
|
-
if (connection.inFlight > 0) return false;
|
|
2176
|
+
if (connection.inFlight > 0 || (connection.activeProtocolOperations ?? 0) > 0) return false;
|
|
2133
2177
|
return (Date.now() - connection.lastUsedAt) > timeoutMs;
|
|
2134
2178
|
}
|
|
2135
2179
|
}
|
package/types.ts
CHANGED
|
@@ -493,6 +493,8 @@ export interface ServerEntry {
|
|
|
493
493
|
searchKeywords?: Record<string, string[]>;
|
|
494
494
|
// Require interactive approval before calling matching MCP tools/resources.
|
|
495
495
|
approveTools?: boolean | "destructive" | string[];
|
|
496
|
+
/** Set to false to never open MCP App UIs for this server's tools; results stay inline. */
|
|
497
|
+
openUi?: boolean;
|
|
496
498
|
// Debug
|
|
497
499
|
debug?: boolean; // Show server stderr (default: false)
|
|
498
500
|
/** Enable metadata-only JSONL protocol tracing for this server. */
|