@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
package/src/mcp/types.ts CHANGED
@@ -206,10 +206,17 @@ export interface MCPResourceContent {
206
206
 
207
207
  export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent;
208
208
 
209
+ /** Structured authentication challenge returned in a tool result. */
210
+ export interface MCPAuthChallenge {
211
+ /** Values from `_meta["mcp/www_authenticate"]`. */
212
+ readonly wwwAuthenticate: readonly string[];
213
+ }
214
+
209
215
  /** tools/call response */
210
216
  export interface MCPToolCallResult {
211
217
  content: MCPContent[];
212
218
  isError?: boolean;
219
+ _meta?: Record<string, unknown>;
213
220
  }
214
221
 
215
222
  // =============================================================================
@@ -76,26 +76,43 @@ function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): st
76
76
  return `${model} ${theme.getThinkingBorderColor(level)(display)}`;
77
77
  }
78
78
 
79
+ /** Format a resolved selector, preserving provider identity when requested. */
80
+ function formatResolvedModelBadge(resolved: string, preserveProvider = false): string {
81
+ // Model ids may themselves contain colons (`qwen3:14b`), so only treat the
82
+ // suffix as a thinking level when it parses as one.
83
+ const colon = resolved.lastIndexOf(":");
84
+ const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
85
+ const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
86
+ const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1);
87
+ return formatModelBadge(label, level);
88
+ }
89
+
79
90
  /**
80
91
  * Active model + reasoning level for a hub row: live session state when the
81
92
  * agent is attached, else the executor-reported `resolvedModel` selector
82
- * (`provider/id`, optionally `:<level>`). Undefined when neither is known
93
+ * (`provider/id`, optionally `:<level>`). Active retry fallbacks retain their
94
+ * provider and carry an explicit marker. Undefined when no model is known
83
95
  * (e.g. a parked historical agent restored from disk).
84
96
  */
85
97
  function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
