内容
# PortunusMCP
**一种强制执行策略的网关代理,用于模型上下文协议(MCP)** —— 基于身份的工具可见性,模式漂移("rug pull")检测,每次调用的风险评分,以及一个防篡改的签名审计日志,无需更改上游MCP服务器。
[](https://github.com/BashaarJavaid/PortunusMCP/actions/workflows/ci.yml)
-brightgreen)

[](./LICENSE)

*一个低权限身份只能看到其被允许的工具。上游服务器在会话期间拉取自己的模式,但网关将漂移分类为关键,阻止调用,并将其保留供人类重新批准。一个字节相同的重放请求被拒绝。最后,针对刚刚发生的流量模拟一个草稿策略。*
---
## 为什么
MCP定义了LLM客户端和工具服务器之间的JSON-RPC 2.0传输,但故意将授权、审计性和完整性排除在范围之外——它假设部署组织会构建这一层。实际上,几乎没有人这样做,这留下了三个具体的问题:
1. **没有基于身份的工具可见性。** 任何可以访问服务器的客户端都可以获得服务器的完整`tools/list`。没有原生的“此用户应只能看到工具的子集”的功能。
2. **Rug pulls(模式漂移)。** 服务器可以在人类批准后更改工具的模式,但没有任何检测漂移的方法。
3. **没有审计日志。** 没有记录哪个身份调用了哪个工具,带有哪些参数,在哪种策略下,以一种可以后来证明未被编辑的形式。
PortunusMCP位于两者之间,解决了这三个问题。
### 客户端兼容性
上游服务器无需更改客户端即可进行代理。默认的`bearer`模式适用于客户端可以附加`X-PortunusMCP-Key`到远程MCP连接的情况。
| 客户端 | 结果 | 证据 |
|---|---|---|
| [Claude Desktop](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) 1.20186.0 | **与bearer不兼容** | 远程自定义连接器无法附加头部;它收到401,然后尝试OAuth发现和动态客户端注册。 |
| [Cursor](https://docs.cursor.com/context/model-context-protocol) 3.13.10 | **兼容** | 用户级配置以下连接并提供`tools/list`,包含`send_email`和`read_inbox`,同时修剪`delete_mailbox`;签名审计行记录了相同的集合。 |
| Python MCP SDK 1.28.1 `ClientSession` | **兼容** | 默认的流式HTTP客户端生成了相同的修剪后的`tools/list`;集成套件对此路径进行了端到端的测试。 |
这些是2026-07-27在macOS 15.7.3 arm64上的实时结果。Cursor的测试用户级`~/.cursor/mcp.json`条目为:
```json
{
"mcpServers": {
"portunusmcp": {
"url": "https://gateway.example.com/mcp/default",
"headers": {
"X-PortunusMCP-Key": "${env:PORTUNUSMCP_API_KEY}"
}
}
}
}
```
启动Cursor时,将`PORTUNUSMCP_API_KEY`放入其环境中;不要将原始密钥放入提交的文件中。等效的SDK连接为:
```python
import os
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
headers = {"X-PortunusMCP-Key": os.environ["PORTUNUSMCP_API_KEY"]}
async with streamable_http_client(
"https://gateway.example.com/mcp/default", headers=headers
) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
```
身份验证姿态是每个身份的([`ROADMAP.md`](./ROADMAP.md)项目34):可以采用小型签名客户端的身份选择进入`signed`模式,其中请求携带一个非秘密密钥ID和一个HMAC调用——在这种情况下,线路上没有凭证,这使得重放保护真正起作用(捕获的请求无法用新鲜的nonce重新签名)。权衡是诚实的:`bearer` = 标准客户端加上头部配置,密钥随请求而去;`signed` = 自定义客户端,捕获证明。
---
## 架构
网关内的每个模块都是`services/gateway/`中的一个模块,带有相同的名称;编号阶段是`ARCHITECTURE.md`§4.2顺序中的决策管道。序列、部署和数据流图位于§4.4–§4.7。
```mermaid
graph TD
Client["MCP Client"] -->|"Streamable HTTP"| Interceptor
subgraph Gateway["PortunusMCP Gateway process"]
Interceptor["JSON-RPC Interceptor + Session Manager"]
Interceptor --> Replay["1 Replay Guard"]
Replay --> Auth["2 Auth / Identity"]
Auth --> Policy["3+4 Policy Engine: RBAC + ABAC conditions + versioning"]
Policy --> Drift["5 Drift Detector"]
Drift --> Risk["6 Risk Engine"]
Risk --> Validator["7 Param Validator"]
Validator --> UpClient["Upstream Client"]
Interceptor --> SchemaCache["Schema Cache + Pruner (tools/list)"]
Interceptor --> AuditW["Audit Log Writer (hash-chained, ECDSA-signed)"]
Approvals["Approvals lifecycle (admin API)"]
Explainer["Decision Explainer (admin API)"]
Simulator["Policy Simulator (admin API)"]
end
UpClient --> Docker["Local Docker daemon"]
Docker --> Srv["Hardened upstream container (one per session)"]
Replay --> Redis[("Redis: nonces, schema cache, risk counters, session TTL")]
Risk --> Redis
SchemaCache --> Redis
Policy --> PG[("Postgres: audit_log, policy_versions, tool_baselines, approvals")]
Drift --> PG
AuditW --> PG
Approvals --> PG
Explainer --> PG
Simulator --> PG
Policy --> Rev["policy root: active file, staging journal, revision snapshots"]
Simulator --> Rev
Verifier["audit_verifier sidecar (separate process, read-only chain walk)"] --> PG
```
**多个上游服务器在策略的`servers:`块中注册**,作为键入的容器规格,版本化和回滚与策略的其他部分相同。客户端连接到`/mcp/<server_id>`;一个会话拥有一个强化上游容器。RBAC授予、漂移基线、模式缓存、风险计数器、批准和运行时指纹都以实际的`server_id`为键。
```yaml
servers:
github:
image: "ghcr.io/acme/github-mcp@sha256:..."
command: ["python", "-m", "github_mcp"]
env:
GITHUB_TOKEN: PORTUNUSMCP_UPSTREAM_GITHUB_TOKEN
volumes:
- source: "portunusmcp-upstream-dev-github-config"
target: "/config"
network: none
resources: {memory_mb: 256, cpus: 0.5, pids: 64}
```
本地Docker守护进程、每个引用的镜像和每个命名的卷在策略激活之前都经过预检;镜像在运行时从不被拉取。容器以UID 65532运行,具有只读根目录、限制的`/tmp`、没有新特权、没有能力以及有界内存/CPU/PID。环境值只能来自主机变量前缀`PORTUNUSMCP_UPSTREAM_`;网关数据库、签名、TOTP和审计密钥秘密不被继承。网络默认为`none`。参见[ADR-007](./docs/adr/ADR-007-upstream-container-isolation.md)。
---
## 威胁模型(摘要)
完整版本,包括整个模型所依赖的假设,在[`THREAT_MODEL.md`](./THREAT_MODEL.md)中。它明确说明了未涵盖的内容——诚实的范围边界比隐含的总覆盖声明更有价值。
| 威胁 | 保护? | 如何 / 为什么不 |
|---|---|---|
| 已知身份的未经授权工具访问 | 是 | RBAC + ABAC策略解析 |
| 流氓/拉取MCP服务器(模式突变) | 是 | 漂移检测器分类突变;`tools/call`上的高/关键阻塞,直到重新批准 |
| 授权身份的上下文风险调用 | 是 | 风险引擎——挑战/人工批准/拒绝,按分数段 |
| 审计日志篡改 | 是 | 哈希链 + 每行ECDSA签名;由一个仅持有公钥的边车独立验证 |
| 捕获请求的重放 | **是(signed)/部分(bearer)** | 签名请求不携带凭证:字节相同的重放被去重(`DENY_REPLAY`),并且新的nonce无法重新签名(边缘401)。`bearer`仅保持机会性去重——API密钥随捕获的请求而去 |
| 工具中毒(描述中的对抗性文本) | **部分** | 批准后更改描述会阻塞直到重新批准(默认高,项目36a);首次接触基线被启发式扫描——命中被审计(`BASELINE_FLAGGED`)并提高后续每个调用的风险,但标志永远不会阻塞,新型措辞会逃避模式列表。描述仍然逐字到达LLM——部分是上限 |
| 破坏的注册上游读取网关秘密 | **是(有范围)** | 会话强化容器接收最小的允许环境和网关秘密目录、Docker套接字或DB/Redis凭证;主机/网关泄露仍然超出范围 |
| 针对Streamable HTTP的DNS绑定攻击 | **是(有范围)** | MCP SDK验证每个`/mcp/*`主机和提供的源与配置的允许列表是否一致,然后进行身份验证或解析;部署必须配置其真实的公共名称 |
| 身份验证资源耗尽/工具卡住 | **部分** | 基于身份的会话、正在进行的调用、固定窗口速率限制、有界体/深度和60秒调用截止日期上限一个身份;许多身份仍然可以耗尽主机,并且截止日期无法撤销上游副作用已经开始 |
| 未身份验证的凭证填充/身份验证路径可用性 | **部分** | 坏的bearer/signed凭证被固定窗口限制在MCP/admin和提醒;地址轮换、初始并发突发和共享NAT抵押品仍然存在 |
| 通过工具*结果*的提示注入 | 部分 | 协议层网关可以记录和限制速率,但不能语义评估结果内容——客户端/代理框架责任 |
| 盗窃API密钥 | 部分 | 行为风险因素减少爆炸半径;密钥本身无法与持有者区分开来。`signed`身份的秘密从不出现在线路上——窃取它意味着破坏主机环境 |
| 破坏的网关主机 | 否 | 攻击者拥有签名密钥——基础设施硬化问题,而不是应用程序问题 |
| 内部人员滥用合法访问 | 否 | 可归因且防篡改后审计,不会阻止;设计了两人激活,未构建 |
---
## 运行演示
```bash
cp .env.demo.example .env.demo
python scripts/generate_signing_key.py # 一次:审计签名密钥对(网关在没有它时不会启动)
python scripts/run_demo.py # 重置演示状态,生成密钥,写入policies/demo-policy.yaml,等待
```
```bash
# 首先在.env.demo中设置UPSTREAM_RUNTIME_NAMESPACE和DOCKER_GID。可以使用以下命令查找GID:
# docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:29.6.1-cli \
# stat -c '%g' /var/run/docker.sock
#
# 在另一个终端(流氓上游容器位于策略的servers:块中):
POLICY_FILE=policies/demo-policy.yaml \
docker compose --env-file .env.demo -f compose.demo.yml up -d --build
# 当驱动程序提示时 - 拉取地毯,刻意在屏幕上:
curl -X POST localhost:9800/_admin/apply_mutation
# 当它再次提示时 - 热加载收紧的v2策略以完成模拟:
docker kill -s HUP portunusmcp-demo-gateway-1
```
驱动程序以`developer`身份连接 - 一个标准的MCP客户端,第一个调用时没有自定义`_meta`(只能看到`send_email` / `read_inbox`;破坏性的`delete_mailbox`被*省略*,而不是标记),然后以`ops-admin`身份连接(可以看到这三个)。它发出一个成功的调用,等待操作员的突变curl,然后显示漂移分类为关键并被阻止(`DENY_DRIFT`),管理员重新批准,以及相同的调用在TOTP升级步骤后成功。然后它显示`signed` ci-agent捕获的请求被字节相同地重放(`DENY_REPLAY`)并带有伪造的新nonce(HTTP 401),然后是v2草稿的策略模拟(`would_now_deny: 2`)和哈希链接的审计收据。
所有七个节拍都是实时的 - 没有任何内容是脚本或伪造的。突变仅在操作员实际调用该端点时触发,因此对抗性事件在摄像头上可见,而不是在计时器上在屏幕上发生。
如果后续演示策略v1内容与记录状态冲突,关闭激活检查会拒绝启动并命名开发专用的重置:`docker compose --env-file .env.demo -f compose.demo.yml run --rm gateway python scripts/reset_dev_state.py --yes`。检查不会被削弱。
**开发设置:** `python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]"`,复制`.env.demo.example`到`.env.demo`,设置其所需的Docker命名空间/GID值,使用`docker build -t portunusmcp:dev .`构建本地上游镜像,然后运行`.venv/bin/pytest`。挂载的Docker套接字是根等效的主机访问权限;只有受信任的操作员应该在网关容器中接收shell。完整的命令列表在[`CLAUDE.md`](./CLAUDE.md)。
## 自托管生产配置文件
`compose.prod.yml`是一个强化的**单主机,单网关副本**配置文件。它在没有明确选择的情况下不会被`docker compose up`选中;每次调用都需要显式命名生产文件和环境。
```bash
cp .env.prod.example .env.prod
cp .env.prod.gateway.example .env.prod.gateway
chmod 600 .env.prod .env.prod.gateway
# 填写每个所需的密码、摘要、允许列表、路径、命名空间和Docker GID。
docker compose --env-file .env.prod -f compose.prod.yml config
docker compose --env-file .env.prod -f compose.prod.yml pull
docker compose --env-file .env.prod -f compose.prod.yml up -d
```
标记的生产包用测试工作流中测试的确切清单摘要填充每个服务镜像;源树环境示例保留开发版本之间的占位符。每生产`servers:`镜像也应为`repository@sha256:...`,并且必须已经存在于主机上,因为运行时拉取被禁用。
生产环境还会在网关上挂载两个操作员拥有的可写根目录:
- `POLICY_DIR_HOST`(模式`0700`,UID/GID 1000)包含`policy.yaml`;网关创建崩溃恢复临时文件和`revisions/`在其下方。
- `AUDIT_SIGNING_KEY_DIR`(模式`0700`,UID/GID 1000)包含`audit_signing_key.pem`;网关维护`public/<fingerprint>.pub.pem`及其下的旋转日志目录。验证者仅读取该`public/`目录只读。
私有文件和日志为模式`0600`;存档的公共密钥为`0444`。`.env.prod.gateway`仅包含策略引用的`PORTUNUSMCP_UPSTREAM_*`、签名和TOTP机密。Postgres和Redis通过内部数据网络保护,并且都不发布主机端口。
网关绑定到`127.0.0.1:${GATEWAY_PORT:-8000}`。在它前面放置一个操作员管理的TLS反向代理,设置`ALLOWED_HOSTS`/`ALLOWED_ORIGINS`到真实的公共名称,并设置Uvicorn的逗号分隔的`FORWARDED_ALLOW_IPS`到反向代理的IP/CIDR,以便源身份验证限制不能信任客户端伪造的地址。`*`仅在仅限环回的受信任主机边界保持时适用。内部网关到数据库流量是明文的,隔离的单主机Compose网络。
可选的生产监控也仅限回环,并且需要Grafana登录:```bash
docker compose --env-file .env.prod -f compose.prod.yml --profile monitoring up -d
```
不要扩展`gateway`:会话/容器句柄在内存中,审计链有一个安全的写入者。直接的Docker套接字仍然是根等效的主机访问权限。命名卷持久化数据,但不是备份;备份/恢复、TLS终止、主机修补和日志转发仍然是操作员的责任。
### 资源控制和就绪状态
`GET /health`仅是进程活动。`GET /ready`同时检查Postgres、Redis、活动审计密钥/密钥环恢复状态和策略促销恢复状态,仅当所有四个都就绪时返回200(否则503):```json
{"status":"ready","checks":{"postgres":"ok","redis":"ok","signing":"ok","policy":"ok"}}
```
项目40/43边缘设置基于环境;所有数字值必须为正数,允许列表是JSON数组:
| 设置 | 默认值 |
|---|---:|
| `MAX_MCP_BODY_BYTES` | `1048576` |
| `MAX_JSON_DEPTH` | `32` |
| `MAX_SESSIONS_PER_IDENTITY` | `3` |
| `MAX_INFLIGHT_CALLS_PER_IDENTITY` | `5` |
| `TOOL_CALL_RATE_LIMIT` / `TOOL_CALL_RATE_WINDOW_SECONDS` | `60` / `60` |
| `AUTH_FAILURE_RATE_LIMIT` / `AUTH_FAILURE_RATE_WINDOW_SECONDS` | `5` / `300` |
| `TOOL_CALL_DEADLINE_SECONDS` | `60` |
| `READINESS_TIMEOUT_SECONDS` | `1.0` |
| `ALLOWED_HOSTS` | `["localhost:*","127.0.0.1:*"]` |
| `ALLOWED_ORIGINS` | `[]` |
A missing `Origin`仍然对非浏览器MCP客户端有效。任何提供的Origin都必须列出。
### 操作员命令行界面
该软件包安装了`portunusmcp`,一个用于身份验证的`/admin` API的标准库操作员客户端。仅在环境中设置管理员凭证;它绝不会被接受为命令行参数:
```bash
pipx install portunusmcp==0.1.0
export PORTUNUSMCP_URL=https://gateway.example.com
export PORTUNUSMCP_ADMIN_KEY='shown-once-admin-key'
portunusmcp approvals list
portunusmcp baselines list --kind all
portunusmcp baselines show default send_email
portunusmcp decisions get 42
portunusmcp policy validate candidate.yaml
portunusmcp policy simulate candidate.yaml --window 2026-07-01..2026-07-27
portunusmcp --yes policy rollout candidate.yaml
portunusmcp --yes policy rollback 3
portunusmcp keys audit-status
portunusmcp --yes keys rotate-audit
portunusmcp audit export --from-seq 1 --to-seq 500 --output audit.ndjson
.venv/bin/python scripts/verify_audit_chain.py --export audit.ndjson
```
变异操作除非使用 `--yes`,否则会进行交互式确认;JSON 模式下的变异操作必须使用 `--yes`。 `--json` 发出稳定的漂亮 JSON 以便自动化。纯 HTTP 仅适用于 `localhost`、`127.0.0.1` 或 `::1`;远程操作员必须使用 HTTPS,可选地带有 `--ca-file`。
策略的发布和回滚使用一个崩溃可恢复的日志:验证和预检,记录修订,写入旧策略签名的 `POLICY_ACTIVATED` 交接, 原子化地促进 `policy.yaml`,然后交换内存。 SIGHUP 遵循相同的路径,通过消耗相邻的 `policy.next.yaml`;被拒绝的候选人保留在那里进行纠正。审计密钥轮换同样在促进新私钥之前写入 `AUDIT_KEY_ROTATED` 和旧密钥。历史公钥按指纹地址存储并保留,以便旧行仍然可验证。
审批和标记的基准线是有界的审查队列(每个响应 100 行)。审计导出在下载前经过验证,并发出自包含的 NDJSON:一个 manifest 带有确切的公钥包,随后是包含且无间隙的行。 部分范围证明其内部链和签名,但明确不证明省略的前缀。
---
## 性能
在 **2026-07-27** 基于 **`98e4a80`** 的 item-43 工作树上进行测量,而不是估计,基于真实的每会话 Docker 上游和完整的 §4.2 流水线以及 item-40 边缘/速率/截止日期和 item-43 源身份验证控件激活。方法论、硬件和再现步骤:[`ARCHITECTURE.md` §9](./ARCHITECTURE.md#9-performance-benchmarks).
| 场景 | 直接调用 | 通过网关 | 额外开销 |
|---|---|---|---|
| 单次调用,缓存模式 | 0.25 / 0.21 / 0.43 / 0.59 毫秒 | 16.17 / 15.59 / 18.29 / 31.35 毫秒 | 15.93 / 15.38 / 17.85 / 30.76 毫秒 |
| 单次调用,冷缓存模式 | 0.25 / 0.21 / 0.43 / 0.59 毫秒 | 22.14 / 19.26 / 34.01 / 72.71 毫秒 | — |
| 10 个并发会话(p95) | — | 229.20 毫秒 | — |
| 50 个并发会话(p95) | — | 1171.36 毫秒 | — |
| 100 个并发会话(p95) | — | 5376.84 毫秒 | — |
| `tools/list` 载荷(修剪身份) | 744 B(未修剪) | 425 B | **42.9% 减少** |
延迟为平均值 / p50 / p95 / p99。高并发 p95 包括每个会话一个强化 Docker 容器以及同步故障关闭审计写入;这两个都是已知的上限,在 `ARCHITECTURE.md` §10 中讨论。
容器初始化:第一次 899.89 毫秒;接下来 20 p50 338.03 毫秒 / p95 802.46 毫秒。100 会话运行后的峰值 RSS 为 153 MiB(网关 + 套件);初始化的上游容器合计使用 1,095 MiB。
---
## 技术栈
| 层 | 选择 | 原因 |
|---|---|---|
| 运行时 | Python 3.12,FastAPI + Starlette,异步贯穿始终 | 第一方 MCP SDK;异步原生代理几乎完全是 I/O 等待 |
| MCP 处理 | `mcp` 官方 Python SDK | 不自己动手 JSON-RPC 帧——而是在会话层拦截 |
| 存储 | PostgreSQL 16(审计链,基准线,审批,策略版本)+ Redis 7(随机数,模式缓存,风险计数器) | 关系完整性对于哈希链很重要;Redis 用于所有具有 TTL 的内容 |
| 策略 | YAML + Pydantic,带有手工 ABAC 表达式求值器(`ast.parse` + 节点白名单,无 `eval`) | Git-diffable 并在加载时验证。故意不是图灵完备的 - 没有循环,没有递归,没有代码执行([ADR-004](./docs/adr/ADR-004-no-opa-for-v1.md) 说明为什么不使用 OPA) |
| 风险 | 固定权重因子列表 + 行为 Redis 计数器 - **设计上没有 ML** | 操作员无法解释的安全决策是操作员无法信任的安全决策 |
| 加密 | SHA-256 哈希链 + ECDSA P-256 每行签名 | 链本身可以被任何具有 DB 写访问权限的人重新生成;签名不是 |
| 运维 | Docker Compose,Prometheus + Grafana(可选配置文件),structlog JSON 日志,GitHub Actions(ruff / mypy 严格 / pytest 带有 80% 覆盖率门控) | |
---
## 文档
- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — 决策流程,每个组件深入,故障模式,可观察性,基准测试,可扩展性,测试,部署
- [`THREAT_MODEL.md`](./THREAT_MODEL.md) — 保护什么,不保护什么,及背后的假设
- [`SECURITY.md`](./SECURITY.md) — 漏洞披露
- [`COMPATIBILITY.md`](./COMPATIBILITY.md) — 支持的运行时,镜像,客户端和测试平台
- [`UPGRADING.md`](./UPGRADING.md) — 支持的升级和回滚过程
- [`CHANGELOG.md`](./CHANGELOG.md) — 发布历史
- [`docs/adr/`](./docs/adr/) — 每个有影响的决策一个文件,包括为什么 Envoy、OPA、Kong、NGINX、边车和客户端 SDK 中间件在 v1 中被拒绝
- [`ROADMAP.md`](./ROADMAP.md) — 建设顺序作为一个动态检查清单
## 路线图
第 1-6 阶段已经完成。第 7 阶段的障碍(项目 44-47)也已经完成: [`v0.1.0`](https://github.com/BashaarJavaid/PortunusMCP/releases/tag/v0.1.0) 可在 GitHub Releases 和 [PyPI](https://pypi.org/project/portunusmcp/0.1.0/) 上找到,GHCR 标签 `0.1.0` 和 `latest` 解析为测试的 linux/amd64 + linux/arm64 镜像索引 `sha256:fdbfb388e68830fb6dff44c285fb0b3b43633113e586c448ab3e76abd6811073`。第 7 阶段仍然活跃;下一个是项目 48,`portunusmcp quickstart`。
第 1-6 项完成仅在它们的验证通过并且相应的威胁模型声明获得后才算完成。第 7 阶段不会升级威胁模型行;其采用项根据观察到的摩擦进行验证,阶段在五名外部用户完成 `quickstart` 并且他们的摩擦得到分类后关闭。
## 许可证
MIT — 见 [`LICENSE`](./LICENSE)。
MCP 配置
以下是此 MCP Server 的配置信息,可直接复制到 Cursor 或其他 MCP 客户端中使用。
mcp.json
连接信息
猜你喜欢
暂无推荐服务器