imprint-mcp 0.7.0 → 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.
Files changed (58) hide show
  1. package/README.md +66 -2
  2. package/package.json +1 -1
  3. package/prompts/auth-compile-agent.md +173 -22
  4. package/prompts/compile-agent.md +45 -8
  5. package/prompts/component-recovery.md +40 -0
  6. package/prompts/master-teach-decision.md +28 -3
  7. package/prompts/master-teach-focused-planner.md +18 -5
  8. package/src/cli.ts +26 -18
  9. package/src/imprint/agent-tool.ts +15 -0
  10. package/src/imprint/agent.ts +2 -11
  11. package/src/imprint/audit.ts +35 -13
  12. package/src/imprint/auth-browser-discovery.ts +276 -0
  13. package/src/imprint/auth-compile-agent.ts +513 -224
  14. package/src/imprint/auth-compile-tools.ts +213 -25
  15. package/src/imprint/auth-discovery-evidence.ts +81 -0
  16. package/src/imprint/auth-plan.ts +28 -0
  17. package/src/imprint/auth-verifier.ts +234 -20
  18. package/src/imprint/authentication-interaction.ts +137 -0
  19. package/src/imprint/backend-ladder.ts +271 -43
  20. package/src/imprint/build-plan.ts +2 -22
  21. package/src/imprint/cdp-browser-fetch.ts +242 -60
  22. package/src/imprint/chromium.ts +93 -29
  23. package/src/imprint/claude-cli-compile.ts +2 -0
  24. package/src/imprint/codex-cli-compile.ts +99 -46
  25. package/src/imprint/compile-agent-types.ts +9 -5
  26. package/src/imprint/compile-agent.ts +33 -0
  27. package/src/imprint/compile-tool-executor.ts +783 -0
  28. package/src/imprint/compile-tools.ts +42 -9
  29. package/src/imprint/compile-verification.ts +2 -0
  30. package/src/imprint/compile.ts +3 -0
  31. package/src/imprint/compiler-log.ts +16 -0
  32. package/src/imprint/component-recovery.ts +120 -0
  33. package/src/imprint/cookie-jar.ts +117 -7
  34. package/src/imprint/credential-extract.ts +9 -2
  35. package/src/imprint/credential-lock.ts +147 -0
  36. package/src/imprint/credential-observation.ts +261 -0
  37. package/src/imprint/credential-store.ts +284 -44
  38. package/src/imprint/live-component-recovery.ts +73 -0
  39. package/src/imprint/llm.ts +4 -3
  40. package/src/imprint/log.ts +16 -1
  41. package/src/imprint/login.ts +9 -0
  42. package/src/imprint/master-teach-agent-contracts.ts +40 -3
  43. package/src/imprint/master-teach-agents.ts +229 -14
  44. package/src/imprint/master-teach-checks.ts +8 -1
  45. package/src/imprint/master-teach-controller.ts +681 -138
  46. package/src/imprint/master-teach-prompt-projections.ts +2 -0
  47. package/src/imprint/mcp-cdp-sessions.ts +7 -2
  48. package/src/imprint/mcp-compile-server.ts +62 -850
  49. package/src/imprint/mcp-server.ts +667 -91
  50. package/src/imprint/mcp-session-recovery.ts +167 -0
  51. package/src/imprint/native-teach-agents.ts +232 -108
  52. package/src/imprint/runtime.ts +416 -47
  53. package/src/imprint/stealth-chromium.ts +67 -7
  54. package/src/imprint/teach-browser-resources.ts +88 -0
  55. package/src/imprint/teach-stage-progress.ts +38 -0
  56. package/src/imprint/terminal-user-prompt.ts +42 -0
  57. package/src/imprint/types.ts +51 -18
  58. package/src/imprint/user-interaction.ts +836 -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 { type BackendAttemptFact, resolveLadder, runWithLadder } from './backend-ladder.ts';
21
- import { TimeoutError, withTimeoutCleanup } from './concurrency.ts';
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): Record<string, unknown> | undefined {
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.state;
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((t) => ({
240
- name: t.workflow.toolName,
241
- description: buildToolDescription(t.workflow),
242
- inputSchema: t.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
- if (tool.workflow.toolKind === 'authenticate' && typeof rawArgs.action === 'string') {
275
- args.action = rawArgs.action;
276
- }
277
- let initialState: Record<string, unknown> | undefined;
278
- if (tool.workflow.toolKind === 'authenticate' && rawArgs.continuation !== undefined) {
279
- const action = String(args.action ?? tool.workflow.authConfig?.entry ?? '');
280
- if (typeof rawArgs.continuation !== 'string') {
281
- return {
282
- isError: true,
283
- content: [{ type: 'text', text: 'Invalid continuation token.' }],
284
- };
285
- }
286
- initialState = authContinuations.consume(
287
- rawArgs.continuation,
288
- tool.workflow.toolName,
289
- action,
290
- );
291
- if (!initialState) {
292
- return {
293
- isError: true,
294
- content: [{ type: 'text', text: 'Invalid or expired continuation token.' }],
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
- // Audit-only pacing: when the audit harness sets IMPRINT_AUDIT_PACING_MS,
302
- // sleep before each actual workflow execution so same-site queued calls
303
- // stay spaced out instead of all waiting concurrently before the queue.
304
- // Unset in production -> no delay.
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((r) => setTimeout(r, pacingMs));
621
+ await new Promise((resolve) => setTimeout(resolve, pacingMs));
308
622
  }
309
623
 
310
- const ladder = resolveLadder('auto', tool.preferredOrder);
311
- cdpSessions.beginCall(tool.site, tool.workflow.toolName);
312
- const execution = new AbortController();
313
- const run = runWithLadder(ladder, tool, args, assetRoot, stealthCache, {
314
- cdpPool,
315
- winnerCache,
316
- initialState,
317
- signal: execution.signal,
318
- });
319
- const auditDeadlineMs = auditToolDeadlineMs();
320
- let result: ToolResult;
321
- let usedBackend: ConcreteBackend;
322
- let attempts: Awaited<ReturnType<typeof runWithLadder>>['attempts'];
323
- try {
324
- const out = auditDeadlineMs
325
- ? await withTimeoutCleanup(
326
- run,
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
- await runStdio(name, version, tools, assetRoot);
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
- await server.connect(transport);
536
- log(`stdio transport ready (${tools.length} tool${tools.length === 1 ? '' : 's'})`);
537
-
538
- await new Promise<void>((resolve) => {
539
- const done = (reason: string): void => {
540
- log(`stdio transport closing: ${reason}`);
541
- resolve();
542
- };
543
- transport.onclose = () => done('client disconnected');
544
- process.once('SIGINT', () => done('SIGINT'));
545
- process.once('SIGTERM', () => done('SIGTERM'));
546
- });
547
- await closeCdpPool();
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
  /**