MCP server that audits any public web page for tracking tags, duplicate pixels, page weight, header hygiene, and lab performance, from inside Claude Code or Claude Desktop.
| Screenshot | What it shows |
|---|---|
| smoke-tracking-summary.png | Real npm run smoke -- https://web.dev session: the tools/list handshake and the full tracking_summary report (GTM container found, no consent management, no duplicates). |
| ssrf-refusal.png | The same smoke script pointed at http://localhost:8080: every tool returns a structured SSRF refusal instead of fetching, including the cloud metadata IP 169.254.169.254. |
Both screenshots are captured from real sessions on 2026-07-19 against the current build. The second one is a selling point, not an error state: point any tool at a private, broken, or non-HTML URL and you get a structured, actionable error, never a crash or a hang.
Error: Refusing to fetch "localhost": local targets are blocked (SSRF protection).
Requires Node 20 or newer. From a clean clone:
git clone https://github.com/lpecom/webaudit-mcp.git webaudit-mcp
cd webaudit-mcp
npm install
npm run build # compiles TypeScript into dist/
npm test # 60 tests across 6 files, all offline, ~300 msStarting the server directly prints one line to stderr and then waits for an MCP client on stdio (stop it with Ctrl+C):
node dist/index.js
# webaudit-mcp running on stdio (7 tools registered)Claude Code:
claude mcp add webaudit -- node /absolute/path/to/webaudit-mcp/dist/index.jsClaude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"webaudit": {
"command": "node",
"args": ["/absolute/path/to/webaudit-mcp/dist/index.js"]
}
}
}Then ask: "Audit https://example.com" or "What tracking is installed on web.dev?"
Optional, only needed for measure_performance (lab Core Web Vitals):
npx playwright install chromiumTo see every tool fire against a live site without any MCP client:
npm run smoke -- https://web.devNo API keys, no environment variables, no configuration files.
I have spent years on both sides of this problem: running Google Ads, Meta, and Taboola campaigns, and building the tracking under them myself (GA4, GTM, server-side events). Broken tracking silently burns ad budgets: a duplicate GA4 config doubles every metric, a leftover GTM container re-fires conversions, and nobody notices until the numbers stop making sense. The tools to check for it are scattered across browser extensions, PageSpeed, and manual view-source archaeology.
This server puts the whole check behind one conversational interface, built the way I think an MCP server should be: strict input validation, SSRF-guarded fetching, structured errors instead of crashes, and honest labels on every number (lab vs field, measured vs estimated). The report is written for a developer and a media buyer: which analytics and pixel IDs are installed and where, whether anything double-fires, what the page weighs, and how the response headers look.
All 7 tools take a single url argument (absolute http/https URL) and return Markdown.
| Tool | What it does |
|---|---|
audit_page |
One-shot report: tracking tags, duplicate risks, header hygiene, resource counts. Fast, no browser. |
detect_tracking_tags |
Static HTML scan for GA4, GTM, legacy UA, Google Ads, Meta Pixel, TikTok, LinkedIn Insight, Hotjar, Microsoft Clarity, server-side tagging, and consent platforms. Reports IDs and where each was found. |
find_duplicate_tags |
Flags double-initialized IDs, multiple GTM containers, GTM plus hardcoded GA4 double-fire patterns, duplicate pixel IDs, dead UA tags. |
measure_performance |
Headless-Chromium lab metrics: TTFB, FCP, LCP, CLS, load times, request count, transfer sizes. Clearly labeled lab data. |
analyze_page_weight |
Counts and byte sizes of scripts, styles, images, fonts, and iframes referenced in the HTML; largest third-party scripts. |
check_headers |
Caching, compression, HSTS, CSP, clickjacking, referrer policy. Informational grades with explanations, no synthetic scores. |
tracking_summary |
Plain-language briefing on the tracking setup, written for a media buyer: what is installed, what it means for campaigns, consent posture, issues to raise. |
All example outputs below are real, captured 2026-07-19 against https://web.dev and trimmed for length.
The one call to start with: combines the tracking scan, duplicate check, and header check in a single report.
# Audit of https://web.dev/
- HTTP status: 200
- HTML size: 130.7 KB
- Resources referenced in HTML: 14 (0 scripts, 4 stylesheets, 9 images, 0 fonts, 1 iframes; 5 third-party)
[tracking, duplicate, and header sections follow, same content as the
individual tools below, then pointers to the byte-level and lab tools]
Static scan with evidence: every finding names the vendor, the extracted ID, and the exact place in the HTML that triggered it.
# Tracking tags on https://web.dev/
| Vendor | ID | Found in | Evidence |
|---|---|---|---|
| Google Tag Manager | GTM-MZWCJPP | inline script | `[], "gtm": [{"id": "GTM-MZWCJPP", "purpose": 0}], ...` |
## Distinct IDs
- Google Tag Manager: GTM-MZWCJPP
## Consent signals
- None detected. If this site serves EU users with these tags firing pre-consent,
that is a GDPR/ePrivacy problem.
_Static scan: 6 inline scripts and 0 external script tags inspected. Tags added
at runtime by GTM or a CMP are not visible to this scan._
Checks for the patterns that double-count: the same GA4 ID initialized twice, multiple GTM containers, a hardcoded GA4 tag next to a GTM container, multiple Meta pixel IDs, the gtag.js loader included more than once, and leftover Universal Analytics tags. web.dev is clean:
# Duplicate-tag check for https://web.dev/
No duplicate IDs or double-fire patterns found across 3 detected tag reference(s).
When something is found, each issue comes with a severity: HIGH (double-counting almost certain), MEDIUM (likely misconfiguration), INFO (verify manually).
Loads the page once in headless Chromium and reports what that load observed. Numbers vary run to run; the report says so.
# Lab performance metrics for https://web.dev/
**LAB DATA**: one headless-Chromium load from this machine. Not field data (no CrUX/RUM), no INP.
| Metric | Value |
|---|---|
| TTFB (time to first byte) | 495 ms |
| FCP (first contentful paint) | 764 ms |
| LCP (largest contentful paint) | 1.02 s |
| CLS (cumulative layout shift) | 0.088 |
| Load event | 2.34 s |
| Requests | 60 |
| Transferred (compressed) | 152.9 KB |
[transfer-by-initiator table and reference thresholds follow]
Static resource inventory with real byte measurements (capped, guarded requests; up to 30 resources, HEAD first, GET fallback):
# Page weight for https://web.dev/
- HTML document: 130.7 KB
- Resources referenced: 14 (0 scripts, 4 stylesheets, 9 images, 0 fonts, 1 iframes)
- Measured 14 of 14 resources: 1.42 MB total
Note the honest gap between this and measure_performance above: the static scan sees 14 referenced resources, the browser load makes 60 requests. JavaScript loads the rest, and each report tells you which view you are looking at.
# Header check for https://web.dev/ (HTTP 200)
| Check | Grade | Value | Note |
|---|---|---|---|
| Cache-Control | [OK] | `no-cache, must-revalidate` | Caching policy declared. |
| Compression (Content-Encoding) | [OK] | `gzip` | Response compressed with gzip. |
| HSTS (Strict-Transport-Security) | [OK] | `max-age=63072000; includeSubdomains; preload` | HSTS present with a solid max-age. |
| Referrer-Policy | [MISSING] | n/a | Default referrer behavior leaks full URLs to third parties on some browsers. |
_Grades are informational statements about header presence, not a security score._
The same detection engine, rendered as a briefing for a non-developer. See the hero screenshot above for the full real output; the sections are: What is installed, What this means for media buying, Consent, Issues to raise.
src/
index.ts entry point: McpServer + stdio transport, registers the tools
tools.ts tool registration, zod input schemas, Markdown renderers,
error mapping (every failure becomes an isError result)
lib/
url-guard.ts SSRF checks: scheme, hostname denylist, private IP ranges, DNS
safe-fetch.ts hardened fetch: timeout, byte cap, manual redirects, content type
tracking.ts static tag detection (regex + node-html-parser) and duplicate analysis
page-weight.ts resource extraction and capped byte measurement
headers.ts response-header checks
performance.ts Playwright Chromium lab metrics
format.ts byte/ms formatting and grade markers
SSRF protection is layered. url-guard runs two checks: a synchronous one (http/https only; localhost, .local, .internal, .home.arpa and similar hostnames refused; private, loopback, link-local, and CGNAT IP literals refused, for v4 and v6 including IPv4-mapped forms) and an asynchronous one that resolves the hostname with DNS and refuses if any returned address is private, so a public-looking hostname pointing at 192.168.x.x is also blocked. safe-fetch then follows redirects manually (max 5) and re-runs both checks on every hop, enforces a 15 s wall-clock timeout and a 5 MiB body cap, and rejects non-HTML content types for the HTML tools. Every outbound request in the codebase, including page-weight resource measurements, goes through this one client.
Static analysis vs browser analysis. Six of the seven tools parse the served HTML only: fast, no browser dependency, and honest about the boundary (every report footnotes that runtime-injected tags and JS-loaded resources are invisible to it). measure_performance is the one browser tool: it launches headless Chromium via Playwright, registers buffered PerformanceObservers before navigation, loads the page once, waits 3 s for LCP/CLS to settle, and reads the metrics out.
Graceful degradation without Chromium. Playwright is a devDependency and is imported dynamically only inside measure_performance. If the package or the browser binary is missing, the tool returns a structured error containing the exact fix (npx playwright install chromium) instead of crashing, and the other six tools are unaffected.
npm test runs 60 vitest tests in 6 files, entirely offline (network and DNS are mocked; nothing leaves the process):
url-guard.test.ts: scheme rejection, hostname denylist, private v4/v6 ranges including IPv4-mapped and CGNATsafe-fetch.test.ts: timeouts, byte-cap truncation, manual redirect following and re-validationtracking.test.ts: tag and consent detection plus duplicate analysis against synthetic fixtures (clean.html,duplicates.html,full-stack.html)page-weight.test.ts: resource extraction, third-party heuristic (includingco.uk-style domains), HEAD/GET measurement fallbackheaders.test.ts: grades for present, missing, and weak headerstools.test.ts: a real MCP client calling the real server over the SDK's in-memory transport, covering both success and error paths
npm run smoke -- <url> is the live counterpart: it drives the built stdio server with a real MCP client against a real site, on purpose.
- Never throw from a handler. Every tool wraps its body in try/catch and converts
UrlGuardError,FetchError, andPerformanceUnavailableErrorinto MCPisErrorresults whose message says what to do next. - Reports are Markdown a model can relay verbatim. The renderers produce tables and sections a client model can paste into an answer without rework, and each report ends with an honest footnote about what the scan could not see.
- Bounded everything. 15 s request timeout, 5 MiB response cap, max 5 redirects, 30-resource measurement cap with concurrency 6, 10 s per-resource timeout, content-type enforcement for HTML tools. The numbers live as constants in
safe-fetch.tsandpage-weight.ts. - Honest labels on every number. Lab data is stamped LAB DATA, static scans say they are static, the third-party classifier is documented as a heuristic in the report itself.
- Playwright stays a devDependency so the core install is three runtime dependencies (
@modelcontextprotocol/sdk,node-html-parser,zod) and the browser tool degrades gracefully.
Built with the MCP TypeScript SDK (McpServer + registerTool, zod input schemas, stdio transport). Developed with Claude Code.
- Static scan.
detect_tracking_tags,find_duplicate_tags, andtracking_summaryread the served HTML only. Tags injected at runtime by GTM, a CMP after consent, or client-side routing are invisible to them. The reports say so explicitly. - Lab data, not field data.
measure_performanceis one headless load from your machine: no INP (needs real interaction), no CrUX percentiles, and numbers vary run to run. Treat it as a smoke check, not a Core Web Vitals verdict. - Single page scope. Every tool audits exactly the URL you give it. No crawling.
- Third-party classification is a heuristic. Registrable-domain comparison without a full Public Suffix List;
foo.co.ukstyle domains are handled, exotic suffixes may misclassify. - Transfer sizes can read 0 B. Cross-origin resources without
Timing-Allow-Originhide their size from the browser; page-weight measurements reflect what this client was served, which may differ from what your browser gets. - Bot protection. Sites behind aggressive bot walls (Cloudflare challenges and similar) may serve this tool different HTML than they serve a real browser.
MIT license, see LICENSE.