pi-mcp-extension 1.7.3 → 1.9.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.
- package/package.json +1 -1
- package/src/callback-server.ts +28 -47
- package/src/callback-url.ts +136 -0
- package/src/index.ts +277 -40
- package/src/oauth-provider.ts +1 -0
package/package.json
CHANGED
package/src/callback-server.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
|
|
9
9
|
import type { Socket } from "net";
|
|
10
10
|
import { URL } from "url";
|
|
11
|
+
import { CallbackUrlValidationError, isLocalCallbackHost, parseCallbackUrl } from "./callback-url.js";
|
|
11
12
|
|
|
12
13
|
interface PendingAuth {
|
|
13
14
|
resolve: (code: string) => void;
|
|
@@ -73,72 +74,52 @@ function escapeHtml(text: string): string {
|
|
|
73
74
|
* Handle incoming HTTP requests to the callback server.
|
|
74
75
|
*/
|
|
75
76
|
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
77
|
+
let parsed: ReturnType<typeof parseCallbackUrl>;
|
|
78
|
+
try {
|
|
79
|
+
const requestUrl = new URL(req.url || "/", "http://localhost").toString();
|
|
80
|
+
parsed = parseCallbackUrl(requestUrl, {
|
|
81
|
+
expectedState: (state) => pendingAuths.has(state),
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err instanceof CallbackUrlValidationError) {
|
|
85
|
+
if (err.reason === "wrong_path") {
|
|
86
|
+
res.writeHead(404, { "Content-Type": "text/plain", "Connection": "close" });
|
|
87
|
+
res.end("Not found");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
91
|
+
res.end(HTML_ERROR(err.message));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
93
94
|
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
94
|
-
res.end(HTML_ERROR(
|
|
95
|
+
res.end(HTML_ERROR("Invalid callback request"));
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
// Handle OAuth errors
|
|
99
|
-
if (error) {
|
|
100
|
-
const errorMsg = errorDescription || error;
|
|
100
|
+
if ("error" in parsed) {
|
|
101
101
|
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
|
|
102
|
-
res.end(HTML_ERROR(
|
|
103
|
-
if (pendingAuths.has(state)) {
|
|
104
|
-
const pending = pendingAuths.get(state)!;
|
|
102
|
+
res.end(HTML_ERROR(parsed.error));
|
|
103
|
+
if (pendingAuths.has(parsed.state)) {
|
|
104
|
+
const pending = pendingAuths.get(parsed.state)!;
|
|
105
105
|
clearTimeout(pending.timeout);
|
|
106
|
-
pendingAuths.delete(state);
|
|
107
|
-
setTimeout(() => pending.reject(new Error(
|
|
106
|
+
pendingAuths.delete(parsed.state);
|
|
107
|
+
setTimeout(() => pending.reject(new Error(parsed.error)), 0);
|
|
108
108
|
}
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
if (!code) {
|
|
114
|
-
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
115
|
-
res.end(HTML_ERROR("No authorization code provided"));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Validate state parameter
|
|
120
|
-
if (!pendingAuths.has(state)) {
|
|
121
|
-
const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
|
|
122
|
-
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
123
|
-
res.end(HTML_ERROR(errorMsg));
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const pending = pendingAuths.get(state)!;
|
|
112
|
+
const pending = pendingAuths.get(parsed.state)!;
|
|
128
113
|
|
|
129
114
|
// Clear timeout and resolve the pending promise
|
|
130
115
|
clearTimeout(pending.timeout);
|
|
131
|
-
pendingAuths.delete(state);
|
|
132
|
-
pending.resolve(code);
|
|
116
|
+
pendingAuths.delete(parsed.state);
|
|
117
|
+
pending.resolve(parsed.code);
|
|
133
118
|
|
|
134
119
|
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
|
|
135
120
|
res.end(HTML_SUCCESS);
|
|
136
121
|
}
|
|
137
122
|
|
|
138
|
-
function isLocalCallbackHost(hostname: string): boolean {
|
|
139
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
140
|
-
}
|
|
141
|
-
|
|
142
123
|
async function closeCurrentServer(): Promise<void> {
|
|
143
124
|
if (!server) return;
|
|
144
125
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URL validation and parsing helpers for MCP OAuth authorization and callbacks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type CallbackUrlValidationReason =
|
|
6
|
+
| "invalid_url"
|
|
7
|
+
| "wrong_origin"
|
|
8
|
+
| "wrong_path"
|
|
9
|
+
| "missing_state"
|
|
10
|
+
| "invalid_state"
|
|
11
|
+
| "missing_code";
|
|
12
|
+
|
|
13
|
+
export class CallbackUrlValidationError extends Error {
|
|
14
|
+
readonly reason: CallbackUrlValidationReason;
|
|
15
|
+
|
|
16
|
+
constructor(reason: CallbackUrlValidationReason, message: string) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "CallbackUrlValidationError";
|
|
19
|
+
this.reason = reason;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ParseCallbackUrlOptions {
|
|
24
|
+
redirectUrl?: string | URL | undefined;
|
|
25
|
+
expectedState?: string | ((state: string) => boolean) | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isLocalCallbackHost(hostname: string): boolean {
|
|
29
|
+
return (
|
|
30
|
+
hostname === "localhost" ||
|
|
31
|
+
hostname === "127.0.0.1" ||
|
|
32
|
+
hostname === "::1" ||
|
|
33
|
+
hostname === "[::1]"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate that an OAuth authorization URL is safe to pass to a system browser opener.
|
|
39
|
+
* Only allows `https:` URLs or loopback `http:` URLs (`localhost`, `127.0.0.1`, `::1`, `[::1]`).
|
|
40
|
+
*/
|
|
41
|
+
export function isSafeAuthorizationUrl(input: string | URL): boolean {
|
|
42
|
+
let url: URL;
|
|
43
|
+
try {
|
|
44
|
+
url = input instanceof URL ? input : new URL(input);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (url.protocol === "https:") {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (url.protocol === "http:" && isLocalCallbackHost(url.hostname)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse and validate an OAuth callback URL (either from an incoming HTTP callback request
|
|
62
|
+
* or from a user-pasted redirect URL).
|
|
63
|
+
*/
|
|
64
|
+
export function parseCallbackUrl(
|
|
65
|
+
input: string,
|
|
66
|
+
options: ParseCallbackUrlOptions = {},
|
|
67
|
+
): { state: string; code: string } | { state: string; error: string } {
|
|
68
|
+
const trimmed = input.trim();
|
|
69
|
+
let url: URL;
|
|
70
|
+
try {
|
|
71
|
+
url = new URL(trimmed);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new CallbackUrlValidationError("invalid_url", "Invalid redirect URL");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.redirectUrl !== undefined) {
|
|
77
|
+
const expected =
|
|
78
|
+
options.redirectUrl instanceof URL
|
|
79
|
+
? options.redirectUrl
|
|
80
|
+
: new URL(String(options.redirectUrl));
|
|
81
|
+
if (
|
|
82
|
+
url.protocol !== expected.protocol ||
|
|
83
|
+
url.hostname !== expected.hostname ||
|
|
84
|
+
url.port !== expected.port
|
|
85
|
+
) {
|
|
86
|
+
throw new CallbackUrlValidationError(
|
|
87
|
+
"wrong_origin",
|
|
88
|
+
`Redirect URL origin must match ${expected.origin}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (url.pathname !== expected.pathname) {
|
|
92
|
+
throw new CallbackUrlValidationError(
|
|
93
|
+
"wrong_path",
|
|
94
|
+
`Redirect URL path must be ${expected.pathname}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
} else if (url.pathname !== "/callback") {
|
|
98
|
+
throw new CallbackUrlValidationError("wrong_path", "Redirect URL path must be /callback");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const code = url.searchParams.get("code");
|
|
102
|
+
const state = url.searchParams.get("state");
|
|
103
|
+
const error = url.searchParams.get("error");
|
|
104
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
105
|
+
|
|
106
|
+
if (!state) {
|
|
107
|
+
throw new CallbackUrlValidationError(
|
|
108
|
+
"missing_state",
|
|
109
|
+
"Missing required state parameter - potential CSRF attack",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof options.expectedState === "string" && state !== options.expectedState) {
|
|
114
|
+
throw new CallbackUrlValidationError(
|
|
115
|
+
"invalid_state",
|
|
116
|
+
"Invalid or expired state parameter - potential CSRF attack",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (error) {
|
|
121
|
+
return { state, error: errorDescription || error };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!code) {
|
|
125
|
+
throw new CallbackUrlValidationError("missing_code", "No authorization code provided");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof options.expectedState === "function" && !options.expectedState(state)) {
|
|
129
|
+
throw new CallbackUrlValidationError(
|
|
130
|
+
"invalid_state",
|
|
131
|
+
"Invalid or expired state parameter - potential CSRF attack",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { state, code };
|
|
136
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
stopCallbackServer,
|
|
25
25
|
callbackServerConfigFromRedirectUrl,
|
|
26
26
|
} from "./callback-server.js";
|
|
27
|
+
import { CallbackUrlValidationError, isSafeAuthorizationUrl, parseCallbackUrl } from "./callback-url.js";
|
|
27
28
|
import { setCallbackPort, McpOAuthProvider, acquireAuthLock, type AuthLockHandle } from "./oauth-provider.js";
|
|
28
29
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
29
30
|
import { discoverManualAuthChallenge } from "./auth-challenge.js";
|
|
@@ -48,19 +49,25 @@ export function browserOpenCommand(url: string): BrowserOpenCommand {
|
|
|
48
49
|
* Open a URL in the user's default browser.
|
|
49
50
|
* Works on macOS, Linux, and Windows.
|
|
50
51
|
*/
|
|
51
|
-
function openBrowser(url: string): void {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
function openBrowser(url: string): Promise<void> {
|
|
53
|
+
return new Promise<void>((resolve, reject) => {
|
|
54
|
+
const { command, args } = browserOpenCommand(url);
|
|
55
|
+
const child = spawn(command, args, {
|
|
56
|
+
detached: true,
|
|
57
|
+
stdio: "ignore",
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
});
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
child.once("error", (err) => {
|
|
62
|
+
console.error(`[pi-mcp] Failed to open browser: ${err.message}`);
|
|
63
|
+
reject(err);
|
|
64
|
+
});
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
child.once("spawn", () => {
|
|
67
|
+
child.unref();
|
|
68
|
+
resolve();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
export class AuthCancelledError extends Error {
|
|
@@ -71,7 +78,15 @@ export class AuthCancelledError extends Error {
|
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
export const authRetryOption = "Retry: open browser again";
|
|
81
|
+
export const authPasteOption = "Paste redirect URL";
|
|
74
82
|
export const authCancelOption = "Cancel authentication";
|
|
83
|
+
export const startupAuthNowOption = "Authorize now";
|
|
84
|
+
export const startupAuthLaterOption = "Not now";
|
|
85
|
+
|
|
86
|
+
function formatErrorMessage(err: unknown): string {
|
|
87
|
+
const msg = err instanceof McpError ? err.userMessage : err instanceof Error ? err.message : String(err);
|
|
88
|
+
return msg.replace(/^AuthRequiredError:\s*/, "");
|
|
89
|
+
}
|
|
75
90
|
|
|
76
91
|
interface OAuthCallbackResult {
|
|
77
92
|
type: "callback";
|
|
@@ -92,19 +107,35 @@ interface OAuthDialogAbortedResult {
|
|
|
92
107
|
type: "dialogAborted";
|
|
93
108
|
}
|
|
94
109
|
|
|
110
|
+
interface OAuthUserInputResult {
|
|
111
|
+
type: "input";
|
|
112
|
+
value: string | undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
95
115
|
type OAuthWaitResult = OAuthCallbackResult | OAuthCallbackErrorResult | OAuthUserChoiceResult | OAuthDialogAbortedResult;
|
|
116
|
+
type OAuthInputWaitResult = OAuthCallbackResult | OAuthCallbackErrorResult | OAuthUserInputResult | OAuthDialogAbortedResult;
|
|
117
|
+
|
|
118
|
+
export interface OAuthPasteOptions {
|
|
119
|
+
redirectUrl?: string | URL | undefined;
|
|
120
|
+
expectedState?: string | undefined;
|
|
121
|
+
onPasteResolved?: (() => void) | undefined;
|
|
122
|
+
}
|
|
96
123
|
|
|
97
124
|
export async function waitForOAuthCallback(
|
|
98
125
|
serverName: string,
|
|
99
126
|
callbackPromise: Promise<string>,
|
|
100
|
-
reopenBrowser: () => void
|
|
101
|
-
context: {
|
|
127
|
+
reopenBrowser: () => void | Promise<void>,
|
|
128
|
+
context: {
|
|
129
|
+
hasUI: boolean;
|
|
130
|
+
ui: Pick<ExtensionUIContext, "select"> & Partial<Pick<ExtensionUIContext, "input" | "notify">>;
|
|
131
|
+
},
|
|
132
|
+
pasteOptions?: OAuthPasteOptions,
|
|
102
133
|
): Promise<string> {
|
|
103
134
|
if (!context.hasUI) {
|
|
104
135
|
throw new Error(`Authorization for ${serverName} requires an interactive Pi session`);
|
|
105
136
|
}
|
|
106
137
|
|
|
107
|
-
const callbackResultPromise: Promise<
|
|
138
|
+
const callbackResultPromise: Promise<OAuthCallbackResult | OAuthCallbackErrorResult> = callbackPromise
|
|
108
139
|
.then((code): OAuthCallbackResult => ({ type: "callback", code }))
|
|
109
140
|
.catch((error): OAuthCallbackErrorResult => ({ type: "callbackError", error }));
|
|
110
141
|
|
|
@@ -113,7 +144,7 @@ export async function waitForOAuthCallback(
|
|
|
113
144
|
const choicePromise: Promise<OAuthWaitResult> = context.ui
|
|
114
145
|
.select(
|
|
115
146
|
`OAuth pending for ${serverName}`,
|
|
116
|
-
[authRetryOption, authCancelOption],
|
|
147
|
+
[authRetryOption, authPasteOption, authCancelOption],
|
|
117
148
|
{ signal: dialogAbortController.signal },
|
|
118
149
|
)
|
|
119
150
|
.then((choice): OAuthUserChoiceResult => ({ type: "choice", choice }))
|
|
@@ -141,10 +172,66 @@ export async function waitForOAuthCallback(
|
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
if (result.choice === authRetryOption) {
|
|
144
|
-
reopenBrowser();
|
|
175
|
+
await reopenBrowser();
|
|
145
176
|
continue;
|
|
146
177
|
}
|
|
147
178
|
|
|
179
|
+
if (result.choice === authPasteOption) {
|
|
180
|
+
if (!context.ui.input) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const inputAbortController = new AbortController();
|
|
184
|
+
const inputPromise: Promise<OAuthInputWaitResult> = context.ui
|
|
185
|
+
.input(
|
|
186
|
+
`Paste redirect URL for ${serverName}`,
|
|
187
|
+
"http://127.0.0.1:19876/callback?code=...&state=...",
|
|
188
|
+
{ signal: inputAbortController.signal },
|
|
189
|
+
)
|
|
190
|
+
.then((value): OAuthUserInputResult => ({ type: "input", value }))
|
|
191
|
+
.catch((error): OAuthDialogAbortedResult => {
|
|
192
|
+
if (inputAbortController.signal.aborted) {
|
|
193
|
+
return { type: "dialogAborted" };
|
|
194
|
+
}
|
|
195
|
+
throw error;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const inputResult = await Promise.race([callbackResultPromise, inputPromise]);
|
|
199
|
+
|
|
200
|
+
if (inputResult.type === "callback") {
|
|
201
|
+
inputAbortController.abort();
|
|
202
|
+
return inputResult.code;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (inputResult.type === "callbackError") {
|
|
206
|
+
inputAbortController.abort();
|
|
207
|
+
throw inputResult.error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (inputResult.type === "dialogAborted" || !inputResult.value?.trim()) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let parsed: ReturnType<typeof parseCallbackUrl>;
|
|
215
|
+
try {
|
|
216
|
+
parsed = parseCallbackUrl(inputResult.value, {
|
|
217
|
+
redirectUrl: pasteOptions?.redirectUrl,
|
|
218
|
+
expectedState: pasteOptions?.expectedState,
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (err instanceof CallbackUrlValidationError) {
|
|
222
|
+
context.ui.notify?.(`pi-mcp: Invalid redirect URL — ${err.message}`, "error");
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
pasteOptions?.onPasteResolved?.();
|
|
229
|
+
if ("error" in parsed) {
|
|
230
|
+
throw new Error(parsed.error);
|
|
231
|
+
}
|
|
232
|
+
return parsed.code;
|
|
233
|
+
}
|
|
234
|
+
|
|
148
235
|
dialogAbortController.abort();
|
|
149
236
|
throw new AuthCancelledError(serverName);
|
|
150
237
|
}
|
|
@@ -194,7 +281,7 @@ export function serverNameCompletions(
|
|
|
194
281
|
return completions.length > 0 ? completions : null;
|
|
195
282
|
}
|
|
196
283
|
|
|
197
|
-
export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void } = {}): Promise<void> {
|
|
284
|
+
export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string; globalConfigPath?: string; authStorageDir?: string; browserOpen?: (url: string) => void | Promise<void> } = {}): Promise<void> {
|
|
198
285
|
// ── 1. Load and validate config ──────────────────────────────────────────
|
|
199
286
|
// cwd is available on the ExtensionContext passed to event handlers.
|
|
200
287
|
// We load config lazily on session_start to get the correct per-session cwd.
|
|
@@ -219,8 +306,91 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
219
306
|
const manager = new ServerManager(config, undefined, paths.authStorageDir);
|
|
220
307
|
const bridge = new ToolBridge(config.settings, pi);
|
|
221
308
|
const connectAttempts = new Map<string, { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }>();
|
|
309
|
+
const declinedStartupAuth = new Set<string>();
|
|
310
|
+
const startupPromptControllers = new Map<string, AbortController>();
|
|
222
311
|
let authQueue: Promise<void> = Promise.resolve();
|
|
223
312
|
|
|
313
|
+
function enqueueStartupAuthPrompt(name: string, ctx: ExtensionContext): void {
|
|
314
|
+
let release!: () => void;
|
|
315
|
+
const previous = authQueue;
|
|
316
|
+
authQueue = new Promise<void>((resolve) => {
|
|
317
|
+
release = resolve;
|
|
318
|
+
});
|
|
319
|
+
void (async () => {
|
|
320
|
+
try {
|
|
321
|
+
await previous;
|
|
322
|
+
if (
|
|
323
|
+
declinedStartupAuth.has(name) ||
|
|
324
|
+
manager.getServer(name)?.state === "ready" ||
|
|
325
|
+
connectAttempts.has(name)
|
|
326
|
+
) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const promptController = new AbortController();
|
|
330
|
+
startupPromptControllers.set(name, promptController);
|
|
331
|
+
let choice: string | undefined;
|
|
332
|
+
try {
|
|
333
|
+
choice = await new Promise<string | undefined>((resolve, reject) => {
|
|
334
|
+
if (promptController.signal.aborted) {
|
|
335
|
+
resolve(undefined);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const onAbort = () => resolve(undefined);
|
|
339
|
+
promptController.signal.addEventListener("abort", onAbort, { once: true });
|
|
340
|
+
ctx.ui
|
|
341
|
+
.select(
|
|
342
|
+
`${name} needs authorization`,
|
|
343
|
+
[startupAuthNowOption, startupAuthLaterOption],
|
|
344
|
+
{ signal: promptController.signal },
|
|
345
|
+
)
|
|
346
|
+
.then(
|
|
347
|
+
(value) => {
|
|
348
|
+
promptController.signal.removeEventListener("abort", onAbort);
|
|
349
|
+
resolve(value);
|
|
350
|
+
},
|
|
351
|
+
(err) => {
|
|
352
|
+
promptController.signal.removeEventListener("abort", onAbort);
|
|
353
|
+
if (promptController.signal.aborted) resolve(undefined);
|
|
354
|
+
else reject(err);
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
} finally {
|
|
359
|
+
if (startupPromptControllers.get(name) === promptController) {
|
|
360
|
+
startupPromptControllers.delete(name);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (
|
|
364
|
+
promptController.signal.aborted ||
|
|
365
|
+
manager.getServer(name)?.state === "ready" ||
|
|
366
|
+
connectAttempts.has(name)
|
|
367
|
+
) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (choice === startupAuthNowOption) {
|
|
371
|
+
try {
|
|
372
|
+
await connectRequested(name, ctx, undefined, false, true);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (!(err instanceof AuthCancelledError)) {
|
|
375
|
+
const msg = formatErrorMessage(err);
|
|
376
|
+
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
declinedStartupAuth.add(name);
|
|
381
|
+
ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
|
|
382
|
+
}
|
|
383
|
+
} catch (err) {
|
|
384
|
+
if (!(err instanceof AuthCancelledError)) {
|
|
385
|
+
const msg = formatErrorMessage(err);
|
|
386
|
+
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
387
|
+
}
|
|
388
|
+
} finally {
|
|
389
|
+
release();
|
|
390
|
+
}
|
|
391
|
+
})();
|
|
392
|
+
}
|
|
393
|
+
|
|
224
394
|
manager.setToolRefreshCallback(async (serverName, client) => {
|
|
225
395
|
await bridge.refreshTools(serverName, client, () => {
|
|
226
396
|
const server = manager.getServer(serverName);
|
|
@@ -236,7 +406,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
236
406
|
try {
|
|
237
407
|
sessionConfig = await loadConfig(ctx.cwd, paths.globalConfigPath);
|
|
238
408
|
} catch (err) {
|
|
239
|
-
const msg =
|
|
409
|
+
const msg = formatErrorMessage(err);
|
|
240
410
|
ctx.ui.notify(`pi-mcp: Config error — ${msg}`, "error");
|
|
241
411
|
return;
|
|
242
412
|
}
|
|
@@ -244,6 +414,11 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
244
414
|
// If config changed (different cwd with project-level overrides),
|
|
245
415
|
// shut down old servers and rebuild the manager's server list
|
|
246
416
|
if (JSON.stringify(sessionConfig) !== JSON.stringify(config)) {
|
|
417
|
+
declinedStartupAuth.clear();
|
|
418
|
+
for (const controller of startupPromptControllers.values()) {
|
|
419
|
+
controller.abort();
|
|
420
|
+
}
|
|
421
|
+
startupPromptControllers.clear();
|
|
247
422
|
// Deactivate and remove all tools from old config
|
|
248
423
|
for (const server of manager.getAllServers()) {
|
|
249
424
|
bridge.removeServer(server.name);
|
|
@@ -259,20 +434,41 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
259
434
|
([, cfg]) => cfg.lifecycle === "eager",
|
|
260
435
|
);
|
|
261
436
|
|
|
437
|
+
const authRequiredServers = new Set<string>();
|
|
438
|
+
|
|
262
439
|
// Start all eager servers concurrently
|
|
263
440
|
await Promise.allSettled(
|
|
264
|
-
eagerServers.map(async ([name]) => {
|
|
441
|
+
eagerServers.map(async ([name, cfg]) => {
|
|
265
442
|
try {
|
|
266
443
|
await manager.startServer(name, ctx.cwd);
|
|
267
444
|
} catch (err) {
|
|
268
|
-
|
|
445
|
+
if (err instanceof AuthRequiredError && cfg.auth && ctx.hasUI) {
|
|
446
|
+
authRequiredServers.add(name);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const msg = formatErrorMessage(err);
|
|
269
450
|
ctx.ui.notify(`pi-mcp: Failed to start ${name} — ${msg}`, "error");
|
|
270
451
|
}
|
|
271
452
|
}),
|
|
272
453
|
);
|
|
454
|
+
|
|
455
|
+
for (const [name] of eagerServers) {
|
|
456
|
+
if (!authRequiredServers.has(name)) continue;
|
|
457
|
+
if (declinedStartupAuth.has(name)) {
|
|
458
|
+
ctx.ui.setStatus?.("pi-mcp", `run /mcp:auth ${name}`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
enqueueStartupAuthPrompt(name, ctx);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (let i = 0; i < 4 * authRequiredServers.size; i++) {
|
|
465
|
+
await Promise.resolve();
|
|
466
|
+
}
|
|
273
467
|
});
|
|
274
468
|
|
|
275
469
|
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
470
|
+
for (const controller of startupPromptControllers.values()) controller.abort();
|
|
471
|
+
startupPromptControllers.clear();
|
|
276
472
|
for (const attempt of connectAttempts.values()) attempt.controller.abort();
|
|
277
473
|
await stopCallbackServer().catch(() => {});
|
|
278
474
|
|
|
@@ -330,6 +526,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
330
526
|
ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
|
|
331
527
|
return;
|
|
332
528
|
}
|
|
529
|
+
startupPromptControllers.get(serverName)?.abort();
|
|
333
530
|
connectAttempts.get(serverName)?.controller.abort();
|
|
334
531
|
bridge.deactivateServer(serverName);
|
|
335
532
|
await manager.stopServer(serverName);
|
|
@@ -355,7 +552,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
355
552
|
await connectRequested(serverName, ctx);
|
|
356
553
|
ctx.ui.notify(`pi-mcp: Started ${serverName}`, "info");
|
|
357
554
|
} catch (err) {
|
|
358
|
-
const msg =
|
|
555
|
+
const msg = formatErrorMessage(err);
|
|
359
556
|
ctx.ui.notify(`pi-mcp: Failed to start ${serverName} — ${msg}`, "error");
|
|
360
557
|
}
|
|
361
558
|
},
|
|
@@ -415,13 +612,34 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
415
612
|
.join("");
|
|
416
613
|
|
|
417
614
|
// 3. Create the auth provider.
|
|
615
|
+
const launchAuthorizationBrowser = async (url: URL): Promise<void> => {
|
|
616
|
+
if (!isSafeAuthorizationUrl(url)) {
|
|
617
|
+
throw new McpError(
|
|
618
|
+
`Refusing to open unsafe authorization URL for "${serverName}": only HTTPS (or loopback HTTP) URLs are allowed`,
|
|
619
|
+
serverName,
|
|
620
|
+
"protocol",
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
latestAuthorizationUrl = new URL(url.toString());
|
|
624
|
+
if (signal.aborted || lockLost) return;
|
|
625
|
+
try {
|
|
626
|
+
await (paths.browserOpen ?? openBrowser)(url.toString());
|
|
627
|
+
ctx.ui.notify(
|
|
628
|
+
`pi-mcp: Authorize ${serverName}: ${url.toString()} (On SSH, forward the callback with ssh -L ${port}:127.0.0.1:${port}, or choose "${authPasteOption}".)`,
|
|
629
|
+
"info",
|
|
630
|
+
);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
ctx.ui.notify(
|
|
633
|
+
`pi-mcp: Failed to open browser for ${serverName} (${err instanceof Error ? err.message : String(err)}). Open ${url.toString()} manually or choose "${authPasteOption}".`,
|
|
634
|
+
"error",
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
418
639
|
const authProvider = new McpOAuthProvider(
|
|
419
640
|
serverName,
|
|
420
641
|
config.auth || { type: "oauth" },
|
|
421
|
-
|
|
422
|
-
latestAuthorizationUrl = new URL(url.toString());
|
|
423
|
-
if (!signal.aborted && !lockLost) (paths.browserOpen ?? openBrowser)(url.toString());
|
|
424
|
-
},
|
|
642
|
+
launchAuthorizationBrowser,
|
|
425
643
|
paths.authStorageDir,
|
|
426
644
|
false,
|
|
427
645
|
() => signal.aborted,
|
|
@@ -474,12 +692,6 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
474
692
|
// Auth succeeded without needing browser interaction (e.g., had valid tokens).
|
|
475
693
|
ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully. Starting MCP server...`, "info");
|
|
476
694
|
} else if (authResult === "REDIRECT") {
|
|
477
|
-
// Browser was opened, wait for the callback from the user
|
|
478
|
-
ctx.ui.notify(
|
|
479
|
-
`pi-mcp: Browser opened for ${serverName}. Complete authorization to continue. Choose Retry to reopen the browser or Cancel to stop authentication.`,
|
|
480
|
-
"info",
|
|
481
|
-
);
|
|
482
|
-
|
|
483
695
|
// 7. Wait for the callback while giving the user an escape hatch.
|
|
484
696
|
const code = await waitForOAuthCallback(
|
|
485
697
|
serverName,
|
|
@@ -492,9 +704,14 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
492
704
|
"protocol",
|
|
493
705
|
);
|
|
494
706
|
}
|
|
495
|
-
|
|
707
|
+
return launchAuthorizationBrowser(latestAuthorizationUrl);
|
|
496
708
|
},
|
|
497
709
|
ctx,
|
|
710
|
+
{
|
|
711
|
+
redirectUrl: authProvider.redirectUrl,
|
|
712
|
+
expectedState: oauthState,
|
|
713
|
+
onPasteResolved: cancel,
|
|
714
|
+
},
|
|
498
715
|
);
|
|
499
716
|
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
500
717
|
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
@@ -553,12 +770,21 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
553
770
|
}
|
|
554
771
|
}
|
|
555
772
|
|
|
556
|
-
async function connectRequested(
|
|
773
|
+
async function connectRequested(
|
|
774
|
+
serverName: string,
|
|
775
|
+
ctx: ExtensionContext,
|
|
776
|
+
signal?: AbortSignal,
|
|
777
|
+
reset = false,
|
|
778
|
+
alreadyQueued = false,
|
|
779
|
+
): Promise<void> {
|
|
557
780
|
const server = manager.getServer(serverName);
|
|
558
781
|
if (!server) throw new McpError(`Unknown server "${serverName}"`, serverName, "config");
|
|
559
782
|
if (signal?.aborted) throw new AuthCancelledError(serverName);
|
|
560
783
|
if (reset && !server.config.auth) throw new McpError(`Server "${serverName}" has no OAuth configuration`, serverName, "config");
|
|
561
784
|
if (reset && !ctx.hasUI) throw new AuthRequiredError(serverName);
|
|
785
|
+
if (!alreadyQueued) {
|
|
786
|
+
startupPromptControllers.get(serverName)?.abort();
|
|
787
|
+
}
|
|
562
788
|
|
|
563
789
|
const waitForAttempt = async (entry: { promise: Promise<void>; controller: AbortController; reset: boolean; waiters: number }): Promise<void> => {
|
|
564
790
|
entry.waiters++;
|
|
@@ -625,24 +851,35 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
625
851
|
if (!reset) await manager.startServer(serverName, ctx.cwd);
|
|
626
852
|
else throw new AuthRequiredError(serverName);
|
|
627
853
|
if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
|
|
854
|
+
declinedStartupAuth.delete(serverName);
|
|
855
|
+
if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
|
|
628
856
|
return;
|
|
629
857
|
} catch (err) {
|
|
630
858
|
if (!(err instanceof AuthRequiredError) || !server.config.auth) throw err;
|
|
631
859
|
}
|
|
632
860
|
if (!ctx.hasUI) throw new AuthRequiredError(serverName);
|
|
633
|
-
|
|
634
|
-
const previous = authQueue;
|
|
635
|
-
authQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
636
|
-
try {
|
|
637
|
-
await previous;
|
|
861
|
+
const runInteractiveAuth = async (): Promise<void> => {
|
|
638
862
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
639
863
|
await authorize(serverName, ctx, controller.signal, reset);
|
|
640
864
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
641
865
|
await manager.startServer(serverName, ctx.cwd);
|
|
642
866
|
if (controller.signal.aborted) { await manager.stopServer(serverName); throw new AuthCancelledError(serverName); }
|
|
643
|
-
}
|
|
644
|
-
|
|
867
|
+
};
|
|
868
|
+
if (alreadyQueued) {
|
|
869
|
+
await runInteractiveAuth();
|
|
870
|
+
} else {
|
|
871
|
+
let release!: () => void;
|
|
872
|
+
const previous = authQueue;
|
|
873
|
+
authQueue = new Promise<void>((resolve) => { release = resolve; });
|
|
874
|
+
try {
|
|
875
|
+
await previous;
|
|
876
|
+
await runInteractiveAuth();
|
|
877
|
+
} finally {
|
|
878
|
+
release();
|
|
879
|
+
}
|
|
645
880
|
}
|
|
881
|
+
declinedStartupAuth.delete(serverName);
|
|
882
|
+
if (declinedStartupAuth.size === 0) ctx.ui.setStatus?.("pi-mcp", undefined);
|
|
646
883
|
})().catch((err: unknown) => {
|
|
647
884
|
if (controller.signal.aborted) throw new AuthCancelledError(serverName);
|
|
648
885
|
throw err;
|
package/src/oauth-provider.ts
CHANGED
|
@@ -263,6 +263,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
263
263
|
...(state.clientInfo.client_secret && { client_secret: state.clientInfo.client_secret }),
|
|
264
264
|
};
|
|
265
265
|
}
|
|
266
|
+
if (this.silent) throw new AuthRequiredError(this.serverName);
|
|
266
267
|
return undefined;
|
|
267
268
|
}
|
|
268
269
|
|