nfunc-mcp 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +121 -379
  2. package/dist/index.js +4 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/mappers/a11yDedupe.js +38 -7
  5. package/dist/mappers/a11yDedupe.js.map +1 -1
  6. package/dist/mappers/defectFormatter.d.ts +9 -1
  7. package/dist/mappers/defectFormatter.js +53 -13
  8. package/dist/mappers/defectFormatter.js.map +1 -1
  9. package/dist/mappers/labFieldComparator.d.ts +62 -0
  10. package/dist/mappers/labFieldComparator.js +134 -0
  11. package/dist/mappers/labFieldComparator.js.map +1 -0
  12. package/dist/mappers/priorityMapper.d.ts +42 -0
  13. package/dist/mappers/priorityMapper.js +58 -0
  14. package/dist/mappers/priorityMapper.js.map +1 -1
  15. package/dist/mappers/psiAggregator.d.ts +130 -0
  16. package/dist/mappers/psiAggregator.js +293 -0
  17. package/dist/mappers/psiAggregator.js.map +1 -0
  18. package/dist/mappers/runComparator.d.ts +85 -0
  19. package/dist/mappers/runComparator.js +165 -0
  20. package/dist/mappers/runComparator.js.map +1 -0
  21. package/dist/mappers/wcagLevels.d.ts +73 -0
  22. package/dist/mappers/wcagLevels.js +320 -0
  23. package/dist/mappers/wcagLevels.js.map +1 -0
  24. package/dist/mappers/webVitalsMapper.d.ts +52 -0
  25. package/dist/mappers/webVitalsMapper.js +131 -0
  26. package/dist/mappers/webVitalsMapper.js.map +1 -0
  27. package/dist/tools/accessibility.d.ts +1 -0
  28. package/dist/tools/accessibility.js +488 -63
  29. package/dist/tools/accessibility.js.map +1 -1
  30. package/dist/tools/lighthouse.js +370 -102
  31. package/dist/tools/lighthouse.js.map +1 -1
  32. package/dist/tools/performanceAudit.d.ts +2 -0
  33. package/dist/tools/performanceAudit.js +446 -0
  34. package/dist/tools/performanceAudit.js.map +1 -0
  35. package/dist/tools/performanceAuditPlan.d.ts +2 -0
  36. package/dist/tools/performanceAuditPlan.js +438 -0
  37. package/dist/tools/performanceAuditPlan.js.map +1 -0
  38. package/dist/utils/batchState.d.ts +75 -0
  39. package/dist/utils/batchState.js +128 -0
  40. package/dist/utils/batchState.js.map +1 -0
  41. package/dist/utils/csvReader.d.ts +20 -0
  42. package/dist/utils/csvReader.js +172 -0
  43. package/dist/utils/csvReader.js.map +1 -0
  44. package/dist/utils/httpClient.d.ts +84 -0
  45. package/dist/utils/httpClient.js +171 -0
  46. package/dist/utils/httpClient.js.map +1 -0
  47. package/dist/utils/outputParsers.js +26 -30
  48. package/dist/utils/outputParsers.js.map +1 -1
  49. package/dist/utils/psiAuth.d.ts +26 -0
  50. package/dist/utils/psiAuth.js +36 -0
  51. package/dist/utils/psiAuth.js.map +1 -0
  52. package/dist/utils/psiParser.d.ts +135 -0
  53. package/dist/utils/psiParser.js +200 -0
  54. package/dist/utils/psiParser.js.map +1 -0
  55. package/dist/utils/publicUrl.d.ts +17 -0
  56. package/dist/utils/publicUrl.js +115 -0
  57. package/dist/utils/publicUrl.js.map +1 -0
  58. package/dist/utils/sitemapReader.d.ts +27 -0
  59. package/dist/utils/sitemapReader.js +272 -0
  60. package/dist/utils/sitemapReader.js.map +1 -0
  61. package/dist/utils/urlClassifier.d.ts +45 -0
  62. package/dist/utils/urlClassifier.js +267 -0
  63. package/dist/utils/urlClassifier.js.map +1 -0
  64. package/dist/utils/urlInput.d.ts +30 -0
  65. package/dist/utils/urlInput.js +130 -0
  66. package/dist/utils/urlInput.js.map +1 -0
  67. package/docs/manual.md +769 -0
  68. package/docs/psi-report-spec.md +174 -0
  69. package/package.json +13 -3
