Content
# custodian-mcp
A **governed agent-memory MCP server**: fail-closed authentication, strict
multi-tenant isolation, and audited authorization — on synthetic data.
<p align="center">
<a href="https://github.com/Zacplischka/custodian-mcp/actions/workflows/ci.yml"><img src="https://github.com/Zacplischka/custodian-mcp/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
<img src="https://img.shields.io/badge/node-%E2%89%A520-339933?logo=node.js&logoColor=white" alt="Node >= 20">
</p>
<p align="center">
<a href="https://zacplischka.vercel.app/work/clarence">Case study</a> ·
<a href="#see-it-refuse">See it refuse</a> ·
<a href="#architecture">Architecture</a> ·
<a href="#quickstart">Quickstart</a> ·
<a href="#security-model">Security model</a> ·
<a href="#design-decisions">Design decisions</a>
</p>
> [!NOTE]
> **What this is, honestly:** This is a standalone demonstration on synthetic
> data of architecture patterns from a production multi-tenant
> agent-knowledge platform I built. It is not that system and contains none
> of its code or data. The three tenants (acme, globex, initech) are
> fictional, every knowledge entry is invented, and this repo makes no claims
> about production usage of itself. The production system's architecture is
> written up as a case study: <https://zacplischka.vercel.app/work/clarence>.
## Why governed memory
A vector database stores facts — it cannot tell you which version is true,
and it certainly cannot tell you *who is allowed to know*. As soon as agents
share a memory across clients or teams, the hard problem stops being
retrieval and becomes governance: **who may read or write which tenant's
memory, and can you prove it afterwards?**
This repo demonstrates that governance half, end to end and offline:
- a **fail-closed agent-key gateway** in front of the MCP server,
- a **tool envelope** that makes parse-then-authorize a single structural
code path for every tool,
- **tenant isolation by allow-list intersection**, enforced twice,
- an **append-only audit log** of every allow *and* deny, with reasons,
- and a real-HTTP test harness that proves all of the above.
## See it refuse
Everything below is real output from the quickstart (Node 24, June 2026).
An unknown key is rejected with a structured reason:
```console
$ curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:7070/mcp \
-H "Authorization: Bearer ak_live_0000000000000000000000000000000000000000" ...
{"error":"unauthorized","reason":"unknown_key"}
HTTP 401
```
A *valid* key asking to read another tenant's memory is denied loudly — not
silently narrowed. Here the `demo-acme` key (allow-list: `acme`) requests
tenant `globex`:
```json
{
"error": "tenant_out_of_scope",
"tool": "search_knowledge",
"message": "This agent key is not authorized for tenant(s): globex.",
"requestedTenants": ["globex"],
"allowedTenants": ["acme"]
}
```
And the audit log has one JSONL row for every decision above — denies
included, raw tokens never:
```jsonl
{"ts":"2026-06-11T23:12:40.807Z","kind":"auth","decision":"deny","reason":"unknown_key"}
{"ts":"2026-06-11T23:12:40.819Z","kind":"auth","decision":"allow","reason":"key_verified","keyId":"key_19750e7fd0e0","grantedTenants":["acme"]}
{"ts":"2026-06-11T23:12:40.831Z","kind":"authz","decision":"deny","reason":"tenant_out_of_scope","keyId":"key_19750e7fd0e0","tool":"search_knowledge","requestedTenants":["globex"],"grantedTenants":[]}
{"ts":"2026-06-11T23:12:40.845Z","kind":"authz","decision":"allow","reason":"tenant_scope_granted","keyId":"key_19750e7fd0e0","tool":"search_knowledge","requestedTenants":[],"grantedTenants":["acme"]}
```
The test suite asserts this behaviour over real HTTP:
```console
$ npm test
Test Files 7 passed (7)
Tests 64 passed (64)
Duration 687ms
```
## Architecture
```mermaid
flowchart TD
client["MCP client<br/>(Claude Code, or any other)"]
gateway["agent-key gateway — node:http<br/>strips forged x-custodian-principal · SHA-256 key lookup<br/>drops raw Authorization · stamps verified principal"]
deny401["401 {reason}<br/>missing_token · malformed_token<br/>unknown_key · revoked_key · expired_key"]
deny503["503 — key store unreadable<br/>fail closed, never anonymous"]
server["MCP server — Streamable HTTP, stateless per request"]
envelope["Tool Envelope, fixed order:<br/>1. zod-parse args · 2. tenant authorization · 3. handler"]
denyScope["structured deny<br/>tenant_out_of_scope"]
store["knowledge store (port)<br/>JSON file + BM25-style search<br/>re-filters by scope — 2nd isolation layer"]
audit[("audit log — JSONL<br/>every allow AND deny, with reason")]
client -- "Authorization: Bearer ak_live_…" --> gateway
gateway --> deny401
gateway --> deny503
gateway -- "Principal {keyId, tenants}" --> server
server --> envelope
envelope --> denyScope
envelope -- "granted tenant scope" --> store
gateway -.-> audit
envelope -.-> audit
```
All four tools are registered through the same envelope:
| Tool | What it does | Access |
|---|---|---|
| `search_knowledge` | Keyword (BM25-style) search over governed entries | read — requested tenants intersected with the key's allow-list |
| `capture_knowledge` | Write a note / decision / lesson into one tenant's memory | write — target tenant must be on the allow-list |
| `browse_recent` | Most recent entries across readable tenants, newest first | read |
| `knowledge_stats` | Per-tenant counts by kind + latest capture timestamp | read |
## Quickstart
Requires Node >= 20. Everything runs offline; there are no API keys and no
network calls to paid services.
```bash
npm install
npm run seed # generate the synthetic corpus (3 fictional tenants)
npm run keys # mint demo agent keys — RAW TOKENS PRINT ONCE, hashes stored
npm start # gateway + MCP server on http://localhost:7070/mcp
```
Then connect Claude Code (or any MCP client) with one of the printed tokens:
```bash
claude mcp add --transport http custodian http://localhost:7070/mcp \
--header "Authorization: Bearer ak_live_…"
```
or in an MCP config file:
```json
{
"mcpServers": {
"custodian": {
"type": "http",
"url": "http://localhost:7070/mcp",
"headers": { "Authorization": "Bearer ak_live_…" }
}
}
}
```
Local single-tenant dev without the gateway (dev-only; no authentication on
this path, tenant via `CUSTODIAN_TENANT`, default `acme`):
```bash
npm run dev:stdio
```
## Security model
- **Fail-closed gateway** ([`src/gateway/gateway.ts`](src/gateway/gateway.ts)).
Authentication happens before any MCP handling. If the key store is
unavailable or unreadable, the gateway returns 503 — it never falls back to
anonymous access.
- **Hash-only key storage** ([`src/gateway/keyStore.ts`](src/gateway/keyStore.ts)).
Tokens look like `ak_live_<random>`; the key store keeps only SHA-256 hashes
plus tenant allow-list, revoked flag and optional expiry. A leaked key file
contains nothing replayable. Raw tokens are printed once at mint time and
never persisted or audited.
- **Structured 401 reason matrix.** `missing_token`, `malformed_token`,
`unknown_key`, `revoked_key`, `expired_key` — in the response body and in
the audit log.
- **Principal-forgery defence.** The gateway deletes any inbound
`x-custodian-principal` header (and writes a `flag` audit row) before
authenticating, then stamps the verified principal itself. The raw
`Authorization` header is dropped at the gateway; nothing inward of it ever
sees a token.
- **One authorization code path** ([`src/server/envelope.ts`](src/server/envelope.ts)).
Every tool goes through the Tool Envelope: zod-parse, then tenant
authorization against the key's allow-list, then — and only then — the
handler, which receives the *granted* scope. Reads that name an
out-of-scope tenant are denied loudly, not silently narrowed; writes must
target an allowed tenant. The store
([`src/store/jsonKnowledgeStore.ts`](src/store/jsonKnowledgeStore.ts))
re-filters by scope as a second layer.
- **Audited everything** ([`src/audit/auditSink.ts`](src/audit/auditSink.ts)).
Every authentication and authorization decision — allow and deny — appends
one JSONL row with a reason. The test suite asserts exact row counts for
scripted sequences and that raw tokens never appear in audit rows.
The authz harness ([`test/harness.test.ts`](test/harness.test.ts)) boots the
real gateway and MCP server on an ephemeral port and proves the 401 matrix,
the 503 fail-closed path, forgery defence, the cross-tenant denial matrix,
and audit accounting over real HTTP with a real MCP client.
## Development
```bash
npm test # vitest — 7 files, 64 tests, < 1s, no network
npm run typecheck # tsc --noEmit
```
Repository layout:
```
src/gateway/ fail-closed agent-key gateway, key store, token hashing, authentication
src/server/ MCP server, Tool Envelope, governed tools, HTTP + stdio entrypoints
src/store/ KnowledgeStore port, JSON-file/in-memory adapter, BM25-style scoring
src/audit/ AuditSink port, JSONL + in-memory sinks
src/seed/ deterministic synthetic corpus for the three fictional tenants
src/cli/ `npm run seed` and `npm run keys`
docs/adr/ four short ADRs documenting the decisions above
test/ vitest suite: auth matrix, envelope ordering, isolation matrix,
store behaviour, audit sink, real-HTTP authz harness
```
## Design decisions
| ADR | Decision |
|---|---|
| [0001](docs/adr/0001-fail-closed-agent-key-gateway.md) | Fail-closed agent-key gateway in front of the MCP server |
| [0002](docs/adr/0002-tool-envelope-parse-then-authorize.md) | Tool Envelope: parse-then-authorize as a single code path |
| [0003](docs/adr/0003-isolation-via-allow-list-intersection.md) | Tenant isolation via allow-list intersection |
| [0004](docs/adr/0004-keyword-search-instead-of-embeddings.md) | Keyword (BM25-style) search instead of embeddings |
## Limitations (on purpose, and otherwise)
- **Keyword search, not embeddings.** The production pattern this mirrors
uses vector embeddings and a knowledge graph; this demo uses deterministic
BM25-style scoring so it stays free, offline, and exactly testable
(ADR 0004). Semantic recall is accordingly modest.
- **Demo-scale persistence.** JSON files, re-read/rewritten whole; no
concurrency control across processes, no migrations, no backups.
- **No TLS, no rate limiting, no key rotation ceremony.** The gateway
demonstrates authentication, isolation and audit — it is not a hardened
internet-facing deployment.
- **Stdio path is unauthenticated by design** and documented as local
dev-only.
- **Synthetic data only.** Nothing here derives from any real client or
production system.
## License
MIT — Zac Plischka <zacplischka@gmail.com>
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available