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
@@ -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
  };
@@ -0,0 +1,36 @@
1
+ import type { CapturedRequest, WorkflowRequest } from './types.ts';
2
+
3
+ const TEMPLATE_PLACEHOLDER = /\$\{[^}]+\}/g;
4
+
5
+ function escapeRegExp(value: string): string {
6
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ }
8
+
9
+ /** Match a concrete recorded value against a workflow template without
10
+ * interpreting or substituting the dynamic values themselves. */
11
+ function templateMatchesRecorded(template: string, recorded: string): boolean {
12
+ if (template === recorded) return true;
13
+ let cursor = 0;
14
+ let pattern = '^';
15
+ for (const match of template.matchAll(TEMPLATE_PLACEHOLDER)) {
16
+ pattern += escapeRegExp(template.slice(cursor, match.index));
17
+ pattern += '.*?';
18
+ cursor = (match.index ?? 0) + match[0].length;
19
+ }
20
+ if (cursor === 0) return false;
21
+ pattern += `${escapeRegExp(template.slice(cursor))}$`;
22
+ return new RegExp(pattern).test(recorded);
23
+ }
24
+
25
+ export function recordedRequestMatchesWorkflow(
26
+ recorded: Pick<CapturedRequest, 'method' | 'url' | 'body'>,
27
+ workflow: Pick<WorkflowRequest, 'method' | 'url' | 'body'>,
28
+ ): boolean {
29
+ return (
30
+ recorded.method.toUpperCase() === workflow.method.toUpperCase() &&
31
+ templateMatchesRecorded(workflow.url, recorded.url) &&
32
+ (workflow.body === undefined
33
+ ? recorded.body === undefined || recorded.body === ''
34
+ : recorded.body !== undefined && templateMatchesRecorded(workflow.body, recorded.body))
35
+ );
36
+ }
@@ -1,23 +1,21 @@
1
1
  /**
2
2
  * Shared, format-agnostic helpers for resolving a `RequestCapture` against a
3
- * response. Used by the runtime (resolving `authConfig.sessionCapture` /
4
- * per-request captures during replay) and by `imprint login` (resolving the
5
- * same declarative captures against a recorded login response). Keeping a single
6
- * implementation means a capture locator behaves identically wherever it runs.
3
+ * response. Used by the runtime and by `imprint login` when resolving captures
4
+ * named by `authConfig.persist`. Keeping one implementation means a capture
5
+ * locator behaves identically wherever it runs.
7
6
  *
8
7
  * This is a LEAF module — it must not import other `src/imprint` modules so it
9
8
  * can be consumed from anywhere without introducing an import cycle.
10
9
  */
11
10
 
12
11
  /** Lookup a JSON path inside a parsed value. Segments may be:
13
- * - an object key: `reauth.mfaId`
12
+ * - an object key: `result.identifier`
14
13
  * - a numeric array index: `items[0]` (bracket) or `items.0` (dot)
15
- * - a field-match predicate: `challenges[type=push]`
14
+ * - a field-match predicate: `items[type=primary]`
15
+ * - a JSONPath predicate: `items[?(@.type=='primary')]`
16
16
  * → the FIRST array element whose `element[field]` stringifies to the value.
17
- * The predicate makes captures robust to non-deterministic array ordering — e.g.
18
- * a 2FA endpoint that returns its SMS/email/push challenges in a varying order, so
19
- * a fixed `challenges[0]` grabs the wrong one while `[type=push]` always selects
20
- * the push one. A bracketed token that is neither a number nor a `key=value`
17
+ * Predicates keep captures stable when array ordering is non-deterministic. A
18
+ * bracketed token that is neither a number nor a `key=value`
21
19
  * predicate is treated as a literal object key (so a top-level key that itself
22
20
  * contains dots, e.g. `[customers.userInformation.accountNumber]`, is matched
23
21
  * verbatim rather than traversed). */
@@ -39,13 +37,8 @@ export function jsonpath(root: unknown, path: string): unknown {
39
37
  if (/^\d+$/.test(inner)) {
40
38
  tokens.push({ kind: 'index', v: Number.parseInt(inner, 10) });
41
39
  } else {
42
- const eq = inner.indexOf('=');
43
- if (eq >= 0)
44
- tokens.push({
45
- kind: 'pred',
46
- k: inner.slice(0, eq).trim(),
47
- v: inner.slice(eq + 1).trim(),
48
- });
40
+ const predicate = parseFieldPredicate(inner);
41
+ if (predicate) tokens.push(predicate);
49
42
  else tokens.push({ kind: 'key', v: inner });
50
43
  }
51
44
  }
@@ -73,6 +66,36 @@ export function jsonpath(root: unknown, path: string): unknown {
73
66
  return cur;
74
67
  }
75
68
 
69
+ export function captureValueMatches(
70
+ value: unknown,
71
+ equals?: string | number | boolean | null,
72
+ ): boolean {
73
+ if (equals !== undefined) return Object.is(value, equals);
74
+ return value !== undefined && value !== null && value !== '';
75
+ }
76
+
77
+ function parseFieldPredicate(inner: string): { kind: 'pred'; k: string; v: string } | null {
78
+ const standard = /^\?\(\s*@\.([A-Za-z_$][\w$-]*)\s*==\s*(.+?)\s*\)$/.exec(inner);
79
+ if (standard?.[1] && standard[2] !== undefined) {
80
+ return { kind: 'pred', k: standard[1], v: unquotePredicateValue(standard[2]) };
81
+ }
82
+
83
+ const eq = inner.indexOf('=');
84
+ if (eq < 1 || inner[eq + 1] === '=') return null;
85
+ return {
86
+ kind: 'pred',
87
+ k: inner.slice(0, eq).trim(),
88
+ v: unquotePredicateValue(inner.slice(eq + 1).trim()),
89
+ };
90
+ }
91
+
92
+ function unquotePredicateValue(value: string): string {
93
+ const first = value[0];
94
+ return value.length >= 2 && (first === "'" || first === '"') && value.at(-1) === first
95
+ ? value.slice(1, -1)
96
+ : value;
97
+ }
98
+
76
99
  /** Read a response header value, honoring the capture `mode`. Multi-valued
77
100
  * headers are comma-split. `set-cookie` is never projected (cookies are handled
78
101
  * by the cookie jar, not as plain header captures). */