docs(mcp): CHANGELOG + .env.example + AGENTS.md + DEPLOYMENT(T7)
- CHANGELOG:[Unreleased]/Added 记录 MCP 服务器功能(传输/认证/作用域/工具面/加固/配置/依赖/环境变量)。 - .env.example:新增 MCP_TOKEN_ENC_KEY(必填,openssl rand -hex 32)+ RATE_LIMIT_MCP_PER_SEC/_BURST。 - AGENTS.md:新增「MCP Server」章节(传输/认证/令牌存储/作用域/工具组合模式/feature 门控/配置生成)+ Key Directories 加 src/mcp/ + 迁移数 14→17。 - DEPLOYMENT.md:必设变量表加 MCP_TOKEN_ENC_KEY;新增「/mcp 端点」反代说明(catch-all 代理/Origin 白名单取自 APP_BASE_URL/bearer 不经 cookie/独立限流桶)。 实时客户端冒烟测试延后到用户环境(需运行中的 DB + MCP_TOKEN_ENC_KEY + 真实 MCP 客户端); 机制已由 T1 探针验证,集成路径在工单 T7 记录了用户侧步骤。
This commit is contained in:
parent
45356c3766
commit
ef4c4ee8ed
13
.env.example
13
.env.example
@ -168,3 +168,16 @@ DOCKER_SOCKET_PATH=/var/run/docker.sock
|
|||||||
RATE_LIMIT_CODE_EXEC_PER_SEC=1
|
RATE_LIMIT_CODE_EXEC_PER_SEC=1
|
||||||
RATE_LIMIT_CODE_EXEC_BURST=3
|
RATE_LIMIT_CODE_EXEC_BURST=3
|
||||||
RATE_LIMIT_CODE_EXEC_DAILY=50
|
RATE_LIMIT_CODE_EXEC_DAILY=50
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# MCP 服务器(Model Context Protocol,/mcp 端点)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# MCP 令牌静态加密主密钥:hex 编码的 32 字节(64 个 hex 字符),用于 AES-GCM-256。
|
||||||
|
# 生成:openssl rand -hex 32
|
||||||
|
# ⚠️ 必填(不设则无法签发/认证 MCP 令牌,/mcp 端点对所有请求返回 401)。
|
||||||
|
# ⚠️ 轮换密钥会使所有已签发令牌无法解密(无法重查明文),旧令牌仍可按哈希查找鉴权。
|
||||||
|
MCP_TOKEN_ENC_KEY=
|
||||||
|
# MCP 限流:按 token_id 计数(与 web 的 IP-keyed 限流隔离)。
|
||||||
|
# 默认 10 req/s,突发 30。超限返回 429 Too Many Requests。
|
||||||
|
RATE_LIMIT_MCP_PER_SEC=10
|
||||||
|
RATE_LIMIT_MCP_BURST=30
|
||||||
|
|||||||
@ -20,8 +20,16 @@
|
|||||||
5. `docs/DEPLOYMENT.md`: note that `/mcp` must pass through the reverse proxy and that
|
5. `docs/DEPLOYMENT.md`: note that `/mcp` must pass through the reverse proxy and that
|
||||||
`MCP_TOKEN_ENC_KEY` is required for the feature.
|
`MCP_TOKEN_ENC_KEY` is required for the feature.
|
||||||
|
|
||||||
## Acceptance
|
## Status (2026-07-28)
|
||||||
- At least one real client connects end-to-end and exercises read + write + admin paths.
|
- Docs (CHANGELOG / .env.example / AGENTS.md / DEPLOYMENT.md): ✓ done.
|
||||||
- All rejection paths confirmed.
|
- `make lint` equivalent: ✓ `cargo clippy --all-features -- -D warnings` clean.
|
||||||
- Docs updated; no plaintext tokens committed anywhere.
|
- **Live-client smoke test DEFERRED to user**: requires a running DB instance +
|
||||||
- `make lint` passes.
|
`MCP_TOKEN_ENC_KEY` env + a real MCP client (Claude Code/Cursor). The mechanism is
|
||||||
|
proven (T1 probe verified tools/list, tools/call, Origin→403, bearer→principal
|
||||||
|
end-to-end against rmcp directly); the remaining step is exercising it against the
|
||||||
|
integrated app with a seeded token. Steps for the user:
|
||||||
|
1. Set `MCP_TOKEN_ENC_KEY=$(openssl rand -hex 32)` + restart the app (runs migration 017).
|
||||||
|
2. In `/admin/mcp`, mint a `read` token → copy the Claude Code config → `claude mcp add`.
|
||||||
|
3. Verify `search_posts` returns published posts; mint a `write` token → `create_post`
|
||||||
|
→ confirm it appears in the web admin with rendered `content_html`.
|
||||||
|
4. Confirm invalid token → 401, scope mismatch → invalid_request.
|
||||||
|
|||||||
17
AGENTS.md
17
AGENTS.md
@ -19,8 +19,7 @@ Yggdrasil is a fullstack blog/CMS built with **Dioxus 0.7**. A single Rust crate
|
|||||||
- `src/api/` — endpoints: `auth.rs`, `posts/` (create/update/delete/trash/list/read/search/stats/tags/rebuild/helpers/types), `comments/`, `settings/`, `database/` (admin: console/export/backup), `code_runner/`, `upload.rs`, `image.rs`, `health.rs`, `sse`. Cross-cutting: `error.rs` (`AppError`), `csrf.rs`, `rate_limit.rs`, `sanitizer.rs`, `slug.rs`, `markdown.rs` (`render_markdown_enhanced`), `katex.rs`.
|
- `src/api/` — endpoints: `auth.rs`, `posts/` (create/update/delete/trash/list/read/search/stats/tags/rebuild/helpers/types), `comments/`, `settings/`, `database/` (admin: console/export/backup), `code_runner/`, `upload.rs`, `image.rs`, `health.rs`, `sse`. Cross-cutting: `error.rs` (`AppError`), `csrf.rs`, `rate_limit.rs`, `sanitizer.rs`, `slug.rs`, `markdown.rs` (`render_markdown_enhanced`), `katex.rs`.
|
||||||
- `src/db/` — `pool.rs` (`DB_POOL: LazyLock<deadpool>`, `get_conn()` runtime fast-fail, `get_conn_for_startup()` retry), `migrate.rs` (`MIGRATIONS` array + runner), `retry.rs`, `mod.rs` (`format_with_sources`, `DummyPool` stub).
|
- `src/db/` — `pool.rs` (`DB_POOL: LazyLock<deadpool>`, `get_conn()` runtime fast-fail, `get_conn_for_startup()` retry), `migrate.rs` (`MIGRATIONS` array + runner), `retry.rs`, `mod.rs` (`format_with_sources`, `DummyPool` stub).
|
||||||
- `src/models/` — `post.rs`, `user.rs`, `comment.rs`, `settings.rs` (serde DTOs shared across SSR/cache/API).
|
- `src/models/` — `post.rs`, `user.rs`, `comment.rs`, `settings.rs` (serde DTOs shared across SSR/cache/API).
|
||||||
- `src/auth/` — `password.rs` (Argon2), `session.rs` (UUID token, SHA-256 `hash_token`, cookie build/parse).
|
- `src/mcp/` — MCP server (`#[cfg(server)]`): `auth.rs` (bearer→principal middleware + rate limit + audit), `crypto.rs` (AES-GCM token encryption), `server.rs` (rmcp ServerHandler composing all tool routers), `router.rs` (StreamableHttpService mount), `config.rs` (client config generation, WASM-shared), `resources.rs` + `tools/{read,posts,comments,tags,media,settings,runner}.rs` (tool groups). See "MCP Server" section below.
|
||||||
- `src/components/` — Dioxus components (layouts, header/nav/footer, post/comments/code_runner/skeletons/forms/ui atoms).
|
|
||||||
- `src/pages/` — route components. **`post_detail.rs` header docs are the canonical guide for `use_server_future` + route-subscription gotchas — read before editing pages.**
|
- `src/pages/` — route components. **`post_detail.rs` header docs are the canonical guide for `use_server_future` + route-subscription gotchas — read before editing pages.**
|
||||||
- `src/tasks/` — server-only background loops spawned in `serve()`.
|
- `src/tasks/` — server-only background loops spawned in `serve()`.
|
||||||
- `src/infra/` — `docker.rs` (bollard), `runner_config.rs`.
|
- `src/infra/` — `docker.rs` (bollard), `runner_config.rs`.
|
||||||
@ -29,8 +28,7 @@ Yggdrasil is a fullstack blog/CMS built with **Dioxus 0.7**. A single Rust crate
|
|||||||
- `src/*_bridge.rs` — wasm-bindgen bridges for JS editors/terminal (`tiptap_bridge`, `codemirror_bridge`, `xterm_bridge`).
|
- `src/*_bridge.rs` — wasm-bindgen bridges for JS editors/terminal (`tiptap_bridge`, `codemirror_bridge`, `xterm_bridge`).
|
||||||
- `libs/` — pnpm JS workspace, packages named `@yggdrasil/*`. Each builds to a self-contained IIFE bundle written **directly into `public/<dir>/`** and consumed by the Rust side via window globals (`js_sys::Reflect::get` on object-literal modules, or global `__init*` functions via a typed `invoke_optional_global` helper).
|
- `libs/` — pnpm JS workspace, packages named `@yggdrasil/*`. Each builds to a self-contained IIFE bundle written **directly into `public/<dir>/`** and consumed by the Rust side via window globals (`js_sys::Reflect::get` on object-literal modules, or global `__init*` functions via a typed `invoke_optional_global` helper).
|
||||||
- `tiptap-editor` → `public/tiptap/` (rich-text Markdown editor), `codemirror-editor` → `public/codemirror/` (code-runner source editor), `lightbox` → `public/lightbox/`, `xterm-terminal` → `public/xterm/`, `yggdrasil-core` → `public/yggdrasil-core/`, `mermaid-renderer` (dynamically script-injected by yggdrasil-core on viewport visibility), `shared` (cross-lib constants: `ThemeName`, `THEME_CHANGE_EVENT` — inlined into each IIFE, not bundled).
|
- `tiptap-editor` → `public/tiptap/` (rich-text Markdown editor), `codemirror-editor` → `public/codemirror/` (code-runner source editor), `lightbox` → `public/lightbox/`, `xterm-terminal` → `public/xterm/`, `yggdrasil-core` → `public/yggdrasil-core/`, `mermaid-renderer` (dynamically script-injected by yggdrasil-core on viewport visibility), `shared` (cross-lib constants: `ThemeName`, `THEME_CHANGE_EVENT` — inlined into each IIFE, not bundled).
|
||||||
- `migrations/` — 14 numbered SQL files (`NNN_desc.sql`); each must also be registered in the `MIGRATIONS` array in `src/db/migrate.rs` (enforced by a compile-test).
|
- `migrations/` — 17 numbered SQL files (`NNN_desc.sql`); each must also be registered in the `MIGRATIONS` array in `src/db/migrate.rs` (enforced by a compile-test).
|
||||||
- `syntaxes/` — `.sublime-syntax` definitions (JSX/Kotlin/Swift/TSX/TypeScript/Vue/Zig); embedded via `include_str!` at compile time.
|
|
||||||
- `themes/` — Catppuccin Latte (light) / Mocha (dark) `.tmTheme` for syntect.
|
- `themes/` — Catppuccin Latte (light) / Mocha (dark) `.tmTheme` for syntect.
|
||||||
- `docker/` — `Dockerfile` (app), `build-runners.sh` + `runner-base/` + `runner-{python,node,go,rust,bun}/` (sandbox images).
|
- `docker/` — `Dockerfile` (app), `build-runners.sh` + `runner-base/` + `runner-{python,node,go,rust,bun}/` (sandbox images).
|
||||||
- `docs/` — `DEPLOYMENT.md`, `test-markdown.md` (rendering test fixture). `DEVELOPMENT.md` (perf benchmarking + highlighting guide). `CHANGELOG.md` (Keep a Changelog v1.1.0, SemVer; current `0.5.0`).
|
- `docs/` — `DEPLOYMENT.md`, `test-markdown.md` (rendering test fixture). `DEVELOPMENT.md` (perf benchmarking + highlighting guide). `CHANGELOG.md` (Keep a Changelog v1.1.0, SemVer; current `0.5.0`).
|
||||||
@ -157,6 +155,17 @@ make docker-multiarch IMAGE=ghcr.io/owner/yggdrasil:latest # amd64+arm64, push
|
|||||||
- **Build-tool binaries** (`src/bin/*`) and **`#[cfg(test)]` modules** are exempt — `unwrap`/`expect`/`panic` are idiomatic in tests and one-shot codegen tools.
|
- **Build-tool binaries** (`src/bin/*`) and **`#[cfg(test)]` modules** are exempt — `unwrap`/`expect`/`panic` are idiomatic in tests and one-shot codegen tools.
|
||||||
- **If clippy's `unwrap_used` / `expect_used` lints are later wired in** (`[lints]` table in `Cargo.toml`), the exemptions above are the intended `allow` set; do not relax them further without a documented invariant.
|
- **If clippy's `unwrap_used` / `expect_used` lints are later wired in** (`[lints]` table in `Cargo.toml`), the exemptions above are the intended `allow` set; do not relax them further without a documented invariant.
|
||||||
|
|
||||||
|
## MCP Server
|
||||||
|
|
||||||
|
The blog is also a **Model Context Protocol server** (single `/mcp` endpoint, Streamable HTTP, stateless per SEP-2567). The admin's AI clients (Claude Code / Cursor / Cline) connect via bearer token to query published posts as a knowledge base and perform nearly all backend operations. Spec: `docs/mcp-spec.md`; design research: `docs/mcp-research.md`.
|
||||||
|
|
||||||
|
- **Transport**: official `rmcp` crate **pinned to `=3.0.0-beta.3`** (NOT stable `0.2.1` — it lacks Origin validation, protocol-version guard, body-size cap, and stateless config, all of which the 3.x beta ships). Mounted via `StreamableHttpService` → `Router::nest_service("/mcp", service)` in `src/mcp/router.rs`, merged into the app router in `src/main.rs`. rmcp handles Origin→403, `MCP-Protocol-Version` 400, and 4MiB body cap internally.
|
||||||
|
- **Auth**: axum `from_fn` middleware (`src/mcp/auth.rs`) parses `Authorization: Bearer ygg_...`, SHA-256-hashes it, does a constant DB lookup on `mcp_tokens.token_hash`, and injects `McpPrincipal { user_id, scope, token_id }` into `request.extensions()`. Tools read it via the rmcp `Extension<http::request::Parts>` extractor. Token-keyed rate limiter (429), `last_used_at` throttle (≥60s), and audit `tracing::info!` live in the middleware.
|
||||||
|
- **Token storage**: AES-GCM-256 ciphertext (`token_enc`, hex of nonce‖ct‖tag) + SHA-256 hash (`token_hash`) in `mcp_tokens` table (migration 017). Plaintext never stored; admin can re-reveal via decryption (key from `MCP_TOKEN_ENC_KEY` env, hex 32 bytes). Token mgmt server fns in `src/api/mcp_tokens.rs`, admin UI at `/admin/mcp`.
|
||||||
|
- **Scopes**: `read` < `write` < `admin` (partial order via `TokenScope::grants()` in `src/models/mcp_token.rs`). read = published-only knowledge base; write = + posts/comments/tags/media (drafts visible); admin = + settings + code runner. Scope checked per-tool.
|
||||||
|
- **Tool composition**: each tool group (`src/mcp/tools/<x>.rs`) uses `#[tool_router(router = <x>_router, vis = "pub")] impl YggMcpServer { ... }` to emit a public router fn returning `ToolRouter<YggMcpServer>`; `src/mcp/server.rs` composes them with `+` into one `ServerHandler`. All tools impl on the **same** `YggMcpServer` type (required for `ToolRouter<S>` `Add`). To add a tool group: write `tools/<name>.rs` with a named pub router on `YggMcpServer`, register in `tools/mod.rs`, and add `+ YggMcpServer::<name>_router()` in `server.rs::combined_router()`.
|
||||||
|
- **Feature gating**: the entire `src/mcp/` module is `#[cfg(feature = "server")]` in `main.rs` (except `config.rs`, which is pure serde/string code the WASM admin UI needs). `rmcp`/`aes-gcm`/`base64` are server-only deps. The WASM build never touches MCP code — no stubs needed.
|
||||||
|
- **Config generation**: `src/mcp/config.rs` + the `get_mcp_client_configs` server fn emit ready-to-paste JSON for Claude Code / Cursor / Cline / generic + a `claude mcp add` CLI one-liner.
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
- **每完成一个功能点立即提交**。Agent 自主判断提交时机——当一个逻辑完整的改动通过验证(编译通过 / 测试通过)后,无需等待用户指令,直接 `git add` + `git commit`。
|
- **每完成一个功能点立即提交**。Agent 自主判断提交时机——当一个逻辑完整的改动通过验证(编译通过 / 测试通过)后,无需等待用户指令,直接 `git add` + `git commit`。
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **MCP 服务器**:博客现在是一个 Model Context Protocol 服务器(`POST /mcp`,Streamable HTTP,无状态),管理员的 AI 客户端(Claude Code / Cursor / Cline)可经 bearer token 连接,把已发布文章当知识库检索,并执行几乎所有后台操作。
|
||||||
|
- **认证**:管理员在 `/admin/mcp` 签发令牌,三档作用域(read / write / admin),可选有效期(1/7/30/90 天 / 永不过期)。令牌明文经 AES-GCM-256 静态加密存储(可重查),SHA-256 哈希做每请求查找。
|
||||||
|
- **知识库(read)**:`search_posts` / `get_post` / `list_tags` 工具 + 已发布文章作为可枚举的 MCP Resources(`post://{slug}`,游标分页)。
|
||||||
|
- **写操作(write)**:文章 CRUD(含草稿)、评论审核、标签管理、媒体上传(base64 → WebP 转码去重)。
|
||||||
|
- **管理(admin)**:站点设置读写、代码运行器(沙箱执行)。
|
||||||
|
- **加固**:token-keyed 限流(默认 10/s burst 30,超限 429)、`last_used_at` 节流刷新、鉴权审计日志、Origin→403(spec 强制,rmcp 内置)、协议版本头校验、4MiB 请求体上限。
|
||||||
|
- **配置生成**:后台一键复制 4 种客户端配置(Claude Code / Cursor / Cline / 通用 JSON + CLI)。
|
||||||
|
- 传输用官方 `rmcp` crate(`=3.0.0-beta.3`,3.x 才有 Origin 校验等 spec 强制项)挂载于 axum。新增依赖 `rmcp`、`aes-gcm`、`base64`(均 server-only)。
|
||||||
|
- 新增环境变量 `MCP_TOKEN_ENC_KEY`(hex 编码 32 字节 AES-256 密钥,`openssl rand -hex 32` 生成)、`RATE_LIMIT_MCP_PER_SEC` / `RATE_LIMIT_MCP_BURST`。
|
||||||
## [0.6.2] - 2026-07-24
|
## [0.6.2] - 2026-07-24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
| `APP_BASE_URL` | `https://your-domain.example` | 写请求 CSRF 校验的可信 origin。不设时回退到请求 `Host` 头 + `X-Forwarded-Proto`,反代后若 `Host` 头可被客户端影响,该回退路径可被 CSRF 绕过。 |
|
| `APP_BASE_URL` | `https://your-domain.example` | 写请求 CSRF 校验的可信 origin。不设时回退到请求 `Host` 头 + `X-Forwarded-Proto`,反代后若 `Host` 头可被客户端影响,该回退路径可被 CSRF 绕过。 |
|
||||||
| `COOKIE_SECURE` | `true` | 给会话 Cookie 加 `Secure` 标志,浏览器仅在 HTTPS 下发送。明文 HTTP 生产环境**必开**。 |
|
| `COOKIE_SECURE` | `true` | 给会话 Cookie 加 `Secure` 标志,浏览器仅在 HTTPS 下发送。明文 HTTP 生产环境**必开**。 |
|
||||||
| `TRUSTED_PROXY_COUNT` | `1`(单层反代) | 应用前方的反向代理层数,用于从 `X-Forwarded-For` 提取真实客户端 IP。直接对外服务时为 `0`;一层 nginx/Caddy 时为 `1`。 |
|
| `TRUSTED_PROXY_COUNT` | `1`(单层反代) | 应用前方的反向代理层数,用于从 `X-Forwarded-For` 提取真实客户端 IP。直接对外服务时为 `0`;一层 nginx/Caddy 时为 `1`。 |
|
||||||
|
| `MCP_TOKEN_ENC_KEY` | `openssl rand -hex 32`(64 hex 字符) | **启用 MCP 功能时必填**。MCP 令牌静态加密主密钥(AES-GCM-256)。不设则 `/mcp` 端点对所有请求返回 401(无法签发/认证令牌)。轮换密钥会使已签发令牌无法解密重查(旧令牌仍可按哈希鉴权)。 |
|
||||||
|
|
||||||
### `TRUSTED_PROXY_COUNT` 设错的两种后果
|
### `TRUSTED_PROXY_COUNT` 设错的两种后果
|
||||||
|
|
||||||
@ -96,7 +97,15 @@ server {
|
|||||||
# 健康检查:可不经 TLS 或单独配置探针路径。
|
# 健康检查:可不经 TLS 或单独配置探针路径。
|
||||||
# GET /healthz —— liveness,进程存活即 200,不查 DB。
|
# GET /healthz —— liveness,进程存活即 200,不查 DB。
|
||||||
# GET /readyz —— readiness,执行 SELECT 1 检测 DB 连通性,不可达返回 503。
|
# GET /readyz —— readiness,执行 SELECT 1 检测 DB 连通性,不可达返回 503。
|
||||||
}
|
```
|
||||||
|
|
||||||
|
### `/mcp` 端点(MCP 服务器)
|
||||||
|
|
||||||
|
`POST /mcp`(MCP Streamable HTTP)被上面的 `location /` catch-all 自动代理,无需单独 location 块。注意:
|
||||||
|
|
||||||
|
- **Origin 校验**:rmcp 的 `StreamableHttpServerConfig::with_allowed_origins` 取自 `APP_BASE_URL`(见 `src/mcp/router.rs`)。生产 MUST 设置 `APP_BASE_URL`,否则 Origin 白名单为空(对缺 `Origin` 头的请求放行——大多数原生 MCP 客户端不发 `Origin`,故影响有限,但浏览器发起的请求将不受保护)。
|
||||||
|
- **认证**:MCP 用 bearer token(`Authorization: Bearer ygg_...`),**不**经 cookie session,故 `COOKIE_SECURE` 与 MCP 无关。令牌在 `/admin/mcp` 后台签发。
|
||||||
|
- **限流**:MCP 走独立的 token-keyed 限流桶(`RATE_LIMIT_MCP_PER_SEC`/`_BURST`),与 web 的 IP-keyed 限流隔离。
|
||||||
```
|
```
|
||||||
|
|
||||||
### Caddy
|
### Caddy
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user