@oh-my-pi/pi-coding-agent 15.4.3 → 15.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -5
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/auth-broker-cli.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +8 -0
- package/dist/types/config/settings-schema.d.ts +42 -1
- package/dist/types/edit/index.d.ts +2 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +8 -2
- package/dist/types/extensibility/hooks/types.d.ts +4 -0
- package/dist/types/hashline/executor.d.ts +6 -3
- package/dist/types/lsp/index.d.ts +9 -1
- package/dist/types/mcp/client.d.ts +2 -1
- package/dist/types/mcp/oauth-discovery.d.ts +4 -3
- package/dist/types/mcp/timeout.d.ts +9 -0
- package/dist/types/mcp/types.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/streaming-output.d.ts +1 -1
- package/dist/types/task/index.d.ts +2 -0
- package/dist/types/task/types.d.ts +4 -0
- package/dist/types/tools/approval.d.ts +46 -0
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/ast-edit.d.ts +2 -0
- package/dist/types/tools/ast-grep.d.ts +1 -0
- package/dist/types/tools/bash.d.ts +11 -1
- package/dist/types/tools/browser.d.ts +2 -0
- package/dist/types/tools/calculator.d.ts +1 -0
- package/dist/types/tools/checkpoint.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +9 -1
- package/dist/types/tools/eval.d.ts +2 -0
- package/dist/types/tools/find.d.ts +10 -0
- package/dist/types/tools/gh.d.ts +2 -1
- package/dist/types/tools/hindsight-recall.d.ts +1 -0
- package/dist/types/tools/hindsight-reflect.d.ts +1 -0
- package/dist/types/tools/hindsight-retain.d.ts +1 -0
- package/dist/types/tools/inspect-image.d.ts +1 -0
- package/dist/types/tools/irc.d.ts +1 -0
- package/dist/types/tools/job.d.ts +1 -0
- package/dist/types/tools/read.d.ts +1 -0
- package/dist/types/tools/recipe/index.d.ts +1 -0
- package/dist/types/tools/render-mermaid.d.ts +1 -0
- package/dist/types/tools/resolve.d.ts +1 -0
- package/dist/types/tools/search-tool-bm25.d.ts +1 -0
- package/dist/types/tools/search.d.ts +1 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/tools/todo-write.d.ts +1 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/tools/yield.d.ts +1 -0
- package/dist/types/web/search/index.d.ts +1 -0
- package/package.json +7 -7
- package/src/cli/args.ts +14 -0
- package/src/cli/auth-broker-cli.ts +171 -22
- package/src/commands/auth-broker.ts +3 -0
- package/src/commands/launch.ts +16 -0
- package/src/config/mcp-schema.json +2 -2
- package/src/config/model-registry.ts +19 -4
- package/src/config/prompt-templates.ts +0 -125
- package/src/config/settings-schema.ts +59 -1
- package/src/config/settings.ts +2 -1
- package/src/dap/session.ts +35 -2
- package/src/discovery/builtin.ts +2 -2
- package/src/discovery/mcp-json.ts +1 -1
- package/src/edit/index.ts +26 -0
- package/src/edit/modes/patch.ts +1 -1
- package/src/edit/streaming.ts +12 -2
- package/src/exec/bash-executor.ts +6 -2
- package/src/extensibility/custom-commands/bundled/review/index.ts +18 -14
- package/src/extensibility/custom-tools/types.ts +16 -2
- package/src/extensibility/extensions/wrapper.ts +36 -1
- package/src/extensibility/hooks/types.ts +8 -1
- package/src/hashline/apply.ts +47 -2
- package/src/hashline/executor.ts +46 -24
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/lsp/edits.ts +82 -29
- package/src/lsp/index.ts +38 -1
- package/src/lsp/utils.ts +1 -1
- package/src/main.ts +6 -0
- package/src/mcp/client.ts +8 -6
- package/src/mcp/oauth-discovery.ts +120 -32
- package/src/mcp/oauth-flow.ts +34 -6
- package/src/mcp/timeout.ts +59 -0
- package/src/mcp/transports/http.ts +42 -44
- package/src/mcp/transports/stdio.ts +8 -5
- package/src/mcp/types.ts +1 -1
- package/src/modes/components/hook-editor.ts +11 -3
- package/src/modes/components/mcp-add-wizard.ts +6 -2
- package/src/modes/components/model-selector.ts +33 -11
- package/src/modes/controllers/command-controller.ts +6 -4
- package/src/modes/controllers/mcp-command-controller.ts +8 -4
- package/src/prompts/review-custom-request.md +22 -0
- package/src/prompts/review-headless-request.md +16 -0
- package/src/prompts/review-request.md +2 -3
- package/src/prompts/system/project-prompt.md +4 -0
- package/src/prompts/tools/debug.md +1 -0
- package/src/prompts/tools/find.md +4 -2
- package/src/prompts/tools/hashline.md +43 -93
- package/src/sdk.ts +47 -73
- package/src/session/agent-session.ts +93 -27
- package/src/session/streaming-output.ts +1 -1
- package/src/slash-commands/helpers/usage-report.ts +3 -1
- package/src/task/executor.ts +11 -0
- package/src/task/index.ts +19 -0
- package/src/task/render.ts +12 -2
- package/src/task/types.ts +4 -0
- package/src/tools/approval.ts +185 -0
- package/src/tools/ask.ts +1 -0
- package/src/tools/ast-edit.ts +25 -1
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash.ts +69 -1
- package/src/tools/browser/tab-supervisor.ts +1 -1
- package/src/tools/browser.ts +15 -0
- package/src/tools/calculator.ts +1 -0
- package/src/tools/checkpoint.ts +2 -0
- package/src/tools/debug.ts +38 -0
- package/src/tools/eval.ts +15 -0
- package/src/tools/find.ts +17 -8
- package/src/tools/gh.ts +21 -1
- package/src/tools/hindsight-recall.ts +1 -0
- package/src/tools/hindsight-reflect.ts +1 -0
- package/src/tools/hindsight-retain.ts +1 -0
- package/src/tools/image-gen.ts +1 -0
- package/src/tools/inspect-image.ts +1 -0
- package/src/tools/irc.ts +1 -0
- package/src/tools/job.ts +1 -0
- package/src/tools/path-utils.ts +14 -1
- package/src/tools/read.ts +1 -0
- package/src/tools/recipe/index.ts +1 -0
- package/src/tools/render-mermaid.ts +1 -0
- package/src/tools/report-tool-issue.ts +1 -0
- package/src/tools/resolve.ts +1 -0
- package/src/tools/review.ts +1 -0
- package/src/tools/search-tool-bm25.ts +1 -0
- package/src/tools/search.ts +1 -0
- package/src/tools/ssh.ts +8 -0
- package/src/tools/todo-write.ts +1 -0
- package/src/tools/write.ts +12 -1
- package/src/tools/yield.ts +1 -0
- 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 (
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 {
|
|
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
|
|
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}
|
|
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 = [
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
}
|
package/src/mcp/oauth-flow.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|