@oh-my-pi/pi-coding-agent 17.0.8 → 17.0.9

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/dist/cli.js +4850 -4774
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +27 -0
  5. package/dist/types/config/settings-schema.d.ts +34 -3
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  7. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  9. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  10. package/dist/types/lsp/client.d.ts +2 -0
  11. package/dist/types/mcp/manager.d.ts +6 -2
  12. package/dist/types/mcp/render.d.ts +2 -2
  13. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  14. package/dist/types/mcp/types.d.ts +6 -0
  15. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  16. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  17. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  18. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  19. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  20. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  21. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  22. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  23. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  24. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +8 -1
  26. package/dist/types/session/session-loader.d.ts +6 -4
  27. package/dist/types/task/types.d.ts +14 -0
  28. package/dist/types/tools/hub/jobs.d.ts +1 -1
  29. package/dist/types/tools/index.d.ts +3 -0
  30. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  31. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  32. package/dist/types/web/search/types.d.ts +1 -1
  33. package/package.json +13 -12
  34. package/scripts/legacy-pi-virtual-module.ts +1 -1
  35. package/src/cli/grievances-cli.ts +2 -2
  36. package/src/cli/usage-cli.ts +1 -1
  37. package/src/config/__tests__/model-registry.test.ts +147 -0
  38. package/src/config/model-registry.ts +40 -0
  39. package/src/config/model-resolver.ts +0 -1
  40. package/src/config/settings-schema.ts +41 -3
  41. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  42. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  43. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  44. package/src/internal-urls/registry-helpers.ts +40 -0
  45. package/src/lsp/client.ts +23 -0
  46. package/src/lsp/clients/biome-client.ts +3 -4
  47. package/src/lsp/index.ts +25 -7
  48. package/src/main.ts +1 -0
  49. package/src/mcp/manager.ts +39 -8
  50. package/src/mcp/render.ts +94 -35
  51. package/src/mcp/tool-bridge.ts +107 -11
  52. package/src/mcp/types.ts +7 -0
  53. package/src/modes/components/agent-hub.ts +26 -9
  54. package/src/modes/components/oauth-selector.ts +24 -7
  55. package/src/modes/components/settings-defs.ts +5 -2
  56. package/src/modes/components/settings-selector.ts +9 -5
  57. package/src/modes/components/tool-execution.test.ts +63 -2
  58. package/src/modes/controllers/command-controller.ts +14 -7
  59. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  60. package/src/modes/interactive-mode.ts +3 -0
  61. package/src/modes/rpc/rpc-client.ts +94 -3
  62. package/src/modes/rpc/rpc-frame.ts +164 -4
  63. package/src/modes/rpc/rpc-messages.ts +127 -0
  64. package/src/modes/rpc/rpc-mode.ts +79 -7
  65. package/src/modes/rpc/rpc-types.ts +34 -2
  66. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  67. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  68. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  69. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  70. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  71. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  72. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  73. package/src/prompts/goals/guided-goal-system.md +24 -3
  74. package/src/prompts/tools/task.md +12 -2
  75. package/src/sdk.ts +45 -3
  76. package/src/session/agent-session.ts +63 -32
  77. package/src/session/session-context.test.ts +224 -1
  78. package/src/session/session-context.ts +41 -2
  79. package/src/session/session-loader.ts +10 -5
  80. package/src/slash-commands/helpers/usage-report.ts +3 -1
  81. package/src/task/executor.ts +3 -0
  82. package/src/task/index.ts +52 -8
  83. package/src/task/structured-subagent.ts +3 -1
  84. package/src/task/types.ts +16 -0
  85. package/src/tools/hub/index.ts +1 -1
  86. package/src/tools/hub/jobs.ts +67 -8
  87. package/src/tools/index.ts +3 -0
  88. package/src/tools/report-tool-issue.ts +79 -28
  89. package/src/web/search/providers/firecrawl.ts +46 -13
  90. package/src/web/search/types.ts +5 -1
@@ -90,3 +90,43 @@ export async function sessionFilesFromDisk(): Promise<Map<string, string>> {
90
90
  for (const dir of artifactsDirsFromRegistry()) await scan(dir, 0);
91
91
  return found;
92
92
  }
