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
|
@@ -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
|
+
}
|
package/src/imprint/runtime.ts
CHANGED
|
@@ -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
|
|
985
|
-
|
|
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:
|
|
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
|
-
|
|
1160
|
+
const transform = requestTransform;
|
|
1161
|
+
if (transform) {
|
|
1134
1162
|
try {
|
|
1135
|
-
const transformed = await
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
package/src/imprint/types.ts
CHANGED
|
@@ -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`) ───────────────────────────────────
|