An MCP server over a personal knowledge vault, built to answer one question properly: how does an AI agent prove who it is, and what is it allowed to read?
The vault is a git repository of Markdown pages. Some of them are ordinary. Some are encrypted on the remote with git-crypt because they are confidential. A few hold identity documents of real people. Until now all of them sat behind one shared bearer token on a private website — the same token for every reader, every device and every agent. That is OWASP NHI9 (identity reuse) and NHI10 (a human using a machine credential), and the repository's own notes had flagged it as accepted-for-now since August 2026.
This server replaces it. Every client is an OAuth 2.1 client with its own identity, every token is bound to this server as its audience, and every page is served or refused by scope.
Two tools, both read-only:
| Tool | Input | Output |
|---|---|---|
search |
query, optional limit |
pages whose title or body match, with a snippet |
read_page |
path |
one page with its content |
Both filter by the caller's scopes. A page the caller may not see is indistinguishable from a page that does not exist; the server logs the real reason (path, class, missing scope, subject), the client does not learn it.
Confidentiality is derived from the vault's own .gitattributes, not
configured here. Every path git-crypt encrypts is confidential; two rules
refine it.
| Class | Rule | Scope that unlocks it |
|---|---|---|
ordinary |
not encrypted | wiki:read |
confidential |
git-crypt pattern matches | wiki:confidential |
people |
git-crypt and under wiki/people/ |
wiki:people |
secret |
*.private.md (credential values, by vault convention) |
none — never served |
Scopes are additive and none implies another. An agent that needs ordinary
and confidential pages asks for both; access to wiki/people/ is a separate
decision every time. The scope model and the classifier are in
internal/authz/scope.go and internal/vault/classify.go, with tests
against the real vault's rules.
The server side of the MCP authorization specification, which is OAuth 2.1 with a fixed set of extensions:
- RFC 9728 protected-resource metadata at
/.well-known/oauth-protected-resource/mcp, naming the authorization server and the scopes above. - 401 +
WWW-Authenticatepointing at that document, so a client with no token, or the wrong one, discovers where to get a right one. - RFC 8414 authorization-server metadata, discovered from the issuer.
- PKCE (S256), mandatory, public clients only.
- RFC 8707
resourceparameter on the authorization and token requests, so the access token'saudis this server's canonical URI. A token minted for any other resource is rejected. This is what makes token passthrough impossible by construction rather than by policy. - RFC 9068 JWT access tokens:
typ=at+jwt,iss,aud,exp,scope,sub, verified against the issuer's JWKS on every request.
The HTTP middleware and the metadata handler come from the official
Go SDK (auth and
oauthex packages). The token verifier, the scope model, the classifier and
the tools are this repository's.
Written on day one so that the tests describe the target before the code exists. What is real and what is not:
| Piece | State |
|---|---|
Classifier from .gitattributes, four classes |
done, tested against the real vault rules |
Scope model, Permit, secrets never served |
done, tested |
| Vault store: search, read, traversal and editor-cache guards | done, tested |
| MCP tools, scope-filtered | done, exercised only through the store tests |
| RFC 9728 metadata endpoint, 401 challenge | done, verified with curl against the real vault |
Token verifier (authz.NewVerifier) |
stub: rejects everything. Eight skipped tests in internal/authz/jwt_test.go say what it must do |
Dev authorization server (cmd/devauth) |
stub: the package comment is the endpoint list |
| Confused-deputy test with an upstream API | not started; a read-only vault has no upstream. Planned as a third tool that calls a separate service with the server's own credential, never the inbound token |
Deployment on fenrir behind nginx, systemd unit in deploy/ |
unit written, not installed |
The verifier and the dev AS are scheduled for 22–23 September 2026. Until then the server runs, publishes its metadata, and refuses every token — which is the correct behaviour for a resource server whose trust anchor is not configured yet.
go build ./cmd/brain-mcp
BRAIN_MCP_VAULT=/path/to/vault \
BRAIN_MCP_LISTEN=127.0.0.1:8090 \
BRAIN_MCP_RESOURCE=https://mcp.example.org/mcp \
BRAIN_MCP_ISSUER=https://auth.example.org \
BRAIN_MCP_JWKS_URL=https://auth.example.org/jwks \
./brain-mcpgo test ./... # skipped tests are the unbuilt verifier; see aboveThe vault path is runtime configuration. This repository contains no vault content and never will.
- Deny reads as not-found. Returning 403 for
wiki/people/x.mdconfirms thatxexists. The cost is a less helpful error for a misconfigured client; the operator log carries the reason instead. - No scope implies another.
wiki:confidentialdoes not includewiki:read. Verbose for clients, but every grant is explicit and readable in the token. - Secrets have no scope.
*.private.mdcannot be requested. Not "requires admin"; not served, full stop. - Classification lives in the vault, not in this server. If the repo starts encrypting a new directory, the server refuses it on the next request with no deploy. The rule that decides what is sensitive should be in exactly one place.
- The SDK's middleware, not a hand-rolled one. The interesting code is the
verifier and the policy, and the
WWW-Authenticateformat is not where a reviewer's time should go.