93
+
94
+ /**
95
+ * Availability half of the `history://` resolution semantics: true when a
96
+ * transcript for `agentId` can be served from a registered ref's live session
97
+ * or retained session file, or from an on-disk `.jsonl` under a known
98
+ * artifacts dir. Hint surfaces use this so they only advertise
99
+ * `history://<agentId>` links that `HistoryProtocolHandler` can actually
100
+ * resolve. A retained sessionFile path is verified on disk before it counts,
101
+ * and probing never throws: a stale path or unreadable artifacts subtree
102
+ * reads as unavailable instead of disturbing the caller's delivery path.
103
+ */
104
+ export async function hasResolvableTranscript(agentId: string): Promise<boolean> {
105
+ try {
106
+ const registry = AgentRegistry.global();
107
+ const lower = agentId.toLowerCase();
108
+ let ref = registry.get(agentId);
109
+ if (ref?.kind === "advisor") ref = undefined;
110
+ ref ??= registry.list().find(candidate => candidate.kind !== "advisor" && candidate.id.toLowerCase() === lower);
111
+ if (ref?.session) return true;
112
+ if (ref?.sessionFile && (await isReadableFile(ref.sessionFile))) return true;
113
+ const files = await sessionFilesFromDisk();
114
+ for (const id of files.keys()) {
115
+ if (id.toLowerCase() === lower) return true;
116
+ }
117
+ } catch {
118
+ // Availability probing is advisory; any filesystem failure means the
119
+ // transcript cannot be promised, never that delivery should fail.
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /** True when `file` exists and is a regular file; never throws. */
125
+ async function isReadableFile(file: string): Promise<boolean> {
126
+ try {
127
+ const stat = await fs.stat(file);
128
+ return stat.isFile();
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
package/src/lsp/client.ts CHANGED
@@ -846,6 +846,29 @@ export async function getOrCreateClient(
846
846
  return clientPromise;
847
847
  }
848
848
 
849
+ /** Return an active or already-starting client without starting a language server. */
850
+ export async function getActiveOrPendingClient(
851
+ config: ServerConfig,
852
+ cwd: string,
853
+ signal?: AbortSignal,
854
+ ): Promise<LspClient | undefined> {
855
+ throwIfAborted(signal);
856
+ const client = clients.get(`${config.command}:${cwd}`);
857
+ if (client) {
858
+ client.lastActivity = Date.now();
859
+ return client;
860
+ }
861
+
862
+ const pending = clientLocks.get(`${config.command}:${cwd}`);
863
+ if (!pending) return undefined;
864
+ try {
865
+ return await untilAborted(signal, pending);
866
+ } catch {
867
+ throwIfAborted(signal);
868
+ return undefined;
869
+ }
870
+ }
871
+
849
872
  /**
850
873
  * Ensure a file is opened in the LSP client.
851
874
  * Sends didOpen notification if the file is not already tracked.
@@ -2,7 +2,7 @@
2
2
  * Biome CLI-based linter client.
3
3
  * Uses Biome's CLI with JSON output instead of LSP (which has stale diagnostics issues).
4
4
  */
5
- import path from "node:path";
5
+ import * as path from "node:path";
6
6
  import { logger } from "@oh-my-pi/pi-utils";
7
7
  import type { Diagnostic, DiagnosticSeverity, LinterClient, ServerConfig } from "../../lsp/types";
8
8
 
@@ -141,14 +141,13 @@ export class BiomeClient implements LinterClient {
141
141
  ) {}
142
142
 
143
143
  async format(filePath: string, content: string): Promise<string> {
144
- // Write content to file first
144
+ // Keep the standalone LinterClient contract: callers supply the content to
145
+ // format, regardless of what is currently on disk.
145
146
  await Bun.write(filePath, content);
146
147
 
147
- // Run biome format --write
148
148
  const result = await runBiome(["format", "--write", filePath], this.cwd, this.config.resolvedCommand);
149
149
 
150
150
  if (result.success) {
151
- // Read back formatted content
152
151
  return await Bun.file(filePath).text();
153
152
  }
154
153
 
package/src/lsp/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  ensureFileOpen,
21
21
  FileChangeType,
22
22
  getActiveClients,
23
+ getActiveOrPendingClient,
23
24
  getOrCreateClient,
24
25
  type LspServerStatus,
25
26
  notifySaved,
@@ -211,6 +212,7 @@ async function syncFileContent(
211
212
  cwd: string,
212
213
  servers: Array<[string, ServerConfig]>,
213
214
  signal?: AbortSignal,
215
+ createMissing = true,
214
216
  ): Promise<void> {
215
217
  throwIfAborted(signal);
216
218
  await Promise.allSettled(
@@ -219,11 +221,15 @@ async function syncFileContent(
219
221
  if (serverConfig.createClient) {
220
222
  return;
221
223
  }
222
- const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
224
+ const client = createMissing
225
+ ? await getOrCreateClient(serverConfig, cwd, undefined, signal)
226
+ : await getActiveOrPendingClient(serverConfig, cwd, signal);
227
+ if (!client) return;
223
228
  throwIfAborted(signal);
224
229
  await syncContent(client, absolutePath, content, signal);
225
230
  }),
226
231
  );
232
+ throwIfAborted(signal);
227
233
  }
228
234
 
229
235
  /**
@@ -239,6 +245,7 @@ async function notifyFileSaved(
239
245
  cwd: string,
240
246
  servers: Array<[string, ServerConfig]>,
241
247
  signal?: AbortSignal,
248
+ createMissing = true,
242
249
  ): Promise<void> {
243
250
  throwIfAborted(signal);
244
251
  await Promise.allSettled(
@@ -247,10 +254,14 @@ async function notifyFileSaved(
247
254
  if (serverConfig.createClient) {
248
255
  return;
249
256
  }
250
- const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
257
+ const client = createMissing
258
+ ? await getOrCreateClient(serverConfig, cwd, undefined, signal)
259
+ : await getActiveOrPendingClient(serverConfig, cwd, signal);
260
+ if (!client) return;
251
261
  await notifySaved(client, absolutePath, signal);
252
262
  }),
253
263
  );
264
+ throwIfAborted(signal);
254
265
  }
255
266
 
256
267
  // Cache config per cwd to avoid repeated file I/O
@@ -1333,7 +1344,8 @@ async function runLspWritethrough(
1333
1344
  // Capture diagnostic versions BEFORE syncing to detect stale diagnostics
1334
1345
  // Bound client creation by the writethrough budget: a hung/broken server
1335
1346
  // must not add its full init wait (30s default) to every edit.
1336
- const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers, 5_000, signal) : undefined;
1347
+ const minVersionsPromise = enableDiagnostics ? captureDiagnosticVersions(cwd, servers, 5_000, signal) : undefined;
1348
+ let minVersions = useCustomFormatter ? undefined : await minVersionsPromise;
1337
1349
  let expectedDocumentVersions: ServerVersionMap | undefined;
1338
1350
 
1339
1351
  let formatter: FileFormatResult | undefined;
@@ -1353,13 +1365,19 @@ async function runLspWritethrough(
1353
1365
  operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
1354
1366
  await untilAborted(operationSignal, async () => {
1355
1367
  if (useCustomFormatter) {
1356
- // Custom linters (e.g. Biome CLI) require on-disk input.
1368
+ // Custom linters operate on on-disk input; the shared pre-write also
1369
+ // supports implementations that inspect the file before formatting.
1357
1370
  await writeContent(content);
1358
- finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal);
1371
+ const [formattedContent, capturedVersions] = await Promise.all([
1372
+ formatContent(dst, content, cwd, customLinterServers, operationSignal),
1373
+ minVersionsPromise,
1374
+ ]);
1375
+ finalContent = formattedContent;
1376
+ minVersions = capturedVersions;
1359
1377
  formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
1360
1378
  await writeContent(finalContent);
1361
1379
  await notifyWriteCommitted(operationSignal);
1362
- await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
1380
+ await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal, enableDiagnostics);
1363
1381
  } else {
1364
1382
  // 1. Sync original content to LSP servers
1365
1383
  await syncFileContent(dst, content, cwd, lspServers, operationSignal);
@@ -1385,7 +1403,7 @@ async function runLspWritethrough(
1385
1403
  }
1386
1404
 
1387
1405
  // 5. Notify saved to LSP servers
1388
- await notifyFileSaved(dst, cwd, lspServers, operationSignal);
1406
+ await notifyFileSaved(dst, cwd, lspServers, operationSignal, !useCustomFormatter || enableDiagnostics);
1389
1407
  });
1390
1408
  synced = true;
1391
1409
  } catch {
package/src/main.ts CHANGED
@@ -134,6 +134,7 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
134
134
  // embedders need project-level opt-outs for reminder/prelude prompt injection.
135
135
  const HOST_DEFAULTED_SETTING_PATHS: SettingPath[] = [
136
136
  "task.isolation.mode",
137
+ "task.isolation.apply",
137
138
  "task.isolation.merge",
138
139
  "task.isolation.commits",
139
140
  "task.eager",
@@ -38,6 +38,7 @@ import type { MCPToolDetails } from "./tool-bridge";
38
38
  import { DeferredMCPTool, MCPTool } from "./tool-bridge";
39
39
  import type { MCPToolCache } from "./tool-cache";
40
40
  import type {
41
+ MCPAuthChallenge,
41
42
  MCPGetPromptResult,
42
43
  MCPPrompt,
43
44
  MCPRequestOptions,
@@ -160,6 +161,9 @@ export interface MCPDiscoverOptions {
160
161
  onStatus?: (event: McpConnectionStatusEvent) => void;
161
162
  }
162
163
 
164
+ /** Handles an MCP `WWW-Authenticate` challenge and returns refreshed config. */
165
+ export type MCPAuthHandler = (serverName: string, challenge: MCPAuthChallenge) => Promise<MCPServerConfig | undefined>;
166
+
163
167
  /**
164
168
  * MCP Server Manager.
165
169
  *
@@ -189,6 +193,7 @@ export class MCPManager {
189
193
  #pendingToolLoads = new Map<string, Promise<ToolLoadResult>>();
190
194
  #sources = new Map<string, SourceMeta>();
191
195
  #authStorage: AuthStorage | null = null;
196
+ #authHandler?: MCPAuthHandler;
192
197
  #onNotification?: (serverName: string, method: string, params: unknown) => void;
193
198
  #onToolsChanged?: (tools: CustomTool<TSchema, MCPToolDetails>[]) => void;
194
199
  #onResourcesChanged?: (serverName: string, uri: string) => void;
@@ -201,7 +206,7 @@ export class MCPManager {
201
206
  /** Preserved configs for reconnection after connection loss. */
202
207
  #serverConfigs = new Map<string, MCPServerConfig>();
203
208
  /**
204
- * Timestamps of recent `reconnectServer` invocations per server, used by the
209
+ * Timestamps of recent reconnectServer invocations per server, used by the
205
210
  * crash-storm circuit breaker (see {@link RECONNECT_BURST_LIMIT}).
206
211
  */
207
212
  #reconnectHistory = new Map<string, number[]>();
@@ -312,6 +317,11 @@ export class MCPManager {
312
317
  this.#authStorage = authStorage;
313
318
  }
314
319
 
320
+ /** Set the callback used to complete OAuth after a tool-level auth challenge. */
321
+ setAuthHandler(handler: MCPAuthHandler | undefined): void {
322
+ this.#authHandler = handler;
323
+ }
324
+
315
325
  /**
316
326
  * Discover and connect to all MCP servers from .mcp.json files.
317
327
  * Returns tools and any connection errors.
@@ -474,7 +484,8 @@ export class MCPManager {
474
484
  .then(async ({ connection, serverTools }) => {
475
485
  if (this.#pendingToolLoads.get(name) !== toolsPromise) return;
476
486
  this.#pendingToolLoads.delete(name);
477
- const reconnect = () => this.reconnectServer(name);
487
+ const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) =>
488
+ this.reconnectServer(name, options);
478
489
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
479
490
  this.#replaceServerTools(name, customTools);
480
491
  this.#onToolsChanged?.(this.#tools);
@@ -810,13 +821,15 @@ export class MCPManager {
810
821
  * the same server share one reconnection attempt. Returns the new
811
822
  * connection, or `null` if reconnection failed or the per-server crash
812
823
  * burst limit (see {@link RECONNECT_BURST_LIMIT}) is exceeded.
813
- *
814
824
  * @param options.manual - When `true`, resets the crash-burst window so a
815
825
  * user-driven retry (e.g. `/mcp reconnect`) is never blocked by an
816
826
  * earlier storm. Defaults to `false`; the transport `onClose` callback
817
827
  * and the per-tool-call retry path in `tool-bridge` MUST NOT set it.
818
828
  */
819
- async reconnectServer(name: string, options?: { manual?: boolean }): Promise<MCPServerConnection | null> {
829
+ async reconnectServer(
830
+ name: string,
831
+ options?: { manual?: boolean; authChallenge?: MCPAuthChallenge },
832
+ ): Promise<MCPServerConnection | null> {
820
833
  if (options?.manual) {
821
834
  this.#reconnectHistory.delete(name);
822
835
  }
@@ -828,7 +841,7 @@ export class MCPManager {
828
841
  return null;
829
842
  }
830
843
 
831
- const attempt = this.#doReconnect(name);
844
+ const attempt = this.#doReconnect(name, options?.authChallenge);
832
845
  this.#pendingReconnections.set(name, attempt);
833
846
  return attempt.finally(() => this.#pendingReconnections.delete(name));
834
847
  }
@@ -873,12 +886,30 @@ export class MCPManager {
873
886
  return false;
874
887
  }
875
888
 
876
- async #doReconnect(name: string): Promise<MCPServerConnection | null> {
889
+ async #doReconnect(name: string, authChallenge?: MCPAuthChallenge): Promise<MCPServerConnection | null> {
877
890
  const oldConnection = this.#connections.get(name);
878
- const config = oldConnection?.config ?? this.#serverConfigs.get(name);
891
+ let config = oldConnection?.config ?? this.#serverConfigs.get(name);
879
892
  const source = this.#sources.get(name) ?? oldConnection?._source;
880
893
  if (!config) return null;
881
894
 
895
+ if (authChallenge) {
896
+ if (!this.#authHandler) {
897
+ logger.error("MCP auth challenge cannot be handled; no auth handler is configured", {
898
+ path: `mcp:${name}`,
899
+ });
900
+ return null;
901
+ }
902
+ try {
903
+ const refreshedConfig = await this.#authHandler(name, authChallenge);
904
+ if (!refreshedConfig) return null;
905
+ config = refreshedConfig;
906
+ this.#serverConfigs.set(name, config);
907
+ } catch (error) {
908
+ logger.error("MCP auth challenge handling failed", { path: `mcp:${name}`, error });
909
+ return null;
910
+ }
911
+ }
912
+
882
913
  logger.debug("MCP reconnecting", { path: `mcp:${name}` });
883
914
 
884
915
  // Close the old transport without removing tools or notifying consumers.
@@ -987,7 +1018,7 @@ export class MCPManager {
987
1018
  };
988
1019
  try {
989
1020
  const serverTools = await listTools(connection);
990
- const reconnect = () => this.reconnectServer(name);
1021
+ const reconnect = (options?: { authChallenge?: MCPAuthChallenge }) => this.reconnectServer(name, options);
991
1022
  const customTools = MCPTool.fromTools(connection, serverTools, reconnect);
992
1023
  void this.toolCache?.set(name, config, serverTools);
993
1024
  this.#replaceServerTools(name, customTools);
package/src/mcp/render.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * Provides structured display of MCP tool calls and results,
5
5
  * showing args and output in JSON tree format similar to task tool.
6
6
  */
7
- import type { Component } from "@oh-my-pi/pi-tui";
7
+ import { type Component, Markdown } from "@oh-my-pi/pi-tui";
8
+ import { settings } from "../config/settings";
8
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
- import type { Theme } from "../modes/theme/theme";
10
+ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
10
11
  import {
11
12
  formatArgsInline,
12
13
  JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -48,6 +49,61 @@ export function renderMCPCall(args: Record<string, unknown>, theme: Theme, label
48
49
  );
49
50
  }
50
51
 
52
+ /** Render an MCP status/args prefix followed by Markdown-aware text output. */
53
+ function renderMarkdownMCPResult(
54
+ result: { details?: MCPToolDetails; isError?: boolean },
55
+ trimmedOutput: string,
56
+ truncationWarning: string | null,
57
+ options: RenderResultOptions,
58
+ theme: Theme,
59
+ args?: Record<string, unknown>,
60
+ ): Component {
61
+ const markdown = new Markdown(trimmedOutput, 0, 0, getMarkdownTheme(), {
62
+ color: text => theme.fg("toolOutput", text),
63
+ });
64
+ return {
65
+ render(contentWidth: number): readonly string[] {
66
+ const lines: string[] = [];
67
+ const isError = result.isError ?? result.details?.isError ?? false;
68
+ const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP";
69
+ lines.push(
70
+ renderStatusLine(
71
+ isError ? { icon: "error", title } : { iconOverride: theme.styledSymbol("tool.mcp", "accent"), title },
72
+ theme,
73
+ ),
74
+ );
75
+
76
+ if (options.expanded && args && Object.keys(args).length > 0) {
77
+ lines.push(theme.fg("dim", "Args"));
78
+ const tree = renderJsonTreeLines(
79
+ args,
80
+ theme,
81
+ JSON_TREE_MAX_DEPTH_EXPANDED,
82
+ JSON_TREE_MAX_LINES_EXPANDED,
83
+ JSON_TREE_SCALAR_LEN_EXPANDED,
84
+ );
85
+ lines.push(...tree.lines);
86
+ if (tree.truncated) lines.push(theme.fg("dim", "…"));
87
+ lines.push("");
88
+ }
89
+
90
+ const rendered = markdown.render(Math.max(1, contentWidth));
91
+ const maxOutputLines = options.expanded ? 12 : 4;
92
+ lines.push(...rendered.slice(0, maxOutputLines));
93
+ if (rendered.length > maxOutputLines) {
94
+ lines.push(
95
+ `${theme.fg("dim", `… ${rendered.length - maxOutputLines} more lines`)} ${formatExpandHint(theme, options.expanded, true)}`,
96
+ );
97
+ } else if (!options.expanded) {
98
+ lines.push(formatExpandHint(theme, options.expanded, true));
99
+ }
100
+ if (truncationWarning) lines.push(truncationWarning);
101
+ return lines;
102
+ },
103
+ invalidate(): void {},
104
+ };
105
+ }
106
+
51
107
  /**
52
108
  * Render MCP tool result.
53
109
  */
@@ -58,6 +114,24 @@ export function renderMCPResult(
58
114
  args?: Record<string, unknown>,
59
115
  ): Component {
60
116
  const { expanded } = options;
117
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
118
+ const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
119
+ const truncationWarning = result.details?.meta?.truncation
120
+ ? formatStyledTruncationWarning(result.details.meta, theme)
121
+ : null;
122
+ let parsedOutput: unknown;
123
+ let isJsonOutput = false;
124
+ if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
125
+ try {
126
+ parsedOutput = JSON.parse(trimmedOutput);
127
+ isJsonOutput = true;
128
+ } catch {
129
+ // Non-JSON text beginning with a bracket is still eligible for Markdown.
130
+ }
131
+ }
132
+ if (trimmedOutput && settings.get("mcp.renderMarkdownResults") && !isJsonOutput) {
133
+ return renderMarkdownMCPResult(result, trimmedOutput, truncationWarning, options, theme, args);
134
+ }
61
135
  return new WidthAwareText(
62
136
  contentWidth => {
63
137
  const lines: string[] = [];
@@ -86,46 +160,31 @@ export function renderMCPResult(
86
160
  lines.push(""); // Blank line before output
87
161
  }
88
162
 
89
- // Output section
90
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
91
- // Strip the LLM-facing spill notice before parsing/rendering: a spilled
92
- // result appends `[Showing… artifact://N]` to the body, which would break
93
- // JSON detection and bury the recovery link. Surface it as a styled warning
94
- // instead, mirroring the built-in read/bash/ssh/browser renderers.
95
- const trimmedOutput = stripOutputNotice(textContent, result.details?.meta).trimEnd();
96
- const truncationWarning = result.details?.meta?.truncation
97
- ? formatStyledTruncationWarning(result.details.meta, theme)
98
- : null;
163
+ // Output section. The body and spill metadata are normalized before
164
+ // component selection so the opt-in Markdown path can use its own renderer.
99
165
 
100
166
  if (!trimmedOutput) {
101
167
  lines.push(theme.fg("dim", "(no output)"));
102
168
  return lines.join("\n");
103
169
  }
104
170
 
105
- // Try to parse as JSON for structured display
106
- if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
107
- try {
108
- const parsed = JSON.parse(trimmedOutput);
109
- const maxDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
110
- const maxLines = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
111
- const maxScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
112
- const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen);
113
-
114
- if (tree.lines.length > 0) {
115
- for (const line of tree.lines) {
116
- lines.push(line);
117
- }
118
- // Always show expand hint when collapsed (expanded view shows longer values and deeper nesting)
119
- if (!expanded) {
120
- lines.push(formatExpandHint(theme, expanded, true));
121
- } else if (tree.truncated) {
122
- lines.push(theme.fg("dim", "…"));
123
- }
124
- if (truncationWarning) lines.push(truncationWarning);
125
- return lines.join("\n");
171
+ // Preserve the existing structured JSON renderer regardless of the
172
+ // Markdown preference; JSON trees remain more useful than styled source.
173
+ if (isJsonOutput) {
174
+ const maxDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
175
+ const maxLines = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
176
+ const maxScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
177
+ const tree = renderJsonTreeLines(parsedOutput, theme, maxDepth, maxLines, maxScalarLen);
178
+
179
+ if (tree.lines.length > 0) {
180
+ lines.push(...tree.lines);
181
+ if (!expanded) {
182
+ lines.push(formatExpandHint(theme, expanded, true));
183
+ } else if (tree.truncated) {
184
+ lines.push(theme.fg("dim", "…"));
126
185
  }
127
- } catch {
128
- // Fall through to raw output
186
+ if (truncationWarning) lines.push(truncationWarning);
187
+ return lines.join("\n");
129
188
  }
130
189
  }
131
190
 
@@ -22,10 +22,17 @@ import { normalizeLocalScheme } from "../tools/path-utils";
22
22
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
23
23
  import { callTool } from "./client";
24
24
  import { renderMCPCall, renderMCPResult } from "./render";
25
- import type { MCPContent, MCPServerConnection, MCPToolCallParams, MCPToolCallResult, MCPToolDefinition } from "./types";
25
+ import type {
26
+ MCPAuthChallenge,
27
+ MCPContent,
28
+ MCPServerConnection,
29
+ MCPToolCallParams,
30
+ MCPToolCallResult,
31
+ MCPToolDefinition,
32
+ } from "./types";
26
33
 
27
- /** Reconnect callback: tears down stale connection, returns new one or null. */
28
- export type MCPReconnect = () => Promise<MCPServerConnection | null>;
34
+ /** Reconnect callback: tears down a stale connection, optionally authorizing first. */
35
+ export type MCPReconnect = (options?: { authChallenge?: MCPAuthChallenge }) => Promise<MCPServerConnection | null>;
29
36
 
30
37
  /**
31
38
  * Network-level and stale-session errors that warrant a reconnect + single retry.
@@ -174,6 +181,8 @@ export interface MCPToolDetails {
174
181
  isError?: boolean;
175
182
  /** Raw content from MCP response */
176
183
  rawContent?: MCPContent[];
184
+ /** Structured metadata from the MCP response */
185
+ mcpMeta?: Record<string, unknown>;
177
186
  /** Provider ID (e.g., "claude", "mcp-json") */
178
187
  provider?: string;
179
188
  /** Provider display name (e.g., "Claude Code", "MCP Config") */
@@ -222,6 +231,7 @@ function buildResult(
222
231
  mcpToolName,
223
232
  isError: result.isError,
224
233
  rawContent: result.content,
234
+ mcpMeta: result._meta,
225
235
  provider,
226
236
  providerName,
227
237
  };
@@ -249,6 +259,51 @@ function buildErrorResult(
249
259
  };
250
260
  }
251
261
 
262
+ type MCPToolCallAttempt = {
263
+ connection: MCPServerConnection;
264
+ result?: MCPToolCallResult;
265
+ error?: unknown;
266
+ };
267
+
268
+ function getMcpAuthChallenge(result: MCPToolCallResult): MCPAuthChallenge | undefined {
269
+ if (!result.isError) return undefined;
270
+ const values = result._meta?.["mcp/www_authenticate"];
271
+ if (!Array.isArray(values)) return undefined;
272
+ const wwwAuthenticate = values.filter((value): value is string => typeof value === "string" && value.trim() !== "");
273
+ return wwwAuthenticate.length > 0 ? { wwwAuthenticate } : undefined;
274
+ }
275
+
276
+ async function callToolWithAuthRetry(
277
+ connection: MCPServerConnection,
278
+ toolName: string,
279
+ args: MCPToolArgs,
280
+ reconnect: MCPReconnect | undefined,
281
+ signal?: AbortSignal,
282
+ ): Promise<MCPToolCallAttempt> {
283
+ const result = await callTool(connection, toolName, args, { signal });
284
+ const authChallenge = getMcpAuthChallenge(result);
285
+ if (!authChallenge || !reconnect) return { connection, result };
286
+
287
+ let newConnection: MCPServerConnection | null;
288
+ try {
289
+ newConnection = await reconnectWithAbort(reconnect, signal, { authChallenge });
290
+ } catch (error) {
291
+ rethrowIfAborted(error, signal);
292
+ return { connection, error };
293
+ }
294
+ if (!newConnection) return { connection, result };
295
+
296
+ try {
297
+ return {
298
+ connection: newConnection,
299
+ result: await callTool(newConnection, toolName, args, { signal }),
300
+ };
301
+ } catch (error) {
302
+ rethrowIfAborted(error, signal);
303
+ return { connection: newConnection, error };
304
+ }
305
+ }
306
+
252
307
  /** Re-throw abort-related errors so they bypass error-result handling. */
253
308
  function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
254
309
  if (error instanceof ToolAbortError) throw error;
@@ -256,9 +311,13 @@ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
256
311
  if (signal?.aborted) throw new ToolAbortError();
257
312
  }
258
313
 
259
- async function reconnectWithAbort(reconnect: MCPReconnect, signal?: AbortSignal): Promise<MCPServerConnection | null> {
314
+ async function reconnectWithAbort(
315
+ reconnect: MCPReconnect,
316
+ signal?: AbortSignal,
317
+ options?: { authChallenge?: MCPAuthChallenge },
318
+ ): Promise<MCPServerConnection | null> {
260
319
  try {
261
- return await untilAborted(signal, reconnect);
320
+ return await untilAborted(signal, () => reconnect(options));
262
321
  } catch (error) {
263
322
  rethrowIfAborted(error, signal);
264
323
  return null;
@@ -379,8 +438,27 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
379
438
  const providerName = this.connection._source?.providerName;
380
439
 
381
440
  try {
382
- const result = await callTool(this.connection, this.tool.name, args, { signal });
383
- return buildResult(result, this.connection.name, this.tool.name, provider, providerName);
441
+ const attempt = await callToolWithAuthRetry(this.connection, this.tool.name, args, this.reconnect, signal);
442
+ if (attempt.error !== undefined) {
443
+ return buildErrorResult(attempt.error, this.connection.name, this.tool.name, provider, providerName);
444
+ }
445
+ if (!attempt.result) {
446
+ return buildErrorResult(
447
+ new Error("MCP tool call returned no result"),
448
+ this.connection.name,
449
+ this.tool.name,
450
+ provider,
451
+ providerName,
452
+ );
453
+ }
454
+ this.connection = attempt.connection;
455
+ return buildResult(
456
+ attempt.result,
457
+ attempt.connection.name,
458
+ this.tool.name,
459
+ attempt.connection._source?.provider ?? provider,
460
+ attempt.connection._source?.providerName ?? providerName,
461
+ );
384
462
  } catch (error) {
385
463
  rethrowIfAborted(error, signal);
386
464
  if (this.reconnect && isRetriableConnectionError(error)) {
@@ -483,13 +561,31 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
483
561
  const connection = await untilAborted(signal, () => this.getConnection());
484
562
  throwIfAborted(signal);
485
563
  try {
486
- const result = await callTool(connection, this.tool.name, args, { signal });
564
+ const attempt = await callToolWithAuthRetry(connection, this.tool.name, args, this.reconnect, signal);
565
+ if (attempt.error !== undefined) {
566
+ return buildErrorResult(
567
+ attempt.error,
568
+ this.serverName,
569
+ this.tool.name,
570
+ attempt.connection._source?.provider ?? provider,
571
+ attempt.connection._source?.providerName ?? providerName,
572
+ );
573
+ }
574
+ if (!attempt.result) {
575
+ return buildErrorResult(
576
+ new Error("MCP tool call returned no result"),
577
+ this.serverName,
578
+ this.tool.name,
579
+ provider,
580
+ providerName,
581
+ );
582
+ }
487
583
  return buildResult(
488
- result,
584
+ attempt.result,
489
585
  this.serverName,
490
586
  this.tool.name,
491
- connection._source?.provider ?? provider,
492
- connection._source?.providerName ?? providerName,
587
+ attempt.connection._source?.provider ?? provider,
588
+ attempt.connection._source?.providerName ?? providerName,
493
589
  );
494
590
  } catch (callError) {
495
591
  rethrowIfAborted(callError, signal);