Skip to content
← All posts
Blog Meta · Tooling

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 --noEmit passed — MDX isn’t TypeScript.
  • astro check passed — 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 build passed — 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.mjs and pnpm audit:prod for the cheap, fast public-markup check.
  • An audit doc in docs/audits/2026-06-10-prod-phase-11-validation.md with 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.

Did the post resonate?

If this resonates, there's more in the playbooks and the lab's products. Or book a conversation to discuss anything written here.