pi-mcp-extension 1.7.2 → 1.7.3
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/auth-lock.ts +426 -0
- package/src/index.ts +25 -7
- package/src/oauth-provider.ts +146 -90
package/package.json
CHANGED
package/src/auth-lock.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process per-server interactive OAuth authorization lock.
|
|
3
|
+
*
|
|
4
|
+
* Uses an atomic lock file (<hash>.json.lock, mode 0600) containing a
|
|
5
|
+
* versioned JSON record (PID, hostname, random nonce, creation timestamp)
|
|
6
|
+
* plus a periodic mtime heartbeat. Automatically reclaims locks left behind
|
|
7
|
+
* by crashed or SIGKILLed processes using a nonce-verified rename protocol.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
open,
|
|
12
|
+
readFile,
|
|
13
|
+
stat,
|
|
14
|
+
rename,
|
|
15
|
+
unlink,
|
|
16
|
+
link,
|
|
17
|
+
utimes,
|
|
18
|
+
mkdir,
|
|
19
|
+
chmod,
|
|
20
|
+
} from "node:fs/promises";
|
|
21
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
22
|
+
import { hostname as osHostname } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { agentDir } from "./config.js";
|
|
27
|
+
|
|
28
|
+
// ─── Constants & Schema ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export const HEARTBEAT_MS = 30_000;
|
|
31
|
+
export const STALE_MS = 90_000; // 3 × 30s heartbeat
|
|
32
|
+
|
|
33
|
+
export const LockRecordSchema = z.object({
|
|
34
|
+
v: z.literal(1),
|
|
35
|
+
pid: z.number().int().positive(),
|
|
36
|
+
hostname: z.string().min(1),
|
|
37
|
+
nonce: z.string().min(1),
|
|
38
|
+
createdAt: z.number(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export type LockRecord = z.output<typeof LockRecordSchema>;
|
|
42
|
+
|
|
43
|
+
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export class AuthRequiredError extends Error {
|
|
46
|
+
constructor(
|
|
47
|
+
serverName: string,
|
|
48
|
+
message = `Authorization required for ${serverName}. Connect in an interactive Pi session or use /mcp:auth ${serverName}.`,
|
|
49
|
+
) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "AuthRequiredError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class AuthLockError extends AuthRequiredError {
|
|
56
|
+
constructor(serverName: string, details?: string) {
|
|
57
|
+
const message = details
|
|
58
|
+
? details.startsWith("Authorization lock") || details.startsWith("Another Pi process")
|
|
59
|
+
? details
|
|
60
|
+
: `Another Pi process (${details}) is authorizing ${serverName}. Retry after it finishes.`
|
|
61
|
+
: `Another Pi process is authorizing ${serverName}. Retry after it finishes.`;
|
|
62
|
+
super(serverName, message);
|
|
63
|
+
this.name = "AuthLockError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Dependency Injection Types ───────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export interface AuthLockFileHandle {
|
|
70
|
+
writeFile(data: string): Promise<void>;
|
|
71
|
+
sync(): Promise<void>;
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AuthLockFs {
|
|
76
|
+
open(path: string, flags: string, mode?: number): Promise<AuthLockFileHandle>;
|
|
77
|
+
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
78
|
+
stat(path: string): Promise<{ mtimeMs: number }>;
|
|
79
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
80
|
+
unlink(path: string): Promise<void>;
|
|
81
|
+
link(existingPath: string, newPath: string): Promise<void>;
|
|
82
|
+
utimes(path: string, atime: number | string | Date, mtime: number | string | Date): Promise<void>;
|
|
83
|
+
mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined | void>;
|
|
84
|
+
chmod(path: string, mode: number): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AuthLockTimer {
|
|
88
|
+
unref?: () => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AuthLockClock {
|
|
92
|
+
now(): number;
|
|
93
|
+
setInterval(callback: () => void, ms: number): ReturnType<typeof setInterval> | AuthLockTimer;
|
|
94
|
+
clearInterval(timer: any): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface AuthLockDeps {
|
|
98
|
+
fs: AuthLockFs;
|
|
99
|
+
clock: AuthLockClock;
|
|
100
|
+
pid: number;
|
|
101
|
+
hostname: string;
|
|
102
|
+
isPidAlive: (pid: number) => boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface AcquireAuthLockOptions {
|
|
106
|
+
onLost?: (() => void) | undefined;
|
|
107
|
+
deps?: Partial<AuthLockDeps> | undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type AuthLockHandle = (() => Promise<void>) & {
|
|
111
|
+
assertHeld: () => Promise<void>;
|
|
112
|
+
isLost: () => boolean;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ─── Path & Default Dependency Helpers ────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export function authDir(storageDir?: string): string {
|
|
118
|
+
return storageDir ?? join(agentDir(), "mcp-auth");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function statePath(serverName: string, storageDir?: string): string {
|
|
122
|
+
// Hash the server name to avoid filesystem issues with special chars
|
|
123
|
+
const hash = createHash("sha256").update(serverName).digest("hex").slice(0, 16);
|
|
124
|
+
return join(authDir(storageDir), `${hash}.json`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function lockPath(serverName: string, storageDir?: string): string {
|
|
128
|
+
return `${statePath(serverName, storageDir)}.lock`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function defaultIsPidAlive(pid: number): boolean {
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, 0);
|
|
134
|
+
return true;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createDefaultAuthLockDeps(): AuthLockDeps {
|
|
141
|
+
return {
|
|
142
|
+
fs: {
|
|
143
|
+
open: (path, flags, mode) => open(path, flags, mode),
|
|
144
|
+
readFile: (path, encoding) => readFile(path, encoding),
|
|
145
|
+
stat: (path) => stat(path),
|
|
146
|
+
rename: (oldPath, newPath) => rename(oldPath, newPath),
|
|
147
|
+
unlink: (path) => unlink(path),
|
|
148
|
+
link: (existingPath, newPath) => link(existingPath, newPath),
|
|
149
|
+
utimes: (path, atime, mtime) => utimes(path, atime, mtime),
|
|
150
|
+
mkdir: (path, options) => mkdir(path, options),
|
|
151
|
+
chmod: (path, mode) => chmod(path, mode),
|
|
152
|
+
},
|
|
153
|
+
clock: {
|
|
154
|
+
now: () => Date.now(),
|
|
155
|
+
setInterval: (cb, ms) => setInterval(cb, ms),
|
|
156
|
+
clearInterval: (timer) => clearInterval(timer),
|
|
157
|
+
},
|
|
158
|
+
pid: process.pid,
|
|
159
|
+
hostname: osHostname(),
|
|
160
|
+
isPidAlive: defaultIsPidAlive,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Pure Staleness Policy ────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
export function parseLockRecord(raw: string): LockRecord | null {
|
|
167
|
+
if (!raw) return null;
|
|
168
|
+
try {
|
|
169
|
+
const parsed: unknown = JSON.parse(raw);
|
|
170
|
+
const result = LockRecordSchema.safeParse(parsed);
|
|
171
|
+
return result.success ? result.data : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pure policy determining whether an existing lock file is stale and safe to reclaim.
|
|
179
|
+
*/
|
|
180
|
+
export function isLockStale(
|
|
181
|
+
record: LockRecord | null,
|
|
182
|
+
mtimeMs: number,
|
|
183
|
+
now: number,
|
|
184
|
+
self: { hostname: string; isPidAlive: (pid: number) => boolean },
|
|
185
|
+
): boolean {
|
|
186
|
+
if (now - mtimeMs > STALE_MS) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (record === null) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (record.hostname === self.hostname && !self.isPidAlive(record.pid)) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatHolderDetails(record: LockRecord | null): string | undefined {
|
|
199
|
+
if (!record) return undefined;
|
|
200
|
+
return `pid ${record.pid} on ${record.hostname}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Lock Acquisition, Heartbeat & Release ────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
206
|
+
|
|
207
|
+
export async function acquireAuthLock(
|
|
208
|
+
serverName: string,
|
|
209
|
+
storageDir?: string,
|
|
210
|
+
options?: AcquireAuthLockOptions,
|
|
211
|
+
): Promise<AuthLockHandle> {
|
|
212
|
+
const defaults = createDefaultAuthLockDeps();
|
|
213
|
+
const deps: AuthLockDeps = {
|
|
214
|
+
fs: options?.deps?.fs ?? defaults.fs,
|
|
215
|
+
clock: options?.deps?.clock ?? defaults.clock,
|
|
216
|
+
pid: options?.deps?.pid ?? defaults.pid,
|
|
217
|
+
hostname: options?.deps?.hostname ?? defaults.hostname,
|
|
218
|
+
isPidAlive: options?.deps?.isPidAlive ?? defaults.isPidAlive,
|
|
219
|
+
};
|
|
220
|
+
const usingRealFs = options?.deps?.fs === undefined;
|
|
221
|
+
|
|
222
|
+
const directory = authDir(storageDir);
|
|
223
|
+
await deps.fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
224
|
+
await deps.fs.chmod(directory, 0o700);
|
|
225
|
+
const path = lockPath(serverName, storageDir);
|
|
226
|
+
|
|
227
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
228
|
+
const myRecord: LockRecord = {
|
|
229
|
+
v: 1,
|
|
230
|
+
pid: deps.pid,
|
|
231
|
+
hostname: deps.hostname,
|
|
232
|
+
nonce: randomUUID(),
|
|
233
|
+
createdAt: deps.clock.now(),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const file = await deps.fs.open(path, "wx", 0o600);
|
|
238
|
+
try {
|
|
239
|
+
await file.writeFile(JSON.stringify(myRecord));
|
|
240
|
+
await file.sync();
|
|
241
|
+
} catch (writeErr) {
|
|
242
|
+
await file.close().catch(() => {});
|
|
243
|
+
await deps.fs.unlink(path).catch(() => {});
|
|
244
|
+
throw writeErr;
|
|
245
|
+
}
|
|
246
|
+
await file.close();
|
|
247
|
+
|
|
248
|
+
let released = false;
|
|
249
|
+
let lost = false;
|
|
250
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | AuthLockTimer | null = null;
|
|
251
|
+
|
|
252
|
+
const stopHeartbeat = () => {
|
|
253
|
+
if (heartbeatTimer !== null) {
|
|
254
|
+
deps.clock.clearInterval(heartbeatTimer);
|
|
255
|
+
heartbeatTimer = null;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const onExit = () => {
|
|
260
|
+
if (released || lost || !usingRealFs) return;
|
|
261
|
+
try {
|
|
262
|
+
const raw = readFileSync(path, "utf8");
|
|
263
|
+
const current = parseLockRecord(raw);
|
|
264
|
+
if (current && current.nonce === myRecord.nonce) {
|
|
265
|
+
unlinkSync(path);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// Best-effort cleanup on process exit
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const markLost = () => {
|
|
273
|
+
if (lost || released) return;
|
|
274
|
+
lost = true;
|
|
275
|
+
stopHeartbeat();
|
|
276
|
+
process.removeListener("exit", onExit);
|
|
277
|
+
options?.onLost?.();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
process.once("exit", onExit);
|
|
281
|
+
|
|
282
|
+
heartbeatTimer = deps.clock.setInterval(async () => {
|
|
283
|
+
if (released || lost) return;
|
|
284
|
+
let current: LockRecord | null = null;
|
|
285
|
+
try {
|
|
286
|
+
const raw = await deps.fs.readFile(path, "utf8");
|
|
287
|
+
current = parseLockRecord(raw);
|
|
288
|
+
} catch {
|
|
289
|
+
current = null;
|
|
290
|
+
}
|
|
291
|
+
if (released || lost) return;
|
|
292
|
+
if (!current || current.nonce !== myRecord.nonce) {
|
|
293
|
+
markLost();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const nowSec = deps.clock.now() / 1000;
|
|
297
|
+
try {
|
|
298
|
+
await deps.fs.utimes(path, nowSec, nowSec);
|
|
299
|
+
} catch {
|
|
300
|
+
if (!released && !lost) {
|
|
301
|
+
markLost();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}, HEARTBEAT_MS);
|
|
305
|
+
heartbeatTimer.unref?.();
|
|
306
|
+
|
|
307
|
+
const assertHeld = async (): Promise<void> => {
|
|
308
|
+
if (lost || released) {
|
|
309
|
+
throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
310
|
+
}
|
|
311
|
+
let current: LockRecord | null = null;
|
|
312
|
+
try {
|
|
313
|
+
const raw = await deps.fs.readFile(path, "utf8");
|
|
314
|
+
current = parseLockRecord(raw);
|
|
315
|
+
} catch {
|
|
316
|
+
current = null;
|
|
317
|
+
}
|
|
318
|
+
if (!current || current.nonce !== myRecord.nonce) {
|
|
319
|
+
markLost();
|
|
320
|
+
throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const unlock = async (): Promise<void> => {
|
|
325
|
+
if (released) return;
|
|
326
|
+
released = true;
|
|
327
|
+
stopHeartbeat();
|
|
328
|
+
process.removeListener("exit", onExit);
|
|
329
|
+
if (lost) return;
|
|
330
|
+
let current: LockRecord | null = null;
|
|
331
|
+
try {
|
|
332
|
+
const raw = await deps.fs.readFile(path, "utf8");
|
|
333
|
+
current = parseLockRecord(raw);
|
|
334
|
+
} catch {
|
|
335
|
+
current = null;
|
|
336
|
+
}
|
|
337
|
+
if (current && current.nonce === myRecord.nonce) {
|
|
338
|
+
await deps.fs.unlink(path).catch(() => {});
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return Object.assign(unlock, {
|
|
343
|
+
assertHeld,
|
|
344
|
+
isLost: () => lost,
|
|
345
|
+
});
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Lock file already exists — inspect and check staleness
|
|
352
|
+
let statResult: { mtimeMs: number };
|
|
353
|
+
try {
|
|
354
|
+
statResult = await deps.fs.stat(path);
|
|
355
|
+
} catch (statErr) {
|
|
356
|
+
if ((statErr as NodeJS.ErrnoException).code === "ENOENT") {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
throw statErr;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let record: LockRecord | null = null;
|
|
363
|
+
try {
|
|
364
|
+
const raw = await deps.fs.readFile(path, "utf8");
|
|
365
|
+
record = parseLockRecord(raw);
|
|
366
|
+
} catch (readErr) {
|
|
367
|
+
if ((readErr as NodeJS.ErrnoException).code === "ENOENT") {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
record = null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const stale = isLockStale(record, statResult.mtimeMs, deps.clock.now(), {
|
|
374
|
+
hostname: deps.hostname,
|
|
375
|
+
isPidAlive: deps.isPidAlive,
|
|
376
|
+
});
|
|
377
|
+
if (!stale) {
|
|
378
|
+
throw new AuthLockError(serverName, formatHolderDetails(record));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Reclaim protocol: move stale candidate aside, verify identity, then delete or restore
|
|
382
|
+
const movedPath = `${path}.stale-${randomUUID()}`;
|
|
383
|
+
try {
|
|
384
|
+
await deps.fs.rename(path, movedPath);
|
|
385
|
+
} catch (renameErr) {
|
|
386
|
+
if ((renameErr as NodeJS.ErrnoException).code === "ENOENT") {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
throw renameErr;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let movedRecord: LockRecord | null = null;
|
|
393
|
+
try {
|
|
394
|
+
const movedRaw = await deps.fs.readFile(movedPath, "utf8");
|
|
395
|
+
movedRecord = parseLockRecord(movedRaw);
|
|
396
|
+
} catch {
|
|
397
|
+
movedRecord = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let matchesStale = false;
|
|
401
|
+
if (record !== null) {
|
|
402
|
+
matchesStale = movedRecord !== null && movedRecord.nonce === record.nonce;
|
|
403
|
+
} else {
|
|
404
|
+
try {
|
|
405
|
+
const movedStat = await deps.fs.stat(movedPath);
|
|
406
|
+
matchesStale = movedRecord === null && movedStat.mtimeMs === statResult.mtimeMs;
|
|
407
|
+
} catch {
|
|
408
|
+
matchesStale = false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (matchesStale) {
|
|
413
|
+
await deps.fs.unlink(movedPath).catch(() => {});
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await deps.fs.link(movedPath, path).catch(() => {});
|
|
418
|
+
await deps.fs.unlink(movedPath).catch(() => {});
|
|
419
|
+
throw new AuthLockError(serverName, formatHolderDetails(movedRecord));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
throw new AuthLockError(serverName);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export { acquireAuthLock as acquireLock };
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
stopCallbackServer,
|
|
25
25
|
callbackServerConfigFromRedirectUrl,
|
|
26
26
|
} from "./callback-server.js";
|
|
27
|
-
import { setCallbackPort, McpOAuthProvider, acquireAuthLock } from "./oauth-provider.js";
|
|
27
|
+
import { setCallbackPort, McpOAuthProvider, acquireAuthLock, type AuthLockHandle } from "./oauth-provider.js";
|
|
28
28
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
29
29
|
import { discoverManualAuthChallenge } from "./auth-challenge.js";
|
|
30
30
|
import type { ManualAuthChallenge } from "./auth-challenge.js";
|
|
@@ -367,14 +367,23 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
367
367
|
const config = server.config;
|
|
368
368
|
let oauthState: string | undefined;
|
|
369
369
|
let latestAuthorizationUrl: URL | undefined;
|
|
370
|
+
let lockLost = false;
|
|
370
371
|
const cancel = () => { if (oauthState) cancelCallback(oauthState); };
|
|
371
372
|
signal.addEventListener("abort", cancel, { once: true });
|
|
372
|
-
let unlock:
|
|
373
|
+
let unlock: AuthLockHandle | undefined;
|
|
373
374
|
try {
|
|
374
375
|
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
375
|
-
unlock = await acquireAuthLock(serverName, paths.authStorageDir
|
|
376
|
+
unlock = await acquireAuthLock(serverName, paths.authStorageDir, {
|
|
377
|
+
onLost: () => {
|
|
378
|
+
lockLost = true;
|
|
379
|
+
cancel();
|
|
380
|
+
},
|
|
381
|
+
});
|
|
376
382
|
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
377
|
-
if (reset)
|
|
383
|
+
if (reset) {
|
|
384
|
+
await unlock.assertHeld();
|
|
385
|
+
await manager.resetServerAuth(serverName);
|
|
386
|
+
}
|
|
378
387
|
// Validate that we have a server URL (required for OAuth)
|
|
379
388
|
if (!config.url) {
|
|
380
389
|
throw new McpError(
|
|
@@ -411,11 +420,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
411
420
|
config.auth || { type: "oauth" },
|
|
412
421
|
(url: URL) => {
|
|
413
422
|
latestAuthorizationUrl = new URL(url.toString());
|
|
414
|
-
if (!signal.aborted) (paths.browserOpen ?? openBrowser)(url.toString());
|
|
423
|
+
if (!signal.aborted && !lockLost) (paths.browserOpen ?? openBrowser)(url.toString());
|
|
415
424
|
},
|
|
416
425
|
paths.authStorageDir,
|
|
417
426
|
false,
|
|
418
427
|
() => signal.aborted,
|
|
428
|
+
() => unlock!.assertHeld(),
|
|
419
429
|
);
|
|
420
430
|
|
|
421
431
|
// Why set state before calling auth(): the SDK reads provider.state() while
|
|
@@ -458,6 +468,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
458
468
|
// wait for the callback code and call auth() a second time with authorizationCode.
|
|
459
469
|
const authResult = await auth(authProvider, authOptions);
|
|
460
470
|
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
471
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
461
472
|
|
|
462
473
|
if (authResult === "AUTHORIZED") {
|
|
463
474
|
// Auth succeeded without needing browser interaction (e.g., had valid tokens).
|
|
@@ -481,11 +492,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
481
492
|
"protocol",
|
|
482
493
|
);
|
|
483
494
|
}
|
|
484
|
-
if (!signal.aborted) (paths.browserOpen ?? openBrowser)(latestAuthorizationUrl.toString());
|
|
495
|
+
if (!signal.aborted && !lockLost) (paths.browserOpen ?? openBrowser)(latestAuthorizationUrl.toString());
|
|
485
496
|
},
|
|
486
497
|
ctx,
|
|
487
498
|
);
|
|
488
499
|
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
500
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
489
501
|
ctx.ui.notify(
|
|
490
502
|
`pi-mcp: Authorization callback received for ${serverName}. Exchanging token...`,
|
|
491
503
|
"info",
|
|
@@ -527,6 +539,12 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
527
539
|
}
|
|
528
540
|
|
|
529
541
|
if (signal.aborted) throw new AuthCancelledError(serverName);
|
|
542
|
+
if (lockLost) throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
if (lockLost && !signal.aborted) {
|
|
545
|
+
throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
546
|
+
}
|
|
547
|
+
throw err;
|
|
530
548
|
} finally {
|
|
531
549
|
signal.removeEventListener("abort", cancel);
|
|
532
550
|
cancel();
|
|
@@ -674,7 +692,7 @@ export default async function (pi: ExtensionAPI, paths: { bootstrapCwd?: string;
|
|
|
674
692
|
: !manager.getServer(name)
|
|
675
693
|
? "Unknown MCP server name. Use mcp_status to list configured servers."
|
|
676
694
|
: err instanceof AuthLockError
|
|
677
|
-
? "Another Pi process is authorizing this server. Retry after it finishes
|
|
695
|
+
? "Another Pi process is authorizing this server. Retry after it finishes."
|
|
678
696
|
: err instanceof AuthRequiredError
|
|
679
697
|
? ctx.hasUI
|
|
680
698
|
? "MCP authorization required. Retry with mcp_connect or /mcp:auth."
|
package/src/oauth-provider.ts
CHANGED
|
@@ -13,10 +13,21 @@
|
|
|
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, unlink, chmod,
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
16
|
+
import { readFile, writeFile, mkdir, unlink, chmod, rename } from "node:fs/promises";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { McpError } from "./errors.js";
|
|
20
|
+
import {
|
|
21
|
+
acquireAuthLock,
|
|
22
|
+
AuthLockError,
|
|
23
|
+
AuthRequiredError,
|
|
24
|
+
authDir,
|
|
25
|
+
statePath,
|
|
26
|
+
type AcquireAuthLockOptions,
|
|
27
|
+
type AuthLockHandle,
|
|
28
|
+
} from "./auth-lock.js";
|
|
29
|
+
|
|
30
|
+
export { acquireAuthLock, AuthLockError, AuthRequiredError, type AcquireAuthLockOptions, type AuthLockHandle };
|
|
20
31
|
|
|
21
32
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
22
33
|
|
|
@@ -57,7 +68,7 @@ export function getCallbackPort(): number {
|
|
|
57
68
|
return callbackPort;
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
// ─── Persistent State Types
|
|
71
|
+
// ─── Persistent State Types & Schema ──────────────────────────────────────────
|
|
61
72
|
|
|
62
73
|
interface StoredClientInfo {
|
|
63
74
|
client_id: string;
|
|
@@ -77,74 +88,113 @@ interface StoredTokens {
|
|
|
77
88
|
interface StoredState {
|
|
78
89
|
clientInfo: StoredClientInfo | undefined;
|
|
79
90
|
tokens: StoredTokens | undefined;
|
|
80
|
-
codeVerifier: string | undefined;
|
|
81
91
|
discoveryState: OAuthDiscoveryState | undefined;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
const StoredClientInfoSchema = z.object({
|
|
95
|
+
client_id: z.string(),
|
|
96
|
+
client_secret: z.string().optional(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const StoredTokensSchema = z.object({
|
|
100
|
+
access_token: z.string(),
|
|
101
|
+
token_type: z.string().optional(),
|
|
102
|
+
refresh_token: z.string().optional(),
|
|
103
|
+
expires_in: z.number().optional(),
|
|
104
|
+
scope: z.string().optional(),
|
|
105
|
+
saved_at: z.string().optional(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const StoredStateSchema = z.object({
|
|
109
|
+
clientInfo: StoredClientInfoSchema.optional(),
|
|
110
|
+
tokens: StoredTokensSchema.optional(),
|
|
111
|
+
discoveryState: z.record(z.unknown()).optional(),
|
|
112
|
+
});
|
|
89
113
|
|
|
90
|
-
|
|
91
|
-
// Hash the server name to avoid filesystem issues with special chars
|
|
92
|
-
const hash = createHash("sha256").update(serverName).digest("hex").slice(0, 16);
|
|
93
|
-
return join(authDir(storageDir), `${hash}.json`);
|
|
94
|
-
}
|
|
114
|
+
// ─── File helpers ─────────────────────────────────────────────────────────────
|
|
95
115
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
100
|
-
await chmod(directory, 0o700);
|
|
101
|
-
const path = `${statePath(serverName, storageDir)}.lock`;
|
|
116
|
+
async function loadState(serverName: string, storageDir?: string): Promise<StoredState> {
|
|
117
|
+
const path = statePath(serverName, storageDir);
|
|
118
|
+
let raw: string;
|
|
102
119
|
try {
|
|
103
|
-
|
|
104
|
-
try { await file.writeFile(String(process.pid)); }
|
|
105
|
-
catch (err) { await unlink(path).catch(() => {}); throw err; }
|
|
106
|
-
finally { await file.close(); }
|
|
107
|
-
return async () => { await unlink(path).catch(() => {}); };
|
|
120
|
+
raw = await readFile(path, "utf8");
|
|
108
121
|
} catch (err) {
|
|
109
|
-
if ((err as NodeJS.ErrnoException).code
|
|
110
|
-
|
|
122
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
123
|
+
return {
|
|
124
|
+
clientInfo: undefined,
|
|
125
|
+
tokens: undefined,
|
|
126
|
+
discoveryState: undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
111
130
|
}
|
|
112
|
-
}
|
|
113
131
|
|
|
114
|
-
|
|
132
|
+
let parsed: z.output<typeof StoredStateSchema>;
|
|
115
133
|
try {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
tokens: undefined,
|
|
128
|
-
codeVerifier: undefined,
|
|
129
|
-
discoveryState: undefined,
|
|
130
|
-
};
|
|
134
|
+
const json: unknown = JSON.parse(raw);
|
|
135
|
+
parsed = StoredStateSchema.parse(json);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const backupPath = `${path}.corrupt-${Date.now()}`;
|
|
138
|
+
await rename(path, backupPath);
|
|
139
|
+
throw new McpError(
|
|
140
|
+
`Corrupt OAuth state file for "${serverName}" backed up to ${backupPath}`,
|
|
141
|
+
serverName,
|
|
142
|
+
"config",
|
|
143
|
+
err,
|
|
144
|
+
);
|
|
131
145
|
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
clientInfo: parsed.clientInfo
|
|
149
|
+
? {
|
|
150
|
+
client_id: parsed.clientInfo.client_id,
|
|
151
|
+
client_secret: parsed.clientInfo.client_secret,
|
|
152
|
+
}
|
|
153
|
+
: undefined,
|
|
154
|
+
tokens: parsed.tokens
|
|
155
|
+
? {
|
|
156
|
+
access_token: parsed.tokens.access_token,
|
|
157
|
+
token_type: parsed.tokens.token_type,
|
|
158
|
+
refresh_token: parsed.tokens.refresh_token,
|
|
159
|
+
expires_in: parsed.tokens.expires_in,
|
|
160
|
+
scope: parsed.tokens.scope,
|
|
161
|
+
saved_at: parsed.tokens.saved_at,
|
|
162
|
+
}
|
|
163
|
+
: undefined,
|
|
164
|
+
discoveryState: parsed.discoveryState as OAuthDiscoveryState | undefined,
|
|
165
|
+
};
|
|
132
166
|
}
|
|
133
167
|
|
|
134
|
-
async function saveState(
|
|
168
|
+
async function saveState(
|
|
169
|
+
serverName: string,
|
|
170
|
+
state: StoredState,
|
|
171
|
+
storageDir?: string,
|
|
172
|
+
isCancelled: () => boolean = () => false,
|
|
173
|
+
): Promise<void> {
|
|
135
174
|
const directory = authDir(storageDir);
|
|
136
175
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
137
176
|
await chmod(directory, 0o700);
|
|
138
|
-
// Only write defined fields
|
|
177
|
+
// Only write defined fields (never write codeVerifier to shared state)
|
|
139
178
|
const toWrite: Record<string, unknown> = {};
|
|
140
179
|
if (state.clientInfo !== undefined) toWrite.clientInfo = state.clientInfo;
|
|
141
180
|
if (state.tokens !== undefined) toWrite.tokens = state.tokens;
|
|
142
|
-
if (state.codeVerifier !== undefined) toWrite.codeVerifier = state.codeVerifier;
|
|
143
181
|
if (state.discoveryState !== undefined) toWrite.discoveryState = state.discoveryState;
|
|
144
182
|
if (isCancelled()) throw new AuthRequiredError(serverName, "Authorization cancelled");
|
|
145
183
|
const path = statePath(serverName, storageDir);
|
|
146
|
-
|
|
147
|
-
|
|
184
|
+
const tmpPath = `${path}.tmp-${randomUUID()}`;
|
|
185
|
+
try {
|
|
186
|
+
await writeFile(tmpPath, JSON.stringify(toWrite, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
187
|
+
await chmod(tmpPath, 0o600);
|
|
188
|
+
if (isCancelled()) {
|
|
189
|
+
await unlink(tmpPath).catch(() => {});
|
|
190
|
+
throw new AuthRequiredError(serverName, "Authorization cancelled");
|
|
191
|
+
}
|
|
192
|
+
await rename(tmpPath, path);
|
|
193
|
+
await chmod(path, 0o600);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
await unlink(tmpPath).catch(() => {});
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
148
198
|
}
|
|
149
199
|
|
|
150
200
|
// ─── OAuthClientProvider Implementation ────────────────────────────────────────
|
|
@@ -155,6 +205,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
155
205
|
private _redirectUrl: string | undefined;
|
|
156
206
|
private _onAuthRequired: ((url: URL) => void | Promise<void>) | undefined;
|
|
157
207
|
private _oauthState: string | undefined;
|
|
208
|
+
private _codeVerifier: string | undefined;
|
|
209
|
+
private _cachedTokens: StoredTokens | undefined;
|
|
158
210
|
|
|
159
211
|
constructor(
|
|
160
212
|
serverName: string,
|
|
@@ -163,6 +215,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
163
215
|
private readonly storageDir?: string,
|
|
164
216
|
private readonly silent = false,
|
|
165
217
|
private readonly isCancelled: () => boolean = () => false,
|
|
218
|
+
private readonly assertLockHeld?: (() => Promise<void>) | undefined,
|
|
166
219
|
) {
|
|
167
220
|
this.serverName = serverName;
|
|
168
221
|
this.authConfig = authConfig;
|
|
@@ -217,19 +270,21 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
217
270
|
|
|
218
271
|
async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
|
|
219
272
|
if (this.silent) throw new AuthRequiredError(this.serverName);
|
|
273
|
+
await this.assertLockHeld?.();
|
|
220
274
|
const state = await loadState(this.serverName, this.storageDir);
|
|
221
275
|
state.clientInfo = {
|
|
222
276
|
client_id: clientInformation.client_id,
|
|
223
277
|
client_secret: clientInformation.client_secret,
|
|
224
278
|
};
|
|
279
|
+
await this.assertLockHeld?.();
|
|
225
280
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
226
281
|
}
|
|
227
282
|
|
|
228
283
|
// --- tokens ---
|
|
229
284
|
|
|
230
285
|
async tokens(): Promise<OAuthTokens | undefined> {
|
|
231
|
-
const
|
|
232
|
-
if (!
|
|
286
|
+
const storedTokens = this._cachedTokens ?? (await loadState(this.serverName, this.storageDir)).tokens;
|
|
287
|
+
if (!storedTokens) return undefined;
|
|
233
288
|
|
|
234
289
|
// Always return stored tokens — even if expired.
|
|
235
290
|
// The SDK's auth() function checks tokens?.refresh_token and attempts
|
|
@@ -247,19 +302,17 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
247
302
|
|
|
248
303
|
// Build OAuthTokens — only include defined fields
|
|
249
304
|
const tokens: Record<string, string> = {
|
|
250
|
-
access_token:
|
|
305
|
+
access_token: storedTokens.access_token,
|
|
251
306
|
};
|
|
252
|
-
if (
|
|
253
|
-
if (
|
|
307
|
+
if (storedTokens.token_type !== undefined) tokens.token_type = storedTokens.token_type;
|
|
308
|
+
if (storedTokens.refresh_token !== undefined) tokens.refresh_token = storedTokens.refresh_token;
|
|
254
309
|
return tokens as unknown as OAuthTokens;
|
|
255
310
|
}
|
|
256
311
|
|
|
257
312
|
// --- saveTokens ---
|
|
258
313
|
|
|
259
314
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
260
|
-
|
|
261
|
-
const state = await loadState(this.serverName, this.storageDir);
|
|
262
|
-
state.tokens = {
|
|
315
|
+
const storedTokens: StoredTokens = {
|
|
263
316
|
access_token: tokens.access_token,
|
|
264
317
|
token_type: tokens.token_type,
|
|
265
318
|
refresh_token: tokens.refresh_token,
|
|
@@ -267,8 +320,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
267
320
|
scope: tokens.scope,
|
|
268
321
|
saved_at: new Date().toISOString(),
|
|
269
322
|
};
|
|
323
|
+
|
|
324
|
+
if (this.silent) {
|
|
325
|
+
try {
|
|
326
|
+
const state = await loadState(this.serverName, this.storageDir);
|
|
327
|
+
state.tokens = storedTokens;
|
|
328
|
+
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
329
|
+
this._cachedTokens = undefined;
|
|
330
|
+
} catch (err) {
|
|
331
|
+
this._cachedTokens = storedTokens;
|
|
332
|
+
console.error(`[pi-mcp] Failed to persist refreshed OAuth tokens for "${this.serverName}":`, err);
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
|
|
338
|
+
await this.assertLockHeld?.();
|
|
339
|
+
const state = await loadState(this.serverName, this.storageDir);
|
|
340
|
+
state.tokens = storedTokens;
|
|
270
341
|
if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
|
|
342
|
+
await this.assertLockHeld?.();
|
|
271
343
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
344
|
+
this._cachedTokens = undefined;
|
|
272
345
|
}
|
|
273
346
|
|
|
274
347
|
// --- redirectToAuthorization ---
|
|
@@ -285,30 +358,30 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
285
358
|
}
|
|
286
359
|
}
|
|
287
360
|
|
|
288
|
-
// --- PKCE code verifier ---
|
|
361
|
+
// --- PKCE code verifier (in-memory per provider instance) ---
|
|
289
362
|
|
|
290
363
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
|
291
|
-
// The SDK saves PKCE before redirecting. Silent attempts must not
|
|
364
|
+
// The SDK saves PKCE before redirecting. Silent attempts must not start a browser flow.
|
|
292
365
|
if (this.silent) throw new AuthRequiredError(this.serverName);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
366
|
+
if (this.isCancelled()) throw new AuthRequiredError(this.serverName, "Authorization cancelled");
|
|
367
|
+
this._codeVerifier = codeVerifier;
|
|
296
368
|
}
|
|
297
369
|
|
|
298
370
|
async codeVerifier(): Promise<string> {
|
|
299
|
-
|
|
300
|
-
if (!state.codeVerifier) {
|
|
371
|
+
if (!this._codeVerifier) {
|
|
301
372
|
throw new Error(`[pi-mcp] No PKCE code verifier found for "${this.serverName}"`);
|
|
302
373
|
}
|
|
303
|
-
return
|
|
374
|
+
return this._codeVerifier;
|
|
304
375
|
}
|
|
305
376
|
|
|
306
377
|
// --- Discovery state caching ---
|
|
307
378
|
|
|
308
379
|
async saveDiscoveryState(discState: OAuthDiscoveryState): Promise<void> {
|
|
309
380
|
if (this.silent) return; // No shared state writes from background discovery.
|
|
381
|
+
await this.assertLockHeld?.();
|
|
310
382
|
const state = await loadState(this.serverName, this.storageDir);
|
|
311
383
|
state.discoveryState = discState;
|
|
384
|
+
await this.assertLockHeld?.();
|
|
312
385
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
313
386
|
}
|
|
314
387
|
|
|
@@ -338,35 +411,33 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|
|
338
411
|
|
|
339
412
|
// --- Credential invalidation ---
|
|
340
413
|
|
|
341
|
-
private async checkInteractiveLock(): Promise<void> {
|
|
342
|
-
try { await stat(`${statePath(this.serverName, this.storageDir)}.lock`); }
|
|
343
|
-
catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return; throw err; }
|
|
344
|
-
throw new AuthRequiredError(this.serverName);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
414
|
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
|
|
348
415
|
if (this.silent) throw new AuthRequiredError(this.serverName);
|
|
416
|
+
await this.assertLockHeld?.();
|
|
349
417
|
const state = await loadState(this.serverName, this.storageDir);
|
|
350
418
|
switch (scope) {
|
|
351
419
|
case "all":
|
|
352
420
|
state.clientInfo = undefined;
|
|
353
421
|
state.tokens = undefined;
|
|
354
|
-
state.codeVerifier = undefined;
|
|
355
422
|
state.discoveryState = undefined;
|
|
423
|
+
this._codeVerifier = undefined;
|
|
424
|
+
this._cachedTokens = undefined;
|
|
356
425
|
break;
|
|
357
426
|
case "client":
|
|
358
427
|
state.clientInfo = undefined;
|
|
359
428
|
break;
|
|
360
429
|
case "tokens":
|
|
361
430
|
state.tokens = undefined;
|
|
431
|
+
this._cachedTokens = undefined;
|
|
362
432
|
break;
|
|
363
433
|
case "verifier":
|
|
364
|
-
|
|
434
|
+
this._codeVerifier = undefined;
|
|
365
435
|
break;
|
|
366
436
|
case "discovery":
|
|
367
437
|
state.discoveryState = undefined;
|
|
368
438
|
break;
|
|
369
439
|
}
|
|
440
|
+
await this.assertLockHeld?.();
|
|
370
441
|
await saveState(this.serverName, state, this.storageDir, this.isCancelled);
|
|
371
442
|
}
|
|
372
443
|
}
|
|
@@ -387,7 +458,6 @@ export async function getAuthStatus(serverName: string, storageDir?: string): Pr
|
|
|
387
458
|
if (
|
|
388
459
|
state.clientInfo === undefined &&
|
|
389
460
|
state.tokens === undefined &&
|
|
390
|
-
state.codeVerifier === undefined &&
|
|
391
461
|
state.discoveryState === undefined
|
|
392
462
|
) {
|
|
393
463
|
return null;
|
|
@@ -412,17 +482,3 @@ export async function resetAuth(serverName: string, storageDir?: string): Promis
|
|
|
412
482
|
throw err;
|
|
413
483
|
}
|
|
414
484
|
}
|
|
415
|
-
|
|
416
|
-
export class AuthRequiredError extends Error {
|
|
417
|
-
constructor(serverName: string, message = `Authorization required for ${serverName}. Connect in an interactive Pi session or use /mcp:auth ${serverName}.`) {
|
|
418
|
-
super(message);
|
|
419
|
-
this.name = "AuthRequiredError";
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
export class AuthLockError extends AuthRequiredError {
|
|
424
|
-
constructor(serverName: string) {
|
|
425
|
-
super(serverName, `Another Pi process is authorizing ${serverName}; if it exited, remove its stale auth lock before retrying`);
|
|
426
|
-
this.name = "AuthLockError";
|
|
427
|
-
}
|
|
428
|
-
}
|