gristmill-mcp
# gristmill-mcp
An MCP server that inspects AI-generated code and returns a deterministic list of structural and safety violations, so an AI coding agent can fix its own output before the code lands.
*Grist* is grain brought to a mill for grinding. AI output is grist — genuinely valuable raw material, but unprocessed. The mill gives it structure.
**AI writes the grist. Gristmill makes it code you can ship.**
## Why an MCP server, not a skill
A skill is text loaded into a model's context — it changes what the model *knows*. An MCP server is a program the model *executes* — it changes what the model *can do*.
Style guidance ("prefer classes over loose functions") belongs in a skill. Verification ("this file has 7 top-level functions at lines 12, 40, 66…") requires running code against the file. A model reading its own output and reasoning "this looks like it has too many functions" is a guess dressed up as an observation — it has no ground truth for what "too many" means in *this* file, and no reliable way to count. Gristmill parses the AST and counts. That distinction — instruction versus execution — is why this exists as a server instead of a paragraph of advice.
The server never calls an LLM, never varies between runs on the same input, and never emits a confidence score. Same input → byte-identical output, every time. That determinism is the entire product. The AI layer sits *above* this server, consuming its findings and deciding what to do about them — the server's job stops at reporting facts with line numbers.
## Install
```bash
git clone <this repo> gristmill-mcp
cd gristmill-mcp
python3 -m venv .venv
.venv/bin/pip install -e .
```
### Claude Code
Register it with the CLI, pointing at the venv's console script:
```bash
claude mcp add gristmill -- /absolute/path/to/gristmill-mcp/.venv/bin/gristmill-mcp
```
Or add it directly to your MCP config (`.mcp.json` in a project, or your global Claude Code config):
```json
{
"mcpServers": {
"gristmill": {
"command": "/absolute/path/to/gristmill-mcp/.venv/bin/gristmill-mcp"
}
}
}
```
### Other MCP clients
Any stdio-based MCP client can launch the same binary — `gristmill-mcp` (or `python3 -m gristmill.server` inside the venv) speaks the standard MCP stdio transport with no client-specific configuration.
### Command line (no MCP client)
For local testing, or to reproduce the worked example below, a thin CLI wraps the same engine:
```bash
.venv/bin/gristmill-verify path/to/file_or_dir [--checks secrets structure comment_slop] [--severity-floor warning] [--json]
```
## Worked example
`demo/billing.py`, an unedited first draft of a Stripe billing helper:
```python
import stripe
# I've added this as you requested — sets up the Stripe client
STRIPE_SECRET_KEY = None # was a literal sk_live_... key — see note below
stripe.api_key = STRIPE_SECRET_KEY
def customer_create(config):
return stripe.Customer.create(**config)
def customer_delete(config):
return stripe.Customer.delete(config["id"])
def customer_find(config):
return stripe.Customer.retrieve(config["id"])
def customer_update(config):
return stripe.Customer.modify(config["id"], **config)
```
```bash
.venv/bin/gristmill-verify demo/billing.py
```
Output with a real Stripe-live-key-shaped literal in place of the `None` above:
```
gristmill: 1 files scanned, 0 skipped (2 error, 4 warning, 0 info) in 1ms
[WARNING] STR002 billing.py:1 4 top-level functions share the prefix `customer_` — consider a `Customer` class or module
[WARNING] STR003 billing.py:1 4 top-level functions take a first parameter named `config` — consider making it instance state
[WARNING] CMT001 billing.py:3 Comment addresses the reader conversationally ('as you requested')
[ERROR ] SEC006 billing.py:4:22 Stripe live key assigned to `STRIPE_SECRET_KEY`
[ERROR ] SEC010 billing.py:4:22 String literal assigned to `STRIPE_SECRET_KEY`, which looks credential-shaped
[WARNING] SEC011 billing.py:4:22 High-entropy string literal (5.1 bits/char) assigned to `STRIPE_SECRET_KEY`
```
(File paths are shown relative to the nearest `.gristmill.toml` — `demo/` carries its own so this example's output stays stable independent of the top-level project config.)
> **Note:** GitHub's push protection blocks any pushed file containing a real-format secret — including in a comment or a markdown code block, this README included. `demo/billing.py` currently has the key swapped for `None` to unblock the initial push; this is a TODO to restore (via an allow-listed secret-scanning exception) so the demo is live again.
The `--json` flag (or the `verify` MCP tool, which returns both) gives the full structured form — file, line, column, a static suggestion string, and a redacted `evidence` field (`sk_l… (49 chars)`, never the key itself).
## Tools
### `verify`
> Inspect source files for secrets, structural problems, and low-quality comments. Returns deterministic findings with file paths and line numbers. Call this after generating or editing code, before presenting it as finished.
Input: `paths` (files or directories, required), `checks` (optional subset of `secrets`/`structure`/`comment_slop`, default all), `severity_floor` (optional, default `info`).
Output: a compact human-readable summary, followed by the full structured JSON — file, line, column, message, redacted evidence, and a static suggestion string per rule. Findings are sorted by `path`, then `line`, then `rule_id`, always — that stability is what makes runs byte-identical and lets a model navigate straight to the problem.
### `explain_rule`
Takes a `rule_id` (e.g. `SEC001`) and returns its rationale, what it catches, what it misses, and how to suppress it — the same content as [`docs/RULES.md`](docs/RULES.md), served on demand so `verify` output can stay terse.
## Rules
| Rule | Check | Title | Default severity |
|---|---|---|---|
| `SEC001` | secrets | AWS access key ID | error |
| `SEC002` | secrets | AWS secret access key | error |
| `SEC003` | secrets | GitHub token | error |
| `SEC004` | secrets | Google API key | error |
| `SEC005` | secrets | Slack token | error |
| `SEC006` | secrets | Stripe live key | error |
| `SEC007` | secrets | Private key block | error |
| `SEC008` | secrets | JWT | error |
| `SEC009` | secrets | Database URI with inline password | error |
| `SEC010` | secrets | Generic credential-shaped assignment | error |
| `SEC011` | secrets | High-entropy string literal | warning |
| `STR001` | structure | Too many top-level functions (default limit 5) | warning |
| `STR002` | structure | Shared function-name prefix (3+ functions) | warning |
| `STR003` | structure | Repeated first-parameter name (3+ functions) | warning |
| `STR004` | structure | Function too long (default limit 60 lines) | warning |
| `STR005` | structure | Mutable module-level state, mutated elsewhere in the file | warning |
| `CMT001` | comment_slop | Conversational address in comment | warning |
| `CMT002` | comment_slop | Comment narrates the obvious | info |
| `CMT003` | comment_slop | Oversized comment block on a short function | info |
| `CMT004` | comment_slop | Placeholder scaffolding left in place | warning |
| `CMT005` | comment_slop | Repeated section-divider banners (4+ per file) | info |
Full rationale, false-negative notes, and suppression instructions per rule: [`docs/RULES.md`](docs/RULES.md).
## Configuration
`.gristmill.toml` at the project root, all keys optional:
```toml
[checks]
enabled = ["secrets", "structure", "comment_slop"]
[structure]
max_top_level_functions = 5
max_function_lines = 60
[secrets]
entropy_threshold = 4.5
[ignore]
paths = ["legacy/**", "vendor/**"]
rules = ["CMT003"]
```
A `.gristmillignore` file (gitignore syntax) works alongside `[ignore] paths`. Inline suppression is also honored on the flagged line or the line above it:
```python
SUPPRESSED = "ghp_" + "..." # gristmill: ignore SEC003
```
```javascript
// gristmill: ignore SEC003
const suppressed = "ghp_" + "...";
```
## Language support
- **Python** — full support (stdlib `ast` and `tokenize`).
- **JavaScript/TypeScript** — full support, via `tree-sitter` with the `tree-sitter-javascript` and `tree-sitter-typescript` compiled grammars, rather than shelling out to a Node-based parser. This trades a compiled Python dependency for independence from the host having Node installed at all — `structure` and `comment_slop` work identically whether or not `node` is on `PATH`, and it gives a real AST instead of a text-only fallback.
- **Anything else** — the `secrets` check still runs (it's regex-based and language-agnostic); `structure` and `comment_slop` are skipped for that file, reported in `skipped_paths`.
## Limitations
Read this before trusting the tool more than it's earned:
- **`secrets` only catches shaped or high-entropy strings.** A low-entropy human password like `hunter2` will never be flagged — there is no reliable way to distinguish it from an ordinary short string. Credentials assembled at runtime (string concatenation, `os.environ.get(...) or "fallback"`, base64-decoded pieces) are invisible to a regex/entropy pass over static text.
- **Structural problems that span files are invisible.** `structure` looks at one file at a time; a class that should be split across files, or duplicated logic in two different modules, is out of scope.
- **`comment_slop`'s CMT002 is deliberately narrow.** It's the highest false-positive-risk rule in the set, so it's implemented to bias hard toward silence — it will miss real narration far more often than it over-flags. See `docs/RULES.md` for the exact subset-match rule.
- **Languages outside Python and JS/TS get secrets-only coverage.** No structural or comment analysis for Go, Rust, Ruby, etc. in v1.
- **This is not a secrets-in-git-history scanner.** It inspects the working tree as given. A key that was committed and later removed from the current file is not this tool's concern (a git-history scanner is a different, complementary tool).
- **No auto-fix.** Gristmill reports; the calling model decides what and how to change. That split is intentional (see "Why an MCP server, not a skill" above), but it means a `verify` call alone never fixes anything.
A tool that oversells its coverage is worse than one that's upfront about its blind spots — silence beats false confidence here as much as it beats noisy findings.
## Roadmap
Explicitly out of scope for v1, in rough priority order:
- Auto-fix / patch generation (the calling model does this today, using `verify` findings)
- Dependency freshness and CVE checking (needs network calls to package registries — a natural v2)
- Language support beyond Python and JavaScript/TypeScript
- Git history scanning for secrets that were committed and later removed
- A hosted service, web UI, or dashboard
## Development
```bash
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest tests/ -q
```
Regenerate `docs/RULES.md` after editing `src/gristmill/rules.py`:
```bash
.venv/bin/python3 scripts/generate_rules_doc.py
```
Tests cover (`tests/`): golden-file output for a known-dirty fixture directory, 10x determinism with and without parallelism, a false-positive corpus that must produce zero findings, redaction (no raw secret ever reaches any output field), and resilience (invalid syntax, binary, empty, and oversized files never crash a run).
## License
MIT — see [`LICENSE`](LICENSE).
TDQS
Scored across 2 tools
The two tools have clearly distinct purposes: one is for static analysis of code, the other for documentation of rules. An agent would not confuse them.
The naming is inconsistent: 'verify' uses a plain verb while 'explain_rule' uses verb_noun pattern. Both are clear in isolation, but the lack of a unified pattern (e.g., 'verify_code' vs 'explain_rule') makes the set feel ad-hoc.
With only 2 tools, the server feels thin for a tool suite called 'gristmill-mcp'. A code analysis server typically needs more tools like listing rules or scanning for specific categories to feel properly scoped.
The server only provides a scan tool and a rule lookup tool, but is missing operations like listing all rules, skipping specific rules, or generating reports. Users cannot discover available rules without knowing their IDs, creating a dead end.