98
+ const progress = observed?.progress;
99
+ // Prefer the live session's own resolved fallback selector; else honor the
100
+ // executor-reported fallback flag. The latter covers observer-only rows (no
101
+ // live session) AND live rows whose fallback armed no session retry state —
102
+ // e.g. the Fireworks Fast → base degrade, which emits `retry_fallback_applied`
103
+ // without populating `#activeRetryFallback`, so `retryFallbackModel` is undefined.
104
+ const fallbackSelector =
105
+ ref.session?.retryFallbackModel ?? (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined);
106
+ if (fallbackSelector) {
107
+ return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true)}`;
108
+ }
86
109
  const model = ref.session?.model;
87
110
  if (model) {
88
111
  const level = model.thinking ? ref.session?.thinkingLevel : undefined;
89
112
  return formatModelBadge(model.id, level);
90
113
  }
91
- const resolved = observed?.progress?.resolvedModel;
92
- if (!resolved) return undefined;
93
- // Model ids may themselves contain colons (`qwen3:14b`), so only treat the
94
- // suffix as a thinking level when it parses as one.
95
- const colon = resolved.lastIndexOf(":");
96
- const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
97
- const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
98
- return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level);
114
+ const resolved = progress?.resolvedModel;
115
+ return resolved ? formatResolvedModelBadge(resolved) : undefined;
99
116
  }
100
117
 
101
118
  /** Result of one host-backed transcript read for the Agent Hub viewer. */
@@ -60,6 +60,8 @@ export class OAuthSelectorComponent extends Container {
60
60
  /** First provider index of the visible ScrollView window (last #updateList). */
61
61
  #scrollStart = 0;
62
62
  #visibleCount = 0;
63
+ /** Visible list window, shrunk by {@link setMaxHeight} on short screens. */
64
+ #maxVisible = OAUTH_SELECTOR_MAX_VISIBLE;
63
65
  #mode: "login" | "logout";
64
66
  #authStorage: AuthStorage;
65
67
  #onSelectCallback: (providerId: string) => void;
@@ -111,6 +113,24 @@ export class OAuthSelectorComponent extends Container {
111
113
  this.#validationGeneration += 1;
112
114
  this.#stopSpinner();
113
115
  }
116
+
117
+ /**
118
+ * Fit the selector into `lines` rendered rows by shrinking the visible list
119
+ * window (the window is centered on the selection, so the selected row is
120
+ * always visible at any height). Prefers keeping the full chrome — borders,
121
+ * spacers, title, search status — but sacrifices the trailing spacer/border
122
+ * (clipped by the host) before dropping below three visible rows.
123
+ */
124
+ setMaxHeight(lines: number): void {
125
+ // Above the rows: LIST_ROW_OFFSET; below: search status + spacer + border.
126
+ const strict = lines - LIST_ROW_OFFSET - 3;
127
+ // Keeps only the rows + search status inside `lines`.
128
+ const relaxed = lines - LIST_ROW_OFFSET - 1;
129
+ const rows = Math.min(OAUTH_SELECTOR_MAX_VISIBLE, Math.max(1, strict, Math.min(relaxed, 3)));
130
+ if (rows === this.#maxVisible) return;
131
+ this.#maxVisible = rows;
132
+ this.#updateList();
133
+ }
114
134
  #hasSelectableAuth(providerId: string): boolean {
115
135
  return this.#mode === "logout" ? this.#authStorage.has(providerId) : this.#authStorage.hasAuth(providerId);
116
136
  }
@@ -227,7 +247,7 @@ export class OAuthSelectorComponent extends Container {
227
247
  }
228
248
 
229
249
  #isSearchEnabled(): boolean {
230
- return this.#allProviders.length > OAUTH_SELECTOR_MAX_VISIBLE;
250
+ return this.#allProviders.length > this.#maxVisible;
231
251
  }
232
252
 
233
253
  #shouldRenderSearchStatus(): boolean {
@@ -286,7 +306,7 @@ export class OAuthSelectorComponent extends Container {
286
306
  this.#listContainer.clear();
287
307
 
288
308
  const total = this.#filteredProviders.length;
289
- const maxVisible = OAUTH_SELECTOR_MAX_VISIBLE;
309
+ const maxVisible = this.#maxVisible;
290
310
  const startIndex =
291
311
  total <= maxVisible
292
312
  ? 0
@@ -381,7 +401,7 @@ export class OAuthSelectorComponent extends Container {
381
401
  // Page up - jump up by one visible page
382
402
  else if (matchesKey(keyData, "pageUp")) {
383
403
  if (this.#filteredProviders.length > 0) {
384
- this.#selectedIndex = Math.max(0, this.#selectedIndex - OAUTH_SELECTOR_MAX_VISIBLE);
404
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#maxVisible);
385
405
  }
386
406
  this.#statusMessage = undefined;
387
407
  this.#updateList();
@@ -389,10 +409,7 @@ export class OAuthSelectorComponent extends Container {
389
409
  // Page down - jump down by one visible page
390
410
  else if (matchesKey(keyData, "pageDown")) {
391
411
  if (this.#filteredProviders.length > 0) {
392
- this.#selectedIndex = Math.min(
393
- this.#filteredProviders.length - 1,
394
- this.#selectedIndex + OAUTH_SELECTOR_MAX_VISIBLE,
395
- );
412
+ this.#selectedIndex = Math.min(this.#filteredProviders.length - 1, this.#selectedIndex + this.#maxVisible);
396
413
  }
397
414
  this.#statusMessage = undefined;
398
415
  this.#updateList();
@@ -66,6 +66,7 @@ export interface SubmenuSettingDef extends BaseSettingDef {
66
66
 
67
67
  export interface TextInputSettingDef extends BaseSettingDef {
68
68
  type: "text";
69
+ secret: boolean;
69
70
  }
70
71
 
71
72
  export interface ProviderLimitsSettingDef extends BaseSettingDef {
@@ -176,11 +177,13 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
176
177
  if (options) {
177
178
  return { ...base, type: "submenu", options };
178
179
  }
179
- return { ...base, type: "text" };
180
+ return { ...base, type: "text", secret: ui.secret === true };
180
181
  }
181
182
 
182
183
  if (schemaType === "record") {
183
- return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } : { ...base, type: "text" };
184
+ return path === "providers.maxInFlightRequests"
185
+ ? { ...base, type: "providerLimits" }
186
+ : { ...base, type: "text", secret: false };
184
187
  }
185
188
 
186
189
  return null;
@@ -65,6 +65,7 @@ class TextInputSubmenu extends Container {
65
65
  label: string,
66
66
  description: string,
67
67
  currentValue: string,
68
+ secret: boolean,
68
69
  private readonly onSubmit: (value: string) => void,
69
70
  private readonly onCancel: () => void,
70
71
  ) {
@@ -78,6 +79,7 @@ class TextInputSubmenu extends Container {
78
79
  this.addChild(new Spacer(1));
79
80
 
80
81
  this.#input = new Input();
82
+ this.#input.mask = secret;
81
83
  if (currentValue) {
82
84
  this.#input.setValue(currentValue);
83
85
  }
@@ -293,6 +295,7 @@ class ProviderLimitsSubmenu extends Container {
293
295
  `Max In-Flight Requests: ${provider}`,
294
296
  "Enter a positive number. Decimals round down. Clear the field to make this provider unlimited.",
295
297
  limits[provider]?.toString() ?? "",
298
+ false,
296
299
  value => {
297
300
  const next = { ...limits };
298
301
  const trimmed = value.trim();
@@ -837,7 +840,7 @@ export class SettingsSelectorComponent implements Component {
837
840
  id: def.path,
838
841
  label: def.label,
839
842
  description: def.description,
840
- currentValue: this.#formatTextInputValue(def.path, currentValue),
843
+ currentValue: this.#formatTextInputValue(def, currentValue),
841
844
  submenu: (cv, done) => this.#createTextInput(def, cv, done),
842
845
  changed,
843
846
  };
@@ -992,12 +995,13 @@ export class SettingsSelectorComponent implements Component {
992
995
  def.label,
993
996
  def.description,
994
997
  this.#formatTextInputEditValue(def.path, settings.get(def.path)),
998
+ def.secret,
995
999
  value => {
996
1000
  // Empty string clears the setting; undefined-typed string settings
997
1001
  // store "" which the browser.ts expandPath ignores (no-op fallback).
998
1002
  this.#setSettingValue(def.path, value);
999
1003
  this.callbacks.onChange(def.path, settings.get(def.path));
1000
- wrappedDone(this.#formatTextInputValue(def.path, settings.get(def.path)));
1004
+ wrappedDone(this.#formatTextInputValue(def, settings.get(def.path)));
1001
1005
  },
1002
1006
  () => wrappedDone(),
1003
1007
  );
@@ -1022,9 +1026,9 @@ export class SettingsSelectorComponent implements Component {
1022
1026
  return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
1023
1027
  }
1024
1028
 
1025
- #formatTextInputValue(path: SettingPath, value: unknown): string {
1026
- if (path === "providers.maxInFlightRequests") return this.#formatProviderLimitsValue(value);
1027
- return this.#formatTextInputEditValue(path, value);
1029
+ #formatTextInputValue(def: SettingDef & { type: "text" }, value: unknown): string {
1030
+ if (def.secret) return value ? "••••••••" : "";
1031
+ return this.#formatTextInputEditValue(def.path, value);
1028
1032
  }
1029
1033
 
1030
1034
  #formatTextInputEditValue(_path: SettingPath, value: unknown): string {
@@ -1,7 +1,9 @@
1
- import { beforeAll, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeAll, describe, expect, it } from "bun:test";
2
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import { type Component, Text } from "@oh-my-pi/pi-tui";
4
- import { Settings } from "../../config/settings";
4
+ import { Settings, settings } from "../../config/settings";
5
+ import { renderMCPResult } from "../../mcp/render";
6
+ import type { MCPToolDetails } from "../../mcp/tool-bridge";
5
7
  import { getThemeByName, setThemeInstance, theme } from "../theme/theme";
6
8
  import { ToolExecutionComponent, type ToolExecutionUi } from "./tool-execution";
7
9
 
@@ -26,6 +28,10 @@ describe("ToolExecutionComponent custom renderer failures", () => {
26
28
  setThemeInstance(loaded);
27
29
  });
28
30
 
31
+ afterEach(() => {
32
+ settings.set("mcp.renderMarkdownResults", true);
33
+ });
34
+
29
35
  it("falls back to the custom tool label when a renderCall child component throws during render", () => {
30
36
  const tool: AgentTool = {
31
37
  name: "graphify_graph",
@@ -99,3 +105,58 @@ describe("ToolExecutionComponent custom renderer failures", () => {
99
105
  expect(text).toContain(rawResultText);
100
106
  });
101
107
  });
108
+
109
+ describe("MCP result Markdown rendering", () => {
110
+ const details: MCPToolDetails = {
111
+ serverName: "context-mode",
112
+ mcpToolName: "ctx_search",
113
+ };
114
+
115
+ beforeAll(async () => {
116
+ await Settings.init({ inMemory: true });
117
+ const loaded = await getThemeByName("dark");
118
+ if (!loaded) throw new Error("theme unavailable");
119
+ setThemeInstance(loaded);
120
+ });
121
+
122
+ afterEach(() => {
123
+ settings.set("mcp.renderMarkdownResults", true);
124
+ });
125
+
126
+ it("renders inline Markdown by default", () => {
127
+ const component = renderMCPResult(
128
+ { content: [{ type: "text", text: "**bold result** and `code`" }], details },
129
+ { expanded: true, isPartial: false },
130
+ theme,
131
+ );
132
+ const rendered = visibleText(component.render(80));
133
+
134
+ expect(rendered).toContain("bold result and code");
135
+ expect(rendered).not.toContain("**bold result**");
136
+ expect(rendered).not.toContain("`code`");
137
+ });
138
+
139
+ it("keeps Markdown syntax literal when the setting is disabled", () => {
140
+ settings.set("mcp.renderMarkdownResults", false);
141
+ const component = renderMCPResult(
142
+ { content: [{ type: "text", text: "**bold result**" }], details },
143
+ { expanded: true, isPartial: false },
144
+ theme,
145
+ );
146
+
147
+ expect(visibleText(component.render(80))).toContain("**bold result**");
148
+ });
149
+
150
+ it("preserves structured JSON rendering when Markdown is enabled", () => {
151
+ settings.set("mcp.renderMarkdownResults", true);
152
+ const component = renderMCPResult(
153
+ { content: [{ type: "text", text: '{"status":"**ok**"}' }], details },
154
+ { expanded: true, isPartial: false },
155
+ theme,
156
+ );
157
+ const rendered = visibleText(component.render(80));
158
+
159
+ expect(rendered).toContain("status");
160
+ expect(rendered).toContain("**ok**");
161
+ });
162
+ });
@@ -1636,17 +1636,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
1636
1636
  }
1637
1637
 
1638
1638
  function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
1639
- const absolute = limits
1640
- .map(limit => limit.window?.resetsAt)
1641
- .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs);
1642
- if (absolute.length === 0) return null;
1643
- const offsets = absolute.map(value => value - nowMs);
1639
+ const windows = limits
1640
+ .map(limit => limit.window)
1641
+ .filter(
1642
+ (window): window is NonNullable<UsageLimit["window"]> =>
1643
+ window?.resetsAt !== undefined && Number.isFinite(window.resetsAt) && window.resetsAt > nowMs,
1644
+ );
1645
+ if (windows.length === 0) return null;
1646
+ // Use the shared verb when every contributing window agrees (e.g. all "tick");
1647
+ // mixed or absent labels fall back to the generic "resets".
1648
+ const labels = new Set(windows.map(window => window.resetLabel ?? "resets"));
1649
+ const verb = labels.size === 1 ? [...labels][0]! : "resets";
1650
+ const offsets = windows.map(window => window.resetsAt! - nowMs);
1644
1651
  const minReset = Math.min(...offsets);
1645
1652
  const maxReset = Math.max(...offsets);
1646
1653
  if (maxReset - minReset > 60_000) {
1647
- return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
1654
+ return `${verb} in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
1648
1655
  }
