imprint-mcp 0.6.0 → 0.6.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 (43) hide show
  1. package/README.md +2 -0
  2. package/package.json +4 -2
  3. package/prompts/audit-agent.md +4 -2
  4. package/prompts/auth-compile-agent.md +149 -158
  5. package/prompts/build-planning.md +14 -9
  6. package/prompts/compile-agent.md +46 -23
  7. package/prompts/live-verifier-agent.md +101 -0
  8. package/prompts/playbook-compilation.md +8 -1
  9. package/prompts/tool-candidate-detection.md +9 -29
  10. package/src/cli.ts +83 -1
  11. package/src/imprint/audit.ts +219 -20
  12. package/src/imprint/auth-compile-agent.ts +215 -155
  13. package/src/imprint/auth-compile-tools.ts +300 -118
  14. package/src/imprint/auth-verifier.ts +167 -180
  15. package/src/imprint/backend-cache.ts +18 -11
  16. package/src/imprint/backend-ladder.ts +225 -177
  17. package/src/imprint/build-plan.ts +3 -8
  18. package/src/imprint/cdp-browser-fetch.ts +930 -83
  19. package/src/imprint/claude-cli-compile.ts +46 -20
  20. package/src/imprint/codex-cli-compile.ts +436 -139
  21. package/src/imprint/compile-agent-types.ts +15 -10
  22. package/src/imprint/compile-agent.ts +88 -28
  23. package/src/imprint/compile-tools.ts +228 -39
  24. package/src/imprint/compile-verification.ts +187 -0
  25. package/src/imprint/compile.ts +71 -2
  26. package/src/imprint/credential-store.ts +57 -44
  27. package/src/imprint/emit.ts +3 -1
  28. package/src/imprint/freeform-redact.ts +27 -6
  29. package/src/imprint/live-verifier.ts +1665 -0
  30. package/src/imprint/llm.ts +22 -3
  31. package/src/imprint/login.ts +107 -25
  32. package/src/imprint/mcp-compile-server.ts +447 -114
  33. package/src/imprint/mcp-live-verifier-server.ts +359 -0
  34. package/src/imprint/mcp-server.ts +116 -35
  35. package/src/imprint/probe-backends.ts +88 -7
  36. package/src/imprint/recording-request.ts +36 -0
  37. package/src/imprint/request-capture.ts +40 -17
  38. package/src/imprint/runtime.ts +490 -337
  39. package/src/imprint/teach-state.ts +36 -2
  40. package/src/imprint/teach.ts +302 -146
  41. package/src/imprint/tool-candidates.ts +8 -10
  42. package/src/imprint/tool-loader.ts +12 -4
  43. package/src/imprint/types.ts +137 -68
