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:
xfy 2026-07-28 12:01:23 +08:00
parent 45356c3766
commit ef4c4ee8ed
5 changed files with 60 additions and 10 deletions

View File

@ -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

View File

@ -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.

View File

@ -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`

View File

@ -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→403spec 强制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

View File

@ -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