@pi-archimedes/mcp 2.3.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/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local OAuth callback server for the `authorization_code` flow.
|
|
3
|
+
*
|
|
4
|
+
* The provider redirects the browser back to
|
|
5
|
+
* `http://localhost:<port>/callback?code=...&state=...[&iss=...]`. This
|
|
6
|
+
* singleton `node:http` server (bound to 127.0.0.1 only) validates the CSRF
|
|
7
|
+
* `state` against waiters registered via {@link waitForCallback} (or states
|
|
8
|
+
* pre-accepted via {@link reserveAuthState}) and hands the code straight to
|
|
9
|
+
* the waiting promise — the code is never stored anywhere.
|
|
10
|
+
*
|
|
11
|
+
* Bind lifecycle:
|
|
12
|
+
* - `ensureCallbackServer({ port: 0 })` binds an OS-assigned port (dynamic
|
|
13
|
+
* clients); `strictPort` binds the exact requested port (pre-registered
|
|
14
|
+
* clients with a fixed redirect URI) and rejects with a clear error naming
|
|
15
|
+
* the port on EADDRINUSE/EACCES.
|
|
16
|
+
* - A `bindingPromise` mutex serializes concurrent binds. Joiners that
|
|
17
|
+
* requested the same target inherit its outcome (the resolved port on
|
|
18
|
+
* success, the clear error on failure); different-target callers wait out
|
|
19
|
+
* the in-flight bind but never inherit its failure, then rebind for
|
|
20
|
+
* themselves. A generation counter invalidates binds started before a
|
|
21
|
+
* `stopCallbackServer()`, so a stop racing an in-flight bind can never
|
|
22
|
+
* resurrect a dead server.
|
|
23
|
+
*
|
|
24
|
+
* Cancellation: `waitForCallback(state, signal)` rejects with
|
|
25
|
+
* `"OAuth cancelled"` when the signal aborts, and `"OAuth callback timed
|
|
26
|
+
* out"` after {@link CALLBACK_TIMEOUT_MS}.
|
|
27
|
+
*
|
|
28
|
+
* No external dependencies — `node:http`, `node:url` only.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createServer } from "node:http";
|
|
32
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
33
|
+
import type { AddressInfo } from "node:net";
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_CALLBACK_PORT = 19876;
|
|
36
|
+
export const DEFAULT_CALLBACK_PATH = "/callback";
|
|
37
|
+
/** How long a waiter blocks for the browser to complete the redirect. */
|
|
38
|
+
export const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/** Successful callback resolution handed to the waiting auth flow. */
|
|
41
|
+
export interface OAuthCallbackResult {
|
|
42
|
+
code: string;
|
|
43
|
+
/** RFC 9207 issuer echoed in the redirect (multi-tenant providers). */
|
|
44
|
+
iss?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EnsureCallbackServerOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Bind exactly `port`. On EADDRINUSE/EACCES the returned promise rejects
|
|
50
|
+
* with a clear error naming the port instead of falling back to a
|
|
51
|
+
* different one.
|
|
52
|
+
*/
|
|
53
|
+
strictPort?: boolean;
|
|
54
|
+
/** Port to bind; `0` (and the default) lets the OS assign a free port. */
|
|
55
|
+
port?: number;
|
|
56
|
+
/** Path the handler accepts; default `/callback`. */
|
|
57
|
+
path?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The server holds an OAuth authorization code — loopback only. */
|
|
61
|
+
const HOST = "127.0.0.1";
|
|
62
|
+
|
|
63
|
+
interface RunningServer {
|
|
64
|
+
server: Server;
|
|
65
|
+
/** Actual bound port (resolved from the OS for port 0). */
|
|
66
|
+
port: number;
|
|
67
|
+
path: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface PendingAuth {
|
|
71
|
+
resolve: (result: OAuthCallbackResult) => void;
|
|
72
|
+
reject: (error: Error) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let running: RunningServer | undefined;
|
|
76
|
+
/** In-flight bind (mutex): concurrent ensureCallbackServer calls join it. */
|
|
77
|
+
let bindingPromise: Promise<number> | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* The requested target (port + path) of `bindingPromise`. Set together with
|
|
80
|
+
* the promise, cleared when the bind settles; lets joiners tell a
|
|
81
|
+
* same-target join (inherit the outcome) from a foreign-target wait (never
|
|
82
|
+
* inherit that bind's failure).
|
|
83
|
+
*/
|
|
84
|
+
let bindingTarget: { port: number; path: string } | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Bumped by stopCallbackServer(). A bind whose generation is stale at the
|
|
87
|
+
* listening/error moment has been stopped mid-flight and must fail instead
|
|
88
|
+
* of re-arming the singleton.
|
|
89
|
+
*/
|
|
90
|
+
let callbackGeneration = 0;
|
|
91
|
+
/** States a `waitForCallback` waiter is waiting on (map value wins). */
|
|
92
|
+
const pendingAuths = new Map<string, PendingAuth>();
|
|
93
|
+
/** States accepted by the handler before a waiter registers on them. */
|
|
94
|
+
const reservedAuthStates = new Set<string>();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Callback port: `MCP_OAUTH_CALLBACK_PORT` override, else 19876.
|
|
98
|
+
* Invalid values (non-numeric, out of 1–65535) fall back to the default.
|
|
99
|
+
*/
|
|
100
|
+
export function getCallbackPort(): number {
|
|
101
|
+
const raw = process.env.MCP_OAUTH_CALLBACK_PORT;
|
|
102
|
+
if (raw === undefined) return DEFAULT_CALLBACK_PORT;
|
|
103
|
+
const parsed = Number(raw);
|
|
104
|
+
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535) return parsed;
|
|
105
|
+
return DEFAULT_CALLBACK_PORT;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getCallbackPath(): string {
|
|
109
|
+
return DEFAULT_CALLBACK_PATH;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Mark `oauthState` as an accepted CSRF state before a waiter exists for it
|
|
114
|
+
* (keeps it accepted even once a waiter registers).
|
|
115
|
+
*/
|
|
116
|
+
export function reserveAuthState(oauthState: string): void {
|
|
117
|
+
reservedAuthStates.add(oauthState);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Ensure the singleton callback server is bound; resolves the actual
|
|
122
|
+
* listening port. Reuses the running server when it satisfies the request
|
|
123
|
+
* (same path; same exact port when `strictPort`). A rebind is required for a
|
|
124
|
+
* different target, in which case the old server is stopped first.
|
|
125
|
+
*/
|
|
126
|
+
export async function ensureCallbackServer(
|
|
127
|
+
opts: EnsureCallbackServerOptions = {},
|
|
128
|
+
): Promise<number> {
|
|
129
|
+
const desiredPort = opts.port ?? 0;
|
|
130
|
+
const desiredPath = opts.path ?? getCallbackPath();
|
|
131
|
+
const strict = opts.strictPort ?? false;
|
|
132
|
+
|
|
133
|
+
const satisfies = (s: RunningServer): boolean =>
|
|
134
|
+
s.path === desiredPath && (!strict || s.port === desiredPort);
|
|
135
|
+
|
|
136
|
+
// Join any in-flight bind, re-checking singleton state after each await.
|
|
137
|
+
// While we wait, that bind may settle and a NEW bind may have started (a
|
|
138
|
+
// different-target caller rebound right after a swallowed foreign
|
|
139
|
+
// failure). Reusing the original captured promise to justify a second
|
|
140
|
+
// `bindNewServer` here would bump the generation counter and invalidate
|
|
141
|
+
// the newer still-in-flight bind, spuriously rejecting one of two
|
|
142
|
+
// legitimate callers with "OAuth callback server stopped" — so the loop
|
|
143
|
+
// rejoins a newer in-flight bind instead, keeping the `bindingPromise`
|
|
144
|
+
// mutex serializing concurrent binds.
|
|
145
|
+
while (true) {
|
|
146
|
+
// Reuses the running server when it satisfies the request (same path;
|
|
147
|
+
// same exact port when `strictPort`).
|
|
148
|
+
if (running && satisfies(running)) {
|
|
149
|
+
return running.port;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const pendingBind = bindingPromise;
|
|
153
|
+
if (!pendingBind) {
|
|
154
|
+
break; // nothing in flight — rebind below
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A bind may be in flight: same-target joiners inherit its outcome
|
|
158
|
+
// (the resolved port on success, the clear error on failure);
|
|
159
|
+
// different-target callers wait it out but never inherit its failure —
|
|
160
|
+
// they rebind below.
|
|
161
|
+
const sameTarget =
|
|
162
|
+
bindingTarget?.port === desiredPort && bindingTarget?.path === desiredPath;
|
|
163
|
+
try {
|
|
164
|
+
await pendingBind;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (sameTarget) throw error;
|
|
167
|
+
// A foreign target's bind failed — not this caller's error. Loop
|
|
168
|
+
// re-checks for a newer in-flight bind before rebounding.
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// Success: loop re-checks `running`/satisfies at the top and returns
|
|
172
|
+
// the right port (or falls through to rebind if the server stopped
|
|
173
|
+
// meanwhile).
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (running) {
|
|
177
|
+
await stopCallbackServer();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return bindNewServer(desiredPort, desiredPath);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function bindNewServer(port: number, path: string): Promise<number> {
|
|
184
|
+
const generation = ++callbackGeneration;
|
|
185
|
+
const server = createServer(handleRequest);
|
|
186
|
+
|
|
187
|
+
const binding = new Promise<number>((resolve, reject) => {
|
|
188
|
+
let settled = false;
|
|
189
|
+
|
|
190
|
+
const releaseMutex = (): void => {
|
|
191
|
+
if (bindingPromise === binding) {
|
|
192
|
+
bindingPromise = undefined;
|
|
193
|
+
bindingTarget = undefined;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const fail = (error: Error): void => {
|
|
197
|
+
if (settled) return;
|
|
198
|
+
settled = true;
|
|
199
|
+
releaseMutex();
|
|
200
|
+
reject(error);
|
|
201
|
+
};
|
|
202
|
+
const succeed = (actualPort: number): void => {
|
|
203
|
+
if (settled) return;
|
|
204
|
+
settled = true;
|
|
205
|
+
releaseMutex();
|
|
206
|
+
resolve(actualPort);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
server.once("error", (error: NodeJS.ErrnoException) => {
|
|
210
|
+
if (generation !== callbackGeneration) {
|
|
211
|
+
// Stopped while binding — stopCallbackServer() already took over.
|
|
212
|
+
server.close(() => undefined);
|
|
213
|
+
fail(new Error("OAuth callback server stopped"));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
fail(describeBindError(error, port));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
server.listen(port, HOST, () => {
|
|
220
|
+
if (generation !== callbackGeneration) {
|
|
221
|
+
// Stopped mid-bind: never resurrect a dead server.
|
|
222
|
+
server.close(() => undefined);
|
|
223
|
+
fail(new Error("OAuth callback server stopped"));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const address = server.address();
|
|
227
|
+
const actualPort =
|
|
228
|
+
address !== null && typeof address !== "string" ? address.port : port;
|
|
229
|
+
running = { server, port: actualPort, path };
|
|
230
|
+
succeed(actualPort);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
bindingTarget = { port, path };
|
|
235
|
+
bindingPromise = binding;
|
|
236
|
+
return binding;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function describeBindError(error: NodeJS.ErrnoException, port: number): Error {
|
|
240
|
+
switch (error.code) {
|
|
241
|
+
case "EADDRINUSE":
|
|
242
|
+
return new Error(
|
|
243
|
+
`Cannot bind OAuth callback server: port ${port} is already in use — ` +
|
|
244
|
+
`stop the other process or change the callback port ` +
|
|
245
|
+
`(MCP_OAUTH_CALLBACK_PORT, or the pre-registered redirect URI)`,
|
|
246
|
+
);
|
|
247
|
+
case "EACCES":
|
|
248
|
+
return new Error(
|
|
249
|
+
`Cannot bind OAuth callback server: no permission to bind port ${port}`,
|
|
250
|
+
);
|
|
251
|
+
default:
|
|
252
|
+
return new Error(`Cannot bind OAuth callback server on port ${port}: ${error.message}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Wait for the callback for `oauthState`. Resolves `{ code, iss? }`.
|
|
258
|
+
*
|
|
259
|
+
* Rejects with:
|
|
260
|
+
* - `"OAuth callback timed out"` after {@link CALLBACK_TIMEOUT_MS}
|
|
261
|
+
* - `"OAuth cancelled"` when `signal` aborts (immediately, if already aborted)
|
|
262
|
+
* - `"OAuth callback server stopped"` when the singleton is stopped
|
|
263
|
+
* - the provider's `error` / `error_description` for a rejected redirect
|
|
264
|
+
*
|
|
265
|
+
* Registering a second waiter for the same state supersedes the first
|
|
266
|
+
* (one state per flow); the superseded waiter rejects.
|
|
267
|
+
*/
|
|
268
|
+
export function waitForCallback(
|
|
269
|
+
oauthState: string,
|
|
270
|
+
signal?: AbortSignal,
|
|
271
|
+
): Promise<OAuthCallbackResult> {
|
|
272
|
+
if (signal?.aborted) {
|
|
273
|
+
return Promise.reject(new Error("OAuth cancelled"));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// A waiter now owns the state; a prior reservation is redundant.
|
|
277
|
+
reservedAuthStates.delete(oauthState);
|
|
278
|
+
|
|
279
|
+
return new Promise<OAuthCallbackResult>((resolve, reject) => {
|
|
280
|
+
let settled = false;
|
|
281
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
282
|
+
|
|
283
|
+
const onAbort = (): void => {
|
|
284
|
+
finish(() => reject(new Error("OAuth cancelled")));
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** Settle at most once; releases the timer, abort listener, and state map entry. */
|
|
288
|
+
const finish = (settle: () => void): void => {
|
|
289
|
+
if (settled) return;
|
|
290
|
+
settled = true;
|
|
291
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
292
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
293
|
+
if (pendingAuths.get(oauthState) === entry) {
|
|
294
|
+
pendingAuths.delete(oauthState);
|
|
295
|
+
}
|
|
296
|
+
settle();
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const entry: PendingAuth = {
|
|
300
|
+
resolve: (result) => finish(() => resolve(result)),
|
|
301
|
+
reject: (error) => finish(() => reject(error)),
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// A state belongs to one flow: a new waiter supersedes the old one.
|
|
305
|
+
const superseded = pendingAuths.get(oauthState);
|
|
306
|
+
pendingAuths.set(oauthState, entry);
|
|
307
|
+
if (superseded) {
|
|
308
|
+
// Its own finish() clears the map entry (ownership check keeps the new
|
|
309
|
+
// entry in place), the timer, and any abort listener.
|
|
310
|
+
superseded.reject(new Error("OAuth callback waiter superseded"));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
timer = setTimeout(() => {
|
|
314
|
+
finish(() => reject(new Error("OAuth callback timed out")));
|
|
315
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
316
|
+
|
|
317
|
+
if (signal) {
|
|
318
|
+
signal.addEventListener("abort", onAbort);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Stop the singleton: bumps the generation (killing in-flight binds), rejects
|
|
325
|
+
* every pending waiter with `"OAuth callback server stopped"`, and closes the
|
|
326
|
+
* server. Resolves once the server is fully closed. No-op when not running.
|
|
327
|
+
*/
|
|
328
|
+
export async function stopCallbackServer(): Promise<void> {
|
|
329
|
+
callbackGeneration++;
|
|
330
|
+
|
|
331
|
+
const inFlight = bindingPromise;
|
|
332
|
+
bindingPromise = undefined;
|
|
333
|
+
bindingTarget = undefined;
|
|
334
|
+
|
|
335
|
+
for (const [state, pending] of [...pendingAuths]) {
|
|
336
|
+
pendingAuths.delete(state);
|
|
337
|
+
pending.reject(new Error("OAuth callback server stopped"));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const current = running;
|
|
341
|
+
running = undefined;
|
|
342
|
+
|
|
343
|
+
if (inFlight) {
|
|
344
|
+
// The stale bind detects the generation change, closes its own server,
|
|
345
|
+
// and rejects — swallow so an unawaited bind cannot crash the process.
|
|
346
|
+
inFlight.catch(() => undefined);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!current) return;
|
|
350
|
+
await closeServer(current.server);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function closeServer(server: Server): Promise<void> {
|
|
354
|
+
return new Promise<void>((resolve) => {
|
|
355
|
+
server.close(() => resolve());
|
|
356
|
+
// Kill idle keep-alive sockets so close() completes promptly.
|
|
357
|
+
server.closeAllConnections?.();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Never throws out to the http server (a thrown handler would surface as a 500 crash). */
|
|
362
|
+
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
363
|
+
try {
|
|
364
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
365
|
+
const path = running?.path ?? getCallbackPath();
|
|
366
|
+
|
|
367
|
+
if (url.pathname !== path) {
|
|
368
|
+
sendText(res, 404, "Not found");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const code = url.searchParams.get("code");
|
|
373
|
+
const iss = url.searchParams.get("iss");
|
|
374
|
+
const state = url.searchParams.get("state");
|
|
375
|
+
const error = url.searchParams.get("error");
|
|
376
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
377
|
+
|
|
378
|
+
// CSRF: state is required; identical responses do not leak which
|
|
379
|
+
// states are pending, reserved, or unknown.
|
|
380
|
+
if (!state) {
|
|
381
|
+
sendText(res, 400, "Missing required state parameter");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const pending = pendingAuths.get(state);
|
|
386
|
+
const isReserved = pending === undefined && reservedAuthStates.has(state);
|
|
387
|
+
|
|
388
|
+
if (error) {
|
|
389
|
+
if (!pending && !isReserved) {
|
|
390
|
+
sendText(res, 400, "Invalid or expired state parameter");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const message = errorDescription || error;
|
|
394
|
+
if (pending) {
|
|
395
|
+
pendingAuths.delete(state);
|
|
396
|
+
pending.reject(new Error(message));
|
|
397
|
+
} else {
|
|
398
|
+
reservedAuthStates.delete(state);
|
|
399
|
+
}
|
|
400
|
+
sendHtml(res, 200, htmlError(message));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!pending && !isReserved) {
|
|
405
|
+
sendText(res, 400, "Invalid or expired state parameter");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!code) {
|
|
410
|
+
if (pending) {
|
|
411
|
+
pendingAuths.delete(state);
|
|
412
|
+
pending.reject(
|
|
413
|
+
new Error(
|
|
414
|
+
"No authorization code received — re-run the auth flow and, if prompted, " +
|
|
415
|
+
"paste the full callback URL from your browser address bar",
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
} else {
|
|
419
|
+
reservedAuthStates.delete(state);
|
|
420
|
+
}
|
|
421
|
+
sendHtml(
|
|
422
|
+
res,
|
|
423
|
+
200,
|
|
424
|
+
htmlManual(
|
|
425
|
+
"The redirect completed without an authorization code. Re-run the auth " +
|
|
426
|
+
"command in your terminal; if it prompts, paste the full callback URL " +
|
|
427
|
+
"from your browser address bar.",
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (pending) {
|
|
434
|
+
pendingAuths.delete(state);
|
|
435
|
+
pending.resolve({ code, ...(iss ? { iss } : {}) });
|
|
436
|
+
sendHtml(res, 200, htmlSuccess());
|
|
437
|
+
} else {
|
|
438
|
+
// Accepted state but no live waiter (e.g. pi restarted mid-flow):
|
|
439
|
+
// hand the hand-off off to the user manually.
|
|
440
|
+
reservedAuthStates.delete(state);
|
|
441
|
+
sendHtml(
|
|
442
|
+
res,
|
|
443
|
+
200,
|
|
444
|
+
htmlManual(
|
|
445
|
+
"Authorization succeeded, but no flow was waiting in the terminal. " +
|
|
446
|
+
"Copy the full callback URL from your browser address bar, start the " +
|
|
447
|
+
"auth command again, and paste it when prompted.",
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
} catch {
|
|
452
|
+
try {
|
|
453
|
+
sendText(res, 500, "Internal error");
|
|
454
|
+
} catch {
|
|
455
|
+
// Response already destroyed — nothing left to do.
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function sendText(res: ServerResponse, status: number, body: string): void {
|
|
461
|
+
if (res.writableEnded) return;
|
|
462
|
+
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
|
|
463
|
+
res.end(body);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function sendHtml(res: ServerResponse, status: number, html: string): void {
|
|
467
|
+
if (res.writableEnded) return;
|
|
468
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
469
|
+
res.end(html);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Self-contained, no external assets, light/dark aware via system colors. */
|
|
473
|
+
function htmlPage(
|
|
474
|
+
title: string,
|
|
475
|
+
badgeClass: "ok" | "err" | "info",
|
|
476
|
+
badge: string,
|
|
477
|
+
message: string,
|
|
478
|
+
autoCloseMs?: number,
|
|
479
|
+
): string {
|
|
480
|
+
const script = autoCloseMs
|
|
481
|
+
? `\n<script>\n\tsetTimeout(() => {\n\t\ttry { window.close(); } catch {\n\t\t\t/* browsers block programmatic close; the user closes manually */\n\t\t}\n\t}, ${autoCloseMs});\n</script>\n`
|
|
482
|
+
: "";
|
|
483
|
+
return `<!doctype html>
|
|
484
|
+
<html lang="en">
|
|
485
|
+
<head>
|
|
486
|
+
<meta charset="utf-8" />
|
|
487
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
488
|
+
<meta name="color-scheme" content="light dark" />
|
|
489
|
+
<title>${escapeHtml(title)}</title>
|
|
490
|
+
<style>
|
|
491
|
+
:root { color-scheme: light dark; }
|
|
492
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: Canvas; color: CanvasText; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
493
|
+
.card { max-width: 26rem; margin: 1rem; padding: 2rem 1.75rem; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 12px; text-align: center; }
|
|
494
|
+
.badge { display: inline-block; margin-bottom: 0.75rem; padding: 0.2rem 0.7rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
|
|
495
|
+
.ok { color: #1a7f37; background: rgba(26, 127, 55, 0.12); }
|
|
496
|
+
.err { color: #c62828; background: rgba(198, 40, 40, 0.12); }
|
|
497
|
+
.info { color: CanvasText; border: 1px solid color-mix(in srgb, currentColor 40%, transparent); }
|
|
498
|
+
h1 { margin: 0 0 0.5rem; font-size: 1.25rem; }
|
|
499
|
+
p { margin: 0; line-height: 1.5; font-size: 0.95rem; }
|
|
500
|
+
</style>
|
|
501
|
+
</head>
|
|
502
|
+
<body>
|
|
503
|
+
<div class="card">
|
|
504
|
+
<span class="badge ${badgeClass}">${escapeHtml(badge)}</span>
|
|
505
|
+
<h1>${escapeHtml(title)}</h1>
|
|
506
|
+
<p>${escapeHtml(message)}</p>
|
|
507
|
+
</div>${script}
|
|
508
|
+
</body>
|
|
509
|
+
</html>
|
|
510
|
+
`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function htmlSuccess(): string {
|
|
514
|
+
return htmlPage(
|
|
515
|
+
"pi MCP — authentication complete",
|
|
516
|
+
"ok",
|
|
517
|
+
"Success",
|
|
518
|
+
"The OAuth flow completed. You can close this window and return to the terminal.",
|
|
519
|
+
2000,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function htmlError(message: string): string {
|
|
524
|
+
return htmlPage("pi MCP — authentication failed", "err", "Error", message);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function htmlManual(note: string): string {
|
|
528
|
+
return htmlPage("pi MCP — manual hand-off", "info", "Copy & paste", note);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function escapeHtml(value: string): string {
|
|
532
|
+
return value
|
|
533
|
+
.replaceAll("&", "&")
|
|
534
|
+
.replaceAll("<", "<")
|
|
535
|
+
.replaceAll(">", ">")
|
|
536
|
+
.replaceAll('"', """)
|
|
537
|
+
.replaceAll("'", "'");
|
|
538
|
+
}
|