内容
受管内存服务器
一个**受代理内存服务器**:关闭失败认证,严格的多租和审计授权 — 基于合成数据。
<p align="center">
a href="https://github.com/Zacplischkaodian-mcp/actions/workflows.yml"><img src="github.com/Zacplischka/custodian-mcp/actionsci.yml/badge.svg" alt="持续集成"></a>
a href="LICENSE"><="https://img.shields/license-MIT-blue.svg" alt="许可证:MIT"></ <img src="https://img.shields.io/badge/node-%%89%A520-339933?logo=node.js&logoColor=white" alt="Node >=">
</p>
<pcenter">
<a href="https://zacplischka/work/clarence">案例研究</a> ·
href="#see-it-refuse">查看拒绝</a>a href="#architecture">a> ·
<a href="#quickstart"></a> ·
href="#security-model"></a> ·
href="#design-decisions">设计决策</a>
</p [!NOTE]
> **这是什么,坦率地说:是一个独立的演示,基于合成数据,展示了我构建多租户代理知识平台的架构模式。它不是该包含其代码或数据。三个租户(acmeex,initech)是虚构的,每个知识条目都是虚构的,本仓库不自己可以用于生产生产系统的架构已编写为案例研究:<https://ischka.vercel.app/workence>。
## 为什么需要受管内存
向量数据库存储事实 — 它无法告诉你正确的,也无法告诉你**谁有权知道**。只要代理跨客户端或团队共享内存,难题就不再是检索,而是治理:**谁可以读取或写入哪个租户的内存,你能证明吗?**
本仓库演示了治理部分,从头到尾和离线:
- **关闭失败的代理密钥网关** 在MCP服务器前面,
- 一个**工具信封**,使解析然后授权成为单个结构化代码路径,工具,
- **租户隔离通过允许列表交集强制执行两次,
- **追加审计日志**,允许和拒绝,附带原因,
- 以及一个真正的工具,证明上述所有内容。
## 查看拒绝
以下都是快速入门(Node 24,2026年6输出。未知密钥被拒绝,附带结构化```console
$ curl -s -w "\nhttp_code}\n" -X POST http://localhost:707cp \
-Authorization: Bearer ak000000000000000000000000000000000000" ...
{"error":"","reason":"unknown_key 401
```
一个*有效*密钥请求读取另一个租户的内存被大声拒绝 — 而不是静默缩小 这里的`demo-acme`密钥(允许列表acme`)请求`globex`:json
{
" "tenant_out_of_scopetool": "search_knowledge",
"message此代理密钥不授权租户():globex。",
requestedTenants": ["glob "allowedTen ["acme"]
}
```
审计日志有一条JSONL上述每个决策 — 拒绝包含,原始令牌:
```jsonl
{"ts":"2026-11T23:12:40.807Z","kind","decision":"deny","reason":"unknown_key"}
{"2026-06-11T23:12:40Z","kind":"auth","decision":"allow","reason_verified","keyId":"key_19750e7fd0","grantedTenants":["acme"]}
{"ts6-06-11T23:12:40.","kind":"authz","decision":"deny","reason_out_of_scope","keyId":"key_19750e7fd0e0","search_knowledge","requested":["globex"],"grantedTenants":[]}
{"2026-06-11T23:12:40Z","kind":"authz","decision":"allow","reason":"tenant_scope_granted","":"key_19750e0e0","tool":"search_knowledge","ants":[],"grantedTenants":["acme"]}
```
测试通过真正的HTTP断行为:
```console npm test
Test Files 7 passed (7)
64 passed (64)
Duration 687ms
```
## 架构
```mermaid
flowchart TD
client["MCP 客户端<br/>(Claude Code, 或其他)"]
gateway["代理密钥网关 — node:http<br/>剥离伪造的 x-custodian-principal · SHA-256 密钥查找<br/>删除原始 Authorization标记验证主体"]
deny401["401br/>missing_token · malformed_token<br/>unknown_key · revoked_key · expired_key deny503["503 — 密钥存储不可读<br/>关闭失败,绝不 server["MCP 服务器 — 流式 HTTP,无状态 envelope["工具信固定顺序:<. zod 解析参数 · 2. 租 · 3. 处理程序"]
denyScope["结构<br/>tenant_out_of_scope"]
store["知识端口)<br/>JSON 文件 + BM25 <br/>按范围重新过滤 — 第二层隔离层"]
audit[("审计日志 — JSONL<br/>每个允许和拒绝,附带原因")]
client -- "Authorization: Bearer ak_live_…" --> gateway
gateway --> deny401
gateway --> deny503
gateway -- "主体 {keyId, tenants}" --> server
server --> envelope
envelope --> denyScope
envelope -- "授予的租户范围" --> store
gateway -.-> audit
envelope -.-> audit
```
所有四个工具都通过同一个信封注册:
| 工具 | 功能 | 访问权限 |
|---|---|---|
| `search_knowledge` | 受管条目的关键词(BM25 风格)搜索 | 读取 — 请求的租户与密钥的允许列表相交 |
| `capture_knowledge` | 在单个租户的内存中写入备注/决策/经验 | 写入 — 目标租户必须在允许列表中 |
| `browse_recent` | 跨可读租户的最新条目,按最新顺序排列 | 读取 |
| `knowledge_stats` | 按类型划分的每个租户的计数 + 最新捕获时间戳 | 读取 |
## 快速开始
需要 Node >= 20。所有操作都离线进行;没有 API 密钥,也没有对付费服务的网络调用。
```bash
npm install
npm run seed # 生成合成语料库(3个虚构租户)
npm run keys # 铸造演示代理密钥 — 原始令牌只打印一次,存储哈希值
npm start # 网关 + MCP 服务器在 http://localhost:7070/mcp
```
然后使用打印的令牌之一连接 Claude Code(或任何 MCP 客户端):
```bash
claude mcp add --transport http custodian http://localhost:7070/mcp \
--header "Authorization: Bearer ak_live_…"
```
或在MCP配置文件中:
```json
{
"mcpServers": {
"custodian": {
"type": "http",
"url": "http://localhost:7070/mcp",
"headers": { "Authorization": "Bearer ak_live_…" }
}
}
}
```
本地单租户开发,无网关(仅限开发;此路径上无身份验证,租户通过 `CUSTODIAN_TENANT`,默认为 `acme`):
```bash
npm run dev:stdio
```
## 安全模型
- **关闭失败的网关** ([`src/gateway/gateway.ts`](src/gateway/gateway.ts))。身份验证发生在任何MCP处理之前。如果密钥存储不可用或无法读取,网关返回503 — 它绝不会回落到匿名访问。
- **仅哈希密钥存储** ([`src/gateway/keyStore.ts`](src/gateway/keyStore.ts))。令牌类似于 `ak_live_<random>`;密钥存储仅保留SHA-256哈希值,加上租户允许列表、撤销标志和可选过期时间。泄露的密钥文件中不包含任何可重放的内容。原始令牌仅在铸造时打印一次,从不持久化或审计。
- **结构化401原因矩阵。** `missing_token`, `malformed_token`, `unknown_key`, `revoked_key`, `expired_key` — 在响应正文中和审计日志中。
- **主体伪造防御。** 网关删除任何传入的 `x-custodian-principal` 头部(并写入 `flag` 审计行),然后在身份验证之前验证主体,然后标记验证后的主体。原始 `Authorization` 头部在网关处被删除;其内部的任何内容都从未看到令牌。
- **单个授权代码路径** ([`src/server/envelope.ts`](src/server/envelope.ts))。每个工具都经过工具信封:zod 解析,然后针对密钥的允许列表进行租户授权,然后 — 且仅然后 — 处理程序接收 **授予** 的范围。读取以名称表示的非范围租户被大声拒绝,而不是静默缩小范围;写入必须针对允许的租户。存储 ([`src/store/jsonKnowledgeStore.ts`](src/store/jsonKnowledgeStore.ts)) 按范围重新过滤作为第二层。
- **审计所有内容** ([`src/audit/auditSink.ts`](src/audit/auditSink.ts))。每个身份验证和授权决策 — 允许和拒绝 — 都追加一行 JSONL,附带原因。测试套件断言脚本化序列的确切行数和原始令牌从不出现在审计行中。
身份验证工具包([`test/harness.test.ts`](test/harness.test.ts))启动真正的网关和MCP服务器在临时端口上,并通过真正的MCP客户端和真正的HTTP证明401矩阵、503关闭失败路径、伪造防御、跨租户拒绝矩阵和审计会计。
## 开发
```bash
npm test # vitest — 7 文件,64 测试,< 1s,无网络
npm run typecheck # tsc --noEmit
```
仓库布局:
```
src/gateway/ 关闭失败的代理密钥网关,密钥存储,令牌哈希,身份验证
src/server/ MCP 服务器,工具信封,受管工具,HTTP + stdio 入口点
src/store/ 知识存储端口,JSON 文件/内存适配器,BM25 风格评分
src/audit/ 审计端口,JSONL + 内存接收器
src/seed/ 三个虚构租户的确定性合成语料库
src/cli/ `npm run seed` 和 `npm run keys`
docs/adr/ 四个短 ADR 文档记录上述决策
test/ vitest 套件:身份验证矩阵,信封排序,隔离矩阵,存储行为,审计接收器,真实 HTTP 身份验证工具包
```
## 设计决策
| ADR | 决策 |
|---|---|
| [0001](docs/adr/0001-fail-closed-agent-key-gateway.md) | MCP 服务器前置关闭失败的代理密钥网关 |
| [0002](docs/adr/0002-tool-envelope-parse-then-authorize.md) | 工具信封:解析然后授权作为单个代码路径 |
| [0003](docs/adr/0003-isolation-via-allow-list-intersection.md) | 通过允许列表交集实现租户隔离 |
| [0004](docs/adr/0004-keyword-search-instead-of-embeddings.md) | 关键词(BM25 风格)搜索代替嵌入 |
## 限制(故意和非故意)
- **关键词搜索,而不是嵌入。** 此演示的镜像生产模式使用向量嵌入和知识图谱;此演示使用确定性 BM25 风格评分,以便保持免费、离线和精确可测试(ADR 0004)。语义召回因此较为谦逊。
- **演示规模的持久性。** JSON 文件,重新读取/重写整个文件;没有跨进程的并发控制,没有迁移,没有备份。
- **无 TLS,无速率限制,无密钥轮换仪式。** 网关演示身份验证、隔离和审计 — 它不是一个坚固的面向互联网的部署。
- **标准输入/输出路径按设计无身份验证**,并记录为仅限本地开发。
- **仅合成数据。** 这里没有任何内容源自任何真实客户端或生产系统。
## 许可证
MIT — Zac Plischka <zacplischka@gmail.com>