imprint-mcp 0.7.1 → 0.7.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 +2 -0
- package/package.json +1 -1
- package/prompts/auth-compile-agent.md +6 -0
- package/src/imprint/backend-ladder.ts +59 -15
- package/src/imprint/cdp-browser-fetch.ts +50 -7
- package/src/imprint/credential-lock.ts +2 -1
- package/src/imprint/credential-store.ts +41 -4
- package/src/imprint/mcp-cdp-sessions.ts +7 -2
- package/src/imprint/mcp-server.ts +667 -91
- package/src/imprint/mcp-session-recovery.ts +167 -0
- package/src/imprint/runtime.ts +60 -12
- package/src/imprint/types.ts +5 -0
|
@@ -14,16 +14,31 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
14
14
|
import {
|
|
15
15
|
CallToolRequestSchema,
|
|
16
16
|
type CallToolResult,
|
|
17
|
+
ElicitResultSchema,
|
|
17
18
|
ListToolsRequestSchema,
|
|
18
19
|
type Tool,
|
|
19
20
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
20
|
-
import {
|
|
21
|
-
|
|
21
|
+
import {
|
|
22
|
+
type BackendAttemptFact,
|
|
23
|
+
cdpReplayPoolKey,
|
|
24
|
+
resolveLadder,
|
|
25
|
+
runWithLadder,
|
|
26
|
+
} from './backend-ladder.ts';
|
|
27
|
+
import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
|
|
28
|
+
import { TimeoutError, withAbortSignal, withTimeoutCleanup } from './concurrency.ts';
|
|
29
|
+
import { acquireSiteAuthRecoveryLock } from './credential-store.ts';
|
|
22
30
|
import { createLog } from './log.ts';
|
|
23
31
|
import { McpCdpSessions } from './mcp-cdp-sessions.ts';
|
|
32
|
+
import {
|
|
33
|
+
type RecoveryPolicy,
|
|
34
|
+
loadRecoveryPolicy,
|
|
35
|
+
missingAuthInputs,
|
|
36
|
+
recoverSession,
|
|
37
|
+
unsafeAuthRequests,
|
|
38
|
+
} from './mcp-session-recovery.ts';
|
|
24
39
|
import { imprintHomeDir } from './paths.ts';
|
|
25
40
|
import { loadBackendsCacheStatus, persistRuntimeBackendsCache } from './probe-backends.ts';
|
|
26
|
-
import { checkSiteCredentialsReady } from './runtime.ts';
|
|
41
|
+
import { checkSiteCredentialsReady, loadCredentialStore } from './runtime.ts';
|
|
27
42
|
import { availableSitesHint } from './sites.ts';
|
|
28
43
|
import type { StealthFetch } from './stealth-fetch.ts';
|
|
29
44
|
import {
|
|
@@ -142,6 +157,11 @@ interface StoredAuthContinuation {
|
|
|
142
157
|
toolName: string;
|
|
143
158
|
nextAction?: string;
|
|
144
159
|
state: Record<string, unknown>;
|
|
160
|
+
recoveryRevision?: string;
|
|
161
|
+
recoveryBrowser?: CdpBrowserFetch;
|
|
162
|
+
queued?: boolean;
|
|
163
|
+
standaloneBrowser?: WeakRef<CdpBrowserFetch>;
|
|
164
|
+
standaloneUntil?: number;
|
|
145
165
|
}
|
|
146
166
|
|
|
147
167
|
export class AuthContinuationStore {
|
|
@@ -153,16 +173,65 @@ export class AuthContinuationStore {
|
|
|
153
173
|
return token;
|
|
154
174
|
}
|
|
155
175
|
|
|
156
|
-
consume(token: string, toolName: string, action: string):
|
|
176
|
+
consume(token: string, toolName: string, action: string): StoredAuthContinuation | undefined {
|
|
157
177
|
const value = this.values.get(token);
|
|
158
178
|
this.values.delete(token);
|
|
159
179
|
if (!value || value.toolName !== toolName || value.nextAction !== action) return undefined;
|
|
160
|
-
return value
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
managed(token: string): StoredAuthContinuation | undefined {
|
|
184
|
+
const value = this.values.get(token);
|
|
185
|
+
return value?.recoveryRevision === undefined ? undefined : value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
pending(
|
|
189
|
+
toolName: string,
|
|
190
|
+
isRetained: (browser: CdpBrowserFetch) => boolean,
|
|
191
|
+
):
|
|
192
|
+
| {
|
|
193
|
+
nextAction: string;
|
|
194
|
+
continuation: string;
|
|
195
|
+
managed: boolean;
|
|
196
|
+
revision?: string;
|
|
197
|
+
}
|
|
198
|
+
| undefined {
|
|
199
|
+
for (const [continuation, entry] of this.values) {
|
|
200
|
+
if (entry.toolName !== toolName || !entry.nextAction) continue;
|
|
201
|
+
if (entry.recoveryRevision === undefined) {
|
|
202
|
+
const browser = entry.standaloneBrowser?.deref();
|
|
203
|
+
if (
|
|
204
|
+
entry.standaloneBrowser
|
|
205
|
+
? !browser || !isRetained(browser)
|
|
206
|
+
: (entry.standaloneUntil ?? 0) <= Date.now()
|
|
207
|
+
)
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (entry.recoveryBrowser && !isRetained(entry.recoveryBrowser)) {
|
|
211
|
+
this.values.delete(continuation);
|
|
212
|
+
} else {
|
|
213
|
+
return {
|
|
214
|
+
nextAction: entry.nextAction,
|
|
215
|
+
continuation,
|
|
216
|
+
managed: entry.recoveryRevision !== undefined,
|
|
217
|
+
revision: entry.recoveryRevision,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
161
222
|
}
|
|
162
223
|
|
|
163
224
|
clear(): void {
|
|
164
225
|
this.values.clear();
|
|
165
226
|
}
|
|
227
|
+
|
|
228
|
+
clearTool(toolName: string): boolean {
|
|
229
|
+
let cleared = false;
|
|
230
|
+
for (const [token, value] of this.values) {
|
|
231
|
+
if (value.toolName === toolName) cleared = this.values.delete(token) || cleared;
|
|
232
|
+
}
|
|
233
|
+
return cleared;
|
|
234
|
+
}
|
|
166
235
|
}
|
|
167
236
|
|
|
168
237
|
export async function runSerializedBySite<T>(
|
|
@@ -196,6 +265,7 @@ function buildServer(
|
|
|
196
265
|
version: string,
|
|
197
266
|
tools: ResolvedTool[],
|
|
198
267
|
assetRoot: string,
|
|
268
|
+
recoveryPolicy?: RecoveryPolicy,
|
|
199
269
|
): { server: Server; closeCdpPool: () => Promise<void> } {
|
|
200
270
|
const server = new Server(
|
|
201
271
|
{ name, version },
|
|
@@ -224,6 +294,11 @@ function buildServer(
|
|
|
224
294
|
const cdpSessions = new McpCdpSessions();
|
|
225
295
|
const cdpPool = cdpSessions.pool;
|
|
226
296
|
const authContinuations = new AuthContinuationStore();
|
|
297
|
+
const isRetained = (browser: CdpBrowserFetch) => [...cdpPool.values()].includes(browser);
|
|
298
|
+
function authBrowser(tool: ResolvedTool): CdpBrowserFetch | undefined {
|
|
299
|
+
const prefix = cdpReplayPoolKey(tool.site, tool.workflow.toolName, '');
|
|
300
|
+
return [...cdpPool.entries()].reverse().find(([key]) => key.startsWith(prefix))?.[1];
|
|
301
|
+
}
|
|
227
302
|
|
|
228
303
|
// Per-tool memo of the winning backend for THIS server session. After the
|
|
229
304
|
// first call discovers the right rung, later calls skip the doomed early ones
|
|
@@ -234,16 +309,152 @@ function buildServer(
|
|
|
234
309
|
// Keep same-site calls sequential so concurrent calls cannot race a tool's
|
|
235
310
|
// browser, winner memo, or backend cache. Unrelated sites remain independent.
|
|
236
311
|
const siteExecutionQueues = new Map<string, Promise<void>>();
|
|
312
|
+
const authTool =
|
|
313
|
+
recoveryPolicy &&
|
|
314
|
+
tools.find(
|
|
315
|
+
({ workflow, site }) =>
|
|
316
|
+
workflow.toolName === recoveryPolicy.authTool &&
|
|
317
|
+
workflow.toolKind === 'authenticate' &&
|
|
318
|
+
workflow.site === site &&
|
|
319
|
+
workflow.authConfig?.actions,
|
|
320
|
+
);
|
|
321
|
+
let recoveryGeneration = 0;
|
|
322
|
+
const shuttingDown = new AbortController();
|
|
323
|
+
const activeRecovery = new Set<Promise<unknown>>();
|
|
324
|
+
let releaseParkedSignIn: (() => Promise<void>) | undefined;
|
|
325
|
+
|
|
326
|
+
async function closeSignIn(tool: ResolvedTool): Promise<void> {
|
|
327
|
+
const release = releaseParkedSignIn;
|
|
328
|
+
releaseParkedSignIn = undefined;
|
|
329
|
+
try {
|
|
330
|
+
await cdpSessions.closeTool(tool.site, tool.workflow.toolName);
|
|
331
|
+
} finally {
|
|
332
|
+
await release?.();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parkSignIn(
|
|
337
|
+
tool: ResolvedTool,
|
|
338
|
+
browser: CdpBrowserFetch,
|
|
339
|
+
release: () => Promise<void>,
|
|
340
|
+
): void {
|
|
341
|
+
releaseParkedSignIn = release;
|
|
342
|
+
cdpSessions.finishCall(tool.site, tool.workflow.toolName, (closed, closing) => {
|
|
343
|
+
if (closed !== browser || releaseParkedSignIn !== release) return;
|
|
344
|
+
const drain = closing.then(release);
|
|
345
|
+
const releaseAfterClose = () => drain;
|
|
346
|
+
releaseParkedSignIn = releaseAfterClose;
|
|
347
|
+
void drain
|
|
348
|
+
.catch((error) => log(`Failed to release sign-in lock: ${String(error)}`))
|
|
349
|
+
.finally(() => {
|
|
350
|
+
if (releaseParkedSignIn === releaseAfterClose) releaseParkedSignIn = undefined;
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function trackRecovery<T>(work: Promise<T>): Promise<T> {
|
|
356
|
+
activeRecovery.add(work);
|
|
357
|
+
try {
|
|
358
|
+
return await work;
|
|
359
|
+
} finally {
|
|
360
|
+
activeRecovery.delete(work);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function executeTool(
|
|
365
|
+
tool: ResolvedTool,
|
|
366
|
+
args: Record<string, string | number | boolean>,
|
|
367
|
+
initialState?: Record<string, unknown>,
|
|
368
|
+
options?: {
|
|
369
|
+
retainBrowser?: boolean;
|
|
370
|
+
browserOnly?: boolean;
|
|
371
|
+
authBrowser?: CdpBrowserFetch;
|
|
372
|
+
replayOnly?: boolean;
|
|
373
|
+
signal?: AbortSignal;
|
|
374
|
+
credentials?: Awaited<ReturnType<typeof loadCredentialStore>>;
|
|
375
|
+
},
|
|
376
|
+
): Promise<Awaited<ReturnType<typeof runWithLadder>>> {
|
|
377
|
+
const preferred = resolveLadder('auto', tool.preferredOrder);
|
|
378
|
+
const ladder: ConcreteBackend[] = options?.browserOnly
|
|
379
|
+
? ['cdp-replay']
|
|
380
|
+
: options?.replayOnly
|
|
381
|
+
? preferred.filter((backend) => backend !== 'playbook')
|
|
382
|
+
: preferred;
|
|
383
|
+
cdpSessions.beginCall(tool.site, tool.workflow.toolName);
|
|
384
|
+
const execution = new AbortController();
|
|
385
|
+
const signal = options?.signal
|
|
386
|
+
? AbortSignal.any([execution.signal, options.signal])
|
|
387
|
+
: execution.signal;
|
|
388
|
+
const run = runWithLadder(ladder, tool, args, assetRoot, stealthCache, {
|
|
389
|
+
cdpPool,
|
|
390
|
+
authBrowser: options?.authBrowser,
|
|
391
|
+
winnerCache,
|
|
392
|
+
initialState,
|
|
393
|
+
credentials: options?.credentials ?? undefined,
|
|
394
|
+
signal,
|
|
395
|
+
});
|
|
396
|
+
const deadline = auditToolDeadlineMs();
|
|
397
|
+
try {
|
|
398
|
+
return deadline
|
|
399
|
+
? await withTimeoutCleanup(
|
|
400
|
+
run,
|
|
401
|
+
deadline,
|
|
402
|
+
`${tool.workflow.toolName} audit MCP call`,
|
|
403
|
+
() => {
|
|
404
|
+
execution.abort();
|
|
405
|
+
void cdpSessions.closeTool(tool.site, tool.workflow.toolName);
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
: await run;
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (!(error instanceof TimeoutError)) throw error;
|
|
411
|
+
return {
|
|
412
|
+
result: {
|
|
413
|
+
ok: false,
|
|
414
|
+
error: 'NETWORK',
|
|
415
|
+
message: `${tool.workflow.toolName} exceeded the audit MCP internal deadline after ${Math.round((deadline ?? 0) / 1000)}s`,
|
|
416
|
+
},
|
|
417
|
+
usedBackend: ladder[0] ?? 'fetch',
|
|
418
|
+
attempts: [],
|
|
419
|
+
};
|
|
420
|
+
} finally {
|
|
421
|
+
if (!options?.retainBrowser) cdpSessions.finishCall(tool.site, tool.workflow.toolName);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function authRequired(
|
|
426
|
+
message: string,
|
|
427
|
+
continuation?: {
|
|
428
|
+
authTool: string;
|
|
429
|
+
nextAction: string;
|
|
430
|
+
continuation?: string;
|
|
431
|
+
},
|
|
432
|
+
): CallToolResult {
|
|
433
|
+
const instructions = [`[AUTH_REQUIRED] ${message}`];
|
|
434
|
+
if (continuation) {
|
|
435
|
+
instructions.push(
|
|
436
|
+
`Continue with ${continuation.authTool} using action ${JSON.stringify(continuation.nextAction)}.`,
|
|
437
|
+
);
|
|
438
|
+
if (continuation.continuation) {
|
|
439
|
+
instructions.push(` continuation: ${JSON.stringify(continuation.continuation)}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
isError: false,
|
|
444
|
+
structuredContent: { status: 'auth_required', message, ...continuation },
|
|
445
|
+
content: [{ type: 'text', text: instructions.join('\n') }],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
237
448
|
|
|
238
449
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
239
|
-
tools: tools.map((
|
|
240
|
-
name:
|
|
241
|
-
description: buildToolDescription(
|
|
242
|
-
inputSchema:
|
|
450
|
+
tools: tools.map((tool) => ({
|
|
451
|
+
name: tool.workflow.toolName,
|
|
452
|
+
description: buildToolDescription(tool.workflow),
|
|
453
|
+
inputSchema: tool.inputSchema,
|
|
243
454
|
})),
|
|
244
455
|
}));
|
|
245
456
|
|
|
246
|
-
server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
|
|
457
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra): Promise<CallToolResult> => {
|
|
247
458
|
const tool = tools.find((t) => t.workflow.toolName === req.params.name);
|
|
248
459
|
if (!tool) {
|
|
249
460
|
return {
|
|
@@ -271,83 +482,415 @@ function buildServer(
|
|
|
271
482
|
>;
|
|
272
483
|
|
|
273
484
|
const rawArgs = (req.params.arguments ?? {}) as Record<string, unknown>;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
let
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
485
|
+
const isAuth = tool.workflow.toolKind === 'authenticate';
|
|
486
|
+
if (isAuth && typeof rawArgs.action === 'string') args.action = rawArgs.action;
|
|
487
|
+
const action = String(args.action ?? (isAuth ? tool.workflow.authConfig?.entry : '') ?? '');
|
|
488
|
+
let continuation: StoredAuthContinuation | undefined;
|
|
489
|
+
let managedToken: string | undefined;
|
|
490
|
+
let managedEntry: StoredAuthContinuation | undefined;
|
|
491
|
+
let standaloneToken: string | undefined;
|
|
492
|
+
const invalidContinuation = (): CallToolResult => ({
|
|
493
|
+
isError: true,
|
|
494
|
+
content: [{ type: 'text', text: 'Invalid or expired continuation token.' }],
|
|
495
|
+
});
|
|
496
|
+
if (isAuth && rawArgs.continuation !== undefined) {
|
|
497
|
+
if (typeof rawArgs.continuation !== 'string') return invalidContinuation();
|
|
498
|
+
managedEntry = authContinuations.managed(rawArgs.continuation);
|
|
499
|
+
if (managedEntry) {
|
|
500
|
+
managedToken = rawArgs.continuation;
|
|
501
|
+
} else if (authTool?.workflow.toolName === tool.workflow.toolName) {
|
|
502
|
+
standaloneToken = rawArgs.continuation;
|
|
503
|
+
} else {
|
|
504
|
+
continuation = authContinuations.consume(
|
|
505
|
+
rawArgs.continuation,
|
|
506
|
+
tool.workflow.toolName,
|
|
507
|
+
action,
|
|
508
|
+
);
|
|
509
|
+
if (!continuation) return invalidContinuation();
|
|
296
510
|
}
|
|
297
511
|
}
|
|
298
512
|
|
|
513
|
+
const policy = recoveryPolicy;
|
|
514
|
+
const protectedRead =
|
|
515
|
+
!isAuth &&
|
|
516
|
+
authTool &&
|
|
517
|
+
policy?.protectedTools.includes(tool.workflow.toolName) &&
|
|
518
|
+
tool.site === tool.workflow.site &&
|
|
519
|
+
tool.site === authTool.site &&
|
|
520
|
+
tool.workflow.requests.length > 0 &&
|
|
521
|
+
tool.workflow.requests.every((request) => request.effect === 'safe');
|
|
522
|
+
const generation = recoveryGeneration;
|
|
523
|
+
const elicitation = server.getClientCapabilities()?.elicitation;
|
|
524
|
+
const legacyForm = elicitation !== undefined && !elicitation.form && !elicitation.url;
|
|
525
|
+
const requestConsent =
|
|
526
|
+
(elicitation?.form || legacyForm) && policy
|
|
527
|
+
? async (name: string, signal: AbortSignal) => {
|
|
528
|
+
const form = {
|
|
529
|
+
message: `Allow ${tool.site} to run the sign-in step "${name}"? It may send a new approval request.`,
|
|
530
|
+
requestedSchema: { type: 'object' as const, properties: {} },
|
|
531
|
+
};
|
|
532
|
+
const options = {
|
|
533
|
+
signal,
|
|
534
|
+
timeout: policy.timeoutMs,
|
|
535
|
+
relatedRequestId: extra.requestId,
|
|
536
|
+
};
|
|
537
|
+
const response = legacyForm
|
|
538
|
+
? await server.request(
|
|
539
|
+
{ method: 'elicitation/create', params: form },
|
|
540
|
+
ElicitResultSchema,
|
|
541
|
+
options,
|
|
542
|
+
)
|
|
543
|
+
: await server.elicitInput({ mode: 'form', ...form }, options);
|
|
544
|
+
return response.action === 'accept';
|
|
545
|
+
}
|
|
546
|
+
: undefined;
|
|
547
|
+
const signal =
|
|
548
|
+
protectedRead || managedToken !== undefined
|
|
549
|
+
? AbortSignal.any([extra.signal, shuttingDown.signal])
|
|
550
|
+
: undefined;
|
|
299
551
|
try {
|
|
552
|
+
let dequeueResume: (() => void) | undefined;
|
|
553
|
+
if (managedEntry && signal) {
|
|
554
|
+
signal?.throwIfAborted();
|
|
555
|
+
const entry = managedEntry;
|
|
556
|
+
if (
|
|
557
|
+
entry.toolName !== tool.workflow.toolName ||
|
|
558
|
+
entry.nextAction !== action ||
|
|
559
|
+
entry.queued
|
|
560
|
+
) {
|
|
561
|
+
return invalidContinuation();
|
|
562
|
+
}
|
|
563
|
+
const browser = entry.recoveryBrowser;
|
|
564
|
+
const release = releaseParkedSignIn;
|
|
565
|
+
if (!browser || !release || !isRetained(browser)) {
|
|
566
|
+
if (managedToken) authContinuations.consume(managedToken, entry.toolName, action);
|
|
567
|
+
return authRequired(
|
|
568
|
+
'The browser for this sign-in has closed. Start sign-in again if needed.',
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
const missing = missingAuthInputs(tool.workflow, action, args);
|
|
572
|
+
if (missing.length) {
|
|
573
|
+
return {
|
|
574
|
+
isError: true,
|
|
575
|
+
content: [
|
|
576
|
+
{
|
|
577
|
+
type: 'text',
|
|
578
|
+
text: `[STATE_MISSING] Auth action "${action}" requires: ${missing.join(', ')}. Retry with the same continuation.`,
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
// A valid resume owns the retained browser even while a read is ahead
|
|
584
|
+
// of it in the site queue. Keep its token visible to that read.
|
|
585
|
+
entry.queued = true;
|
|
586
|
+
cdpSessions.beginCall(tool.site, entry.toolName);
|
|
587
|
+
const cancel = () => {
|
|
588
|
+
dequeueResume?.();
|
|
589
|
+
if (
|
|
590
|
+
!shuttingDown.signal.aborted &&
|
|
591
|
+
releaseParkedSignIn === release &&
|
|
592
|
+
isRetained(browser)
|
|
593
|
+
) {
|
|
594
|
+
parkSignIn(tool, browser, release);
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
598
|
+
dequeueResume = () => {
|
|
599
|
+
dequeueResume = undefined;
|
|
600
|
+
signal.removeEventListener('abort', cancel);
|
|
601
|
+
entry.queued = false;
|
|
602
|
+
};
|
|
603
|
+
}
|
|
300
604
|
return await runSerializedBySite(siteExecutionQueues, tool.site, async () => {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
605
|
+
dequeueResume?.();
|
|
606
|
+
signal?.throwIfAborted();
|
|
607
|
+
if (managedToken !== undefined) {
|
|
608
|
+
continuation = authContinuations.consume(managedToken, tool.workflow.toolName, action);
|
|
609
|
+
if (!continuation) return invalidContinuation();
|
|
610
|
+
}
|
|
611
|
+
const revision = continuation?.recoveryRevision;
|
|
612
|
+
if (continuation?.recoveryBrowser && !isRetained(continuation.recoveryBrowser)) {
|
|
613
|
+
return authRequired(
|
|
614
|
+
'The browser for this sign-in has closed. Start sign-in again if needed.',
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Pace only the bounded audit harness.
|
|
305
619
|
const pacingMs = Number(process.env.IMPRINT_AUDIT_PACING_MS);
|
|
306
620
|
if (Number.isFinite(pacingMs) && pacingMs > 0) {
|
|
307
|
-
await new Promise((
|
|
621
|
+
await new Promise((resolve) => setTimeout(resolve, pacingMs));
|
|
308
622
|
}
|
|
309
623
|
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
auditDeadlineMs,
|
|
328
|
-
`${tool.workflow.toolName} audit MCP call`,
|
|
329
|
-
() => {
|
|
330
|
-
execution.abort();
|
|
331
|
-
void cdpSessions.closeTool(tool.site, tool.workflow.toolName);
|
|
332
|
-
},
|
|
333
|
-
)
|
|
334
|
-
: await run;
|
|
335
|
-
result = out.result;
|
|
336
|
-
usedBackend = out.usedBackend;
|
|
337
|
-
attempts = out.attempts;
|
|
338
|
-
} catch (err) {
|
|
339
|
-
if (!(err instanceof TimeoutError)) throw err;
|
|
340
|
-
usedBackend = ladder[0] ?? 'fetch';
|
|
341
|
-
attempts = [];
|
|
342
|
-
result = {
|
|
343
|
-
ok: false,
|
|
344
|
-
error: 'NETWORK',
|
|
345
|
-
message: `${tool.workflow.toolName} exceeded the audit MCP internal deadline after ${Math.round((auditDeadlineMs ?? 0) / 1000)}s`,
|
|
346
|
-
};
|
|
347
|
-
} finally {
|
|
348
|
-
cdpSessions.finishCall(tool.site, tool.workflow.toolName);
|
|
624
|
+
const authName = authTool?.workflow.toolName;
|
|
625
|
+
if (authName === tool.workflow.toolName && revision === undefined) {
|
|
626
|
+
const restart =
|
|
627
|
+
standaloneToken === undefined &&
|
|
628
|
+
(action === tool.workflow.authConfig?.entry || action === policy?.startAction);
|
|
629
|
+
if (releaseParkedSignIn && !restart) {
|
|
630
|
+
return authRequired(
|
|
631
|
+
'A sign-in is already waiting for a response. Use its continuation.',
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
if (restart && (authContinuations.clearTool(authName) || releaseParkedSignIn)) {
|
|
635
|
+
await closeSignIn(tool);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (standaloneToken !== undefined) {
|
|
639
|
+
continuation = authContinuations.consume(standaloneToken, tool.workflow.toolName, action);
|
|
640
|
+
if (!continuation) return invalidContinuation();
|
|
349
641
|
}
|
|
642
|
+
|
|
643
|
+
let outcome: Awaited<ReturnType<typeof executeTool>>;
|
|
644
|
+
if (revision !== undefined && signal && policy) {
|
|
645
|
+
const browser = continuation?.recoveryBrowser;
|
|
646
|
+
const release = releaseParkedSignIn;
|
|
647
|
+
if (!browser || !release || !isRetained(browser)) return invalidContinuation();
|
|
648
|
+
cdpSessions.beginCall(tool.site, tool.workflow.toolName);
|
|
649
|
+
const deadline = AbortSignal.timeout(policy.timeoutMs);
|
|
650
|
+
const resumeSignal = AbortSignal.any([signal, deadline]);
|
|
651
|
+
const resumed = await trackRecovery(
|
|
652
|
+
(async () => {
|
|
653
|
+
let keepBrowser = false;
|
|
654
|
+
try {
|
|
655
|
+
const saved = await loadCredentialStore(tool.site, resumeSignal, {
|
|
656
|
+
includeEmpty: true,
|
|
657
|
+
});
|
|
658
|
+
if (!saved || saved.durableRevision !== revision) {
|
|
659
|
+
return authRequired('The saved session changed. Retry the protected call.');
|
|
660
|
+
}
|
|
661
|
+
const unsafe = unsafeAuthRequests(tool.workflow, action);
|
|
662
|
+
if (!unsafe) return authRequired('This action needs standalone manual sign-in.');
|
|
663
|
+
if (
|
|
664
|
+
unsafe.size &&
|
|
665
|
+
!(requestConsent && (await requestConsent(action, resumeSignal)))
|
|
666
|
+
) {
|
|
667
|
+
if (!continuation) return invalidContinuation();
|
|
668
|
+
keepBrowser = true;
|
|
669
|
+
return authRequired('Consent is required before running that sign-in action.', {
|
|
670
|
+
authTool: tool.workflow.toolName,
|
|
671
|
+
nextAction: action,
|
|
672
|
+
continuation: authContinuations.issue(continuation),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if (
|
|
676
|
+
unsafe.size &&
|
|
677
|
+
(await loadCredentialStore(tool.site, resumeSignal, { includeEmpty: true }))
|
|
678
|
+
?.durableRevision !== revision
|
|
679
|
+
) {
|
|
680
|
+
return authRequired('The saved session changed. Retry the protected call.');
|
|
681
|
+
}
|
|
682
|
+
const execution = await executeTool(tool, args, continuation?.state, {
|
|
683
|
+
browserOnly: true,
|
|
684
|
+
authBrowser: browser,
|
|
685
|
+
retainBrowser: true,
|
|
686
|
+
signal: resumeSignal,
|
|
687
|
+
credentials: { ...saved, authCommitRevision: revision },
|
|
688
|
+
});
|
|
689
|
+
keepBrowser =
|
|
690
|
+
!execution.result.ok &&
|
|
691
|
+
execution.result.continuation !== undefined &&
|
|
692
|
+
isRetained(browser);
|
|
693
|
+
if (!keepBrowser && !execution.result.ok) {
|
|
694
|
+
execution.result = { ...execution.result, continuation: undefined };
|
|
695
|
+
}
|
|
696
|
+
return execution;
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (signal.aborted || !deadline.aborted) throw error;
|
|
699
|
+
return authRequired('Sign-in did not finish before the recovery deadline.');
|
|
700
|
+
} finally {
|
|
701
|
+
if (keepBrowser) parkSignIn(tool, browser, release);
|
|
702
|
+
else await closeSignIn(tool);
|
|
703
|
+
}
|
|
704
|
+
})(),
|
|
705
|
+
);
|
|
706
|
+
if ('content' in resumed) return resumed;
|
|
707
|
+
outcome = resumed;
|
|
708
|
+
} else {
|
|
709
|
+
const saved = protectedRead
|
|
710
|
+
? await loadCredentialStore(tool.site, signal, { includeEmpty: true })
|
|
711
|
+
: undefined;
|
|
712
|
+
outcome = await executeTool(tool, args, continuation?.state, {
|
|
713
|
+
signal,
|
|
714
|
+
credentials: saved,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
if (
|
|
718
|
+
authTool &&
|
|
719
|
+
policy &&
|
|
720
|
+
signal &&
|
|
721
|
+
protectedRead &&
|
|
722
|
+
!outcome.result.ok &&
|
|
723
|
+
outcome.result.error === 'AUTH_EXPIRED'
|
|
724
|
+
) {
|
|
725
|
+
const authName = authTool.workflow.toolName;
|
|
726
|
+
const pending = authContinuations.pending(authName, isRetained);
|
|
727
|
+
if (pending && !pending.managed) {
|
|
728
|
+
return authRequired('Finish the standalone sign-in, then retry the protected call.');
|
|
729
|
+
}
|
|
730
|
+
if (pending && pending.revision === saved?.durableRevision) {
|
|
731
|
+
return authRequired('A sign-in is already waiting for a response.', {
|
|
732
|
+
authTool: authName,
|
|
733
|
+
nextAction: pending.nextAction,
|
|
734
|
+
continuation: pending.continuation,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
if (releaseParkedSignIn) await closeSignIn(authTool);
|
|
738
|
+
if (generation !== recoveryGeneration) {
|
|
739
|
+
return authRequired(
|
|
740
|
+
'Another call already tried to renew this session. Try again to start a new attempt.',
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
const deadline = AbortSignal.timeout(policy.timeoutMs);
|
|
744
|
+
const recoverySignal = AbortSignal.any([signal, deadline]);
|
|
745
|
+
try {
|
|
746
|
+
const result = await trackRecovery(
|
|
747
|
+
(async () => {
|
|
748
|
+
const release = await acquireSiteAuthRecoveryLock(tool.site, recoverySignal);
|
|
749
|
+
let keepBrowser = false;
|
|
750
|
+
try {
|
|
751
|
+
const current = await loadCredentialStore(tool.site, recoverySignal, {
|
|
752
|
+
includeEmpty: true,
|
|
753
|
+
});
|
|
754
|
+
const currentRevision = current?.durableRevision;
|
|
755
|
+
if (!current || currentRevision === undefined) {
|
|
756
|
+
return {
|
|
757
|
+
status: 'blocked',
|
|
758
|
+
message: 'The saved session could not be loaded.',
|
|
759
|
+
} as const;
|
|
760
|
+
}
|
|
761
|
+
// A 401 may have arrived after another sign-in restored
|
|
762
|
+
// the session, even if the saved credential bytes match.
|
|
763
|
+
try {
|
|
764
|
+
outcome = await withAbortSignal(
|
|
765
|
+
() =>
|
|
766
|
+
executeTool(tool, args, undefined, {
|
|
767
|
+
credentials: current,
|
|
768
|
+
signal: recoverySignal,
|
|
769
|
+
replayOnly: true,
|
|
770
|
+
retainBrowser: true,
|
|
771
|
+
}),
|
|
772
|
+
recoverySignal,
|
|
773
|
+
);
|
|
774
|
+
} finally {
|
|
775
|
+
const toolName = tool.workflow.toolName;
|
|
776
|
+
if (recoverySignal.aborted) await cdpSessions.closeTool(tool.site, toolName);
|
|
777
|
+
else cdpSessions.finishCall(tool.site, toolName);
|
|
778
|
+
}
|
|
779
|
+
if (outcome.result.ok || outcome.result.error !== 'AUTH_EXPIRED') {
|
|
780
|
+
return { status: 'rechecked' } as const;
|
|
781
|
+
}
|
|
782
|
+
if (currentRevision !== saved?.durableRevision) {
|
|
783
|
+
return {
|
|
784
|
+
status: 'blocked',
|
|
785
|
+
message:
|
|
786
|
+
'The protected call still needs sign-in with the newer saved session.',
|
|
787
|
+
} as const;
|
|
788
|
+
}
|
|
789
|
+
await cdpSessions.closeTool(tool.site, authName);
|
|
790
|
+
const stillCurrent = async () =>
|
|
791
|
+
(await loadCredentialStore(tool.site, recoverySignal, { includeEmpty: true }))
|
|
792
|
+
?.durableRevision === currentRevision;
|
|
793
|
+
const recovered = await recoverSession(authTool.workflow, policy, {
|
|
794
|
+
signal: recoverySignal,
|
|
795
|
+
execute: async (action, state) => {
|
|
796
|
+
if (!(await stillCurrent())) {
|
|
797
|
+
return {
|
|
798
|
+
ok: false,
|
|
799
|
+
error: 'AUTH_EXPIRED',
|
|
800
|
+
message: 'The saved session changed.',
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
return (
|
|
804
|
+
await executeTool(authTool, { action }, state, {
|
|
805
|
+
browserOnly: true,
|
|
806
|
+
authBrowser: state === undefined ? undefined : authBrowser(authTool),
|
|
807
|
+
retainBrowser: true,
|
|
808
|
+
signal: recoverySignal,
|
|
809
|
+
credentials: { ...current, authCommitRevision: currentRevision },
|
|
810
|
+
})
|
|
811
|
+
).result;
|
|
812
|
+
},
|
|
813
|
+
requestConsent: requestConsent
|
|
814
|
+
? async (name) =>
|
|
815
|
+
(await stillCurrent()) && requestConsent(name, recoverySignal)
|
|
816
|
+
: undefined,
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
if (recovered.status !== 'authenticated' && !(await stillCurrent())) {
|
|
820
|
+
return { status: 'retry' } as const;
|
|
821
|
+
}
|
|
822
|
+
if (recovered.status === 'manual') {
|
|
823
|
+
const unsafe = unsafeAuthRequests(authTool.workflow, recovered.action);
|
|
824
|
+
const browser =
|
|
825
|
+
recovered.state === undefined ? undefined : authBrowser(authTool);
|
|
826
|
+
if (!unsafe || (unsafe.size && (!requestConsent || !browser))) {
|
|
827
|
+
return {
|
|
828
|
+
status: 'blocked',
|
|
829
|
+
message: 'This action needs standalone manual sign-in.',
|
|
830
|
+
} as const;
|
|
831
|
+
}
|
|
832
|
+
if (browser) {
|
|
833
|
+
parkSignIn(authTool, browser, release);
|
|
834
|
+
keepBrowser = true;
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
...recovered,
|
|
838
|
+
continuation:
|
|
839
|
+
browser && recovered.state !== undefined
|
|
840
|
+
? authContinuations.issue({
|
|
841
|
+
toolName: authName,
|
|
842
|
+
nextAction: recovered.action,
|
|
843
|
+
state: recovered.state,
|
|
844
|
+
recoveryRevision: currentRevision,
|
|
845
|
+
recoveryBrowser: browser,
|
|
846
|
+
})
|
|
847
|
+
: undefined,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
return recovered;
|
|
851
|
+
} finally {
|
|
852
|
+
if (!keepBrowser) {
|
|
853
|
+
try {
|
|
854
|
+
await cdpSessions.closeTool(tool.site, authName);
|
|
855
|
+
} finally {
|
|
856
|
+
await release();
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
})(),
|
|
861
|
+
);
|
|
862
|
+
if (result.status === 'manual') {
|
|
863
|
+
return authRequired(result.message, {
|
|
864
|
+
authTool: authTool.workflow.toolName,
|
|
865
|
+
nextAction: result.action,
|
|
866
|
+
continuation: result.continuation,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
if (result.status === 'blocked') return authRequired(result.message);
|
|
870
|
+
} catch (error) {
|
|
871
|
+
if (signal.aborted || !deadline.aborted) throw error;
|
|
872
|
+
return authRequired('Sign-in did not finish before the recovery deadline.');
|
|
873
|
+
} finally {
|
|
874
|
+
recoveryGeneration++;
|
|
875
|
+
}
|
|
876
|
+
if (!outcome.result.ok && outcome.result.error === 'AUTH_EXPIRED') {
|
|
877
|
+
outcome = await executeTool(tool, args, undefined, { signal, replayOnly: true });
|
|
878
|
+
}
|
|
879
|
+
if (!outcome.result.ok && outcome.result.error === 'AUTH_EXPIRED') {
|
|
880
|
+
return authRequired(
|
|
881
|
+
'The session is still unavailable after retrying the protected call.',
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const { result, usedBackend, attempts } = outcome;
|
|
350
887
|
if (!result.ok) {
|
|
888
|
+
const standaloneBrowser =
|
|
889
|
+
revision === undefined &&
|
|
890
|
+
authName === tool.workflow.toolName &&
|
|
891
|
+
usedBackend === 'cdp-replay'
|
|
892
|
+
? authBrowser(tool)
|
|
893
|
+
: undefined;
|
|
351
894
|
const continuationToken =
|
|
352
895
|
tool.workflow.toolKind === 'authenticate' && result.continuation
|
|
353
896
|
? authContinuations.issue({
|
|
@@ -355,6 +898,15 @@ function buildServer(
|
|
|
355
898
|
nextAction:
|
|
356
899
|
result.nextAction ?? String(args.action ?? tool.workflow.authConfig?.entry),
|
|
357
900
|
state: result.continuation,
|
|
901
|
+
recoveryRevision: revision,
|
|
902
|
+
recoveryBrowser: continuation?.recoveryBrowser,
|
|
903
|
+
standaloneBrowser: standaloneBrowser
|
|
904
|
+
? new WeakRef(standaloneBrowser)
|
|
905
|
+
: continuation?.standaloneBrowser,
|
|
906
|
+
standaloneUntil:
|
|
907
|
+
revision === undefined && policy && authName === tool.workflow.toolName
|
|
908
|
+
? Date.now() + policy.timeoutMs
|
|
909
|
+
: undefined,
|
|
358
910
|
})
|
|
359
911
|
: undefined;
|
|
360
912
|
const text = formatToolError(result, continuationToken, attempts);
|
|
@@ -387,7 +939,10 @@ function buildServer(
|
|
|
387
939
|
});
|
|
388
940
|
|
|
389
941
|
async function closeCdpPool(): Promise<void> {
|
|
942
|
+
shuttingDown.abort();
|
|
943
|
+
await Promise.allSettled(activeRecovery);
|
|
390
944
|
await cdpSessions.closeAll();
|
|
945
|
+
await releaseParkedSignIn?.();
|
|
391
946
|
authContinuations.clear();
|
|
392
947
|
}
|
|
393
948
|
|
|
@@ -513,7 +1068,15 @@ export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
|
|
|
513
1068
|
const host = opts.host ?? '127.0.0.1';
|
|
514
1069
|
await runHttp(name, version, tools, host, port, assetRoot);
|
|
515
1070
|
} else {
|
|
516
|
-
|
|
1071
|
+
let policy: RecoveryPolicy | undefined;
|
|
1072
|
+
if (auditToolDeadlineMs() === undefined) {
|
|
1073
|
+
try {
|
|
1074
|
+
policy = loadRecoveryPolicy(assetRoot, opts.site);
|
|
1075
|
+
} catch {
|
|
1076
|
+
log('Ignoring invalid mcp-session-recovery.json. Existing tools are still available.');
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
await runStdio(name, version, tools, assetRoot, policy);
|
|
517
1080
|
}
|
|
518
1081
|
}
|
|
519
1082
|
|
|
@@ -529,22 +1092,35 @@ async function runStdio(
|
|
|
529
1092
|
version: string,
|
|
530
1093
|
tools: ResolvedTool[],
|
|
531
1094
|
assetRoot: string,
|
|
1095
|
+
policy?: RecoveryPolicy,
|
|
532
1096
|
): Promise<void> {
|
|
533
|
-
const { server, closeCdpPool } = buildServer(name, version, tools, assetRoot);
|
|
1097
|
+
const { server, closeCdpPool } = buildServer(name, version, tools, assetRoot, policy);
|
|
534
1098
|
const transport = new StdioServerTransport();
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1099
|
+
const stopped = Promise.withResolvers<string>();
|
|
1100
|
+
const disconnect = () => stopped.resolve('client disconnected');
|
|
1101
|
+
const interrupt = () => stopped.resolve('SIGINT');
|
|
1102
|
+
const terminate = () => stopped.resolve('SIGTERM');
|
|
1103
|
+
server.onclose = disconnect;
|
|
1104
|
+
process.stdin.once('end', disconnect);
|
|
1105
|
+
process.on('SIGINT', interrupt);
|
|
1106
|
+
process.on('SIGTERM', terminate);
|
|
1107
|
+
try {
|
|
1108
|
+
await server.connect(transport);
|
|
1109
|
+
log(`stdio transport ready (${tools.length} tool${tools.length === 1 ? '' : 's'})`);
|
|
1110
|
+
log(`stdio transport closing: ${await stopped.promise}`);
|
|
1111
|
+
} finally {
|
|
1112
|
+
process.stdin.off('end', disconnect);
|
|
1113
|
+
try {
|
|
1114
|
+
try {
|
|
1115
|
+
await server.close();
|
|
1116
|
+
} finally {
|
|
1117
|
+
await closeCdpPool();
|
|
1118
|
+
}
|
|
1119
|
+
} finally {
|
|
1120
|
+
process.off('SIGINT', interrupt);
|
|
1121
|
+
process.off('SIGTERM', terminate);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
548
1124
|
}
|
|
549
1125
|
|
|
550
1126
|
/**
|