1649
- return `resets in ${formatDuration(minReset)}`;
1656
+ return `${verb} in ${formatDuration(minReset)}`;
1650
1657
  }
1651
1658
  /**
1652
1659
  * Compact one-line quota summary for a single advisor's provider.
@@ -47,7 +47,7 @@ import {
47
47
  searchSmitheryRegistry,
48
48
  toConfigName,
49
49
  } from "../../mcp/smithery-registry";
50
- import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
50
+ import type { MCPAuthChallenge, MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
51
51
  import { shortenPath } from "../../tools/render-utils";
52
52
  import { urlHyperlinkAlways } from "../../tui";
53
53
  import { copyToClipboard } from "../../utils/clipboard";
@@ -1042,7 +1042,10 @@ export class MCPCommandController {
1042
1042
  return next;
1043
1043
  }
1044
1044
 
1045
- async #resolveOAuthEndpointsFromServer(config: MCPServerConfig): Promise<OAuthEndpoints> {
1045
+ async #resolveOAuthEndpointsFromServer(
1046
+ config: MCPServerConfig,
1047
+ authChallenge?: MCPAuthChallenge,
1048
+ ): Promise<OAuthEndpoints> {
1046
1049
  // Stdio servers manage credentials inside the child process; OMP's OAuth
1047
1050
  // flow only applies to http/sse transports. Without this guard the
1048
1051
  // unauthenticated preflight below spawns the child, which happily reuses
@@ -1068,13 +1071,20 @@ export class MCPCommandController {
1068
1071
  connectionError = error as Error;
1069
1072
  }
1070
1073
 
1071
- // Server connected fine without auth — reauth is not needed
1072
- if (connectionSucceeded) {
1074
+ // Server connected fine without auth — reauth is not needed. A tool-level
1075
+ // challenge overrides this: servers may allow the anonymous handshake yet
1076
+ // protect individual tool calls with `_meta["mcp/www_authenticate"]`.
1077
+ if (connectionSucceeded && !authChallenge) {
1073
1078
  throw new Error("Server connection succeeded without OAuth; reauthorization is not required.");
1074
1079
  }
1075
1080
 
1076
- // Analyze the connection error to extract OAuth endpoints
1077
- const authResult = analyzeAuthError(connectionError!, "url" in config ? config.url : undefined);
1081
+ // Tool calls can carry richer RFC 6750/RFC 9728 hints than the original
1082
+ // connection error. Feed those hints through the same analyzer so
1083
+ // resource_metadata and scope reach protected-resource discovery.
1084
+ const authError = authChallenge
1085
+ ? new Error(`${connectionError?.message ?? "HTTP 401"}\n${authChallenge.wwwAuthenticate.join("\n")}`)
1086
+ : connectionError!;
1087
+ const authResult = analyzeAuthError(authError, "url" in config ? config.url : undefined);
1078
1088
  let oauth = authResult.authType === "oauth" ? (authResult.oauth ?? null) : null;
1079
1089
 
1080
1090
  if (!oauth && (config.type === "http" || config.type === "sse") && config.url) {
@@ -1678,21 +1688,29 @@ export class MCPCommandController {
1678
1688
  }
1679
1689
  }
1680
1690
 
1681
- async #handleReauth(name: string | undefined): Promise<void> {
1691
+ /** Reauthorize a server after a tool-level OAuth challenge. */
1692
+ async handleMCPAuthChallenge(name: string, challenge: MCPAuthChallenge): Promise<MCPServerConfig | undefined> {
1693
+ return this.#handleReauth(name, { silent: true, reload: false, authChallenge: challenge });
1694
+ }
1695
+
1696
+ async #handleReauth(
1697
+ name: string | undefined,
1698
+ options: { silent?: boolean; reload?: boolean; authChallenge?: MCPAuthChallenge } = {},
1699
+ ): Promise<MCPServerConfig | undefined> {
1682
1700
  if (!name) {
1683
- this.ctx.showError("Server name required. Usage: /mcp reauth <name>");
1701
+ if (!options.silent) this.ctx.showError("Server name required. Usage: /mcp reauth <name>");
1684
1702
  return;
1685
1703
  }
1686
1704
 
1687
1705
  try {
1688
1706
  const found = await this.#resolveServerForAuth(name);
1689
1707
  if (!found) {
1690
- this.ctx.showError(`Server "${name}" not found.`);
1708
+ if (!options.silent) this.ctx.showError(`Server "${name}" not found.`);
1691
1709
  return;
1692
1710
  }
1693
1711
 
1694
1712
  if (found.config.enabled === false) {
1695
- this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`);
1713
+ if (!options.silent) this.ctx.showError(`Server "${name}" is disabled. Run /mcp enable ${name} first.`);
1696
1714
  return;
1697
1715
  }
1698
1716
 
@@ -1705,7 +1723,7 @@ export class MCPCommandController {
1705
1723
  // happened yet if the server turns out not to need (or support) OAuth.
1706
1724
  // Use the same env-expanded config shape runtime discovery passes to
1707
1725
  // MCPManager; the raw file value may contain `${...}` placeholders.
1708
- const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig);
1726
+ const oauth = await this.#resolveOAuthEndpointsFromServer(runtimeBaseConfig, options.authChallenge);
1709
1727
  const serverUrl =
1710
1728
  runtimeBaseConfig.type === "http" || runtimeBaseConfig.type === "sse" ? runtimeBaseConfig.url : undefined;
1711
1729
  // A user-supplied client secret may live in either block (the wizard
@@ -1719,7 +1737,9 @@ export class MCPCommandController {
1719
1737
  const userClientSecret = found.config.oauth?.clientSecret ?? currentAuth?.clientSecret;
1720
1738
  const flowClientSecret = userClientSecret ?? storedClientSecret ?? "";
1721
1739
 
1722
- this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1740
+ if (!options.silent) {
1741
+ this.#showMessage(["", theme.fg("muted", `Reauthorizing "${name}"...`), ""].join("\n"));
1742
+ }
1723
1743
 
1724
1744
  const currentAuthResource = currentAuth?.resource ? expandEnvVarsDeep(currentAuth.resource) : undefined;
1725
1745
  const oauthResource =
@@ -1755,39 +1775,49 @@ export class MCPCommandController {
1755
1775
  // Definition-only entries resolve through the url-keyed binding alone;
1756
1776
  // skip the write-back so a committed project mcp.json stays clean.
1757
1777
  const urlKeyedId = serverUrl ? mcpOAuthCredentialId(serverUrl) : undefined;
1758
- if (currentAuth || oauthResult.credentialId !== urlKeyedId) {
1759
- const updated = this.#persistOAuthResult(baseConfig, oauthResult, {
1760
- tokenUrl: oauth.tokenUrl,
1761
- clientId: oauth.clientId,
1762
- userClientSecret,
1763
- resource: oauthResource,
1764
- stripSameOriginResource: oauthResourceIsFallback,
1765
- });
1766
- await updateMCPServer(found.filePath, name, updated);
1767
- }
1768
- await this.#reloadMCP();
1769
- const state = await this.#waitForServerConnectionWithAnimation(name);
1778
+ const shouldPersist = currentAuth || oauthResult.credentialId !== urlKeyedId;
1779
+ const updatedConfig = shouldPersist
1780
+ ? this.#persistOAuthResult(baseConfig, oauthResult, {
1781
+ tokenUrl: oauth.tokenUrl,
1782
+ clientId: oauth.clientId,
1783
+ userClientSecret,
1784
+ resource: oauthResource,
1785
+ stripSameOriginResource: oauthResourceIsFallback,
1786
+ })
1787
+ : baseConfig;
1788
+ if (shouldPersist) {
1789
+ await updateMCPServer(found.filePath, name, updatedConfig);
1790
+ }
1791
+ if (options.reload !== false) {
1792
+ await this.#reloadMCP();
1793
+ const state = await this.#waitForServerConnectionWithAnimation(name);
1770
1794
 
1771
- const lines = [
1772
- "",
1773
- theme.fg("success", `✓ Reauthorized "${name}" (${found.scope} config)`),
1774
- "",
1775
- ` Status: ${
1776
- state === "connected"
1777
- ? theme.fg("success", "connected")
1778
- : state === "connecting"
1779
- ? theme.fg("muted", "connecting")
1780
- : theme.fg("warning", "not connected")
1781
- }`,
1782
- "",
1783
- ];
1784
- this.#showMessage(lines.join("\n"));
1795
+ const lines = [
1796
+ "",
1797
+ theme.fg("success", `✓ Reauthorized "${name}" (${found.scope} config)`),
1798
+ "",
1799
+ ` Status: ${
1800
+ state === "connected"
1801
+ ? theme.fg("success", "connected")
1802
+ : state === "connecting"
1803
+ ? theme.fg("muted", "connecting")
1804
+ : theme.fg("warning", "not connected")
1805
+ }`,
1806
+ "",
1807
+ ];
1808
+ this.#showMessage(lines.join("\n"));
1809
+ }
1810
+ return updatedConfig;
1785
1811
  } catch (error) {
1786
1812
  if (error instanceof MCPOAuthCancelledError) {
1787
- this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
1813
+ if (!options.silent) this.ctx.showStatus(`Reauthorization cancelled for "${name}"`);
1788
1814
  return;
1789
1815
  }
1790
- this.ctx.showError(`Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`);
1816
+ if (!options.silent) {
1817
+ this.ctx.showError(
1818
+ `Failed to reauthorize server: ${error instanceof Error ? error.message : String(error)}`,
1819
+ );
1820
+ }
1791
1821
  }
1792
1822
  }
1793
1823
 
@@ -656,6 +656,9 @@ export class InteractiveMode implements InteractiveModeContext {
656
656
  this.#toolUiContextSetter = setToolUIContext;
657
657
  this.lspServers = lspServers;
658
658
  this.mcpManager = mcpManager;
659
+ this.mcpManager?.setAuthHandler((serverName, challenge) =>
660
+ new MCPCommandController(this).handleMCPAuthChallenge(serverName, challenge),
661
+ );
659
662
  this.#eventBus = eventBus;
660
663
  if (eventBus) {
661
664
  this.#eventBusUnsubscribers.push(