@oh-my-pi/pi-coding-agent 15.4.3 → 15.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +75 -5
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/auth-broker-cli.d.ts +1 -1
  4. package/dist/types/commands/launch.d.ts +8 -0
  5. package/dist/types/config/settings-schema.d.ts +42 -1
  6. package/dist/types/edit/index.d.ts +2 -0
  7. package/dist/types/extensibility/custom-tools/types.d.ts +8 -2
  8. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  9. package/dist/types/lsp/index.d.ts +9 -1
  10. package/dist/types/mcp/client.d.ts +2 -1
  11. package/dist/types/mcp/oauth-discovery.d.ts +4 -3
  12. package/dist/types/mcp/timeout.d.ts +9 -0
  13. package/dist/types/mcp/types.d.ts +1 -1
  14. package/dist/types/sdk.d.ts +2 -0
  15. package/dist/types/session/streaming-output.d.ts +1 -1
  16. package/dist/types/task/index.d.ts +2 -0
  17. package/dist/types/task/types.d.ts +4 -0
  18. package/dist/types/tools/approval.d.ts +46 -0
  19. package/dist/types/tools/ask.d.ts +1 -0
  20. package/dist/types/tools/ast-edit.d.ts +2 -0
  21. package/dist/types/tools/ast-grep.d.ts +1 -0
  22. package/dist/types/tools/bash.d.ts +11 -1
  23. package/dist/types/tools/browser.d.ts +2 -0
  24. package/dist/types/tools/calculator.d.ts +1 -0
  25. package/dist/types/tools/checkpoint.d.ts +2 -0
  26. package/dist/types/tools/debug.d.ts +9 -1
  27. package/dist/types/tools/eval.d.ts +2 -0
  28. package/dist/types/tools/find.d.ts +10 -0
  29. package/dist/types/tools/gh.d.ts +2 -1
  30. package/dist/types/tools/hindsight-recall.d.ts +1 -0
  31. package/dist/types/tools/hindsight-reflect.d.ts +1 -0
  32. package/dist/types/tools/hindsight-retain.d.ts +1 -0
  33. package/dist/types/tools/inspect-image.d.ts +1 -0
  34. package/dist/types/tools/irc.d.ts +1 -0
  35. package/dist/types/tools/job.d.ts +1 -0
  36. package/dist/types/tools/read.d.ts +1 -0
  37. package/dist/types/tools/recipe/index.d.ts +1 -0
  38. package/dist/types/tools/render-mermaid.d.ts +1 -0
  39. package/dist/types/tools/resolve.d.ts +1 -0
  40. package/dist/types/tools/search-tool-bm25.d.ts +1 -0
  41. package/dist/types/tools/search.d.ts +1 -0
  42. package/dist/types/tools/ssh.d.ts +2 -0
  43. package/dist/types/tools/todo-write.d.ts +1 -0
  44. package/dist/types/tools/write.d.ts +2 -0
  45. package/dist/types/tools/yield.d.ts +1 -0
  46. package/dist/types/web/search/index.d.ts +1 -0
  47. package/package.json +7 -7
  48. package/src/cli/args.ts +14 -0
  49. package/src/cli/auth-broker-cli.ts +171 -22
  50. package/src/commands/auth-broker.ts +3 -0
  51. package/src/commands/launch.ts +16 -0
  52. package/src/config/mcp-schema.json +2 -2
  53. package/src/config/model-registry.ts +19 -4
  54. package/src/config/settings-schema.ts +59 -1
  55. package/src/config/settings.ts +2 -1
  56. package/src/dap/session.ts +35 -2
  57. package/src/discovery/builtin.ts +2 -2
  58. package/src/discovery/mcp-json.ts +1 -1
  59. package/src/edit/index.ts +26 -0
  60. package/src/edit/modes/patch.ts +1 -1
  61. package/src/edit/streaming.ts +12 -2
  62. package/src/exec/bash-executor.ts +6 -2
  63. package/src/extensibility/custom-commands/bundled/review/index.ts +18 -14
  64. package/src/extensibility/custom-tools/types.ts +16 -2
  65. package/src/extensibility/extensions/wrapper.ts +36 -1
  66. package/src/extensibility/hooks/types.ts +8 -1
  67. package/src/hashline/apply.ts +47 -2
  68. package/src/internal-urls/docs-index.generated.ts +8 -7
  69. package/src/lsp/edits.ts +82 -29
  70. package/src/lsp/index.ts +38 -1
  71. package/src/lsp/utils.ts +1 -1
  72. package/src/main.ts +6 -0
  73. package/src/mcp/client.ts +8 -6
  74. package/src/mcp/oauth-discovery.ts +120 -32
  75. package/src/mcp/oauth-flow.ts +34 -6
  76. package/src/mcp/timeout.ts +59 -0
  77. package/src/mcp/transports/http.ts +42 -44
  78. package/src/mcp/transports/stdio.ts +8 -5
  79. package/src/mcp/types.ts +1 -1
  80. package/src/modes/components/hook-editor.ts +11 -3
  81. package/src/modes/components/mcp-add-wizard.ts +6 -2
  82. package/src/modes/components/model-selector.ts +33 -11
  83. package/src/modes/controllers/command-controller.ts +6 -4
  84. package/src/modes/controllers/mcp-command-controller.ts +8 -4
  85. package/src/prompts/review-custom-request.md +22 -0
  86. package/src/prompts/review-headless-request.md +16 -0
  87. package/src/prompts/review-request.md +2 -3
  88. package/src/prompts/system/project-prompt.md +4 -0
  89. package/src/prompts/tools/debug.md +1 -0
  90. package/src/prompts/tools/find.md +4 -2
  91. package/src/prompts/tools/hashline.md +1 -0
  92. package/src/sdk.ts +47 -73
  93. package/src/session/agent-session.ts +93 -27
  94. package/src/session/streaming-output.ts +1 -1
  95. package/src/slash-commands/helpers/usage-report.ts +3 -1
  96. package/src/task/executor.ts +11 -0
  97. package/src/task/index.ts +19 -0
  98. package/src/task/render.ts +12 -2
  99. package/src/task/types.ts +4 -0
  100. package/src/tools/approval.ts +185 -0
  101. package/src/tools/ask.ts +1 -0
  102. package/src/tools/ast-edit.ts +25 -1
  103. package/src/tools/ast-grep.ts +1 -0
  104. package/src/tools/bash.ts +69 -1
  105. package/src/tools/browser/tab-supervisor.ts +1 -1
  106. package/src/tools/browser.ts +15 -0
  107. package/src/tools/calculator.ts +1 -0
  108. package/src/tools/checkpoint.ts +2 -0
  109. package/src/tools/debug.ts +38 -0
  110. package/src/tools/eval.ts +15 -0
  111. package/src/tools/find.ts +17 -8
  112. package/src/tools/gh.ts +21 -1
  113. package/src/tools/hindsight-recall.ts +1 -0
  114. package/src/tools/hindsight-reflect.ts +1 -0
  115. package/src/tools/hindsight-retain.ts +1 -0
  116. package/src/tools/image-gen.ts +1 -0
  117. package/src/tools/inspect-image.ts +1 -0
  118. package/src/tools/irc.ts +1 -0
  119. package/src/tools/job.ts +1 -0
  120. package/src/tools/path-utils.ts +14 -1
  121. package/src/tools/read.ts +1 -0
  122. package/src/tools/recipe/index.ts +1 -0
  123. package/src/tools/render-mermaid.ts +1 -0
  124. package/src/tools/report-tool-issue.ts +1 -0
  125. package/src/tools/resolve.ts +1 -0
  126. package/src/tools/review.ts +1 -0
  127. package/src/tools/search-tool-bm25.ts +1 -0
  128. package/src/tools/search.ts +1 -0
  129. package/src/tools/ssh.ts +8 -0
  130. package/src/tools/todo-write.ts +1 -0
  131. package/src/tools/write.ts +12 -1
  132. package/src/tools/yield.ts +1 -0
  133. package/src/web/search/index.ts +2 -0
