webaudit-mcp
# webaudit-mcp
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](docs/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](docs/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).
```
## Quickstart
Requires Node 20 or newer. From a clean clone:
```bash
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 ms
```
Starting the server directly prints one line to stderr and then waits for an MCP client on stdio (stop it with Ctrl+C):
```bash
node dist/index.js
# webaudit-mcp running on stdio (7 tools registered)
```
**Claude Code:**
```bash
claude mcp add webaudit -- node /absolute/path/to/webaudit-mcp/dist/index.js
```
**Claude Desktop** (`claude_desktop_config.json`):
```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):
```bash
npx playwright install chromium
```
To see every tool fire against a live site without any MCP client:
```bash
npm run smoke -- https://web.dev
```
No API keys, no environment variables, no configuration files.
## Why I built this
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.
## Tool reference
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.
### audit_page
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]
```
### detect_tracking_tags
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._
```
### find_duplicate_tags
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).
### measure_performance
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]
```
### analyze_page_weight
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.
### check_headers
```
# 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._
```
### tracking_summary
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.
## How it works
```
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.
## Tests
`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 CGNAT
- `safe-fetch.test.ts`: timeouts, byte-cap truncation, manual redirect following and re-validation
- `tracking.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 (including `co.uk`-style domains), HEAD/GET measurement fallback
- `headers.test.ts`: grades for present, missing, and weak headers
- `tools.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.
## Design decisions
- **Never throw from a handler.** Every tool wraps its body in try/catch and converts `UrlGuardError`, `FetchError`, and `PerformanceUnavailableError` into MCP `isError` results 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.ts` and `page-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](https://github.com/modelcontextprotocol/typescript-sdk) (`McpServer` + `registerTool`, zod input schemas, stdio transport). Developed with Claude Code.
## Limitations
- **Static scan.** `detect_tracking_tags`, `find_duplicate_tags`, and `tracking_summary` read 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_performance` is 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.uk` style domains are handled, exotic suffixes may misclassify.
- **Transfer sizes can read 0 B.** Cross-origin resources without `Timing-Allow-Origin` hide 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.
## License
MIT license, see [LICENSE](LICENSE).
TDQS
Scored across 7 tools
Tools are mostly distinct with clear roles: audit_page is a composite overview while the others go deeper on specific aspects. The descriptions explicitly cross-reference each other, reducing ambiguity, though audit_page does overlap with several specialized tools.
Most names follow verb_noun convention (audit_page, detect_tracking_tags, find_duplicate_tags, measure_performance, analyze_page_weight, check_headers). tracking_summary breaks the pattern by being a noun phrase, causing a minor inconsistency.
Seven tools is well-scoped for a web audit server, covering tracking, performance, headers, and weight without redundancy or bloat. Each tool has a distinct purpose and contributes to the overall workflow.
The set covers the core audit lifecycle: detection, duplicate analysis, performance, page weight, headers, and a summary. Minor gaps exist (e.g., no field performance data or cookie inspection), but the provided tools handle the primary use cases effectively.