How we validate deploys with MCP browser tools in Claude Code
An MDX bug truncated the HTML with no warning. To keep it from happening again, we built two-layer post-deploy validation — a static script and MCP browser tools running real Chrome. This is the full arc, with the actual findings.
claude-code mcp observability audit tooling
A green build does not guarantee a green render. We learned this
when an MDX block with an identifier inside curly braces silently
truncated the HTML of the first blog post by half — no warnings,
no failing tests, no Lighthouse complaints in local. Phase 10
merged the change and production served invalid HTML for a few
hours until we caught it by hand. That incident triggered two
things: an MDX-specific validator wired into pnpm check, and the
decision to take post-deploy validation seriously. This post
documents how we got to the second part, what each layer covers,
and the actual findings from the first pass against production.
The bug that didn’t show up in any check
The first blog post (how-this-site-is-built) had a table that
mentioned each product’s maturity field in the lab. In the prose,
`{Maturity}` ended up without backticks by accident. MDX
treats {something} as a JSX expression — it tries to evaluate
it, can’t find a Maturity binding in scope, and instead of
failing loudly, the compiler truncates the render from that point
on and emits valid HTML up to where it got. The site came up, the
post got published, and the bottom half simply didn’t exist. Zero
alerts.
What makes the bug interesting is that no standard check caught it:
tsc --noEmitpassed — MDX isn’t TypeScript.astro checkpassed — Astro parsed the MDX without errors because the expression is syntactically valid.- Vitest passed — there was no test for the rendered content of the post.
- Lighthouse local passed — Lighthouse audits the HTML the server returns, and the HTML returned was structurally valid, just incomplete.
pnpm buildpassed — Astro generated the file with no warnings.
Four green validation layers and broken production. The hotfix
(PR #20) was trivial: wrap the identifier in backticks. The hard
part was convincing ourselves that the next unwritten
`{something}` wasn’t going to slide through the same funnel.
The multi-agent audit that found the cracks
Before writing new tooling, we stopped to understand the scope of the problem. How many other checks were we missing? What other categories of bugs could slip past the radar the same way?
For this we delegated a multi-agent audit from Claude Code: a senior code reviewer auditing existing MDX against JSX-trigger patterns, an SRE agent auditing post-deploy observability (logs, alerts, synthetics), and a general-purpose agent mapping which external validators could run against the published site (Lighthouse, OG validators, sitemap, JSON-LD).
The important pattern here isn’t the concrete list of findings —
those live in docs/audits/2026-06-10-prod-phase-11-validation.md
— but the shape of the search. When the problem space is wide
(“what other bugs like this could happen”), a single model
reasoning linearly tends to bias toward the first ideas. Three
agents with different prompts, each reviewing their area in
parallel, return a more useful diff of the system’s state because
they cover angles that don’t interfere with each other. Claude
Code orchestrates this without the user having to thread the
context by hand.
The audit produced two concrete outputs: a list of 11 SEO and
distribution improvements that landed in Phase 11 (bilingual RSS,
og:type=article, JSON-LD for services, sitemap with lastmod,
skip-link a11y, among others), and a map of which checks remained
uncovered even after Phase 11 — the direct motivation for Layer 1
and Layer 2 of post-deploy validation.
Layer 1: the script that runs in 1.8 seconds
scripts/audit-prod.mjs is a standalone Node 22 script — with no
dependencies beyond native fetch, regex and JSON — that executes
66 checks against https://www.culturetech.cl. It covers five
categories: social cards (og:type, og:image reachable with
correct dimensions, twitter:card, article:* meta), RSS feeds
(valid XML, items with required fields, auto-discovery in
<head>), JSON-LD (presence of the expected @type per page
kind, BlogPosting with wordCount and image as ImageObject),
skip-link a11y (anchor with sr-only focus:not-sr-only classes
and target <main id="main">), and sitemap (sitemap-index.xml
reachable, lastmod per URL, sample URLs verified, robots.txt
referencing the sitemap).
Each check has severity (BLOCKER, HIGH, MEDIUM, LOW). Exit
code 1 if any BLOCKER or HIGH fails. It runs with
pnpm audit:prod and finishes in 1.8 seconds against production.
The script’s virtue is that it needs no browser. It makes HTTP requests, parses HTML with regex scoped to the markup that matters, validates JSON-LD as JSON, and verifies the URLs announced in the sitemap return 200. That makes it cheap to run — you can put it on a cron, you can run it before every push, no need to maintain Chromium or wrangle render timeouts.
The virtue is also its limit. The script verifies the HTML the
server returns, not what the browser renders. If a third-party
script injects <noindex> after load, the script doesn’t notice.
If the OG image responds 200 but is visually broken, the script
doesn’t notice. If real-browser Tab key doesn’t follow DOM order
because of weird CSS, the script doesn’t notice. For those cases,
you need Layer 2.
Layer 2: when static HTML isn’t enough
Some validations can only be done with a real browser executing
the HTML. Lighthouse SEO score depends on the browser parsing the
document, executing the JS, and evaluating heuristics that mix
markup with render. A social card preview depends on an external
scraper (X, LinkedIn, Facebook) reading the meta tags exactly the
way their own crawlers do. Tab key moves between focusable
elements in the order the browser determines, combining DOM with
tabindex, display, visibility and pointer-events.
Performance metrics like LCP and CLS only exist as artifacts of
the render process under specific network and CPU conditions.
For all of that, we configured a pair of MCP servers in Claude Code:
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"],
},
},
}
chrome-devtools-mcp spins up an instance of real Chrome —
not headless Chromium for testing — and exposes tools like
mcp__chrome-devtools__lighthouse_audit,
mcp__chrome-devtools__navigate_page,
mcp__chrome-devtools__take_screenshot,
mcp__chrome-devtools__press_key and
mcp__chrome-devtools__performance_start_trace. This is
deliberate: visual and performance validations must reflect the
browser users actually run, not a testing one with an engine
subset. Playwright is also installed as a fallback, but we prefer
Chrome when there’s a choice.
The first Layer 2 pass over Phase 11 covered four dimensions:
Mobile Lighthouse over three representative URLs — a blog
post (BlogPosting), a service (Service) and a vertical
(WebPage). All three returned 100/100/100/100 (Accessibility,
Best Practices, SEO, Agentic Browsing), with zero failures across
54 checks. A note: on my first attempt I navigated to a wrong URL
(/es-CL/verticales/banca/ directly, when the real URL lives
under /es-CL/servicios/verticales/banca/). Lighthouse returned
0/0/0/0 because Cloudflare served the Astro 404 page with no SEO
structure. This confirms Layer 2’s value: a wrong URL passes
silently through Layer 1, which validates URLs you hand it but
doesn’t discover what the correct URLs are.
Social card screenshots via opengraph.xyz — a no-login
validator that renders Facebook, X, LinkedIn, WhatsApp and Discord
side by side reading the same meta tags each platform uses. The
meta-tag inspector returned 11 Good, 2 Warning (og:description
of 135 chars that some mobile clients truncate around 125; OG
image with no visual CTA), 0 Errors. The three screenshots got
versioned in docs/audits/assets/ as visual evidence.
Real Tab key from the home page. The first Tab keystroke
moved focus to the skip-link (Saltar al contenido,
href="#main"), confirming that WCAG 2.1 SC 2.4.1 (Bypass
Blocks) is not just valid markup but actual behavior. The second
Tab moved it to the banner logo — sane DOM order. Layer 1 had
verified that the sr-only focus:not-sr-only classes and
<main id="main"> target existed. Layer 2 confirmed that the
browser actually honors that tab order.
Mobile performance trace with Slow 4G + 4x CPU throttling.
LCP 871 ms (good threshold < 2,500 ms), CLS 0.00, TTFB 12 ms. The
raw trace got compressed to .json.gz (654 KB) and is importable
into Chrome DevTools → Performance for deeper analysis. All the
latency is render delay, not network — expected for a blog post
with custom variable typography and very little JS.
The pattern: audit as code
The point of all this isn’t the list of checks or the particular scores. It’s that the site is left with two versioned artifacts that any future Claude Code session — or any dev — can run with no extra coordination:
scripts/audit-prod.mjsandpnpm audit:prodfor the cheap, fast public-markup check.- An audit doc in
docs/audits/2026-06-10-prod-phase-11-validation.mdwith Layer 1 and Layer 2 results, visual evidence (screenshots, perf trace) and prioritized findings. Linkable from any future PR that needs context.
The obvious anti-pattern here is the audit-as-screenshot-in-Google-Drive.
A validation that lives outside the repo goes stale the day the
owner leaves, the day the doc URL changes, or simply the day
nobody remembers it existed. A validation that lives in
scripts/ and docs/audits/ of the repo itself lives as long as
the repo does. And in the meantime, it stays there as an
executable reference for the next time someone wants to verify
that the Phase N deploy didn’t regress something Phase N-1 had
working.
Claude Code reinforces this pattern without being asked: in every
session it loads the project’s CLAUDE.md, reads prior audits
when relevant, and proposes running pnpm audit:prod
automatically after a merge to main. Post-deploy validation
stops being a manual ritual that happens if anyone remembers. It
becomes a natural suggestion from the working environment.
What’s next
Layer 2 still depends on a human (or Claude Code in interactive
mode) to drive the session. It needs to be integrated into CI — a
GitHub Actions job that runs pnpm audit:prod automatically
after deploy, and a scalable lane for Layer 2 (lighthouse-ci
handles the Lighthouse subset; the rest is still manual). That’s
the next PR.
Real screen reader reading is also missing. NVDA on Windows or VoiceOver on macOS aren’t automatable from MCP — that would need a different validation layer, probably an assisted manual-testing agent. For now, it’s documented as a known gap in the audit doc.
And finer real-browser coverage under load is missing: 100 URLs instead of 3, different throttling, more realistic network profiles (not every user is on Slow 4G). The trade-off is clear — more coverage costs more wall-clock and more output-parsing complexity. What we did in this iteration is enough to validate Phase 11. When the site has real traffic and there’s an incentive to optimize for percentile, the trade-off shifts.
In the meantime, the pattern is in place, the {Maturity} bug
can’t come back (it’s blocked by scripts/validate-mdx.mjs run
in pnpm check), and the next deploy to production has two
checks behind it instead of zero. That’s what changed.