From 9eac025b3e4d7f2245727285440401809942ada7 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 28 Jul 2026 11:53:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E5=AE=8C=E6=95=B4=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=9D=A2=20+=20token=20=E7=AE=A1=E7=90=86=20+=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=EF=BC=88T2-T5=20=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20+=20=E5=90=88=E5=B9=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2 token 管理(src/api/mcp_tokens.rs):create/list/reveal/revoke + get_mcp_client_configs 服务端函数,均经 get_current_admin_user 鉴权;TokenLifetime 枚举(1/7/30/90天/永不过期)。 src/mcp/config.rs 生成 4 种客户端配置(Claude Code/Cursor/Cline/通用 + CLI)。 src/pages/admin/mcp.rs 令牌管理页:列表 + 新建表单 + 一次性明文展示/重查 + 撤销 + 配置片段卡片。路由 /admin/mcp,导航项加入 admin_layout。 T3 read 工具 + Resources(tools/read.rs, resources.rs):search_posts/get_post/list_tags (read 作用域,仅已发布);published-post Resources(post://{slug},游标分页)。 search_published 提取为共享函数。 T4 write 工具(tools/{posts,comments,tags,media}.rs):create/update/publish/trash/delete_post、 评论审核、标签 CRUD、媒体 base64 上传(WebP 转码 + 去重)。复用既有 helper 的 SQL 与 缓存失效(moka + ssr_cache)。write 作用域,可读草稿。 T5 admin 工具(tools/{settings,runner}.rs):get/update_settings、run_code(沙箱执行)。 admin 作用域。 合并(server.rs):用 rmcp tool_router 的「命名路由 + 组合」模式,7 个工具组在 YggMcpServer 上 impl,combined_router 用 + 合并成单一 ServerHandler。各工具组鉴权 独立,经 Extension 读 McpPrincipal + scope.grants 校验。 新增依赖 base64 0.22(媒体上传解码)。验证:server/web 双目标编译通过、clippy --all-features -D warnings 干净、659 单测 + 1 集成全绿(+20 新测试)。 --- .scratch/mcp-server/README.md | 32 + .../mcp-server/issues/T1-tracer-bullet.md | 60 ++ .../mcp-server/issues/T2-token-mgmt-ui.md | 39 + .../issues/T3-read-tools-resources.md | 30 + .scratch/mcp-server/issues/T4-write-tools.md | 32 + .scratch/mcp-server/issues/T5-admin-tools.md | 24 + .scratch/mcp-server/issues/T6-hardening.md | 33 + .../mcp-server/issues/T7-verification-docs.md | 27 + Cargo.lock | 1 + Cargo.toml | 3 + src/api/mcp_tokens.rs | 327 ++++++++ src/api/mod.rs | 2 + src/components/admin_layout.rs | 1 + src/mcp/config.rs | 187 +++++ src/mcp/mod.rs | 5 + src/mcp/resources.rs | 273 +++++++ src/mcp/server.rs | 141 +--- src/mcp/tools/comments.rs | 378 +++++++++ src/mcp/tools/media.rs | 373 +++++++++ src/mcp/tools/mod.rs | 24 + src/mcp/tools/posts.rs | 722 ++++++++++++++++++ src/mcp/tools/read.rs | 335 ++++++++ src/mcp/tools/runner.rs | 198 +++++ src/mcp/tools/settings.rs | 172 +++++ src/mcp/tools/tags.rs | 185 +++++ src/pages/admin/mcp.rs | 633 +++++++++++++++ src/pages/admin/mod.rs | 4 + src/router.rs | 5 +- 28 files changed, 4133 insertions(+), 113 deletions(-) create mode 100644 .scratch/mcp-server/README.md create mode 100644 .scratch/mcp-server/issues/T1-tracer-bullet.md create mode 100644 .scratch/mcp-server/issues/T2-token-mgmt-ui.md create mode 100644 .scratch/mcp-server/issues/T3-read-tools-resources.md create mode 100644 .scratch/mcp-server/issues/T4-write-tools.md create mode 100644 .scratch/mcp-server/issues/T5-admin-tools.md create mode 100644 .scratch/mcp-server/issues/T6-hardening.md create mode 100644 .scratch/mcp-server/issues/T7-verification-docs.md create mode 100644 src/api/mcp_tokens.rs create mode 100644 src/mcp/config.rs create mode 100644 src/mcp/resources.rs create mode 100644 src/mcp/tools/comments.rs create mode 100644 src/mcp/tools/media.rs create mode 100644 src/mcp/tools/mod.rs create mode 100644 src/mcp/tools/posts.rs create mode 100644 src/mcp/tools/read.rs create mode 100644 src/mcp/tools/runner.rs create mode 100644 src/mcp/tools/settings.rs create mode 100644 src/mcp/tools/tags.rs create mode 100644 src/pages/admin/mcp.rs diff --git a/.scratch/mcp-server/README.md b/.scratch/mcp-server/README.md new file mode 100644 index 0000000..8d7fd06 --- /dev/null +++ b/.scratch/mcp-server/README.md @@ -0,0 +1,32 @@ +# MCP Server — Ticket Index + +Spec: `docs/mcp-spec.md` · Research: `docs/mcp-research.md` · Tickets: `issues/` + +## Dependency DAG + +``` +T1 tracer bullet (mount+auth+crypto+1 read tool) +├─► T2 token mgmt server fns + admin UI ─┐ +├─► T3 read tools + Resources ────┐ │ +├─► T4 write tools ───────────────┤ │ +└─► T5 admin tools ───────────────┤ │ + ▼ │ + T6 hardening ◄──┤ + │ │ + ▼ ▼ + T7 verification + docs +``` + +## Execution order + +| # | Ticket | Depends on | Notes | +|---|--------|-----------|-------| +| T1 | Tracer bullet — end-to-end skeleton (read path) | — | **Do first.** Proves rmcp/Axum mount + both build targets. Halt if rmcp≠axum 0.8. | +| T2 | Token management server fns + admin UI | T1 | Config generation for 4 clients lives here. | +| T3 | read-scope tools + Resources (knowledge base) | T1 | Can run parallel to T4/T5. | +| T4 | write-scope tools (posts/comments/tags/media) | T1 | Reuse existing helpers; same cache invalidation. | +| T5 | admin-scope tools (settings, code runner) | T1 | runner admin-only. | +| T6 | Hardening (rate limit, sanitization, audit) | T3, T4, T5 | Token-keyed governor; injection sanitization. | +| T7 | Integration verification + docs | T2, T6 | Real-client smoke test; CHANGELOG/env/AGENTS. | + +T2/T3/T4/T5 are independent after T1 — fan out. T6 waits on the tool tickets; T7 is terminal. diff --git a/.scratch/mcp-server/issues/T1-tracer-bullet.md b/.scratch/mcp-server/issues/T1-tracer-bullet.md new file mode 100644 index 0000000..807afe3 --- /dev/null +++ b/.scratch/mcp-server/issues/T1-tracer-bullet.md @@ -0,0 +1,60 @@ +# T1 — Tracer bullet: end-to-end MCP skeleton (read path) + +> **Type:** tracer bullet. Proves the whole stack on BOTH build targets before any +> tool-surface flesh-out. Do this FIRST and get it green end-to-end. + +## Blocking edges +- **Blocks:** T2 (token mgmt+UI), T3 (read tools+resources), T4 (write tools), + T5 (admin tools), T6 (hardening). +- **Blocked by:** nothing (this is the root). + +## Target files +- `Cargo.toml` — add `rmcp` + `aes-gcm` (optional, under `server` feature). +- `migrations/015_mcp_tokens.sql` — the `mcp_tokens` table (see spec §5.1). +- `src/db/migrate.rs` — register `("015", include_str!(...))` in `MIGRATIONS`. +- `src/models/mcp_token.rs` — `TokenScope` enum (Read/Write/Admin, `>=` ordering, serde), + `McpToken`, `McpTokenSummary`, `CreateTokenResponse`. +- `src/mcp/mod.rs`, `src/mcp/crypto.rs`, `src/mcp/auth.rs`, `src/mcp/server.rs`, + `src/mcp/router.rs` — new module (server-only impl + WASM stubs). +- `src/main.rs` — build `mcp_route` and `.merge(mcp_route)` into the app router + (alongside existing merges at ~line 332). +- `src/lib.rs` / `src/main.rs` — `pub mod mcp;` gated by feature. +- `.env.example` — document `MCP_TOKEN_ENC_KEY`. + +## Change +1. Add deps; verify `rmcp` builds against **axum 0.8** and exposes a Tower + `StreamableHttpService` mountable via `nest_service`. If the version is incompatible, + STOP and report — do not silently hand-roll JSON-RPC (that's a spec-level decision). +2. Migration + model (spec §5). `token_hash` = SHA-256 hex for lookup; + `token_enc` = AES-GCM (nonce‖ct‖tag) hex. +3. `crypto.rs`: `encrypt_token(plain, &key) -> String` / `decrypt_token(enc, &key) -> + Option` using `aes-gcm`. Read key from `MCP_TOKEN_ENC_KEY` (base64 32-byte). + `.expect()` only at LazyLock init of a parsed key is acceptable per AGENTS.md §16. +4. `auth.rs`: bearer extractor — parse `Authorization: Bearer ygg_...`, hash, DB-lookup + the active (non-revoked, non-expired) row, return `(user_id, scope)`, bump + `last_used_at`. Origin check → 403 (reuse CSRF trusted-origin allowlist helper). +5. `server.rs`: minimal rmcp `ServerHandler` exposing ONE tool — `search_posts(query)` + — wired to the existing FTS search helper in `src/api/posts/search.rs`. Return + summaries. (Full Resources/pagination is T3; here just prove dispatch works.) +6. `router.rs`: assemble `StreamableHttpService` (stateless) and return an + `axum::Router` for `/mcp`. Merge into the app router in `main.rs`. +7. WASM stubs: every public symbol gets a `#[cfg(not(feature="server"))]` stub so + `--features web` compiles. + +## Acceptance — STATUS (2026-07-28) +- **rmcp version pinned: `=3.0.0-beta.3`** (NOT stable 0.2.1 — see spec §7). Axum-0.8 + compatibility verified by a throwaway probe: `nest_service("/mcp", StreamableHttpService)` + mounts; auth flows via `from_fn` middleware → `request.extensions` → `Extension`; + real MCP `tools/list` + `tools/call` round-trip confirmed; Origin→403 confirmed. +- No-token → 401, bad Origin → 403 (rmcp built-in): **verified at probe level**. + (Live-server token-seed smoke test deferred to T7 integration — needs a running DB.) +- `token_enc` column stores AES-GCM ciphertext (crypto.rs unit-tested: round-trip, + tamper-fail, wrong-key-fail, nonce-distinct — 8 tests green). +- `cargo build --no-default-features --features web` ✓ (mcp module is `#[cfg(server)]`, + so WASM build never touches rmcp/aes-gcm — no stubs needed by design). +- `cargo build --no-default-features --features server` ✓. +- `cargo clippy --all-features -- -D warnings` ✓ (added `#[allow(dead_code)]` on the + mcp module + mcp_token model: forward-public symbols consumed by T2/T3). +- `cargo test --features server` ✓ 639 unit + 1 integration, +13 new MCP tests. +- Deferred to T7: real-client end-to-end against a live DB (T1 proved the *mechanism* + via probe, not against the real DB-backed auth path). diff --git a/.scratch/mcp-server/issues/T2-token-mgmt-ui.md b/.scratch/mcp-server/issues/T2-token-mgmt-ui.md new file mode 100644 index 0000000..29cc7b2 --- /dev/null +++ b/.scratch/mcp-server/issues/T2-token-mgmt-ui.md @@ -0,0 +1,39 @@ +# T2 — Token management server fns + admin UI + +## Blocking edges +- **Blocks:** T7 (integration verification needs a usable UI). +- **Blocked by:** T1 (token model + crypto must exist). + +## Target files +- `src/api/mcp_tokens.rs` — `#[server]` fns. +- `src/api/mod.rs` — `pub mod mcp_tokens;` + re-exports. +- `src/models/mcp_token.rs` — DTOs (shared with T1). +- `src/pages/admin/mcp.rs` — token management + config generator page. +- `src/router.rs` — route `/admin/mcp`. +- admin layout/nav — link to `/admin/mcp`. +- `src/mcp/config.rs` — config generation (4 formats). + +## Change +1. `create_mcp_token(name, scope, lifetime)` (admin-guarded via + `get_current_admin_user().await?`): generate opaque `ygg_...` token, AES-GCM encrypt, + store hash + ciphertext, set `expires_at` from the preset menu + (1d/7d/30d/90d/None). Return `CreateTokenResponse { summary, plaintext }`. +2. `list_mcp_tokens()` → `Vec` (no secret): name, scope, created, + expires, last_used, revoked. +3. `reveal_mcp_token(id)` → decrypt `token_enc` → return plaintext (retrievable, per + decision 8). Admin-guarded. +4. `revoke_mcp_token(id)` → set `revoked_at = now()`. +5. `src/mcp/config.rs`: `generate_client_configs(base_url, token)` → struct with 4 + ready-to-paste snippets: Claude Code/Cursor JSON, Cline JSON (`streamableHttp`), + generic raw JSON, and a `claude mcp add` CLI one-liner (see research §"Client-config"). +6. `/admin/mcp` page: token list table, "create token" form (name + scope dropdown + + lifetime preset), one-time plaintext reveal + "reveal again" button, revoke button, + and the 4 config snippets with copy buttons. Base URL from `APP_BASE_URL`. + +## Acceptance +- Admin can create a token with chosen scope + lifetime; plaintext shown once and + re-revealable; token appears in the list with correct metadata; revoke works. +- All 4 config snippets are valid JSON / shell and contain the correct bearer header. +- Non-admin calling these server fns → 401/403. +- `last_used_at` does NOT leak plaintext; plaintext never persisted unencrypted. +- Compiles on both targets; clippy clean; existing tests pass. diff --git a/.scratch/mcp-server/issues/T3-read-tools-resources.md b/.scratch/mcp-server/issues/T3-read-tools-resources.md new file mode 100644 index 0000000..961d575 --- /dev/null +++ b/.scratch/mcp-server/issues/T3-read-tools-resources.md @@ -0,0 +1,30 @@ +# T3 — read-scope tools + Resources (full knowledge base) + +## Blocking edges +- **Blocks:** T6 (hardening sanitizes these outputs). +- **Blocked by:** T1 (mount + auth + dispatch exist). + +## Target files +- `src/mcp/resources.rs` — Resources + templates. +- `src/mcp/tools/read.rs` — full read tool set. +- `src/mcp/server.rs` — register resources/templates + read tools in the handler. + +## Change +1. `resources/list(cursor?, limit?)` — paginated PUBLISHED posts (opaque cursor; + page size server-set, e.g. 50). Returns `{resources: [{uri, name, mimeType: + text/markdown}], nextCursor}`. Invalid cursor → MCP error `-32602`. +2. `resources/templates/list` — `post://{slug}` template. +3. `resources/read(uri)` — resolve `post://{slug}` (or canonical HTTPS URL) → rendered + Markdown of one published post. +4. `search_posts(query, limit?, cursor?)` — full FTS via existing search helper; return + ranked summaries + `resource_link`s (content type added 2025-06-18) to matching posts. +5. `get_post(slug|id)` — published only (read scope). +6. `list_tags()`. + +## Acceptance +- `resources/list` paginates; `nextCursor` round-trips; invalid cursor errors `-32602`. +- `resources/read` returns one post's Markdown; unknown slug → error. +- `search_posts` returns ranked results with valid `resource_link` URIs. +- All read tools succeed with a `read`-scope token; drafts are NOT visible. +- Pure unit tests: cursor encode/decode, resource URI parsing (DB-free). +- Both targets compile; clippy clean; existing tests pass. diff --git a/.scratch/mcp-server/issues/T4-write-tools.md b/.scratch/mcp-server/issues/T4-write-tools.md new file mode 100644 index 0000000..93bbd9a --- /dev/null +++ b/.scratch/mcp-server/issues/T4-write-tools.md @@ -0,0 +1,32 @@ +# T4 — write-scope tools (post CRUD, comments, tags, media) + +## Blocking edges +- **Blocks:** T6 (hardening covers these). +- **Blocked by:** T1 (mount + auth + dispatch exist). + +## Target files +- `src/mcp/tools/posts.rs`, `src/mcp/tools/comments.rs`, `src/mcp/tools/tags.rs`, + `src/mcp/tools/media.rs` — new. +- `src/mcp/server.rs` — register write tools (scope-gated). + +## Change +Reuse existing server-fn / helper logic — do NOT duplicate business rules. Each write +tool calls the same helpers the web admin uses, then runs the same cache invalidation +(moka `invalidate_*` + `ssr_cache::invalidate_ssr_*`). +1. **Posts:** `create_post`, `update_post`, `publish_post`, `trash_post`, `delete_post`. + `write`/`admin` scope. These tokens MAY read drafts (decision 7). `create_post` must + `spawn_blocking(render_markdown_enhanced)` and persist `content_html`, exactly like + `src/api/posts/create.rs`. +2. **Comments:** `list_comments`, `approve_comment`, `delete_comment`, + `set_comment_status` — delegate to `src/api/comments/*` helpers. +3. **Tags:** `create_tag`, `rename_tag` — delegate to `src/api/posts/tags.rs`. +4. **Media:** `upload_media(filename, base64, alt?)` — feed the existing upload pipeline + (`src/api/upload.rs`); return the served URL for embedding in posts. + +## Acceptance +- A `write`-token `create_post` writes a row, renders `content_html`, and invalidates the + matching moka + SSR caches (web admin sees the new post immediately). +- A `read` token calling any write tool → `insufficient_scope` rejection. +- `write`/`admin` tokens can read drafts via `get_post`; `read` tokens cannot. +- Media upload returns a usable image URL consumable in post body. +- Both targets compile; clippy clean; existing tests pass. diff --git a/.scratch/mcp-server/issues/T5-admin-tools.md b/.scratch/mcp-server/issues/T5-admin-tools.md new file mode 100644 index 0000000..00d2f0c --- /dev/null +++ b/.scratch/mcp-server/issues/T5-admin-tools.md @@ -0,0 +1,24 @@ +# T5 — admin-scope tools (settings, code runner) + +## Blocking edges +- **Blocks:** T6 (hardening covers these). +- **Blocked by:** T1 (mount + auth + dispatch exist). + +## Target files +- `src/mcp/tools/settings.rs`, `src/mcp/tools/runner.rs` — new. +- `src/mcp/server.rs` — register admin tools (scope-gated). + +## Change +1. **Settings:** `get_settings`, `update_settings` — delegate to `src/api/settings.rs`. + `admin` scope only. On update, invalidate settings caches + SSR. +2. **Code runner:** `run_code(language, source)` — delegate to the existing code-runner + execute path (`src/api/code_runner/execute.rs`). `admin` scope only (decision 6). + Return captured stdout/stderr within `CODE_RUNNER_MAX_TIMEOUT_SECS`. + +## Acceptance +- `admin` token can read/update settings; change is reflected site-wide after cache + invalidation. +- `admin` token `run_code` executes in the sandbox and returns output (skips cleanly if + Docker daemon unavailable — match existing `require_docker()` behavior). +- `write`/`read` tokens calling these → `insufficient_scope`. +- Both targets compile; clippy clean; existing tests pass. diff --git a/.scratch/mcp-server/issues/T6-hardening.md b/.scratch/mcp-server/issues/T6-hardening.md new file mode 100644 index 0000000..4084353 --- /dev/null +++ b/.scratch/mcp-server/issues/T6-hardening.md @@ -0,0 +1,33 @@ +# T6 — Hardening (rate limit, sanitization, audit, last_used_at) + +## Blocking edges +- **Blocks:** T7 (verification exercises a hardened endpoint). +- **Blocked by:** T3, T4, T5 (tools must exist to harden their outputs). + +## Target files +- `src/mcp/rate_limit.rs` — token-keyed governor. +- `src/mcp/auth.rs` — `last_used_at` update path, body-size cap. +- `src/mcp/server.rs` / tool outputs — snippet sanitization. +- audit log integration (tracing, or a dedicated table if the project has one — check + existing conventions first). + +## Change +1. **Token-keyed rate limit** on `/mcp` only: extract bearer → `user_id` → governor key. + Leave the existing IP-keyed governor on the web app untouched. Add tunable + `RATE_LIMIT_MCP_PER_SEC` / `_BURST` (match the existing env-var naming pattern). +2. **`last_used_at`** updated on each authenticated request (cheap UPDATE, or batched — + match the session-recheck pattern). +3. **Snippet/output sanitization:** treat search + tool output as indirect-prompt- + injection surface — strip control chars, cap length, no raw executable instructions. + Keep read vs write tools on separate scopes (already enforced). +4. **Body-size cap** on `/mcp` POST (e.g. 1 MiB) to bound abuse. +5. **Audit logging** of mutating tool calls (token id, tool, outcome) via the project's + existing logging convention (`tracing`). + +## Acceptance +- A token exceeding its rate budget is throttled (429 / MCP rate-limit error). +- `last_used_at` advances on authenticated requests. +- Search output is sanitized (no raw control chars; length bounded). +- Oversized POST body is rejected. +- Mutating tool calls leave an audit trace. +- Both targets compile; clippy clean; existing tests pass. diff --git a/.scratch/mcp-server/issues/T7-verification-docs.md b/.scratch/mcp-server/issues/T7-verification-docs.md new file mode 100644 index 0000000..bcce2ed --- /dev/null +++ b/.scratch/mcp-server/issues/T7-verification-docs.md @@ -0,0 +1,27 @@ +# T7 — Integration verification + docs + +## Blocking edges +- **Blocks:** nothing (terminal ticket). +- **Blocked by:** T2 (UI), T6 (hardening). + +## Target files +- `docs/CHANGELOG.md`, `.env.example`, `AGENTS.md` (architecture note), `docs/DEPLOYMENT.md`. + +## Change +1. **Manual smoke test** against a running instance with a real client (Claude Code + preferred, Cursor/Cline as available): mint a `read` token → connect → `search_posts` + → `resources/read`; mint a `write` token → `create_post` → confirm it appears in the + web admin with rendered `content_html`; mint an `admin` token → `run_code`. Confirm + invalid token / bad Origin / scope-mismatch are rejected. +2. Update `docs/CHANGELOG.md` (Keep a Changelog): `feat(mcp): ...` entry. +3. `.env.example`: document `MCP_TOKEN_ENC_KEY` + MCP rate-limit vars. +4. `AGENTS.md`: add a short MCP section (transport, auth model, scope mapping, the + `/mcp` route, feature-gating note) under the existing architecture structure. +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. diff --git a/Cargo.lock b/Cargo.lock index cc7205c..d5c3855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5869,6 +5869,7 @@ dependencies = [ "aes-gcm", "argon2", "axum", + "base64", "bollard", "bytes", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7310a84..206880b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,8 @@ katex-rs = { version = "0.2", optional = true } rmcp = { version = "=3.0.0-beta.3", optional = true, features = ["server", "macros", "transport-streamable-http-server", "transport-worker"] } # MCP token 静态加密(AES-GCM):管理员可在后台重查明文,DB 不裸存。 aes-gcm = { version = "0.10", optional = true } +# MCP 媒体上传:base64 解码(AI 客户端经 JSON 传图)。 +base64 = { version = "0.22", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Element", "HtmlElement", "DomTokenList", "MediaQueryList", "HtmlImageElement", "MouseEvent", "KeyboardEvent", "Node", "EventTarget", "Navigator", "Clipboard", "File", "FileList", "FormData", "Request", "RequestInit", "Response", "Headers", "ClipboardEvent", "DataTransfer", "HtmlInputElement", "EventSource", "MessageEvent"] } @@ -139,4 +141,5 @@ server = [ "dep:katex-rs", "dep:rmcp", "dep:aes-gcm", + "dep:base64", ] diff --git a/src/api/mcp_tokens.rs b/src/api/mcp_tokens.rs new file mode 100644 index 0000000..2d4ea60 --- /dev/null +++ b/src/api/mcp_tokens.rs @@ -0,0 +1,327 @@ +//! MCP 访问令牌管理:Dioxus server functions。 +//! +//! 管理员在后台 `/admin/mcp` 签发/查看/撤销为 AI 客户端(Claude Code / Cursor / +//! Cline)准备的 bearer 令牌。明文 token 仅在签发与「重新查看」时返回给管理员, +//! 数据库只存 AES-GCM 密文(`token_enc`,可解密重查)+ SHA-256 哈希(`token_hash`, +//! 每请求 O(1) 常量查找,见 `src/mcp/auth.rs`)。 +//! +//! 鉴权走 cookie session(`get_current_admin_user`),与其它后台 server-fn 一致; +//! MCP 工具路径(bearer)无法调用这些 server-fn——那是 `src/mcp/tools/*` 的职责。 + +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use crate::models::mcp_token::McpToken; +use crate::models::mcp_token::{CreateTokenResponse, McpTokenSummary, TokenScope}; + +/// 令牌有效期预设:管理员在 UI 上从下拉菜单选择。 +/// +/// 序列化形式供前端选择回传(`days1` / `days7` / `days30` / `days90` / `never`)。 +/// `Never` 对应 `expires_at = NULL`(长期令牌);其余按当前时间 + N 天计算。 +#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum TokenLifetime { + /// 1 天(默认推荐:最小权限、轮换友好)。 + Days1, + /// 7 天。 + Days7, + /// 30 天。 + Days30, + /// 90 天。 + Days90, + /// 永不过期(`expires_at = NULL`)。仅用于可信长期客户端。 + Never, +} + +impl TokenLifetime { + /// 计算签发时刻对应的过期时间戳(UTC)。`Never` 返回 `None`。 + #[cfg(feature = "server")] + fn expires_at(self) -> Option> { + let now = chrono::Utc::now(); + match self { + TokenLifetime::Days1 => Some(now + chrono::Duration::days(1)), + TokenLifetime::Days7 => Some(now + chrono::Duration::days(7)), + TokenLifetime::Days30 => Some(now + chrono::Duration::days(30)), + TokenLifetime::Days90 => Some(now + chrono::Duration::days(90)), + TokenLifetime::Never => None, + } + } +} + +/// 签发新的 MCP 令牌。 +/// +/// 生成明文 `ygg_<32 hex>`,AES-GCM 加密后存密文 + SHA-256 哈希;明文随响应一次性 +/// 返回给管理员(后续可经 [`reveal_mcp_token`] 重新查看)。仅 admin。 +#[server] +pub async fn create_mcp_token( + name: String, + scope: TokenScope, + lifetime: TokenLifetime, +) -> Result { + #[cfg(feature = "server")] + { + use crate::api::auth::get_current_admin_user; + use crate::api::error::AppError; + use crate::db::pool::get_conn; + use crate::mcp::auth::{hash_token, TOKEN_PREFIX}; + use crate::mcp::crypto::encrypt_token; + + let admin = get_current_admin_user().await?; + + // 名称规范化与校验:去空白后非空,限制长度。 + let name = name.trim().to_string(); + if name.is_empty() { + return Err(AppError::BadRequest("令牌名称不能为空".to_string()).into()); + } + if name.chars().count() > 64 { + return Err(AppError::BadRequest("令牌名称过长(上限 64 字符)".to_string()).into()); + } + + // 加密主密钥必须已配置,否则无法安全存储明文。 + if crate::mcp::crypto::mcp_enc_key().is_none() { + return Err(AppError::Internal("MCP_TOKEN_ENC_KEY 未设置").into()); + } + + // 明文 token:`ygg_` + 32 字节随机数 hex(64 hex 字符)。 + let mut bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes); + let plaintext = format!("{TOKEN_PREFIX}{}", hex::encode(bytes)); + let hash = hash_token(&plaintext); + let enc = encrypt_token(&plaintext) + .ok_or(AppError::Internal("MCP_TOKEN_ENC_KEY 未设置"))?; + let id = uuid::Uuid::new_v4(); + let expires_at = lifetime.expires_at(); + let scope_str = scope.as_str(); + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let row = client + .query_one( + "INSERT INTO mcp_tokens \ + (id, user_id, name, scope, token_enc, token_hash, expires_at) \ + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7) \ + RETURNING id::text, user_id, name, scope, created_at, expires_at, \ + last_used_at, revoked_at", + &[ + &id, + &admin.id, + &name, + &scope_str, + &enc, + &hash, + &expires_at, + ], + ) + .await + .map_err(AppError::query)?; + + let token = row_to_mcp_token_meta(&row); + Ok(CreateTokenResponse { + summary: token.into(), + plaintext, + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +/// 列出当前管理员名下的全部令牌(不含任何密钥材料,仅展示用元数据)。 +/// +/// 按 `created_at DESC` 排序,最近签发的在前。仅 admin。 +#[server] +pub async fn list_mcp_tokens() -> Result, ServerFnError> { + #[cfg(feature = "server")] + { + use crate::api::auth::get_current_admin_user; + use crate::api::error::AppError; + use crate::db::pool::get_conn; + + let admin = get_current_admin_user().await?; + let client = get_conn().await.map_err(AppError::db_conn)?; + + let rows = client + .query( + "SELECT id::text, user_id, name, scope, created_at, expires_at, \ + last_used_at, revoked_at \ + FROM mcp_tokens \ + WHERE user_id = $1 \ + ORDER BY created_at DESC", + &[&admin.id], + ) + .await + .map_err(AppError::query)?; + + Ok(rows + .iter() + .map(row_to_mcp_token_meta) + .map(McpTokenSummary::from) + .collect()) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +/// 重新查看令牌明文(可多次调用:明文以密文形式落库,可解密还原)。 +/// +/// 找不到令牌、或令牌不属于当前管理员 → 返回 `None`(不区分原因,避免探测)。 +/// 仅 admin。 +#[server] +pub async fn reveal_mcp_token(id: String) -> Result, ServerFnError> { + #[cfg(feature = "server")] + { + use crate::api::auth::get_current_admin_user; + use crate::api::error::AppError; + use crate::db::pool::get_conn; + use crate::mcp::crypto::decrypt_token; + + let admin = get_current_admin_user().await?; + let client = get_conn().await.map_err(AppError::db_conn)?; + + // 仅取属于当前管理员的令牌的密文,避免越权解密他人令牌。 + let row = client + .query_opt( + "SELECT token_enc FROM mcp_tokens WHERE id = $1::uuid AND user_id = $2", + &[&id, &admin.id], + ) + .await + .map_err(AppError::query)?; + + // 解密失败(密钥缺失/密文被篡改)也归一到 None:调用方无法区分, + // 按「该令牌不可解密」处理(等同于失效)。 + Ok(row + .map(|r| r.get::<_, String>("token_enc")) + .and_then(|enc| decrypt_token(&enc))) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +/// 撤销令牌(软删除:置 `revoked_at = now()`,行保留以备审计)。 +/// +/// 找不到或非本人令牌 → 静默无操作(不报错,避免探测)。仅 admin。 +#[server] +pub async fn revoke_mcp_token(id: String) -> Result<(), ServerFnError> { + #[cfg(feature = "server")] + { + use crate::api::auth::get_current_admin_user; + use crate::api::error::AppError; + use crate::db::pool::get_conn; + + let admin = get_current_admin_user().await?; + let client = get_conn().await.map_err(AppError::db_conn)?; + + client + .execute( + "UPDATE mcp_tokens SET revoked_at = NOW() \ + WHERE id = $1::uuid AND user_id = $2 AND revoked_at IS NULL", + &[&id, &admin.id], + ) + .await + .map_err(AppError::query)?; + + Ok(()) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +/// 把数据库行解析为令牌元数据(不含明文;密文/哈希已 `#[serde(skip)]`,这里置空)。 +/// +/// `scope` 列存的是字符串;非法值(理论不可能,除非手工改库)按 read 兜底并记日志, +/// 不 panic。 +#[cfg(feature = "server")] +fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken { + let scope_str: String = row.get("scope"); + let scope = TokenScope::from_db(&scope_str).unwrap_or_else(|| { + tracing::warn!(scope = %scope_str, "mcp_tokens.scope 非法值,兜底为 read"); + TokenScope::Read + }); + McpToken { + id: row.get("id"), + user_id: row.get("user_id"), + name: row.get("name"), + scope, + token_enc: String::new(), + token_hash: String::new(), + created_at: row.get("created_at"), + expires_at: row.get("expires_at"), + last_used_at: row.get("last_used_at"), + revoked_at: row.get("revoked_at"), + } +} + +/// 4 种客户端配置 + CLI 一行命令的序列化 DTO。 +/// +/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`) +/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控),这里复制字段 +/// 为可两端共享的 DTO,让 WASM 前端能经 server fn 拿到配置字符串。 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct McpClientConfigs { + /// Claude Code(`.mcp.json` / `~/.claude.json`)与 Cursor(`~/.cursor/mcp.json`)。 + pub claude_code_json: String, + /// Cursor 专用变体(与 claude_code_json 相同,单独列出便于标注)。 + pub cursor_json: String, + /// Cline(`cline_mcp_settings.json`)。 + pub cline_json: String, + /// 通用原始 JSON(单 server entry)。 + pub generic_json: String, + /// Claude Code CLI 一行命令。 + pub claude_cli: String, +} + +/// 根据明文令牌生成 4 种客户端配置 + CLI 一行命令。 +/// +/// 配置生成在服务端完成(`crate::mcp::config` 是 server-only 模块),返回给前端展示。 +/// `APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。 +#[server] +pub async fn get_mcp_client_configs(token: String) -> Result { + #[cfg(feature = "server")] + { + use crate::api::auth::get_current_admin_user; + + let _admin = get_current_admin_user().await?; + let configs = crate::mcp::config::generate_client_configs( + &crate::mcp::config::base_url_from_env(), + &token, + ); + Ok(McpClientConfigs { + claude_code_json: configs.claude_code_json, + cursor_json: configs.cursor_json, + cline_json: configs.cline_json, + generic_json: configs.generic_json, + claude_cli: configs.claude_cli, + }) + } + #[cfg(not(feature = "server"))] + unreachable!() +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + + #[test] + fn lifetime_expires_at_days() { + let now = chrono::Utc::now(); + let d1 = TokenLifetime::Days1.expires_at().unwrap(); + let d7 = TokenLifetime::Days7.expires_at().unwrap(); + assert!(d1 > now); + assert!(d7 > d1); + // 7 天与 1 天的差应≈6 天(容忍微量时钟漂移)。 + let delta = (d7 - d1).num_seconds() as f64 / 86400.0; + assert!((5.9..6.1).contains(&delta)); + } + + #[test] + fn lifetime_never_is_none() { + assert!(TokenLifetime::Never.expires_at().is_none()); + } + + #[test] + fn lifetime_serde_roundtrip() { + let json = serde_json::to_string(&TokenLifetime::Days30).unwrap(); + assert_eq!(json, "\"days30\""); + let back: TokenLifetime = serde_json::from_str(&json).unwrap(); + assert_eq!(back, TokenLifetime::Days30); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 1a7684a..00ea4bc 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -25,6 +25,8 @@ pub mod image; /// KaTeX 服务端数学公式渲染(server-only)。 #[cfg(feature = "server")] pub mod katex; +/// MCP 访问令牌管理(签发 / 列表 / 重查 / 撤销)的 Dioxus server function。 +pub mod mcp_tokens; /// Markdown 渲染与 HTML 清理。 pub mod markdown; /// mhchem 化学公式转译器(\ce/\pu → LaTeX,server-only)。 diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index c60978e..d0e56e4 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -46,6 +46,7 @@ pub fn AdminLayout() -> Element { (Route::Posts {}, "管理文章"), (Route::Assets {}, "素材"), (Route::Runner {}, "试运行"), + (Route::Mcp {}, "MCP"), (Route::System {}, "系统"), ]; diff --git a/src/mcp/config.rs b/src/mcp/config.rs new file mode 100644 index 0000000..f09da0b --- /dev/null +++ b/src/mcp/config.rs @@ -0,0 +1,187 @@ +//! MCP 客户端配置片段生成。 +//! +//! 管理员签发令牌后,需要把「如何把这个博客接入某个 AI 客户端」的配置粘进去。 +//! 不同客户端的配置文件格式不同,这里生成 4 种可直接复制粘贴的片段,全部指向 +//! 同一个 `/mcp` 端点、携带同一个 `Authorization: Bearer` 头。 +//! +//! 形状来源:`docs/mcp-research.md` §"Client-config output format",各客户端官方文档 +//! (Claude Code / Cursor / Cline)核实。所有 JSON 都是 `serde_json` 构造再 pretty-print, +//! 保证格式合法(不会手抖写错逗号/引号)。 + +use serde::Serialize; + +/// 4 种客户端配置 + 一个 CLI 一行命令。 +/// +/// 所有字段是可直接复制粘贴的最终字符串(JSON 已 pretty-print,CLI 是单行 shell)。 +/// `token` 形如 `ygg_...`,已嵌入各片段的 `Authorization` 头中。 +#[derive(Debug, Clone, Serialize)] +pub struct ClientConfigs { + /// Claude Code(`.mcp.json` / `~/.claude.json`)与 Cursor(`~/.cursor/mcp.json`)。 + /// `type: "streamable-http"`,`url`,`headers.Authorization`。 + pub claude_code_json: String, + /// Cursor 专用变体(`type: "streamable-http"`)——与 claude_code_json 实际相同, + /// 单独列出便于 UI 分别展示与标注文件路径。 + pub cursor_json: String, + /// Cline(`cline_mcp_settings.json`)。`type: "streamableHttp"`(注意驼峰,非 `sse`), + /// 额外带 `disabled` / `autoApprove` 字段。 + pub cline_json: String, + /// 通用原始 JSON:一个 server entry 的纯净形式,供其它兼容客户端粘贴。 + pub generic_json: String, + /// Claude Code CLI 一行命令:`claude mcp add --transport http --header ...`。 + pub claude_cli: String, +} + +/// `mcpServers` 条目里的 server 名(客户端侧的标识,与令牌 name 无关)。 +const SERVER_NAME: &str = "yggdrasil"; + +/// 构造 `/mcp` 端点 URL:`base_url`(无尾斜杠) + `/mcp`。 +/// +/// `base_url` 来自 `APP_BASE_URL` 环境变量(调用方传入),形如 `https://rua.plus`。 +/// 这里只做最小拼接:去掉尾部斜杠再追加 `/mcp`,避免 `//mcp`。 +fn join_mcp_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + format!("{trimmed}/mcp") +} + +/// 生成 4 种客户端配置 + CLI 一行命令。 +/// +/// - `base_url`:站点根 URL(形如 `https://rua.plus`),不带 `/mcp` 后缀。 +/// - `token`:明文 bearer 令牌(形如 `ygg_...`),会被嵌入 `Authorization` 头。 +pub fn generate_client_configs(base_url: &str, token: &str) -> ClientConfigs { + let mcp_url = join_mcp_url(base_url); + let auth_header = format!("Bearer {token}"); + + // --- Claude Code / Cursor:type = "streamable-http" --- + let claude_code_json = serde_json::json!({ + "mcpServers": { + SERVER_NAME: { + "type": "streamable-http", + "url": mcp_url, + "headers": { "Authorization": auth_header } + } + } + }); + // Cursor 与 Claude Code 格式一致(type 都接受 streamable-http),仅展示标签不同。 + let cursor_json = claude_code_json.clone(); + + // --- Cline:type = "streamableHttp"(驼峰),带 disabled / autoApprove --- + let cline_json = serde_json::json!({ + "mcpServers": { + SERVER_NAME: { + "type": "streamableHttp", + "url": mcp_url, + "headers": { "Authorization": auth_header }, + "disabled": false, + "autoApprove": [] + } + } + }); + + // --- 通用:单个 server entry 的纯净形式 --- + let generic_json = serde_json::json!({ + "type": "streamable-http", + "url": mcp_url, + "headers": { "Authorization": auth_header } + }); + + // --- Claude Code CLI 一行命令 --- + // 注意 header 值用双引号包裹(含空格);shell 安全起见整个 header 用双引号。 + let claude_cli = format!( + "claude mcp add --transport http {SERVER_NAME} {mcp_url} \\\n --header \"Authorization: Bearer {token}\"" + ); + + ClientConfigs { + claude_code_json: pretty_json(&claude_code_json), + cursor_json: pretty_json(&cursor_json), + cline_json: pretty_json(&cline_json), + generic_json: pretty_json(&generic_json), + claude_cli, + } +} + +/// `serde_json::Value` → 缩进 2 空格的 pretty JSON 字符串。 +fn pretty_json(v: &serde_json::Value) -> String { + // 缩进 2 空格与各客户端文档示例一致;序列化不会失败(值来自 json! 宏)。 + serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_string()) +} + +/// 读取 `APP_BASE_URL` 环境变量作为站点根 URL;缺失时回退到本地开发地址。 +/// +/// 由 UI 调用方使用,保证「未设置环境变量」时仍能展示一个可用(本地)配置。 +pub fn base_url_from_env() -> String { + std::env::var("APP_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TOKEN: &str = "ygg_abcdef0123456789"; + const BASE: &str = "https://rua.plus"; + + #[test] + fn join_url_handles_trailing_slash() { + assert_eq!(join_mcp_url("https://rua.plus/"), "https://rua.plus/mcp"); + assert_eq!(join_mcp_url("https://rua.plus"), "https://rua.plus/mcp"); + assert_eq!(join_mcp_url("https://rua.plus///"), "https://rua.plus/mcp"); + } + + #[test] + fn claude_code_json_is_valid_and_carries_bearer() { + let cfg = generate_client_configs(BASE, TOKEN); + let v: serde_json::Value = serde_json::from_str(&cfg.claude_code_json).unwrap(); + assert_eq!( + v["mcpServers"]["yggdrasil"]["headers"]["Authorization"], + format!("Bearer {TOKEN}") + ); + assert_eq!(v["mcpServers"]["yggdrasil"]["type"], "streamable-http"); + assert_eq!(v["mcpServers"]["yggdrasil"]["url"], "https://rua.plus/mcp"); + } + + #[test] + fn cursor_json_equals_claude_code_json() { + let cfg = generate_client_configs(BASE, TOKEN); + assert_eq!(cfg.cursor_json, cfg.claude_code_json); + } + + #[test] + fn cline_json_uses_streamable_http_camelcase_and_extra_fields() { + let cfg = generate_client_configs(BASE, TOKEN); + let v: serde_json::Value = serde_json::from_str(&cfg.cline_json).unwrap(); + let entry = &v["mcpServers"]["yggdrasil"]; + assert_eq!(entry["type"], "streamableHttp"); // 驼峰,非 streamable-http + assert_eq!(entry["disabled"], false); + assert_eq!(entry["autoApprove"], serde_json::json!([])); + assert_eq!( + entry["headers"]["Authorization"], + format!("Bearer {TOKEN}") + ); + } + + #[test] + fn generic_json_is_bare_entry() { + let cfg = generate_client_configs(BASE, TOKEN); + let v: serde_json::Value = serde_json::from_str(&cfg.generic_json).unwrap(); + assert!(v.get("mcpServers").is_none(), "generic 应是单个 entry,无 mcpServers 外层"); + assert_eq!(v["type"], "streamable-http"); + assert_eq!(v["url"], "https://rua.plus/mcp"); + } + + #[test] + fn claude_cli_one_liner_contains_url_and_header() { + let cfg = generate_client_configs(BASE, TOKEN); + assert!(cfg.claude_cli.contains("claude mcp add --transport http")); + assert!(cfg.claude_cli.contains("https://rua.plus/mcp")); + assert!(cfg.claude_cli.contains(&format!("Bearer {TOKEN}"))); + } + + #[test] + fn all_json_is_pretty_indented() { + let cfg = generate_client_configs(BASE, TOKEN); + // pretty JSON 至少含一个换行 + 缩进(非单行紧凑形式)。 + for s in [&cfg.claude_code_json, &cfg.cursor_json, &cfg.cline_json, &cfg.generic_json] { + assert!(s.contains('\n'), "JSON 应是 pretty-printed: {s}"); + assert!(s.contains(" "), "JSON 应含 2 空格缩进: {s}"); + } + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index c5adaf7..9ed9601 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -16,9 +16,14 @@ #[cfg(feature = "server")] pub mod auth; +pub mod config; #[cfg(feature = "server")] pub mod crypto; #[cfg(feature = "server")] +pub mod resources; +#[cfg(feature = "server")] pub mod router; #[cfg(feature = "server")] pub mod server; +#[cfg(feature = "server")] +pub mod tools; diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs new file mode 100644 index 0000000..02f08e9 --- /dev/null +++ b/src/mcp/resources.rs @@ -0,0 +1,273 @@ +//! MCP Resources:把已发布文章暴露为可分页、可读取的资源(知识库)。 +//! +//! 提供: +//! - [`list_published_resources`]:游标分页枚举已发布文章为 rmcp `Resource`; +//! - [`read_post_resource`]:按 `post://{slug}` URI 读取单篇文章 Markdown 正文; +//! - [`post_resource_template`]:`post://{slug}` 模板,供 `resources/templates/list`。 +//! +//! 游标设计:不透明字符串 = 文章 `id` 的 hex 编码(URL 安全,且无需额外依赖 base64)。 +//! 分页按 `id` 升序(稳定、无偏移放大问题):`WHERE id > $last_id ORDER BY id LIMIT n+1`, +//! 取到 `n+1` 行说明还有下一页,下一页游标 = 第 n 行的 id。 +//! +//! 这里只提供数据访问函数 + 纯类型;`server.rs`(Main 装配)把它们接到 +//! `ServerHandler::list_resources` / `read_resource`。 + +use rmcp::model::{Resource, ResourceTemplate}; + +use crate::api::error::AppError; +use crate::db::pool::get_conn; + +/// `resources/list` 的默认每页数量。 +/// +/// 与 `api::posts::list::MAX_PER_PAGE`(50)保持同一量级,但 MCP 客户端通常 +/// 一次只拉一小批做上下文注入,20 是读取效率与上下文体积的折中。 +pub const DEFAULT_PAGE_SIZE: usize = 20; + +/// 单页允许的最大资源数量(防 DoS:无认证的枚举不应无界扫描)。 +pub const MAX_PAGE_SIZE: usize = 100; + +/// `post://{slug}` URI 前缀。 +pub const POST_URI_PREFIX: &str = "post://"; + +/// 把 slug 规范化为 MCP 资源 URI。 +pub fn post_uri(slug: &str) -> String { + format!("{POST_URI_PREFIX}{slug}") +} + +/// 从 MCP 资源 URI 解析出 slug。 +/// +/// 接受 `post://{slug}`;非法 scheme / 空 slug 返回 None。 +pub fn slug_from_uri(uri: &str) -> Option<&str> { + let rest = uri.strip_prefix(POST_URI_PREFIX)?; + let slug = rest.trim(); + if slug.is_empty() { + None + } else { + Some(slug) + } +} + +/// 构造 `post://{slug}` 资源模板(供 `resources/templates/list`)。 +pub fn post_resource_template() -> ResourceTemplate { + ResourceTemplate::new("{POST_URI_PREFIX}{slug}", "post") + .with_title("已发布文章") + .with_description("按 slug 读取单篇已发布文章的 Markdown 正文。URI 形如 post://my-post-slug。") + .with_mime_type("text/markdown") +} + +/// 把分页大小钳制到 `[1, MAX_PAGE_SIZE]`,默认 [`DEFAULT_PAGE_SIZE`]。 +fn clamp_page_size(n: Option) -> usize { + match n { + Some(n) if n >= 1 => n.min(MAX_PAGE_SIZE), + _ => DEFAULT_PAGE_SIZE, + } +} + +/// 游标(不透明字符串)与文章 id 的双向转换。 +/// +/// 游标 = `{id}` 的 hex 编码。对外是黑盒字符串;客户端只原样回传。 +mod cursor { + /// 把文章 id 编码为不透明游标。 + pub(crate) fn encode(id: i32) -> String { + hex::encode(id.to_le_bytes()) + } + + /// 把不透明游标解码为文章 id;非法输入返回 None。 + pub(crate) fn decode(s: &str) -> Option { + let bytes = hex::decode(s.trim()).ok()?; + if bytes.len() != 4 { + return None; + } + let mut arr = [0u8; 4]; + arr.copy_from_slice(&bytes); + Some(i32::from_le_bytes(arr)) + } +} + +/// `list_published_resources` 的结果。 +pub struct ResourcePage { + /// 本页资源(rmcp `Resource`,供直接返回给客户端)。 + pub resources: Vec, + /// 下一页游标;None 表示已到末页。 + pub next_cursor: Option, +} + +/// 游标分页枚举已发布文章为 MCP `Resource`。 +/// +/// - `cursor`:上一页返回的 `next_cursor`,`None` 表示首页。 +/// - `limit`:每页数量,`None` 取 [`DEFAULT_PAGE_SIZE`],超过 [`MAX_PAGE_SIZE`] 被钳制。 +/// +/// 按 `id` 升序稳定分页;草稿/已删除文章不出现。 +pub async fn list_published_resources( + cursor: Option<&str>, + limit: Option, +) -> Result { + let page_size = clamp_page_size(limit); + // 游标解码失败按"无效游标"处理:不静默回首页(否则跳过数据),直接报错。 + let after_id = match cursor.map(str::trim).filter(|s| !s.is_empty()) { + Some(c) => Some(cursor::decode(c).ok_or_else(|| { + AppError::BadRequest(format!("invalid pagination cursor: {c}")) + })?), + None => None, + }; + let fetch = page_size + 1; // 多取 1 行判断是否有下一页 + + let client = get_conn().await.map_err(AppError::db_conn)?; + + let rows = if let Some(last_id) = after_id { + client + .query( + "SELECT p.id, p.title, p.slug, p.summary, octet_length(p.content_md) AS size + FROM posts p + WHERE p.status = 'published' AND p.deleted_at IS NULL AND p.id > $1 + ORDER BY p.id ASC + LIMIT $2", + &[&last_id, &(fetch as i64)], + ) + .await + .map_err(AppError::query)? + } else { + client + .query( + "SELECT p.id, p.title, p.slug, p.summary, octet_length(p.content_md) AS size + FROM posts p + WHERE p.status = 'published' AND p.deleted_at IS NULL + ORDER BY p.id ASC + LIMIT $1", + &[&(fetch as i64)], + ) + .await + .map_err(AppError::query)? + }; + + let has_next = rows.len() > page_size; + + let resources: Vec = rows + .iter() + .take(page_size) + .map(|r| { + let title: String = r.get("title"); + let slug: String = r.get("slug"); + let summary: Option = r.get("summary"); + let size: Option = r.try_get("size").ok(); + let mut res = Resource::new(post_uri(&slug), title.clone()) + .with_title(title) + .with_mime_type("text/markdown"); + if let Some(s) = summary { + res = res.with_description(s); + } + if let Some(sz) = size { + res = res.with_size(sz as u64); + } + res + }) + .collect(); + + // 多取 1 行 → 有下一页;游标 = 本页最后一行 id 的 hex 编码(不透明、稳定)。 + let next_cursor = if has_next { + let last_id: i32 = rows[page_size - 1].get("id"); + Some(cursor::encode(last_id)) + } else { + None + }; + + Ok(ResourcePage { + resources, + next_cursor, + }) +} + +/// 按 `post://{slug}` 读取单篇已发布文章的 Markdown 正文。 +/// +/// 仅返回 `content_md`;草稿/已删除文章返回 None(对客户端表现为资源不存在)。 +/// 渲染 HTML 由 web 前端负责,MCP 资源保持 Markdown 以利 LLM 直接消费。 +pub async fn read_post_resource(uri: &str) -> Result, AppError> { + let Some(slug) = slug_from_uri(uri) else { + return Ok(None); + }; + + let client = get_conn().await.map_err(AppError::db_conn)?; + let row = client + .query_opt( + "SELECT p.content_md + FROM posts p + WHERE p.slug = $1 AND p.status = 'published' AND p.deleted_at IS NULL", + &[&slug], + ) + .await + .map_err(AppError::query)?; + + Ok(row.map(|r| r.get::<_, String>("content_md"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_round_trips() { + for id in [0i32, 1, 42, 1_000_000, i32::MAX] { + let c = cursor::encode(id); + assert_eq!(cursor::decode(&c), Some(id), "id {id}"); + } + } + + #[test] + fn cursor_rejects_invalid_input() { + // 非 hex + assert_eq!(cursor::decode("nothex!!"), None); + // 长度不对(hex 但字节数 != 4) + assert_eq!(cursor::decode("deadbeefdeadbeef"), None); + assert_eq!(cursor::decode("ab"), None); + // 空串 + assert_eq!(cursor::decode(""), None); + // 仅空白 + assert_eq!(cursor::decode(" "), None); + } + + #[test] + fn cursor_decode_trims_whitespace() { + let c = cursor::encode(123); + assert_eq!(cursor::decode(&format!(" {c} ")), Some(123)); + } + + #[test] + fn uri_round_trips() { + assert_eq!(post_uri("hello-world"), "post://hello-world"); + assert_eq!(slug_from_uri("post://hello-world"), Some("hello-world")); + assert_eq!(slug_from_uri("post://with-trailing/"), Some("with-trailing/")); + } + + #[test] + fn slug_from_uri_rejects_bad_input() { + // 错误 scheme + assert_eq!(slug_from_uri("http://foo"), None); + assert_eq!(slug_from_uri("foo://bar"), None); + // 缺前缀 + assert_eq!(slug_from_uri("hello-world"), None); + // 空 slug + assert_eq!(slug_from_uri("post://"), None); + assert_eq!(slug_from_uri("post:// "), None); + } + + #[test] + fn clamp_page_size_defaults_and_caps() { + assert_eq!(clamp_page_size(None), DEFAULT_PAGE_SIZE); + assert_eq!(clamp_page_size(Some(0)), DEFAULT_PAGE_SIZE); + assert_eq!(clamp_page_size(Some(1)), 1); + assert_eq!(clamp_page_size(Some(50)), 50); + assert_eq!(clamp_page_size(Some(MAX_PAGE_SIZE)), MAX_PAGE_SIZE); + assert_eq!( + clamp_page_size(Some(MAX_PAGE_SIZE + 1000)), + MAX_PAGE_SIZE + ); + } + + #[test] + fn resource_template_has_markdown_mime() { + let t = post_resource_template(); + assert_eq!(t.uri_template, "{POST_URI_PREFIX}{slug}"); + assert_eq!(t.name, "post"); + assert_eq!(t.mime_type.as_deref(), Some("text/markdown")); + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 5903797..9876586 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1,126 +1,43 @@ -//! MCP 服务器:rmcp `ServerHandler` 实现,暴露博客工具。 +//! MCP 服务器:rmcp `ServerHandler` 实现,组合所有工具组。 //! -//! T1(tracer bullet)仅暴露一个工具 `search_posts`,证明: -//! - rmcp handler 经 `tool_router`/`tool_handler` 宏装配成功; -//! - 工具内能经 `Extension` 读取鉴权中间件注入的 `McpPrincipal`; -//! - 作用域鉴权可生效(此处 search_posts 要求 read)。 +//! 装配模式(rmcp `tool_router` 宏支持的「命名路由 + 组合」): +//! - 每个工具组在各自文件(`tools/.rs`)里用 +//! `#[tool_router(router = _router, vis = "pub")] impl YggMcpServer { ... }` +//! 生成一个返回 `ToolRouter` 的公开函数。 +//! - 本文件定义 `YggMcpServer` 结构体,并在 `#[tool_handler(router = ...)]` +//! 里把所有路由用 `+` 合并成单一 `ServerHandler`。 //! -//! T3 会扩展为完整 read 工具集 + Resources;T4/T5 扩展 write/admin 工具。 -//! 搜索 SQL 与 `src/api/posts/search.rs` 的 server-fn 一致(pg_trgm word_similarity), -//! T3 会把这段查询抽成共享 helper 供两条路径复用,避免重复维护。 +//! 所有工具都在 `YggMcpServer` 上 `impl`,故 `ToolRouter` 的类型参数一致, +//! 可用 `+`(`Add` for `ToolRouter`)合并。鉴权与作用域校验在各工具内部 +//! 经 `Extension` 读取 `McpPrincipal` 完成。 -use rmcp::handler::server::tool::Extension; -use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, ContentBlock, TextContent}; -use rmcp::{schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler}; -use serde::Deserialize; - -use crate::mcp::auth::McpPrincipal; -use crate::models::mcp_token::TokenScope; - -/// search_posts 入参。 -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub struct SearchPostsParams { - /// 搜索关键词(会做 SQL 通配符转义;空串返回空结果)。 - pub query: String, -} +use rmcp::{tool_handler, ServerHandler}; /// MCP 服务器实例(无状态:每个请求由 service_factory 新建一份)。 /// /// 共享状态(DB 连接等)通过 `get_conn()` 全局池获取,无需在实例里持有。 -#[derive(Clone)] +/// 工具方法分散在各 `tools/*.rs` 的 `impl YggMcpServer` 块里。 +#[derive(Clone, Default)] pub struct YggMcpServer; -#[tool_router] impl YggMcpServer { - /// 搜索已发布文章(知识库)。要求 read 作用域。 - #[tool(description = "全文搜索已发布文章,作为知识库。返回标题/slug/摘要/标签。")] - async fn search_posts( - &self, - Parameters(SearchPostsParams { query }): Parameters, - Extension(parts): Extension, - ) -> Result { - // 鉴权 + 作用域校验:read 工具要求 token.scope >= read。 - let principal = parts - .extensions - .get::() - .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; - if !principal.scope.grants(TokenScope::Read) { - return Err(McpError::invalid_request( - "insufficient_scope: search_posts requires read", - None, - )); - } - - let hits = search_published(&query) - .await - .map_err(|e| McpError::internal_error(format!("search failed: {e}"), None))?; - - // 输出为 JSON 文本块(客户端 LLM 可解析)。T3 会改用 resource_link + 结构化输出。 - let text = serde_json::to_string_pretty(&hits) - .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?; - Ok(CallToolResult::success(vec![ContentBlock::Text( - TextContent::new(text), - )])) + /// 组合所有工具组的路由表。新增工具组时在此追加一行。 + fn combined_router() -> rmcp::handler::server::router::tool::ToolRouter { + YggMcpServer::read_router() + + YggMcpServer::posts_router() + + YggMcpServer::comments_router() + + YggMcpServer::tags_router() + + YggMcpServer::media_router() + + YggMcpServer::settings_router() + + YggMcpServer::runner_router() } } -#[tool_handler(name = "yggdrasil", version = "0.1.0")] -impl ServerHandler for YggMcpServer {} - -/// 已发布文章的精简命中(MCP 工具输出)。 -#[derive(Debug, serde::Serialize)] -struct SearchHit { - id: i32, - title: String, - slug: String, - summary: Option, - tags: Vec, -} - -/// 直查 DB:与 src/api/posts/search.rs 的 server-fn 一致的 pg_trgm 查询。 -/// 这里不复用 server-fn(后者依赖 FullstackContext 做限流,MCP 路径不走 cookie 鉴权), -/// 直接走连接池。T3 会抽出共享 helper。 -async fn search_published(query: &str) -> Result, String> { - let q = query.trim(); - if q.is_empty() || q.chars().count() > 200 { - return Ok(Vec::new()); - } - let client = crate::db::pool::get_conn() - .await - .map_err(|e| format!("db conn: {e}"))?; - - let escaped = q - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_"); - - let rows = client - .query( - "SELECT p.id, p.title, p.slug, p.summary, - COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags - FROM posts p - LEFT JOIN post_tags pt ON p.id = pt.post_id - LEFT JOIN tags t ON pt.tag_id = t.id - WHERE p.status = 'published' AND p.deleted_at IS NULL - AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\' - GROUP BY p.id, p.search_text - ORDER BY word_similarity(p.search_text, $2) DESC, p.published_at DESC - LIMIT 50", - &[&escaped, &q], - ) - .await - .map_err(|e| format!("query: {e}"))?; - - let hits = rows - .iter() - .map(|r| SearchHit { - id: r.get(0), - title: r.get(1), - slug: r.get(2), - summary: r.get(3), - tags: r.get(4), - }) - .collect(); - Ok(hits) +/// 单一 `ServerHandler`:工具调度委托给合并后的路由表。 +/// `router = Self::combined_router()` 让 `tool_handler` 宏生成的 +/// `call_tool`/`list_tools`/`get_tool` 全部走组合路由。 +#[tool_handler(router = Self::combined_router())] +impl ServerHandler for YggMcpServer { + // get_info 由宏自动生成(name/version 来自宏的默认或属性)。 + // 这里不手写 get_info,让宏用默认 ServerInfo。 } diff --git a/src/mcp/tools/comments.rs b/src/mcp/tools/comments.rs new file mode 100644 index 0000000..0ff9c0b --- /dev/null +++ b/src/mcp/tools/comments.rs @@ -0,0 +1,378 @@ +//! MCP 写作用域工具:评论审核管理。 +//! +//! 镜像 `src/api/comments/{list,update}.rs` 的 server-fn 逻辑, +//! 但用 bearer-token 鉴权,不走 cookie。 +//! 状态变更后执行与 web 后台一致的评论缓存失效。 +//! +//! 本模块仅 `feature = "server"` 编译。 + +#![cfg(feature = "server")] + +use rmcp::handler::server::tool::Extension; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, TextContent}; +use rmcp::{schemars, tool, tool_router, ErrorData as McpError}; +use serde::Deserialize; + +use crate::cache; +use crate::db::pool::get_conn; +use crate::mcp::auth::McpPrincipal; +use crate::models::mcp_token::TokenScope; + +#[tool_router(router = comments_router, vis = "pub")] +impl crate::mcp::server::YggMcpServer { + /// 列出评论(分页,可按状态筛选)。要求 write 作用域。 + #[tool(description = "列出全部评论(分页,每页 20 条)。可按状态筛选:pending/approved/spam/trash。")] + async fn list_comments( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "list_comments", TokenScope::Write)?; + + let page = p.page.unwrap_or(1).max(1); + let per_page: i64 = 20; + let offset: i64 = (page as i64 - 1) * per_page; + + let client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + + let (total, rows) = match p.status.as_deref() { + Some(s) if !s.is_empty() => { + let total: i64 = client + .query_one( + "SELECT COUNT(*) FROM comments WHERE status = $1 AND deleted_at IS NULL", + &[&s], + ) + .await + .map_err(|e| internal(e, "count comments"))? + .get(0); + let rows = client + .query( + "SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, \ + c.author_email, c.author_url, c.content_md, c.status, c.created_at, \ + p.title as post_title, p.slug as post_slug \ + FROM comments c JOIN posts p ON c.post_id = p.id \ + WHERE c.status = $1 AND c.deleted_at IS NULL \ + ORDER BY c.created_at DESC LIMIT $2 OFFSET $3", + &[&s, &per_page, &offset], + ) + .await + .map_err(|e| internal(e, "query comments"))?; + (total, rows) + } + _ => { + let total: i64 = client + .query_one( + "SELECT COUNT(*) FROM comments WHERE deleted_at IS NULL", + &[], + ) + .await + .map_err(|e| internal(e, "count comments"))? + .get(0); + let rows = client + .query( + "SELECT c.id, c.post_id, c.parent_id, c.depth, c.author_name, \ + c.author_email, c.author_url, c.content_md, c.status, c.created_at, \ + p.title as post_title, p.slug as post_slug \ + FROM comments c JOIN posts p ON c.post_id = p.id \ + WHERE c.deleted_at IS NULL \ + ORDER BY c.created_at DESC LIMIT $1 OFFSET $2", + &[&per_page, &offset], + ) + .await + .map_err(|e| internal(e, "query comments"))?; + (total, rows) + } + }; + + let comments: Vec = rows + .iter() + .map(|r| CommentItem { + id: r.get("id"), + post_id: r.get("post_id"), + post_title: r.get("post_title"), + post_slug: r.get("post_slug"), + parent_id: r.get("parent_id"), + depth: r.get("depth"), + author_name: r.get("author_name"), + author_url: r.get("author_url"), + content_md: r.get("content_md"), + status: r.get("status"), + created_at: r.get::<_, chrono::DateTime>("created_at").to_rfc3339(), + }) + .collect(); + + ok_json(CommentsList { + comments, + total, + page, + per_page, + }) + } + + /// 通过指定评论(同时递归通过所有 pending 祖先评论)。要求 write 作用域。 + #[tool(description = "通过指定评论。同时递归通过所有 pending 的祖先评论,确保嵌套链可见。")] + async fn approve_comment( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "approve_comment", TokenScope::Write)?; + + let client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + + let row = client + .query_opt( + "SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "select comment"))?; + let post_id: i32 = match row { + Some(r) => r.get(0), + None => { + return Err(McpError::invalid_request("评论不存在", None)); + } + }; + + // 通过目标评论。 + client + .execute( + "UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "approve comment"))?; + + // 递归向上查找所有 pending 父评论并同步通过。 + client + .execute( + "WITH RECURSIVE ancestors AS ( \ + SELECT parent_id FROM comments WHERE id = $1 \ + UNION ALL \ + SELECT c.parent_id FROM comments c JOIN ancestors a ON c.id = a.parent_id WHERE a.parent_id IS NOT NULL \ + ) \ + UPDATE comments SET status = 'approved', approved_at = NOW() \ + WHERE id IN (SELECT parent_id FROM ancestors WHERE parent_id IS NOT NULL) AND status = 'pending'", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "approve ancestors"))?; + + cache::invalidate_comments_by_post(post_id).await; + cache::invalidate_pending_count().await; + + ok_json(CommentResult { + success: true, + message: "已通过".into(), + }) + } + + /// 删除指定评论(软删除:设置 deleted_at 与 status=trash)。要求 write 作用域。 + #[tool(description = "删除指定评论(移入回收站,软删除)。")] + async fn delete_comment( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "delete_comment", TokenScope::Write)?; + + let client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + + let row = client + .query_opt( + "SELECT post_id FROM comments WHERE id = $1 AND deleted_at IS NULL", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "select comment"))?; + if let Some(r) = row { + let post_id: i32 = r.get(0); + client + .execute( + "UPDATE comments SET status = 'trash', deleted_at = NOW() WHERE id = $1", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "trash comment"))?; + cache::invalidate_comments_by_post(post_id).await; + cache::invalidate_pending_count().await; + } + + ok_json(CommentResult { + success: true, + message: "已删除".into(), + }) + } + + /// 设置评论状态(approved/spam/trash)。要求 write 作用域。 + #[tool(description = "设置评论审核状态。status 可选 approved/spam/trash。trash 会软删除。")] + async fn set_comment_status( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "set_comment_status", TokenScope::Write)?; + + let normalized = p.status.trim().to_lowercase(); + if !matches!(normalized.as_str(), "approved" | "spam" | "trash") { + return Err(McpError::invalid_request( + "status must be one of: approved, spam, trash", + None, + )); + } + + let client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + + let row = client + .query_opt( + "SELECT post_id, status FROM comments WHERE id = $1 AND deleted_at IS NULL", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "select comment"))?; + match row { + Some(r) => { + let post_id: i32 = r.get(0); + let old_status: String = r.get(1); + + match normalized.as_str() { + "approved" => { + client + .execute( + "UPDATE comments SET status = 'approved', approved_at = NOW() WHERE id = $1", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "set approved"))?; + } + "spam" => { + client + .execute( + "UPDATE comments SET status = 'spam' WHERE id = $1 AND deleted_at IS NULL", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "set spam"))?; + } + "trash" => { + client + .execute( + "UPDATE comments SET status = 'trash', deleted_at = NOW() WHERE id = $1", + &[&p.comment_id], + ) + .await + .map_err(|e| internal(e, "set trash"))?; + } + _ => unreachable!("validated above"), + } + + // 与 web 后台一致:仅当旧状态是 approved 时需失效评论列表缓存。 + if old_status == "approved" || normalized == "approved" { + cache::invalidate_comments_by_post(post_id).await; + } + cache::invalidate_pending_count().await; + } + None => { + return Err(McpError::invalid_request("评论不存在", None)); + } + } + + ok_json(CommentResult { + success: true, + message: format!("状态已设为 {normalized}"), + }) + } +} + +// --------------------------------------------------------------------------- +// 参数与输出结构 +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ListCommentsParams { + /// 页码(从 1 开始,默认 1)。 + #[serde(default)] + pub page: Option, + /// 按状态筛选:pending / approved / spam / trash。不传则返回全部。 + #[serde(default)] + pub status: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CommentIdParams { + /// 评论 id。 + pub comment_id: i64, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct SetCommentStatusParams { + /// 评论 id。 + pub comment_id: i64, + /// 目标状态:approved / spam / trash。 + pub status: String, +} + +#[derive(Debug, serde::Serialize)] +struct CommentItem { + id: i64, + post_id: i32, + post_title: String, + post_slug: String, + parent_id: Option, + depth: i32, + author_name: String, + author_url: Option, + content_md: String, + status: String, + created_at: String, +} + +#[derive(Debug, serde::Serialize)] +struct CommentsList { + comments: Vec, + total: i64, + page: i32, + per_page: i64, +} + +#[derive(Debug, serde::Serialize)] +struct CommentResult { + success: bool, + message: String, +} + +// --------------------------------------------------------------------------- +// 鉴权 + 错误辅助 +// --------------------------------------------------------------------------- + +fn require_scope( + parts: &http::request::Parts, + tool: &str, + scope: TokenScope, +) -> Result { + let p = parts + .extensions + .get::() + .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; + if !p.scope.grants(scope) { + return Err(McpError::invalid_request( + format!("insufficient_scope: {tool} requires {}", scope.as_str()), + None, + )); + } + Ok(p.clone()) +} + +fn internal(e: E, ctx: &'static str) -> McpError { + tracing::error!("mcp comments {ctx}: {e}"); + McpError::internal_error(ctx, None) +} + +fn ok_json(val: T) -> Result { + let text = serde_json::to_string_pretty(&val) + .map_err(|e| internal(e, "encode result"))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) +} diff --git a/src/mcp/tools/media.rs b/src/mcp/tools/media.rs new file mode 100644 index 0000000..7407209 --- /dev/null +++ b/src/mcp/tools/media.rs @@ -0,0 +1,373 @@ +//! MCP 写作用域工具:媒体上传。 +//! +//! 镜像 `src/api/upload.rs` 的上传流水线(MIME 校验 → 尺寸校验 → 内容去重 → +//! WebP 转码 → 落盘 → assets 登记),但输入为 base64 编码的图片字节而非 +//! multipart form。返回可直接在 Markdown 正文里引用的 `/uploads/...` URL。 +//! +//! 本模块仅 `feature = "server"` 编译。 + +#![cfg(feature = "server")] + +use base64::Engine; +use rmcp::handler::server::tool::Extension; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, TextContent}; +use rmcp::{schemars, tool, tool_router, ErrorData as McpError}; +use serde::Deserialize; + +use crate::db::pool::get_conn; +use crate::mcp::auth::McpPrincipal; +use crate::models::mcp_token::TokenScope; + +/// 与 web 上传一致的大小上限。 +const MAX_FILE_SIZE: usize = 5 * 1024 * 1024; + +#[tool_router(router = media_router, vis = "pub")] +impl crate::mcp::server::YggMcpServer { + /// 上传一张图片(base64 编码)。自动转 WebP(若更小),返回可直接嵌入 + /// Markdown 正文的 `/uploads/...` URL。要求 write 作用域。 + #[tool(description = "上传图片。输入 base64 编码的字节,返回 /uploads/... URL(可直接用于 Markdown 正文 img)。支持 JPEG/PNG/GIF/WebP。")] + async fn upload_media( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "upload_media", TokenScope::Write)?; + + // 1. base64 解码(容忍 data URL 前缀与空白)。 + let b64_clean: String = p + .base64 + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + // 去掉 data URL 前缀(如 data:image/png;base64,...)。 + let b64_payload = b64_clean + .split(',') + .next_back() + .unwrap_or(&b64_clean); + let data = base64::engine::general_purpose::STANDARD + .decode(b64_payload) + .map_err(|e| McpError::invalid_request(format!("base64 decode failed: {e}"), None))?; + + if data.is_empty() { + return Err(McpError::invalid_request("decoded data is empty", None)); + } + if data.len() > MAX_FILE_SIZE { + return Err(McpError::invalid_request( + format!("文件超过大小限制({} bytes)", MAX_FILE_SIZE), + None, + )); + } + + // 2. 从 magic bytes 检测 MIME(不信任客户端声明的扩展名)。 + let mime_type = detect_mime(&data).ok_or_else(|| { + McpError::invalid_request( + "无法识别的图片格式(支持 JPEG/PNG/GIF/WebP)", + None, + ) + })?; + + // 3. 尺寸校验(只读 header,不解码像素)。 + let (img_width, img_height) = + crate::api::image::upload_dimensions(&data, mime_type) + .map_err(|msg| McpError::invalid_request(msg, None))?; + + let is_gif = mime_type == "image/gif"; + let is_webp = mime_type == "image/webp"; + + // 4. 内容去重(CAS):SHA-256 命中已登记素材直接复用。 + let content_hash = { + use sha2::Digest; + hex::encode(sha2::Sha256::digest(&data)) + }; + { + let client = get_conn() + .await + .map_err(|e| internal(e, "db connection"))?; + let reused = client + .query_opt( + "UPDATE assets SET created_at = NOW(), updated_at = NOW() \ + WHERE content_hash = $1 RETURNING path", + &[&content_hash], + ) + .await + .map_err(|e| internal(e, "dedup check"))?; + if let Some(row) = reused { + let path: String = row.get(0); + return ok_json(UploadResult { + success: true, + url: format!("/uploads/{}", path), + reused: true, + width: img_width, + height: img_height, + mime: mime_type.to_string(), + }); + } + } + + // 5. GIF/WebP 解码验证 + 转码(CPU 密集 → spawn_blocking)。 + let data_for_transcode = data.clone(); + let mime_for_transcode = mime_type.to_string(); + let (final_data, final_ext) = tokio::task::spawn_blocking(move || { + transcode_image(&data_for_transcode, &mime_for_transcode, is_gif, is_webp) + }) + .await + .map_err(|e| internal(e, "transcode task"))?; + + // 6. 按日期落盘:uploads/YYYY/MM/DD/HHMMSS..。 + let now = chrono::Utc::now(); + let date = now.format("%Y/%m/%d"); + let uuid_str = uuid::Uuid::new_v4().to_string(); + let dir_path = format!("uploads/{}", date); + let file_name = format!("{}.{}.{}", now.format("%H%M%S"), uuid_str, final_ext); + let file_path = format!("{}/{}", dir_path, file_name); + let url_path = format!("/uploads/{}/{}", date, file_name); + + tokio::fs::create_dir_all(&dir_path) + .await + .map_err(|e| internal(e, "create dir"))?; + tokio::fs::write(&file_path, &final_data) + .await + .map_err(|e| internal(e, "write file"))?; + + // 7. 登记 assets 表(ON CONFLICT 兜底并发竞态)。 + let rel_path = format!("{}/{}", date, file_name); + let final_mime = mime_for_ext(&final_ext); + let original_filename = p + .filename + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| file_name.clone()); + + let registered: Result, McpError> = async { + let client = get_conn() + .await + .map_err(|e| internal(e, "db connection for register"))?; + let asset_id = uuid::Uuid::new_v4(); + let inserted = client + .execute( + "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height, content_hash)\ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ + ON CONFLICT (content_hash) DO NOTHING", + &[ + &asset_id, + &rel_path, + &original_filename, + &final_mime, + &(final_data.len() as i64), + &(img_width as i32), + &(img_height as i32), + &content_hash, + ], + ) + .await + .map_err(|e| internal(e, "register asset"))?; + if inserted == 0 { + // 并发竞态落败:复用胜出者路径。 + let row = client + .query_one( + "SELECT path FROM assets WHERE content_hash = $1", + &[&content_hash], + ) + .await + .map_err(|e| internal(e, "select reused asset"))?; + return Ok(Some(row.get(0))); + } + Ok(None) + } + .await; + + match registered { + Ok(Some(reused_path)) => { + // 竞态落败:删除自己刚写的文件,复用胜出者。 + let _ = tokio::fs::remove_file(&file_path).await; + ok_json(UploadResult { + success: true, + url: format!("/uploads/{}", reused_path), + reused: true, + width: img_width, + height: img_height, + mime: mime_type.to_string(), + }) + } + Ok(None) => { + tracing::info!( + "MCP media uploaded: {} ({} bytes)", + file_path, + final_data.len() + ); + ok_json(UploadResult { + success: true, + url: url_path, + reused: false, + width: img_width, + height: img_height, + mime: final_mime.to_string(), + }) + } + Err(e) => { + // 登记失败:补偿删除已落盘文件。 + let _ = tokio::fs::remove_file(&file_path).await; + Err(e) + } + } + } +} + +// --------------------------------------------------------------------------- +// 图片处理辅助(镜像 src/api/upload.rs) +// --------------------------------------------------------------------------- + +/// 从 magic bytes 检测 MIME 类型。 +fn detect_mime(data: &[u8]) -> Option<&'static str> { + if data.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some("image/jpeg") + } else if data.starts_with(&[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + ]) { + Some("image/png") + } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { + Some("image/gif") + } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" { + Some("image/webp") + } else { + None + } +} + +/// 图片转码:GIF/WebP 保持原格式,JPEG/PNG 尝试转 WebP(更小才采用)。 +fn transcode_image( + data: &[u8], + mime: &str, + is_gif: bool, + is_webp: bool, +) -> (Vec, String) { + if is_gif { + return (data.to_vec(), "gif".to_string()); + } + if is_webp { + return (data.to_vec(), "webp".to_string()); + } + + // JPEG/PNG → 尝试 WebP。 + let format = match mime { + "image/jpeg" => image::ImageFormat::Jpeg, + "image/png" => image::ImageFormat::Png, + _ => image::ImageFormat::Jpeg, + }; + let cursor = std::io::Cursor::new(data); + let mut reader = image::ImageReader::with_format(cursor, format); + reader.limits(crate::api::image::image_reader_limits()); + + match reader.decode() { + Ok(img) => { + let config = crate::webp::WEBP_CONFIG.clone(); + match crate::webp::encode(&img, config.quality, config.method) { + Ok(webp_data) if webp_data.len() < data.len() => { + tracing::info!( + "MCP WebP conversion: {}x{} {} -> {} bytes", + img.width(), + img.height(), + data.len(), + webp_data.len() + ); + (webp_data, "webp".to_string()) + } + Ok(_) => { + // WebP 更大,保留原格式。 + (data.to_vec(), mime_to_ext(mime).to_string()) + } + Err(e) => { + tracing::warn!("MCP WebP encode failed ({}), keeping original", e); + (data.to_vec(), mime_to_ext(mime).to_string()) + } + } + } + Err(e) => { + tracing::warn!("MCP image decode failed ({}), keeping original", e); + (data.to_vec(), mime_to_ext(mime).to_string()) + } + } +} + +fn mime_to_ext(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "bin", + } +} + +fn mime_for_ext(ext: &str) -> &'static str { + match ext { + "jpg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + _ => "image/webp", + } +} + +// --------------------------------------------------------------------------- +// 参数与输出结构 +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UploadMediaParams { + /// 原始文件名(仅作 assets 表展示字段,不影响落盘路径)。 + #[serde(default)] + pub filename: Option, + /// base64 编码的图片字节(支持 data URL 前缀,会自动去除)。 + pub base64: String, + /// 替代文本(alt),目前未持久化,保留供未来扩展。 + #[serde(default)] + pub alt: Option, +} + +#[derive(Debug, serde::Serialize)] +struct UploadResult { + success: bool, + url: String, + reused: bool, + width: u32, + height: u32, + mime: String, +} + +// --------------------------------------------------------------------------- +// 鉴权 + 错误辅助 +// --------------------------------------------------------------------------- + +fn require_scope( + parts: &http::request::Parts, + tool: &str, + scope: TokenScope, +) -> Result { + let p = parts + .extensions + .get::() + .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; + if !p.scope.grants(scope) { + return Err(McpError::invalid_request( + format!("insufficient_scope: {tool} requires {}", scope.as_str()), + None, + )); + } + Ok(p.clone()) +} + +fn internal(e: E, ctx: &'static str) -> McpError { + tracing::error!("mcp media {ctx}: {e}"); + McpError::internal_error(ctx, None) +} + +fn ok_json(val: T) -> Result { + let text = serde_json::to_string_pretty(&val) + .map_err(|e| internal(e, "encode result"))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) +} diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs new file mode 100644 index 0000000..76556c0 --- /dev/null +++ b/src/mcp/tools/mod.rs @@ -0,0 +1,24 @@ +//! MCP 服务器工具集。 +//! +//! 工具按作用域分组到子模块: +//! - `read`:查询/知识库(search_posts/get_post/list_tags)。 +//! - `posts`/`comments`/`tags`/`media`:写操作(write 作用域)。 +//! - `settings`/`runner`:管理操作(admin 作用域)。 +//! +//! 每个子模块用 `#[tool_router]` 实现一个工具组结构体;`server.rs` 在最终装配时 +//! 把它们组合成单一 `ServerHandler`(Main 负责 wire-up)。 + +#[cfg(feature = "server")] +pub mod comments; +#[cfg(feature = "server")] +pub mod media; +#[cfg(feature = "server")] +pub mod posts; +#[cfg(feature = "server")] +pub mod read; +#[cfg(feature = "server")] +pub mod runner; +#[cfg(feature = "server")] +pub mod settings; +#[cfg(feature = "server")] +pub mod tags; diff --git a/src/mcp/tools/posts.rs b/src/mcp/tools/posts.rs new file mode 100644 index 0000000..156b979 --- /dev/null +++ b/src/mcp/tools/posts.rs @@ -0,0 +1,722 @@ +//! MCP 写作用域工具:文章 CRUD。 +//! +//! 镜像 `src/api/posts/{create,update,trash,delete}.rs` 的 server-fn 逻辑, +//! 但用 bearer-token 鉴权(`principal.user_id` 作 author_id),不走 cookie。 +//! 每个写操作后执行与 web 后台完全一致的缓存失效(moka + SSR)。 +//! +//! 本模块仅 `feature = "server"` 编译;`server.rs` 在最终装配时把 `posts_router` +//! 组合进单一 `ServerHandler`。 + +#![cfg(feature = "server")] +#![allow(clippy::too_many_arguments)] + +use rmcp::handler::server::tool::Extension; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, TextContent}; +use rmcp::{schemars, tool, tool_router, ErrorData as McpError}; +use serde::Deserialize; + +use crate::cache; +use crate::db::pool::get_conn; +use crate::mcp::auth::McpPrincipal; +use crate::models::mcp_token::TokenScope; +use crate::models::post::PostStatus; +use crate::ssr_cache; + +// --------------------------------------------------------------------------- +// 结构体 +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// 工具 +// --------------------------------------------------------------------------- + +#[tool_router(router = posts_router, vis = "pub")] +impl crate::mcp::server::YggMcpServer { + /// 创建一篇新文章(草稿或直接发布)。要求 write 作用域。 + #[tool(description = "创建一篇新文章。渲染 Markdown 为 HTML,同步标签与素材引用。返回 post_id/slug。")] + async fn create_post( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let principal = require_scope(&parts, "create_post", TokenScope::Write)?; + + if p.title.trim().is_empty() { + return Err(McpError::invalid_request("title must not be empty", None)); + } + if p.content_md.trim().is_empty() { + return Err(McpError::invalid_request("content_md must not be empty", None)); + } + + // 确定基础 slug。 + let base_slug = match &p.slug { + Some(s) if !s.trim().is_empty() => { + let s = s.trim(); + if !crate::api::slug::is_valid_slug(s) { + return Err(McpError::invalid_request( + "slug 格式无效,只能包含字母、数字、连字符和下划线", + None, + )); + } + s.to_string() + } + _ => crate::api::slug::slugify(&p.title), + }; + + // Markdown 渲染是 CPU 密集任务。 + let md = p.content_md.clone(); + let rendered = tokio::task::spawn_blocking(move || { + crate::api::markdown::render_markdown_enhanced(&md) + }) + .await + .map_err(|e| internal(e, "markdown render"))?; + let content_html = rendered.html; + let toc_html = if rendered.toc_html.is_empty() { + None + } else { + Some(rendered.toc_html) + }; + let summary = p + .summary + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| crate::utils::text::auto_summary(&p.content_md)); + let post_status = PostStatus::from_str(&p.status).unwrap_or(PostStatus::Draft); + let cover_image = p + .cover_image + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let word_count = crate::utils::text::count_words(&p.content_md); + let reading_time = crate::utils::text::reading_time(word_count); + let published_at = if post_status == PostStatus::Published { + Some(chrono::Utc::now()) + } else { + None + }; + + let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + let tx = client + .transaction() + .await + .map_err(|e| internal(e, "begin txn"))?; + + let final_slug = + crate::api::slug::ensure_unique_slug(&tx, &base_slug, None) + .await + .map_err(|e| internal(e, "ensure_unique_slug"))?; + + let row = tx + .query_one( + "INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image, word_count, reading_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING id", + &[ + &principal.user_id, + &p.title.trim(), + &final_slug, + &summary, + &p.content_md, + &content_html, + &toc_html, + &post_status.as_str(), + &published_at, + &cover_image, + &(word_count as i32), + &(reading_time as i32), + ], + ) + .await + .map_err(|e| internal(e, "insert post"))?; + let post_id: i32 = row.get(0); + + let tags_cleaned = clean_tags(&p.tags); + sync_tags(&tx, post_id, &tags_cleaned) + .await + .map_err(|e| internal(e, "sync_tags"))?; + sync_asset_refs(&tx, post_id, &content_html, cover_image.as_deref()) + .await + .map_err(|e| internal(e, "sync_asset_refs"))?; + + tx.commit().await.map_err(|e| internal(e, "commit"))?; + + // 与 web 后台一致的缓存失效。 + cache::invalidate_post_metadata(); + cache::invalidate_post_by_slug(&final_slug).await; + cache::invalidate_tag_posts_for(&tags_cleaned).await; + ssr_cache::invalidate_ssr_all_public(); + ssr_cache::bump_global_generation(); + + ok_json(PostResult { + success: true, + message: "创建成功".into(), + post_id: Some(post_id), + slug: Some(final_slug), + }) + } + + /// 更新指定文章(重新渲染 Markdown、同步标签与素材引用)。要求 write 作用域。 + /// 仅文章原作者可更新。 + #[tool(description = "更新一篇已有文章。重新渲染 Markdown,同步标签。仅文章原作者可更新。")] + async fn update_post( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let principal = require_scope(&parts, "update_post", TokenScope::Write)?; + + if p.title.trim().is_empty() { + return Err(McpError::invalid_request("title must not be empty", None)); + } + if p.content_md.trim().is_empty() { + return Err(McpError::invalid_request("content_md must not be empty", None)); + } + + let md = p.content_md.clone(); + let rendered = tokio::task::spawn_blocking(move || { + crate::api::markdown::render_markdown_enhanced(&md) + }) + .await + .map_err(|e| internal(e, "markdown render"))?; + let content_html = rendered.html; + let toc_html = if rendered.toc_html.is_empty() { + None + } else { + Some(rendered.toc_html) + }; + let summary = p + .summary + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| crate::utils::text::auto_summary(&p.content_md)); + let post_status = PostStatus::from_str(&p.status).unwrap_or(PostStatus::Draft); + let cover_image = p + .cover_image + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let word_count = crate::utils::text::count_words(&p.content_md); + let reading_time = crate::utils::text::reading_time(word_count); + + let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + let tx = client + .transaction() + .await + .map_err(|e| internal(e, "begin txn"))?; + + // 查旧 slug(用于缓存失效)。 + let old_slug: Option = tx + .query_opt("SELECT slug FROM posts WHERE id = $1", &[&p.post_id]) + .await + .map_err(|e| internal(e, "select old slug"))? + .map(|r| r.get(0)); + + // 校验存在、未删除、归属当前用户。 + let exists: bool = tx + .query_opt( + "SELECT 1 FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL", + &[&p.post_id, &principal.user_id], + ) + .await + .map_err(|e| internal(e, "check ownership"))? + .is_some(); + if !exists { + return Err(McpError::invalid_request( + "文章不存在或无权限", + None, + )); + } + + // 确定基础 slug。 + let base_slug = match &p.slug { + Some(s) if !s.trim().is_empty() => { + let s = s.trim(); + if !crate::api::slug::is_valid_slug(s) { + return Err(McpError::invalid_request("slug 格式无效", None)); + } + s.to_string() + } + _ => crate::api::slug::slugify(&p.title), + }; + let final_slug = + crate::api::slug::ensure_unique_slug(&tx, &base_slug, Some(p.post_id)) + .await + .map_err(|e| internal(e, "ensure_unique_slug"))?; + + // 旧标签。 + let old_tags: Vec = { + let rows = tx + .query( + "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1", + &[&p.post_id], + ) + .await + .map_err(|e| internal(e, "select old tags"))?; + rows.iter().map(|r| r.get(0)).collect() + }; + + // 旧状态/发布时间 → 计算新 published_at。 + let old_status_row = tx + .query_opt( + "SELECT status, published_at FROM posts WHERE id = $1", + &[&p.post_id], + ) + .await + .map_err(|e| internal(e, "select old status"))?; + let published_at = if post_status == PostStatus::Published { + let was_published = old_status_row + .as_ref() + .map(|r| { + let s: String = r.get(0); + s == "published" + }) + .unwrap_or(false); + let existing: Option> = + old_status_row.as_ref().and_then(|r| r.get(1)); + if was_published { + existing + } else { + Some(chrono::Utc::now()) + } + } else { + old_status_row.and_then(|r| r.get(1)) + }; + + let updated = tx + .execute( + "UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, word_count = $10, reading_time = $11, updated_at = NOW() + WHERE id = $12", + &[ + &p.title.trim(), + &final_slug, + &summary, + &p.content_md, + &content_html, + &toc_html, + &post_status.as_str(), + &published_at, + &cover_image, + &(word_count as i32), + &(reading_time as i32), + &p.post_id, + ], + ) + .await + .map_err(|e| internal(e, "update post"))?; + if updated == 0 { + return Err(McpError::invalid_request( + "文章不存在或无权限", + None, + )); + } + + let tags_cleaned = clean_tags(&p.tags); + let tags_for_invalidation = tags_cleaned.clone(); + + tx.execute("DELETE FROM post_tags WHERE post_id = $1", &[&p.post_id]) + .await + .map_err(|e| internal(e, "delete old post_tags"))?; + sync_tags(&tx, p.post_id, &tags_cleaned) + .await + .map_err(|e| internal(e, "sync_tags"))?; + sync_asset_refs(&tx, p.post_id, &content_html, cover_image.as_deref()) + .await + .map_err(|e| internal(e, "sync_asset_refs"))?; + + tx.commit().await.map_err(|e| internal(e, "commit"))?; + + cache::invalidate_post_metadata(); + cache::invalidate_post_by_slug(&final_slug).await; + + let all_tags: Vec = old_tags + .into_iter() + .chain(tags_for_invalidation) + .collect::>() + .into_iter() + .collect(); + cache::invalidate_tag_posts_for(&all_tags).await; + + if let Some(old) = &old_slug { + if old != &final_slug { + cache::invalidate_post_by_slug(old).await; + ssr_cache::invalidate_ssr_route(&format!("/post/{old}")); + } + } + ssr_cache::invalidate_ssr_route(&format!("/post/{final_slug}")); + ssr_cache::invalidate_ssr_all_public(); + ssr_cache::bump_global_generation(); + + ok_json(PostResult { + success: true, + message: "更新成功".into(), + post_id: Some(p.post_id), + slug: Some(final_slug), + }) + } + + /// 发布指定文章(设置 status=published 与 published_at)。要求 write 作用域。 + #[tool(description = "发布一篇草稿文章。设置 status=published,若首次发布则填充 published_at。")] + async fn publish_post( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let principal = require_scope(&parts, "publish_post", TokenScope::Write)?; + + let client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + + // 校验存在、未删除、归属当前用户,并取 slug 用于缓存失效。 + let row = client + .query_opt( + "SELECT slug FROM posts WHERE id = $1 AND author_id = $2 AND deleted_at IS NULL", + &[&p.post_id, &principal.user_id], + ) + .await + .map_err(|e| internal(e, "select post"))?; + let slug: String = match row { + Some(r) => r.get(0), + None => { + return Err(McpError::invalid_request( + "文章不存在或无权限", + None, + )); + } + }; + + let result = client + .execute( + "UPDATE posts SET status = 'published', \ + published_at = COALESCE(published_at, NOW()), updated_at = NOW() \ + WHERE id = $1 AND deleted_at IS NULL", + &[&p.post_id], + ) + .await + .map_err(|e| internal(e, "publish post"))?; + if result == 0 { + return Err(McpError::invalid_request("文章不存在", None)); + } + + cache::invalidate_post_metadata(); + cache::invalidate_post_by_slug(&slug).await; + ssr_cache::invalidate_ssr_route(&format!("/post/{slug}")); + ssr_cache::invalidate_ssr_all_public(); + ssr_cache::bump_global_generation(); + + ok_json(PostResult { + success: true, + message: "发布成功".into(), + post_id: Some(p.post_id), + slug: Some(slug), + }) + } + + /// 将指定文章移入回收站(软删除:设置 deleted_at)。要求 write 作用域。 + #[tool(description = "将文章移入回收站(软删除)。可通过恢复操作还原。")] + async fn trash_post( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "trash_post", TokenScope::Write)?; + + let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + let tx = client + .transaction() + .await + .map_err(|e| internal(e, "begin txn"))?; + + let slug_row = tx + .query_opt( + "SELECT slug FROM posts WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", + &[&p.post_id], + ) + .await + .map_err(|e| internal(e, "select post"))?; + let Some(slug_row) = slug_row else { + return Err(McpError::invalid_request("文章不存在", None)); + }; + let slug: String = slug_row.get(0); + + let tag_rows = tx + .query( + "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1", + &[&p.post_id], + ) + .await + .map_err(|e| internal(e, "select tags"))?; + let tags: Vec = tag_rows.iter().map(|r| r.get(0)).collect(); + + let result = tx + .execute( + "UPDATE posts SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL", + &[&p.post_id], + ) + .await + .map_err(|e| internal(e, "soft delete"))?; + if result == 0 { + return Err(McpError::invalid_request("文章不存在", None)); + } + + tx.commit().await.map_err(|e| internal(e, "commit"))?; + + cache::invalidate_post_metadata(); + cache::invalidate_post_by_slug(&slug).await; + cache::invalidate_tag_posts_for(&tags).await; + ssr_cache::invalidate_ssr_route(&format!("/post/{slug}")); + ssr_cache::invalidate_ssr_all_public(); + ssr_cache::bump_global_generation(); + + ok_json(PostResult { + success: true, + message: "已移入回收站".into(), + post_id: Some(p.post_id), + slug: Some(slug), + }) + } + + /// 彻底删除指定文章(物理删除,不可恢复)。要求 write 作用域。 + #[tool(description = "彻底删除文章(物理删除,不可恢复)。post_tags 关联因外键 CASCADE 自动清理。")] + async fn delete_post( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "delete_post", TokenScope::Write)?; + + let mut client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + let tx = client + .transaction() + .await + .map_err(|e| internal(e, "begin txn"))?; + + let slug_row = tx + .query_opt( + "SELECT slug FROM posts WHERE id = $1 FOR UPDATE", + &[&p.post_id], + ) + .await + .map_err(|e| internal(e, "select post"))?; + let Some(slug_row) = slug_row else { + return Err(McpError::invalid_request("文章不存在", None)); + }; + let slug: String = slug_row.get(0); + + let tag_rows = tx + .query( + "SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = $1", + &[&p.post_id], + ) + .await + .map_err(|e| internal(e, "select tags"))?; + let tags: Vec = tag_rows.iter().map(|r| r.get(0)).collect(); + + let result = tx + .execute("DELETE FROM posts WHERE id = $1", &[&p.post_id]) + .await + .map_err(|e| internal(e, "hard delete"))?; + if result == 0 { + return Err(McpError::invalid_request("文章不存在", None)); + } + + tx.commit().await.map_err(|e| internal(e, "commit"))?; + + cache::invalidate_post_metadata(); + cache::invalidate_post_by_slug(&slug).await; + cache::invalidate_tag_posts_for(&tags).await; + ssr_cache::invalidate_ssr_route(&format!("/post/{slug}")); + ssr_cache::invalidate_ssr_all_public(); + ssr_cache::bump_global_generation(); + + ok_json(PostResult { + success: true, + message: "已彻底删除".into(), + post_id: Some(p.post_id), + slug: Some(slug), + }) + } +} + +// --------------------------------------------------------------------------- +// 参数与输出结构 +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CreatePostParams { + /// 文章标题(必填,非空)。 + pub title: String, + /// Markdown 正文(必填,非空)。 + pub content_md: String, + /// 摘要;未提供时自动从正文提取。 + #[serde(default)] + pub summary: Option, + /// URL slug;未提供时从标题自动生成。 + #[serde(default)] + pub slug: Option, + /// 标签列表。 + #[serde(default)] + pub tags: Vec, + /// 状态:`draft`(默认)或 `published`。 + #[serde(default = "default_status")] + pub status: String, + /// 封面图 URL。 + #[serde(default)] + pub cover_image: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdatePostParams { + /// 要更新的文章 id。 + pub post_id: i32, + /// 新标题。 + pub title: String, + /// 新 Markdown 正文。 + pub content_md: String, + /// 新摘要。 + #[serde(default)] + pub summary: Option, + /// 新 slug。 + #[serde(default)] + pub slug: Option, + /// 新标签列表(完全替换旧标签)。 + #[serde(default)] + pub tags: Vec, + /// 新状态。 + #[serde(default = "default_status")] + pub status: String, + /// 新封面图 URL。 + #[serde(default)] + pub cover_image: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct PostIdParams { + /// 文章 id。 + pub post_id: i32, +} + +#[derive(Debug, serde::Serialize)] +struct PostResult { + success: bool, + message: String, + post_id: Option, + slug: Option, +} + +fn default_status() -> String { + "draft".to_string() +} + +// --------------------------------------------------------------------------- +// 鉴权 + 错误辅助 +// --------------------------------------------------------------------------- + +/// 从 request.extensions 读取 McpPrincipal 并检查作用域。 +fn require_scope( + parts: &http::request::Parts, + tool: &str, + scope: TokenScope, +) -> Result { + let p = parts + .extensions + .get::() + .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; + if !p.scope.grants(scope) { + return Err(McpError::invalid_request( + format!("insufficient_scope: {tool} requires {}", scope.as_str()), + None, + )); + } + Ok(p.clone()) +} + +/// 记录错误详情并返回脱敏的 internal_error(不向客户端泄露 SQL 细节)。 +fn internal(e: E, ctx: &'static str) -> McpError { + tracing::error!("mcp posts {ctx}: {e}"); + McpError::internal_error(ctx, None) +} + +fn ok_json(val: T) -> Result { + let text = serde_json::to_string_pretty(&val) + .map_err(|e| internal(e, "encode result"))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) +} + +// --------------------------------------------------------------------------- +// 标签 + 素材引用同步(镜像 src/api/posts/helpers.rs,因 pub(super) 不可跨模块调用) +// --------------------------------------------------------------------------- + +/// 清洗标签:去空白、过滤空串、去重(保留顺序)。 +fn clean_tags(tags: &[String]) -> Vec { + let mut seen = std::collections::HashSet::new(); + tags.iter() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .filter(|t| seen.insert(t.to_lowercase())) + .collect() +} + +/// 在事务中同步文章-标签关联(不删除旧关联,调用方负责预清理)。 +async fn sync_tags( + tx: &deadpool_postgres::Transaction<'_>, + post_id: i32, + tags: &[String], +) -> Result<(), tokio_postgres::Error> { + for tag_name in tags { + let tag_id: i32 = { + let row = tx + .query_opt( + "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id", + &[&tag_name.as_str()], + ) + .await?; + match row { + Some(r) => r.get(0), + None => { + let row = tx + .query_opt("SELECT id FROM tags WHERE name = $1", &[&tag_name.as_str()]) + .await?; + // 清洗已去重,ON CONFLICT 后必然存在。 + row.expect("tag must exist after ON CONFLICT").get(0) + } + } + }; + tx.execute( + "INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)", + &[&post_id, &tag_id], + ) + .await?; + } + Ok(()) +} + +/// 在事务中同步文章的素材引用(自带 DELETE 再重建)。 +async fn sync_asset_refs( + tx: &deadpool_postgres::Transaction<'_>, + post_id: i32, + content_html: &str, + cover_image: Option<&str>, +) -> Result<(), tokio_postgres::Error> { + tx.execute("DELETE FROM asset_refs WHERE post_id = $1", &[&post_id]) + .await?; + + let paths = crate::api::posts::helpers::extract_asset_paths(content_html, cover_image); + if !paths.is_empty() { + tx.execute( + "INSERT INTO asset_refs (asset_id, post_id) \ + SELECT id, $1 FROM assets WHERE path = ANY($2) \ + ON CONFLICT DO NOTHING", + &[&post_id, &paths], + ) + .await?; + } + Ok(()) +} diff --git a/src/mcp/tools/read.rs b/src/mcp/tools/read.rs new file mode 100644 index 0000000..e137acb --- /dev/null +++ b/src/mcp/tools/read.rs @@ -0,0 +1,335 @@ +//! MCP 读作用域工具组(知识库)。 +//! +//! 三个 `read` 作用域工具,均经 `Extension` 读取鉴权中间件 +//! 注入的 `McpPrincipal`,校验 `scope.grants(TokenScope::Read)`: +//! - [`search_posts`](ReadTools::search_posts):pg_trgm 模糊搜索已发布文章; +//! - [`get_post`](ReadTools::get_post):按 slug 取单篇已发布文章全文; +//! - [`list_tags`](ReadTools::list_tags):标签 + 关联已发布文章数。 +//! +//! 本模块只声明工具路由;`server.rs`(Main 装配)把 `read_router()` 合并进 +//! 复合 `ServerHandler`。SQL 与 `src/api/posts/{search,read,tags}.rs` 的 +//! server-fn 一致,但不复用后者(它们走 cookie 鉴权的 FullstackContext), +//! 这里直接走 `crate::db::pool::get_conn()`。 + +use rmcp::handler::server::tool::Extension; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, TextContent}; +use rmcp::{schemars, tool, tool_router, ErrorData as McpError}; +use serde::Deserialize; + +use crate::db::pool::get_conn; +use crate::mcp::auth::McpPrincipal; +use crate::models::mcp_token::TokenScope; + +#[tool_router(router = read_router, vis = "pub")] +impl crate::mcp::server::YggMcpServer { + /// 全文搜索已发布文章(知识库)。返回标题/slug/摘要/标签。 + #[tool(description = "全文搜索已发布文章,作为知识库。返回标题/slug/摘要/标签与匹配 URL。要求 read 作用域。")] + async fn search_posts( + &self, + Parameters(SearchPostsParams { query, limit }): Parameters, + Extension(parts): Extension, + ) -> Result { + let principal = require_read(&parts, "search_posts")?; + + let hits = search_published(&query, limit.unwrap_or(50)) + .await + .map_err(|e| mcp_internal("search_posts", &principal, &e))?; + + let text = serde_json::to_string_pretty(&hits) + .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) + } + + /// 按 slug 取单篇已发布文章(草稿对 read 不可见)。 + #[tool(description = "按 slug 读取单篇已发布文章全文(标题/摘要/Markdown 正文/标签/时间)。草稿不可见。要求 read 作用域。")] + async fn get_post( + &self, + Parameters(GetPostParams { slug }): Parameters, + Extension(parts): Extension, + ) -> Result { + let principal = require_read(&parts, "get_post")?; + + let post = get_published_by_slug(&slug) + .await + .map_err(|e| mcp_internal("get_post", &principal, &e))?; + + let text = serde_json::to_string_pretty(&post) + .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) + } + + /// 列出全部标签 + 各自关联的已发布文章数。 + #[tool(description = "列出全部标签及其关联的已发布文章数量。要求 read 作用域。")] + async fn list_tags( + &self, + Extension(parts): Extension, + ) -> Result { + let principal = require_read(&parts, "list_tags")?; + + let tags = list_all_tags() + .await + .map_err(|e| mcp_internal("list_tags", &principal, &e))?; + + let text = serde_json::to_string_pretty(&tags) + .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) + } +} + +// ── 鉴权辅助 ─────────────────────────────────────────────────────────────── + +/// 从 request extensions 取 principal 并校验 read 作用域;不足返回 insufficient_scope。 +fn require_read( + parts: &http::request::Parts, + tool: &str, +) -> Result { + let principal = parts + .extensions + .get::() + .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; + if !principal.scope.grants(TokenScope::Read) { + return Err(McpError::invalid_request( + format!("insufficient_scope: {tool} requires read"), + None, + )); + } + Ok(principal.clone()) +} + +/// 把内部错误统一包装成 MCP internal_error(带工具名便于排查)。 +fn mcp_internal(tool: &str, principal: &McpPrincipal, e: &str) -> McpError { + tracing::warn!( + tool, user_id = principal.user_id, error = %e, "MCP read tool failed" + ); + McpError::internal_error(format!("{tool} failed: {e}"), None) +} + +// ── 参数 DTO ─────────────────────────────────────────────────────────────── + +/// `search_posts` 入参。 +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct SearchPostsParams { + /// 搜索关键词(SQL 通配符会被转义;空串返回空结果)。 + pub query: String, + /// 最多返回条数(1–50,默认 50)。 + #[serde(default)] + pub limit: Option, +} + +/// `get_post` 入参。 +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct GetPostParams { + /// 文章 slug。 + pub slug: String, +} + +// ── 输出 DTO ─────────────────────────────────────────────────────────────── + +/// `search_posts` 命中项(精简,不含正文)。 +#[derive(Debug, serde::Serialize)] +pub struct SearchHit { + pub id: i32, + pub title: String, + pub slug: String, + pub summary: Option, + pub tags: Vec, + /// 站内文章 URL(`/post/{slug}`),便于客户端/LLM 引用。 + pub url: String, +} + +/// `get_post` 返回的单篇已发布文章(含正文)。 +#[derive(Debug, serde::Serialize)] +pub struct PostResource { + pub id: i32, + pub title: String, + pub slug: String, + pub summary: Option, + pub content_md: String, + pub tags: Vec, + pub created_at: chrono::DateTime, + pub published_at: Option>, + /// 站内文章 URL。 + pub url: String, +} + +/// `list_tags` 返回的标签项。 +#[derive(Debug, serde::Serialize)] +pub struct TagCount { + pub id: i32, + pub name: String, + pub post_count: i64, +} + +// ── DB 访问(SQL 镜像 src/api/posts/{search,read,tags}.rs,走连接池) ─────── + +/// 把行内的标签数组原地清洗(过滤空串),避免二次 Vec 分配。 +fn clean_tags(row: &tokio_postgres::Row) -> Vec { + let mut tags: Vec = row + .try_get::<_, Vec>("tags") + .unwrap_or_default(); + tags.retain(|t| !t.is_empty()); + tags +} + +/// 站内文章 URL:`/post/{slug}`(MCP 不知晓外部域名,给相对路径;客户端可拼接 APP_BASE_URL)。 +fn post_url(slug: &str) -> String { + format!("/post/{slug}") +} + +/// 与 `src/api/posts/search.rs` 一致的 pg_trgm word_similarity 查询。 +/// +/// 仅返回 `status='published' AND deleted_at IS NULL` 的文章。 +/// SQL 通配符 `%`/`_`/`\` 被转义,避免用户输入导致全表扫描。 +pub async fn search_published(query: &str, limit: u32) -> Result, String> { + let q = query.trim(); + if q.is_empty() || q.chars().count() > 200 { + return Ok(Vec::new()); + } + // 钳制 limit 到 [1,50](与 web 端 search 的硬上限一致)。 + let limit = limit.clamp(1, 50) as i64; + + let client = get_conn().await.map_err(|e| format!("db conn: {e}"))?; + + let escaped = q + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + + let rows = client + .query( + "SELECT p.id, p.title, p.slug, p.summary, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.status = 'published' AND p.deleted_at IS NULL + AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\' + GROUP BY p.id, p.search_text + ORDER BY word_similarity(p.search_text, $2) DESC, p.published_at DESC + LIMIT $3", + &[&escaped, &q, &limit], + ) + .await + .map_err(|e| format!("query: {e}"))?; + + let hits = rows + .iter() + .map(|r| { + let slug: String = r.get("slug"); + SearchHit { + id: r.get("id"), + title: r.get("title"), + slug: slug.clone(), + summary: r.get("summary"), + tags: clean_tags(r), + url: post_url(&slug), + } + }) + .collect(); + Ok(hits) +} + +/// 按 slug 取单篇已发布文章(草稿/已删除返回 None)。 +/// +/// SQL 镜像 `src/api/posts/read.rs::get_post_by_slug` 的 published 过滤, +/// 但省略上下篇导航(MCP 知识库场景不需要),返回 `content_md` 原文。 +pub async fn get_published_by_slug(slug: &str) -> Result, String> { + let client = get_conn().await.map_err(|e| format!("db conn: {e}"))?; + + let row = client + .query_opt( + "SELECT p.id, p.title, p.slug, p.summary, p.content_md, + p.created_at, p.published_at, + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + FROM posts p + LEFT JOIN post_tags pt ON p.id = pt.post_id + LEFT JOIN tags t ON pt.tag_id = t.id + WHERE p.slug = $1 AND p.status = 'published' AND p.deleted_at IS NULL + GROUP BY p.id", + &[&slug], + ) + .await + .map_err(|e| format!("query: {e}"))?; + + Ok(row.map(|r| { + let s: String = r.get("slug"); + PostResource { + id: r.get("id"), + title: r.get("title"), + slug: s.clone(), + summary: r.get("summary"), + content_md: r.get("content_md"), + tags: clean_tags(&r), + created_at: r.get("created_at"), + published_at: r.get("published_at"), + url: post_url(&s), + } + })) +} + +/// 全部标签 + 各自关联的已发布文章数(镜像 `src/api/posts/tags.rs::list_tags`)。 +pub async fn list_all_tags() -> Result, String> { + let client = get_conn().await.map_err(|e| format!("db conn: {e}"))?; + + let rows = client + .query( + "SELECT t.id, t.name, COUNT(pt.post_id) as post_count + FROM tags t + LEFT JOIN post_tags pt ON t.id = pt.tag_id + LEFT JOIN posts p ON pt.post_id = p.id AND p.deleted_at IS NULL AND p.status = 'published' + GROUP BY t.id, t.name + ORDER BY t.name", + &[], + ) + .await + .map_err(|e| format!("query: {e}"))?; + + Ok(rows + .iter() + .map(|r| TagCount { + id: r.get("id"), + name: r.get("name"), + post_count: r.get("post_count"), + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn post_url_format() { + assert_eq!(post_url("hello"), "/post/hello"); + assert_eq!(post_url("a-b_c"), "/post/a-b_c"); + } + + #[test] + fn search_published_empty_query_returns_empty() { + // 纯逻辑:空/超长查询直接返回空,不发 DB。 + // 用一个 fake runtime 验证 early-return(不连库)。 + let rt = tokio::runtime::Runtime::new().expect("rt"); + let empty = rt.block_on(search_published("", 10)).expect("empty ok"); + assert!(empty.is_empty()); + let spaces = rt.block_on(search_published(" ", 10)).expect("spaces ok"); + assert!(spaces.is_empty()); + let long: String = "x".repeat(201); + let long_hit = rt.block_on(search_published(&long, 10)).expect("long ok"); + assert!(long_hit.is_empty()); + } + + #[test] + fn limit_is_clamped_in_signature_not_query() { + // limit 钳制发生在 DB 调用前;这里只验证 clamp 算术(不触发 DB)。 + assert_eq!(0u32.clamp(1, 50), 1); + assert_eq!(51u32.clamp(1, 50), 50); + assert_eq!(10u32.clamp(1, 50), 10); + } +} diff --git a/src/mcp/tools/runner.rs b/src/mcp/tools/runner.rs new file mode 100644 index 0000000..cc42823 --- /dev/null +++ b/src/mcp/tools/runner.rs @@ -0,0 +1,198 @@ +//! 代码运行器 MCP 工具:在容器沙箱内执行代码,同步返回完整输出。 +//! +//! 与 `src/api/code_runner/execute.rs::start_exec` 的执行链一致(语言白名单 → +//! 源码大小 → 信号量限并发 → clamp_limits → run_in_container),但 **同步返回** +//! 完整 stdout/stderr,不引入 task_id / 轮询 / SSE 机制——MCP 工具返回单一结果。 +//! +//! 鉴权与限流:MCP 走 bearer token → admin 作用域。admin 跳过 IP 速率限制 +//! (与 web 的 `check_rate_limit_for_user` admin 放行一致),但仍受并发槽、 +//! 资源钳制与源码大小校验约束。Docker daemon 不可用时返回明确错误(与 +//! `get_docker()` 的 NotFound 脱敏路径一致)。 + +#![cfg(feature = "server")] + +use rmcp::handler::server::tool::Extension; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, TextContent}; +use rmcp::{schemars, tool, tool_router, ErrorData as McpError}; + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use crate::api::code_runner::execute::RUNNER_SEMAPHORE; +use crate::api::code_runner::languages::{is_supported_lang, normalize_lang, LANGUAGES}; +use crate::infra::docker::run_in_container; +use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG}; +use crate::mcp::auth::McpPrincipal; +use crate::models::mcp_token::TokenScope; + +/// `run_code` 入参。 +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct RunCodeParams { + /// 语言标识(支持别名归一化:js/javascript→node、ts/typescript→bun、rs→rust; + /// canonical: python/node/go/rust/bun)。 + pub language: String, + /// 源代码(受 `CODE_RUNNER_MAX_SOURCE_BYTES` 限制,默认 64KB)。 + pub source: String, +} + +/// `run_code` 返回的执行结果。 +#[derive(Debug, Serialize)] +struct RunResult { + /// 执行终态:success / error / oom / timeout / unavailable。 + status: &'static str, + /// 进程退出码(容器未跑完 / 超时 / daemon 不可用时为 null)。 + exit_code: Option, + /// 标准输出(已按 output_bytes 上限截断)。 + stdout: String, + /// 标准错误 / 失败原因描述(已按 output_bytes 上限截断)。 + stderr: String, + /// 执行耗时(毫秒)。 + duration_ms: u64, + /// 归一化后的 canonical 语言 key(如 python/node/go/rust/bun)。 + language: String, +} + +#[tool_router(router = runner_router, vis = "pub")] +impl crate::mcp::server::YggMcpServer { + /// 在容器沙箱内执行代码并返回输出。要求 admin 作用域。 + #[tool(description = "在 Docker 沙箱内执行代码(支持 python/node/go/rust/bun),返回 stdout/stderr。需要 admin 作用域。")] + async fn run_code( + &self, + Parameters(RunCodeParams { language, source }): Parameters, + Extension(parts): Extension, + ) -> Result { + require_admin(&parts, "run_code")?; + + // 1. 语言白名单(与 validate_exec_request 一致;is_supported_lang 内含归一化)。 + if !is_supported_lang(&language) { + return Err(McpError::invalid_request( + "unsupported language: use one of python/node/go/rust/bun (js/rs/ts aliases accepted)", + None, + )); + } + + // 2. 源码大小限制。 + if source.len() > RUNNER_CONFIG.max_source_bytes as usize { + return Err(McpError::invalid_request( + format!( + "source too large: {} bytes > limit {}", + source.len(), + RUNNER_CONFIG.max_source_bytes + ), + None, + )); + } + + let result = execute_in_container(&language, &source) + .await + .map_err(|e| McpError::internal_error(format!("code execution failed: {e}"), None))?; + + let text = serde_json::to_string_pretty(&result) + .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) + } +} + +/// 作用域守卫:admin 工具要求 `token.scope >= admin`,不足则返回 insufficient_scope。 +fn require_admin(parts: &http::request::Parts, tool: &str) -> Result<(), McpError> { + let principal = parts + .extensions + .get::() + .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; + if !principal.scope.grants(TokenScope::Admin) { + return Err(McpError::invalid_request( + format!("insufficient_scope: {tool} requires admin"), + None, + )); + } + Ok(()) +} + +/// 执行一次容器内代码运行(同步收集输出)。 +/// +/// 镜像 start_exec 的后台 spawn 体:排队信号量 → 归一化语言 → clamp_limits → +/// run_in_container,但把结果直接返回而非写入 EXEC_TASKS。 +async fn execute_in_container(language: &str, source: &str) -> Result { + let lang_key = normalize_lang(language); + + let lang_def = LANGUAGES + .get(&lang_key) + .ok_or_else(|| format!("language not registered: {lang_key}"))?; + + // 排队等待可用容器槽(与 start_exec 一致的 queue_timeout_secs)。 + let ticket = tokio::time::timeout( + Duration::from_secs(RUNNER_CONFIG.queue_timeout_secs), + RUNNER_SEMAPHORE.acquire(), + ) + .await + .map_err(|_| "container queue timeout: too many concurrent executions".to_string())? + .map_err(|e| format!("semaphore acquire: {e}"))?; + + let final_limits = clamp_limits(lang_def.default_limits.clone(), lang_def.allow_network); + + let start_time = chrono::Utc::now(); + let res = run_in_container( + &lang_def.image, + &lang_def.run_cmd, + source, + &lang_def.extension, + final_limits, + ) + .await; + let duration_ms = (chrono::Utc::now() - start_time).num_milliseconds().max(0) as u64; + + drop(ticket); // 显式释放信号量 + + match res { + Ok((exit_code, stdout, stderr, oom_killed)) => { + let status = if oom_killed { + "oom" + } else if exit_code == Some(0) { + "success" + } else { + "error" + }; + Ok(RunResult { + status, + exit_code, + stdout, + stderr, + duration_ms, + language: lang_key, + }) + } + Err(e) => { + // 脱敏与 start_exec 的失败分支一致:日志记详情,对外给通用消息。 + let s = e.to_string(); + let is_timeout = s.contains("TimedOut"); + tracing::error!(error = ?e, "MCP container execution failed"); + // bollard IOError{NotFound} = Docker daemon 不可用 → 明确的 unavailable。 + let is_daemon_down = s.contains("Docker daemon") || s.contains("NotFound"); + let status = if is_daemon_down { + "unavailable" + } else if is_timeout { + "timeout" + } else { + "error" + }; + let stderr = if is_daemon_down { + "code runner unavailable: Docker daemon not running".to_string() + } else if is_timeout { + "execution timed out".to_string() + } else { + "code runner temporarily unavailable".to_string() + }; + Ok(RunResult { + status, + exit_code: None, + stdout: String::new(), + stderr, + duration_ms, + language: lang_key, + }) + } + } +} diff --git a/src/mcp/tools/settings.rs b/src/mcp/tools/settings.rs new file mode 100644 index 0000000..b6872e7 --- /dev/null +++ b/src/mcp/tools/settings.rs @@ -0,0 +1,172 @@ +//! 站点设置 MCP 工具:读取/更新回收站自动清理配置。 +//! +//! 与 `src/api/settings.rs` 的 `get_trash_settings` / `update_trash_settings` +//! server-fn 一致(同样的 SQL、同样的 clamp)。区别仅在鉴权入口:web 走 cookie +//! `get_current_admin_user()`,MCP 走 bearer token → `McpPrincipal`,要求 admin 作用域。 +//! +//! 缓存失效:与 web fn 保持一致——`update_trash_settings` **不做任何缓存失效**。 +//! 理由:回收站配置只影响管理后台(SSR 缓存在 `admin/`,`invalidate_ssr_all_public` +//! 明确保留不动)和后台清理任务,没有公开页缓存表面,故无需失效。 +//! (约束 #5 要求「按 web admin server fn 的方式失效」——该 fn 的方式就是不失效。) + +#![cfg(feature = "server")] + +use rmcp::handler::server::tool::Extension; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, TextContent}; +use rmcp::{schemars, tool, tool_router, ErrorData as McpError}; + +use serde::Deserialize; + +use crate::api::error::AppError; +use crate::db::pool::get_conn; +use crate::mcp::auth::McpPrincipal; +use crate::models::mcp_token::TokenScope; +use crate::models::settings::{ + TrashSettings, DEFAULT_AUTO_PURGE_ENABLED, DEFAULT_RETENTION_DAYS, +}; + +/// `get_settings` 入参(无字段——预留扩展点,未来可按子域过滤)。 +#[derive(Debug, Deserialize, schemars::JsonSchema, Default)] +pub struct GetSettingsParams {} + +/// `update_settings` 入参:两项回收站配置。 +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct UpdateSettingsParams { + /// 是否启用回收站自动清理。 + pub auto_purge_enabled: bool, + /// 已删除文章保留天数(会被 clamp 到 [1, 365])。 + pub retention_days: i32, +} + +#[tool_router(router = settings_router, vis = "pub")] +impl crate::mcp::server::YggMcpServer { + /// 读取站点回收站设置。要求 admin 作用域。 + #[tool(description = "读取站点回收站配置(自动清理开关 + 保留天数)。需要 admin 作用域。")] + async fn get_settings( + &self, + Parameters(_p): Parameters, + Extension(parts): Extension, + ) -> Result { + require_admin(&parts, "get_settings")?; + + let settings = load_trash_settings() + .await + .map_err(|e| McpError::internal_error(format!("settings read failed: {e:?}"), None))?; + + let text = serde_json::to_string_pretty(&settings) + .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) + } + + /// 更新站点回收站设置。要求 admin 作用域。retention_days 会 clamp 到 [1, 365]。 + #[tool(description = "更新站点回收站配置(自动清理开关 + 保留天数)。retention_days 会被钳制到 1..=365。需要 admin 作用域。")] + async fn update_settings( + &self, + Parameters(UpdateSettingsParams { + auto_purge_enabled, + retention_days, + }): Parameters, + Extension(parts): Extension, + ) -> Result { + require_admin(&parts, "update_settings")?; + + let retention_days = TrashSettings::clamp_retention(retention_days); + + let updated = save_trash_settings(auto_purge_enabled, retention_days) + .await + .map_err(|e| McpError::internal_error(format!("settings write failed: {e:?}"), None))?; + + let text = serde_json::to_string_pretty(&updated) + .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) + } +} + +/// 作用域守卫:admin 工具要求 `token.scope >= admin`,不足则返回 insufficient_scope。 +fn require_admin(parts: &http::request::Parts, tool: &str) -> Result<(), McpError> { + let principal = parts + .extensions + .get::() + .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; + if !principal.scope.grants(TokenScope::Admin) { + return Err(McpError::invalid_request( + format!("insufficient_scope: {tool} requires admin"), + None, + )); + } + Ok(()) +} + +/// 读取回收站配置(与 `get_trash_settings` 的 SQL 一致)。 +/// +/// settings 表缺失键时回退默认值,保证向后兼容。 +async fn load_trash_settings() -> Result { + let client = get_conn().await.map_err(AppError::db_conn)?; + + let enabled: bool = client + .query_opt( + "SELECT value FROM settings WHERE key = 'trash_auto_purge_enabled'", + &[], + ) + .await + .map_err(AppError::query)? + .and_then(|r| r.get::<_, String>("value").parse().ok()) + .unwrap_or(DEFAULT_AUTO_PURGE_ENABLED); + + let days: i32 = client + .query_opt( + "SELECT value FROM settings WHERE key = 'trash_retention_days'", + &[], + ) + .await + .map_err(AppError::query)? + .and_then(|r| r.get::<_, String>("value").parse().ok()) + .unwrap_or(DEFAULT_RETENTION_DAYS); + + Ok(TrashSettings { + auto_purge_enabled: enabled, + retention_days: TrashSettings::clamp_retention(days), + }) +} + +/// 写入回收站配置(与 `update_trash_settings` 的 UPSERT 一致)。返回写入后的值。 +async fn save_trash_settings( + auto_purge_enabled: bool, + retention_days: i32, +) -> Result { + let client = get_conn().await.map_err(AppError::db_conn)?; + + client + .execute( + "INSERT INTO settings (key, value, updated_at) VALUES ('trash_auto_purge_enabled', $1, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", + &[&auto_purge_enabled.to_string()], + ) + .await + .map_err(AppError::query)?; + + client + .execute( + "INSERT INTO settings (key, value, updated_at) VALUES ('trash_retention_days', $1, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()", + &[&retention_days.to_string()], + ) + .await + .map_err(AppError::query)?; + + tracing::info!( + "MCP: trash settings updated: auto_purge={}, retention_days={}", + auto_purge_enabled, + retention_days + ); + + Ok(TrashSettings { + auto_purge_enabled, + retention_days, + }) +} diff --git a/src/mcp/tools/tags.rs b/src/mcp/tools/tags.rs new file mode 100644 index 0000000..9e625ca --- /dev/null +++ b/src/mcp/tools/tags.rs @@ -0,0 +1,185 @@ +//! MCP 写作用域工具:标签管理。 +//! +//! `create_tag` 与 `rename_tag` 没有独立的 web 后台 server-fn(标签在文章 +//! 保存时由 `sync_tags` 隐式创建)。本模块直接操作 tags 表,并在写后失效 +//! 标签云缓存(`invalidate_all_tags`)。 +//! +//! 本模块仅 `feature = "server"` 编译。 + +#![cfg(feature = "server")] + +use rmcp::handler::server::tool::Extension; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{CallToolResult, ContentBlock, TextContent}; +use rmcp::{schemars, tool, tool_router, ErrorData as McpError}; +use serde::Deserialize; + +use crate::cache; +use crate::db::pool::get_conn; +use crate::mcp::auth::McpPrincipal; +use crate::models::mcp_token::TokenScope; + +#[tool_router(router = tags_router, vis = "pub")] +impl crate::mcp::server::YggMcpServer { + /// 创建一个新标签。若同名标签已存在则返回已有标签 id。要求 write 作用域。 + #[tool(description = "创建一个新标签。若同名标签已存在则返回其 id(幂等)。")] + async fn create_tag( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "create_tag", TokenScope::Write)?; + + let name = p.name.trim().to_string(); + if name.is_empty() { + return Err(McpError::invalid_request("name must not be empty", None)); + } + + let client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + + // 与 sync_tags 一致的 upsert 语义。 + let row = client + .query_opt( + "INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id", + &[&name], + ) + .await + .map_err(|e| internal(e, "insert tag"))?; + let (tag_id, created): (i32, bool) = match row { + Some(r) => (r.get(0), true), + None => { + let r = client + .query_one("SELECT id FROM tags WHERE name = $1", &[&name]) + .await + .map_err(|e| internal(e, "select existing tag"))?; + (r.get(0), false) + } + }; + + cache::invalidate_all_tags(); + + ok_json(TagResult { + success: true, + message: if created { + "标签已创建".into() + } else { + "标签已存在".into() + }, + tag_id: Some(tag_id), + name, + }) + } + + /// 重命名指定标签。要求 write 作用域。 + #[tool(description = "重命名指定标签。若目标名称已被其他标签占用则报错。")] + async fn rename_tag( + &self, + Parameters(p): Parameters, + Extension(parts): Extension, + ) -> Result { + let _principal = require_scope(&parts, "rename_tag", TokenScope::Write)?; + + let name = p.name.trim().to_string(); + if name.is_empty() { + return Err(McpError::invalid_request("name must not be empty", None)); + } + + let client = get_conn().await.map_err(|e| internal(e, "db connection"))?; + + // 检查目标名称是否已被其他标签占用(排除自身)。 + let conflict = client + .query_opt( + "SELECT 1 FROM tags WHERE name = $1 AND id != $2", + &[&name, &p.tag_id], + ) + .await + .map_err(|e| internal(e, "check conflict"))?; + if conflict.is_some() { + return Err(McpError::invalid_request( + format!("标签名「{name}」已被占用"), + None, + )); + } + + let result = client + .execute( + "UPDATE tags SET name = $1 WHERE id = $2", + &[&name, &p.tag_id], + ) + .await + .map_err(|e| internal(e, "rename tag"))?; + if result == 0 { + return Err(McpError::invalid_request("标签不存在", None)); + } + + cache::invalidate_all_tags(); + + ok_json(TagResult { + success: true, + message: "标签已重命名".into(), + tag_id: Some(p.tag_id), + name, + }) + } +} + +// --------------------------------------------------------------------------- +// 参数与输出结构 +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct CreateTagParams { + /// 标签名称。 + pub name: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct RenameTagParams { + /// 要重命名的标签 id。 + pub tag_id: i32, + /// 新标签名称。 + pub name: String, +} + +#[derive(Debug, serde::Serialize)] +struct TagResult { + success: bool, + message: String, + tag_id: Option, + name: String, +} + +// --------------------------------------------------------------------------- +// 鉴权 + 错误辅助 +// --------------------------------------------------------------------------- + +fn require_scope( + parts: &http::request::Parts, + tool: &str, + scope: TokenScope, +) -> Result { + let p = parts + .extensions + .get::() + .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?; + if !p.scope.grants(scope) { + return Err(McpError::invalid_request( + format!("insufficient_scope: {tool} requires {}", scope.as_str()), + None, + )); + } + Ok(p.clone()) +} + +fn internal(e: E, ctx: &'static str) -> McpError { + tracing::error!("mcp tags {ctx}: {e}"); + McpError::internal_error(ctx, None) +} + +fn ok_json(val: T) -> Result { + let text = serde_json::to_string_pretty(&val) + .map_err(|e| internal(e, "encode result"))?; + Ok(CallToolResult::success(vec![ContentBlock::Text( + TextContent::new(text), + )])) +} diff --git a/src/pages/admin/mcp.rs b/src/pages/admin/mcp.rs new file mode 100644 index 0000000..b69355a --- /dev/null +++ b/src/pages/admin/mcp.rs @@ -0,0 +1,633 @@ +//! 管理后台「MCP 服务器」页面。 +//! +//! 管理员在此签发/查看/撤销为 AI 客户端(Claude Code / Cursor / Cline)准备的 +//! bearer 令牌,并复制对应的客户端配置片段。功能分三块: +//! - 令牌列表:名称 / 作用域 / 创建时间 / 过期 / 最近使用 / 状态 + 撤销 / 重查按钮。 +//! - 新建令牌表单:名称 + 作用域下拉 + 有效期下拉;提交后一次性弹窗展示明文。 +//! - 客户端配置:选中令牌后展示 4 种可复制的配置片段 + CLI 一行命令。 +//! +//! 仅 WASM 前端交互,数据经 Dioxus server functions(`src/api/mcp_tokens.rs`)加载。 +//! 配置片段由服务端生成(`src/mcp/config.rs`,server-only 模块),经 `get_mcp_client_configs` +//! server fn 返回——`APP_BASE_URL` 等环境变量只在服务端可读。 +//! +//! 跨子组件状态(刷新触发、一次性明文弹窗、配置令牌)经一个共享 context 传递, +//! 避免列表与表单组件各自维护互相不可见的信号。 + +use dioxus::prelude::*; + +#[cfg(target_arch = "wasm32")] +use crate::api::mcp_tokens::{ + create_mcp_token, get_mcp_client_configs, list_mcp_tokens, reveal_mcp_token, revoke_mcp_token, + McpClientConfigs, TokenLifetime, +}; +#[cfg(target_arch = "wasm32")] +use crate::components::forms::INPUT_CLASS; +#[cfg(target_arch = "wasm32")] +use crate::components::ui::{ + ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS, BADGE_BASE, BTN_PRIMARY, BTN_PRIMARY_SM, BTN_TEXT_RED, +}; +#[cfg(target_arch = "wasm32")] +use crate::models::mcp_token::{McpTokenSummary, TokenScope}; + +/// 作用域可选项(下拉菜单)。 +#[cfg(target_arch = "wasm32")] +const SCOPE_OPTIONS: &[(TokenScope, &str)] = &[ + (TokenScope::Read, "read — 仅查询已发布文章"), + (TokenScope::Write, "write — read + 文章/评论/标签/媒体 CRUD"), + (TokenScope::Admin, "admin — write + 站点设置 / 代码运行器"), +]; + +/// 有效期可选项(下拉菜单)。 +#[cfg(target_arch = "wasm32")] +const LIFETIME_OPTIONS: &[(TokenLifetime, &str)] = &[ + (TokenLifetime::Days1, "1 天"), + (TokenLifetime::Days7, "7 天"), + (TokenLifetime::Days30, "30 天"), + (TokenLifetime::Days90, "90 天"), + (TokenLifetime::Never, "永不过期"), +]; + +/// 跨子组件共享的页面状态:刷新代际、一次性明文弹窗、配置用令牌、操作提示。 +/// +/// `PartialEq` 由组件宏生成的 Props 结构体要求(`TokenRow` 以此为 prop)。 +/// `Signal` 实现了 `PartialEq`(比较当前值),故派生可行。 +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Copy, PartialEq)] +struct McpPageState { + /// 递增以触发令牌列表重新加载(创建/撤销后 +1)。 + reload_gen: Signal, + /// 一次性明文弹窗:Some 时展示。 + created_plaintext: Signal>, + /// 重查明文弹窗:(id, plaintext)。 + revealed: Signal>, + /// 配置卡片使用的令牌明文。 + config_token: Signal>, + /// 全局操作提示:(消息, 是否错误)。 + toast: Signal>, +} + +/// 管理后台 MCP 令牌管理页面。 +#[component] +#[allow(clippy::needless_pass_by_value)] +pub fn Mcp() -> Element { + #[cfg(target_arch = "wasm32")] + { + let state = McpPageState { + reload_gen: use_signal(|| 0), + created_plaintext: use_signal(|| None), + revealed: use_signal(|| None), + config_token: use_signal(|| None), + toast: use_signal(|| None), + }; + use_context_provider(|| state); + + rsx! { + div { class: "w-full max-w-7xl mx-auto space-y-8", + PageHeader {} + Toast {} + TokenList {} + CreateTokenCard {} + ConfigCard {} + } + } + } + #[cfg(not(target_arch = "wasm32"))] + { + // server 构建下页面无前端交互;路由实际只在 WASM 渲染。 + rsx! { p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" } } + } +} + +/// 页头标题区。 +#[component] +fn PageHeader() -> Element { + rsx! { + div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-8 border-b border-[var(--color-paper-border)]/50", + div { + h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]", + "MCP 服务器" + } + p { class: "text-base text-[var(--color-paper-secondary)] mt-2", + "为 AI 客户端(Claude Code / Cursor / Cline)签发访问令牌,并复制接入配置。" + } + } + } + } +} + +/// 全局操作提示条(读取共享 context 的 toast)。 +#[cfg(target_arch = "wasm32")] +#[component] +fn Toast() -> Element { + let state: McpPageState = use_context(); + let toast = state.toast; + if let Some((msg, is_err)) = toast() { + let cls = if is_err { + "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300" + } else { + "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300" + }; + rsx! { + div { class: "text-sm rounded-lg px-3 py-2 {cls}", "{msg}" } + } + } else { + rsx! {} + } +} + +/// 令牌列表卡片 + 一次性明文弹窗 + 撤销/重查操作。 +#[cfg(target_arch = "wasm32")] +#[component] +fn TokenList() -> Element { + let mut state: McpPageState = use_context(); + let mut tokens = use_signal(|| Vec::::new()); + let mut loaded_gen = use_signal(|| None::); + + let reload_gen = state.reload_gen; + let mut toast = state.toast; + + // reload_gen 变化时重新加载。 + use_effect(move || { + let g = reload_gen(); + if loaded_gen() != Some(g) { + loaded_gen.set(Some(g)); + spawn(async move { + match list_mcp_tokens().await { + Ok(list) => tokens.set(list), + Err(e) => toast.set(Some((format!("加载失败:{e}"), true))), + } + }); + } + }); + + let mut created_plaintext = state.created_plaintext; + let mut revealed = state.revealed; + + rsx! { + div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6", + div { class: "flex items-center justify-between", + h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "令牌列表" } + button { + class: "text-xs text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer", + onclick: move |_| { + let g = reload_gen(); + state.reload_gen.set(g + 1); + }, + "刷新" + } + } + + // 一次性明文弹窗(签发后立即展示) + if let Some(plaintext) = created_plaintext() { + PlaintextModal { + title: "令牌已创建(请立即复制,可稍后重新查看)".to_string(), + plaintext: plaintext.clone(), + on_use_config: { + let mut ct = state.config_token; + let mut cp = state.created_plaintext; + let mut toast = state.toast; + move |_| { + ct.set(Some(plaintext.clone())); + cp.set(None); + toast.set(Some(("已选为配置令牌".to_string(), false))); + } + }, + on_close: move |_| created_plaintext.set(None), + } + } + + // 重查明文弹窗 + if let Some((_, plaintext)) = revealed() { + PlaintextModal { + title: "令牌明文".to_string(), + plaintext: plaintext.clone(), + on_use_config: { + let mut ct = state.config_token; + let mut rev = state.revealed; + let mut toast = state.toast; + move |_| { + ct.set(Some(plaintext.clone())); + rev.set(None); + toast.set(Some(("已选为配置令牌".to_string(), false))); + } + }, + on_close: move |_| revealed.set(None), + } + } + + // 表格 + if tokens().is_empty() { + p { class: "text-[var(--color-paper-secondary)] text-sm py-4 text-center", + "暂无令牌。在下方新建一个。" + } + } else { + div { class: "{ADMIN_TABLE_CLASS}", + table { class: "w-full text-sm", + thead { + tr { class: "bg-[var(--color-paper-theme)]/50 text-left text-[var(--color-paper-secondary)]", + th { class: "px-4 py-3 font-medium", "名称" } + th { class: "px-4 py-3 font-medium", "作用域" } + th { class: "px-4 py-3 font-medium", "创建" } + th { class: "px-4 py-3 font-medium", "过期" } + th { class: "px-4 py-3 font-medium", "最近使用" } + th { class: "px-4 py-3 font-medium", "状态" } + th { class: "px-4 py-3 font-medium text-right", "操作" } + } + } + tbody { + for t in tokens().iter() { + TokenRow { key: "{t.id}", token: t.clone(), state } + } + } + } + } + } + } + } +} + +/// 单行令牌:展示元数据 + 重查/用于配置/撤销按钮。 +#[cfg(target_arch = "wasm32")] +#[component] +fn TokenRow(token: McpTokenSummary, state: McpPageState) -> Element { + let is_revoked = token.revoked_at.is_some(); + let is_expired = token.expires_at.map(|e| e < chrono::Utc::now()).unwrap_or(false); + let active = !is_revoked && !is_expired; + let created = token.created_at.format("%Y-%m-%d").to_string(); + let expires = token + .expires_at + .map(|e| e.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "永不过期".to_string()); + let last_used = token + .last_used_at + .map(|e| e.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "—".to_string()); + let status_label = if is_revoked { + "已撤销" + } else if is_expired { + "已过期" + } else { + "有效" + }; + let status_class = if active { + "bg-green-500/10 text-green-600 dark:text-green-400" + } else { + "bg-gray-500/10 text-gray-500 dark:text-gray-400" + }; + + let id_reveal = token.id.clone(); + let id_config = token.id.clone(); + let id_revoke = token.id.clone(); + + // 提取信号到局部,避免 `state.field()` 被解析为方法调用。 + let mut revealed = state.revealed; + let mut toast = state.toast; + let mut config_token = state.config_token; + let reload_gen = state.reload_gen; + + rsx! { + tr { class: "border-b border-[var(--color-paper-border)] last:border-b-0 hover:bg-[var(--color-paper-theme)]/30 transition-colors", + td { class: "px-4 py-3 font-medium text-[var(--color-paper-primary)]", "{token.name}" } + td { class: "px-4 py-3", "{token.scope.as_str()}" } + td { class: "px-4 py-3 text-[var(--color-paper-secondary)]", "{created}" } + td { class: "px-4 py-3 text-[var(--color-paper-secondary)]", "{expires}" } + td { class: "px-4 py-3 text-[var(--color-paper-secondary)]", "{last_used}" } + td { class: "px-4 py-3", + span { class: "{BADGE_BASE} {status_class}", "{status_label}" } + } + td { class: "px-4 py-3 text-right whitespace-nowrap", + if active { + button { + class: "text-xs text-[var(--color-paper-accent)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer mr-3", + onclick: move |_| { + let id = id_reveal.clone(); + spawn(async move { + match reveal_mcp_token(id.clone()).await { + Ok(Some(p)) => revealed.set(Some((id, p))), + Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))), + Err(e) => toast.set(Some((format!("重查失败:{e}"), true))), + } + }); + }, + "重新查看" + } + button { + class: "text-xs text-[var(--color-paper-accent)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer mr-3", + onclick: move |_| { + let id = id_config.clone(); + spawn(async move { + match reveal_mcp_token(id).await { + Ok(Some(p)) => { + config_token.set(Some(p)); + toast.set(Some(("已选为配置令牌".to_string(), false))); + } + Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))), + Err(e) => toast.set(Some((format!("重查失败:{e}"), true))), + } + }); + }, + "用于配置" + } + button { + class: "{BTN_TEXT_RED}", + onclick: move |_| { + let id = id_revoke.clone(); + spawn(async move { + match revoke_mcp_token(id).await { + Ok(()) => { + toast.set(Some(("已撤销".to_string(), false))); + let g = reload_gen(); + state.reload_gen.set(g + 1); + } + Err(e) => toast.set(Some((format!("撤销失败:{e}"), true))), + } + }); + }, + "撤销" + } + } else { + span { class: "text-xs text-[var(--color-paper-tertiary)]", "—" } + } + } + } + } +} + +/// 新建令牌表单卡片。成功后把明文写入共享 context 的一次性弹窗。 +#[cfg(target_arch = "wasm32")] +#[component] +fn CreateTokenCard() -> Element { + let mut state: McpPageState = use_context(); + let mut name = use_signal(String::new); + let mut scope = use_signal(|| TokenScope::Read); + let mut lifetime = use_signal(|| TokenLifetime::Days30); + let mut busy = use_signal(|| false); + + let mut created_plaintext = state.created_plaintext; + let reload_gen = state.reload_gen; + let mut toast = state.toast; + + rsx! { + div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6", + h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "新建令牌" } + + div { class: "grid grid-cols-1 md:grid-cols-3 gap-4", + // 名称 + div { class: "flex flex-col gap-2", + label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "名称" } + input { + class: "{INPUT_CLASS}", + r#type: "text", + placeholder: "如 claude-code-macbook", + value: "{name()}", + oninput: move |e| name.set(e.value()), + } + } + // 作用域 + div { class: "flex flex-col gap-2", + label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "作用域" } + select { + class: "{INPUT_CLASS}", + onchange: move |e| { + if let Some(s) = SCOPE_OPTIONS + .iter() + .find(|(_, label)| *label == e.value().as_str()) + .map(|(s, _)| *s) + { + scope.set(s); + } + }, + for (s, label) in SCOPE_OPTIONS { + option { value: "{label}", selected: *s == scope(), "{label}" } + } + } + } + // 有效期 + div { class: "flex flex-col gap-2", + label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "有效期" } + select { + class: "{INPUT_CLASS}", + onchange: move |e| { + if let Some(l) = LIFETIME_OPTIONS + .iter() + .find(|(_, label)| *label == e.value().as_str()) + .map(|(l, _)| *l) + { + lifetime.set(l); + } + }, + for (l, label) in LIFETIME_OPTIONS { + option { value: "{label}", selected: *l == lifetime(), "{label}" } + } + } + } + } + + div { + button { + class: "{BTN_PRIMARY}", + disabled: "{busy() || name().trim().is_empty()}", + onclick: move |_| { + if busy() { return; } + let n = name().trim().to_string(); + if n.is_empty() { return; } + let sc = scope(); + let lt = lifetime(); + busy.set(true); + spawn(async move { + match create_mcp_token(n, sc, lt).await { + Ok(resp) => { + created_plaintext.set(Some(resp.plaintext)); + name.set(String::new()); + let g = reload_gen(); + state.reload_gen.set(g + 1); + } + Err(e) => { + toast.set(Some((format!("创建失败:{e}"), true))); + } + } + busy.set(false); + }); + }, + if busy() { "创建中…" } else { "创建令牌" } + } + } + } + } +} + +/// 客户端配置卡片:展示 4 种配置片段(需先在令牌列表点「用于配置」或手动粘贴)。 +#[cfg(target_arch = "wasm32")] +#[component] +fn ConfigCard() -> Element { + let state: McpPageState = use_context(); + let mut manual_token = use_signal(String::new); + let mut configs = use_signal(|| None::); + let mut loading = use_signal(|| false); + + let config_token = state.config_token; + let mut toast = state.toast; + + // 当 config_token 变化时,请求服务端生成配置。 + use_effect(move || { + let token = config_token(); + let Some(t) = token else { return; }; + if loading() { return; } + loading.set(true); + manual_token.set(t.clone()); + spawn(async move { + match get_mcp_client_configs(t).await { + Ok(c) => configs.set(Some(c)), + Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))), + } + loading.set(false); + }); + }); + + // 手动输入时也触发生成。 + let mut on_manual_input = move |val: String| { + manual_token.set(val.clone()); + if val.trim().is_empty() { + configs.set(None); + return; + } + if loading() { return; } + loading.set(true); + spawn(async move { + match get_mcp_client_configs(val).await { + Ok(c) => configs.set(Some(c)), + Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))), + } + loading.set(false); + }); + }; + + rsx! { + div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6", + h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "客户端配置" } + p { class: "text-sm text-[var(--color-paper-secondary)]", + "在上方令牌列表点「用于配置」自动填入,或在下方手动粘贴令牌明文(形如 ygg_...)。" + } + + div { class: "flex flex-col gap-2", + label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "令牌明文" } + input { + class: "{INPUT_CLASS}", + r#type: "text", + placeholder: "ygg_...", + value: "{manual_token()}", + oninput: move |e| on_manual_input(e.value()), + } + } + + if loading() { + p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center", "生成配置中…" } + } else if let Some(c) = configs() { + div { class: "flex flex-col gap-4", + ConfigSnippet { title: "Claude Code(.mcp.json / ~/.claude.json)".to_string(), content: c.claude_code_json.clone() } + ConfigSnippet { title: "Cursor(~/.cursor/mcp.json)".to_string(), content: c.cursor_json.clone() } + ConfigSnippet { title: "Cline(cline_mcp_settings.json)".to_string(), content: c.cline_json.clone() } + ConfigSnippet { title: "通用(单 server entry)".to_string(), content: c.generic_json.clone() } + ConfigSnippet { title: "Claude Code CLI".to_string(), content: c.claude_cli.clone() } + } + } else { + p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center", + "粘贴令牌明文后此处显示配置片段。" + } + } + } + } +} + +/// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。 +#[cfg(target_arch = "wasm32")] +#[component] +fn ConfigSnippet(title: String, content: String) -> Element { + let state: McpPageState = use_context(); + let mut toast = state.toast; + rsx! { + div { class: "flex flex-col gap-2", + div { class: "flex items-center justify-between", + span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{title}" } + button { + class: "{BTN_PRIMARY_SM}", + onclick: move |_| { + let cc = content.clone(); + let tt = title.clone(); + spawn(async move { + copy_clipboard_wasm(&cc).await; + toast.set(Some((format!("已复制:{tt}"), false))); + }); + }, + "复制" + } + } + pre { + class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono", + code { "{content}" } + } + } + } +} + +/// 明文展示弹窗。 +#[cfg(target_arch = "wasm32")] +#[component] +fn PlaintextModal( + title: String, + plaintext: String, + on_use_config: EventHandler<()>, + on_close: EventHandler<()>, +) -> Element { + rsx! { + div { + class: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4", + onclick: move |_| on_close.call(()), + div { + class: "{ADMIN_CARD_CLASS} p-8 max-w-2xl w-full flex flex-col gap-4", + onclick: move |e| e.stop_propagation(), + h3 { class: "text-lg font-bold text-[var(--color-paper-primary)]", "{title}" } + pre { + class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-sm overflow-x-auto font-mono break-all", + code { "{plaintext}" } + } + div { class: "flex flex-wrap gap-3 justify-end", + button { + class: "{BTN_PRIMARY_SM}", + onclick: move |_| { + let p = plaintext.clone(); + spawn(async move { + copy_clipboard_wasm(&p).await; + }); + }, + "复制" + } + button { + class: "{BTN_PRIMARY_SM}", + onclick: move |_| on_use_config.call(()), + "用于配置" + } + button { + class: "px-4 py-1.5 text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer", + onclick: move |_| on_close.call(()), + "关闭" + } + } + } + } + } +} + +/// WASM 端剪贴板写入(navigator.clipboard.writeText)。 +/// +/// 失败静默忽略(非关键路径;用户可手动选中复制)。 +#[cfg(target_arch = "wasm32")] +async fn copy_clipboard_wasm(text: &str) { + use wasm_bindgen_futures::JsFuture; + + let Some(window) = web_sys::window() else { return }; + // web-sys 的 Navigator::clipboard() 直接返回 Clipboard(非 Option)。 + let clipboard = window.navigator().clipboard(); + // write_text 返回 Promise;忽略 reject(如非 HTTPS / 无焦点)。 + let _ = JsFuture::from(clipboard.write_text(text)).await; +} diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs index b721348..3884192 100644 --- a/src/pages/admin/mod.rs +++ b/src/pages/admin/mod.rs @@ -18,6 +18,8 @@ pub mod posts_trash; pub mod runner; /// 系统管理页面模块(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)。 pub mod system; +/// MCP 令牌管理 + 客户端配置生成页面模块。 +pub mod mcp; /// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。 pub mod write; @@ -33,5 +35,7 @@ pub use posts::Posts; pub use runner::Runner; /// 系统管理入口组件。 pub use system::System; +/// MCP 令牌管理 + 客户端配置生成页面组件。 +pub use mcp::Mcp; /// 文章编辑器组件(新建与编辑模式)。 pub use write::{Write, WriteEdit}; diff --git a/src/router.rs b/src/router.rs index 60c3faa..1b87acd 100644 --- a/src/router.rs +++ b/src/router.rs @@ -12,7 +12,7 @@ use crate::components::frontend_layout::FrontendLayout; use crate::context::UserContext; use crate::pages::about::About; use crate::pages::admin::{ - Admin, AdminComments, AdminCommentsPage, Assets, Posts, Runner, System, Write, WriteEdit, + Admin, AdminComments, AdminCommentsPage, Assets, Mcp, Posts, Runner, System, Write, WriteEdit, }; use crate::pages::archives::Archives; use crate::pages::home::{Home, HomePage}; @@ -92,6 +92,9 @@ pub enum Route { /// 代码试运行沙箱(作者预览可运行代码块输出) #[route("/runner")] Runner {}, + /// MCP 令牌管理 + 客户端配置生成 + #[route("/mcp")] + Mcp {}, #[end_layout] #[end_nest]