package/docs/manual.md ADDED
@@ -0,0 +1,769 @@
1
+ # nfunc-mcp — operating manual
2
+
3
+ Everything past the quick start: installation options, per-tool reference,
4
+ output shapes, and troubleshooting. The [README](../README.md) covers what the
5
+ tools are and how to ask for them; this covers how to run and interpret them.
6
+
7
+ **Contents**
8
+
9
+ 1. [Prerequisites](#prerequisites)
10
+ 2. [Install and register](#install-and-register)
11
+ 3. [`run_qa_gate` reference](#run_qa_gate-reference)
12
+ 4. [Individual tool reference](#individual-tool-reference)
13
+ - [Auditing several URLs at once](#auditing-several-urls-at-once)
14
+ - [Comparing two runs (before vs after)](#comparing-two-runs-before-vs-after)
15
+ 5. [PSI performance audit](#psi-performance-audit)
16
+ 6. [Priority system](#priority-system)
17
+ 7. [Project layout](#project-layout)
18
+ 8. [Troubleshooting](#troubleshooting)
19
+
20
+ ---
21
+
22
+ ## Prerequisites
23
+
24
+ Four of the tools wrap CLIs. Install the ones you need:
25
+
26
+ | Tool | Install | Used by |
27
+ |---|---|---|
28
+ | Lighthouse | `npm install -g lighthouse` | `run_lighthouse`, `run_qa_gate` (URL) |
29
+ | pa11y | `npm install -g pa11y` | `run_accessibility_check`, `run_qa_gate` (URL) |
30
+ | ESLint | `npm install -g eslint` | `run_static_analysis`, `run_qa_gate` (path) |
31
+ | Semgrep | `brew install semgrep` or `pip install semgrep` | `run_static_analysis`, `run_qa_gate` (path) |
32
+
33
+ Verify:
34
+
35
+ ```bash
36
+ lighthouse --version && pa11y --version && eslint --version && semgrep --version
37
+ ```
38
+
39
+ **You don't need all four.** A missing tool shows `UNAVAILABLE` in the
40
+ scorecard and its findings are skipped; the gate still runs. URL-only runs need
41
+ Lighthouse and pa11y; path-only runs need ESLint and Semgrep.
42
+
43
+ ### PageSpeed Insights API key
44
+
45
+ The PSI tools call an HTTP API rather than a CLI, but they want a key:
46
+
47
+ 1. Enable the **PageSpeed Insights API** in the Google Cloud console.
48
+ 2. Create an API key, restricted to that API.
49
+ 3. Put it in the `env` block of your MCP client config — the client launches
50
+ this server, so it owns the environment:
51
+
52
+ ```bash
53
+ claude mcp remove nfunc-mcp -s local
54
+ claude mcp add nfunc-mcp -s local -e PAGESPEED_API_KEY=your_key -- npx -y nfunc-mcp
55
+ ```
56
+
57
+ Confirm with any `plan_performance_audit` call: the response carries
58
+ `api_key: { present: true, source: "env" }` and never the key itself.
59
+
60
+ **Without a key the tools still load but are capped at 4 runs.** The shared
61
+ anonymous quota is exhausted in practice — expect `429 Quota exceeded` on the
62
+ first real request. A key gives 25,000 requests/day.
63
+
64
+ An `api_key` tool input also works but is discouraged: it lands in the
65
+ conversation transcript and in client logs. It is redacted from every error
66
+ message either way.
67
+
68
+ ---
69
+
70
+ ## Install and register
71
+
72
+ ### Option A — npm (recommended)
73
+
74
+ ```bash
75
+ claude mcp add nfunc-mcp -- npx -y nfunc-mcp
76
+ ```
77
+
78
+ `npx` fetches and runs the server. No clone, no build.
79
+
80
+ ### Option B — manual config
81
+
82
+ In `~/.claude.json` under `mcpServers`:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "nfunc-mcp": {
88
+ "command": "npx",
89
+ "args": ["-y", "nfunc-mcp"],
90
+ "env": { "PAGESPEED_API_KEY": "your_key" }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ ### Option C — from source (contributors)
97
+
98
+ ```bash
99
+ git clone https://github.com/Hiddensound/NFunc_MCP.git
100
+ cd NFunc_MCP
101
+ npm install
102
+ npm run build
103
+ claude mcp add nfunc-mcp -- node /absolute/path/to/NFunc_MCP/dist/index.js
104
+ ```
105
+
106
+ | Script | Purpose |
107
+ |---|---|
108
+ | `npm run build` | Compile TypeScript → `dist/` |
109
+ | `npm start` | Run the compiled server |
110
+ | `npm run dev` | Run from source with hot reload (`tsx watch`) |
111
+
112
+ For local development, registering `tsx src/index.ts` instead of
113
+ `dist/index.js` means edits need only an MCP reconnect, not a rebuild.
114
+
115
+ ### Verify
116
+
117
+ 1. `/mcp` in Claude Code — `nfunc-mcp` shows as connected.
118
+ 2. Ask: *"Call the nfunc-mcp ping tool."*
119
+ 3. Expect `{ "status": "ok", "timestamp": "..." }`
120
+
121
+ After changing the server's code or environment, reconnect via `/mcp` — the
122
+ running process does not pick up changes on its own.
123
+
124
+ ---
125
+
126
+ ## `run_qa_gate` reference
127
+
128
+ ### Inputs
129
+
130
+ Both `url` and `path` are optional; at least one is required.
131
+
132
+ | Input | Type | When to provide |
133
+ |---|---|---|
134
+ | `url` | string (URL) | A running page — production, staging, preview, or localhost. Enables Lighthouse and pa11y. |
135
+ | `path` | string (path) | A local codebase. Enables ESLint and Semgrep. |
136
+ | `context` | string | Optional free-text description (e.g. `"React e-commerce checkout"`). Helps interpretation. |
137
+ | `form_factor` | `mobile` \| `desktop` \| `both` | Optional, default `mobile`. See [mobile vs desktop](#mobile-vs-desktop). |
138
+ | `a11y_runner` | `htmlcs` \| `axe` \| `both` | Optional, default `htmlcs`. See [choosing an engine](#choosing-an-accessibility-engine). |
139
+
140
+ ### Release readiness tiers
141
+
142
+ | Value | Meaning | Condition |
143
+ |---|---|---|
144
+ | `BLOCKED` | Cannot ship | Any P1 finding |
145
+ | `CONDITIONAL` | Shippable with caveats | P2 findings, no P1s |
146
+ | `ADVISORY` | Safe to ship; P3s are tech debt | Only P3 findings |
147
+ | `CLEAR` | No issues detected | Zero findings |
148
+
149
+ ### Composite score
150
+
151
+ A single 0–100 health measure. Start at 100 and deduct: **P1 −15, P2 −7,
152
+ P3 −3**, floored at 0. Most useful as a trend line across sprints rather than
153
+ as an absolute grade.
154
+
155
+ ### Scorecard
156
+
157
+ One line per tool:
158
+
159
+ ```json
160
+ [
161
+ { "tool": "Lighthouse", "gate": "WARN", "score": 75,
162
+ "breakdown": { "performance": 52, "accessibility": 98, "seo": 100, "best-practices": 58 } },
163
+ { "tool": "pa11y", "gate": "PASS", "issues": 0 },
164
+ { "tool": "ESLint / Semgrep", "gate": "SKIPPED" }
165
+ ]
166
+ ```
167
+
168
+ | Gate | Meaning |
169
+ |---|---|
170
+ | `PASS` | No issues at this tool's threshold |
171
+ | `WARN` | Issues below the FAIL threshold |
172
+ | `FAIL` | P1-level issues (or Lighthouse average < 50) |
173
+ | `SKIPPED` | Input not provided |
174
+ | `UNAVAILABLE` | Tool invoked but not installed, or errored |
175
+
176
+ ### Cross-tool corroboration
177
+
178
+ When Lighthouse and pa11y independently flag the same accessibility gap, the
179
+ findings are **merged** into `corroborated_findings`, **promoted one tier**
180
+ (P3→P2, P2→P1), and annotated `confidence: "high"` with
181
+ `confirmed_by: ["lighthouse", "pa11y"]`.
182
+
183
+ Two independent tools agreeing is stronger evidence than either alone, so these
184
+ appear above all other findings and bubble to the top of `top_issues`.
185
+
186
+ **Rule 1 — accessibility mapping:**
187
+
188
+ | Lighthouse audit | pa11y technique |
189
+ |---|---|
190
+ | `color-contrast` | `.G18`, `.G145`, `.G174` |
191
+ | `image-alt` | `.H37`, `.H67`, `.F65` |
192
+ | `label` | `.H44`, `.F68`, `.H91.Input` |
193
+ | `link-name` | `.H30`, `.H91.A.` |
194
+ | `html-has-lang` | `.H57` |
195
+ | `button-name` | `.H91.Button` |
196
+ | *(and more)* | |
197
+
198
+ **Rule 2 — performance ↔ code:** when a Lighthouse performance finding's
199
+ display value contains a filename that also appears in a static analysis
200
+ finding, the static finding is attached as `related_findings`. They are linked
201
+ by reference, not merged.
202
+
203
+ ### HTML report
204
+
205
+ Every call writes a self-contained HTML file to `/tmp` and returns its path as
206
+ `report_file`. It contains the readiness banner, a composite-score gauge,
207
+ the scorecard, cross-confirmed findings, and collapsible finding cards grouped
208
+ by priority. Inline CSS, no server needed.
209
+
210
+ ### Output shape
211
+
212
+ ```jsonc
213
+ {
214
+ "release_readiness": "BLOCKED", // BLOCKED | CONDITIONAL | ADVISORY | CLEAR
215
+ "composite_score": 22, // 0–100
216
+ "report_file": "file:///tmp/qa-report-xxx.html",
217
+ "scorecard": [ ... ],
218
+ "eslint_config_used": "project", // only when path was supplied
219
+ "summary": "110 findings (101 P1, 9 P2) across 2 tools. ...",
220
+ "corroborated_findings": [ ... ], // cross-confirmed, confidence: "high"
221
+ "top_issues": [ ... ], // top 3 (corroborated first)
222
+ "all_findings": [ ... ], // all, sorted by priority
223
+ "correlations_found": 1,
224
+ "errors": [ ... ] // only if a tool errored
225
+ }
226
+ ```
227
+
228
+ Each finding:
229
+
230
+ ```jsonc
231
+ {
232
+ "priority": "P1",
233
+ "title": "Largest Contentful Paint",
234
+ "description": "Users see main content 34s after navigation...",
235
+ "evidence": { "audit_id": "largest-contentful-paint", "value": "34.3 s" },
236
+ "source_tool": "lighthouse",
237
+ // corroborated findings also carry:
238
+ "confirmed_by": ["lighthouse", "pa11y"],
239
+ "confidence": "high"
240
+ }
241
+ ```
242
+
243
+ ---
244
+
245
+ ## Individual tool reference
246
+
247
+ ### `run_lighthouse`
248
+
249
+ Returns `url`, `form_factor`, `scores` per category, `ttfb_ms`, and
250
+ priority-ordered `findings`.
251
+
252
+ #### Mobile vs desktop
253
+
254
+ `form_factor` accepts `desktop` (default), `mobile`, or `both`.
255
+
256
+ **This default deliberately differs from the Lighthouse CLI's**, which is
257
+ mobile: a 412×823 screen, mid-range Android user agent, simulated slow 4G, and
258
+ a **4× CPU slowdown**. That profile reports substantially lower performance
259
+ scores for the same page, so `mobile` here is not a like-for-like comparison
260
+ with a default CLI run — check `form_factor` in the response before comparing
261
+ two reports.
262
+
263
+ **The two are not interchangeable.** They render different DOM, so they find
264
+ different defects, not just different numbers. On one commerce category page:
265
+
266
+ | | Mobile | Desktop |
267
+ |---|---|---|
268
+ | performance | 54 | 62 |
269
+ | accessibility | **87** | **73** |
270
+ | seo | 77 | 69 |
271
+
272
+ Five accessibility audits failed on desktop that mobile never reported —
273
+ `image-alt`, `aria-required-children`, `aria-required-parent`,
274
+ `aria-allowed-attr`, `aria-valid-attr-value` — while three others failed only
275
+ on mobile. Neither profile is a superset of the other.
276
+
277
+ With `both`, the two run concurrently (little more wall time than one), `scores`
278
+ is keyed by form factor, and each finding carries `affects_form_factors` and
279
+ `form_factor_specific`.
280
+
281
+ ### `run_accessibility_check`
282
+
283
+ pa11y at WCAG 2 AA by default, violations only. Returns `url`, `standard`,
284
+ `runners`, `violation_count`, `raw_violation_count`, `findings`.
285
+
286
+ `raw_violation_count` versus `violation_count` shows the dedup at work: a rule
287
+ failing on more than two elements collapses into one systemic finding carrying
288
+ `distinct_elements` and the full `sample_selectors` list, rather than one line
289
+ per element. Three gallery images with no alt text are one template to fix, not
290
+ three authoring mistakes.
291
+
292
+ This matters across pages as much as within one. Before the threshold was
293
+ lowered, a homepage with 11 duplicate ids collapsed to a single finding while a
294
+ category page with 10 listed every one — the same component, but one page
295
+ appeared four times worse. Collapsing consistently is what makes per-page counts
296
+ comparable at all.
297
+
298
+ #### WCAG conformance and priority
299
+
300
+ Findings are priced by what they cost a **conformance claim**, not by how bad
301
+ the defect feels. `target_level` names the level the project has committed to —
302
+ default `AA`, the legal and industry bar for essentially all commercial work.
303
+
304
+ | Finding | Priority | Why |
305
+ |---|---|---|
306
+ | Level **A** criterion fails | **P1** | The floor. While any Level A criterion fails, no higher level is achievable — AA conformance is impossible regardless of how the AA-specific criteria score |
307
+ | Level **AA** criterion fails | **P2** | Blocks an AA commitment |
308
+ | Criterion **above** the target | **P3** | An enhancement, not a gap. `target-size` is 2.5.5, Level **AAA** in WCAG 2.1 — it should not fail an AA audit |
309
+ | Not a success criterion | **P3** | A best-practice rule. Worth fixing; does not affect a conformance claim |
310
+
311
+ Two demotions apply after that: axe's `needsFurtherReview` (a maybe should not
312
+ gate a release as hard as a certainty) and an axe impact of `minor` — the "very
313
+ minor AA issue" tier.
314
+
315
+ Every finding carries `wcag_criterion`, `wcag_name`, `wcag_level` and
316
+ `blocks_target` in its evidence, and its description states what the failure
317
+ means for the claim.
318
+
319
+ Each run also returns a `conformance` block, and a batch adds a cross-page
320
+ rollup:
321
+
322
+ ```jsonc
323
+ "conformance": {
324
+ "target_level": "AA",
325
+ "conformant": false,
326
+ "failing_criteria": { "A": 6, "AA": 1, "AAA": 0 },
327
+ "beyond_target": 0,
328
+ "failed_criteria": [
329
+ { "criterion": "4.1.1", "name": "Parsing", "level": "A", "findings": 5, "blocks_target": true }
330
+ ],
331
+ "summary": "Not Level AA conformant. 6 Level A criteria fail... Level A is the floor..."
332
+ }
333
+ ```
334
+
335
+ **The unit is the criterion, not the finding.** Twelve findings against one
336
+ criterion is one thing to fix and one line in a conformance statement. The
337
+ finding count answers "how much work"; the criterion count answers "are we
338
+ conformant". A report giving only the first is how a page ends up described as
339
+ having 22 accessibility issues when it fails five criteria.
340
+
341
+ Criterion levels are transcribed from
342
+ [WCAG 2.1](https://www.w3.org/TR/WCAG21/). htmlcs encodes the criterion in its
343
+ rule code; axe does not expose WCAG tags through pa11y, so its rule ids go
344
+ through a lookup table in `src/mappers/wcagLevels.ts`. Rules axe classifies as
345
+ best-practice map to no criterion deliberately — reporting one as a conformance
346
+ failure would overstate the legal position.
347
+
348
+ **Automated testing reaches roughly a third of WCAG criteria.** A `conformant:
349
+ true` result means nothing automated failed, not that the page conforms. Focus
350
+ order, keyboard traps, meaningful sequence, error suggestion and content on
351
+ hover all need a human.
352
+
353
+ #### Choosing an accessibility engine
354
+
355
+ | Engine | Strongest at | Severity source |
356
+ |---|---|---|
357
+ | `htmlcs` (default) | WCAG techniques, document structure, form labelling, duplicate ids | WCAG technique class |
358
+ | `axe` | **ARIA** — invalid roles, missing required parent/child relationships, prohibited and unsupported attributes — and computed colour contrast | axe's own `impact` |
359
+
360
+ **Reach for `axe` whenever the work involves ARIA, a component library, or a
361
+ design system.** The overlap is smaller than expected. On the same page:
362
+
363
+ - htmlcs found unlabelled inputs, forms with no submit mechanism, and ten
364
+ duplicate ids that axe did not report.
365
+ - axe found `aria-allowed-attr`, `aria-prohibited-attr`,
366
+ `aria-required-parent`, `aria-required-children` and `image-alt` failures
367
+ htmlcs missed entirely.
368
+
369
+ `both` runs them concurrently and merges. An element flagged by both appears
370
+ twice, because they emit different rule codes — deliberate, since two engines
371
+ agreeing is corroboration worth seeing.
372
+
373
+ axe findings carry `axe_impact`, and `needs_manual_review: true` where axe
374
+ wants human confirmation. Those are demoted one tier: a maybe should not gate a
375
+ release as hard as a certainty.
376
+
377
+ ### `run_static_analysis`
378
+
379
+ ESLint and Semgrep in parallel against a local directory. Uses the project's own
380
+ ESLint config when it finds one, otherwise a QA-focused baseline. Returns
381
+ `path`, `tools_run`, `eslint_config_used`, `issue_count`, `findings`,
382
+ `warnings`.
383
+
384
+
385
+ ### Auditing several URLs at once
386
+
387
+ `run_lighthouse` and `run_accessibility_check` both accept three input shapes in
388
+ the same `url` field, and detect which they were given:
389
+
390
+ | You pass | Detected as |
391
+ |---|---|
392
+ | `https://site.com/page` | a single URL — one report, returned immediately |
393
+ | `https://a.com, https://b.com` (or newline-separated) | a list — batch mode |
394
+ | `./top-pages.csv` | a CSV — the URL column is found by name or by content |
395
+
396
+ A bare domain gets `https://` assumed, duplicates are dropped, and unparseable
397
+ entries are reported rather than silently skipped. An explicit `urls` array
398
+ works too.
399
+
400
+ **One URL behaves exactly as before** — same response shape, no batch fields.
401
+ Several URLs switch to batch mode:
402
+
403
+ - Each call is bounded by `max_seconds_per_call` (default **100 s**, chosen to
404
+ stay under the 120 s at which Claude Code backgrounds a tool call) and returns
405
+ a `cursor`. Keep calling until `complete` is true.
406
+ - Every report is written to `output_dir` as it lands — the **raw** Lighthouse
407
+ LHR, so the individual audits survive — and merged into a running
408
+ `_index.json`.
409
+ - **Re-run to fill gaps.** Call again with the same input and *no cursor*;
410
+ completed URL/variant pairs are skipped automatically. `skip_completed: false`
411
+ forces fresh measurements.
412
+ - The final call adds an `aggregate` block. Quote its numbers rather than
413
+ recomputing them.
414
+
415
+ Lighthouse runs **sequentially** in batch mode, unlike the single-URL path.
416
+ Two Chrome instances on one machine contend for CPU, and a performance audit
417
+ whose numbers came from a half-busy machine is not worth having. pa11y still
418
+ runs its engines concurrently — it is not measuring time.
419
+
420
+ #### What the aggregates tell you
421
+
422
+ `run_lighthouse` returns per-strategy means, a **per-template rollup** (the same
423
+ classifier the PSI plan tool uses, so twelve product URLs report as "PDP average
424
+ 61" rather than as twelve rows), CWV verdict tallies, and outlier detection
425
+ against the median.
426
+
427
+ `run_accessibility_check` returns something a single-page run cannot: **which
428
+ rules fail across most pages**. A rule failing on 80%+ of pages is marked
429
+ `shared_layout: true` — it lives in the header, footer or base template, so one
430
+ fix clears every page. A rule failing on one page is that page's own bug. On a
431
+ four-page sample, `color-contrast` hit 4/4 while `image-alt` and `link-name` hit
432
+ 1/4: two completely different pieces of work, and volume alone cannot separate
433
+ them.
434
+
435
+ #### Mobile accessibility
436
+
437
+ `form_factor` on `run_accessibility_check` defaults to `desktop` and accepts
438
+ `mobile` or `both`. Mobile emulates 412×823 at 2× DPR with touch, matching
439
+ `run_lighthouse`'s mobile profile so the two describe the same rendered page.
440
+
441
+ The viewport genuinely applies — a screenshot from the mobile run measures
442
+ 824×23418 against 1280×2418 for the default. **But temper expectations:** on a
443
+ test site, neither htmlcs nor axe reported a single different violation between
444
+ the two viewports, because the rules both engines run here are structural —
445
+ missing labels, duplicate ids, absent alt text — and structure does not change
446
+ with width. It earns its keep on sites whose mobile DOM genuinely differs (a
447
+ hamburger nav, different components rendered), which is common on real commerce
448
+ sites.
449
+
450
+ For viewport-*dependent* accessibility defects today, `run_lighthouse` with
451
+ `form_factor: "both"` is the stronger tool: it reported `target-size` tagged
452
+ `form_factor_specific: true`, a touch-target failure that exists only on mobile
453
+ and that pa11y did not surface at all.
454
+
455
+
456
+ ### Comparing two runs (before vs after)
457
+
458
+ A snapshot answers "what is wrong with this page". A developer about to open a
459
+ PR is asking something else: *did my fix work, and did I break anything?* A
460
+ violation count cannot separate those.
461
+
462
+ Both tools take `baseline_dir`. Point it at an earlier `output_dir`:
463
+
464
+ ```
465
+ # 1. capture a baseline before touching anything
466
+ Run an accessibility check on http://localhost:3000, save to ./a11y-base
467
+
468
+ # 2. make the fix, then re-scan against it
469
+ Scan http://localhost:3000 again and compare to ./a11y-base
470
+ ```
471
+
472
+ The response gains a `comparison` block:
473
+
474
+ ```jsonc
475
+ "comparison": {
476
+ "verdict": "mixed", // clean | improved | mixed | regression | unchanged
477
+ "summary": "5 defect(s) fixed, but 1 newly introduced (worst: P2). Total went 6 to 2; the drop is real but incomplete — check newly_introduced before treating this as a clean fix.",
478
+ "fixed": [{ "id": "image-alt", "was": "P1", ... }],
479
+ "still_failing": [{ "id": "color-contrast", "priority": "P3", ... }],
480
+ "newly_introduced": [{ "id": "aria-valid-attr-value", "priority": "P2", ... }],
481
+ "score_changes": [{ "category": "accessibility", "before": 94, "after": 64, "delta": -30 }]
482
+ }
483
+ ```
484
+
485
+ `newly_introduced` is the half that earns this feature. Measured on a real
486
+ page: adding `alt` text, an `aria-label` and a `<label>` fixed **five P1s** and
487
+ introduced one **P2**, because the `aria-labelledby` also added pointed at an id
488
+ that did not exist. A Lighthouse run against a page given a render-blocking
489
+ script reported `regression` with accessibility **94 → 64** and named all four
490
+ injected defects.
491
+
492
+ Notes on how it behaves:
493
+
494
+ - **This works with a single URL**, not only batches. A single-URL run touches
495
+ disk only when `output_dir` is given, so a one-off check stays a one-off while
496
+ the same call can seed a baseline.
497
+ - **`localhost` is fully supported** — pa11y and Lighthouse run Chrome on your
498
+ machine. This is the pre-PR check PSI cannot do.
499
+ - **`baseline_dir` may equal `output_dir`.** The baseline is read before
500
+ anything is written, so "compare against the last run in here" works.
501
+ - **`skip_completed` flips to `false` when comparing.** Re-measuring is the
502
+ whole point; skipping completed work would compare a run against itself.
503
+ - **Comparison is per `(url, variant, defect id)`.** Only runs present on both
504
+ sides are compared — a page that was not re-tested is reported under
505
+ `not_in_current` rather than counted as fixed, since a defect can only be
506
+ called fixed if the page was measured again.
507
+ - A defect that changed priority between runs counts as **still failing**, not
508
+ as fixed-and-reintroduced. It is the same defect on the same element.
509
+
510
+ ---
511
+
512
+ ## PSI performance audit
513
+
514
+ `plan_performance_audit` and `run_performance_audit` wrap the Google PageSpeed
515
+ Insights API. They are **exclusive and opt-in** — `run_qa_gate` never calls
516
+ them. Use them when someone asks for a PSI audit, a Core Web Vitals report, or
517
+ real-user field data.
518
+
519
+ ### Why two tools
520
+
521
+ An MCP tool cannot ask a question mid-call, and a useful audit needs decisions
522
+ first — which URLs, how many per template, what to do about pages PSI cannot
523
+ reach. So:
524
+
525
+ 1. `plan_performance_audit` discovers, classifies and costs the run, then
526
+ returns a `questions` array. **Spends no quota.**
527
+ 2. Those questions go to the user.
528
+ 3. `run_performance_audit` executes the approved page list.
529
+
530
+ A misclassified template should cost a conversation turn, not forty API calls.
531
+
532
+ ### What PSI adds over `run_lighthouse`
533
+
534
+ One call returns two independent datasets: a Lighthouse run on Google's
535
+ infrastructure (**lab**) and Chrome UX Report data for the URL (**field** — real
536
+ users, 28-day 75th percentile). `run_lighthouse` gives you the first. Only PSI
537
+ gives the second, and the disagreement is the point:
538
+
539
+ | Lab | Field | Meaning |
540
+ |---|---|---|
541
+ | Pass | Pass | Genuinely fine |
542
+ | Fail | Pass | Lab profile harsher than the real audience — deprioritise |
543
+ | **Pass** | **Fail** | **The test environment is lying to you.** Real users hit something the simulation does not |
544
+ | Fail | Fail | Confirmed by two independent measurements |
545
+
546
+ Row three is invisible to every other tool here. On one commerce homepage the
547
+ lab reported a perfect CLS of 0 while real users were at 0.55 — 5.5× the "poor"
548
+ threshold, affecting 70% of them. On another site the lab reported TTFB of 2 ms
549
+ (Google's network sits next to the origin) against 1.5 s in the field.
550
+
551
+ CrUX is **not real-time** — it is a 28-day trailing aggregate. It is valuable
552
+ because it is real users, not because it is current.
553
+
554
+ ### Where PSI does not work
555
+
556
+ - **Localhost and private hosts** — PSI fetches from Google's infrastructure.
557
+ Rejected at preflight; use `run_lighthouse`.
558
+ - **Cart, checkout, account pages** — PSI fetches anonymously, so it would
559
+ measure an empty cart or a login redirect. The plan tool flags these and
560
+ routes them to `run_lighthouse`, which can carry session cookies.
561
+ - **Low-traffic URLs** — reachable, but with little or no CrUX data. You get a
562
+ lab-only audit, labelled as such. Staging and preview deployments are always
563
+ in this category, which is why PSI is optional for non-production and
564
+ authoritative for hosted sites.
565
+
566
+ ### URL discovery
567
+
568
+ `discovery` accepts:
569
+
570
+ | Mode | Behaviour |
571
+ |---|---|
572
+ | `sitemap` (default) | robots.txt, then `/sitemap.xml` and `/sitemap_index.xml`, then `<link rel="sitemap">`, then seven common CMS locations. Tiers only advance when earlier ones find nothing. |
573
+ | `list` | Explicit `urls` array. |
574
+ | `csv` | `csv_path` — column detected by name or by content. An analytics top-pages export is the best input for a performance audit, being traffic-weighted. |
575
+ | `crawl` | Not implemented, and declined deliberately — see below. |
576
+
577
+ **Why there is no crawler.** Static link extraction was measured against real
578
+ homepages and found 11 internal paths on nodejs.org (whose sitemap has 1,723),
579
+ 7 on gap.com, and nothing at all on a site behind a bot wall. The pages most
580
+ likely to lack a sitemap are the same ones that are client-rendered or
581
+ bot-protected, so a crawler fails precisely where it would be needed. An
582
+ analytics top-pages export is a better input anyway, being weighted by real
583
+ traffic. If one is ever built it should drive a headless browser rather than
584
+ parse static HTML.
585
+
586
+ Discovered URLs are clustered into **templates** by path shape, and the plan
587
+ tool proposes representative samples per template. Passing a URL with a path
588
+ (e.g. `https://site.com/shop/`) scopes discovery to that subtree.
589
+
590
+ ### Chunking and time
591
+
592
+ PSI is slow and erratic: measured latency on live runs ranged from **10 s to
593
+ 57 s for the same URL**, with occasional hangs and intermittent 500s. Roughly
594
+ one run in three failed on one origin.
595
+
596
+ So `run_performance_audit` runs in chunks. Each call is bounded by
597
+ `max_seconds_per_call` (default 150) and returns a `cursor`; keep calling until
598
+ `complete` is true. Raw reports are written to `output_dir` as they land and
599
+ merged into `_index.json`, so nothing completed is ever lost.
600
+
601
+ **Re-run to fill gaps.** Call again with the same pages and *no cursor* —
602
+ completed page/strategy pairs are skipped automatically, so only failures are
603
+ retried. Pass `skip_completed: false` to force fresh measurements.
604
+
605
+ `runs_per_url` defaults to 1. Raising it to 3 takes the median run and removes
606
+ single-run outlier risk, at three times the wall clock.
607
+
608
+ ### Reading the output
609
+
610
+ The final call returns an `aggregate` block with every cross-page number:
611
+ per-strategy means, `lab_metric_failures`, CWV verdict tallies,
612
+ `lab_vs_field_summary`, and outliers. **Quote those rather than recomputing
613
+ them.**
614
+
615
+ Two redundancy rules apply there:
616
+
617
+ - **Systemic collapse** — a vital failing on 80%+ of runs *with little
618
+ variation between pages* collapses into one site-wide finding. Computed only
619
+ from URL-level field data, since origin-level CrUX is one number repeated and
620
+ cannot evidence a claim about variation.
621
+ - **Component suppression** — FCP is folded into LCP when both fail on a page,
622
+ since FCP is a component of LCP rather than an independent defect.
623
+
624
+ `field_source` on every field metric says whether the number describes the URL
625
+ or the whole origin. **PSI substitutes origin data silently**, so trust that
626
+ field rather than the presence of a populated field block.
627
+
628
+ [`psi-report-spec.md`](psi-report-spec.md) is the full guide to turning this
629
+ output into a written report.
630
+
631
+ ---
632
+
633
+ ## Priority system
634
+
635
+ | Priority | Meaning | Lighthouse | WCAG | ESLint / Semgrep | CrUX field |
636
+ |---|---|---|---|---|---|
637
+ | P1 | Blocker — fix before shipping | Score < 50 | **Level A failure** — puts the target out of reach | Semgrep security, ESLint error | Core vital rated poor |
638
+ | P2 | Warning — track before merging | 50–79 | **Level AA failure** | ESLint warning | Needs improvement, or any diagnostic |
639
+ | P3 | Advisory — log as tech debt | 80–89 | **Above the target**, or a best-practice rule | — | — |
640
+ | *(suppressed)* | Passing — never reported | ≥ 90 | — | — | Good |
641
+
642
+ Lighthouse findings are actually ranked by `weight × (1 − score)` — the category
643
+ points an audit really costs — rather than by score alone, so a weight-30 metric
644
+ failing outright outranks a weight-1 SEO check that also scores 0.
645
+
646
+ Adjustments:
647
+
648
+ - **Corroborated** findings (two tools agreeing) are promoted one tier.
649
+ - **Field-confirmed** findings are promoted one tier; **lab-only** findings
650
+ contradicted by healthy field data are demoted and tagged `lab_only`.
651
+ - **Non-core vitals** (FCP, TTFB) never exceed P2 — they explain a Core Web
652
+ Vital rather than being one.
653
+
654
+ ---
655
+
656
+ ## Project layout
657
+
658
+ ```
659
+ ├── src/
660
+ │ ├── index.ts # MCP server bootstrap + tool registration
661
+ │ ├── types.ts # Shared types (Finding, Priority)
662
+ │ ├── config/
663
+ │ │ └── qa-mcp-baseline.eslint.config.js # Fallback ESLint config
664
+ │ ├── tools/ # One file per MCP tool
665
+ │ │ ├── qaGate.ts # Orchestrator
666
+ │ │ ├── lighthouse.ts
667
+ │ │ ├── accessibility.ts
668
+ │ │ ├── staticAnalysis.ts
669
+ │ │ ├── performanceAuditPlan.ts # plan_performance_audit
670
+ │ │ └── performanceAudit.ts # run_performance_audit
671
+ │ ├── mappers/ # Raw output → QA report shape
672
+ │ │ ├── correlator.ts # Cross-tool correlation (Rule 1 + 2)
673
+ │ │ ├── defectFormatter.ts # Findings and defect prose
674
+ │ │ ├── priorityMapper.ts # Score/severity → P1/P2/P3
675
+ │ │ ├── a11yDedupe.ts # Systemic a11y collapse
676
+ │ │ ├── compositeScore.ts # Per-tool sub-scores
677
+ │ │ ├── webVitalsMapper.ts # CrUX thresholds → priorities → prose
678
+ │ │ ├── labFieldComparator.ts # Lab vs field verdicts
679
+ │ │ ├── psiAggregator.ts # Cross-run arithmetic + redundancy rules
680
+ │ │ ├── runComparator.ts # Before/after diff: fixed, still failing, new
681
+ │ │ └── wcagLevels.ts # WCAG 2.1 criteria, levels, conformance rollup
682
+ │ └── utils/ # Cross-tool helpers
683
+ │ ├── shellRunner.ts # Subprocess choke point
684
+ │ ├── httpClient.ts # HTTP choke point (retry, deadline, redaction)
685
+ │ ├── reportGenerator.ts # HTML report builder
686
+ │ ├── outputParsers.ts # Per-tool JSON parsers
687
+ │ ├── psiParser.ts # PSI response → lab + field
688
+ │ ├── psiAuth.ts # API key resolution
689
+ │ ├── sitemapReader.ts # Tiered sitemap discovery
690
+ │ ├── urlClassifier.ts # URL list → page templates
691
+ │ ├── csvReader.ts # URL extraction from CSV
692
+ │ ├── publicUrl.ts # Reachability + session-gate checks
693
+ │ ├── urlInput.ts # One URL / list / CSV → URL array
694
+ │ ├── batchState.ts # Cursor, budget, index merge, gap-fill
695
+ │ ├── eslintConfigDetector.ts
696
+ │ └── toolResponse.ts
697
+ ├── docs/
698
+ │ ├── manual.md # This file
699
+ │ └── psi-report-spec.md # How to write the PSI audit report
700
+ └── dist/ # Compiled output (gitignored)
701
+ ```
702
+
703
+ External calls go through exactly two choke points: `shellRunner` for
704
+ subprocesses, `httpClient` for HTTP. Anything holding an API key must use
705
+ `httpClient`, which redacts secrets from every error.
706
+
707
+ ---
708
+
709
+ ## Troubleshooting
710
+
711
+ ### The new tools don't appear after an update
712
+
713
+ Reconnect: `/mcp` → the server → **Reconnect**. The running process does not
714
+ reload code or environment changes on its own.
715
+
716
+ ### `429 Quota exceeded` on the first PSI call
717
+
718
+ No API key. The shared anonymous quota is exhausted in practice, so keyless PSI
719
+ fails immediately rather than working slowly. See
720
+ [the key setup](#pagespeed-insights-api-key).
721
+
722
+ ### A PSI call gets "moved to the background"
723
+
724
+ Claude Code backgrounds an MCP call at 120 s, and the default
725
+ `max_seconds_per_call` is 150. Results still arrive by notification. To keep
726
+ runs in the foreground, pass `max_seconds_per_call: 100` or lower.
727
+
728
+ ### PSI runs fail intermittently
729
+
730
+ Expected — roughly one in three on some origins, as PSI 500s or hangs. Call the
731
+ tool again with the same pages and no cursor; completed pairs are skipped and
732
+ only the gaps are retried.
733
+
734
+ ### The plan tool warns about HTTP 403
735
+
736
+ A bot wall (Cloudflare and similar) blocking the preflight. It says nothing
737
+ about PSI, which fetches from Google's address space and is usually
738
+ allowlisted. Not a reason to stop — but if the audit returns nothing, the
739
+ challenge page is the first suspect.
740
+
741
+ ### "No sitemap found"
742
+
743
+ Nine locations were tried. Supply URLs with `discovery: "list"`, or point at a
744
+ CSV export — an analytics top-pages export is a better input for a performance
745
+ audit anyway, being weighted by real traffic.
746
+
747
+ ### Field data says "site-wide data"
748
+
749
+ That URL has too little traffic for its own CrUX entry, so the numbers describe
750
+ the whole origin. They are still real, but they do not describe the page you
751
+ asked about. Two different URLs reporting identical p75 values are both being
752
+ served origin data.
753
+
754
+ ### A tool shows `UNAVAILABLE`
755
+
756
+ Its CLI is not on PATH. Install it (see
757
+ [prerequisites](#prerequisites)) or ignore it — the rest of the gate still runs.
758
+
759
+ ### A comparison reports nothing was compared
760
+
761
+ `baseline_dir` had no index, or it covers different URLs than this run. Check
762
+ `not_in_baseline` and `not_in_current` in the comparison block — only runs
763
+ present on both sides can be compared. Create a baseline by running once with
764
+ `output_dir` set.
765
+
766
+ ### Lighthouse scores look far worse than expected
767
+
768
+ Check `form_factor`. The mobile profile applies a 4× CPU slowdown and simulated
769
+ slow 4G; it is not comparable to a desktop run of the same page.