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.
@@ -0,0 +1,167 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import type { ToolResult, Workflow } from './types.ts';
5
+
6
+ const RecoveryPolicySchema = z
7
+ .object({
8
+ authTool: z.string().min(1),
9
+ protectedTools: z.array(z.string().min(1)).min(1),
10
+ startAction: z.string().min(1).optional(),
11
+ refreshAction: z.string().min(1).optional(),
12
+ timeoutMs: z.number().int().min(1).max(2_147_483_647).default(240_000),
13
+ })
14
+ .strict();
15
+
16
+ export type RecoveryPolicy = z.infer<typeof RecoveryPolicySchema>;
17
+
18
+ export function loadRecoveryPolicy(assetRoot: string, site: string): RecoveryPolicy | undefined {
19
+ const path = join(assetRoot, site, 'mcp-session-recovery.json');
20
+ try {
21
+ return RecoveryPolicySchema.parse(JSON.parse(readFileSync(path, 'utf8')));
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
24
+ throw error;
25
+ }
26
+ }
27
+
28
+ type AuthFailure = Extract<ToolResult, { ok: false }>;
29
+ type RecoveryResult =
30
+ | { status: 'authenticated' }
31
+ | { status: 'blocked'; message: string }
32
+ | { status: 'manual'; action: string; state?: Record<string, unknown>; message: string };
33
+
34
+ interface RecoveryHost {
35
+ signal: AbortSignal;
36
+ execute(action: string, state?: Record<string, unknown>): Promise<ToolResult>;
37
+ requestConsent?(action: string): Promise<boolean>;
38
+ }
39
+
40
+ function manual(action: string, pause?: AuthFailure): RecoveryResult {
41
+ return {
42
+ status: 'manual',
43
+ action,
44
+ state: pause?.continuation,
45
+ message: pause?.message ?? 'Sign-in needs to continue through the authentication tool.',
46
+ };
47
+ }
48
+
49
+ function isAuthenticated(result: ToolResult): boolean {
50
+ return (
51
+ result.ok &&
52
+ typeof result.data === 'object' &&
53
+ result.data !== null &&
54
+ 'authenticated' in result.data &&
55
+ result.data.authenticated === true
56
+ );
57
+ }
58
+
59
+ export function missingAuthInputs(
60
+ workflow: Workflow,
61
+ action: string,
62
+ args: Record<string, unknown> = {},
63
+ ): string[] {
64
+ return (workflow.authConfig?.actions[action]?.parameters ?? []).filter(
65
+ (name) =>
66
+ args[name] === undefined &&
67
+ !workflow.parameters.some(
68
+ (parameter) => parameter.name === name && parameter.default !== undefined,
69
+ ),
70
+ );
71
+ }
72
+
73
+ /** Requests that need consent. Undefined means this action stays standalone. */
74
+ export function unsafeAuthRequests(workflow: Workflow, action: string): Set<number> | undefined {
75
+ const actions = workflow.authConfig?.actions;
76
+ const steps = actions && Object.hasOwn(actions, action) ? actions[action]?.steps : undefined;
77
+ if (!steps) return undefined;
78
+
79
+ const unsafe = new Set<number>();
80
+ for (const step of steps) {
81
+ const request = workflow.requests[step.request];
82
+ if (request?.effect === 'safe' || request?.effect === 'idempotent') continue;
83
+ if (
84
+ request?.effect !== 'unsafe' ||
85
+ request.mode === 'navigate' ||
86
+ unsafe.has(step.request) ||
87
+ (step.repeat?.maxAttempts ?? 1) > 1
88
+ )
89
+ return undefined;
90
+ unsafe.add(step.request);
91
+ }
92
+ return unsafe;
93
+ }
94
+
95
+ export async function recoverSession(
96
+ workflow: Workflow,
97
+ policy: RecoveryPolicy,
98
+ host: RecoveryHost,
99
+ ): Promise<RecoveryResult> {
100
+ const config = workflow.authConfig;
101
+ if (!config?.actions) return { status: 'blocked', message: 'No compiled sign-in is available.' };
102
+
103
+ let action = policy.startAction ?? config.entry;
104
+ let pause: AuthFailure | undefined;
105
+ const refresh = policy.refreshAction === action ? undefined : policy.refreshAction;
106
+ const refreshAction =
107
+ refresh && Object.hasOwn(config.actions, refresh) ? config.actions[refresh] : undefined;
108
+ if (
109
+ refresh &&
110
+ refreshAction &&
111
+ !missingAuthInputs(workflow, refresh).length &&
112
+ refreshAction.steps.every(({ request }) => {
113
+ const effect = workflow.requests[request]?.effect;
114
+ return effect === 'safe' || effect === 'idempotent';
115
+ })
116
+ ) {
117
+ host.signal.throwIfAborted();
118
+ const result = await host.execute(refresh);
119
+ if (isAuthenticated(result)) return { status: 'authenticated' };
120
+ if (!result.ok && result.error === 'ACTION_REQUIRED' && result.nextAction) {
121
+ if (result.resume !== 'automatic') return manual(result.nextAction, result);
122
+ action = result.nextAction;
123
+ pause = result;
124
+ } else if (result.ok || result.error !== 'AUTH_EXPIRED') {
125
+ return { status: 'blocked', message: 'The saved session could not be refreshed.' };
126
+ }
127
+ }
128
+
129
+ const visited = new Set<string>();
130
+ const dispatchedUnsafe = new Set<number>();
131
+ while (!visited.has(action)) {
132
+ visited.add(action);
133
+ host.signal.throwIfAborted();
134
+ const unsafe = unsafeAuthRequests(workflow, action);
135
+ if (!unsafe || [...unsafe].some((request) => dispatchedUnsafe.has(request))) {
136
+ return { status: 'blocked', message: 'This action needs standalone manual sign-in.' };
137
+ }
138
+ if (missingAuthInputs(workflow, action).length) return manual(action, pause);
139
+
140
+ if (unsafe.size) {
141
+ if (!host.requestConsent) {
142
+ return {
143
+ status: 'blocked',
144
+ message: 'This client cannot request consent for a new approval. Use manual sign-in.',
145
+ };
146
+ }
147
+ if (!(await host.requestConsent(action))) {
148
+ return { status: 'blocked', message: 'Consent was declined or timed out.' };
149
+ }
150
+ for (const request of unsafe) dispatchedUnsafe.add(request);
151
+ }
152
+
153
+ host.signal.throwIfAborted();
154
+ const result = await host.execute(action, pause?.continuation);
155
+ if (isAuthenticated(result)) return { status: 'authenticated' };
156
+ if (result.ok || result.error !== 'ACTION_REQUIRED' || !result.nextAction) {
157
+ return {
158
+ status: 'blocked',
159
+ message: 'The compiled sign-in did not verify the session. Use the standalone auth tool.',
160
+ };
161
+ }
162
+ if (result.resume !== 'automatic') return manual(result.nextAction, result);
163
+ action = result.nextAction;
164
+ pause = result;
165
+ }
166
+ return { status: 'blocked', message: 'The compiled sign-in repeated an action.' };
167
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { dirname, resolve as pathResolve } from 'node:path';
10
+ import { withAbortSignal } from './concurrency.ts';
10
11
  import {
11
12
  type CookieLookupConstraints,
12
13
  type RuntimeCookie,
@@ -34,6 +35,7 @@ import {
34
35
  resolveJsonCapture,
35
36
  } from './request-capture.ts';
36
37
  import type {
38
+ BrowserRequestInit,
37
39
  RequestCapture,
38
40
  RequestStageFact,
39
41
  StateCapability,
@@ -59,6 +61,8 @@ export interface CredentialStore {
59
61
  /** Version of the durable snapshot read by the host, before environment
60
62
  * overlays or browser mutations. Enables conditional cookie writeback. */
61
63
  durableRevision?: string;
64
+ /** Commit managed authentication against the snapshot used to start it. */
65
+ authCommitRevision?: string;
62
66
  }
63
67
 
64
68
  type NavigationOptions = NonNullable<WorkflowRequest['navigation']>;
@@ -173,6 +177,7 @@ export interface BrowserNavigationTransport {
173
177
  export async function loadCredentialStore(
174
178
  site: string,
175
179
  signal?: AbortSignal,
180
+ options?: { includeEmpty?: boolean },
176
181
  ): Promise<CredentialStore | null> {
177
182
  const view = await loadSiteCredentials(site, { signal });
178
183
  const store: CredentialStore = {
@@ -200,6 +205,7 @@ export async function loadCredentialStore(
200
205
  }
201
206
 
202
207
  if (
208
+ !options?.includeEmpty &&
203
209
  Object.keys(store.values).length === 0 &&
204
210
  store.cookies.length === 0 &&
205
211
  (store.storage?.length ?? 0) === 0
@@ -978,12 +984,15 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
978
984
  message: `Auth action ${JSON.stringify(actionName)} requires: ${missingParameters.join(', ')}.`,
979
985
  };
980
986
  }
987
+ const authSignal = credentials.authCommitRevision === undefined ? undefined : opts.signal;
981
988
  let requestTransform: RequestTransform | undefined;
982
989
  if (opts.workflow.requestTransformModule && opts.workflowPath) {
983
990
  try {
984
- const mod = await importModuleFresh(
985
- pathResolve(dirname(opts.workflowPath), opts.workflow.requestTransformModule),
991
+ const modulePath = pathResolve(
992
+ dirname(opts.workflowPath),
993
+ opts.workflow.requestTransformModule,
986
994
  );
995
+ const mod = await withAbortSignal(() => importModuleFresh(modulePath), authSignal);
987
996
  if (typeof mod.transform === 'function') {
988
997
  requestTransform = mod.transform as RequestTransform;
989
998
  }
@@ -1007,6 +1016,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1007
1016
  let currentRequestIndex = 0;
1008
1017
  let phase: RequestStageFact['stage'] = 'preparation';
1009
1018
  let transportInvoked = false;
1019
+ let managedUnsafeDispatches = 0;
1010
1020
  let httpStatus: number | undefined;
1011
1021
  const recordStage = (
1012
1022
  stage: RequestStageFact['stage'],
@@ -1025,6 +1035,18 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1025
1035
  if (stageFacts.length > 32) stageFacts.shift();
1026
1036
  };
1027
1037
  const fail = (result: RuntimeErrorResult): RuntimeErrorResult => {
1038
+ // A user can correct input after the provider rejects this submission.
1039
+ // Managed MCP still requires consent before sending the corrected request.
1040
+ const canCorrectInput =
1041
+ managedUnsafeDispatches === 1 &&
1042
+ action.parameters.length > 0 &&
1043
+ phase === 'send' &&
1044
+ transportInvoked &&
1045
+ opts.workflow.requests[currentRequestIndex]?.effect === 'unsafe' &&
1046
+ result.error !== 'NETWORK' &&
1047
+ httpStatus !== undefined &&
1048
+ httpStatus >= 400 &&
1049
+ httpStatus < 500;
1028
1050
  return {
1029
1051
  ...result,
1030
1052
  requestStageFacts: [...stageFacts, ...(result.requestStageFacts ?? [])].slice(-32),
@@ -1039,7 +1061,12 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1039
1061
  ...(httpStatus === undefined ? {} : { httpStatus }),
1040
1062
  ...result.authExecution,
1041
1063
  },
1042
- continuation: Object.keys(initialState).length > 0 ? initialState : undefined,
1064
+ continuation:
1065
+ (managedUnsafeDispatches === 0 || canCorrectInput) &&
1066
+ (Object.keys(initialState).length > 0 ||
1067
+ (credentials.authCommitRevision !== undefined && opts.initialState !== undefined))
1068
+ ? initialState
1069
+ : undefined,
1043
1070
  };
1044
1071
  };
1045
1072
 
@@ -1130,13 +1157,18 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1130
1157
  const request = substituted.value;
1131
1158
  let navigation = req.navigation;
1132
1159
 
1133
- if (requestTransform) {
1160
+ const transform = requestTransform;
1161
+ if (transform) {
1134
1162
  try {
1135
- const transformed = await requestTransform(
1136
- request.method,
1137
- request.url,
1138
- responseSlots.map((slot) => slot.raw),
1139
- params,
1163
+ const transformed = await withAbortSignal(
1164
+ async () =>
1165
+ transform(
1166
+ request.method,
1167
+ request.url,
1168
+ responseSlots.map((slot) => slot.raw),
1169
+ params,
1170
+ ),
1171
+ authSignal,
1140
1172
  );
1141
1173
  if (typeof transformed === 'string') request.url = transformed;
1142
1174
  else if (transformed) {
@@ -1237,7 +1269,10 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1237
1269
  try {
1238
1270
  transportInvoked = true;
1239
1271
  phase = 'send';
1240
- response = await fetchFn(request.url, {
1272
+ const managedUnsafe =
1273
+ credentials.authCommitRevision !== undefined && req.effect === 'unsafe';
1274
+ if (managedUnsafe) managedUnsafeDispatches++;
1275
+ const init: BrowserRequestInit = {
1241
1276
  method: request.method,
1242
1277
  headers: request.headers,
1243
1278
  body: request.body,
@@ -1245,7 +1280,9 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1245
1280
  ? AbortSignal.any([opts.signal, controller.signal])
1246
1281
  : controller.signal,
1247
1282
  redirect: 'follow',
1248
- });
1283
+ ...(managedUnsafe ? { allowAmbiguousRetry: false } : {}),
1284
+ };
1285
+ response = await fetchFn(request.url, init);
1249
1286
  httpStatus = response.status;
1250
1287
  if (opts.browser)
1251
1288
  cookieJar.reconcileBrowserSnapshot(
@@ -1407,6 +1444,14 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1407
1444
  if (opts.signal?.aborted) return cancellationFailure();
1408
1445
  if (!executed.ok) {
1409
1446
  lastFailure = executed.result;
1447
+ if (
1448
+ managedUnsafeDispatches > 0 &&
1449
+ lastFailure.error === 'NETWORK' &&
1450
+ transportInvoked &&
1451
+ opts.workflow.requests[step.request]?.effect === 'unsafe'
1452
+ ) {
1453
+ return fail(lastFailure);
1454
+ }
1410
1455
  if (step.onError === 'continue') {
1411
1456
  matched = true;
1412
1457
  break;
@@ -1497,7 +1542,8 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1497
1542
  }
1498
1543
  const continuation = Object.fromEntries(outcome.carry.map((name) => [name, state[name]]));
1499
1544
  if (opts.signal?.aborted) return cancellationFailure();
1500
- const cookieFailure = await persistCookies();
1545
+ const cookieFailure =
1546
+ credentials.authCommitRevision === undefined ? await persistCookies() : undefined;
1501
1547
  if (opts.signal?.aborted) return cancellationFailure();
1502
1548
  if (cookieFailure && !opts.browser) return cookieFailure;
1503
1549
  const persistenceWarning = cookieFailure
@@ -1509,6 +1555,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1509
1555
  error: 'ACTION_REQUIRED',
1510
1556
  message: `${outcome.message}${persistenceWarning}`,
1511
1557
  nextAction: outcome.next,
1558
+ ...(outcome.resume ? { resume: outcome.resume } : {}),
1512
1559
  continuation,
1513
1560
  responseBodyPreview: lastResponse?.text.slice(0, 500),
1514
1561
  };
@@ -1536,6 +1583,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
1536
1583
  try {
1537
1584
  await commitSiteAuthState(opts.workflow.site, cookieJar.toJSON(), persistedSecrets, {
1538
1585
  signal: opts.signal,
1586
+ expectedRevision: credentials.authCommitRevision,
1539
1587
  });
1540
1588
  } catch (err) {
1541
1589
  return opts.signal?.aborted
@@ -251,6 +251,9 @@ const BootstrapCaptureSchema = z.discriminatedUnion('source', [
251
251
  ]);
252
252
  export type BootstrapCapture = z.infer<typeof BootstrapCaptureSchema>;
253
253
 
254
+ /** Managed authentication can disable transport retry for an ambiguous dispatch. */
255
+ export type BrowserRequestInit = RequestInit & { allowAmbiguousRetry?: false };
256
+
254
257
  const WorkflowRequestBaseSchema = z.object({
255
258
  method: z.string(),
256
259
  /** Sequence number of the captured request that grounds this outgoing
@@ -370,6 +373,7 @@ const AuthOutcomeSchema = z.discriminatedUnion('type', [
370
373
  .object({
371
374
  type: z.literal('pause'),
372
375
  next: z.string().min(1),
376
+ resume: z.literal('automatic').optional(),
373
377
  evidence: z.array(z.string().min(1)).optional().default([]),
374
378
  carry: z.array(z.string().min(1)).optional().default([]),
375
379
  message: z.string().min(1),
@@ -614,6 +618,7 @@ export type ToolResult<T = unknown> =
614
618
  * unchanged on the next auth call. */
615
619
  continuation?: Record<string, unknown>;
616
620
  nextAction?: string;
621
+ resume?: 'automatic';
617
622
  };
618
623
 
619
624
  // ─── Cron config (input to `imprint cron`) ───────────────────────────────────