@@ -0,0 +1,359 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join as pathJoin } from 'node:path';
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import {
6
+ CallToolRequestSchema,
7
+ type CallToolResult,
8
+ ListToolsRequestSchema,
9
+ type Tool,
10
+ } from '@modelcontextprotocol/sdk/types.js';
11
+ import {
12
+ type LiveIntegrationEvidence,
13
+ runCapturedIntegrationCase,
14
+ } from './compile-verification.ts';
15
+ import {
16
+ LIVE_VERIFICATION_EVIDENCE_FILE,
17
+ LiveVerificationReportSchema,
18
+ appendLiveVerifierLog,
19
+ assertReportCoversWorkflowParameters,
20
+ effectiveParamsForEvidence,
21
+ hasSuiteReceiptForSession,
22
+ persistLiveVerificationEvidence,
23
+ prepareLiveVerificationBackend,
24
+ readPersistedLiveVerificationEvidence,
25
+ runLiveIntegrationSuite,
26
+ } from './live-verifier.ts';
27
+ import { loadCredentialStore } from './runtime.ts';
28
+ import { buildZodValidator } from './tool-loader.ts';
29
+ import { WorkflowSchema } from './types.ts';
30
+
31
+ export function joinBackendPreparation<T>(
32
+ state: { current?: { promise: Promise<T>; forceReprobe: boolean } },
33
+ forceReprobe: boolean,
34
+ start: () => Promise<T>,
35
+ ): { promise: Promise<T>; joined: boolean } {
36
+ if (state.current && (!forceReprobe || state.current.forceReprobe)) {
37
+ return { promise: state.current.promise, joined: true };
38
+ }
39
+
40
+ // A forced reprobe must not be satisfied by an ordinary preparation that
41
+ // was already in flight. Queue it behind that preparation so both calls do
42
+ // not race while updating the same backend cache. Further callers can join
43
+ // the queued forced preparation.
44
+ const preceding = state.current?.promise;
45
+ const started = preceding ? preceding.then(start, start) : start();
46
+ const promise = started.finally(() => {
47
+ if (state.current?.promise === promise) state.current = undefined;
48
+ });
49
+ state.current = { promise, forceReprobe };
50
+ return { promise, joined: false };
51
+ }
52
+
53
+ const RUN_TOOL: Tool = {
54
+ name: 'run_live_integration_test',
55
+ description:
56
+ 'Run a targeted live call for a specific unresolved semantic question. Repeated parameters are allowed when the reason explains why.',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: {
60
+ params: { type: 'object', additionalProperties: true },
61
+ reason: { type: 'string' },
62
+ },
63
+ required: ['params', 'reason'],
64
+ },
65
+ };
66
+
67
+ const PREPARE_BACKEND_TOOL: Tool = {
68
+ name: 'prepare_live_backend',
69
+ description:
70
+ 'Reuse the preferred backend and probe only when no valid preference exists. Set forceReprobe only after a transport, network, or browser-infrastructure failure from that backend; never reprobe for empty, incorrect, or otherwise semantic output failures.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ params: { type: 'object', additionalProperties: true },
75
+ reason: { type: 'string' },
76
+ forceReprobe: { type: 'boolean' },
77
+ },
78
+ required: ['params', 'reason'],
79
+ },
80
+ };
81
+
82
+ const RUN_SUITE_TOOL: Tool = {
83
+ name: 'run_live_integration_suite',
84
+ description:
85
+ 'Run the compiler-proposed final integration suite. A justified rerun is allowed after failure, timeout, or backend reprobe.',
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: { reason: { type: 'string' } },
89
+ additionalProperties: false,
90
+ },
91
+ };
92
+
93
+ const SUBMIT_TOOL: Tool = {
94
+ name: 'submit_verification_report',
95
+ description: 'Submit the final structured semantic verification report exactly once.',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ status: {
100
+ type: 'string',
101
+ enum: ['approved', 'approved_with_gaps', 'changes_required', 'inconclusive'],
102
+ },
103
+ summary: { type: 'string' },
104
+ baseline: {
105
+ type: 'object',
106
+ properties: {
107
+ verdict: {
108
+ type: 'string',
109
+ enum: ['semantically_correct', 'tool_broken', 'bad_parameters', 'infrastructure'],
110
+ },
111
+ reason: { type: 'string' },
112
+ },
113
+ required: ['verdict', 'reason'],
114
+ },
115
+ parameters: {
116
+ type: 'array',
117
+ items: {
118
+ type: 'object',
119
+ properties: {
120
+ name: { type: 'string' },
121
+ verdict: {
122
+ type: 'string',
123
+ enum: ['works', 'no_op', 'broken', 'untestable'],
124
+ },
125
+ reason: { type: 'string' },
126
+ },
127
+ required: ['name', 'verdict', 'reason'],
128
+ additionalProperties: false,
129
+ },
130
+ },
131
+ issues: {
132
+ type: 'array',
133
+ items: {
134
+ type: 'object',
135
+ properties: {
136
+ summary: { type: 'string' },
137
+ expected: { type: 'string' },
138
+ observed: { type: 'string' },
139
+ suggestedFix: { type: 'string' },
140
+ },
141
+ required: ['summary', 'expected', 'observed', 'suggestedFix'],
142
+ additionalProperties: false,
143
+ },
144
+ },
145
+ gaps: { type: 'array', items: { type: 'string' } },
146
+ },
147
+ required: ['status', 'summary', 'baseline', 'parameters', 'issues', 'gaps'],
148
+ },
149
+ };
150
+
151
+ export async function runLiveVerifierMcpServer(opts: {
152
+ workflowPath: string;
153
+ reportPath: string;
154
+ sessionLabel: string;
155
+ logPath?: string;
156
+ attempt?: number;
157
+ }): Promise<void> {
158
+ const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
159
+ const validator = buildZodValidator(workflow.parameters);
160
+ const credentials = (await loadCredentialStore(workflow.site)) ?? undefined;
161
+ let submitted = false;
162
+ const backendPreparation: {
163
+ current?: {
164
+ promise: Promise<Awaited<ReturnType<typeof prepareLiveVerificationBackend>>>;
165
+ forceReprobe: boolean;
166
+ };
167
+ } = {};
168
+ const evidencePath = pathJoin(dirname(opts.reportPath), LIVE_VERIFICATION_EVIDENCE_FILE);
169
+ let targetedCallSequence = readPersistedLiveVerificationEvidence(evidencePath).reduce(
170
+ (highest, item) => {
171
+ const match = /^targeted-call-(\d+)$/.exec(item.label);
172
+ return match ? Math.max(highest, Number(match[1])) : highest;
173
+ },
174
+ 0,
175
+ );
176
+ const hasSuiteReceipt = (): boolean => hasSuiteReceiptForSession(evidencePath, opts.sessionLabel);
177
+
178
+ const server = new Server(
179
+ { name: 'imprint-live-verifier', version: '1.0.0' },
180
+ { capabilities: { tools: {} } },
181
+ );
182
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
183
+ tools: [PREPARE_BACKEND_TOOL, RUN_SUITE_TOOL, RUN_TOOL, SUBMIT_TOOL],
184
+ }));
185
+ server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
186
+ if (request.params.name === 'prepare_live_backend') {
187
+ const input = request.params.arguments as {
188
+ params?: unknown;
189
+ reason?: unknown;
190
+ forceReprobe?: unknown;
191
+ };
192
+ if (typeof input.reason !== 'string' || !input.reason.trim()) {
193
+ return errorResult('reason must explain the backend preparation or reprobe');
194
+ }
195
+ const parsed = validator.safeParse(input.params ?? {});
196
+ if (!parsed.success) return errorResult(`invalid tool params: ${parsed.error.message}`);
197
+ try {
198
+ const forceReprobe = input.forceReprobe === true;
199
+ const preparation = joinBackendPreparation(backendPreparation, forceReprobe, () =>
200
+ prepareLiveVerificationBackend({
201
+ workflowPath: opts.workflowPath,
202
+ params: parsed.data as Record<string, string | number | boolean>,
203
+ reason: input.reason as string,
204
+ forceReprobe,
205
+ logPath: opts.logPath,
206
+ attempt: opts.attempt,
207
+ }),
208
+ );
209
+ if (preparation.joined) {
210
+ appendLiveVerifierLog(opts.logPath, {
211
+ type: 'backend.prepare.joined',
212
+ attempt: opts.attempt,
213
+ reason: input.reason,
214
+ forceReprobe: input.forceReprobe === true,
215
+ });
216
+ }
217
+ const result = await preparation.promise;
218
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
219
+ } catch (error) {
220
+ return errorResult(error instanceof Error ? error.message : String(error));
221
+ }
222
+ }
223
+ if (request.params.name === 'run_live_integration_suite') {
224
+ const input = request.params.arguments as { reason?: unknown };
225
+ try {
226
+ const suite = await runLiveIntegrationSuite({
227
+ toolDir: dirname(opts.workflowPath),
228
+ logPath: opts.logPath,
229
+ attempt: opts.attempt,
230
+ reason: typeof input.reason === 'string' ? input.reason : undefined,
231
+ sessionLabel: opts.sessionLabel,
232
+ });
233
+ return { content: [{ type: 'text', text: JSON.stringify(suite) }] };
234
+ } catch (error) {
235
+ return errorResult(error instanceof Error ? error.message : String(error));
236
+ }
237
+ }
238
+ if (request.params.name === 'run_live_integration_test') {
239
+ if (!hasSuiteReceipt())
240
+ return errorResult('run the final integration suite before a targeted call');
241
+ const input = request.params.arguments as { params?: unknown; reason?: unknown };
242
+ if (typeof input.reason !== 'string' || !input.reason.trim()) {
243
+ return errorResult('reason must explain the unresolved semantic question');
244
+ }
245
+ const parsed = validator.safeParse(input.params ?? {});
246
+ if (!parsed.success) return errorResult(`invalid tool params: ${parsed.error.message}`);
247
+ const label = `targeted-call-${++targetedCallSequence}`;
248
+ const startedAt = Date.now();
249
+ appendLiveVerifierLog(opts.logPath, {
250
+ type: 'targeted-call.started',
251
+ attempt: opts.attempt,
252
+ label,
253
+ reason: input.reason,
254
+ params: parsed.data,
255
+ });
256
+ let run: Awaited<ReturnType<typeof runCapturedIntegrationCase>>;
257
+ try {
258
+ run = await runCapturedIntegrationCase({
259
+ caseName: label,
260
+ workflowPath: opts.workflowPath,
261
+ params: parsed.data as Record<string, string | number | boolean>,
262
+ credentials,
263
+ preferredOnlyBackend: true,
264
+ });
265
+ } catch (error) {
266
+ const message = error instanceof Error ? error.message : String(error);
267
+ appendLiveVerifierLog(opts.logPath, {
268
+ type: 'targeted-call.failed',
269
+ attempt: opts.attempt,
270
+ label,
271
+ error: message,
272
+ });
273
+ return errorResult(message);
274
+ }
275
+ const supplemental: LiveIntegrationEvidence = {
276
+ schemaVersion: 1 as const,
277
+ kind: 'call',
278
+ label,
279
+ caseName: label,
280
+ toolName: workflow.toolName,
281
+ requestedParams: parsed.data as Record<string, string | number | boolean>,
282
+ effectiveParams: effectiveParamsForEvidence(
283
+ workflow.parameters,
284
+ parsed.data as Record<string, string | number | boolean>,
285
+ ),
286
+ result: run.result,
287
+ usedBackend: run.usedBackend,
288
+ attempts: run.attempts,
289
+ durationMs: Date.now() - startedAt,
290
+ };
291
+ persistLiveVerificationEvidence(evidencePath, [supplemental]);
292
+ appendLiveVerifierLog(opts.logPath, {
293
+ type: 'targeted-call.completed',
294
+ attempt: opts.attempt,
295
+ record: supplemental,
296
+ });
297
+ return {
298
+ content: [
299
+ {
300
+ type: 'text',
301
+ text: JSON.stringify({
302
+ label: supplemental.label,
303
+ toolName: workflow.toolName,
304
+ requestedParams: parsed.data,
305
+ result: run.result,
306
+ usedBackend: run.usedBackend,
307
+ attempts: run.attempts,
308
+ }),
309
+ },
310
+ ],
311
+ };
312
+ }
313
+ if (request.params.name === 'submit_verification_report') {
314
+ if (submitted) return errorResult('verification report was already submitted');
315
+ const parsed = LiveVerificationReportSchema.safeParse(request.params.arguments);
316
+ if (!parsed.success)
317
+ return errorResult(`invalid verification report: ${parsed.error.message}`);
318
+ if (!hasSuiteReceipt()) {
319
+ return errorResult('run the final integration suite before submitting');
320
+ }
321
+ try {
322
+ assertReportCoversWorkflowParameters(parsed.data, workflow.parameters);
323
+ } catch (error) {
324
+ return errorResult(error instanceof Error ? error.message : String(error));
325
+ }
326
+ submitted = true;
327
+ appendLiveVerifierLog(opts.logPath, {
328
+ type: 'report.submitted',
329
+ attempt: opts.attempt,
330
+ report: parsed.data,
331
+ });
332
+ writeFileSync(opts.reportPath, `${JSON.stringify(parsed.data, null, 2)}\n`, {
333
+ encoding: 'utf8',
334
+ mode: 0o600,
335
+ });
336
+ return {
337
+ content: [{ type: 'text', text: 'Report accepted. End the verification session now.' }],
338
+ };
339
+ }
340
+ return errorResult(`unknown tool: ${request.params.name}`);
341
+ });
342
+ const transport = new StdioServerTransport();
343
+ await server.connect(transport);
344
+
345
+ // Server.connect() resolves after initialization; it does not own the
346
+ // process lifetime. Keep stdio alive until the verifier disconnects, just
347
+ // like the compile MCP server does, or Codex can lose the server during its
348
+ // initialize handshake.
349
+ await new Promise<void>((resolve) => {
350
+ const close = (): void => resolve();
351
+ transport.onclose = close;
352
+ process.once('SIGINT', close);
353
+ process.once('SIGTERM', close);
354
+ });
355
+ }
356
+
357
+ function errorResult(message: string): CallToolResult {
358
+ return { isError: true, content: [{ type: 'text', text: message }] };
359
+ }
@@ -4,6 +4,7 @@
4
4
  * See docs/getting-started.md for Claude Desktop / mcp-inspector wire-up.
