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.
@@ -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
- // Composed runtime fetches accept Request despite the SDK's narrower type.
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(new Request(request, { headers, redirect: "error" }));
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 Client(
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. */