diff --git a/.env.example b/.env.example index 52d6fbd..c81cd09 100644 --- a/.env.example +++ b/.env.example @@ -168,3 +168,16 @@ DOCKER_SOCKET_PATH=/var/run/docker.sock RATE_LIMIT_CODE_EXEC_PER_SEC=1 RATE_LIMIT_CODE_EXEC_BURST=3 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 diff --git a/.scratch/mcp-server/issues/T7-verification-docs.md b/.scratch/mcp-server/issues/T7-verification-docs.md index bcce2ed..338a7b7 100644 --- a/.scratch/mcp-server/issues/T7-verification-docs.md +++ b/.scratch/mcp-server/issues/T7-verification-docs.md @@ -20,8 +20,16 @@ 5. `docs/DEPLOYMENT.md`: note that `/mcp` must pass through the reverse proxy and that `MCP_TOKEN_ENC_KEY` is required for the feature. -## Acceptance -- At least one real client connects end-to-end and exercises read + write + admin paths. -- All rejection paths confirmed. -- Docs updated; no plaintext tokens committed anywhere. -- `make lint` passes. +## Status (2026-07-28) +- Docs (CHANGELOG / .env.example / AGENTS.md / DEPLOYMENT.md): ✓ done. +- `make lint` equivalent: ✓ `cargo clippy --all-features -- -D warnings` clean. +- **Live-client smoke test DEFERRED to user**: requires a running DB instance + + `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. diff --git a/AGENTS.md b/AGENTS.md index 53058fa..1a183f1 100644 --- a/AGENTS.md +++ b/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/db/` — `pool.rs` (`DB_POOL: LazyLock`, `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/auth/` — `password.rs` (Argon2), `session.rs` (UUID token, SHA-256 `hash_token`, cookie build/parse). - - `src/components/` — Dioxus components (layouts, header/nav/footer, post/comments/code_runner/skeletons/forms/ui atoms). + - `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/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/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`). - `libs/` — pnpm JS workspace, packages named `@yggdrasil/*`. Each builds to a self-contained IIFE bundle written **directly into `public//`** 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). -- `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). -- `syntaxes/` — `.sublime-syntax` definitions (JSX/Kotlin/Swift/TSX/TypeScript/Vue/Zig); embedded via `include_str!` at compile time. +- `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). - `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). - `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. - **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` 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/.rs`) uses `#[tool_router(router = _router, vis = "pub")] impl YggMcpServer { ... }` to emit a public router fn returning `ToolRouter`; `src/mcp/server.rs` composes them with `+` into one `ServerHandler`. All tools impl on the **same** `YggMcpServer` type (required for `ToolRouter` `Add`). To add a tool group: write `tools/.rs` with a named pub router on `YggMcpServer`, register in `tools/mod.rs`, and add `+ YggMcpServer::_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 - **每完成一个功能点立即提交**。Agent 自主判断提交时机——当一个逻辑完整的改动通过验证(编译通过 / 测试通过)后,无需等待用户指令,直接 `git add` + `git commit`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e243c6..cd018ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Fixed diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index aff55ba..2c9c243 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -39,6 +39,7 @@ | `APP_BASE_URL` | `https://your-domain.example` | 写请求 CSRF 校验的可信 origin。不设时回退到请求 `Host` 头 + `X-Forwarded-Proto`,反代后若 `Host` 头可被客户端影响,该回退路径可被 CSRF 绕过。 | | `COOKIE_SECURE` | `true` | 给会话 Cookie 加 `Secure` 标志,浏览器仅在 HTTPS 下发送。明文 HTTP 生产环境**必开**。 | | `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` 设错的两种后果 @@ -96,7 +97,15 @@ server { # 健康检查:可不经 TLS 或单独配置探针路径。 # GET /healthz —— liveness,进程存活即 200,不查 DB。 # 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