MCP server — visual results for AI agents
@testivai/mcp exposes a run's results to any MCP
client: structured verdicts, the actual diff images, and explain_snapshot,
which hands a model the layered evidence for one snapshot so it can explain why
something changed.
The model is yours. TestivAI ships the evidence, not the inference — there's no hosted AI, no API key, and no per-screenshot pricing. Whatever model you already pay for does the reasoning, and the quality scales with it.
Setup
Claude Code
claude mcp add testivai -- npx -y @testivai/mcp
Add --scope project to write it to .mcp.json at the project root instead,
so everyone who opens the repository in Claude Code gets the same server:
claude mcp add --scope project testivai -- npx -y @testivai/mcp.
Any other MCP client — Cursor, Copilot, Zed, or your own — point it at the same command:
{
"mcpServers": {
"testivai": { "command": "npx", "args": ["-y", "@testivai/mcp"] }
}
}
The server reads the project from its working directory (--root <path> to
override) and honours reportDir from .testivai/config.json. It runs locally
and makes no network calls.
When there is no report yet it says why, rather than assuming the tests never ran — on a sharded CI node the tests did run, and the comparison happens elsewhere:
No results found at visual-report/results.json because this is capture-only
shard 3/8. 1 capture(s) are in .testivai/temp/, but no comparison ran here — a
shard sees only its slice of the suite. The report is produced by the job that
merges every shard.
Review and approve in Claude Code
With the server added, a visual review happens in the conversation:
- Run your visual tests (
npx playwright test), or ask Claude to. - Type
/testivai:review-visual-changes(/mcp__testivai__review-visual-changesalso works), or just ask "what changed visually?". Claude reads the verdicts, callsexplain_snapshotfor each change, looks at the diff images withget_diffwhen the evidence is ambiguous (always when the styles were not compared), and recommends approve, investigate, or likely noise for each snapshot. "Likely noise" is only for an identical DOM whose computed styles were compared and match, and whose diff is small and scattered: a diff concentrated in one area, or over 1% of pixels, gets its own verdict (see Matching styles are not always noise). - To accept a change, say so: "approve home". Claude calls
approve_snapshot, and Claude Code shows its permission prompt for that call. Answer Yes to approve it, or No to keep the current baseline. - Commit
.testivai/baselines/.
The prompt in step 3 appears on every approve call. Both approve tools are
marked with anthropic/requiresUserInteraction in tools/list, so Claude Code
(v2.1.214 or later) asks every time, even in acceptEdits, auto and
bypassPermissions modes, offers no "don't ask again", and does not let allow
rules skip it; in dontAsk mode it denies the call. An approval therefore
always comes from you answering the prompt, never from the model alone. Other
MCP clients ignore the marker and apply their own tool-approval settings.
Turning the approval prompt off
If you'd rather not confirm every approval, turn the forced prompt off. The
approve tools then follow your normal Claude Code permission settings: you can
allow them with a permission rule (mcp__testivai__approve_snapshot,
mcp__testivai__approve_all), answer "don't ask again", or run in an
auto-approving mode. Two ways, and the flag wins when both are set:
- Per user: pass
--no-approval-promptto the server, e.g.claude mcp add testivai -- npx -y @testivai/mcp --no-approval-prompt.--approval-promptturns it back on. - Per project: set
"mcpApprovalPrompt": falsein.testivai/config.json. It applies to everyone who uses the repository, so commit it deliberately.
The server reads both when it starts; restart it (or the Claude Code session) after changing them. The tool descriptions still tell the model to approve only when you confirm, but with the prompt off nothing in the client enforces that.
Seeing the images. get_diff returns the baseline, current and diff PNGs
to Claude, which looks at them inline: the full page downscaled, then the
changed regions at native scale (Reading a tall page);
Claude Code also saves the original bytes in the session's tool-results
directory under ~/.claude/projects/.
For yourself, get_visual_results ends with the absolute paths to open: the
HTML report (side-by-side baseline, current and diff), each changed snapshot's
diff image, and each new snapshot's capture.
Tools
| Tool | Returns |
|---|---|
get_visual_results | Every snapshot with a one-line verdict phrased for decisions, opened by groups of snapshots that share an identical signal when there are any, then the paths to open: the HTML report, each changed snapshot's diff image, each new snapshot's capture. Each missing baseline (committed, but no capture this run) gets its own line too (several share one verdict line, so a partial run stays short), with nothing to approve: an intended removal deletes the baseline directory |
explain_snapshot | Layered evidence for one snapshot — pixel regions, element attribution, DOM/style signal, interpretation guidance; for a missing baseline, status missing, the committed baseline files and what to check instead |
get_report | The raw results.json payload |
get_diff (alias get_snapshot_diff) | Baseline, current and diff PNGs: the full page downscaled for model context, then, when it had to be downscaled, each changed region cropped at native scale with 32 px around it and as wide as the page (up to 1280 px), up to 3 crops, each at most 1280×1024 px and 2,000,000 px in all (below). region, or y and height, return only that crop; for a missing baseline, the committed baseline image only, since there is no current capture |
list_baselines | Committed baselines under .testivai/baselines/ |
approve_snapshot / approve_all | Promote reviewed snapshots to baselines — only after a human says so |
Reading a tall page: crops
A full-page capture downscaled to fit is too small to read: a 390×11844 mobile
page becomes about 33×987 px, a 1280×9679 desktop page about 128×968. So after
the three downscaled images, which show where the changes are, get_diff
lists the snapshot's regions (the first 20, in results.json order, with
their index, box, classification and selector) and crops them at native
scale, one image pixel per page pixel, so the model can check text, wrapping
and clipping. A page that fits within 1024 px is already sent at native
scale, so it gets no crops.
- Each crop is a region plus 32 px on every side, inside the page, then made as wide as the page (up to 1280 px, centred on the region on a wider page), so whole lines show and a changed heading is not cut mid-word. Regions whose padded areas meet share a crop while it still fits 1280×1024 px, and a region inside a box another crop already shows joins that crop.
- Changes come before shifts (content that only moved), then the regions'
order in
results.json, top to bottom. - Up to 3 crops per call, each at most 1280×1024 px, and 2,000,000 px in all (per image: each crop is sent as baseline, current and diff). A crop that would pass the total is skipped and listed as not cropped.
- An area wider than 1280 px (a high-DPI capture) is sampled at 1/2 scale or
smaller, and says so; an area taller than 1024 rows shows its top rows and
gives the
yto continue from. - After a size change the diff canvas is padded to the larger capture, so each capture is cut to its own bounds, and a crop of the padded band says the shorter capture has no pixels there.
For another area, call get_diff again with region (an index from the
list) or with y and height (a full-width band; by default as many rows as
one crop shows, 1024 at native scale). Those calls return only that crop,
without the full-page images, on any page. A band works on a new snapshot
too, which has only its current capture.
There are also two prompts. review-visual-changes walks a client through a
full review: summary → explain_snapshot per change → diff images when
ambiguous → a per-snapshot recommendation. triage-visual-diff sorts each
diff into glitch, intended change, bug or possible bug (below).
Triage a diff: glitch, intended change, bug or possible bug
A pixel diff alone can't tell a rendering glitch from a regression. The
triage-visual-diff prompt has the model decide, per snapshot, from the
signals, the images and what the change was meant to do:
| Verdict | Meaning |
|---|---|
| glitch | The page did not change: render noise, a bad capture (the baseline's or the current one), or a font or other resource that failed to load |
| intended change | The page changed, and it matches what the change under test was meant to do |
| bug | The page changed in a way the change was not meant to make, or the result looks broken |
| possible bug | The page really changed, but the evidence can't settle whether that was meant, or it is incomplete (styles not compared, no DOM data); the model says what to check |
In Claude Code, type /testivai:triage-visual-diff (or
/mcp__testivai__triage-visual-diff). Both arguments are optional:
snapshot: triage one snapshot or missing baseline; empty triages every changed or new snapshot and every missing baseline.intent: what the change was meant to do, for example the pull request description. Empty has the model look for it in the conversation, the PR or the branch's diff; with no statement of intent anywhere it calls a real change a possible bug rather than guess.
The rules it gives the model:
- A changed DOM or changed computed styles (
styleCheck: "mismatch") is never a glitch. - An identical DOM whose styles were not compared (
styleCheck: "unavailable"or missing) is not called a glitch from the DOM alone; the images decide. - Render noise is small and scattered. A larger or local diff with an identical DOM and matching styles is a bad capture or a change the style check cannot see (it does not cover size, overflow, position, transform and some other properties, or images). The model calls it a glitch only when nothing the change touched could have caused it, and then says which image is wrong. A bad baseline has to be replaced from a capture two runs agree on.
- A missing baseline (committed, but no capture this run:
missingBaselinesinresults.json) is never a glitch: the model reads the change under test for a removed or renamed test, or one broken before its capture, and otherwise calls it a possible bug and says to check the test run's log, since the report cannot say why nothing was captured. Approving does nothing for a missing baseline: an intended removal deletes its baseline directory in the same change (for examplegit rm -r .testivai/baselines/<name>). - It reports a verdict, confidence, evidence and next step per snapshot, and
never calls
approve_snapshotorapprove_all.
Other MCP clients list it as a prompt too. To run the same triage on every
pull request in CI, use the testivai/testivai-oss/review@v1 action.
What explain_snapshot actually returns
Real output, not a mock. The scenario: a designer changes one CSS custom
property — --brand from #0e7490 to #1f6feb — in the
example project. No markup
changes at all.
The one-line verdict:
changed (1.13% pixels differ) — style-only change: 6 elements restyled with
identical DOM (body > header, body > main > section:nth-of-type(2) > div.row >
button.btn:nth-of-type(1), …); a real change, not noise — confirm it is
intended before approving
The evidence behind it:
{
"name": "home",
"status": "changed",
"layers": {
"pixel": {
"diffPercent": 1.13,
"regionCount": 5,
"regions": [
{
"x": 0, "y": 0, "width": 98, "height": 165,
"diffPixels": 8109, "diffPercent": 50.15,
"classification": "change",
"elements": [
{ "selector": "body > header > p", "role": "changed" },
{ "selector": "body > header", "role": "changed" }
]
},
{
"x": 234, "y": 438, "width": 116, "height": 38,
"diffPixels": 3839, "diffPercent": 87.09,
"classification": "change",
"elements": [
{ "selector": "body > main > section:nth-of-type(2) > div.row > button.btn:nth-of-type(1)",
"role": "changed" }
]
}
// … 3 more regions
]
},
"dom": {
"changed": false,
"noiseHint": false,
"styleCheck": "mismatch",
"styleChanges": {
"count": 6,
"elements": [
"body > header",
"body > main > section:nth-of-type(2) > div.row > button.btn:nth-of-type(1)",
"body > main > section:nth-of-type(3) > div.cards > div.card:nth-of-type(1) > div.body:nth-of-type(2) > div.price"
]
}
}
},
"guidance": [
"Computed styles changed on 6 element(s) … while the DOM stayed identical — a stylesheet-only change. This is REAL, not noise.",
"Baseline approval is a human decision: propose, do not auto-approve."
]
}
Read what that gives a model that a pixel percentage doesn't. 1.13% of pixels differ is unactionable. This says: nothing structural changed, six specific
elements were restyled, and the affected selectors are the header, the primary
buttons, and the price labels on every card — which is exactly the blast radius
of a brand-colour token. A model can now tell you "you changed the brand token;
it hit the header, the primary buttons and the card prices — intended?" rather
than "something moved."
Matching styles are not always noise
The style check digests a fixed list of computed properties: colours, fonts,
borders, padding and margin, display, opacity, visibility and box-shadow. A
real change to size, overflow, position, transform, text decoration, filters,
pseudo-elements, or an image or other asset leaves the DOM identical and the
styles matching, and so does a bad capture (a torn render, text that did not
paint, a bad baseline). What tells those apart from render noise is the shape
of the diff, read from diffPercent, totalPixels and regions:
| Shape | When | Verdict |
|---|---|---|
| Scattered | at most 1% of pixels, and no regions or regions spread out (their combined bounding box covers more than a third of the page) | likely render noise; mention it, don't block |
| Concentrated | every region fits in a box covering at most a third of the page | not noise: a bad capture or a change the style check does not cover; compare the images (get_diff) before approving |
| Large | spread out, but over 1% of pixels | more than render noise usually changes: often fonts or rendering that differ between machines; compare the images |
explain_snapshot returns the shape as layers.pixel.shape, and for a
concentrated diff names the element every region maps to. On real runs of the
example project, with the DOM
identical and the styles matching throughout:
| Diff | Pixels | Regions | Shape |
|---|---|---|---|
max-height + overflow: hidden clipping one product card (a real bug) | 0.03% | 3, in 0.8% of the page | concentrated |
| one card's text did not paint (a bad current capture) | 0.14% | 1, in 1.1% of the page | concentrated |
| a 260px band of the baseline moved 24px (a bad baseline) | 2.07% | 1, in 18.8% of the page | concentrated |
| sixty glyph-edge pixels changed (anti-aliasing) | 0.006% | none | scattered |
every text run re-rasterized (--disable-font-subpixel-positioning) | 0.85% | 20, over 37.8% of the page | scattered |
| a CI-made baseline against a local capture | 4.77% | 22, over 93% of the page | large |
noiseAutoPass applies the same rule: it never passes a concentrated diff.
The limit: a change to a class that repeats down a long page (an underline on
thirty product titles) spreads its regions as widely as text-rendering noise,
so a small one can still read as scattered.
What the model adds (and what it doesn't)
Worth being precise, because this is where visual testing tools usually overclaim.
The detection is not AI. Everything in the payload above — the diff regions,
the selector attribution, dom.changed, styleCheck: "mismatch", the list of
restyled elements — is produced by deterministic comparison. Same input, same
output, every run, no tokens spent, no network call. If you never connect a model
at all, you still get every one of those signals in results.json and the HTML
report.
The model adds interpretation, which is a genuinely different job:
| Intent | The tool says six elements restyled, DOM identical, here are the selectors. A model that can see your diff says you changed the brand token; it reached the header, the primary buttons and every card price. It connects the change to the edit you just made. |
| Triage | Thirty changed snapshots is a reading task. The server already groups what is decidable (below); a model goes further — these twenty-eight are the same token change, these two are something else — by connecting different restyled elements on different pages to one edit, and puts the two in front of you first. |
| Explanation | Turning the evidence into a sentence a reviewer understands, in a PR comment or in your editor. |
| Self-correction | An agent that just edited the UI can check whether it changed anything it didn't intend, and fix it before you ever see it. |
What the server groups
get_visual_results opens with a Grouped by identical signal section when
at least two changed snapshots share one of these signals, chosen in this
order (a snapshot joins at most one group):
| Signal | Grouped when | What it does not tell you |
|---|---|---|
| Style-only change | identical DOM and exactly the same set of restyled elements | whether the new style values are the same: the grouping is on which elements changed style |
| Page shift | the same displacement: identical dy and belowY | whether the same content caused it |
| Styles not compared | identical DOM, styles not compared (styleCheck: "unavailable", or no style check in older results; the two are grouped apart). Labelled render noise or a stylesheet-only change, not ruled out, never labelled noise | which of the two it is: compare the images |
| Large diff | identical DOM and matching styles, but over 1% of pixels differ, spread across the page. Labelled more than render noise usually changes, never labelled noise | whether it is the same cause: often fonts or rendering that differ between machines, sometimes a bad capture or a change the style check does not cover |
| Likely render noise | identical DOM, the computed styles compared and identical (styleCheck: "match"), and a small, scattered diff | anything the DOM, style and diff-shape signals don't cover |
It never groups structural DOM changes, snapshots without DOM data, diffs concentrated in one area (each sits in its own place, so grouping two would suggest one cause), unhealthy captures (a web font or stylesheet that failed to load: each verdict names what failed), or style-only changes with more than 10 restyled elements (witness lists only the first 10, so those lists can't be compared exactly; the section names them instead). Passed, auto-passed and new snapshots are left out. Every snapshot keeps its own verdict line below the groups, unchanged.
This under-groups on purpose: one token change usually restyles a different set of elements on each page, so pages that differ in structure land in different groups, or none. Connecting those to the one edit that caused them is the model's job.
What the model does not do is decide whether something changed. That's already settled before it's involved. This matters for trust: a hallucinating model can produce a bad explanation, but it cannot invent a regression or hide one, because it isn't in the detection path.
Nor does it decide what gets approved: it carries out an approval only when a human asks for one. See the approval rule.
The approval rule
approve_snapshot and approve_all exist so that a human can say "approve it"
in conversation and have the agent carry it out. They are not for an agent
deciding on its own. In Claude Code the client enforces this: it asks you to
confirm every approve call (see
Review and approve in Claude Code).
Approving a baseline rewrites the definition of "correct," so an agent that
approves its own work can launder a regression into the new baseline. The
guardrails are in the wording the model actually reads: both tool descriptions
say to invoke them only after a reviewer has confirmed, the
review-visual-changes and triage-visual-diff prompts repeat it, and every get_visual_results
response ends with the same instruction.
Human-shaped approval paths stay available either way: npx testivai approve
locally, or a /testivai approve comment on the pull request, which verifies the
commenter has write access.
Instructions file (works with any agent)
If your agent isn't MCP-capable, paste into AGENTS.md / CLAUDE.md /
.cursorrules:
## Visual verification
After changing UI code, run the visual tests, then read visual-report/results.json:
- status "changed" + dom.changed true → real structural change; compare against intent
- status "changed" + dom.changed false + dom.styleCheck "mismatch" → style-only change; real, not noise
- status "changed" + dom.noiseHint true + dom.styleCheck "match", diffPercent at most 1, and no regions or regions spread out (their combined bounding box covers more than a third of the page, totalPixels) → likely render noise; mention, don't block
- status "changed" + dom.noiseHint true + dom.styleCheck "match" otherwise (every region within a box of at most a third of the page, or diffPercent over 1) → not noise: a bad capture or a change the style check does not cover (size, overflow, transform, images…); look at the diff before approving
- status "changed" + dom.noiseHint true + dom.styleCheck "unavailable" or missing → styles not compared: could be render noise or a stylesheet-only change; look at the diff before approving
- captureHealth.unhealthy set → unhealthy capture: a web font or stylesheet failed to load (captureHealth.reasons); never noise; fix what failed, never approve it as a baseline. captureHealth.sameOnBothSides true → the baseline failed the same way, so the tolerances still applied; judge the diff, report the broken asset
- status "new" → no baseline yet; tell the human
Never run `testivai approve` yourself — approval is a human decision.