Skip to main content
Glama
README.md
# slop-scorer-mcp

An MCP server that tells a coding agent, with citations, which parts of a codebase or a rendered
web page read as machine-generated template output, and hands back the exact edit that would
remove each one.

No model is involved anywhere in this server. Every finding cites a file and a line number, or a
CSS selector and a computed style value, that you can go and check yourself. Seventy rules, all
of them data, all of them carrying their own rebuttal.

```
claude mcp add slop-scorer -- npx -y github:Yuve21/slop-scorer-mcp
```

Node 20 or newer and nothing else. Not on npm yet, so that line installs from this repository;
the first launch compiles and takes about a minute. Full detail, and the two other ways in, are
under [Install](#install).

---

## Why this exists

The interesting half is not detection. It is `list_rules`, which returns the whole corpus so an
agent can read what not to produce **before** it produces anything.

Detection is a race that eventually gets lost: generators improve, tells decay, and a corpus of
tells is a depreciating asset. Prevention does not decay, because an agent that reads the corpus
first writes work the corpus does not fire on. It is the only loop here that gets stronger the
more it is used.

## What it will not do

This matters more than the feature list, so it is first.

- **It never says a person used AI.** Every sentence it can emit has the same grammar: the
  subject is the server, the object is the artifact, the verb is something the server did. "We
  checked this artifact against 15 deterministic rules and 4 matched, on 88% coverage." Not "this
  is AI." The formatter cannot express the other sentence, and a test in `@slop/core` fails the
  build if a forbidden phrase appears in the source.
- **Abstention is a result, not a failure.** `status` is `assessed`, `inconclusive` (we looked
  and will not publish a number) or `not_assessed` (we did not look). Both abstentions carry
  `score: null` and a coded reason. They are not low scores and they are not clean bills of
  health. Branch on `status`, never on `score`.
- **The score stops at 99.** There is no arrangement of evidence that reaches certainty.
- **A thin read withholds the score entirely.** A page that only half rendered looks exactly like
  a clean page, and printing a confident number over a partial read would be the most dishonest
  thing this server could do.
- **It publishes no accuracy figure, anywhere.** `packages/core/test/no-claims.test.ts` scans
  every shipped source file and every README on each test run and fails the build if one appears.
  An accuracy claim about an inference is a substantiation-bearing claim under FTC Act section 5
  (*In re Workado*, 2026), so the only defensible way to hold one is to compute it from a named
  corpus at test time. If you want a number, run `npm run backtest` and read your own.
- **It never writes to anything it scanned.** No `exec`, no request for write access, and no code
  path that modifies the artifact. Fixes come back as data for your agent to apply under your
  normal approval flow. The package contains exactly one filesystem write, the local observation
  log described below, and it is off until you switch it on, writes only to a directory you named,
  and never touches the target.

## The local observation log (off by default)

Nothing here phones home. There is no telemetry, no usage beacon, no update check and no upload
path in any package, and there is no code that could be pointed at one: the sink type takes a
directory and cannot express a URL, so a future caller cannot quietly supply one.

If you want to help the corpus get better, you can switch on a local log:

```sh
SLOP_OBSERVATIONS_DIR=/an/absolute/path/you/chose
```

Unset, nothing is written and no file is created. That is the default and there is no remote
default behind it. Set, each scan appends one JSON line to `observations-YYYY-MM.jsonl` in that
directory, recording the shape of the read and nothing else: the corpus version, a day rather than
a timestamp, the modality, the status and band, a score bucket rather than the score, the coverage
ratio, which rules fired, which rules were evaluated and did NOT fire, the per-probe denominators,
and a size bucket with markers from a closed vocabulary.

It records no path, no URL, no hostname, no file name, no dependency name and no `observed` value
out of any finding. The record is built by allowlist, field by field, rather than by stripping
fields, because a denylist fails open for every field somebody adds later. A guard then runs on the
finished record before it reaches disk, and it REFUSES rather than sanitises: a rule id the corpus
does not know, an extension that is not a bare extension, a marker outside the vocabulary or a
timestamp where a day belongs throws instead of being quietly rewritten.

The file is newline-delimited JSON. Read it, grep it, delete it. Getting an observation off your
machine is your job and nobody else's: there is no service to send it to.

## Install

`slop-scorer-mcp` is **not on the npm registry yet**, so every command here installs from this
repository. That works fine, with one caveat worth reading before you decide the install broke.

**The first launch compiles from source.** Measured cold, with an empty npm cache: **69 seconds**.
Most MCP clients give a server 30 seconds to answer, so the first connect can report
`connection timed out` even though nothing is wrong. Reconnect, or warm the cache first:

```sh
npx -y github:Yuve21/slop-scorer-mcp   # wait for "listening on stdio", then Ctrl-C
```

Every launch after that is off the cache and immediate.

### Claude Code

```sh
claude mcp add slop-scorer -- npx -y github:Yuve21/slop-scorer-mcp
```

### Claude Desktop

`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS,
`%APPDATA%\Claude\claude_desktop_config.json` on Windows. Restart the app afterwards.

```json
{ "mcpServers": { "slop-scorer": { "command": "npx", "args": ["-y", "github:Yuve21/slop-scorer-mcp"] } } }
```

### Cursor

`~/.cursor/mcp.json` globally, or `.cursor/mcp.json` per project. Same block. Reload the window.

```json
{ "mcpServers": { "slop-scorer": { "command": "npx", "args": ["-y", "github:Yuve21/slop-scorer-mcp"] } } }
```

### From a checkout

Use this if you are going to change a rule and watch it fire.

```sh
git clone https://github.com/Yuve21/slop-scorer-mcp
cd slop-scorer-mcp
npm install                                            # `prepare` builds it for you
npm run install:local --workspace=packages/mcp-server
```

That last line registers the built binary with `claude mcp add` by absolute path. If the `claude`
CLI is not on your PATH it prints the JSON block to paste by hand instead of failing silently.

### Once it is on npm

```sh
claude mcp add slop-scorer -- npx -y slop-scorer-mcp
```

No build step, no cold start, because a registry tarball ships compiled. This 404s today.

### How the GitHub install works

`npx -y github:...` resolves the repository ROOT, and npm has no way to point a git spec at a
subdirectory. So the root `package.json` here carries a `bin` (a single entry, which is what lets
npx pick an executable), a `prepare` script (the only lifecycle hook npm runs for a git
dependency: `prepack` is not run there), a `files` list naming `packages/mcp-server/dist`
(because `dist/` is gitignored and npm falls back to `.gitignore` when packing, which would ship
the tarball with the binary removed), and the two runtime dependencies the esbuild bundle leaves
external. A root `.npmignore` sits beside them so `.gitignore` is never consulted at all.

### `scan_ui` needs a browser

```sh
npx playwright install chromium
```

Without it, `scan_ui` returns `status: "not_assessed"` and says why. It will not fall back to
reading the server HTML: a fetch-only read produces confident findings about a document nobody
sees, which is how a heading check once reported "this route has 0 H1s" for a route whose heading
does not exist until hydration.

---

## The five tools

| tool | what it does |
| --- | --- |
| `list_rules` | The whole corpus, compact. Read before you generate. |
| `scan_codebase` | Static analysis of a checkout, plus git history. |
| `scan_ui` | Renders a URL or a dev server in Chromium and measures the rendered document. |
| `propose_fixes` | Re-scans and returns each finding as a precise, caveated edit. |
| `verify_fix` | Re-scans and puts the before and after finding sets side by side. |

### `list_rules`

Seventy rules across two corpora: 51 web, 19 code. Compact by default, because a tool that asks
to be called before you generate anything cannot cost a fifth of a small context window.

```
index         every rule, one line: id | family | polarity/severity | weight | rationale
fullEntries   why it reads as generated, the counter-evidence that rebuts it, how to avoid it
retrieval     a sentence on every response saying how to get what was left out
```

```
modality: "web" | "code" | "all"                     optional
family:   e.g. "agent-artifact"                      optional
ruleIds:  ["craft.no-og-image", ...]                 full entries for these only
verbose:  true, full entries for every rule          costs roughly 18k tokens
```

The usual pattern after a scan is `ruleIds: [...the ids that fired]`, which costs a few hundred
tokens and returns exactly the rebuttal and the prevention note you need to act on them.

### `scan_codebase`

```
path:        absolute path to the repository root
include:     optional patterns, e.g. ["src/**/*.ts"]
readHistory: read git history for the commit-shape rules (default true)
maxFiles:    cap on files walked (default 5000)
```

**A real reading, produced by pointing this server at its own checkout.** Nothing here is typed
by hand: it is the response bytes, lightly trimmed for length. Note that it finds things in us.

```
SLOP RECEIPT  20 / 99   band: few known signals
corpus code-corpus-2026.09  |  coverage 100%  |  families fired 2
evidence kind: deterministic  |  detectors: code.static-rules

We checked this artifact against 19 deterministic rules from code-corpus-2026.09 and 4
matched, on 100% coverage. That places it in the "few known signals" band at 20 of 99.
Every match below cites what we read and where.

     15  base rate before any evidence
    +26  scaffold.placeholder-markers  [scaffold-residue / medium]  -> 41
         Unfilled placeholders left in shipped source
         evidence: line packages/detectors-code/src/artifact.ts:73 = "lorem ipsum"
                   (expected "a real value")
         evidence: line packages/detectors-code/src/artifact.ts:109 = "TODO: implement"
                   (expected "a real value")
         evidence: line packages/detectors-code/src/suppression.ts:18 = "lorem ipsum"
                   (expected "a real value")
         ... and 4 more, 5 counted toward the weight
         caveat: TODOs are a normal and healthy way to record known gaps, and a mature
                 codebase carries plenty. Markers that name an owner or a ticket are
                 excluded as tracked work. Suppressed by suppress.self-defining-pattern:
                 19 match(es) were withdrawn as self-referential: the cited lines in
                 packages/detectors-code/src/scan.ts are the DEFINITION of the patterns
                 that matched them, not uses of them.
    +23  verify.tautological-tests  [verification-floor / high]  -> 64
         Tests that cannot fail
         evidence: file packages/detectors-code/test/corpus/index.ts
                   = "93 lines, 0 assertions" (expected "at least one assertion")
         evidence: file packages/detectors-web/test/corpus/index.ts
                   = "60 lines, 0 assertions" (expected "at least one assertion")
         caveat: A deliberate smoke test that only checks a module imports without
                 throwing looks like this, and an assertion helper the scanner does not
                 recognise reads as zero assertions.
    -42  counter.rationale-comments  [counter-evidence / counter]  -> 22
         Comments that record a reason, not a restatement
         evidence: line packages/core/src/assessment.ts:61 = "We read the artifact cleanly
                   and it carried no declared provenance either way. ABSENCE OF PROVENANCE
                   IS NOT EVIDENCE OF GENERATION."
         ... and 3 more, 4 counted toward the weight
         caveat: A rationale comment can be copied along with the code it explains, and an
                 agent prompted to explain its reasoning will write plausible ones. The
                 signal is real but it is not proof.
     -2  counter.real-test-coverage  [counter-evidence / counter]  -> 20
         Tests dense enough to actually fail
         evidence: metric "26 test files against 63 source files"
                   = "606 assertions, 0 tautologies"

     20  TOTAL (base 15 + 4 contribution(s))

FAMILY CAPS
  Scaffold residue             +26 pts   logit 1.37 of 1.60 allowed
  Verification floor           +23 pts   logit 1.35 of 0.96 allowed  AT CAP
  Global counter-evidence      logit -2.00  AT CAP  (bypasses family caps)

WHAT WOULD CHANGE THIS SCORE
  -26  scaffold.placeholder-markers: Fail the build on the placeholder strings your
       scaffold ships with. They are the ones nobody notices.
  -23  verify.tautological-tests: Mutation-test the test: break the code it covers and
       confirm it goes red. If it stays green, delete it or fix it.

A high score means this artifact resembles generated-template output. It is not proof that
a tool made it, and it is not a judgement of the person who made it.
```

Four things in that output are worth pointing at, because they are the design:

1. **Counter-evidence subtracts.** Two of the four matches argue *for* the artifact and together
   they are worth more than everything against it. A corpus that could only accuse would score
   every careful codebase as generated.
2. **Families are capped.** "Verification floor" contributed 23 points and is marked `AT CAP`, so
   no single correlated group of tells can carry a verdict alone.
3. **The receipt reconciles.** Base rate plus every printed contribution equals the printed
   score, in integers, exactly. You can recompute the number by hand.
4. **A suppressor fired and said so.** Nineteen placeholder matches were withdrawn because the
   lines that matched are the pattern table that defines the pattern. A phase-two suppressor may
   withdraw evidence about the detector rather than about the artifact, but it has to name the
   rule, the file and the reason, in the receipt, where you can see it.

In the real response every value copied out of the scanned artifact is wrapped in an
`untrusted` fence and the payload closes with a prompt-injection warning, because a scanner
that quotes a stranger's source into an agent's context is an injection vector. The fences are
stripped above only so the example reads.

### `scan_ui`

```
url:            https://example.com/pricing
port:           3000                      (instead of url, for a local dev server)
viewportWidth:  default 390
viewportHeight: default 844
```

Renders in Chromium and measures the rendered document: builder fingerprints, default visual
language, craft-floor defects, structural and motion uniformity, copy tells. A real finding:

```
   +7  css.crushed-tracking  [visual-default / medium]  -> 34
       Headline letter-spacing is crushed at a heavy weight
       evidence: css letter-spacing on h1 = "-0.04em at weight 800"
                 (expected "-0.02em or looser at weight 700+")
       caveat: Some faces genuinely want negative tracking at display size, and a
               type-literate designer may choose exactly this. It is a taste signal,
               not a provenance signal.
```

**One capability is missing from this build and it is missing on purpose.** Text rendered
*inside* an image is not recovered here: the recoverer lives in a private half of the project,
so `packages/ocr-text` in this repository always abstains, with a stated reason attached to
every image it was asked about. The three `imgtext.*` rules therefore cannot fire on a live
`scan_ui` run. They remain in `list_rules` (the advice not to bury your copy in a JPEG is worth
reading either way) and they still score the frozen corpus artifacts, so archived numbers do not
move. This is written down rather than quietly omitted because an unread image is not an image
with no words in it.

### `propose_fixes`

Re-scans the target and returns every finding as a proposal, grouped by family and split four
ways so your agent can present it as "apply these N, skip these M".

| bucket | meaning |
| --- | --- |
| `readyToApply` | `replace_range`, `insert`, `replace_file`. Locator and replacement both determined. |
| `needsConfirmation` | `delete_file`. The only destructive kind, and its own bucket so it can be gated. |
| `needsSourceLocation` | `ui_change`. Selector, property and values are exact; the file that declares them is not knowable from a rendered read. |
| `decideYourself` | `manual`. A person decides. The locator and what a good answer looks like, and no invented value. |

```json
{
  "id": "agent.instruction-file-committed#1",
  "ruleId": "agent.instruction-file-committed",
  "applicability": "confirm",
  "destructive": true,
  "blastRadius": "file",
  "remediation": {
    "kind": "delete_file",
    "path": "CLAUDE.md",
    "bytes": 4200,
    "summary": "Remove CLAUDE.md from the repository and from the index.",
    "rebuttal": "This says how the repository was worked on, not who wrote any given line.",
    "doNotApplyIf": "this file is a deliberate part of how the team works. In that case keep it and say so in the README, which answers the finding without deleting anything."
  }
}
```

Most rules propose `manual` on purpose. A page title, a meta description, an alt attribute and a
brand palette are all things a machine can produce instantly, and all things whose machine
production is the defect this corpus measures. Generated alt text is the clearest case: it
satisfies the checker and tells a screen reader user, confidently, about an image nobody looked
at. So those rules name the gap and stop.

Two constraints hold everywhere in this tool:

- **Only a deterministic read may propose a patch.** Where a detector abstains from certainty,
  the fix cannot assert it.
- **Every fix carries the rule's own rebuttal and an explicit `doNotApplyIf`,** injected from the
  rule's `falsePositiveNote` so the fix and the argument against it can never drift apart.
- **Counter-evidence is never remediable.** A counter finding argues *for* the artifact. The
  corpus throws at load if anyone attaches a fix to one.

### `verify_fix`

```
Before: 9 finding(s). After: 6. 3 no longer present, 6 still present, 0 newly present.
Score moved by -14 point(s).
```

It does not report success. `noLongerPresent` is a list of rules that no longer match at those
locators, which is a fact about a re-scan and not a claim that a problem was solved.
`newlyPresent` is stated first and sets `regression: true`, because a change that resolves two
findings and introduces one has broken something, and a verifier that reported the net would
call that an improvement. If no earlier reading of the target is held in this session it says so
rather than comparing against nothing: a first run is never an all-clear.

---

## The loop

**scan, propose, apply, verify.** The report was never the point; the change was.

```
1. scan_codebase / scan_ui   what is there, with a locator on every claim
2. propose_fixes             the same findings as precise, caveated edits
3. YOUR agent applies them   with its own edit tools, under the user's normal approval
4. verify_fix                re-scan, and show which findings are no longer present
```

Step 3 is deliberately not this server's job. Your agent already has edit tools, an approval
prompt and a user who trusts them. A second, worse copy of that machinery inside an MCP server
would be more code, more risk and less control.

---

## What is in this repository

| package | what it is |
| --- | --- |
| `packages/mcp-server` | The server. Five tools, the stdio binary, the publish-shape bundle. |
| `packages/core` | The rule contract, the scoring engine, the receipt, the calibration harness. |
| `packages/detectors-code` | The code corpus: 19 rules, plus 16 labelled repositories (10 human, 4 generated, 2 synthetic). |
| `packages/detectors-web` | The web corpus: 51 rules, plus 9 labelled pages (5 human, 4 generated). |
| `packages/ocr-text` | The abstaining image-text stand-in described under `scan_ui`. |

The published npm package is self-contained: `npm run build` bundles the three internal
workspace packages into `dist/bin.js` and `dist/index.js` with esbuild and vendors their type
declarations, so nothing in the tarball points at a package that was never published.
`@modelcontextprotocol/sdk` and `zod` stay real external dependencies; `playwright` stays an
optional peer.

### Build it yourself

```sh
npm install
npm run build      # tsc -b, then the esbuild bundle
npm test           # the rule suite, both calibration corpora, the security and FTC guards
npm run backtest   # the gate: replay both frozen corpora and diff against the baseline
```

`npm test` never launches a browser. The web tests run against stored artifacts, which is also
what makes the calibration reproducible.

## Contributing

Read [CONTRIBUTING.md](CONTRIBUTING.md) first. The short version: a rule in this repository is an
accusation with arithmetic behind it, so the bar is `npm run backtest`, not "the tests are
green". Every rule ships a positive fixture that must fire and a mutated fixture that must not,
and the meta-suite runs both on every test run. Adding a corpus member with stated, checkable
provenance is a bigger contribution than adding a rule.

## Licence

MIT. See [LICENSE](LICENSE).