pi-mcp-extension 1.4.1 → 1.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/README.md +25 -5
- package/package.json +1 -1
- package/src/auth-challenge.ts +60 -0
- package/src/callback-server.ts +359 -0
- package/src/config.ts +1 -2
- package/src/index.ts +382 -91
- package/src/oauth-provider.ts +138 -31
- package/src/server-manager.ts +87 -98
- package/src/tool-bridge.ts +2 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
- **Global + project config** — Layered config (project overrides global) with per-server tuning
|
|
22
22
|
- **Tool annotations** — `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` surfaced in tool descriptions
|
|
23
23
|
- **Structured error handling** — Distinct error codes for config, connection, protocol, and tool-level failures
|
|
24
|
+
- **OAuth 2.1** — Browser authorization with PKCE, protected-resource discovery, token refresh, and secure local persistence
|
|
24
25
|
|
|
25
26
|
## Installation
|
|
26
27
|
|
|
@@ -80,6 +81,9 @@ Config files are loaded from two locations. **Project config overrides global co
|
|
|
80
81
|
"supabase": {
|
|
81
82
|
"transport": "streamable-http",
|
|
82
83
|
"url": "https://mcp.supabase.com/mcp",
|
|
84
|
+
"auth": {
|
|
85
|
+
"type": "oauth"
|
|
86
|
+
},
|
|
83
87
|
"lifecycle": "eager"
|
|
84
88
|
},
|
|
85
89
|
"deepsource": {
|
|
@@ -137,18 +141,34 @@ Config files are loaded from two locations. **Project config overrides global co
|
|
|
137
141
|
| `args` | `string[]` | `[]` | Arguments for the command |
|
|
138
142
|
| `env` | `Record<string, string>` | — | Extra environment variables for the child process |
|
|
139
143
|
| `url` | `string` | — | Server URL (**required** for streamable-http/sse) |
|
|
140
|
-
| `
|
|
144
|
+
| `headers` | `Record<string, string>` | — | Static headers sent with HTTP and SSE requests |
|
|
145
|
+
| `auth` | `object` | — | OAuth configuration described below |
|
|
146
|
+
| `lifecycle` | `"eager" \| "lazy"` | `"lazy"` | `eager` = auto-start on session start, `lazy` = connect on request via `/mcp:start` |
|
|
141
147
|
| `requestTimeoutMs` | `number` | global setting | Per-server timeout override |
|
|
142
148
|
| `healthCheckIntervalMs` | `number` | disabled | Opt-in ping interval for connection health monitoring |
|
|
143
149
|
|
|
150
|
+
### OAuth Config
|
|
151
|
+
|
|
152
|
+
| Field | Type | Default | Description |
|
|
153
|
+
|---|---|---|---|
|
|
154
|
+
| `type` | `"oauth"` | `"oauth"` | Authentication type |
|
|
155
|
+
| `scope` | `string` | discovered | Requested scope when the server does not advertise one |
|
|
156
|
+
| `clientId` | `string` | dynamic registration | Pre-registered OAuth client ID |
|
|
157
|
+
| `clientSecret` | `string` | — | Secret for a pre-registered confidential client |
|
|
158
|
+
| `redirectUrl` | `string` | `http://127.0.0.1:19876/callback` | Local HTTP loopback callback used by `/mcp:auth` |
|
|
159
|
+
|
|
144
160
|
## Commands
|
|
145
161
|
|
|
146
162
|
| Command | Description |
|
|
147
163
|
|---|---|
|
|
148
164
|
| `/mcp` | Show status summary of all configured servers |
|
|
149
165
|
| `/mcp <name>` | Show detailed status and stderr log for a specific server |
|
|
150
|
-
| `/mcp:start <name>` |
|
|
151
|
-
| `/mcp:stop <name>` |
|
|
166
|
+
| `/mcp:start <name>` | Connect a server and wait for tool discovery |
|
|
167
|
+
| `/mcp:stop <name>` | Cancel a pending connection or authorization, or stop a running server and deactivate its tools |
|
|
168
|
+
| `/mcp:auth <name>` | Connect with stored OAuth credentials; request browser authorization only if needed |
|
|
169
|
+
| `/mcp:auth <name> --reset` | Discard stored OAuth credentials and request fresh authorization |
|
|
170
|
+
|
|
171
|
+
Repeated credential resets share one pending authorization; stop or shutdown cancels it. Lazy servers remain stopped until requested. An interactive Pi session is required when browser authorization is needed. Eager startup never opens a browser. Retry opens the browser again only when selected in the OAuth prompt.
|
|
152
172
|
|
|
153
173
|
## How It Works
|
|
154
174
|
|
|
@@ -176,7 +196,7 @@ Config files are loaded from two locations. **Project config overrides global co
|
|
|
176
196
|
```
|
|
177
197
|
|
|
178
198
|
1. **Config is loaded** from global and project files (project overrides global by server name)
|
|
179
|
-
2. **Eager servers connect** at session start; lazy servers wait for `/mcp:start`
|
|
199
|
+
2. **Eager servers connect** at session start without opening browsers; lazy servers wait for `/mcp:start`
|
|
180
200
|
3. **Tools are discovered** via paginated `tools/list` calls (cursor-based, up to 100 pages)
|
|
181
201
|
4. **JSON Schema → TypeBox** conversion registers tools with Pi-compatible parameter schemas
|
|
182
202
|
5. **Pi tools are registered** as `<prefix>_<server>_<tool>` (sanitized, max 64 chars with hash suffix)
|
|
@@ -217,7 +237,7 @@ npm install
|
|
|
217
237
|
# Type check (strict mode)
|
|
218
238
|
npm run typecheck
|
|
219
239
|
|
|
220
|
-
# Run all tests
|
|
240
|
+
# Run all tests
|
|
221
241
|
npm test
|
|
222
242
|
|
|
223
243
|
# Run integration tests only (real stdio server)
|
package/package.json
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth challenge discovery helpers for manual MCP authentication commands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { extractWWWAuthenticateParams } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
6
|
+
|
|
7
|
+
export interface ManualAuthChallenge {
|
|
8
|
+
resourceMetadataUrl?: URL | undefined;
|
|
9
|
+
scope?: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AuthChallengeFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
export const AUTH_CHALLENGE_TIMEOUT_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
export interface ManualAuthChallengeOptions {
|
|
17
|
+
fetchFn?: AuthChallengeFetch | undefined;
|
|
18
|
+
timeoutMs?: number | undefined;
|
|
19
|
+
headers?: Record<string, string> | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Discover OAuth challenge parameters from an MCP resource server.
|
|
24
|
+
*
|
|
25
|
+
* Some resource servers expose the protected resource metadata URL only in the
|
|
26
|
+
* WWW-Authenticate header of the initial 401 response. Manual auth must carry
|
|
27
|
+
* those values into the SDK auth flow so discovery reaches the real
|
|
28
|
+
* authorization server instead of falling back to the resource server origin.
|
|
29
|
+
*/
|
|
30
|
+
export async function discoverManualAuthChallenge(
|
|
31
|
+
serverUrl: string,
|
|
32
|
+
options: ManualAuthChallengeOptions = {},
|
|
33
|
+
): Promise<ManualAuthChallenge> {
|
|
34
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
35
|
+
const headers = new Headers(options.headers);
|
|
36
|
+
headers.set("Accept", "application/json, text/event-stream");
|
|
37
|
+
const response = await fetchFn(serverUrl, {
|
|
38
|
+
method: "GET",
|
|
39
|
+
headers,
|
|
40
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? AUTH_CHALLENGE_TIMEOUT_MS),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (response.status !== 401 && response.status !== 403) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
|
|
49
|
+
if (response.status === 403 && error !== "insufficient_scope") {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
...(resourceMetadataUrl ? { resourceMetadataUrl } : {}),
|
|
55
|
+
...(scope ? { scope } : {}),
|
|
56
|
+
};
|
|
57
|
+
} finally {
|
|
58
|
+
await response.body?.cancel().catch(() => {});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
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 type { Socket } from "net";
|
|
10
|
+
import { URL } from "url";
|
|
11
|
+
|
|
12
|
+
interface PendingAuth {
|
|
13
|
+
resolve: (code: string) => void;
|
|
14
|
+
reject: (error: Error) => void;
|
|
15
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let server: Server | null = null;
|
|
19
|
+
let actualServerPort: number | null = null;
|
|
20
|
+
let actualServerHost: string | null = null;
|
|
21
|
+
const serverSockets = new Set<Socket>();
|
|
22
|
+
const pendingAuths = new Map<string, PendingAuth>();
|
|
23
|
+
|
|
24
|
+
const DEFAULT_PORT = 19876;
|
|
25
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
26
|
+
const CALLBACK_PATH = "/callback";
|
|
27
|
+
|
|
28
|
+
export interface CallbackServerOptions {
|
|
29
|
+
/** Host to bind. Defaults to 127.0.0.1 for local-only callbacks. */
|
|
30
|
+
host?: string | undefined;
|
|
31
|
+
/** Whether to scan following ports when the preferred port is busy. */
|
|
32
|
+
allowPortFallback?: boolean | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CallbackServerConfig {
|
|
36
|
+
preferredPort: number;
|
|
37
|
+
host: string;
|
|
38
|
+
allowPortFallback: boolean;
|
|
39
|
+
}
|
|
40
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
41
|
+
|
|
42
|
+
const HTML_SUCCESS = `<!DOCTYPE html>
|
|
43
|
+
<html><head><meta charset="utf-8"><title>Pi - Authorization Successful</title>
|
|
44
|
+
<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}
|
|
45
|
+
.container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
|
|
46
|
+
</head><body>
|
|
47
|
+
<div class="container"><h1>✓ Authorization Successful</h1><p>You can close this window and return to Pi.</p></div>
|
|
48
|
+
<script>setTimeout(()=>window.close(),2000)</script>
|
|
49
|
+
</body></html>`;
|
|
50
|
+
|
|
51
|
+
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
|
52
|
+
<html><head><meta charset="utf-8"><title>Pi - Authorization Failed</title>
|
|
53
|
+
<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}
|
|
54
|
+
.container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
|
|
55
|
+
.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>
|
|
56
|
+
</head><body>
|
|
57
|
+
<div class="container"><h1>✗ Authorization Failed</h1><p>An error occurred during authorization.</p><div class="error">${escapeHtml(error)}</div></div>
|
|
58
|
+
</body></html>`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Escape HTML entities to prevent XSS attacks.
|
|
62
|
+
*/
|
|
63
|
+
function escapeHtml(text: string): string {
|
|
64
|
+
return text
|
|
65
|
+
.replace(/&/g, "&")
|
|
66
|
+
.replace(/</g, "<")
|
|
67
|
+
.replace(/>/g, ">")
|
|
68
|
+
.replace(/"/g, """)
|
|
69
|
+
.replace(/'/g, "'");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Handle incoming HTTP requests to the callback server.
|
|
74
|
+
*/
|
|
75
|
+
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
76
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
77
|
+
|
|
78
|
+
// Only handle the callback path
|
|
79
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
80
|
+
res.writeHead(404, { "Content-Type": "text/plain", "Connection": "close" });
|
|
81
|
+
res.end("Not found");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const code = url.searchParams.get("code");
|
|
86
|
+
const state = url.searchParams.get("state");
|
|
87
|
+
const error = url.searchParams.get("error");
|
|
88
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
89
|
+
|
|
90
|
+
// Enforce state parameter presence for CSRF protection
|
|
91
|
+
if (!state) {
|
|
92
|
+
const errorMsg = "Missing required state parameter - potential CSRF attack";
|
|
93
|
+
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
94
|
+
res.end(HTML_ERROR(errorMsg));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Handle OAuth errors
|
|
99
|
+
if (error) {
|
|
100
|
+
const errorMsg = errorDescription || error;
|
|
101
|
+
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
|
|
102
|
+
res.end(HTML_ERROR(errorMsg));
|
|
103
|
+
if (pendingAuths.has(state)) {
|
|
104
|
+
const pending = pendingAuths.get(state)!;
|
|
105
|
+
clearTimeout(pending.timeout);
|
|
106
|
+
pendingAuths.delete(state);
|
|
107
|
+
setTimeout(() => pending.reject(new Error(errorMsg)), 0);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Require authorization code
|
|
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)!;
|
|
128
|
+
|
|
129
|
+
// Clear timeout and resolve the pending promise
|
|
130
|
+
clearTimeout(pending.timeout);
|
|
131
|
+
pendingAuths.delete(state);
|
|
132
|
+
pending.resolve(code);
|
|
133
|
+
|
|
134
|
+
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
|
|
135
|
+
res.end(HTML_SUCCESS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isLocalCallbackHost(hostname: string): boolean {
|
|
139
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function closeCurrentServer(): Promise<void> {
|
|
143
|
+
if (!server) return;
|
|
144
|
+
|
|
145
|
+
const currentServer = server;
|
|
146
|
+
server = null;
|
|
147
|
+
actualServerPort = null;
|
|
148
|
+
actualServerHost = null;
|
|
149
|
+
|
|
150
|
+
for (const socket of Array.from(serverSockets)) {
|
|
151
|
+
socket.destroy();
|
|
152
|
+
}
|
|
153
|
+
serverSockets.clear();
|
|
154
|
+
|
|
155
|
+
await new Promise<void>((resolve) => {
|
|
156
|
+
currentServer.close(() => {
|
|
157
|
+
resolve();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build callback server settings from the configured OAuth redirect URL.
|
|
164
|
+
*
|
|
165
|
+
* Local HTTP redirect URLs must use a fixed listener port because OAuth
|
|
166
|
+
* providers require exact redirect URI registration. When no local redirect URL
|
|
167
|
+
* is configured, the callback server keeps the historical default behavior and
|
|
168
|
+
* scans forward from the default port if needed.
|
|
169
|
+
*/
|
|
170
|
+
export function callbackServerConfigFromRedirectUrl(redirectUrl?: string): CallbackServerConfig {
|
|
171
|
+
if (!redirectUrl) {
|
|
172
|
+
return {
|
|
173
|
+
preferredPort: DEFAULT_PORT,
|
|
174
|
+
host: DEFAULT_HOST,
|
|
175
|
+
allowPortFallback: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const url = new URL(redirectUrl);
|
|
180
|
+
if (url.protocol !== "http:" || !isLocalCallbackHost(url.hostname)) {
|
|
181
|
+
throw new Error("Manual OAuth redirect URL must use HTTP with localhost, 127.0.0.1, or ::1");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (url.hash) {
|
|
185
|
+
throw new Error("Manual OAuth redirect URL must not include a fragment");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (url.username || url.password) {
|
|
189
|
+
throw new Error("Manual OAuth redirect URL must not include credentials");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
193
|
+
throw new Error(`Local OAuth redirect URL must use path ${CALLBACK_PATH}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const preferredPort = Number(url.port || "80");
|
|
197
|
+
if (preferredPort === 0) {
|
|
198
|
+
throw new Error("Manual OAuth redirect URL must use a fixed non-zero port");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
preferredPort,
|
|
203
|
+
host: url.hostname === "[::1]" ? "::1" : url.hostname,
|
|
204
|
+
allowPortFallback: false,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Ensure the callback server is running.
|
|
210
|
+
* Scans forward for an available local port when port fallback is enabled.
|
|
211
|
+
*
|
|
212
|
+
* @param preferredPort - The preferred port to use (default: 19876)
|
|
213
|
+
* @returns The actual port the server is listening on
|
|
214
|
+
*/
|
|
215
|
+
export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT, options: CallbackServerOptions = {}): Promise<number> {
|
|
216
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
217
|
+
const allowPortFallback = options.allowPortFallback ?? true;
|
|
218
|
+
|
|
219
|
+
if (server) {
|
|
220
|
+
const address = server.address();
|
|
221
|
+
if (actualServerPort === null && address && typeof address === "object" && "port" in address) {
|
|
222
|
+
actualServerPort = address.port;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const serverMatchesRequest = actualServerPort === preferredPort && actualServerHost === host;
|
|
226
|
+
if (serverMatchesRequest && actualServerPort !== null) {
|
|
227
|
+
return actualServerPort;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (pendingAuths.size > 0) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`OAuth callback server is already handling an authorization flow on ${actualServerHost}:${actualServerPort}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await closeCurrentServer();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const maxAttempts = allowPortFallback ? 25 : 1; // Try up to 25 ports unless a fixed redirect URI is configured
|
|
240
|
+
|
|
241
|
+
for (let offset = 0; offset < maxAttempts; offset++) {
|
|
242
|
+
const candidatePort = preferredPort + offset;
|
|
243
|
+
const candidateServer = createServer(handleRequest);
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
candidateServer.on("connection", (socket: Socket) => {
|
|
247
|
+
serverSockets.add(socket);
|
|
248
|
+
socket.once("close", () => {
|
|
249
|
+
serverSockets.delete(socket);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await new Promise<void>((resolve, reject) => {
|
|
254
|
+
candidateServer.once("error", (err: any) => {
|
|
255
|
+
reject(err);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
candidateServer.listen(candidatePort, host, () => {
|
|
259
|
+
resolve();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
server = candidateServer;
|
|
263
|
+
actualServerPort = candidatePort;
|
|
264
|
+
actualServerHost = host;
|
|
265
|
+
server.unref(); // Don't block process exit
|
|
266
|
+
return candidatePort;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const nodeError = error as NodeJS.ErrnoException;
|
|
269
|
+
await new Promise<void>((resolve) => {
|
|
270
|
+
candidateServer.close(() => resolve());
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// If not EADDRINUSE, rethrow
|
|
274
|
+
if (nodeError.code !== "EADDRINUSE") {
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!allowPortFallback) {
|
|
279
|
+
throw new Error(`OAuth callback port ${preferredPort} is already in use, and redirect URL requires this exact port`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
throw new Error(
|
|
285
|
+
`OAuth callback port ${preferredPort} is already in use and no free port was found in range ${preferredPort}-${preferredPort + maxAttempts - 1}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Wait for a callback with the given OAuth state.
|
|
291
|
+
* Returns a promise that resolves with the authorization code.
|
|
292
|
+
*
|
|
293
|
+
* @param oauthState - The OAuth state parameter to wait for
|
|
294
|
+
* @param timeoutMs - Timeout in milliseconds (default: 5 minutes)
|
|
295
|
+
* @returns Promise that resolves with the authorization code
|
|
296
|
+
*/
|
|
297
|
+
export function waitForCallback(
|
|
298
|
+
oauthState: string,
|
|
299
|
+
timeoutMs: number = TIMEOUT_MS
|
|
300
|
+
): Promise<string> {
|
|
301
|
+
return new Promise((resolve, reject) => {
|
|
302
|
+
const timeout = setTimeout(() => {
|
|
303
|
+
if (pendingAuths.has(oauthState)) {
|
|
304
|
+
pendingAuths.delete(oauthState);
|
|
305
|
+
reject(new Error("OAuth callback timeout - authorization took too long"));
|
|
306
|
+
}
|
|
307
|
+
}, timeoutMs);
|
|
308
|
+
|
|
309
|
+
pendingAuths.set(oauthState, { resolve, reject, timeout });
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Cancel a pending authorization by state.
|
|
315
|
+
*
|
|
316
|
+
* @param oauthState - The OAuth state to cancel
|
|
317
|
+
*/
|
|
318
|
+
export function cancelCallback(oauthState: string): void {
|
|
319
|
+
const pending = pendingAuths.get(oauthState);
|
|
320
|
+
if (pending) {
|
|
321
|
+
clearTimeout(pending.timeout);
|
|
322
|
+
pendingAuths.delete(oauthState);
|
|
323
|
+
pending.reject(new Error("Authorization cancelled"));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Stop the callback server and reject all pending authorizations.
|
|
329
|
+
*/
|
|
330
|
+
export async function stopCallbackServer(): Promise<void> {
|
|
331
|
+
await closeCurrentServer();
|
|
332
|
+
|
|
333
|
+
// Reject all pending auths (defer to allow any pending operations to complete)
|
|
334
|
+
const pendingList = Array.from(pendingAuths.entries());
|
|
335
|
+
pendingAuths.clear();
|
|
336
|
+
setTimeout(() => {
|
|
337
|
+
for (const [, pending] of pendingList) {
|
|
338
|
+
clearTimeout(pending.timeout);
|
|
339
|
+
pending.reject(new Error("OAuth callback server stopped"));
|
|
340
|
+
}
|
|
341
|
+
}, 0);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Check if the callback server is running.
|
|
346
|
+
*/
|
|
347
|
+
export function isCallbackServerRunning(): boolean {
|
|
348
|
+
return server !== null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get the number of pending authorizations.
|
|
353
|
+
*/
|
|
354
|
+
export function getPendingAuthCount(): number {
|
|
355
|
+
return pendingAuths.size;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Export constants for testing/config
|
|
359
|
+
export { DEFAULT_PORT, DEFAULT_HOST, CALLBACK_PATH, TIMEOUT_MS };
|
package/src/config.ts
CHANGED
|
@@ -170,8 +170,7 @@ function mergeConfigs(
|
|
|
170
170
|
* Project config takes precedence over global config.
|
|
171
171
|
* Returns a fully validated, merged config.
|
|
172
172
|
*/
|
|
173
|
-
export async function loadConfig(cwd: string): Promise<McpConfig> {
|
|
174
|
-
const globalPath = join(homedir(), ".pi", "agent", "mcp.json");
|
|
173
|
+
export async function loadConfig(cwd: string, globalPath = join(homedir(), ".pi", "agent", "mcp.json")): Promise<McpConfig> {
|
|
175
174
|
const projectPath = join(cwd, ".pi", "mcp.json");
|
|
176
175
|
|
|
177
176
|
const [globalRaw, projectRaw] = await Promise.all([
|