pi-mcp-extension 1.4.1 → 1.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.
- package/package.json +1 -1
- package/src/callback-server.ts +263 -0
- package/src/index.ts +104 -11
- package/src/oauth-provider.ts +57 -6
package/package.json
CHANGED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth Callback Server
|
|
3
|
+
*
|
|
4
|
+
* Simple HTTP server that handles OAuth callbacks from the authorization server.
|
|
5
|
+
* Uses Node.js http module for compatibility with no external dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
|
|
9
|
+
import { URL } from "url";
|
|
10
|
+
|
|
11
|
+
interface PendingAuth {
|
|
12
|
+
resolve: (code: string) => void;
|
|
13
|
+
reject: (error: Error) => void;
|
|
14
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let server: Server | null = null;
|
|
18
|
+
let actualServerPort: number | null = null;
|
|
19
|
+
const pendingAuths = new Map<string, PendingAuth>();
|
|
20
|
+
|
|
21
|
+
const DEFAULT_PORT = 19876;
|
|
22
|
+
const CALLBACK_PATH = "/callback";
|
|
23
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
24
|
+
|
|
25
|
+
const HTML_SUCCESS = `<!DOCTYPE html>
|
|
26
|
+
<html><head><title>Pi - Authorization Successful</title>
|
|
27
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
|
|
28
|
+
.container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
|
|
29
|
+
</head><body>
|
|
30
|
+
<div class="container"><h1>✓ Authorization Successful</h1><p>You can close this window and return to Pi.</p></div>
|
|
31
|
+
<script>setTimeout(()=>window.close(),2000)</script>
|
|
32
|
+
</body></html>`;
|
|
33
|
+
|
|
34
|
+
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
|
35
|
+
<html><head><title>Pi - Authorization Failed</title>
|
|
36
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
|
|
37
|
+
.container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
|
|
38
|
+
.error{color:#fca5a5;font-family:monospace;margin-top:1rem;padding:1rem;background:rgba(248,113,113,0.1);border-radius:.5rem;white-space:pre-wrap;word-break:break-word}</style>
|
|
39
|
+
</head><body>
|
|
40
|
+
<div class="container"><h1>✗ Authorization Failed</h1><p>An error occurred during authorization.</p><div class="error">${escapeHtml(error)}</div></div>
|
|
41
|
+
</body></html>`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Escape HTML entities to prevent XSS attacks.
|
|
45
|
+
*/
|
|
46
|
+
function escapeHtml(text: string): string {
|
|
47
|
+
return text
|
|
48
|
+
.replace(/&/g, "&")
|
|
49
|
+
.replace(/</g, "<")
|
|
50
|
+
.replace(/>/g, ">")
|
|
51
|
+
.replace(/"/g, """)
|
|
52
|
+
.replace(/'/g, "'");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Handle incoming HTTP requests to the callback server.
|
|
57
|
+
*/
|
|
58
|
+
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
59
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
60
|
+
|
|
61
|
+
// Only handle the callback path
|
|
62
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
63
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
64
|
+
res.end("Not found");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const code = url.searchParams.get("code");
|
|
69
|
+
const state = url.searchParams.get("state");
|
|
70
|
+
const error = url.searchParams.get("error");
|
|
71
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
72
|
+
|
|
73
|
+
// Enforce state parameter presence for CSRF protection
|
|
74
|
+
if (!state) {
|
|
75
|
+
const errorMsg = "Missing required state parameter - potential CSRF attack";
|
|
76
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
77
|
+
res.end(HTML_ERROR(errorMsg));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle OAuth errors
|
|
82
|
+
if (error) {
|
|
83
|
+
const errorMsg = errorDescription || error;
|
|
84
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
85
|
+
res.end(HTML_ERROR(errorMsg));
|
|
86
|
+
if (pendingAuths.has(state)) {
|
|
87
|
+
const pending = pendingAuths.get(state)!;
|
|
88
|
+
clearTimeout(pending.timeout);
|
|
89
|
+
pendingAuths.delete(state);
|
|
90
|
+
setTimeout(() => pending.reject(new Error(errorMsg)), 0);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Require authorization code
|
|
96
|
+
if (!code) {
|
|
97
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
98
|
+
res.end(HTML_ERROR("No authorization code provided"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate state parameter
|
|
103
|
+
if (!pendingAuths.has(state)) {
|
|
104
|
+
const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
|
|
105
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
106
|
+
res.end(HTML_ERROR(errorMsg));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pending = pendingAuths.get(state)!;
|
|
111
|
+
|
|
112
|
+
// Clear timeout and resolve the pending promise
|
|
113
|
+
clearTimeout(pending.timeout);
|
|
114
|
+
pendingAuths.delete(state);
|
|
115
|
+
pending.resolve(code);
|
|
116
|
+
|
|
117
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
118
|
+
res.end(HTML_SUCCESS);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ensure the callback server is running.
|
|
123
|
+
* Scans forward for an available local port if the preferred port is busy.
|
|
124
|
+
*
|
|
125
|
+
* @param preferredPort - The preferred port to use (default: 19876)
|
|
126
|
+
* @returns The actual port the server is listening on
|
|
127
|
+
*/
|
|
128
|
+
export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT): Promise<number> {
|
|
129
|
+
if (server) {
|
|
130
|
+
// Already running, return the tracked actual port
|
|
131
|
+
if (actualServerPort !== null) {
|
|
132
|
+
return actualServerPort;
|
|
133
|
+
}
|
|
134
|
+
// Fallback: try to get the port from the server
|
|
135
|
+
const address = server.address();
|
|
136
|
+
if (address && typeof address === "object" && "port" in address) {
|
|
137
|
+
actualServerPort = address.port;
|
|
138
|
+
return address.port;
|
|
139
|
+
}
|
|
140
|
+
// If we still can't determine the port, return the preferred port
|
|
141
|
+
// (this shouldn't happen in practice)
|
|
142
|
+
return preferredPort;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const maxAttempts = 25; // Try up to 25 ports
|
|
146
|
+
|
|
147
|
+
for (let offset = 0; offset < maxAttempts; offset++) {
|
|
148
|
+
const candidatePort = preferredPort + offset;
|
|
149
|
+
const candidateServer = createServer(handleRequest);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await new Promise<void>((resolve, reject) => {
|
|
153
|
+
candidateServer.once("error", (err: any) => {
|
|
154
|
+
reject(err);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Bind to 127.0.0.1 explicitly (IPv4) to avoid issues with IPv6
|
|
158
|
+
candidateServer.listen(candidatePort, "127.0.0.1", () => {
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
server = candidateServer;
|
|
164
|
+
actualServerPort = candidatePort;
|
|
165
|
+
server.unref(); // Don't block process exit
|
|
166
|
+
return candidatePort;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const nodeError = error as NodeJS.ErrnoException;
|
|
169
|
+
await new Promise<void>((resolve) => {
|
|
170
|
+
candidateServer.close(() => resolve());
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// If not EADDRINUSE, rethrow
|
|
174
|
+
if (nodeError.code !== "EADDRINUSE") {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw new Error(
|
|
181
|
+
`OAuth callback port ${preferredPort} is already in use and no free port was found in range ${preferredPort}-${preferredPort + maxAttempts - 1}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wait for a callback with the given OAuth state.
|
|
187
|
+
* Returns a promise that resolves with the authorization code.
|
|
188
|
+
*
|
|
189
|
+
* @param oauthState - The OAuth state parameter to wait for
|
|
190
|
+
* @param timeoutMs - Timeout in milliseconds (default: 5 minutes)
|
|
191
|
+
* @returns Promise that resolves with the authorization code
|
|
192
|
+
*/
|
|
193
|
+
export function waitForCallback(
|
|
194
|
+
oauthState: string,
|
|
195
|
+
timeoutMs: number = TIMEOUT_MS
|
|
196
|
+
): Promise<string> {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const timeout = setTimeout(() => {
|
|
199
|
+
if (pendingAuths.has(oauthState)) {
|
|
200
|
+
pendingAuths.delete(oauthState);
|
|
201
|
+
reject(new Error("OAuth callback timeout - authorization took too long"));
|
|
202
|
+
}
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
|
|
205
|
+
pendingAuths.set(oauthState, { resolve, reject, timeout });
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Cancel a pending authorization by state.
|
|
211
|
+
*
|
|
212
|
+
* @param oauthState - The OAuth state to cancel
|
|
213
|
+
*/
|
|
214
|
+
export function cancelCallback(oauthState: string): void {
|
|
215
|
+
const pending = pendingAuths.get(oauthState);
|
|
216
|
+
if (pending) {
|
|
217
|
+
clearTimeout(pending.timeout);
|
|
218
|
+
pendingAuths.delete(oauthState);
|
|
219
|
+
pending.reject(new Error("Authorization cancelled"));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Stop the callback server and reject all pending authorizations.
|
|
225
|
+
*/
|
|
226
|
+
export async function stopCallbackServer(): Promise<void> {
|
|
227
|
+
if (server) {
|
|
228
|
+
await new Promise<void>((resolve) => {
|
|
229
|
+
server!.close(() => {
|
|
230
|
+
resolve();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
server = null;
|
|
234
|
+
actualServerPort = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Reject all pending auths (defer to allow any pending operations to complete)
|
|
238
|
+
const pendingList = Array.from(pendingAuths.entries());
|
|
239
|
+
pendingAuths.clear();
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
for (const [, pending] of pendingList) {
|
|
242
|
+
clearTimeout(pending.timeout);
|
|
243
|
+
pending.reject(new Error("OAuth callback server stopped"));
|
|
244
|
+
}
|
|
245
|
+
}, 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check if the callback server is running.
|
|
250
|
+
*/
|
|
251
|
+
export function isCallbackServerRunning(): boolean {
|
|
252
|
+
return server !== null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get the number of pending authorizations.
|
|
257
|
+
*/
|
|
258
|
+
export function getPendingAuthCount(): number {
|
|
259
|
+
return pendingAuths.size;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Export constants for testing/config
|
|
263
|
+
export { DEFAULT_PORT, CALLBACK_PATH, TIMEOUT_MS };
|
package/src/index.ts
CHANGED
|
@@ -14,9 +14,17 @@ import type { TransportAuthCallbacks } from "./server-manager.js";
|
|
|
14
14
|
import { ToolBridge } from "./tool-bridge.js";
|
|
15
15
|
import { McpError } from "./errors.js";
|
|
16
16
|
import { exec } from "node:child_process";
|
|
17
|
-
import { promisify } from "node:util";
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
// OAuth imports
|
|
19
|
+
import {
|
|
20
|
+
ensureCallbackServer,
|
|
21
|
+
waitForCallback,
|
|
22
|
+
cancelCallback,
|
|
23
|
+
stopCallbackServer,
|
|
24
|
+
} from "./callback-server.js";
|
|
25
|
+
import { setCallbackPort, McpOAuthProvider } from "./oauth-provider.js";
|
|
26
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
27
|
+
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
20
28
|
|
|
21
29
|
/**
|
|
22
30
|
* Open a URL in the user's default browser.
|
|
@@ -28,8 +36,15 @@ function openBrowser(url: string): void {
|
|
|
28
36
|
: process.platform === "win32"
|
|
29
37
|
? `start "" "${url}"`
|
|
30
38
|
: `xdg-open "${url}"`;
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
|
|
40
|
+
exec(cmd, (err, stdout, stderr) => {
|
|
41
|
+
if (err) {
|
|
42
|
+
const errorMsg = `[pi-mcp] Failed to open browser: ${err.message}`;
|
|
43
|
+
console.error(errorMsg);
|
|
44
|
+
if (stderr) {
|
|
45
|
+
console.error(`[pi-mcp] Browser error output: ${stderr}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
33
48
|
});
|
|
34
49
|
}
|
|
35
50
|
|
|
@@ -116,6 +131,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
116
131
|
});
|
|
117
132
|
|
|
118
133
|
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
134
|
+
// Stop the callback server
|
|
135
|
+
await stopCallbackServer().catch(() => {});
|
|
136
|
+
|
|
119
137
|
// Deactivate all tools before shutting down servers
|
|
120
138
|
for (const server of manager.getAllServers()) {
|
|
121
139
|
bridge.deactivateServer(server.name);
|
|
@@ -245,33 +263,108 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
245
263
|
return;
|
|
246
264
|
}
|
|
247
265
|
|
|
266
|
+
const config = server.config;
|
|
267
|
+
let oauthState: string | undefined;
|
|
268
|
+
|
|
248
269
|
try {
|
|
249
270
|
// Stop the server if running
|
|
250
271
|
if (server.state !== "stopped") {
|
|
251
272
|
bridge.deactivateServer(serverName);
|
|
252
273
|
await manager.stopServer(serverName);
|
|
253
274
|
}
|
|
275
|
+
|
|
276
|
+
// Validate that we have a server URL (required for OAuth)
|
|
277
|
+
if (!config.url) {
|
|
278
|
+
throw new McpError(
|
|
279
|
+
`Server "${serverName}" has OAuth configured but no URL. OAuth requires a URL-based server transport.`,
|
|
280
|
+
serverName,
|
|
281
|
+
"config",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
254
285
|
// Reset all OAuth credentials (tokens, client info, PKCE, discovery)
|
|
255
286
|
await manager.resetServerAuth(serverName);
|
|
287
|
+
|
|
256
288
|
ctx.ui.notify(
|
|
257
|
-
`pi-mcp:
|
|
289
|
+
`pi-mcp: Starting OAuth flow for ${serverName}...`,
|
|
258
290
|
"info",
|
|
259
291
|
);
|
|
260
|
-
// Start the server — this triggers the OAuth flow and opens the browser
|
|
261
|
-
await manager.startServer(serverName, ctx.cwd);
|
|
262
292
|
|
|
263
|
-
|
|
264
|
-
|
|
293
|
+
// 1. Start the callback server
|
|
294
|
+
const port = await ensureCallbackServer();
|
|
295
|
+
setCallbackPort(port);
|
|
296
|
+
|
|
297
|
+
// 2. Generate a cryptographically secure state parameter for CSRF protection
|
|
298
|
+
oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
299
|
+
.map((b: number) => b.toString(16).padStart(2, "0"))
|
|
300
|
+
.join("");
|
|
301
|
+
|
|
302
|
+
// 3. Register the callback promise BEFORE opening the browser
|
|
303
|
+
const callbackPromise = waitForCallback(oauthState);
|
|
304
|
+
|
|
305
|
+
// 4. Create auth provider and transport
|
|
306
|
+
const authProvider = new McpOAuthProvider(
|
|
307
|
+
serverName,
|
|
308
|
+
config.auth || { type: "oauth" },
|
|
309
|
+
(url: URL) => {
|
|
310
|
+
console.error(`[pi-mcp] Opening browser for ${serverName}...`);
|
|
311
|
+
openBrowser(url.toString());
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// CRITICAL FIX #1: Set the OAuth state on the provider before calling auth()
|
|
316
|
+
// This ensures the state parameter is included in the authorization URL
|
|
317
|
+
authProvider.setState(oauthState);
|
|
318
|
+
|
|
319
|
+
const transport = new StreamableHTTPClientTransport(
|
|
320
|
+
new URL(config.url),
|
|
321
|
+
{ authProvider },
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// 5. Start the auth flow - this will trigger redirectToAuthorization which opens the browser
|
|
325
|
+
// CRITICAL FIX #2: Check the return value of auth() instead of catching UnauthorizedError
|
|
326
|
+
// The SDK returns 'REDIRECT' when it needs browser interaction, not an error
|
|
327
|
+
const authResult = await auth(authProvider, { serverUrl: config.url });
|
|
328
|
+
|
|
329
|
+
if (authResult === "AUTHORIZED") {
|
|
330
|
+
// Auth succeeded without needing browser interaction (e.g., had valid tokens)
|
|
265
331
|
ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully!`, "info");
|
|
266
|
-
} else {
|
|
332
|
+
} else if (authResult === "REDIRECT") {
|
|
333
|
+
// Browser was opened, wait for the callback from the user
|
|
267
334
|
ctx.ui.notify(
|
|
268
|
-
`pi-mcp: ${serverName}
|
|
335
|
+
`pi-mcp: Browser opened for ${serverName}. Complete authorization to continue...`,
|
|
269
336
|
"info",
|
|
270
337
|
);
|
|
338
|
+
|
|
339
|
+
// 6. Wait for the callback (this blocks until the user authorizes)
|
|
340
|
+
const code = await callbackPromise;
|
|
341
|
+
|
|
342
|
+
// 7. Complete the OAuth flow with the authorization code
|
|
343
|
+
await transport.finishAuth(code);
|
|
344
|
+
|
|
345
|
+
ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully!`, "info");
|
|
346
|
+
} else {
|
|
347
|
+
throw new McpError(
|
|
348
|
+
`Unexpected auth result: ${authResult}`,
|
|
349
|
+
serverName,
|
|
350
|
+
"protocol",
|
|
351
|
+
);
|
|
271
352
|
}
|
|
353
|
+
|
|
354
|
+
// 8. Close the transport (we'll create a new one when starting the server)
|
|
355
|
+
await transport.close().catch(() => {});
|
|
356
|
+
|
|
357
|
+
// 9. Start the server with fresh auth credentials
|
|
358
|
+
await manager.startServer(serverName, ctx.cwd);
|
|
359
|
+
|
|
272
360
|
} catch (err) {
|
|
273
361
|
const msg = err instanceof McpError ? err.userMessage : String(err);
|
|
274
362
|
ctx.ui.notify(`pi-mcp: Authentication failed for ${serverName} — ${msg}`, "error");
|
|
363
|
+
|
|
364
|
+
// Clean up on error
|
|
365
|
+
if (oauthState) {
|
|
366
|
+
cancelCallback(oauthState);
|
|
367
|
+
}
|
|
275
368
|
}
|
|
276
369
|
},
|
|
277
370
|
});
|
package/src/oauth-provider.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
15
15
|
import type { OAuthClientMetadata, OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
16
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
16
|
+
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { createHash } from "node:crypto";
|
|
@@ -43,6 +43,20 @@ export interface AuthConfig {
|
|
|
43
43
|
clientSecret?: string | undefined;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// ─── Callback Server Port ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
let callbackPort = 19876;
|
|
49
|
+
|
|
50
|
+
/** Set the callback server port. Called by the auth flow when the server starts. */
|
|
51
|
+
export function setCallbackPort(port: number): void {
|
|
52
|
+
callbackPort = port;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Get the current callback server port. */
|
|
56
|
+
export function getCallbackPort(): number {
|
|
57
|
+
return callbackPort;
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
// ─── Persistent State Types ───────────────────────────────────────────────────
|
|
47
61
|
|
|
48
62
|
interface StoredClientInfo {
|
|
@@ -115,6 +129,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
115
129
|
private authConfig: AuthConfig;
|
|
116
130
|
private _redirectUrl: string | undefined;
|
|
117
131
|
private _onAuthRequired: ((url: URL) => void | Promise<void>) | undefined;
|
|
132
|
+
private _oauthState: string | undefined;
|
|
118
133
|
|
|
119
134
|
constructor(
|
|
120
135
|
serverName: string,
|
|
@@ -129,16 +144,19 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
129
144
|
|
|
130
145
|
// --- redirectUrl ---
|
|
131
146
|
|
|
132
|
-
get redirectUrl(): string | URL
|
|
133
|
-
|
|
147
|
+
get redirectUrl(): string | URL {
|
|
148
|
+
// Use configured redirect URL if provided, otherwise use the callback server
|
|
149
|
+
// Use 127.0.0.1 explicitly (IPv4) to match the callback server binding
|
|
150
|
+
return this._redirectUrl || `http://127.0.0.1:${callbackPort}/callback`;
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
// --- clientMetadata ---
|
|
137
154
|
|
|
138
155
|
get clientMetadata(): OAuthClientMetadata {
|
|
156
|
+
const redirectUrl = String(this.redirectUrl);
|
|
139
157
|
return {
|
|
140
158
|
client_name: `pi-mcp/${this.serverName}`,
|
|
141
|
-
redirect_uris:
|
|
159
|
+
redirect_uris: [redirectUrl],
|
|
142
160
|
grant_types: ["authorization_code", "refresh_token"],
|
|
143
161
|
response_types: ["code"],
|
|
144
162
|
token_endpoint_auth_method: this.authConfig.clientSecret ? "client_secret_basic" : "none",
|
|
@@ -182,6 +200,21 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
182
200
|
async tokens(): Promise<OAuthTokens | undefined> {
|
|
183
201
|
const state = await loadState(this.serverName);
|
|
184
202
|
if (!state.tokens) return undefined;
|
|
203
|
+
|
|
204
|
+
// Always return stored tokens — even if expired.
|
|
205
|
+
// The SDK's auth() function checks tokens?.refresh_token and attempts
|
|
206
|
+
// silent refresh before falling back to a new authorization flow.
|
|
207
|
+
// Returning undefined for expired tokens would prevent that silent refresh
|
|
208
|
+
// and force the user to re-authenticate via browser every time.
|
|
209
|
+
//
|
|
210
|
+
// Flow when we return expired tokens:
|
|
211
|
+
// transport sends expired access_token → 401
|
|
212
|
+
// → auth() sees refresh_token → silent refresh → success → retry
|
|
213
|
+
//
|
|
214
|
+
// Flow when we return undefined (WRONG):
|
|
215
|
+
// transport has no token → auth() → no refresh possible → REDIRECT
|
|
216
|
+
// → user must re-authenticate in browser
|
|
217
|
+
|
|
185
218
|
// Build OAuthTokens — only include defined fields
|
|
186
219
|
const tokens: Record<string, string> = {
|
|
187
220
|
access_token: state.tokens.access_token,
|
|
@@ -249,6 +282,25 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
249
282
|
return state.discoveryState;
|
|
250
283
|
}
|
|
251
284
|
|
|
285
|
+
// --- OAuth state parameter (CSRF protection) ---
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Set the OAuth state parameter before calling auth().
|
|
289
|
+
* This should be called with a cryptographically random value.
|
|
290
|
+
*/
|
|
291
|
+
setState(state: string): void {
|
|
292
|
+
this._oauthState = state;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Returns the OAuth state parameter for CSRF protection.
|
|
297
|
+
* This is called by the SDK's auth() function when building the authorization URL.
|
|
298
|
+
* Returns empty string if no state has been set (no CSRF protection).
|
|
299
|
+
*/
|
|
300
|
+
async state(): Promise<string> {
|
|
301
|
+
return this._oauthState || "";
|
|
302
|
+
}
|
|
303
|
+
|
|
252
304
|
// --- Credential invalidation ---
|
|
253
305
|
|
|
254
306
|
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
|
|
@@ -311,6 +363,5 @@ export async function getAuthStatus(serverName: string): Promise<{
|
|
|
311
363
|
* Used to force re-authorization on next connection.
|
|
312
364
|
*/
|
|
313
365
|
export async function resetAuth(serverName: string): Promise<void> {
|
|
314
|
-
|
|
315
|
-
await provider.invalidateCredentials("all");
|
|
366
|
+
await unlink(statePath(serverName)).catch(() => {});
|
|
316
367
|
}
|