package/src/lsp/edits.ts CHANGED
@@ -127,39 +127,92 @@ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promi
127
127
  export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Promise<string[]> {
128
128
  const applied: string[] = [];
129
129
 
130
- // Coalesce all text edits per URI before applying so a single file's edits
131
- // are applied in one pass against a single snapshot — multiple TextDocumentEdits
132
- // for the same URI would otherwise read stale positions on subsequent writes.
133
- const textEditsByUri = flattenWorkspaceTextEdits(edit);
134
- for (const [uri, textEdits] of textEditsByUri) {
135
- const filePath = uriToFile(uri);
136
- await applyTextEdits(filePath, textEdits);
137
- applied.push(`Applied ${textEdits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
138
- }
139
-
140
- // Resource operations (create/rename/delete) preserve their original order.
141
130
  if (edit.documentChanges) {
131
+ // Walk documentChanges in original order. Accumulate text edits per-URI and
132
+ // flush them before any resource op that touches the same URI (or, for folder
133
+ // rename/delete, any descendant URI) so that renames, creates, and deletes
134
+ // always see the correct prior file state.
135
+ const pending = new Map<string, TextEdit[]>();
136
+
137
+ const flushUri = async (uri: string) => {
138
+ const edits = pending.get(uri);
139
+ if (!edits) return;
140
+ pending.delete(uri);
141
+ const filePath = uriToFile(uri);
142
+ await applyTextEdits(filePath, edits);
143
+ applied.push(`Applied ${edits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
144
+ };
145
+
146
+ // Flush the exact URI plus every pending descendant (for folder-level
147
+ // resource ops where the queued edits target child files of the target).
148
+ const flushSubtree = async (uri: string) => {
149
+ const prefix = uri.endsWith("/") ? uri : `${uri}/`;
150
+ const matches: string[] = [];
151
+ for (const candidate of pending.keys()) {
152
+ if (candidate === uri || candidate.startsWith(prefix)) matches.push(candidate);
153
+ }
154
+ for (const target of matches) {
155
+ await flushUri(target);
156
+ }
157
+ };
158
+
142
159
  for (const change of edit.documentChanges) {
143
- if (!("kind" in change) || !change.kind) continue;
144
- if (change.kind === "create") {
145
- const createOp = change as CreateFile;
146
- const filePath = uriToFile(createOp.uri);
147
- await Bun.write(filePath, "");
148
- applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
149
- } else if (change.kind === "rename") {
150
- const renameOp = change as RenameFile;
151
- const oldPath = uriToFile(renameOp.oldUri);
152
- const newPath = uriToFile(renameOp.newUri);
153
- await fs.mkdir(path.dirname(newPath), { recursive: true });
154
- await fs.rename(oldPath, newPath);
155
- applied.push(`Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`);
156
- } else if (change.kind === "delete") {
157
- const deleteOp = change as DeleteFile;
158
- const filePath = uriToFile(deleteOp.uri);
159
- await fs.rm(filePath, { recursive: true });
160
- applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
160
+ if ("textDocument" in change && change.textDocument && "edits" in change && change.edits) {
161
+ const tdc = change as TextDocumentEdit;
162
+ const uri = tdc.textDocument.uri;
163
+ const textEdits = tdc.edits.filter((e): e is TextEdit => "range" in e && "newText" in e);
164
+ if (textEdits.length > 0) {
165
+ const prev = pending.get(uri);
166
+ if (prev) prev.push(...textEdits);
167
+ else pending.set(uri, [...textEdits]);
168
+ }
169
+ } else if ("kind" in change && change.kind) {
170
+ if (change.kind === "create") {
171
+ const createOp = change as CreateFile;
172
+ await flushUri(createOp.uri);
173
+ const filePath = uriToFile(createOp.uri);
174
+ await Bun.write(filePath, "");
175
+ applied.push(`Created ${formatPathRelativeToCwd(filePath, cwd)}`);
176
+ } else if (change.kind === "rename") {
177
+ const renameOp = change as RenameFile;
178
+ // Per LSP §3.16.2 documentChanges are applied in declared order.
179
+ // Flush both the source subtree (so prior edits land before the move)
180
+ // AND the destination subtree (so prior edits land on whatever exists
181
+ // at newUri before the rename overwrites/replaces it — relevant under
182
+ // `options.overwrite` and `options.ignoreIfExists`).
183
+ await flushSubtree(renameOp.oldUri);
184
+ await flushSubtree(renameOp.newUri);
185
+ const oldPath = uriToFile(renameOp.oldUri);
186
+ const newPath = uriToFile(renameOp.newUri);
187
+ await fs.mkdir(path.dirname(newPath), { recursive: true });
188
+ await fs.rename(oldPath, newPath);
189
+ applied.push(
190
+ `Renamed ${formatPathRelativeToCwd(oldPath, cwd)} → ${formatPathRelativeToCwd(newPath, cwd)}`,
191
+ );
192
+ } else if (change.kind === "delete") {
193
+ const deleteOp = change as DeleteFile;
194
+ await flushSubtree(deleteOp.uri);
195
+ const filePath = uriToFile(deleteOp.uri);
196
+ await fs.rm(filePath, { recursive: true });
197
+ applied.push(`Deleted ${formatPathRelativeToCwd(filePath, cwd)}`);
198
+ }
161
199
  }
162
200
  }
201
+
202
+ // Flush text edits not followed by a resource op.
203
+ for (const [uri] of pending) {
204
+ await flushUri(uri);
205
+ }
206
+ } else if (edit.changes) {
207
+ // Legacy changes-map path: apply all text edits in one pass.
208
+ const changes = edit.changes;
209
+ for (const uri in changes) {
210
+ const textEdits = changes[uri];
211
+ if (textEdits.length === 0) continue;
212
+ const filePath = uriToFile(uri);
213
+ await applyTextEdits(filePath, textEdits);
214
+ applied.push(`Applied ${textEdits.length} edit(s) to ${formatPathRelativeToCwd(filePath, cwd)}`);
215
+ }
163
216
  }
164
217
 
165
218
  return applied;
package/src/lsp/index.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import * as fs from "node:fs";
2
2
  import path from "node:path";
3
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
+ import type {
4
+ AgentTool,
5
+ AgentToolContext,
6
+ AgentToolResult,
7
+ AgentToolUpdateCallback,
8
+ ToolApprovalDecision,
9
+ } from "@oh-my-pi/pi-agent-core";
4
10
  import { logger, once, prompt, untilAborted } from "@oh-my-pi/pi-utils";
5
11
  import type { BunFile } from "bun";
6
12
  import { type Theme, theme } from "../modes/theme/theme";
7
13
  import lspDescription from "../prompts/tools/lsp.md" with { type: "text" };
8
14
  import type { ToolSession } from "../tools";
15
+ import { truncateForPrompt } from "../tools/approval";
9
16
  import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils";
10
17
  import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors";
11
18
  import { clampTimeout } from "../tools/tool-timeouts";
@@ -79,6 +86,23 @@ import {
79
86
  export type { LspServerStatus } from "./client";
80
87
  export type { LspToolDetails } from "./types";
81
88
 
89
+ /**
90
+ * LSP actions that do not mutate the workspace or language-server state.
91
+ * Anything not in this set (rename, code_actions with apply, rename_file,
92
+ * reload, raw request, etc.) is classified as write-tier.
93
+ */
94
+ export const LSP_READONLY_ACTIONS: ReadonlySet<string> = new Set([
95
+ "diagnostics",
96
+ "definition",
97
+ "type_definition",
98
+ "implementation",
99
+ "references",
100
+ "hover",
101
+ "symbols",
102
+ "status",
103
+ "capabilities",
104
+ ]);
105
+
82
106
  export interface LspStartupServerInfo {
83
107
  name: string;
84
108
  status: "connecting" | "ready" | "error";
@@ -1174,6 +1198,19 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions
1174
1198
  */
1175
1199
  export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Theme> {
1176
1200
  readonly name = "lsp";
1201
+ readonly approval = (args: unknown): ToolApprovalDecision => {
1202
+ const rawAction = (args as Partial<LspParams>).action;
1203
+ const action = typeof rawAction === "string" ? rawAction.toLowerCase() : "";
1204
+ return LSP_READONLY_ACTIONS.has(action) ? "read" : "write";
1205
+ };
1206
+ readonly formatApprovalDetails = (args: unknown): string[] => {
1207
+ const params = args as Partial<LspParams>;
1208
+ const lines = [`Action: ${typeof params.action === "string" ? params.action : "(missing)"}`];
1209
+ if (typeof params.file === "string" && params.file.length > 0) {
1210
+ lines.push(`File: ${truncateForPrompt(params.file)}`);
1211
+ }
1212
+ return lines;
1213
+ };
1177
1214
  readonly label = "LSP";
1178
1215
  readonly loadMode = "discoverable";
1179
1216
  readonly summary = "Query LSP (language server) for diagnostics, hover info, and references";
package/src/lsp/utils.ts CHANGED
@@ -581,7 +581,7 @@ function firstNonWhitespaceColumn(lineText: string): number {
581
581
  return match ? (match.index ?? 0) : 0;
582
582
  }
583
583
 
584
- const BARE_IDENTIFIER_RE = /^[A-Za-z_][\w]*$/;
584
+ const BARE_IDENTIFIER_RE = /^[$A-Za-z_][\w$]*$/;
585
585
  const IDENTIFIER_CHAR_RE = /[A-Za-z0-9_$]/;
586
586
 
587
587
  function findSymbolMatchIndexes(lineText: string, symbol: string, caseInsensitive = false): number[] {
package/src/main.ts CHANGED
@@ -529,6 +529,7 @@ async function buildSessionOptions(
529
529
  ): Promise<{ options: CreateAgentSessionOptions }> {
530
530
  const options: CreateAgentSessionOptions = {
531
531
  cwd: parsed.cwd ?? getProjectDir(),
532
+ autoApprove: parsed.autoApprove ?? false,
532
533
  };
533
534
 
534
535
  // Auto-discover SYSTEM.md if no CLI system prompt provided
@@ -769,6 +770,11 @@ export async function runRootCommand(
769
770
 
770
771
  const cwd = getProjectDir();
771
772
  const settingsInstance = deps.settings ?? (await logger.time("settings:init", Settings.init, { cwd }));
773
+ if (parsedArgs.approvalMode) {
774
+ // Runtime override (not persisted): every settings.get("tools.approvalMode") downstream
775
+ // sees this value. The wrapper still honours --auto-approve / --yolo on top of it.
776
+ settingsInstance.override("tools.approvalMode", parsedArgs.approvalMode);
777
+ }
772
778
  if (parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") {
773
779
  applyRpcDefaultSettingOverrides(settingsInstance);
774
780
  }
package/src/mcp/client.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import * as path from "node:path";
7
7
  import * as url from "node:url";
8
8
  import { getProjectDir, logger, withTimeout } from "@oh-my-pi/pi-utils";
9
+ import { describeMCPTimeout, isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "./timeout";
9
10
  import { createHttpTransport } from "./transports/http";
10
11
  import { createStdioTransport } from "./transports/stdio";
11
12
  import type {
@@ -39,9 +40,6 @@ import type {
39
40
  /** MCP protocol version we support */
40
41
  const PROTOCOL_VERSION = "2025-03-26";
41
42
 
42
- /** Default connection timeout in ms */
43
- const CONNECTION_TIMEOUT_MS = 30_000;
44
-
45
43
  /** Client info sent during initialization */
46
44
  const CLIENT_INFO = {
47
45
  name: "omp-coding-agent",
@@ -128,7 +126,8 @@ async function initializeConnection(
128
126
 
129
127
  /**
130
128
  * Connect to an MCP server.
131
- * Has a 30 second timeout to prevent blocking startup.
129
+ * Has a 30 second timeout by default to prevent blocking startup.
130
+ * Set OMP_MCP_TIMEOUT_MS=0 to disable MCP client-side timeouts.
132
131
  */
133
132
  export async function connectToServer(
134
133
  name: string,
@@ -139,7 +138,7 @@ export async function connectToServer(
139
138
  onRequest?: (method: string, params: unknown) => Promise<unknown>;
140
139
  },
141
140
  ): Promise<MCPServerConnection> {
142
- const timeoutMs = config.timeout ?? CONNECTION_TIMEOUT_MS;
141
+ const timeoutMs = resolveMCPTimeoutMs(config.timeout);
143
142
  let transport: MCPTransport | undefined;
144
143
 
145
144
  const connect = async (): Promise<MCPServerConnection> => {
@@ -180,10 +179,13 @@ export async function connectToServer(
180
179
  };
181
180
 
182
181
  try {
182
+ if (!isMCPTimeoutEnabled(timeoutMs)) {
183
+ return await connect();
184
+ }
183
185
  return await withTimeout(
184
186
  connect(),
185
187
  timeoutMs,
186
- `Connection to MCP server "${name}" timed out after ${timeoutMs}ms`,
188
+ `Connection to MCP server "${name}" timed out after ${describeMCPTimeout(timeoutMs)}`,
187
189
  options?.signal,
188
190
  );
189
191
  } catch (error) {
@@ -17,22 +17,23 @@ export interface AuthDetectionResult {
17
17
  authType?: "oauth" | "apikey" | "unknown";
18
18
  oauth?: OAuthEndpoints;
19
19
  authServerUrl?: string;
20
+ resourceMetadataUrl?: string;
20
21
  message?: string;
21
22
  }
22
23
 
23
- function parseMcpAuthServerUrl(errorMessage: string): string | undefined {
24
+ function parseMcpAuthServerUrl(errorMessage: string, serverUrl?: string): string | undefined {
24
25
  const match = errorMessage.match(/Mcp-Auth-Server:\s*([^;\]\s]+)/i);
25
26
  if (!match?.[1]) return undefined;
26
27
 
27
28
  try {
28
- return new URL(match[1]).toString();
29
+ return new URL(match[1], serverUrl).toString();
29
30
  } catch {
30
31
  return undefined;
31
32
  }
32
33
  }
33
34
 
34
- export function extractMcpAuthServerUrl(error: Error): string | undefined {
35
- return parseMcpAuthServerUrl(error.message);
35
+ export function extractMcpAuthServerUrl(error: Error, serverUrl?: string): string | undefined {
36
+ return parseMcpAuthServerUrl(error.message, serverUrl);
36
37
  }
37
38
 
38
39
  /**
@@ -189,12 +190,15 @@ export function extractOAuthEndpoints(error: Error): OAuthEndpoints | null {
189
190
  * Analyze an error to determine authentication requirements.
190
191
  * Returns structured info about what auth is needed.
191
192
  */
192
- export function analyzeAuthError(error: Error): AuthDetectionResult {
193
+ export function analyzeAuthError(error: Error, serverUrl?: string): AuthDetectionResult {
193
194
  if (!detectAuthError(error)) {
194
195
  return { requiresAuth: false };
195
196
  }
196
197
 
197
- const authServerUrl = extractMcpAuthServerUrl(error);
198
+ const authServerUrl = extractMcpAuthServerUrl(error, serverUrl);
199
+ // Extract resource_metadata URL from challenge entries in error message
200
+ const resourceMetaMatch = error.message.match(/resource_metadata\s*=\s*"([^"]+)"/i);
201
+ const resourceMetadataUrl = resourceMetaMatch?.[1];
198
202
 
199
203
  // Try to extract OAuth endpoints
200
204
  const oauth = extractOAuthEndpoints(error);
@@ -205,6 +209,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
205
209
  authType: "oauth",
206
210
  oauth,
207
211
  authServerUrl,
212
+ resourceMetadataUrl,
208
213
  message: "Server requires OAuth authentication. Launching authorization flow...",
209
214
  };
210
215
  }
@@ -221,6 +226,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
221
226
  requiresAuth: true,
222
227
  authType: "apikey",
223
228
  authServerUrl,
229
+ resourceMetadataUrl,
224
230
  message: "Server requires API key authentication.",
225
231
  };
226
232
  }
@@ -230,6 +236,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
230
236
  requiresAuth: true,
231
237
  authType: "unknown",
232
238
  authServerUrl,
239
+ resourceMetadataUrl,
233
240
  message: "Server requires authentication but type could not be determined.",
234
241
  };
235
242
  }
@@ -241,6 +248,7 @@ export function analyzeAuthError(error: Error): AuthDetectionResult {
241
248
  export async function discoverOAuthEndpoints(
242
249
  serverUrl: string,
243
250
  authServerUrl?: string,
251
+ resourceMetadataUrl?: string,
244
252
  ): Promise<OAuthEndpoints | null> {
245
253
  const wellKnownPaths = [
246
254
  "/.well-known/oauth-authorization-server",
@@ -250,9 +258,44 @@ export async function discoverOAuthEndpoints(
250
258
  "/.mcp/auth",
251
259
  "/authorize", // Some MCP servers expose OAuth config here
252
260
  ];
253
- const urlsToQuery = [authServerUrl, serverUrl].filter((value): value is string => Boolean(value));
261
+ const urlsToQuery: string[] = [];
254
262
  const visitedAuthServers = new Set<string>();
255
263
 
264
+ // Step 1: If a resource_metadata URL was provided, fetch it to discover auth servers.
265
+ // This follows the RFC 9728 chain: resource_metadata → authorization_servers.
266
+ if (resourceMetadataUrl && !visitedAuthServers.has(resourceMetadataUrl)) {
267
+ visitedAuthServers.add(resourceMetadataUrl);
268
+ try {
269
+ const metaResp = await fetch(resourceMetadataUrl, {
270
+ method: "GET",
271
+ headers: { Accept: "application/json" },
272
+ redirect: "follow",
273
+ });
274
+ if (metaResp.ok) {
275
+ const meta = (await metaResp.json()) as Record<string, unknown>;
276
+ const authServers = Array.isArray(meta.authorization_servers)
277
+ ? meta.authorization_servers.filter((entry): entry is string => typeof entry === "string")
278
+ : [];
279
+ for (const s of authServers) {
280
+ if (!visitedAuthServers.has(s)) {
281
+ urlsToQuery.push(s);
282
+ visitedAuthServers.add(s);
283
+ }
284
+ }
285
+ }
286
+ } catch {
287
+ // Ignore errors, continue to try explicit URLs
288
+ }
289
+ }
290
+
291
+ // Step 2: Add explicit authServerUrl and serverUrl (deduped against visited)
292
+ for (const url of [authServerUrl, serverUrl].filter((v): v is string => Boolean(v))) {
293
+ if (!visitedAuthServers.has(url)) {
294
+ urlsToQuery.push(url);
295
+ visitedAuthServers.add(url);
296
+ }
297
+ }
298
+
256
299
  const findEndpoints = (metadata: Record<string, unknown>): OAuthEndpoints | null => {
257
300
  if (metadata.authorization_endpoint && metadata.token_endpoint) {
258
301
  const scopesSupported = Array.isArray(metadata.scopes_supported)
@@ -311,39 +354,84 @@ export async function discoverOAuthEndpoints(
311
354
  };
312
355
 
313
356
  for (const baseUrl of urlsToQuery) {
314
- visitedAuthServers.add(baseUrl);
315
357
  for (const path of wellKnownPaths) {
316
- try {
317
- const url = new URL(path, baseUrl);
318
- const response = await fetch(url.toString(), {
319
- method: "GET",
320
- headers: { Accept: "application/json" },
321
- });
322
-
323
- if (response.ok) {
324
- const metadata = (await response.json()) as Record<string, unknown>;
325
- const endpoints = findEndpoints(metadata);
326
- if (endpoints) return endpoints;
327
-
328
- if (path === "/.well-known/oauth-protected-resource") {
329
- const authServers = Array.isArray(metadata.authorization_servers)
330
- ? metadata.authorization_servers.filter((entry): entry is string => typeof entry === "string")
331
- : [];
332
-
333
- for (const discoveredAuthServer of authServers) {
334
- if (visitedAuthServers.has(discoveredAuthServer)) {
335
- continue;
358
+ // Try each well-known path at both the absolute origin and relative
359
+ const urlsToTry = buildWellKnownUrls(path, baseUrl);
360
+ for (const url of urlsToTry) {
361
+ try {
362
+ const response = await fetch(url.toString(), {
363
+ method: "GET",
364
+ headers: { Accept: "application/json" },
365
+ redirect: "follow",
366
+ });
367
+
368
+ if (response.ok) {
369
+ const metadata = (await response.json()) as Record<string, unknown>;
370
+ const endpoints = findEndpoints(metadata);
371
+ if (endpoints) return endpoints;
372
+
373
+ if (path === "/.well-known/oauth-protected-resource") {
374
+ const authServers = Array.isArray(metadata.authorization_servers)
375
+ ? metadata.authorization_servers.filter((entry): entry is string => typeof entry === "string")
376
+ : [];
377
+
378
+ for (const discoveredAuthServer of authServers) {
379
+ if (visitedAuthServers.has(discoveredAuthServer)) {
380
+ continue;
381
+ }
382
+ const discovered = await discoverOAuthEndpoints(serverUrl, discoveredAuthServer);
383
+ if (discovered) return discovered;
336
384
  }
337
- const discovered = await discoverOAuthEndpoints(serverUrl, discoveredAuthServer);
338
- if (discovered) return discovered;
339
385
  }
340
386
  }
387
+ } catch {
388
+ // Ignore errors, try next path
341
389
  }
342
- } catch {
343
- // Ignore errors, try next path
344
390
  }
345
391
  }
346
392
  }
347
393
 
348
394
  return null;
349
395
  }
396
+
397
+ function buildWellKnownUrls(wellKnownPath: string, baseUrl: string): URL[] {
398
+ let parsed: URL;
399
+ try {
400
+ parsed = new URL(baseUrl);
401
+ } catch {
402
+ return [];
403
+ }
404
+
405
+ const absUrl = new URL(wellKnownPath, parsed);
406
+ if (!wellKnownPath.startsWith("/")) return [absUrl];
407
+
408
+ const normalizedPath = parsed.pathname.replace(/\/$/, "");
409
+ const lastSlash = normalizedPath.lastIndexOf("/");
410
+ // Bare origin (no path beyond "/") — only the origin-root candidate applies.
411
+ if (lastSlash < 0) return [absUrl];
412
+
413
+ // Path-prefixed well-known (common for gateways with sub-path routing).
414
+ // Multi-segment paths drop the trailing segment (typically the MCP endpoint);
415
+ // single-segment paths (lastSlash === 0) are themselves the gateway prefix.
416
+ const prefixPath = lastSlash === 0 ? normalizedPath : normalizedPath.slice(0, lastSlash);
417
+ const relUrl = new URL(wellKnownPath.slice(1), `${parsed.origin}${prefixPath}/`);
418
+
419
+ const candidates: URL[] = [absUrl];
420
+ const seen = new Set<string>([absUrl.href]);
421
+ const push = (u: URL): void => {
422
+ if (!seen.has(u.href)) {
423
+ candidates.push(u);
424
+ seen.add(u.href);
425
+ }
426
+ };
427
+ push(relUrl);
428
+
429
+ // RFC 8414 §3.1 path-ful issuer form: /.well-known/<suffix>/<issuer-path>.
430
+ // Only meaningful for well-known metadata documents.
431
+ if (wellKnownPath.startsWith("/.well-known/")) {
432
+ const pathfulUrl = new URL(`${wellKnownPath}${normalizedPath}`, parsed.origin);
433
+ push(pathfulUrl);
434
+ }
435
+
436
+ return candidates;
437
+ }
@@ -324,23 +324,51 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
324
324
  }
325
325
 
326
326
  async #resolveRegistrationEndpoint(): Promise<string | null> {
327
+ const authorizationUrl = new URL(this.config.authorizationUrl);
328
+
329
+ // origin-root well-known; most servers serve metadata here.
330
+ const rootUrl = new URL("/.well-known/oauth-authorization-server", authorizationUrl.origin).toString();
331
+ const endpoint = await this.#tryWellKnownForRegistration(rootUrl);
332
+ if (endpoint) return endpoint;
333
+
334
+ // path-prefixed well-known for gateways (e.g. https://gateway.example.com/my-service/).
335
+ const normalizedPath = authorizationUrl.pathname.replace(/\/$/, "");
336
+ const lastSlash = normalizedPath.lastIndexOf("/");
337
+ // Bare-origin authorization URL — nothing further to try.
338
+ if (lastSlash < 0) return null;
339
+
340
+ // Single-segment paths are the gateway prefix itself; multi-segment paths
341
+ // drop the trailing segment (typically a service endpoint).
342
+ const prefixPath = lastSlash === 0 ? normalizedPath : normalizedPath.slice(0, lastSlash);
343
+ const prefixedUrl = new URL(
344
+ ".well-known/oauth-authorization-server",
345
+ `${authorizationUrl.origin}${prefixPath}/`,
346
+ ).toString();
347
+ const prefixedEndpoint = await this.#tryWellKnownForRegistration(prefixedUrl);
348
+ if (prefixedEndpoint) return prefixedEndpoint;
349
+
350
+ // RFC 8414 §3.1 path-ful issuer form: /.well-known/oauth-authorization-server/<path>.
351
+ const pathfulUrl = new URL(
352
+ `/.well-known/oauth-authorization-server${normalizedPath}`,
353
+ authorizationUrl.origin,
354
+ ).toString();
355
+ return await this.#tryWellKnownForRegistration(pathfulUrl);
356
+ }
357
+
358
+ async #tryWellKnownForRegistration(wellKnownUrl: string): Promise<string | null> {
327
359
  try {
328
- const authorizationEndpoint = new URL(this.config.authorizationUrl);
329
- const metadataUrl = new URL("/.well-known/oauth-authorization-server", authorizationEndpoint.origin);
330
- const response = await fetch(metadataUrl.toString(), {
360
+ const response = await fetch(wellKnownUrl, {
331
361
  method: "GET",
332
362
  headers: { Accept: "application/json" },
333
363
  });
334
-
335
364
  if (!response.ok) return null;
336
365
  const metadata = (await response.json()) as { registration_endpoint?: string };
337
366
  if (metadata.registration_endpoint && metadata.registration_endpoint.trim() !== "") {
338
367
  return metadata.registration_endpoint;
339
368
  }
340
369
  } catch {
341
- // Ignore metadata discovery failures.
370
+ // Ignore fetch/parse failures.
342
371
  }
343
-
344
372
  return null;
345
373
  }
346
374
 
@@ -0,0 +1,59 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+
3
+ const DEFAULT_MCP_TIMEOUT_MS = 30_000;
4
+ const MCP_TIMEOUT_ENV = "OMP_MCP_TIMEOUT_MS";
5
+
6
+ let neverAbortController: AbortController | undefined;
7
+
8
+ export function resolveMCPTimeoutMs(configTimeout?: number): number {
9
+ const raw = Bun.env[MCP_TIMEOUT_ENV]?.trim();
10
+ if (raw) {
11
+ const value = Number(raw);
12
+ if (Number.isFinite(value) && value >= 0) return value;
13
+ logger.warn("Ignoring invalid OMP_MCP_TIMEOUT_MS env value; expected a non-negative number", {
14
+ value: raw,
15
+ });
16
+ }
17
+ return configTimeout ?? DEFAULT_MCP_TIMEOUT_MS;
18
+ }
19
+
20
+ export function isMCPTimeoutEnabled(timeoutMs: number): boolean {
21
+ return timeoutMs > 0;
22
+ }
23
+
24
+ export function describeMCPTimeout(timeoutMs: number): string {
25
+ return isMCPTimeoutEnabled(timeoutMs) ? `${timeoutMs}ms` : "disabled";
26
+ }
27
+
28
+ export function getNeverAbortSignal(): AbortSignal {
29
+ neverAbortController ??= new AbortController();
30
+ return neverAbortController.signal;
31
+ }
32
+
33
+ export function createMCPTimeout(
34
+ timeoutMs: number,
35
+ signal?: AbortSignal,
36
+ ): {
37
+ signal?: AbortSignal;
38
+ clear: () => void;
39
+ isTimeoutAbort: (error: unknown) => boolean;
40
+ } {
41
+ if (!isMCPTimeoutEnabled(timeoutMs)) {
42
+ return {
43
+ signal,
44
+ clear: () => {},
45
+ isTimeoutAbort: () => false,
46
+ };
47
+ }
48
+
49
+ const abortController = new AbortController();
50
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
51
+ const operationSignal = signal ? AbortSignal.any([signal, abortController.signal]) : abortController.signal;
52
+
53
+ return {
54
+ signal: operationSignal,
55
+ clear: () => clearTimeout(timeoutId),
56
+ isTimeoutAbort: error =>
57
+ error instanceof Error && error.name === "AbortError" && abortController.signal.aborted && !signal?.aborted,
58
+ };
59
+ }