pi-mcp-adapter 2.26.0 → 2.26.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/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.26.1] - 2026-08-18
11
+
12
+ ### Fixed
13
+ - Scoped “Allow for session” tool approvals to the approved arguments, so one approval no longer applies to later calls with different inputs. Thanks to [@spaceshipmike](https://github.com/spaceshipmike) for #367.
14
+ - Stopped MCP panel commands from hanging in RPC, JSON, and print modes when terminal-only UI is unavailable. Thanks to [@shixin-guo](https://github.com/shixin-guo) for PR #365.
15
+ - Kept compact MCP result rows useful by showing a short input preview and skipping leading blank output in collapsed previews.
16
+ - Recovered MCP gateway requests nested inside proxy `args` instead of showing status, and now rejects invalid nested gateway requests with guidance. Thanks to [@ibrmora](https://github.com/ibrmora) for #363.
17
+ - Kept remote keep-alive tool catalogs fresh across server restarts, so long-lived Pi sessions can discover replacement tools without restarting. Thanks to [@dmorn](https://github.com/dmorn) for #369 and PR #370.
18
+
10
19
  ## [2.26.0] - 2026-08-14
11
20
 
12
21
  ### Added
package/README.md CHANGED
@@ -293,8 +293,10 @@ You can also pass only the `code` query parameter with `args: { code: "..." }`.
293
293
 
294
294
  - **`lazy`** (default) — Don't connect at startup. Connect on first tool call. Disconnect after idle timeout. Cached metadata keeps search/list working without connections.
295
295
  - **`eager`** — Connect at startup but don't auto-reconnect if the connection drops. No idle timeout by default (set `idleTimeout` explicitly to enable).
296
- - **`keep-alive`** — Connect at startup. Auto-reconnect via health checks. No idle timeout. Use for servers you always need available.
297
- - **`lazy-keep-alive`** — Don't connect at startup. Connect on first tool call (like `lazy`). Once spawned, never idle-shut down and auto-reconnect via health checks if the process dies (like `keep-alive`). Use for servers that are expensive to start but should stay resident after their first use.
296
+ - **`keep-alive`** — Connect at startup. Remote HTTP servers refresh their tool catalog during health checks, before user input, and before adapter-triggered turns, reconnecting when the server reports that the session expired. No idle timeout. Use for servers you always need available.
297
+ - **`lazy-keep-alive`** — Don't connect at startup. Connect on first tool call (like `lazy`). Once spawned, never idle-shut down and use the same catalog refresh and reconnect checks as `keep-alive`. Use for servers that are expensive to start but should stay resident after their first use.
298
+
299
+ For remote HTTP keep-alive servers, the authoritative `tools/list` refresh is also the fallback when `list_changed` notifications are unavailable or their stream is lost. Each `tools/list` or `ping` request is capped at 5 seconds, up to 10 servers are checked concurrently, and transient failures use bounded backoff. A successful refresh updates metadata without reconnecting; a response proving that the HTTP session expired triggers a full reconnect and reinstalls the notification handlers. Dynamic direct-tool registration follows the refreshed metadata unless `freezeDirectTools` is enabled.
298
300
 
299
301
  When any enabled server uses `eager` or `keep-alive`, initialization also starts when the extension loads. This supports hosts that embed Pi programmatically and never emit `session_start`; if a session does start later, the session-owned runtime supersedes the load-time runtime.
300
302
 
@@ -658,7 +660,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only
658
660
 
659
661
  `mcp({ connect: "server-name" })` refreshes an already connected server, so new tools, resources, prompts, and instructions can load without restarting Pi.
660
662
 
661
- MCP proxy and direct-tool results use compact self-rendered rows by default. Collapsed success output shows the call title and the first result line, with a `Ctrl+O to expand` hint when more text is hidden. The full result remains available when expanded and is still returned unchanged to the model. Set `settings.toolResultRendering` to `"boxed"` to restore the legacy boxed Pi row, or set `settings.collapsedResultLines` to `2` or `3` when you want more collapsed text.
663
+ MCP proxy and direct-tool results use compact self-rendered rows by default. Collapsed success output shows the call title, a bounded one-line input preview when arguments exist, and the first result line, with a `Ctrl+O to expand` hint when more text is hidden. The full result remains available when expanded and is still returned unchanged to the model. Set `settings.toolResultRendering` to `"boxed"` to restore the legacy boxed Pi row, or set `settings.collapsedResultLines` to `2` or `3` when you want more collapsed text.
662
664
 
663
665
  Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are ranked by weighted matches across name, server, description, and any configured `searchKeywords`, then returned one page at a time (`limit` defaults to 12). Use `details.nextOffset` for the next page. Regex search is still available with `regex: true`, but regex results are paginated without ranking.
664
666
 
@@ -725,7 +727,7 @@ Advertised tool `outputSchema` values support JSON Schema draft-07 and 2020-12.
725
727
  - Idle servers disconnect after 10 minutes (configurable), reconnect automatically on next use
726
728
  - npx-based servers resolve to direct binary paths, skipping the ~143 MB npm parent process
727
729
  - MCP server validates arguments, not the adapter
728
- - Keep-alive servers get health checks and auto-reconnect
730
+ - Remote keep-alive servers force-refresh their tool catalog during health checks, before user input, and before adapter-triggered turns, with bounded reconnect backoff
729
731
  - Specific tools can be promoted from the proxy to first-class Pi tools via `directTools` config, so the LLM sees them directly instead of having to search
730
732
 
731
733
  ## Limitations
package/commands.ts CHANGED
@@ -28,6 +28,19 @@ function terminalHyperlink(label: string, url: string): string {
28
28
  return `\u001B]8;;${sanitizeTerminalText(url)}\u001B\\${sanitizeTerminalText(label)}\u001B]8;;\u001B\\`;
29
29
  }
30
30
 
31
+ /**
32
+ * True when this run mode can display a `ctx.ui.custom()` overlay.
33
+ *
34
+ * `ctx.hasUI` only reports that *some* UI context is bound. In rpc/print mode
35
+ * that context is a headless stub whose `custom()` returns immediately without
36
+ * ever invoking the factory or the `done` callback, so a panel awaited through
37
+ * `new Promise(resolve => ctx.ui.custom(...))` never settles and the command
38
+ * hangs. Overlay panels therefore additionally require `ctx.mode === "tui"`.
39
+ */
40
+ function canRenderPanel(ctx: ExtensionContext): boolean {
41
+ return ctx.hasUI && ctx.mode === "tui";
42
+ }
43
+
31
44
  export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
32
45
  if (!ctx.hasUI) return;
33
46
 
@@ -399,6 +412,10 @@ export async function openMcpSetup(
399
412
  options: { includeHostConfigs?: boolean } = {},
400
413
  ): Promise<PanelFlowResult> {
401
414
  if (!ctx.hasUI) return { configChanged: false };
415
+ if (!canRenderPanel(ctx)) {
416
+ ctx.ui.notify(`The interactive MCP setup panel is only available in the terminal UI (current mode: ${ctx.mode}). Edit .mcp.json directly, or run /mcp status to review servers.`, "info");
417
+ return { configChanged: false };
418
+ }
402
419
  if (state.programmaticConfig) {
403
420
  ctx.ui.notify("MCP setup is unavailable when config is supplied by createMcpAdapter().", "info");
404
421
  return { configChanged: false };
@@ -533,6 +550,11 @@ export async function openMcpPanel(
533
550
  }
534
551
  return { configChanged: false };
535
552
  }
553
+ if (!canRenderPanel(ctx)) {
554
+ // No overlay here, but the same information is available as text.
555
+ await showStatus(state, ctx);
556
+ return { configChanged: false };
557
+ }
536
558
  if (Object.keys(state.config.mcpServers).length === 0) {
537
559
  return openMcpSetup(state, pi, ctx, configOverridePath, "empty", { includeHostConfigs: false });
538
560
  }
@@ -587,6 +609,10 @@ export async function openMcpAuthPanel(
587
609
  configOverridePath?: string,
588
610
  ): Promise<PanelFlowResult> {
589
611
  if (!ctx.hasUI) return { configChanged: false };
612
+ if (!canRenderPanel(ctx)) {
613
+ ctx.ui.notify(`The interactive MCP auth panel is only available in the terminal UI (current mode: ${ctx.mode}). Use /mcp-auth <server> to authenticate a specific server.`, "info");
614
+ return { configChanged: false };
615
+ }
590
616
  if (state.programmaticConfig) {
591
617
  ctx.ui.notify("Use /mcp-auth <server> to authenticate a server from the in-memory SDK config.", "info");
592
618
  return { configChanged: false };
package/index.ts CHANGED
@@ -417,6 +417,29 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
417
417
  }
418
418
  });
419
419
 
420
+ pi.on("input", async () => {
421
+ const inputOwner = currentOwner;
422
+ if (!inputOwner?.isActive()) return;
423
+
424
+ if (!state && initPromise) {
425
+ try {
426
+ await awaitWithTimeout(initPromise, INIT_WAIT_TIMEOUT_MS);
427
+ } catch {
428
+ return;
429
+ }
430
+ }
431
+
432
+ const inputState = state;
433
+ if (!inputState || !inputOwner.isActive()) return;
434
+ try {
435
+ await inputState.lifecycle.ensureConverged(inputOwner.signal);
436
+ } catch (error) {
437
+ if (!isAbortError(error, inputOwner.signal)) {
438
+ logger.debug(`MCP: keep-alive convergence failed before input: ${formatTerminalError(error)}`);
439
+ }
440
+ }
441
+ });
442
+
420
443
  pi.on("session_shutdown", async () => {
421
444
  ++lifecycleGeneration;
422
445
  const currentState = state;
@@ -737,12 +760,12 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
737
760
  action?: string;
738
761
  }, signal: AbortSignal | undefined, _onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined, _ctx: ExtensionContext) {
739
762
  const executeOwner = currentOwner;
740
- let parsedArgs: Record<string, unknown> | undefined;
741
- if (params.args !== undefined && params.args !== "") {
763
+ const parseArgs = (value: string | Record<string, unknown> | undefined): Record<string, unknown> | undefined => {
764
+ if (value === undefined || value === "") return undefined;
742
765
  let args: unknown;
743
- if (typeof params.args === "string") {
766
+ if (typeof value === "string") {
744
767
  try {
745
- args = JSON.parse(params.args);
768
+ args = JSON.parse(value);
746
769
  } catch (error) {
747
770
  if (error instanceof SyntaxError) {
748
771
  throw new Error(`Invalid args JSON: ${error.message}`, { cause: error });
@@ -750,14 +773,35 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
750
773
  throw error;
751
774
  }
752
775
  } else {
753
- args = params.args;
776
+ args = value;
754
777
  }
755
778
 
756
779
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
757
780
  const gotType = Array.isArray(args) ? "array" : args === null ? "null" : typeof args;
758
781
  throw new Error(`Invalid args: expected a JSON object, got ${gotType}`);
759
782
  }
760
- parsedArgs = args as Record<string, unknown>;
783
+ return args as Record<string, unknown>;
784
+ };
785
+ let parsedArgs = parseArgs(params.args);
786
+ let dispatchParams = params;
787
+ const hasGatewayMode = (value: typeof params): boolean =>
788
+ value.tool !== undefined
789
+ || value.connect !== undefined
790
+ || value.describe !== undefined
791
+ || value.instructions !== undefined
792
+ || value.search !== undefined
793
+ || value.server !== undefined
794
+ || value.action !== undefined;
795
+ if (!hasGatewayMode(params) && parsedArgs) {
796
+ const nestedParams = parsedArgs as typeof params;
797
+ if (hasGatewayMode(nestedParams)) {
798
+ dispatchParams = nestedParams;
799
+ parsedArgs = parseArgs(nestedParams.args);
800
+ } else {
801
+ throw new Error("Gateway params were nested inside `args`; pass them top-level (for example, mcp({ search: \"...\" }) or mcp({ tool: \"...\", args: {} })).");
802
+ }
803
+ } else if (!hasGatewayMode(params) && params.args !== undefined) {
804
+ throw new Error("Gateway params were nested inside `args`; pass them top-level (for example, mcp({ search: \"...\" }) or mcp({ tool: \"...\", args: {} })).");
761
805
  }
762
806
 
763
807
  if (!state && initPromise) {
@@ -788,22 +832,22 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
788
832
  }
789
833
  executeOwner?.throwIfInactive();
790
834
 
791
- if (params.action === "ui-messages") {
835
+ if (dispatchParams.action === "ui-messages") {
792
836
  return executeUiMessages(state);
793
837
  }
794
- if (params.action === "auth-start") {
795
- if (!params.server) {
838
+ if (dispatchParams.action === "auth-start") {
839
+ if (!dispatchParams.server) {
796
840
  return {
797
841
  content: [{ type: "text" as const, text: "auth-start requires `server`. Example: mcp({ action: \"auth-start\", server: \"linear-server\" })" }],
798
842
  details: { mode: "auth-start", error: "missing_server" },
799
843
  };
800
844
  }
801
845
  return signal
802
- ? executeAuthStart(state, params.server, signal)
803
- : executeAuthStart(state, params.server);
846
+ ? executeAuthStart(state, dispatchParams.server, signal)
847
+ : executeAuthStart(state, dispatchParams.server);
804
848
  }
805
- if (params.action === "auth-complete") {
806
- if (!params.server) {
849
+ if (dispatchParams.action === "auth-complete") {
850
+ if (!dispatchParams.server) {
807
851
  return {
808
852
  content: [{ type: "text" as const, text: "auth-complete requires `server`." }],
809
853
  details: { mode: "auth-complete", error: "missing_server" },
@@ -817,28 +861,28 @@ function installMcpAdapter(pi: ExtensionAPI, options: McpAdapterOptions) {
817
861
  };
818
862
  }
819
863
  return signal
820
- ? executeAuthComplete(state, params.server, input, signal)
821
- : executeAuthComplete(state, params.server, input);
864
+ ? executeAuthComplete(state, dispatchParams.server, input, signal)
865
+ : executeAuthComplete(state, dispatchParams.server, input);
822
866
  }
823
- if (params.tool) {
824
- return executeCall(state, params.tool, parsedArgs, params.server, getPiTools, signal);
867
+ if (dispatchParams.tool) {
868
+ return executeCall(state, dispatchParams.tool, parsedArgs, dispatchParams.server, getPiTools, signal);
825
869
  }
826
- if (params.connect) {
827
- const result = await executeConnect(state, params.connect, signal);
870
+ if (dispatchParams.connect) {
871
+ const result = await executeConnect(state, dispatchParams.connect, signal);
828
872
  syncToolSurface(_ctx as ExtensionContext);
829
873
  return result;
830
874
  }
831
- if (params.describe) {
832
- return executeDescribe(state, params.describe);
875
+ if (dispatchParams.describe) {
876
+ return executeDescribe(state, dispatchParams.describe);
833
877
  }
834
- if (params.instructions) {
835
- return executeInstructions(state, params.instructions);
878
+ if (dispatchParams.instructions) {
879
+ return executeInstructions(state, dispatchParams.instructions);
836
880
  }
837
- if (params.search !== undefined) {
838
- return executeSearch(state, params.search, params.regex, params.server, params.includeSchemas, params.limit, params.offset);
881
+ if (dispatchParams.search !== undefined) {
882
+ return executeSearch(state, dispatchParams.search, dispatchParams.regex, dispatchParams.server, dispatchParams.includeSchemas, dispatchParams.limit, dispatchParams.offset);
839
883
  }
840
- if (params.server) {
841
- return executeList(state, params.server);
884
+ if (dispatchParams.server) {
885
+ return executeList(state, dispatchParams.server);
842
886
  }
843
887
  return executeStatus(state);
844
888
  },
package/init.ts CHANGED
@@ -179,8 +179,20 @@ export async function initializeMcp(
179
179
  },
180
180
  ...(ui !== undefined ? { ui } : {}),
181
181
  sendMessage: (message, options) => {
182
- if (!owner.isActive()) return;
183
- pi.sendMessage(message as unknown as Parameters<typeof pi.sendMessage>[0], options);
182
+ const deliver = () => {
183
+ if (!owner.isActive()) return;
184
+ pi.sendMessage(message as unknown as Parameters<typeof pi.sendMessage>[0], options);
185
+ };
186
+ if (!options?.triggerTurn) {
187
+ deliver();
188
+ return;
189
+ }
190
+ void lifecycle.ensureConverged(owner.signal).then(deliver, error => {
191
+ if (!owner.isActive() || isAbortError(error, owner.signal)) return;
192
+ const detail = error instanceof Error ? error.message : String(error);
193
+ logger.debug(`MCP: pre-turn keep-alive convergence failed: ${sanitizeTerminalText(detail)}`);
194
+ deliver();
195
+ });
184
196
  },
185
197
  ...(options.statusEvents !== undefined ? { statusEvents: options.statusEvents } : {}),
186
198
  };
@@ -419,6 +431,18 @@ export async function initializeMcp(
419
431
  updateStatusBar(state);
420
432
  });
421
433
 
434
+ lifecycle.setHealthRestoredCallback((serverName) => {
435
+ if (!owner.isActive()) return;
436
+ clearFailure(state, serverName);
437
+ updateStatusBar(state);
438
+ });
439
+
440
+ lifecycle.setAuthRequiredCallback((serverName) => {
441
+ if (!owner.isActive()) return;
442
+ clearFailure(state, serverName);
443
+ updateStatusBar(state);
444
+ });
445
+
422
446
  lifecycle.setIdleShutdownCallback((serverName) => {
423
447
  if (!owner.isActive()) return;
424
448
  const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName);
package/lifecycle.ts CHANGED
@@ -1,11 +1,26 @@
1
+ import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
1
2
  import { isServerDisabled, type ServerDefinition } from "./types.ts";
2
- import type { McpServerManager } from "./server-manager.ts";
3
+ import type { McpServerManager, ServerConnection } from "./server-manager.ts";
3
4
  import { hasPendingAuth } from "./mcp-auth-flow.ts";
4
5
  import { logger } from "./logger.ts";
5
- import { formatTerminalError, sanitizeTerminalText } from "./utils.ts";
6
+ import { formatTerminalError, parallelLimit, sanitizeTerminalText } from "./utils.ts";
7
+ import { isTerminatedSession } from "./session-recovery.ts";
6
8
 
7
- export type ReconnectCallback = (serverName: string) => void;
9
+ export type ReconnectCallback = (serverName: string) => void | Promise<void>;
8
10
  export type ReconnectFailureCallback = (serverName: string, error: unknown) => void;
11
+ export type HealthRestoredCallback = (serverName: string) => void | Promise<void>;
12
+ export type AuthRequiredCallback = (serverName: string) => void | Promise<void>;
13
+
14
+ const KEEP_ALIVE_RETRY_BASE_MS = 30_000;
15
+ const KEEP_ALIVE_RETRY_MAX_MS = 5 * 60_000;
16
+ const KEEP_ALIVE_CHECK_CONCURRENCY = 10;
17
+
18
+ interface RetryState {
19
+ attempts: number;
20
+ nextAttemptAt: number;
21
+ connection: ServerConnection | undefined;
22
+ status: ServerConnection["status"] | undefined;
23
+ }
9
24
 
10
25
  export class McpLifecycleManager {
11
26
  private keepAliveServers = new Map<string, ServerDefinition>();
@@ -15,9 +30,14 @@ export class McpLifecycleManager {
15
30
  private healthCheckInterval: NodeJS.Timeout | undefined;
16
31
  private onReconnect: ReconnectCallback | undefined;
17
32
  private onReconnectFailure: ReconnectFailureCallback | undefined;
33
+ private onHealthRestored: HealthRestoredCallback | undefined;
34
+ private onAuthRequired: AuthRequiredCallback | undefined;
18
35
  private onIdleShutdown: ((serverName: string) => void) | undefined;
19
36
  private activeHealthCheck: Promise<void> | undefined;
37
+ private activeConvergence: Promise<void> | undefined;
20
38
  private shutdownPromise: Promise<void> | undefined;
39
+ private retryStates = new Map<string, RetryState>();
40
+ private pendingMetadataPublications = new Set<string>();
21
41
  private stopped = false;
22
42
  private removeHealthAbortListener: (() => void) | undefined;
23
43
 
@@ -34,6 +54,14 @@ export class McpLifecycleManager {
34
54
  this.onReconnectFailure = callback;
35
55
  }
36
56
 
57
+ setHealthRestoredCallback(callback: HealthRestoredCallback): void {
58
+ this.onHealthRestored = callback;
59
+ }
60
+
61
+ setAuthRequiredCallback(callback: AuthRequiredCallback): void {
62
+ this.onAuthRequired = callback;
63
+ }
64
+
37
65
  markKeepAlive(name: string, definition: ServerDefinition): void {
38
66
  if (isServerDisabled(definition)) return;
39
67
  this.keepAliveServers.set(name, definition);
@@ -82,29 +110,23 @@ export class McpLifecycleManager {
82
110
  this.healthCheckInterval.unref();
83
111
  }
84
112
 
85
- private async checkConnections(signal?: AbortSignal): Promise<void> {
113
+ async ensureConverged(signal?: AbortSignal): Promise<void> {
86
114
  if (this.stopped || signal?.aborted) return;
87
- for (const [name, definition] of this.keepAliveServers) {
88
- if (isServerDisabled(definition)) continue;
89
- const connection = this.manager.getConnection(name);
90
- if (!connection || connection.status !== "connected") {
91
- if (this.hasPendingAuthForServer(name)) {
92
- logger.debug(`Skipping reconnect for ${name} while OAuth authorization is pending`);
93
- continue;
94
- }
95
- try {
96
- await this.manager.connect(name, definition, signal);
97
- if (this.stopped || signal?.aborted) return;
98
- logger.debug(`Reconnected to ${name}`);
99
- this.onReconnect?.(name);
100
- } catch (error) {
101
- if (this.stopped || signal?.aborted) return;
102
- this.onReconnectFailure?.(name, error);
103
- const message = error instanceof Error ? error.message : String(error);
104
- console.error(`MCP: Failed to reconnect to ${name}: ${sanitizeTerminalText(message)}`);
105
- }
106
- }
115
+ if (this.activeConvergence) return this.activeConvergence;
116
+
117
+ const check = this.checkKeepAliveConnections(signal);
118
+ this.activeConvergence = check;
119
+ try {
120
+ await check;
121
+ } finally {
122
+ if (this.activeConvergence === check) this.activeConvergence = undefined;
107
123
  }
124
+ }
125
+
126
+ private async checkConnections(signal?: AbortSignal): Promise<void> {
127
+ if (this.stopped || signal?.aborted) return;
128
+ await this.ensureConverged(signal);
129
+ if (this.stopped || signal?.aborted) return;
108
130
 
109
131
  for (const [name] of this.allServers) {
110
132
  if (this.keepAliveServers.has(name)) continue;
@@ -117,6 +139,205 @@ export class McpLifecycleManager {
117
139
  }
118
140
  }
119
141
 
142
+ private async checkKeepAliveConnections(signal?: AbortSignal): Promise<void> {
143
+ if (this.stopped || signal?.aborted) return;
144
+ await parallelLimit(
145
+ [...this.keepAliveServers],
146
+ KEEP_ALIVE_CHECK_CONCURRENCY,
147
+ ([name, definition]) => this.checkKeepAliveConnection(name, definition, signal),
148
+ );
149
+ }
150
+
151
+ private async checkKeepAliveConnection(
152
+ name: string,
153
+ definition: ServerDefinition,
154
+ signal?: AbortSignal,
155
+ retrySuperseded = true,
156
+ ): Promise<void> {
157
+ if (isServerDisabled(definition) || this.stopped || signal?.aborted) return;
158
+ const connection = this.manager.getConnection(name);
159
+ if (connection?.status === "needs-auth") {
160
+ this.pendingMetadataPublications.delete(name);
161
+ return;
162
+ }
163
+ if (!this.shouldAttemptConnection(name, connection)) return;
164
+ if (!connection || connection.status !== "connected") {
165
+ if (this.hasPendingAuthForServer(name)) {
166
+ logger.debug(`Skipping reconnect for ${name} while OAuth authorization is pending`);
167
+ return;
168
+ }
169
+ let freshConnection: ServerConnection;
170
+ try {
171
+ freshConnection = await this.manager.connect(name, definition, signal);
172
+ } catch (error) {
173
+ if (this.stopped || signal?.aborted) return;
174
+ this.reportConnectionFailure(name, error, "reconnect", this.manager.getConnection(name));
175
+ return;
176
+ }
177
+ if (this.stopped || signal?.aborted) return;
178
+ if (freshConnection.status === "needs-auth") {
179
+ await this.notifyAuthRequired(name);
180
+ return;
181
+ }
182
+ if (freshConnection.status !== "connected") {
183
+ this.reportConnectionFailure(
184
+ name,
185
+ new Error(`MCP server ${name} did not return a connected session`),
186
+ "reconnect",
187
+ freshConnection,
188
+ );
189
+ return;
190
+ }
191
+ logger.debug(`Reconnected to ${name}`);
192
+ await this.publishConnectedMetadata(name, freshConnection);
193
+ return;
194
+ }
195
+
196
+ if (this.pendingMetadataPublications.has(name)) {
197
+ await this.publishConnectedMetadata(name, connection);
198
+ return;
199
+ }
200
+ if (!definition.url) return;
201
+ const hadSessionId = (connection.transport as { sessionId?: string } | undefined)?.sessionId != null;
202
+ let refreshResult: Awaited<ReturnType<McpServerManager["refreshTools"]>>;
203
+ try {
204
+ refreshResult = await this.manager.refreshTools(name, connection, signal);
205
+ } catch (error) {
206
+ if (this.stopped || signal?.aborted) return;
207
+ const current = this.manager.getConnection(name);
208
+ if (current !== connection || connection.status !== "connected") {
209
+ await this.handleSupersededConnection(name, definition, connection, signal, retrySuperseded);
210
+ return;
211
+ }
212
+ if (!shouldReconnectAfterRefresh(error, hadSessionId)) {
213
+ this.reportConnectionFailure(name, error, "refresh", connection);
214
+ return;
215
+ }
216
+ if (this.hasPendingAuthForServer(name)) {
217
+ logger.debug(`Skipping reconnect for ${name} while OAuth authorization is pending`);
218
+ return;
219
+ }
220
+ let freshConnection: ServerConnection;
221
+ try {
222
+ freshConnection = await this.manager.reconnect(name, definition, connection, signal);
223
+ } catch (reconnectError) {
224
+ if (this.stopped || signal?.aborted) return;
225
+ this.reportConnectionFailure(name, reconnectError, "reconnect", this.manager.getConnection(name));
226
+ return;
227
+ }
228
+ if (this.stopped || signal?.aborted) return;
229
+ if (freshConnection.status === "needs-auth") {
230
+ await this.notifyAuthRequired(name);
231
+ return;
232
+ }
233
+ if (freshConnection.status !== "connected") {
234
+ this.reportConnectionFailure(
235
+ name,
236
+ new Error(`MCP server ${name} did not return a connected session`),
237
+ "reconnect",
238
+ freshConnection,
239
+ );
240
+ return;
241
+ }
242
+ logger.debug(`Reconnected stale MCP session for ${name}`);
243
+ await this.publishConnectedMetadata(name, freshConnection);
244
+ return;
245
+ }
246
+
247
+ if (refreshResult === "superseded") {
248
+ await this.handleSupersededConnection(name, definition, connection, signal, retrySuperseded);
249
+ return;
250
+ }
251
+ if (this.retryStates.delete(name)) {
252
+ await this.onHealthRestored?.(name);
253
+ }
254
+ }
255
+
256
+ private async handleSupersededConnection(
257
+ name: string,
258
+ definition: ServerDefinition,
259
+ staleConnection: ServerConnection,
260
+ signal: AbortSignal | undefined,
261
+ retrySuperseded: boolean,
262
+ ): Promise<void> {
263
+ const current = this.manager.getConnection(name);
264
+ if (current === staleConnection && current.status === "connected") {
265
+ if (this.retryStates.delete(name)) await this.onHealthRestored?.(name);
266
+ return;
267
+ }
268
+ if (current?.status === "connected") {
269
+ await this.publishConnectedMetadata(name, current);
270
+ return;
271
+ }
272
+ if (current?.status === "needs-auth") {
273
+ await this.notifyAuthRequired(name);
274
+ return;
275
+ }
276
+ if (retrySuperseded) {
277
+ await this.checkKeepAliveConnection(name, definition, signal, false);
278
+ }
279
+ }
280
+
281
+ private async publishConnectedMetadata(name: string, connection: ServerConnection): Promise<void> {
282
+ this.pendingMetadataPublications.add(name);
283
+ try {
284
+ await this.onReconnect?.(name);
285
+ this.pendingMetadataPublications.delete(name);
286
+ this.retryStates.delete(name);
287
+ } catch (error) {
288
+ if (this.stopped) return;
289
+ this.reportConnectionFailure(name, error, "publish", connection);
290
+ }
291
+ }
292
+
293
+ private async notifyAuthRequired(name: string): Promise<void> {
294
+ this.pendingMetadataPublications.delete(name);
295
+ this.retryStates.delete(name);
296
+ try {
297
+ await this.onAuthRequired?.(name);
298
+ } catch (error) {
299
+ const message = error instanceof Error ? error.message : String(error);
300
+ logger.debug(`MCP: auth-required callback failed for ${name}: ${sanitizeTerminalText(message)}`);
301
+ }
302
+ }
303
+
304
+ private shouldAttemptConnection(name: string, connection: ServerConnection | undefined): boolean {
305
+ const retry = this.retryStates.get(name);
306
+ if (!retry) return true;
307
+ if (retry.connection !== connection || retry.status !== connection?.status) {
308
+ this.retryStates.delete(name);
309
+ return true;
310
+ }
311
+ return Date.now() >= retry.nextAttemptAt;
312
+ }
313
+
314
+ private reportConnectionFailure(
315
+ name: string,
316
+ error: unknown,
317
+ action: "refresh" | "reconnect" | "publish",
318
+ connection: ServerConnection | undefined,
319
+ ): void {
320
+ const attempts = (this.retryStates.get(name)?.attempts ?? 0) + 1;
321
+ const delay = Math.min(
322
+ KEEP_ALIVE_RETRY_BASE_MS * 2 ** Math.min(attempts - 1, 10),
323
+ KEEP_ALIVE_RETRY_MAX_MS,
324
+ );
325
+ this.retryStates.set(name, {
326
+ attempts,
327
+ nextAttemptAt: Date.now() + delay,
328
+ connection,
329
+ status: connection?.status,
330
+ });
331
+ this.onReconnectFailure?.(name, error);
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ const target = action === "reconnect"
334
+ ? `reconnect to ${name}`
335
+ : action === "publish"
336
+ ? `publish metadata for ${name}`
337
+ : `refresh ${name}`;
338
+ console.error(`MCP: Failed to ${target}: ${sanitizeTerminalText(message)}`);
339
+ }
340
+
120
341
  private getIdleTimeout(name: string): number {
121
342
  const perServer = this.serverSettings.get(name)?.idleTimeout;
122
343
  if (perServer !== undefined) return perServer * 60 * 1000;
@@ -137,11 +358,23 @@ export class McpLifecycleManager {
137
358
  this.removeHealthAbortListener = undefined;
138
359
  await this.activeHealthCheck;
139
360
  this.activeHealthCheck = undefined;
361
+ await this.activeConvergence;
362
+ this.activeConvergence = undefined;
140
363
  this.onReconnect = undefined;
141
364
  this.onReconnectFailure = undefined;
365
+ this.onHealthRestored = undefined;
366
+ this.onAuthRequired = undefined;
142
367
  this.onIdleShutdown = undefined;
368
+ this.retryStates.clear();
369
+ this.pendingMetadataPublications.clear();
143
370
  if (typeof this.manager.closeAll === "function") {
144
371
  await this.manager.closeAll();
145
372
  }
146
373
  }
147
374
  }
375
+
376
+ function shouldReconnectAfterRefresh(error: unknown, hadSessionId: boolean): boolean {
377
+ if (isTerminatedSession(error, hadSessionId)) return true;
378
+ return error instanceof SdkError
379
+ && (error.code === SdkErrorCode.NotConnected || error.code === SdkErrorCode.ConnectionClosed);
380
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.26.0",
3
+ "version": "2.26.1",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
package/server-manager.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { mkdirSync } from "node:fs";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import {
3
4
  Client,
4
5
  SdkHttpError,
@@ -7,6 +8,7 @@ import {
7
8
  UnauthorizedError,
8
9
  type GetPromptResult,
9
10
  type ReadResourceResult,
11
+ type CacheableRequestOptions,
10
12
  type RequestOptions,
11
13
  type UrlElicitationRequiredError,
12
14
  type VersionNegotiationOptions,
@@ -123,6 +125,8 @@ export interface ServerConnection {
123
125
  transport: Transport;
124
126
  definition: ServerDefinition;
125
127
  tools: McpTool[];
128
+ /** Monotonic guard against older refresh responses replacing newer notifications. */
129
+ toolsRevision?: number;
126
130
  resources: McpResource[];
127
131
  prompts: McpPrompt[];
128
132
  /** True when prompts were advertised but prompts/list failed. */
@@ -139,6 +143,10 @@ export interface ServerConnection {
139
143
  type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
140
144
  type MetadataListChangedListener = (serverName: string, reason: string) => void;
141
145
 
146
+ export type ToolRefreshResult = "updated" | "unchanged" | "superseded";
147
+
148
+ const KEEP_ALIVE_REFRESH_TIMEOUT_MS = 5_000;
149
+
142
150
  export class McpServerManager {
143
151
  private connections = new Map<string, ServerConnection>();
144
152
  private connectPromises = new Map<string, Promise<ServerConnection>>();
@@ -146,6 +154,10 @@ export class McpServerManager {
146
154
  private uiStreamListeners = new Map<string, UiStreamListener>();
147
155
  private samplingConfig: ServerSamplingConfig | undefined;
148
156
  private metadataListChangedListener: MetadataListChangedListener | undefined;
157
+ private pendingMetadataPublications = new Map<
158
+ string,
159
+ { connection: ServerConnection; reason: string }
160
+ >();
149
161
  private elicitationConfig: ServerElicitationConfig | undefined;
150
162
  private authStorageOptions: AuthStorageOptions = {};
151
163
  private oauthRuntime: McpOAuthRuntime | undefined;
@@ -170,6 +182,25 @@ export class McpServerManager {
170
182
  this.metadataListChangedListener = listener;
171
183
  }
172
184
 
185
+ publishMetadataChanged(
186
+ name: string,
187
+ expectedConnection: ServerConnection,
188
+ reason: string,
189
+ ): boolean {
190
+ const current = this.connections.get(name);
191
+ if (current !== expectedConnection || current.status !== "connected") return false;
192
+ try {
193
+ this.metadataListChangedListener?.(name, reason);
194
+ this.pendingMetadataPublications.delete(name);
195
+ return true;
196
+ } catch (error) {
197
+ this.pendingMetadataPublications.set(name, { connection: expectedConnection, reason });
198
+ const message = error instanceof Error ? error.message : String(error);
199
+ logger.debug(`MCP: metadata publication failed for ${name}; queued for retry: ${message}`);
200
+ return false;
201
+ }
202
+ }
203
+
173
204
  setElicitationConfig(config: ServerElicitationConfig | undefined): void {
174
205
  this.elicitationConfig = config;
175
206
  }
@@ -303,6 +334,88 @@ export class McpServerManager {
303
334
  return abortable(promise, ownedSignal);
304
335
  }
305
336
 
337
+ /**
338
+ * Fetch the authoritative tool catalog for a connection that still appears
339
+ * connected. The SDK response cache is explicitly bypassed so this request
340
+ * also proves the remote Streamable HTTP session is usable. Results are
341
+ * identity-guarded: a late response from a replaced connection is ignored.
342
+ */
343
+ async refreshTools(
344
+ name: string,
345
+ expectedConnection: ServerConnection,
346
+ signal?: AbortSignal,
347
+ ): Promise<ToolRefreshResult> {
348
+ if (this.stopped) throw new Error("MCP server manager is closed");
349
+ const ownedSignal = combineAbortSignals(this.runtimeSignal, signal);
350
+ throwIfAborted(ownedSignal);
351
+
352
+ const current = this.connections.get(name);
353
+ if (current !== expectedConnection || current.status !== "connected") {
354
+ return "superseded";
355
+ }
356
+
357
+ const requestOptions = this.buildRequestOptions(expectedConnection.definition, signal);
358
+ const timeout = Math.min(requestOptions?.timeout ?? KEEP_ALIVE_REFRESH_TIMEOUT_MS, KEEP_ALIVE_REFRESH_TIMEOUT_MS);
359
+ const healthOptions = {
360
+ ...requestOptions,
361
+ timeout,
362
+ };
363
+ if (!expectedConnection.client.getServerCapabilities?.()?.tools) {
364
+ await expectedConnection.client.ping(healthOptions);
365
+ throwIfAborted(ownedSignal);
366
+ if (this.connections.get(name) !== expectedConnection || expectedConnection.status !== "connected") {
367
+ return "superseded";
368
+ }
369
+ this.retryPendingMetadataPublication(name, expectedConnection);
370
+ return "unchanged";
371
+ }
372
+
373
+ const toolsRevision = expectedConnection.toolsRevision ?? 0;
374
+ const refreshSignal = combineAbortSignals(healthOptions.signal, AbortSignal.timeout(timeout));
375
+ const tools = await this.fetchAllTools(expectedConnection.client, {
376
+ ...healthOptions,
377
+ ...(refreshSignal ? { signal: refreshSignal } : {}),
378
+ cacheMode: "refresh",
379
+ });
380
+ throwIfAborted(ownedSignal);
381
+
382
+ if (this.connections.get(name) !== expectedConnection || expectedConnection.status !== "connected") {
383
+ return "superseded";
384
+ }
385
+ if ((expectedConnection.toolsRevision ?? 0) !== toolsRevision) {
386
+ return "superseded";
387
+ }
388
+
389
+ if (isDeepStrictEqual(expectedConnection.tools, tools)) {
390
+ this.retryPendingMetadataPublication(name, expectedConnection);
391
+ return "unchanged";
392
+ }
393
+
394
+ const previousTools = expectedConnection.tools;
395
+ expectedConnection.tools = tools;
396
+ expectedConnection.toolsRevision = toolsRevision + 1;
397
+ try {
398
+ await this.metadataListChangedListener?.(name, "keep-alive-refresh");
399
+ this.pendingMetadataPublications.delete(name);
400
+ } catch (error) {
401
+ if (this.connections.get(name) === expectedConnection && expectedConnection.tools === tools) {
402
+ expectedConnection.tools = previousTools;
403
+ expectedConnection.toolsRevision = toolsRevision;
404
+ }
405
+ throw error;
406
+ }
407
+ return "updated";
408
+ }
409
+
410
+ private retryPendingMetadataPublication(name: string, connection: ServerConnection): void {
411
+ const pendingPublication = this.pendingMetadataPublications.get(name);
412
+ if (pendingPublication?.connection !== connection) return;
413
+ this.metadataListChangedListener?.(name, pendingPublication.reason);
414
+ if (this.pendingMetadataPublications.get(name) === pendingPublication) {
415
+ this.pendingMetadataPublications.delete(name);
416
+ }
417
+ }
418
+
306
419
  private async doReconnect(
307
420
  name: string,
308
421
  definition: ServerDefinition,
@@ -440,6 +553,7 @@ export class McpServerManager {
440
553
  transport,
441
554
  definition,
442
555
  tools: [],
556
+ toolsRevision: 0,
443
557
  resources: [],
444
558
  prompts: [],
445
559
  ...(instructions !== undefined ? { instructions } : {}),
@@ -641,7 +755,9 @@ export class McpServerManager {
641
755
  const connection = this.connections.get(serverName);
642
756
  if (!connection || connection.client !== client || connection.status !== "connected") return;
643
757
  connection.tools = tools;
758
+ connection.toolsRevision = (connection.toolsRevision ?? 0) + 1;
644
759
  this.metadataListChangedListener?.(serverName, "tools-list-changed");
760
+ this.pendingMetadataPublications.delete(serverName);
645
761
  }
646
762
 
647
763
  private handlePromptsListChanged(
@@ -660,6 +776,7 @@ export class McpServerManager {
660
776
  connection.prompts = prompts;
661
777
  connection.promptDiscoveryFailed = false;
662
778
  this.metadataListChangedListener?.(serverName, "prompts-list-changed");
779
+ this.pendingMetadataPublications.delete(serverName);
663
780
  }
664
781
 
665
782
  private handleResourcesListChanged(
@@ -677,6 +794,7 @@ export class McpServerManager {
677
794
  if (!connection || connection.client !== client || connection.status !== "connected") return;
678
795
  connection.resources = resources;
679
796
  this.metadataListChangedListener?.(serverName, "resources-list-changed");
797
+ this.pendingMetadataPublications.delete(serverName);
680
798
  }
681
799
 
682
800
  async handleUrlElicitationRequired(
@@ -851,7 +969,7 @@ export class McpServerManager {
851
969
  }
852
970
  }
853
971
 
854
- private async fetchAllTools(client: Client, requestOptions?: RequestOptions): Promise<McpTool[]> {
972
+ private async fetchAllTools(client: Client, requestOptions?: CacheableRequestOptions): Promise<McpTool[]> {
855
973
  const allTools: McpTool[] = [];
856
974
  let cursor: string | undefined;
857
975
 
@@ -979,6 +1097,7 @@ export class McpServerManager {
979
1097
  async close(name: string): Promise<void> {
980
1098
  this.closeGenerations.set(name, (this.closeGenerations.get(name) ?? 0) + 1);
981
1099
  this.connectAttempts.get(name)?.abort(new Error(`MCP connection ${name} was closed`));
1100
+ this.pendingMetadataPublications.delete(name);
982
1101
 
983
1102
  const connection = this.connections.get(name);
984
1103
  if (!connection) {
@@ -1042,6 +1161,7 @@ export class McpServerManager {
1042
1161
  .filter(error => this.containsCleanupFailure(error));
1043
1162
  this.uiStreamListeners.clear();
1044
1163
  this.acceptedUrlElicitations.clear();
1164
+ this.pendingMetadataPublications.clear();
1045
1165
  this.samplingConfig = undefined;
1046
1166
  this.elicitationConfig = undefined;
1047
1167
  await this.traceWriter?.flush();
@@ -146,6 +146,13 @@ export async function withSessionRecovery<T>(
146
146
  throw err;
147
147
  }
148
148
 
149
+ try {
150
+ deps.manager.publishMetadataChanged(serverName, freshConnection, "session-reconnect");
151
+ } catch (publicationError) {
152
+ const message = publicationError instanceof Error ? publicationError.message : String(publicationError);
153
+ logger.debug(`MCP metadata publication after reconnect failed for "${serverName}": ${message}`);
154
+ }
155
+ throwIfAborted(deps.signal);
149
156
  return fn(freshConnection);
150
157
  }
151
158
  }
package/tool-approval.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { abortable } from "./abort.ts";
3
3
  import { combineAbortSignals } from "./runtime-owner.ts";
4
4
  import type { McpExtensionState } from "./state.ts";
@@ -20,6 +20,18 @@ export type ToolCallApprovalResult =
20
20
  | { ok: true }
21
21
  | { ok: false; reason: "denied" | "approval_required_headless" };
22
22
 
23
+ function stableStringify(value: unknown): string {
24
+ if (value === null || value === undefined || typeof value !== "object") {
25
+ const serialized = JSON.stringify(value);
26
+ return serialized === undefined ? "undefined" : serialized;
27
+ }
28
+ if (Array.isArray(value)) {
29
+ return `[${value.map(item => stableStringify(item)).join(",")}]`;
30
+ }
31
+ const object = value as Record<string, unknown>;
32
+ return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
33
+ }
34
+
23
35
  export function isToolCallApprovalRequired(
24
36
  config: McpConfig,
25
37
  serverName: string,
@@ -136,7 +148,8 @@ export async function ensureToolCallApproved(
136
148
  origin: McpToolApprovalOrigin = toolMeta.resourceUri ? "resource" : "proxy",
137
149
  approvalMetadata?: ReadonlyMap<string, readonly ToolMetadata[]>,
138
150
  ): Promise<ToolCallApprovalResult> {
139
- const cacheKey = `${serverName}\u0000${toolMeta.originalName}`;
151
+ const argsHash = createHash("sha256").update(stableStringify(args ?? {})).digest("hex");
152
+ const cacheKey = `${serverName}\u0000${toolMeta.originalName}\u0000${argsHash}`;
140
153
  const approvedToolCalls = state.approvedToolCalls ??= new Map<string, true>();
141
154
  if (approvedToolCalls.has(cacheKey)) {
142
155
  return { ok: true };
@@ -25,6 +25,7 @@ export interface McpProxyToolCallInput {
25
25
 
26
26
  interface McpToolRenderState {
27
27
  compactTitle?: string;
28
+ compactInputPreview?: string;
28
29
  }
29
30
 
30
31
  interface McpToolRenderContext {
@@ -52,6 +53,7 @@ export interface McpToolResultDisplay {
52
53
  }
53
54
 
54
55
  const DEFAULT_MAX_CALL_INPUT_CHARS = 1500;
56
+ const DEFAULT_MAX_COMPACT_INPUT_CHARS = 240;
55
57
  const DEFAULT_BOXED_COLLAPSED_LINES = 3;
56
58
  const DEFAULT_COMPACT_COLLAPSED_LINES = 1;
57
59
  const DEFAULT_MAX_COLLAPSED_CHARS = 8000;
@@ -70,6 +72,7 @@ class CompactMcpToolResult implements Component {
70
72
 
71
73
  constructor(
72
74
  private readonly title: string,
75
+ private readonly inputPreview: string,
73
76
  private readonly display: McpToolResultDisplay,
74
77
  private readonly theme: RenderTheme,
75
78
  ) {}
@@ -83,7 +86,7 @@ class CompactMcpToolResult implements Component {
83
86
  });
84
87
  const lines = resultLines.length > 0 ? resultLines : [""];
85
88
  const bodies = lines.map((line, index) => {
86
- const prefix = index === 0 && this.title ? `${this.theme.fg("toolTitle", this.title)} → ` : "";
89
+ const prefix = index === 0 ? this.renderPrefix(safeWidth) : "";
87
90
  return `${prefix}${this.theme.fg("toolOutput", line)}`;
88
91
  });
89
92
  const hiddenText = this.display.truncated || bodies.some((body) => visibleWidth(body) > safeWidth);
@@ -106,6 +109,22 @@ class CompactMcpToolResult implements Component {
106
109
  invalidate(): void {
107
110
  this.rendered = null;
108
111
  }
112
+
113
+ private renderPrefix(width: number): string {
114
+ if (!this.title) return "";
115
+ const arrow = " → ";
116
+ if (!this.inputPreview) return `${this.theme.fg("toolTitle", this.title)}${arrow}`;
117
+
118
+ const maxPrefixWidth = Math.max(12, Math.floor(width * 0.55));
119
+ const titleWidth = visibleWidth(this.title);
120
+ const inputWidth = Math.max(0, maxPrefixWidth - titleWidth - 1);
121
+ if (inputWidth <= 3) {
122
+ return `${this.theme.fg("toolTitle", truncateToWidth(this.title, maxPrefixWidth, "…"))}${arrow}`;
123
+ }
124
+
125
+ const input = truncateToWidth(this.inputPreview, inputWidth, "…");
126
+ return `${this.theme.fg("toolTitle", this.title)} ${this.theme.fg("muted", input)}${arrow}`;
127
+ }
109
128
  }
110
129
 
111
130
  class CollapsibleText implements Component {
@@ -239,6 +258,10 @@ function renderToolCallLines(lines: string[], theme?: RenderTheme) {
239
258
  return new Text([styledTitle, ...styledRest].join("\n"), 0, 0);
240
259
  }
241
260
 
261
+ function formatCompactInputPreview(lines: string[], maxChars = DEFAULT_MAX_COMPACT_INPUT_CHARS): string {
262
+ return truncateText(lines.slice(1).join(" ").replace(/\s+/g, " ").trim(), maxChars);
263
+ }
264
+
242
265
  export function resolveMcpToolRenderOptions(settings?: McpToolRenderSettings): McpToolRenderOptions {
243
266
  const resultRendering = settings?.toolResultRendering === "boxed" ? "boxed" : "compact";
244
267
  const collapsedLines = settings?.collapsedResultLines;
@@ -263,7 +286,10 @@ function renderToolCall(
263
286
  context: McpToolRenderContext | undefined,
264
287
  options: McpToolRenderOptions,
265
288
  ) {
266
- if (context?.state) context.state.compactTitle = lines[0] ?? "mcp";
289
+ if (context?.state) {
290
+ context.state.compactTitle = lines[0] ?? "mcp";
291
+ context.state.compactInputPreview = formatCompactInputPreview(lines);
292
+ }
267
293
  if (shouldUseCompactFinalRender(options, context)) return new EmptyComponent();
268
294
  return renderToolCallLines(lines, theme);
269
295
  }
@@ -307,6 +333,19 @@ function collectCollapsedResultLines(
307
333
  let truncated = false;
308
334
 
309
335
  const appendLine = (line: string) => {
336
+ if (lines.length === 0) {
337
+ const previewWidth = Math.min(line.length, remainingChars);
338
+ if (line.slice(0, previewWidth).trim() === "") {
339
+ if (line.length >= remainingChars) {
340
+ truncated = true;
341
+ remainingChars = 0;
342
+ return false;
343
+ }
344
+ remainingChars -= line.length + 1;
345
+ return true;
346
+ }
347
+ }
348
+
310
349
  if (lines.length >= maxLines || remainingChars <= 0) {
311
350
  truncated = true;
312
351
  return false;
@@ -342,7 +381,7 @@ function collectCollapsedResultLines(
342
381
  if (truncated) break;
343
382
  }
344
383
 
345
- if (lines.length === 0) lines.push("");
384
+ if (lines.length === 0) lines.push(truncated ? "(leading blank output omitted)" : "");
346
385
  if (truncated && lines.length >= maxLines) lines.push("…");
347
386
  return { lines, truncated };
348
387
  }
@@ -393,7 +432,8 @@ export function renderMcpToolResult(
393
432
  if (!expanded && renderOptions.resultRendering === "compact") {
394
433
  const display = formatMcpToolResultLines(result, false, renderOptions.collapsedResultLines);
395
434
  const title = context?.state?.compactTitle ?? formatMcpToolResultIdentity(result.details) ?? "";
396
- return new CompactMcpToolResult(title, display, activeTheme);
435
+ const inputPreview = context?.state?.compactInputPreview ?? "";
436
+ return new CompactMcpToolResult(title, inputPreview, display, activeTheme);
397
437
  }
398
438
 
399
439
  const display = formatMcpToolResultLines(result, expanded, renderOptions.collapsedResultLines);