5
5
  */
6
6
 
7
+ import { randomUUID } from 'node:crypto';
7
8
  import { existsSync } from 'node:fs';
8
9
  import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
9
10
  import { resolve as pathResolve } from 'node:path';
@@ -30,7 +31,7 @@ import {
30
31
  buildZodValidator,
31
32
  discoverTools,
32
33
  } from './tool-loader.ts';
33
- import type { ConcreteBackend, ToolResult, WorkflowParameter } from './types.ts';
34
+ import type { ConcreteBackend, ToolResult, Workflow, WorkflowParameter } from './types.ts';
34
35
  import { VERSION } from './version.ts';
35
36
 
36
37
  interface RunMcpServerOptions {
@@ -60,19 +61,32 @@ interface ResolvedTool extends DiscoveredTool {
60
61
  /** Tool description shown to MCP clients. Includes the operator's
61
62
  * recorded narration when present — surprisingly load-bearing for the
62
63
  * LLM picking the right tool. */
63
- function buildToolDescription(w: ResolvedTool['workflow']): string {
64
+ export function buildToolDescription(w: ResolvedTool['workflow']): string {
64
65
  const base = w.intent.description;
65
66
  const said = w.intent.userSaid?.trim();
66
- return said ? `${base}\n\nRecording context: "${said}"` : base;
67
+ const context = said ? `${base}\n\nRecording context: "${said}"` : base;
68
+ if (!w.limitations?.length) return context;
69
+ const limitations = w.limitations
70
+ .map((entry) => {
71
+ const omitted = entry.omittedParameters?.length
72
+ ? ` Omitted inputs: ${entry.omittedParameters.join(', ')}.`
73
+ : '';
74
+ return `- ${entry.feature}: ${entry.reason}${omitted}`;
75
+ })
76
+ .join('\n');
77
+ return `${context}\n\nKnown limitations:\n${limitations}`;
67
78
  }
68
79
 
69
80
  /** MCP advertises tool input as JSON Schema; build it directly from
70
81
  * workflow parameters rather than going through Zod. */
71
82
  export function buildJsonSchema(
72
83
  parameters: WorkflowParameter[],
73
- opts?: { includeTwoFactorContext?: boolean },
84
+ opts?: { authConfig?: Workflow['authConfig'] },
74
85
  ): Tool['inputSchema'] {
75
- const properties: Record<string, { type: string; description: string }> = {};
86
+ const properties: Record<
87
+ string,
88
+ { type: string; description: string; enum?: Array<string | number | boolean> }
89
+ > = {};
76
90
  const required: string[] = [];
77
91
  for (const p of parameters) {
78
92
  // Producer-sourced params: tell the orchestrating LLM where to mint/derive
@@ -81,18 +95,24 @@ export function buildJsonSchema(
81
95
  const description = p.sourcedFrom
82
96
  ? `${p.description} Obtain this value from the \`${p.sourcedFrom.tool}\` tool's \`${p.sourcedFrom.field}\` output — call \`${p.sourcedFrom.tool}\` first and reuse the value across calls (no need to re-fetch each time).`
83
97
  : p.description;
84
- properties[p.name] = { type: p.type, description };
85
- if (p.default === undefined) required.push(p.name);
98
+ const property: { type: string; description: string; enum?: Array<string | number | boolean> } =
99
+ {
100
+ type: p.type,
101
+ description,
102
+ };
103
+ if (p.choices?.length) property.enum = p.choices;
104
+ properties[p.name] = property;
105
+ if (p.default === undefined && !opts?.authConfig) required.push(p.name);
86
106
  }
87
- // Auth 2FA bridge (stateless): on the second (submit_otp) call the caller
88
- // passes back the `twoFactorContext` object echoed verbatim in the prior
89
- // AWAITING_2FA result, so a login token captured on the first call is
90
- // available to the completion request. Never required (absent on initiate).
91
- if (opts?.includeTwoFactorContext) {
92
- properties.twoFactorContext = {
93
- type: 'object',
94
- description:
95
- 'Only for the submit_otp call: pass back the `twoFactorContext` object returned verbatim in the previous AWAITING_2FA response.',
107
+ if (opts?.authConfig) {
108
+ properties.action = {
109
+ type: 'string',
110
+ description: `Auth action to run. Defaults to ${JSON.stringify(opts.authConfig.entry)}.`,
111
+ enum: Object.keys(opts.authConfig.actions),
112
+ };
113
+ properties.continuation = {
114
+ type: 'string',
115
+ description: 'One-use opaque token returned by the previous auth action.',
96
116
  };
97
117
  }
98
118
  return {
@@ -104,6 +124,33 @@ export function buildJsonSchema(
104
124
 
105
125
  const log = createLog('mcp');
106
126
 
127
+ interface StoredAuthContinuation {
128
+ toolName: string;
129
+ nextAction?: string;
130
+ state: Record<string, unknown>;
131
+ }
132
+
133
+ export class AuthContinuationStore {
134
+ private readonly values = new Map<string, StoredAuthContinuation>();
135
+
136
+ issue(value: StoredAuthContinuation): string {
137
+ const token = randomUUID();
138
+ this.values.set(token, value);
139
+ return token;
140
+ }
141
+
142
+ consume(token: string, toolName: string, action: string): Record<string, unknown> | undefined {
143
+ const value = this.values.get(token);
144
+ this.values.delete(token);
145
+ if (!value || value.toolName !== toolName || value.nextAction !== action) return undefined;
146
+ return value.state;
147
+ }
148
+
149
+ clear(): void {
150
+ this.values.clear();
151
+ }
152
+ }
153
+
107
154
  export async function runSerializedBySite<T>(
108
155
  queues: Map<string, Promise<void>>,
109
156
  site: string,
@@ -141,12 +188,20 @@ function buildServer(
141
188
  {
142
189
  capabilities: { tools: {} },
143
190
  instructions:
144
- 'Imprint runs deterministic workflows captured from real browser sessions. Tools prefer fetch API replay, may use gated fetch-bootstrap only for declared browser-minted state, then cdp-replay (API requests run inside a live trusted Chrome so a protected POST refreshes its anti-bot token between calls) for multi-step state-changing flows, then stealth-fetch for bot-defense state, and playbook only for full DOM interaction. Error codes: AUTH_EXPIRED (401, call authenticate_<site> if available or run `imprint login <site>`); AWAITING_2FA (2FA required — approve push / enter OTP then call again with action=complete); STATE_MISSING (required cookie/state was unavailable or ambiguous); FORBIDDEN (403); RATE_LIMITED (429, back off); BAD_RESPONSE (other 4xx/5xx); NETWORK (fetch failed); UNKNOWN (everything else).',
191
+ 'Imprint runs deterministic workflows captured from real browser sessions. Data tools can escalate through API, browser, and playbook backends. Authenticate tools execute their compiled action program without playbooks. Error codes: AUTH_EXPIRED (401); ACTION_REQUIRED (run nextAction and pass continuation back unchanged); STATE_MISSING; FORBIDDEN; RATE_LIMITED; BAD_RESPONSE; NETWORK; UNKNOWN.',
145
192
  },
146
193
  );
147
194
 
148
195
  const validators = new Map(
149
- tools.map((t) => [t.workflow.toolName, buildZodValidator(t.workflow.parameters)] as const),
196
+ tools.map(
197
+ (t) =>
198
+ [
199
+ t.workflow.toolName,
200
+ buildZodValidator(t.workflow.parameters, {
201
+ allOptional: t.workflow.toolKind === 'authenticate',
202
+ }),
203
+ ] as const,
204
+ ),
150
205
  );
151
206
 
152
207
  // Per-site stealth-fetch cache so the ~12s bootstrap runs once per site.
@@ -156,6 +211,7 @@ function buildServer(
156
211
  // the first successful call so subsequent calls reuse it (~2-5s vs ~33s).
157
212
  const cdpPool = new Map<string, CdpBrowserFetch>();
158
213
  const cdpIdleTimers = new Map<string, ReturnType<typeof setTimeout>>();
214
+ const authContinuations = new AuthContinuationStore();
159
215
  const CDP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
160
216
 
161
217
  // Per-tool memo of the winning backend for THIS server session. After the
@@ -208,16 +264,31 @@ function buildServer(
208
264
  string | number | boolean
209
265
  >;
210
266
 
211
- // Auth 2FA bridge (stateless): the validator strips unknown keys, so read the
212
- // echoed `twoFactorContext` from the raw arguments and seed it as initialState
213
- // so a token captured on the initiate call resolves on this submit_otp call.
214
267
  const rawArgs = (req.params.arguments ?? {}) as Record<string, unknown>;
215
- const initialState =
216
- tool.workflow.toolKind === 'authenticate' &&
217
- rawArgs.twoFactorContext &&
218
- typeof rawArgs.twoFactorContext === 'object'
219
- ? (rawArgs.twoFactorContext as Record<string, unknown>)
220
- : undefined;
268
+ if (tool.workflow.toolKind === 'authenticate' && typeof rawArgs.action === 'string') {
269
+ args.action = rawArgs.action;
270
+ }
271
+ let initialState: Record<string, unknown> | undefined;
272
+ if (tool.workflow.toolKind === 'authenticate' && rawArgs.continuation !== undefined) {
273
+ const action = String(args.action ?? tool.workflow.authConfig?.entry ?? '');
274
+ if (typeof rawArgs.continuation !== 'string') {
275
+ return {
276
+ isError: true,
277
+ content: [{ type: 'text', text: 'Invalid continuation token.' }],
278
+ };
279
+ }
280
+ initialState = authContinuations.consume(
281
+ rawArgs.continuation,
282
+ tool.workflow.toolName,
283
+ action,
284
+ );
285
+ if (!initialState) {
286
+ return {
287
+ isError: true,
288
+ content: [{ type: 'text', text: 'Invalid or expired continuation token.' }],
289
+ };
290
+ }
291
+ }
221
292
 
222
293
  try {
223
294
  return await runSerializedBySite(siteExecutionQueues, tool.site, async () => {
@@ -293,7 +364,16 @@ function buildServer(
293
364
  cdpIdleTimers.set(tool.site, timer);
294
365
  }
295
366
  if (!result.ok) {
296
- const text = formatToolError(result);
367
+ const continuationToken =
368
+ tool.workflow.toolKind === 'authenticate' && result.continuation
369
+ ? authContinuations.issue({
370
+ toolName: tool.workflow.toolName,
371
+ nextAction:
372
+ result.nextAction ?? String(args.action ?? tool.workflow.authConfig?.entry),
373
+ state: result.continuation,
374
+ })
375
+ : undefined;
376
+ const text = formatToolError(result, continuationToken);
297
377
  return {
298
378
  isError: true,
299
379
  content: [{ type: 'text', text: `${text}\n(backend: ${usedBackend})` }],
@@ -331,12 +411,16 @@ function buildServer(
331
411
  for (const timer of cdpIdleTimers.values()) clearTimeout(timer);
332
412
  cdpIdleTimers.clear();
333
413
  winnerCache.clear();
414
+ authContinuations.clear();
334
415
  }
335
416
 
336
417
  return { server, closeCdpPool };
337
418
  }
338
419
 
339
- function formatToolError(result: Extract<ToolResult, { ok: false }>): string {
420
+ function formatToolError(
421
+ result: Extract<ToolResult, { ok: false }>,
422
+ continuationToken?: string,
423
+ ): string {
340
424
  const lines = [`[${result.error}] ${result.message}`];
341
425
  if (result.error === 'STATE_MISSING' && result.missing?.length) {
342
426
  for (const item of result.missing) {
@@ -345,11 +429,8 @@ function formatToolError(result: Extract<ToolResult, { ok: false }>): string {
345
429
  );
346
430
  }
347
431
  }
348
- // Auth 2FA bridge (stateless): echo the captured login context back to the
349
- // caller so it can pass it as `twoFactorContext` on the submit_otp call.
350
- if (result.error === 'AWAITING_2FA' && result.twoFactorContext) {
351
- lines.push(` twoFactorContext: ${JSON.stringify(result.twoFactorContext)}`);
352
- }
432
+ if (result.nextAction) lines.push(` nextAction: ${result.nextAction}`);
433
+ if (continuationToken) lines.push(` continuation: ${JSON.stringify(continuationToken)}`);
353
434
  if (result.remediation) lines.push(` → ${result.remediation}`);
354
435
  return lines.join('\n');
355
436
  }
@@ -370,7 +451,7 @@ export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
370
451
  return {
371
452
  ...t,
372
453
  inputSchema: buildJsonSchema(t.workflow.parameters, {
373
- includeTwoFactorContext: t.workflow.toolKind === 'authenticate',
454
+ authConfig: t.workflow.toolKind === 'authenticate' ? t.workflow.authConfig : undefined,
374
455
  }),
375
456
  playbookPath: existsSync(playbookPath) ? playbookPath : undefined,
376
457
  preferredOrder: cacheStatus.status === 'ok' ? cacheStatus.cache.preferredOrder : undefined,