pi-mcp-extension 1.5.0 → 1.5.2
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 +131 -35
- package/src/config.ts +1 -2
- package/src/index.ts +333 -135
- package/src/oauth-provider.ts +83 -27
- package/src/server-manager.ts +98 -95
- 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
|
+
}
|
package/src/callback-server.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
|
|
9
|
+
import type { Socket } from "net";
|
|
9
10
|
import { URL } from "url";
|
|
10
11
|
|
|
11
12
|
interface PendingAuth {
|
|
@@ -16,14 +17,30 @@ interface PendingAuth {
|
|
|
16
17
|
|
|
17
18
|
let server: Server | null = null;
|
|
18
19
|
let actualServerPort: number | null = null;
|
|
20
|
+
let actualServerHost: string | null = null;
|
|
21
|
+
const serverSockets = new Set<Socket>();
|
|
19
22
|
const pendingAuths = new Map<string, PendingAuth>();
|
|
20
23
|
|
|
21
24
|
const DEFAULT_PORT = 19876;
|
|
25
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
22
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
|
+
}
|
|
23
40
|
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
24
41
|
|
|
25
42
|
const HTML_SUCCESS = `<!DOCTYPE html>
|
|
26
|
-
<html><head><title>Pi - Authorization Successful</title>
|
|
43
|
+
<html><head><meta charset="utf-8"><title>Pi - Authorization Successful</title>
|
|
27
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}
|
|
28
45
|
.container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
|
|
29
46
|
</head><body>
|
|
@@ -32,7 +49,7 @@ const HTML_SUCCESS = `<!DOCTYPE html>
|
|
|
32
49
|
</body></html>`;
|
|
33
50
|
|
|
34
51
|
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
|
35
|
-
<html><head><title>Pi - Authorization Failed</title>
|
|
52
|
+
<html><head><meta charset="utf-8"><title>Pi - Authorization Failed</title>
|
|
36
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}
|
|
37
54
|
.container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
|
|
38
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,11 +73,11 @@ function escapeHtml(text: string): string {
|
|
|
56
73
|
* Handle incoming HTTP requests to the callback server.
|
|
57
74
|
*/
|
|
58
75
|
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
59
|
-
const url = new URL(req.url || "/",
|
|
76
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
60
77
|
|
|
61
78
|
// Only handle the callback path
|
|
62
79
|
if (url.pathname !== CALLBACK_PATH) {
|
|
63
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
80
|
+
res.writeHead(404, { "Content-Type": "text/plain", "Connection": "close" });
|
|
64
81
|
res.end("Not found");
|
|
65
82
|
return;
|
|
66
83
|
}
|
|
@@ -73,7 +90,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
|
73
90
|
// Enforce state parameter presence for CSRF protection
|
|
74
91
|
if (!state) {
|
|
75
92
|
const errorMsg = "Missing required state parameter - potential CSRF attack";
|
|
76
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
93
|
+
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
77
94
|
res.end(HTML_ERROR(errorMsg));
|
|
78
95
|
return;
|
|
79
96
|
}
|
|
@@ -81,7 +98,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
|
81
98
|
// Handle OAuth errors
|
|
82
99
|
if (error) {
|
|
83
100
|
const errorMsg = errorDescription || error;
|
|
84
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
101
|
+
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
|
|
85
102
|
res.end(HTML_ERROR(errorMsg));
|
|
86
103
|
if (pendingAuths.has(state)) {
|
|
87
104
|
const pending = pendingAuths.get(state)!;
|
|
@@ -94,7 +111,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
|
94
111
|
|
|
95
112
|
// Require authorization code
|
|
96
113
|
if (!code) {
|
|
97
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
114
|
+
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
98
115
|
res.end(HTML_ERROR("No authorization code provided"));
|
|
99
116
|
return;
|
|
100
117
|
}
|
|
@@ -102,7 +119,7 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
|
102
119
|
// Validate state parameter
|
|
103
120
|
if (!pendingAuths.has(state)) {
|
|
104
121
|
const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
|
|
105
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
122
|
+
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
106
123
|
res.end(HTML_ERROR(errorMsg));
|
|
107
124
|
return;
|
|
108
125
|
}
|
|
@@ -114,54 +131,137 @@ function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
|
114
131
|
pendingAuths.delete(state);
|
|
115
132
|
pending.resolve(code);
|
|
116
133
|
|
|
117
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
134
|
+
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
|
|
118
135
|
res.end(HTML_SUCCESS);
|
|
119
136
|
}
|
|
120
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
|
+
|
|
121
208
|
/**
|
|
122
209
|
* Ensure the callback server is running.
|
|
123
|
-
* Scans forward for an available local port
|
|
210
|
+
* Scans forward for an available local port when port fallback is enabled.
|
|
124
211
|
*
|
|
125
212
|
* @param preferredPort - The preferred port to use (default: 19876)
|
|
126
213
|
* @returns The actual port the server is listening on
|
|
127
214
|
*/
|
|
128
|
-
export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT): Promise<number> {
|
|
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
|
+
|
|
129
219
|
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
220
|
const address = server.address();
|
|
136
|
-
if (address && typeof address === "object" && "port" in address) {
|
|
221
|
+
if (actualServerPort === null && address && typeof address === "object" && "port" in address) {
|
|
137
222
|
actualServerPort = address.port;
|
|
138
|
-
return address.port;
|
|
139
223
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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();
|
|
143
237
|
}
|
|
144
238
|
|
|
145
|
-
const maxAttempts = 25; // Try up to 25 ports
|
|
239
|
+
const maxAttempts = allowPortFallback ? 25 : 1; // Try up to 25 ports unless a fixed redirect URI is configured
|
|
146
240
|
|
|
147
241
|
for (let offset = 0; offset < maxAttempts; offset++) {
|
|
148
242
|
const candidatePort = preferredPort + offset;
|
|
149
243
|
const candidateServer = createServer(handleRequest);
|
|
150
244
|
|
|
151
245
|
try {
|
|
246
|
+
candidateServer.on("connection", (socket: Socket) => {
|
|
247
|
+
serverSockets.add(socket);
|
|
248
|
+
socket.once("close", () => {
|
|
249
|
+
serverSockets.delete(socket);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
152
253
|
await new Promise<void>((resolve, reject) => {
|
|
153
254
|
candidateServer.once("error", (err: any) => {
|
|
154
255
|
reject(err);
|
|
155
256
|
});
|
|
156
257
|
|
|
157
|
-
|
|
158
|
-
candidateServer.listen(candidatePort, "127.0.0.1", () => {
|
|
258
|
+
candidateServer.listen(candidatePort, host, () => {
|
|
159
259
|
resolve();
|
|
160
260
|
});
|
|
161
261
|
});
|
|
162
|
-
|
|
163
262
|
server = candidateServer;
|
|
164
263
|
actualServerPort = candidatePort;
|
|
264
|
+
actualServerHost = host;
|
|
165
265
|
server.unref(); // Don't block process exit
|
|
166
266
|
return candidatePort;
|
|
167
267
|
} catch (error) {
|
|
@@ -174,6 +274,10 @@ export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT)
|
|
|
174
274
|
if (nodeError.code !== "EADDRINUSE") {
|
|
175
275
|
throw error;
|
|
176
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
|
+
}
|
|
177
281
|
}
|
|
178
282
|
}
|
|
179
283
|
|
|
@@ -224,15 +328,7 @@ export function cancelCallback(oauthState: string): void {
|
|
|
224
328
|
* Stop the callback server and reject all pending authorizations.
|
|
225
329
|
*/
|
|
226
330
|
export async function stopCallbackServer(): Promise<void> {
|
|
227
|
-
|
|
228
|
-
await new Promise<void>((resolve) => {
|
|
229
|
-
server!.close(() => {
|
|
230
|
-
resolve();
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
server = null;
|
|
234
|
-
actualServerPort = null;
|
|
235
|
-
}
|
|
331
|
+
await closeCurrentServer();
|
|
236
332
|
|
|
237
333
|
// Reject all pending auths (defer to allow any pending operations to complete)
|
|
238
334
|
const pendingList = Array.from(pendingAuths.entries());
|
|
@@ -260,4 +356,4 @@ export function getPendingAuthCount(): number {
|
|
|
260
356
|
}
|
|
261
357
|
|
|
262
358
|
// Export constants for testing/config
|
|
263
|
-
export { DEFAULT_PORT, CALLBACK_PATH, TIMEOUT_MS };
|
|
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([
|