imprint-mcp 0.6.1 → 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.
@@ -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
+ }
@@ -61,10 +61,20 @@ interface ResolvedTool extends DiscoveredTool {
61
61
  /** Tool description shown to MCP clients. Includes the operator's
62
62
  * recorded narration when present — surprisingly load-bearing for the
63
63
  * LLM picking the right tool. */
64
- function buildToolDescription(w: ResolvedTool['workflow']): string {
64
+ export function buildToolDescription(w: ResolvedTool['workflow']): string {
65
65
  const base = w.intent.description;
66
66
  const said = w.intent.userSaid?.trim();
67
- 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}`;
68
78
  }
69
79
 
70
80
  /** MCP advertises tool input as JSON Schema; build it directly from
@@ -107,7 +107,7 @@ export async function probeAllBackends(
107
107
  return results;
108
108
  }
109
109
 
110
- async function probeResolvedTool(
110
+ export async function probeResolvedTool(
111
111
  opts: Pick<ProbeBackendsOptions, 'site' | 'paramOverrides'>,
112
112
  assetRoot: string,
113
113
  tool: ResolvedTool,
@@ -230,7 +230,7 @@ async function probeResolvedTool(
230
230
  imprintVersion: VERSION,
231
231
  schemaVersion: 2,
232
232
  workflowHash: workflowHash(tool.workflow),
233
- capabilityHash: capabilityHash(tool.workflow),
233
+ capabilityHash: workflowCapabilityHash(tool.workflow),
234
234
  preferredOrder,
235
235
  results,
236
236
  };
@@ -323,16 +323,85 @@ function workflowHash(workflow: ResolvedTool['workflow']): string {
323
323
  .digest('hex');
324
324
  }
325
325
 
326
- function capabilityHash(workflow: ResolvedTool['workflow']): string {
326
+ export function workflowCapabilityHash(workflow: ResolvedTool['workflow']): string {
327
327
  const caps = {
328
328
  bootstrap: Boolean(workflow.bootstrap),
329
- captures: workflow.requests.flatMap((r) =>
330
- (r.captures ?? []).map((c) => `${c.source}:${c.name}:${c.capability}`),
331
- ),
329
+ prefersCdpReplayFirst: prefersCdpReplayFirst(workflow),
330
+ requestModes: normalizedUnique(workflow.requests.map((request) => request.mode ?? 'fetch')),
331
+ captures: normalizedUnique([
332
+ ...(workflow.bootstrap?.captures ?? []).map(
333
+ (capture) => `bootstrap:${capture.source}:${capture.capability}:${capture.required}`,
334
+ ),
335
+ ...workflow.requests.flatMap((request) =>
336
+ (request.captures ?? []).map(
337
+ (capture) => `request:${capture.source}:${capture.capability}:${capture.required}`,
338
+ ),
339
+ ),
340
+ ]),
332
341
  };
333
342
  return createHash('sha256').update(JSON.stringify(caps)).digest('hex');
334
343
  }
335
344
 
345
+ function normalizedUnique(values: string[]): string[] {
346
+ return [...new Set(values)].sort();
347
+ }
348
+
349
+ export function canRebindBackendsCacheToWorkflow(
350
+ tool: Pick<ResolvedTool, 'workflow'>,
351
+ cache: BackendsCache,
352
+ ): boolean {
353
+ return (
354
+ typeof cache.capabilityHash === 'string' &&
355
+ cache.capabilityHash === workflowCapabilityHash(tool.workflow)
356
+ );
357
+ }
358
+
359
+ /** Carry a previously proven backend preference across a compile-time workflow
360
+ * revision without performing network I/O. Runtime cache loading remains
361
+ * hash-strict; this adapter deliberately rebinds only after the verifier has
362
+ * decided the existing preference is still applicable. */
363
+ export function rebindBackendsCacheToWorkflow(
364
+ tool: Pick<ResolvedTool, 'workflow' | 'dir'>,
365
+ cache: BackendsCache,
366
+ outPath = pathResolve(tool.dir, 'backends.json'),
367
+ ): BackendsCache {
368
+ if (!canRebindBackendsCacheToWorkflow(tool, cache)) {
369
+ throw new Error('backend cache capabilities changed; run a fresh probe instead of rebinding');
370
+ }
371
+ const rebound: BackendsCache = {
372
+ ...cache,
373
+ workflowHash: workflowHash(tool.workflow),
374
+ capabilityHash: workflowCapabilityHash(tool.workflow),
375
+ };
376
+ BackendsCacheSchema.parse(rebound);
377
+ writeFileSync(outPath, `${JSON.stringify(rebound, null, 2)}\n`);
378
+ return rebound;
379
+ }
380
+
381
+ /** Rebind an already-proven cache after compile-time metadata is stamped onto
382
+ * workflow.json. This performs no network I/O and intentionally does nothing
383
+ * for missing/invalid caches. */
384
+ export function rebindExistingBackendsCacheToWorkflow(toolDir: string): BackendsCache | null {
385
+ const workflowPath = pathResolve(toolDir, 'workflow.json');
386
+ if (!existsSync(workflowPath)) return null;
387
+
388
+ let workflow: ResolvedTool['workflow'];
389
+ try {
390
+ workflow = WorkflowSchema.parse(JSON.parse(readFileSync(workflowPath, 'utf8')));
391
+ } catch {
392
+ return null;
393
+ }
394
+
395
+ const assetRoot = pathResolve(toolDir, '..', '..');
396
+ const status = loadBackendsCacheStatus(workflow.site, assetRoot, toolDir, {
397
+ warn: false,
398
+ toolName: workflow.toolName,
399
+ });
400
+ if (status.status !== 'ok' && status.status !== 'stale') return null;
401
+ if (!canRebindBackendsCacheToWorkflow({ workflow }, status.cache)) return null;
402
+ return rebindBackendsCacheToWorkflow({ workflow, dir: toolDir }, status.cache);
403
+ }
404
+
336
405
  export function persistRuntimeBackendsCache(opts: {
337
406
  tool: ResolvedTool;
338
407
  assetRoot: string;
@@ -343,6 +412,18 @@ export function persistRuntimeBackendsCache(opts: {
343
412
  warn: false,
344
413
  toolName: opts.tool.workflow.toolName,
345
414
  });
415
+ const soleAttempt = opts.attempts.length === 1 ? opts.attempts[0] : undefined;
416
+ if (
417
+ status.status === 'ok' &&
418
+ status.cache.preferredOrder[0] === opts.usedBackend &&
419
+ soleAttempt?.backend === opts.usedBackend &&
420
+ soleAttempt.outcome === 'ok'
421
+ ) {
422
+ // A normal call through the already-proven preferred backend is not a
423
+ // probe or a new backend-learning event. Preserve the original probe time
424
+ // and its richer cold/warm/ranking diagnostics byte-for-byte on disk.
425
+ return status.cache;
426
+ }
346
427
  const results: BackendsCache['results'] =
347
428
  status.status === 'ok' ? { ...status.cache.results } : {};
348
429
 
@@ -408,7 +489,7 @@ export function persistRuntimeBackendsCache(opts: {
408
489
  imprintVersion: VERSION,
409
490
  schemaVersion: 2,
410
491
  workflowHash: workflowHash(opts.tool.workflow),
411
- capabilityHash: capabilityHash(opts.tool.workflow),
492
+ capabilityHash: workflowCapabilityHash(opts.tool.workflow),
412
493
  preferredOrder,
413
494
  results,
414
495
  };
@@ -41,6 +41,44 @@ export interface CredentialStore {
41
41
  storage?: StorageRecord[];
42
42
  }
43
43
 
44
+ type NavigationOptions = NonNullable<WorkflowRequest['navigation']>;
45
+ type RequestTransformResult =
46
+ | string
47
+ | {
48
+ url?: string;
49
+ body?: string;
50
+ headers?: Record<string, string>;
51
+ navigation?: Partial<NavigationOptions>;
52
+ skip?: boolean;
53
+ };
54
+
55
+ function mergeNavigationOptions(
56
+ base: WorkflowRequest['navigation'],
57
+ override: Partial<NavigationOptions>,
58
+ ): NavigationOptions {
59
+ if (
60
+ override.actions !== undefined &&
61
+ (!Array.isArray(override.actions) ||
62
+ override.actions.length === 0 ||
63
+ override.actions.some(
64
+ (entry) =>
65
+ !entry ||
66
+ entry.action !== 'click' ||
67
+ typeof entry.selector !== 'string' ||
68
+ entry.selector.length === 0,
69
+ ))
70
+ ) {
71
+ throw new Error('request transform returned invalid navigation click actions');
72
+ }
73
+ if (
74
+ override.resultSelector !== undefined &&
75
+ (typeof override.resultSelector !== 'string' || override.resultSelector.length === 0)
76
+ ) {
77
+ throw new Error('request transform returned an invalid navigation result selector');
78
+ }
79
+ return { ...base, ...override } as NavigationOptions;
80
+ }
81
+
44
82
  /** Browser-only workflow operations supplied by cdp-replay. Keeping this
45
83
  * transport generic lets pages mint coupled browser state through their own
46
84
  * JavaScript without adding OAuth-provider or frontend-framework logic here. */
@@ -55,6 +93,9 @@ export interface BrowserNavigationTransport {
55
93
  timeoutMs?: number;
56
94
  pollIntervalMs?: number;
57
95
  urlIncludes?: string;
96
+ selector?: string;
97
+ actions?: Array<{ action: 'click'; selector: string }>;
98
+ resultSelector?: string;
58
99
  cookie?: { name: string; domain?: string; path?: string };
59
100
  },
60
101
  ): Promise<Response>;
@@ -178,16 +219,13 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
178
219
  const dependencyPreflight = preflightStateDependencies(opts.workflow, state, stateCapabilities);
179
220
  if (!dependencyPreflight.ok) return dependencyPreflight.result;
180
221
 
181
- type TransformResult =
182
- | string
183
- | { url?: string; body?: string; headers?: Record<string, string>; skip?: boolean };
184
222
  let requestTransform:
185
223
  | ((
186
224
  method: string,
187
225
  url: string,
188
226
  responses: unknown[],
189
227
  params?: Record<string, string | number | boolean>,
190
- ) => TransformResult)
228
+ ) => RequestTransformResult)
191
229
  | null = null;
192
230
  if (opts.workflow.requestTransformModule && opts.workflowPath) {
193
231
  try {
@@ -217,6 +255,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
217
255
  });
218
256
  if (!subbedResult.ok) return subbedResult.result;
219
257
  const subbed = subbedResult.value;
258
+ let navigation = req.navigation;
220
259
 
221
260
  if (requestTransform) {
222
261
  try {
@@ -237,9 +276,18 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
237
276
  subbed.headers[k] = v;
238
277
  }
239
278
  }
279
+ if (transformResult.navigation) {
280
+ navigation = mergeNavigationOptions(navigation, transformResult.navigation);
281
+ }
240
282
  }
241
- } catch {
242
- // Non-fatal — proceed with the original request.
283
+ } catch (err) {
284
+ return {
285
+ ok: false,
286
+ error: 'BAD_RESPONSE',
287
+ message: `request transform failed for request ${i}: ${
288
+ err instanceof Error ? err.message : String(err)
289
+ }`,
290
+ };
243
291
  }
244
292
  }
245
293
 
@@ -258,7 +306,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
258
306
  }
259
307
  try {
260
308
  resp = await opts.browser.navigate(subbed.url, {
261
- ...req.navigation,
309
+ ...navigation,
262
310
  method: subbed.method,
263
311
  headers: subbed.headers,
264
312
  body: subbed.body,
@@ -469,9 +517,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
469
517
  url: string,
470
518
  responses: unknown[],
471
519
  params?: Record<string, string | number | boolean>,
472
- ) =>
473
- | string
474
- | { url?: string; body?: string; headers?: Record<string, string>; skip?: boolean })
520
+ ) => RequestTransformResult)
475
521
  | undefined;
476
522
  if (opts.workflow.requestTransformModule && opts.workflowPath) {
477
523
  try {
@@ -543,6 +589,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
543
589
  });
544
590
  if (!substituted.ok) return substituted;
545
591
  const request = substituted.value;
592
+ let navigation = req.navigation;
546
593
 
547
594
  if (requestTransform) {
548
595
  try {
@@ -558,6 +605,9 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
558
605
  if (transformed.url !== undefined) request.url = transformed.url;
559
606
  if (transformed.body !== undefined) request.body = transformed.body;
560
607
  if (transformed.headers) Object.assign(request.headers, transformed.headers);
608
+ if (transformed.navigation) {
609
+ navigation = mergeNavigationOptions(navigation, transformed.navigation);
610
+ }
561
611
  }
562
612
  } catch (err) {
563
613
  return {
@@ -592,7 +642,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
592
642
  }
593
643
  try {
594
644
  response = await opts.browser.navigate(request.url, {
595
- ...req.navigation,
645
+ ...navigation,
596
646
  method: request.method,
597
647
  headers: request.headers,
598
648
  body: request.body,