adev.build
Adem replies within the hourStart a project

One engine, three doors: building a site scanner AI agents can use

November 1, 2026, in build notes

I wanted three things that looked like separate projects: free tools on this site that check a store’s tracking, accessibility and speed; an API those tools could call; and an MCP server, so Claude and other AI agents could run the same audits in a conversation.

They’re one project. The checks are the hard part, and all three “products” are thin doors into the same engine. This note covers how it’s put together, and the two problems that took the most care: not letting strangers point a headless browser at my own network, and not giving confident scores for pages the scanner never actually saw.

The shape of it

scanner/
  src/url.js          what the visitor typed -> a URL that is safe to visit
  src/browser.js      one shared headless Chromium, a fresh context per scan
  src/checks/
    tracking.js       load the page, record every request, classify them
    tracking-rules.js pure functions: request -> tool, tools -> issues
    accessibility.js  run axe-core in the page, translate the results
    speed.js          Core Web Vitals from the PageSpeed Insights API
  src/server.js       HTTP API for the website's tools
  src/mcp.js          MCP server exposing the same checks to AI agents
  src/cli.js          for me, in a terminal

Every door calls the same function: scan(check, url). The API adds rate limits and caching, and the MCP server adds tool descriptions an agent can read. Neither knows how a check works.

Tracking: read the network, not the page source

The naive way to check tracking is to search the HTML for gtag( or fbq(. It’s wrong in both directions. Tags installed through Google Tag Manager don’t appear in the source, and code that’s present can still fail to fire.

So the tracking check loads the page in Playwright, scrolls once like a reader would, waits a few seconds and records every request the page makes. Each request goes through a small classifier:

if (/\/g\/collect$/.test(path) && param(url, 'v') === '2') {
  return { tool: 'ga4', kind: 'hit', id: param(url, 'tid'), event: param(url, 'en'),
           firstParty: !host.endsWith('google-analytics.com') };
}

That last field matters. GA4 hits sent to the store’s own domain mean server-side tagging is in place, which recovers the sales that ad blockers and iOS privacy features hide.

Turning hits into advice is a second, separate step, and it’s pure: no browser, so it’s easy to test. Two GA4 page_view hits for one visit means GA4 is installed twice. Two Meta Pixel loaders means duplicate purchase events, which quietly inflates what Meta reports. A Google tag with no consent mode is a problem for any store selling into the EU or UK.

Consent mode needed a second look. My first version only searched dataLayer for gtag('consent', 'default', …). On a large real store that flagged “no consent mode” when consent mode was clearly working, because the consent platform set it a different way. Google’s own tag keeps the consent state in window.google_tag_data.ics, and checking that removed the false alarm. Testing on real sites, not just fixtures, is what caught it.

Accessibility: axe-core, translated

The accessibility check injects axe-core into the page and runs the WCAG 2.0 to 2.2 A and AA rules. axe is excellent; its output is written for developers. The report a business owner sees has to say what is broken, how many times, and which WCAG criterion it fails, in that order of importance.

So the check sorts by impact, then by how often an issue appears, and turns tags like wcag1410 into “WCAG 1.4.10, level AA”. It also says, every time, that automated checks find roughly a third of real accessibility problems. A clean automated score is not compliance, and a report that implies otherwise is doing the owner a disservice.

One snag: strict Content Security Policies block injected scripts, so axe never ran on some sites. Playwright’s bypassCSP context option is designed for exactly this: audit tooling running its own code in the page.

Not getting pointed at my own network

A tool that visits any URL a stranger types is a classic server-side request forgery risk. Point it at 169.254.169.254 and, on many cloud hosts, it reads the machine’s own credentials. Point it at localhost:6379 and it talks to internal services.

There are two layers of protection:

  1. Before the scan. The address is parsed and normalised, and the host is resolved. If any address it resolves to is private, loopback, link-local, carrier-grade NAT, multicast or reserved, the scan is refused with a plain message. IPv4-mapped IPv6 addresses like ::ffff:10.0.0.1 are unwrapped and checked too.
  2. During the scan. A public page can still redirect, or load an image from an internal address. So every request the browser makes is intercepted and checked the same way, and private destinations are aborted.
await context.route('**/*', async (route) => {
  const { hostname } = new URL(route.request().url());
  if (await hostIsPrivate(hostname)) return route.abort('blockedbyclient');
  return route.continue();
});

On top of that: a fresh browser context per scan, service workers blocked, a hard timeout, a per-visitor hourly limit, and at most two scans at once.

Don’t score what you didn’t see

My first accessibility test target was a well-known demo page full of deliberate errors. The scanner reported one issue. The page sits behind a bot check, and the scanner had dutifully audited the “checking your browser” screen.

The fix is a check after loading, for challenge-page titles, challenge iframes and near-empty pages built around a captcha. When it trips, the scanner says the site’s bot protection blocked the check and that a manual audit can still cover it. A free tool that confidently scores the wrong page is worse than one that admits it couldn’t look.

The MCP door

With scan() in place, the MCP server is about forty lines. It registers three tools (check_tracking, check_accessibility, check_speed), each taking a URL, with descriptions written for the model, saying what the tool checks and what it can’t. Then it connects over stdio:

server.tool('check_tracking', 'Load a web page like a visitor and report which analytics and ad tracking fire…',
  { url: z.string() }, ({ url }) => run('tracking', url));

In Claude, that turns “check the tracking on myshop.com and tell me what’s double counted” into one tool call and a readable answer. The agent gets the same structured report as the website, so it can reason about it instead of guessing from HTML.

What I’d tell a team building their own

  • Put the hard logic in pure functions. Classifying requests and deciding what counts as a problem are where the bugs live, and they can be tested without a browser.
  • Test on real sites early. Fixtures said consent detection worked; a real store said otherwise.
  • Treat “visit this URL” as hostile input, including every redirect and sub-request, not just the first address.
  • Say what the tool can’t know. Coverage limits, blocked pages and missing data belong in the report, not in a footnote.

You can try the tracking checker, the accessibility scanner and the speed check on your own site. If you’d like an MCP server built for your product, so AI agents can work with it safely, that’s a service I offer.

Need this built?

Tell me what you need built.

You'll get a reply within 1 business hour, from me, with questions or a first plan and price.