grounded-mcp
by rich-atkins
README.md
# grounded-mcp
**An MCP server for markdown knowledge vaults that ships with its own eval suite.**

Plenty of servers expose your notes over the [Model Context Protocol](https://modelcontextprotocol.io).
Before you point an agent at a knowledge store, though, you need answers to four questions most of
them skip:
1. **Can I trust what it returns?** Every content-bearing response carries a stable
citation id (`path#heading` + line spans) — nothing is returned that can't be quoted with provenance.
2. **Does it know when it doesn't know?** Search **abstains** — an explicit, structured
"no adequate answer" — instead of dressing weak matches up as results.
3. **Who is allowed to see what?** Declarative **entitlements**, enforced at *index* level:
content a profile can't see is never indexed for it, so it can't leak through scores,
snippets, or rankings. A **redaction guard** refuses to serve notes that look like they
contain credentials, regardless of entitlements.
4. **Is it still true?** A note marked `superseded_by: <path>` is **never searchable** and is
read back flagged with its successor. "No longer true" is a different failure from "no
answer" and "no access", and the suite counts it separately (v0.3).
And none of that is a claim — it's a **CI-gated scorecard** run against the committed demo vault,
reported **by slice** (each slice is one kind of failure; the case count sits next to pass/fail
so nothing hides inside an aggregate):
```
slice n pass fail detail
answerable 24 24 0 hit@1 1.00 recall@5 1.00 MRR 1.00 miss 0 false-abstain 0
unsupported 10 10 0 abstention 1.00 false-answer 0
superseded 16 16 0 stale-cited 0 (must be 0) current-hit 12/12 history-readable 4/4
denied 24 24 0 leaks 0 (must be 0)
secret 6 6 0 violations 0 (must be 0)
EVAL GATE: PASS
```
Weaken the abstention gates and the build fails — try it:
```bash
GROUNDED_MIN_SCORE=0 GROUNDED_MIN_COVERAGE=0 python evals/run_evals.py # EVAL GATE: FAIL, exit 1
```
## Quickstart
```bash
pip install -e .
grounded-mcp # serves the bundled ACME demo vault over stdio
```
Point it at your own vault (Obsidian works as-is — frontmatter, `[[wikilinks]]`, tags):
```bash
GROUNDED_VAULT=~/notes GROUNDED_PROFILE=default grounded-mcp
```
### Claude Code / Claude Desktop
```json
{
"mcpServers": {
"grounded": {
"command": "grounded-mcp",
"env": { "GROUNDED_VAULT": "/path/to/your/vault" }
}
}
}
```
## Tools
| Tool | What it does |
|---|---|
| `search(query, k)` | BM25 over titles/headings/tags/body. Cited hits — or an explicit abstention with the scores that failed the bar. |
| `read_note(citation, section_only)` | A note or single section by citation id; every block carries its own citation anchor. A superseded note comes back with `superseded: true`, `superseded_by` and a warning. |
| `backlinks(path)` | Wikilink graph: what links here, within your entitlements. |
| `browse(prefix, tag, include_superseded)` | List visible *current* notes by folder/tag; superseded ones only on request, each with its successor path. |
Read-only **by design** — a server that can quote your vault but never rewrite it is a trust
feature, not a missing feature.
## Configuration
| Env var | Default | Meaning |
|---|---|---|
| `GROUNDED_VAULT` | `./demo_vault` | vault root |
| `GROUNDED_PROFILE` | `default` | entitlements profile to serve as |
| `GROUNDED_ENTITLEMENTS` | `<vault>/entitlements.yaml` | rules file (absent = allow all) |
| `GROUNDED_MIN_SCORE` | `1.0` | abstention score threshold |
| `GROUNDED_MIN_COVERAGE` | `0.5` | abstention term-coverage threshold |
| `GROUNDED_SERVE_REDACTED` | `false` | serve notes containing secret patterns |
| `GROUNDED_TRANSPORT` | `stdio` | `stdio` \| `http` (v0.2) |
| `GROUNDED_TOKENS` | — | REQUIRED in http mode: yaml token map (sha256 → client/profile) |
| `GROUNDED_HOST` / `GROUNDED_PORT` | `127.0.0.1` / `8000` | http bind address |
Entitlements (`deny` wins, then `allow`, then default-deny):
```yaml
profiles:
staff:
allow: ["public/**", "internal/**"]
deny: ["restricted/**"]
contractor:
allow: ["public/**"]
```
## Why abstention needs two gates (a measured finding)
BM25 score alone cannot separate true answers from confident-looking misses on a small vault:
in our golden set, genuine answers score as low as **2.4** while wrong-but-plausible matches
reach **4.3** — a single common word ("policy") landing in a weighted title field looks like a
result. The discriminator is **term coverage**: what fraction of the query's content words the
note actually contains. Both gates together take abstention from **0.30 → 1.00** with zero
retrieval loss. The eval suite is what made that tuning honest — full details in
`evals/run_evals.py` and the scorecard baseline.
## Why supersession is structural, not a ranking rule (a measured finding, v0.3)
Real vaults keep old versions: last year's expenses policy next to this year's, an archived
datasheet, a superseded deploy runbook. Mark the old one in its frontmatter:
```yaml
---
title: Expenses Policy
superseded_by: internal/hr/expenses-policy.md
---
```
and it is excluded from the search index at build time — the same mechanism as entitlements,
so it can never be scored, ranked or cited — while `read_note` still returns it by path,
flagged, so history stays reachable on purpose and never by accident.
Why exclusion rather than a recency boost: measured before this existed, with four stale notes
placed beside their successors, the stale copy **tied its successor on BM25 score in 7 of 12
queries, tied on coverage in 11 of 12, ranked first in 8 of 12 and was cited in 12 of 12**.
Where scores tied the stale note won every time, because `-2025.md` sorts before `.md`.
Both abstention gates passed it in 12 of 12: a stale note *is* relevant, it is just no longer
true, and no relevance signal can see that. The v0.2 scorecard reported the damage as a
ranking regression in retrieval and named the wrong cause. v0.3 gives "no longer true" its
own slice, with a hard zero.
## HTTP mode (v0.2): real per-client entitlements
```bash
python -m grounded_mcp.authz my-raw-token # -> sha256 for the tokens file
GROUNDED_TRANSPORT=http GROUNDED_TOKENS=tokens.yaml GROUNDED_VAULT=~/notes grounded-mcp
```
Each client's bearer token maps to an entitlements profile (see `example-tokens.yaml`);
the server keeps one index per profile, so enforcement stays index-level per VERIFIED
identity. Unknown token = 401, never a default. Empty token file = server refuses to
start. Tested over the wire: two tokens against one live server get different vaults,
and the leakage probe runs as a real client.
## Honest boundaries
- **stdio = one user.** Over stdio, client and server run as the same user, so profiles
demonstrate the *deployment pattern* rather than enforce against a hostile peer — for
real multi-client enforcement, use HTTP mode (above). The enforcement machinery is
identical either way — index-level, not response-filtering.
- **A denied note and a nonexistent note return the same response.** The server refuses to be
an existence oracle for content outside your entitlements.
- **The redaction patterns are high-precision, not exhaustive.** They catch key-shaped strings
(AWS/GitHub/Slack tokens, private-key blocks, `api_key = "..."` assignments), not every secret.
- **Supersession is declared, not detected.** The server trusts `superseded_by` in frontmatter;
it does not guess that two similar notes are versions of each other. A stale note nobody
marked is still served — the eval slice exists so you can measure how many you have.
- **BM25 is the deliberate v0.1 baseline** — deterministic, dependency-free, measurable. Hybrid
semantic retrieval lands in v0.4 *with its eval delta published, per slice, against this baseline*.
## The demo vault
A fictional company handbook ("ACME Ltd") with three zones — `public/`, `internal/`,
`restricted/` — plus one deliberately seeded fake-credentials note (the classic AWS
documentation example key) that the redaction guard must refuse to serve, and four
`*-2025.md` notes marked `superseded_by` their current versions so the superseded slice has
something real-shaped to fail on. All content is
synthetic; the vault exists so the eval suite has something real-shaped to prove things against.
## Development
```bash
pip install -e ".[dev]"
pytest -q # unit tests
python evals/run_evals.py # the eval gate (CI runs both)
python evals/run_evals.py --write-baseline # accept current scores after a deliberate change
```
## Roadmap
- **v0.2** ✅ shipped — streamable HTTP transport with per-client entitlements.
- **v0.3** ✅ shipped — supersession (`superseded_by`) enforced at index build, plus the slice-aware scorecard (answerable / unsupported / superseded / denied / secret, n next to pass/fail, false abstention split from misses).
- **v0.4** — hybrid semantic retrieval, landing only with its eval delta vs the BM25 baseline published *per slice*.
- Pluggable store backends (the vault interface is small); community adapters welcome.
MIT © Richard Atkins
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues