@broberg/lens-daemon 0.0.0-stage → 0.1.0
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 +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
// F074.5 — Lens MCP surface (daemon /mcp).
|
|
2
|
+
//
|
|
3
|
+
// The daemon hosts its OWN MCP server exposing only the lens_* tools. This is
|
|
4
|
+
// the architecture that keeps "cloud runs no browser" AND "cc calls lens"
|
|
5
|
+
// both true: the cloud MCP (services.cardmem.com) stays browser-free; the
|
|
6
|
+
// browser-owning daemon is locally reachable, so cc adds this local endpoint
|
|
7
|
+
// to its .mcp.json and calls lens_* directly. Stateless transport per request,
|
|
8
|
+
// mirroring apps/server/src/mcp.ts.
|
|
9
|
+
|
|
10
|
+
import { Hono } from 'hono';
|
|
11
|
+
import { MANUSCRIPT_VERBS, STEP_TYPES } from './flow.ts';
|
|
12
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
13
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
14
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import {
|
|
16
|
+
lensCapture,
|
|
17
|
+
lensVerify,
|
|
18
|
+
lensCaptureCatalogue,
|
|
19
|
+
lensApproveBaseline,
|
|
20
|
+
lensClearBaseline,
|
|
21
|
+
lensListRuns,
|
|
22
|
+
lensRunFlow,
|
|
23
|
+
lensRecord,
|
|
24
|
+
lensRunManuscript,
|
|
25
|
+
lensListFlowRuns,
|
|
26
|
+
type LensDeps,
|
|
27
|
+
promoteEvidence,
|
|
28
|
+
lensExport,
|
|
29
|
+
} from './service.ts';
|
|
30
|
+
|
|
31
|
+
const authSchema = {
|
|
32
|
+
type: 'object',
|
|
33
|
+
description:
|
|
34
|
+
'Optional auth pre-step. Secrets are read from the daemon env or a gitignored file by name/path — never the raw secret here. INHERITANCE IS FLOW/MANUSCRIPT ONLY: lens_run_flow and lens_run_manuscript take auth from the project lens.manifest.json when you pass local_path (they report auth.source "manifest"). lens_capture and lens_verify do NOT inherit — local_path only resolves RELATIVE paths inside an auth object you pass yourself. Omit auth on a capture of an authed page and you photograph the sign-in wall, which in viewport mode can still report pass. Pass auth explicitly there.',
|
|
35
|
+
properties: {
|
|
36
|
+
adapter: { type: 'string', enum: ['storageState', 'cookieJwt', 'mintEndpoint'] },
|
|
37
|
+
statePath: { type: 'string', description: 'storageState: path to a Playwright storageState JSON (relative → resolved against local_path/repo root). Preferred over stateEnv (no daemon restart).' },
|
|
38
|
+
stateEnv: { type: 'string', description: 'storageState: env var holding the storageState path/JSON (needs daemon restart to inject).' },
|
|
39
|
+
cookieName: { type: 'string' },
|
|
40
|
+
domain: { type: 'string' },
|
|
41
|
+
path: { type: 'string' },
|
|
42
|
+
tokenEnv: { type: 'string', description: 'cookieJwt: env var holding the token' },
|
|
43
|
+
url: { type: 'string', description: 'mintEndpoint: app endpoint that mints a short-lived storageState' },
|
|
44
|
+
method: { type: 'string' },
|
|
45
|
+
secretEnv: { type: 'string', description: 'mintEndpoint: env var with the bearer secret' },
|
|
46
|
+
secretPath: { type: 'string', description: 'mintEndpoint: gitignored file with the bearer secret (relative → repo root)' },
|
|
47
|
+
body: { type: 'object', description: 'mintEndpoint: optional JSON body for the POST mint call (e.g. {org, site}) so the endpoint mints a session for a SPECIFIC site on-demand, not a fixed env default. Validated by the endpoint; never a secret. Ignored for GET.' },
|
|
48
|
+
},
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
// Declaring viewport as a typed object is the FIX: undeclared nested-object
|
|
52
|
+
// params get stringified by the MCP harness → the daemon forwarded a string to
|
|
53
|
+
// Playwright newContext ("expected object, got string" → capture hang).
|
|
54
|
+
const viewportSchema = {
|
|
55
|
+
type: 'object',
|
|
56
|
+
description:
|
|
57
|
+
'Render viewport {width,height}, e.g. {"width":390,"height":844} for mobile. Default 1280×800.',
|
|
58
|
+
properties: {
|
|
59
|
+
width: { type: 'number' },
|
|
60
|
+
height: { type: 'number' },
|
|
61
|
+
},
|
|
62
|
+
} as const;
|
|
63
|
+
const deviceSchema = {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description:
|
|
66
|
+
'Device preset (wins over viewport): iphone-15 | iphone-13 | iphone-15-pro-max | iphone-se | pixel-8 | ipad | mobile (390×844) | tablet | desktop.',
|
|
67
|
+
} as const;
|
|
68
|
+
// F098.9 — engine selection. Chromium-only Lens is BLIND to Safari/WebKit bugs
|
|
69
|
+
// (it renders them fine → false GREEN). webkit/firefox close that gap.
|
|
70
|
+
const engineSchema = {
|
|
71
|
+
type: 'string',
|
|
72
|
+
enum: ['chromium', 'webkit', 'firefox'],
|
|
73
|
+
description:
|
|
74
|
+
'Browser engine (default chromium). Use webkit to catch Safari-only bugs (backdrop-filter containing-block, -webkit quirks, 100vh/dvh, sticky) that chromium renders fine.',
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
const localeSchema = {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description:
|
|
80
|
+
'F074.57 — BCP-47 browser locale, e.g. "da-DK". Decides navigator.language, Accept-Language and every Intl/toLocaleString result (a Danish site otherwise photographs as 1,234.5 and 8/23/2026). Wins over the project lens.manifest.json "locale". OMITTED passes NOTHING to the browser, so an existing baseline cannot move.',
|
|
81
|
+
} as const;
|
|
82
|
+
const timezoneSchema = {
|
|
83
|
+
type: 'string',
|
|
84
|
+
description:
|
|
85
|
+
'F074.57 — IANA timezone, e.g. "Europe/Copenhagen". Omitted, the context inherits the HOST machine\'s zone, so the same page renders different dates on a Mac and on Cloud Lens. Declare it for machine-independent captures.',
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
const reducedMotionSchema = {
|
|
89
|
+
type: 'string',
|
|
90
|
+
enum: ['reduce', 'no-preference'],
|
|
91
|
+
description:
|
|
92
|
+
"F074.59 — what this run reports for prefers-reduced-motion. DEFAULT 'reduce', which is what every Lens run has always done (we set it in F098.8 so a perpetual rAF loop cannot starve the page into a blank capture, and so pixel baselines are motion-stable) — omitting this cannot move an existing baseline. Pass 'no-preference' when you need the browser to actually ANIMATE: it is the only way expectAnimating can see anything, and it is how you prove a spinner/skeleton/loading indicator really moves rather than merely having a CSS rule. Pair it with a 'reduce' run over the same element to prove the reduced-motion branch really stops it.",
|
|
93
|
+
} as const;
|
|
94
|
+
|
|
95
|
+
const storageSchema = {
|
|
96
|
+
type: 'object',
|
|
97
|
+
description:
|
|
98
|
+
"F074.97 — localStorage/sessionStorage written BEFORE the first navigation, so the app's own FIRST RENDER already sees it. This is the only way to photograph a bug whose input is SAVED CLIENT STATE: a restored tab strip, a remembered filter, an unsent draft, a dismissed banner, a stale timestamp a prune reads. Shape: {\"local\":{\"key\":\"value\"},\"session\":{…}} — values are strings (a non-string is JSON-stringified, because the realistic payload is json). SCOPED TO THIS RUN'S OWN ORIGIN, derived from the url/base_url — you cannot name the origin, so a seed can never follow a redirect into a payment provider or a third-party iframe. OMITTED registers no init script at all, so an existing baseline cannot move. Writing after navigation would be the false green this exists to prevent: the app has already booted on an empty store, so the run photographs the DEFAULT state while reporting that it seeded the stale one.",
|
|
99
|
+
properties: {
|
|
100
|
+
local: { type: 'object', description: 'localStorage keys to write for this origin.' },
|
|
101
|
+
session: { type: 'object', description: 'sessionStorage keys to write for this origin.' },
|
|
102
|
+
},
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
const captureProps = {
|
|
106
|
+
project: { type: 'string', description: 'Project slug — scopes all runs/baselines (required).' },
|
|
107
|
+
url: { type: 'string', description: 'Target URL (localhost dev-server or public).' },
|
|
108
|
+
mode: { type: 'string', enum: ['viewport', 'fullPage', 'element'] },
|
|
109
|
+
selector: { type: 'string', description: 'Required for element mode. Prefer [data-testid="…"].' },
|
|
110
|
+
waitFor: { description: 'Selector (string) or ms (number) to wait after navigation.' },
|
|
111
|
+
surface_key: { type: 'string', description: 'Stable name for this surface (groups runs).' },
|
|
112
|
+
extension: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
description:
|
|
115
|
+
"F074.69 — load an UNPACKED browser extension and photograph its OWN pages. Point `path` at the built extension directory (the one holding manifest.json) and write the url as `extension://popup.html`; Lens substitutes the id Chromium generates at LOAD time, which a caller cannot know in advance and a manuscript must never hardcode. CHROMIUM ONLY — another engine is refused with a reason rather than failing oddly. Use this instead of serving dist/popup.html over http: the popup calls chrome.* on mount, throws where no extension APIs exist, and photographs blank or as a stub that LOOKS like the product — which on a store listing would be a fabricated screenshot. The response carries `extension.id`.",
|
|
116
|
+
properties: { path: { type: 'string', description: 'Absolute path to the unpacked extension build directory.' } },
|
|
117
|
+
required: ['path'],
|
|
118
|
+
},
|
|
119
|
+
viewport: viewportSchema,
|
|
120
|
+
device: deviceSchema,
|
|
121
|
+
engine: engineSchema,
|
|
122
|
+
locale: localeSchema,
|
|
123
|
+
timezone: timezoneSchema,
|
|
124
|
+
reduced_motion: reducedMotionSchema,
|
|
125
|
+
auth: authSchema,
|
|
126
|
+
storage: storageSchema,
|
|
127
|
+
critic: {
|
|
128
|
+
type: 'string',
|
|
129
|
+
enum: ['off', 'dom', 'vision', 'both'],
|
|
130
|
+
description:
|
|
131
|
+
'F126 composition critic: off (default) | dom (free geometry — overlap/occlusion/clipped/off-viewport/contrast) | vision (metered model judgement) | both. A high-severity finding folds status to fail (blocks Review→Done). The F095 gate sets this from the project\'s composition_critic.vision setting.',
|
|
132
|
+
},
|
|
133
|
+
critic_vision_model: {
|
|
134
|
+
type: 'string',
|
|
135
|
+
description: 'Catalog key for the critic vision pass: mistral-large (default, EU/GDPR-safe) | mistral-small (EU, cheapest) | haiku (US, sharp) | sonnet (US, sharpest). An unarmed key falls back to the cheapest available. The gate sets it from the project\'s composition_critic.vision_model setting; GET 127.0.0.1:7475/lens/vision-models lists the catalog + availability.',
|
|
136
|
+
},
|
|
137
|
+
scale: {
|
|
138
|
+
type: 'number',
|
|
139
|
+
description:
|
|
140
|
+
'Device pixel ratio for the screenshot (deviceScaleFactor). Default 1. Use 2 or 3 for retina / App-Store-quality shots — a 390×844 viewport at scale 2 renders a 780×1688-px image. Also sharpens pixel-diff baselines. (F252.1)',
|
|
141
|
+
},
|
|
142
|
+
} as const;
|
|
143
|
+
|
|
144
|
+
export const TOOLS = [
|
|
145
|
+
{
|
|
146
|
+
name: 'lens_capture',
|
|
147
|
+
description:
|
|
148
|
+
'Screenshot a surface (viewport / fullPage / element-via-[data-testid]) and record the run. Returns screenshot_url + run_id. Scoped to the given project.',
|
|
149
|
+
inputSchema: { type: 'object', required: ['project', 'url'], properties: captureProps },
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'lens_verify',
|
|
153
|
+
description:
|
|
154
|
+
'Verify a surface: capture + optional pixel-diff vs an approved baseline (by baseline_key) + optional assert.js DOM measurement. Returns status (pass/fail/no-baseline/error) + screenshot/diff URLs. The visual half of an AC gate.',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
required: ['project', 'url'],
|
|
158
|
+
properties: {
|
|
159
|
+
...captureProps,
|
|
160
|
+
assert: { type: 'string', description: 'JS body run in the page; return boolean or {pass, detail}.' },
|
|
161
|
+
baseline_key: { type: 'string', description: 'Diff against the approved baseline for this key (auto-loaded).' },
|
|
162
|
+
diff_threshold: { type: 'number', description: 'Pass if diffRatio < threshold. Default 0.01.' },
|
|
163
|
+
video: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
description:
|
|
166
|
+
'Video-liveness: drive a <video> and assert it actually PLAYS (currentTime advances + frames move + no MediaError) — presence/pixel-diff can\'t see playback. A not-playing verdict fails the verify. Returns a `video` block (playing, readyState, advancedSec, errorCode, frameMotionRatio, …). Audio is reported, never the verdict.',
|
|
167
|
+
properties: {
|
|
168
|
+
selector: { type: 'string', description: '[data-testid]/CSS of the <video> or a container holding one. Omit → first <video>.' },
|
|
169
|
+
playMs: { type: 'number', description: 'How long to let it play before re-sampling (ms). Default 1200.' },
|
|
170
|
+
requireMotion: { type: 'boolean', description: 'Require visible frame motion (default true) — catches a frozen/poster video whose time still ticks.' },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'lens_approve_baseline',
|
|
178
|
+
description: 'Promote a run\'s screenshot to the approved baseline for its surface. Future verifies diff against it.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
required: ['project', 'run_id'],
|
|
182
|
+
properties: {
|
|
183
|
+
project: { type: 'string' },
|
|
184
|
+
run_id: { type: 'string' },
|
|
185
|
+
baseline_key: { type: 'string', description: 'Defaults to the run\'s surface_key.' },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: 'lens_capture_catalogue',
|
|
191
|
+
description:
|
|
192
|
+
'Verify a batch of surfaces against one base_url in a single call. Returns a per-surface verdict + aggregate pass/fail. Feed it a project\'s lens.manifest.json surfaces.',
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
required: ['project', 'base_url', 'surfaces'],
|
|
196
|
+
properties: {
|
|
197
|
+
project: { type: 'string' },
|
|
198
|
+
base_url: { type: 'string' },
|
|
199
|
+
auth: authSchema,
|
|
200
|
+
diff_threshold: { type: 'number' },
|
|
201
|
+
surfaces: {
|
|
202
|
+
type: 'array',
|
|
203
|
+
items: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
required: ['name', 'path'],
|
|
206
|
+
properties: {
|
|
207
|
+
name: { type: 'string' },
|
|
208
|
+
path: { type: 'string' },
|
|
209
|
+
mode: { type: 'string', enum: ['viewport', 'fullPage', 'element'] },
|
|
210
|
+
selector: { type: 'string' },
|
|
211
|
+
waitFor: {},
|
|
212
|
+
assert: { type: 'string' },
|
|
213
|
+
baseline_key: { type: 'string' },
|
|
214
|
+
viewport: viewportSchema,
|
|
215
|
+
device: deviceSchema,
|
|
216
|
+
engine: engineSchema,
|
|
217
|
+
locale: localeSchema,
|
|
218
|
+
timezone: timezoneSchema,
|
|
219
|
+
reduced_motion: reducedMotionSchema,
|
|
220
|
+
no_diff: { type: 'boolean', description: 'Smoke-only: never pixel-diff (live-data dashboards). Verify on render + anchor + assert.' },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'lens_clear_baseline',
|
|
229
|
+
description: 'Unapprove a baseline: drop it so its surface returns to no-baseline (smoke), stopping the pixel-diff. Use when a live-data surface (a dashboard with changing counts/timestamps) was approved and now drifts to a false RED.',
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
required: ['project', 'baseline_key'],
|
|
233
|
+
properties: {
|
|
234
|
+
project: { type: 'string' },
|
|
235
|
+
baseline_key: { type: 'string' },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'lens_list_runs',
|
|
241
|
+
description: 'List recent Lens runs for a project (most recent first), optionally filtered by status or surface_key.',
|
|
242
|
+
inputSchema: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
required: ['project'],
|
|
245
|
+
properties: {
|
|
246
|
+
project: { type: 'string' },
|
|
247
|
+
status: { type: 'string', enum: ['pass', 'fail', 'no-baseline', 'error'] },
|
|
248
|
+
surface_key: { type: 'string' },
|
|
249
|
+
limit: { type: 'number' },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: 'lens_record',
|
|
255
|
+
description:
|
|
256
|
+
'RECORD a surface for N seconds and measure what MOVED — for a defect that only exists in motion, which a screenshot cannot show. Returns the video AND the probe: defect_count (a move nobody caused THAT THE READER COULD SEE), every finding classified, plus detector_alive and exercise. READ THOSE TWO FIRST: zero findings from a blind probe and zero from a working product are the same number, and detector_alive/exercise are the only things that tell them apart — a run with exercise.exercised false is NO RESULT, not a pass. LOCAL ONLY: the daemon owns the browser, so this needs a running cardmem daemon; Cloud Lens has no recorder. Steps are optional on purpose — the reproduction for "it jumps and I did not touch it" is to open it and sit still.',
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: 'object',
|
|
259
|
+
required: ['project', 'url', 'seconds'],
|
|
260
|
+
properties: {
|
|
261
|
+
project: { type: 'string', description: 'Project slug — scopes the run.' },
|
|
262
|
+
url: { type: 'string', description: 'The surface to watch (localhost dev-server or public URL).' },
|
|
263
|
+
seconds: { type: 'number', description: 'How long to record. A motion defect needs a window it can occur in — 30–120s is typical, not 3.' },
|
|
264
|
+
selector: { type: 'string', description: 'The scrolling container to watch. Prefer [data-testid]. Omit → the page.' },
|
|
265
|
+
row_attr: {
|
|
266
|
+
type: 'string',
|
|
267
|
+
description:
|
|
268
|
+
'Attribute identifying the repeated ROWS inside the container (e.g. data-msg-id). It is what lets the probe say how far the PICTURE moved rather than how far scrollTop moved — different quantities, and conflating them reports correct behaviour as a defect.',
|
|
269
|
+
},
|
|
270
|
+
viewport: {
|
|
271
|
+
type: 'object',
|
|
272
|
+
description: 'Render viewport, e.g. {"width":390,"height":844}. Default 1280x800.',
|
|
273
|
+
properties: { width: { type: 'number' }, height: { type: 'number' } },
|
|
274
|
+
},
|
|
275
|
+
reduced_motion: {
|
|
276
|
+
type: 'string',
|
|
277
|
+
description: "'reduce' (default) or 'no-preference'. Pass no-preference when the thing you are recording IS an animation.",
|
|
278
|
+
},
|
|
279
|
+
scroll_up_px: {
|
|
280
|
+
type: 'number',
|
|
281
|
+
description:
|
|
282
|
+
'Scroll UP this far before watching. Load-bearing for a chat/feed: at the bottom, being dragged along by new content is CORRECT, so a run that stays there measures the one place the defect cannot exist.',
|
|
283
|
+
},
|
|
284
|
+
clicks: {
|
|
285
|
+
type: 'array',
|
|
286
|
+
description: 'Press controls DURING the recording, in order. Each press is reported with pressed true/false — a run that never exercised its own scenario must not read as a clean result.',
|
|
287
|
+
items: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: { testid: { type: 'string' }, at_ms: { type: 'number' } },
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
interval_ms: { type: 'number', description: 'Probe sampling interval. Default ~50ms.' },
|
|
293
|
+
min_delta_px: { type: 'number', description: 'Ignore moves smaller than this. Default 2.' },
|
|
294
|
+
fps: { type: 'number', description: 'Frames cut from the video for the timeline. 0 disables stills.' },
|
|
295
|
+
burst_fps: { type: 'number', description: 'Dense frames cut around each finding, so a jump can be read frame by frame.' },
|
|
296
|
+
local_path: { type: 'string', description: "Repo root — used to inherit the project's lens.manifest.json auth when `auth` is omitted." },
|
|
297
|
+
auth: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
description: 'Auth pre-step, same adapters as lens_capture (storageState / cookieJwt / mintEndpoint). Omit on an authed page and you record the sign-in wall.',
|
|
300
|
+
properties: {
|
|
301
|
+
adapter: { type: 'string' },
|
|
302
|
+
statePath: { type: 'string' },
|
|
303
|
+
stateEnv: { type: 'string' },
|
|
304
|
+
tokenEnv: { type: 'string' },
|
|
305
|
+
cookieName: { type: 'string' },
|
|
306
|
+
domain: { type: 'string' },
|
|
307
|
+
url: { type: 'string' },
|
|
308
|
+
method: { type: 'string' },
|
|
309
|
+
secretEnv: { type: 'string' },
|
|
310
|
+
secretPath: { type: 'string' },
|
|
311
|
+
body: { type: 'object' },
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'lens_run_flow',
|
|
319
|
+
description:
|
|
320
|
+
// F074.55 — the verb list is INTERPOLATED from the parser's own vocabulary.
|
|
321
|
+
// A hand-written list in a tool description is the only thing most callers
|
|
322
|
+
// ever read, and ours had drifted to 9 of 26: three repos reported an
|
|
323
|
+
// existing verb as missing because of it.
|
|
324
|
+
`Run an E2E manuscript flow: drive a site step-by-step in one authed page. Steps (${[...STEP_TYPES].length}): ${[...STEP_TYPES].join(' / ')}. testid-anchored steps use [data-testid]; fillSelector/clickSelector use a raw CSS selector + optional cross-origin frame chain to reach a 3rd-party iframe a testid can\'t (e.g. Stripe PCI card fields), and waitForUrl asserts the redirect back to our origin. Stops + pins a screenshot on the first failing step. Returns a per-step report with screenshot_url per step. This is full End-to-End — it can create users, submit forms, complete a Stripe test payment, etc. (set mutates:true for write flows). Scoped to the project slug.`,
|
|
325
|
+
inputSchema: {
|
|
326
|
+
type: 'object',
|
|
327
|
+
required: ['project', 'flow'],
|
|
328
|
+
properties: {
|
|
329
|
+
project: { type: 'string', description: 'Project slug — scopes the flow run.' },
|
|
330
|
+
from: { type: 'number', description: 'Resume: skip flattened steps before this index (re-run a bulk flow from where it left off).' },
|
|
331
|
+
local_path: { type: 'string', description: 'Repo root. Resolves a relative auth statePath AND inherits the project lens.manifest.json auth when the flow has none — so authed routes work (not just /lens/gate).' },
|
|
332
|
+
viewport: viewportSchema,
|
|
333
|
+
device: deviceSchema,
|
|
334
|
+
engine: engineSchema,
|
|
335
|
+
locale: localeSchema,
|
|
336
|
+
timezone: timezoneSchema,
|
|
337
|
+
reduced_motion: reducedMotionSchema,
|
|
338
|
+
storage: storageSchema,
|
|
339
|
+
critic: {
|
|
340
|
+
type: 'string',
|
|
341
|
+
enum: ['off', 'dom', 'vision', 'both'],
|
|
342
|
+
description:
|
|
343
|
+
'F126 composition critic on each screenshot step: off (default) | dom (free geometry) | vision (metered) | both. A high-severity finding fails that step (and the flow).',
|
|
344
|
+
},
|
|
345
|
+
record: {
|
|
346
|
+
type: 'boolean',
|
|
347
|
+
description:
|
|
348
|
+
'F255.5 — record the whole driven run as a .webm and return it as video_url (alongside the per-step screenshots). Off by default. Kept even when the flow fails, which is when watching it matters most.',
|
|
349
|
+
},
|
|
350
|
+
flow: {
|
|
351
|
+
type: 'object',
|
|
352
|
+
required: ['name', 'base_url', 'steps'],
|
|
353
|
+
properties: {
|
|
354
|
+
name: { type: 'string' },
|
|
355
|
+
base_url: { type: 'string', description: 'http(s) prefix; goto paths resolve against it.' },
|
|
356
|
+
mutates: { type: 'boolean', description: 'true when the flow creates/changes data.' },
|
|
357
|
+
wait_for_build: {
|
|
358
|
+
type: ['string', 'boolean'],
|
|
359
|
+
description:
|
|
360
|
+
'F255.7 — do not start until the app is serving the build under test. A commit sha waits for THAT build; true waits for any change from the build live at run start. Polls GET /api/health for a `build` field (the fleet contract), from the runner with no-store, requiring 3 consecutive matching reads so a fleet mid-rollout cannot end the wait early. Fails loudly if the app exposes no marker. Use it right after a deploy — otherwise a green run may be verifying the build you just replaced.',
|
|
361
|
+
},
|
|
362
|
+
auth: authSchema,
|
|
363
|
+
steps: {
|
|
364
|
+
type: 'array',
|
|
365
|
+
description: 'Ordered steps. Each has a "type"; testid-anchored steps carry a "testid". fillSelector/clickSelector carry a CSS "selector" + optional "frame" (one iframe selector or an outer→inner string[] chain) to reach a cross-origin iframe; fillSelector also carries "value". waitForUrl carries a "url" substring. A "loop" step has data[] + steps[] and substitutes {{field}}/{{__index}}.',
|
|
366
|
+
items: { type: 'object', required: ['type'], properties: { type: { type: 'string' }, testid: { type: 'string' }, selector: { type: 'string' }, frame: { type: ['string', 'array'], items: { type: 'string' } }, url: { type: 'string' }, value: { type: 'string' }, text: { type: 'string' }, path: { type: 'string' }, js: { type: 'string' }, label: { type: 'string' }, timeout: { type: 'number' }, sha: { type: 'string', description: 'F255.7 — waitForBuild step: the commit sha to wait for. Omit to wait for any change from the build live when the step starts.' }, at: { type: 'string', description: 'F255.4 — click a POINT inside the element instead of its centre: "50%,28%" (fraction of the box) or "40,120" (css px). For canvas/3D surfaces where the centre hits nothing. Works on click + clickSelector.' }, activate: { type: 'string', enum: ['auto', 'click', 'enter'], description: 'F255.1 — how a button is fired. auto (default) clicks and falls back to focus+Enter only if nothing observable happened; enter forces focus+Enter (Angular/MDC forms); click keeps mouse-only.' } } },
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
name: 'lens_run_manuscript',
|
|
375
|
+
description:
|
|
376
|
+
`Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" · "- upload [input-testid] = <fixture>" · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
|
|
377
|
+
inputSchema: {
|
|
378
|
+
type: 'object',
|
|
379
|
+
required: ['project', 'manuscript'],
|
|
380
|
+
properties: {
|
|
381
|
+
project: { type: 'string' },
|
|
382
|
+
manuscript: { type: 'string', description: 'The markdown manuscript text.' },
|
|
383
|
+
auth: authSchema,
|
|
384
|
+
local_path: { type: 'string', description: 'Repo root. Resolves a relative auth statePath AND inherits the project lens.manifest.json auth when none is passed. The run always REPORTS what it used as `auth: {source, adapter, note}` — source "none" carries the reason, so an unauthenticated run is never something you have to infer from a confusing failure (F074.36).' },
|
|
385
|
+
viewport: viewportSchema,
|
|
386
|
+
device: deviceSchema,
|
|
387
|
+
engine: engineSchema,
|
|
388
|
+
locale: localeSchema,
|
|
389
|
+
timezone: timezoneSchema,
|
|
390
|
+
reduced_motion: reducedMotionSchema,
|
|
391
|
+
storage: storageSchema,
|
|
392
|
+
record: {
|
|
393
|
+
type: 'boolean',
|
|
394
|
+
description: 'F255.5 — record the whole driven run as a .webm and return it as video_url. Off by default.',
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: 'lens_list_flow_runs',
|
|
401
|
+
description: 'List recent E2E flow runs for a project (most recent first), optionally filtered by flow_name or status (pass/fail/error).',
|
|
402
|
+
inputSchema: {
|
|
403
|
+
type: 'object',
|
|
404
|
+
required: ['project'],
|
|
405
|
+
properties: {
|
|
406
|
+
project: { type: 'string' },
|
|
407
|
+
flow_name: { type: 'string' },
|
|
408
|
+
status: { type: 'string', enum: ['pass', 'fail', 'error'] },
|
|
409
|
+
limit: { type: 'number' },
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
name: 'lens_promote_evidence',
|
|
415
|
+
description:
|
|
416
|
+
"F272.2/F074.68 — Copy a Lens run's screenshots into the project's DURABLE artifact store, and return the evidence line to cite. Works for a FLOW run (flow_run_id) and for a plain CAPTURE run (run_id from lens_capture / lens_verify) — pass exactly one. Use this before citing a Lens run as proof for an acceptance criterion: a run's stills live on the daemon machine's disk behind 127.0.0.1, so the owner cannot open them and a disk wipe removes them silently while the AC still reads as proven. The promoted /api/artifacts/<id>/raw urls are the ONLY form cardmem_record_review can verify server-side — a run id alone can at best be judged 'unknown'. A capture that also has a baseline diff promotes BOTH, each named in the result (screenshot / diff) rather than left to a count. Bytes go daemon → cloud directly; they never pass through your context.",
|
|
417
|
+
inputSchema: {
|
|
418
|
+
type: 'object',
|
|
419
|
+
required: ['project'],
|
|
420
|
+
properties: {
|
|
421
|
+
project: { type: 'string', description: 'Project slug the run belongs to.' },
|
|
422
|
+
flow_run_id: { type: 'string', description: 'The flow_run_id from lens_run_flow / lens_list_flow_runs. Exactly one of flow_run_id / run_id.' },
|
|
423
|
+
run_id: { type: 'string', description: 'The run_id from lens_capture / lens_verify. Exactly one of flow_run_id / run_id.' },
|
|
424
|
+
step_indexes: {
|
|
425
|
+
type: 'array',
|
|
426
|
+
items: { type: 'number' },
|
|
427
|
+
description: 'Optional subset of step indexes. Omit to promote every step that HAS a screenshot.',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
name: 'lens_export',
|
|
434
|
+
description:
|
|
435
|
+
"F074.63 — Render a URL to a VECTOR PDF, where text stays text. Use this for anything a HUMAN outside the fleet must be able to EDIT or PRINT: customer print material, a flyer, an A3 poster, a spec handed to a graphic designer. It is NOT the tool for verification — screenshots are still lens_capture/lens_verify. The difference from stitching lens_capture PNGs into a PDF is the whole point: that produces a picture of a document (one linked image, zero text objects in Illustrator) and no resolution is high enough to fix it, because the daemon's pixel limit caps A3 at ~257 DPI against a 300 DPI print spec. A vector PDF has no resolution at all. The response carries `editable_text` and a `content` block (text runs / fonts / images) measured FROM THE PRODUCED BYTES — check `editable_text: true` before you hand the file to anyone, because 'a PDF came back' is exactly the green a bitmap already passes. Chromium only. For SVG the response also carries `unsupported` (gradients, transforms, shadows — NAMED, never faked into a flat fill) and `lint.outOfBounds` (SVG has no line wrapping, so text wider than the sheet is otherwise discovered at the printer; measured on BOTH axes, because a guard that measures one is green on half the faults it is about).",
|
|
436
|
+
inputSchema: {
|
|
437
|
+
type: 'object',
|
|
438
|
+
required: ['project', 'url'],
|
|
439
|
+
properties: {
|
|
440
|
+
project: { type: 'string' },
|
|
441
|
+
url: { type: 'string' },
|
|
442
|
+
format: {
|
|
443
|
+
type: 'string',
|
|
444
|
+
enum: ['pdf', 'svg'],
|
|
445
|
+
description: "'pdf' (default) — a printable document. 'svg' (F074.64) — the file a DESIGNER edits: read from the LIVE DOM, so a heading is ONE <text> object with a named font, not 109 letter-positions, and the units are MILLIMETRES (viewBox 0 0 297 420 for A3). Use svg for anything going to a graphic designer, pdf for anything going to a printer. The svg response carries `unsupported` (what could not be represented, named rather than faked) and `lint.outOfBounds` (SVG does not wrap, so text wider than the sheet is only discovered at the printer unless something measures it).",
|
|
446
|
+
},
|
|
447
|
+
page_size: {
|
|
448
|
+
type: 'string',
|
|
449
|
+
description: "Paper size: 'A4' (default), 'A3', 'Letter', … Combine with landscape for an A3 poster.",
|
|
450
|
+
},
|
|
451
|
+
landscape: { type: 'boolean', description: 'Rotate the paper. Default false.' },
|
|
452
|
+
print_background: {
|
|
453
|
+
type: 'boolean',
|
|
454
|
+
description: 'Default TRUE — deliberately against the browser default of false. With it off, every background colour and image disappears and a poster comes out WHITE while the export still reports success. Only pass false if you actually want ink-saving output.',
|
|
455
|
+
},
|
|
456
|
+
margin: {
|
|
457
|
+
type: 'object',
|
|
458
|
+
description: "Page margins, e.g. { top: '0', right: '0', bottom: '0', left: '0' } for a full-bleed poster. CSS units.",
|
|
459
|
+
properties: {
|
|
460
|
+
top: { type: 'string' },
|
|
461
|
+
right: { type: 'string' },
|
|
462
|
+
bottom: { type: 'string' },
|
|
463
|
+
left: { type: 'string' },
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
media: {
|
|
467
|
+
type: 'string',
|
|
468
|
+
enum: ['screen', 'print'],
|
|
469
|
+
description: "Which CSS media to render. Default 'screen', deliberately against the browser default of 'print': a page designed for screen can lose its whole layout to an @media print rule, and NOTHING errors — you just get a confident, wrong PDF. Pass 'print' only when the page has a print stylesheet you want.",
|
|
470
|
+
},
|
|
471
|
+
selector: {
|
|
472
|
+
type: 'string',
|
|
473
|
+
description: "SVG ONLY (F074.64) — export ONE surface, e.g. '[data-testid=\"flade-a3-plakat\"]'. A print job is ONE SHEET and a mockup document usually holds several: measured on fd-sundhed's material, omitting this gave the whole documentation page (158 text blocks, 795mm tall) instead of the A3 poster (7 text blocks). Ignored by format:'pdf', which has no element mode.",
|
|
474
|
+
},
|
|
475
|
+
selector_index: {
|
|
476
|
+
type: 'number',
|
|
477
|
+
description: 'Which match to take when `selector` hits several — a bifold folder has two sheets under one class. Default 0. The error names how many DO match, because "matched nothing" and "matched 2, you asked for 5" need different fixes.',
|
|
478
|
+
},
|
|
479
|
+
engine: engineSchema,
|
|
480
|
+
viewport: viewportSchema,
|
|
481
|
+
device: deviceSchema,
|
|
482
|
+
scale: { type: 'number', description: 'Device pixel ratio while rendering. Affects raster images inside the page; the text stays vector regardless.' },
|
|
483
|
+
waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'commit'] },
|
|
484
|
+
waitFor: { description: 'CSS selector or ms to wait before exporting.' },
|
|
485
|
+
auth: authSchema,
|
|
486
|
+
timeout_ms: { type: 'number' },
|
|
487
|
+
expect_text: {
|
|
488
|
+
type: 'array',
|
|
489
|
+
items: { type: 'string' },
|
|
490
|
+
description: "Strings you expect to find AS TEXT in the result. Answered as `text_found: {\"…\": true|false}` — checked on the produced bytes, whitespace-insensitively (a PDF stores no space where the page broke a line). This is how you PROVE a headline survived as editable text instead of becoming part of a picture. The extracted text itself is never returned: a poster's worth of it is thousands of tokens you cannot use.",
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
] as const;
|
|
496
|
+
|
|
497
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
498
|
+
async function dispatch(deps: LensDeps, name: string, args: any): Promise<unknown> {
|
|
499
|
+
switch (name) {
|
|
500
|
+
case 'lens_capture':
|
|
501
|
+
return lensCapture(deps, args);
|
|
502
|
+
case 'lens_verify':
|
|
503
|
+
return lensVerify(deps, args);
|
|
504
|
+
case 'lens_approve_baseline':
|
|
505
|
+
return lensApproveBaseline(deps, args);
|
|
506
|
+
case 'lens_clear_baseline':
|
|
507
|
+
return lensClearBaseline(deps, args);
|
|
508
|
+
case 'lens_capture_catalogue':
|
|
509
|
+
return lensCaptureCatalogue(deps, args);
|
|
510
|
+
case 'lens_list_runs':
|
|
511
|
+
return lensListRuns(deps, args);
|
|
512
|
+
case 'lens_record':
|
|
513
|
+
return lensRecord(deps, args);
|
|
514
|
+
case 'lens_run_flow':
|
|
515
|
+
return lensRunFlow(deps, args);
|
|
516
|
+
case 'lens_run_manuscript':
|
|
517
|
+
return lensRunManuscript(deps, args);
|
|
518
|
+
case 'lens_list_flow_runs':
|
|
519
|
+
return lensListFlowRuns(deps, args);
|
|
520
|
+
case 'lens_promote_evidence':
|
|
521
|
+
return promoteEvidence(deps, args);
|
|
522
|
+
case 'lens_export':
|
|
523
|
+
return lensExport(deps, args);
|
|
524
|
+
default:
|
|
525
|
+
throw new Error(`unknown tool: ${name}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function createLensMcpRouter(deps: LensDeps): Hono {
|
|
530
|
+
const router = new Hono();
|
|
531
|
+
router.all('/', async (c) => {
|
|
532
|
+
const server = new Server(
|
|
533
|
+
{ name: 'cardmem-lens', version: '0.1.0' },
|
|
534
|
+
{
|
|
535
|
+
capabilities: { tools: {} },
|
|
536
|
+
instructions:
|
|
537
|
+
'Lens is the cardmem daemon\'s visual-verification engine. Call lens_verify against a localhost dev-server (or public URL) to confirm a surface looks + behaves right before a card moves to Done. Capture-by-[data-testid] is the preferred, stable mode. All runs/baselines are scoped to the project slug you pass.',
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
542
|
+
tools: TOOLS as unknown as Array<{ name: string; description: string; inputSchema: object }>,
|
|
543
|
+
}));
|
|
544
|
+
|
|
545
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
546
|
+
try {
|
|
547
|
+
const result = await dispatch(deps, req.params.name, req.params.arguments ?? {});
|
|
548
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
549
|
+
} catch (err) {
|
|
550
|
+
return {
|
|
551
|
+
content: [{ type: 'text', text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
552
|
+
isError: true,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const transport = new WebStandardStreamableHTTPServerTransport();
|
|
558
|
+
await server.connect(transport);
|
|
559
|
+
return transport.handleRequest(c.req.raw);
|
|
560
|
+
});
|
|
561
|
+
return router;
|
|
562
|
+
}
|