feat(mcp): 完整工具面 + token 管理 + 管理页(T2-T5 并行实现 + 合并)
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<Parts> 读 McpPrincipal + scope.grants 校验。
新增依赖 base64 0.22(媒体上传解码)。验证:server/web 双目标编译通过、clippy
--all-features -D warnings 干净、659 单测 + 1 集成全绿(+20 新测试)。
This commit is contained in:
parent
7b9dfa7851
commit
9eac025b3e
32
.scratch/mcp-server/README.md
Normal file
32
.scratch/mcp-server/README.md
Normal file
@ -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.
|
||||
60
.scratch/mcp-server/issues/T1-tracer-bullet.md
Normal file
60
.scratch/mcp-server/issues/T1-tracer-bullet.md
Normal file
@ -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<String>` 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<Parts>`;
|
||||
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).
|
||||
39
.scratch/mcp-server/issues/T2-token-mgmt-ui.md
Normal file
39
.scratch/mcp-server/issues/T2-token-mgmt-ui.md
Normal file
@ -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<McpTokenSummary>` (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.
|
||||
30
.scratch/mcp-server/issues/T3-read-tools-resources.md
Normal file
30
.scratch/mcp-server/issues/T3-read-tools-resources.md
Normal file
@ -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.
|
||||
32
.scratch/mcp-server/issues/T4-write-tools.md
Normal file
32
.scratch/mcp-server/issues/T4-write-tools.md
Normal file
@ -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.
|
||||
24
.scratch/mcp-server/issues/T5-admin-tools.md
Normal file
24
.scratch/mcp-server/issues/T5-admin-tools.md
Normal file
@ -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.
|
||||
33
.scratch/mcp-server/issues/T6-hardening.md
Normal file
33
.scratch/mcp-server/issues/T6-hardening.md
Normal file
@ -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.
|
||||
27
.scratch/mcp-server/issues/T7-verification-docs.md
Normal file
27
.scratch/mcp-server/issues/T7-verification-docs.md
Normal file
@ -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.
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -5869,6 +5869,7 @@ dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64",
|
||||
"bollard",
|
||||
"bytes",
|
||||
"chrono",
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
327
src/api/mcp_tokens.rs
Normal file
327
src/api/mcp_tokens.rs
Normal file
@ -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<chrono::DateTime<chrono::Utc>> {
|
||||
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<CreateTokenResponse, 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::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<Vec<McpTokenSummary>, 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<Option<String>, 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<McpClientConfigs, ServerFnError> {
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@ -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)。
|
||||
|
||||
@ -46,6 +46,7 @@ pub fn AdminLayout() -> Element {
|
||||
(Route::Posts {}, "管理文章"),
|
||||
(Route::Assets {}, "素材"),
|
||||
(Route::Runner {}, "试运行"),
|
||||
(Route::Mcp {}, "MCP"),
|
||||
(Route::System {}, "系统"),
|
||||
];
|
||||
|
||||
|
||||
187
src/mcp/config.rs
Normal file
187
src/mcp/config.rs
Normal file
@ -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 <name> <url> --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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
273
src/mcp/resources.rs
Normal file
273
src/mcp/resources.rs
Normal file
@ -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>) -> 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<i32> {
|
||||
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<Resource>,
|
||||
/// 下一页游标;None 表示已到末页。
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// 游标分页枚举已发布文章为 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<usize>,
|
||||
) -> Result<ResourcePage, AppError> {
|
||||
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<Resource> = rows
|
||||
.iter()
|
||||
.take(page_size)
|
||||
.map(|r| {
|
||||
let title: String = r.get("title");
|
||||
let slug: String = r.get("slug");
|
||||
let summary: Option<String> = r.get("summary");
|
||||
let size: Option<i64> = 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<Option<String>, 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"));
|
||||
}
|
||||
}
|
||||
@ -1,126 +1,43 @@
|
||||
//! MCP 服务器:rmcp `ServerHandler` 实现,暴露博客工具。
|
||||
//! MCP 服务器:rmcp `ServerHandler` 实现,组合所有工具组。
|
||||
//!
|
||||
//! T1(tracer bullet)仅暴露一个工具 `search_posts`,证明:
|
||||
//! - rmcp handler 经 `tool_router`/`tool_handler` 宏装配成功;
|
||||
//! - 工具内能经 `Extension<http::request::Parts>` 读取鉴权中间件注入的 `McpPrincipal`;
|
||||
//! - 作用域鉴权可生效(此处 search_posts 要求 read)。
|
||||
//! 装配模式(rmcp `tool_router` 宏支持的「命名路由 + 组合」):
|
||||
//! - 每个工具组在各自文件(`tools/<x>.rs`)里用
|
||||
//! `#[tool_router(router = <x>_router, vis = "pub")] impl YggMcpServer { ... }`
|
||||
//! 生成一个返回 `ToolRouter<YggMcpServer>` 的公开函数。
|
||||
//! - 本文件定义 `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<S>`)合并。鉴权与作用域校验在各工具内部
|
||||
//! 经 `Extension<http::request::Parts>` 读取 `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<SearchPostsParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
// 鉴权 + 作用域校验:read 工具要求 token.scope >= read。
|
||||
let principal = parts
|
||||
.extensions
|
||||
.get::<McpPrincipal>()
|
||||
.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<Self> {
|
||||
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<String>,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// 直查 DB:与 src/api/posts/search.rs 的 server-fn 一致的 pg_trgm 查询。
|
||||
/// 这里不复用 server-fn(后者依赖 FullstackContext 做限流,MCP 路径不走 cookie 鉴权),
|
||||
/// 直接走连接池。T3 会抽出共享 helper。
|
||||
async fn search_published(query: &str) -> Result<Vec<SearchHit>, 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。
|
||||
}
|
||||
|
||||
378
src/mcp/tools/comments.rs
Normal file
378
src/mcp/tools/comments.rs
Normal file
@ -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<ListCommentsParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<CommentItem> = 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<chrono::Utc>>("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<CommentIdParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<CommentIdParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<SetCommentStatusParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<i32>,
|
||||
/// 按状态筛选:pending / approved / spam / trash。不传则返回全部。
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
depth: i32,
|
||||
author_name: String,
|
||||
author_url: Option<String>,
|
||||
content_md: String,
|
||||
status: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct CommentsList {
|
||||
comments: Vec<CommentItem>,
|
||||
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<McpPrincipal, McpError> {
|
||||
let p = parts
|
||||
.extensions
|
||||
.get::<McpPrincipal>()
|
||||
.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: std::fmt::Display>(e: E, ctx: &'static str) -> McpError {
|
||||
tracing::error!("mcp comments {ctx}: {e}");
|
||||
McpError::internal_error(ctx, None)
|
||||
}
|
||||
|
||||
fn ok_json<T: serde::Serialize>(val: T) -> Result<CallToolResult, McpError> {
|
||||
let text = serde_json::to_string_pretty(&val)
|
||||
.map_err(|e| internal(e, "encode result"))?;
|
||||
Ok(CallToolResult::success(vec![ContentBlock::Text(
|
||||
TextContent::new(text),
|
||||
)]))
|
||||
}
|
||||
373
src/mcp/tools/media.rs
Normal file
373
src/mcp/tools/media.rs
Normal file
@ -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<UploadMediaParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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.<uuid>.<ext>。
|
||||
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<Option<String>, 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<u8>, 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<String>,
|
||||
/// base64 编码的图片字节(支持 data URL 前缀,会自动去除)。
|
||||
pub base64: String,
|
||||
/// 替代文本(alt),目前未持久化,保留供未来扩展。
|
||||
#[serde(default)]
|
||||
pub alt: Option<String>,
|
||||
}
|
||||
|
||||
#[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<McpPrincipal, McpError> {
|
||||
let p = parts
|
||||
.extensions
|
||||
.get::<McpPrincipal>()
|
||||
.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: std::fmt::Display>(e: E, ctx: &'static str) -> McpError {
|
||||
tracing::error!("mcp media {ctx}: {e}");
|
||||
McpError::internal_error(ctx, None)
|
||||
}
|
||||
|
||||
fn ok_json<T: serde::Serialize>(val: T) -> Result<CallToolResult, McpError> {
|
||||
let text = serde_json::to_string_pretty(&val)
|
||||
.map_err(|e| internal(e, "encode result"))?;
|
||||
Ok(CallToolResult::success(vec![ContentBlock::Text(
|
||||
TextContent::new(text),
|
||||
)]))
|
||||
}
|
||||
24
src/mcp/tools/mod.rs
Normal file
24
src/mcp/tools/mod.rs
Normal file
@ -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;
|
||||
722
src/mcp/tools/posts.rs
Normal file
722
src/mcp/tools/posts.rs
Normal file
@ -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<CreatePostParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<UpdatePostParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<String> = 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<String> = {
|
||||
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<chrono::DateTime<chrono::Utc>> =
|
||||
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<String> = old_tags
|
||||
.into_iter()
|
||||
.chain(tags_for_invalidation)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.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<PostIdParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<PostIdParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<String> = 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<PostIdParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<String> = 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<String>,
|
||||
/// URL slug;未提供时从标题自动生成。
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
/// 标签列表。
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// 状态:`draft`(默认)或 `published`。
|
||||
#[serde(default = "default_status")]
|
||||
pub status: String,
|
||||
/// 封面图 URL。
|
||||
#[serde(default)]
|
||||
pub cover_image: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// 新 slug。
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
/// 新标签列表(完全替换旧标签)。
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// 新状态。
|
||||
#[serde(default = "default_status")]
|
||||
pub status: String,
|
||||
/// 新封面图 URL。
|
||||
#[serde(default)]
|
||||
pub cover_image: Option<String>,
|
||||
}
|
||||
|
||||
#[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<i32>,
|
||||
slug: Option<String>,
|
||||
}
|
||||
|
||||
fn default_status() -> String {
|
||||
"draft".to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 鉴权 + 错误辅助
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 从 request.extensions 读取 McpPrincipal 并检查作用域。
|
||||
fn require_scope(
|
||||
parts: &http::request::Parts,
|
||||
tool: &str,
|
||||
scope: TokenScope,
|
||||
) -> Result<McpPrincipal, McpError> {
|
||||
let p = parts
|
||||
.extensions
|
||||
.get::<McpPrincipal>()
|
||||
.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: std::fmt::Display>(e: E, ctx: &'static str) -> McpError {
|
||||
tracing::error!("mcp posts {ctx}: {e}");
|
||||
McpError::internal_error(ctx, None)
|
||||
}
|
||||
|
||||
fn ok_json<T: serde::Serialize>(val: T) -> Result<CallToolResult, McpError> {
|
||||
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<String> {
|
||||
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(())
|
||||
}
|
||||
335
src/mcp/tools/read.rs
Normal file
335
src/mcp/tools/read.rs
Normal file
@ -0,0 +1,335 @@
|
||||
//! MCP 读作用域工具组(知识库)。
|
||||
//!
|
||||
//! 三个 `read` 作用域工具,均经 `Extension<http::request::Parts>` 读取鉴权中间件
|
||||
//! 注入的 `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<SearchPostsParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<GetPostParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<McpPrincipal, McpError> {
|
||||
let principal = parts
|
||||
.extensions
|
||||
.get::<McpPrincipal>()
|
||||
.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<u32>,
|
||||
}
|
||||
|
||||
/// `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<String>,
|
||||
pub tags: Vec<String>,
|
||||
/// 站内文章 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<String>,
|
||||
pub content_md: String,
|
||||
pub tags: Vec<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// 站内文章 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<String> {
|
||||
let mut tags: Vec<String> = row
|
||||
.try_get::<_, Vec<String>>("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<Vec<SearchHit>, 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<Option<PostResource>, 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<Vec<TagCount>, 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);
|
||||
}
|
||||
}
|
||||
198
src/mcp/tools/runner.rs
Normal file
198
src/mcp/tools/runner.rs
Normal file
@ -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<i64>,
|
||||
/// 标准输出(已按 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<RunCodeParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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::<McpPrincipal>()
|
||||
.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<RunResult, String> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/mcp/tools/settings.rs
Normal file
172
src/mcp/tools/settings.rs
Normal file
@ -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<GetSettingsParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<UpdateSettingsParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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::<McpPrincipal>()
|
||||
.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<TrashSettings, AppError> {
|
||||
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<TrashSettings, AppError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
185
src/mcp/tools/tags.rs
Normal file
185
src/mcp/tools/tags.rs
Normal file
@ -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<CreateTagParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<RenameTagParams>,
|
||||
Extension(parts): Extension<http::request::Parts>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<i32>,
|
||||
name: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 鉴权 + 错误辅助
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn require_scope(
|
||||
parts: &http::request::Parts,
|
||||
tool: &str,
|
||||
scope: TokenScope,
|
||||
) -> Result<McpPrincipal, McpError> {
|
||||
let p = parts
|
||||
.extensions
|
||||
.get::<McpPrincipal>()
|
||||
.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: std::fmt::Display>(e: E, ctx: &'static str) -> McpError {
|
||||
tracing::error!("mcp tags {ctx}: {e}");
|
||||
McpError::internal_error(ctx, None)
|
||||
}
|
||||
|
||||
fn ok_json<T: serde::Serialize>(val: T) -> Result<CallToolResult, McpError> {
|
||||
let text = serde_json::to_string_pretty(&val)
|
||||
.map_err(|e| internal(e, "encode result"))?;
|
||||
Ok(CallToolResult::success(vec![ContentBlock::Text(
|
||||
TextContent::new(text),
|
||||
)]))
|
||||
}
|
||||
633
src/pages/admin/mcp.rs
Normal file
633
src/pages/admin/mcp.rs
Normal file
@ -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<T>` 实现了 `PartialEq`(比较当前值),故派生可行。
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct McpPageState {
|
||||
/// 递增以触发令牌列表重新加载(创建/撤销后 +1)。
|
||||
reload_gen: Signal<u32>,
|
||||
/// 一次性明文弹窗:Some 时展示。
|
||||
created_plaintext: Signal<Option<String>>,
|
||||
/// 重查明文弹窗:(id, plaintext)。
|
||||
revealed: Signal<Option<(String, String)>>,
|
||||
/// 配置卡片使用的令牌明文。
|
||||
config_token: Signal<Option<String>>,
|
||||
/// 全局操作提示:(消息, 是否错误)。
|
||||
toast: Signal<Option<(String, bool)>>,
|
||||
}
|
||||
|
||||
/// 管理后台 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::<McpTokenSummary>::new());
|
||||
let mut loaded_gen = use_signal(|| None::<u32>);
|
||||
|
||||
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::<McpClientConfigs>);
|
||||
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<void>;忽略 reject(如非 HTTPS / 无焦点)。
|
||||
let _ = JsFuture::from(clipboard.write_text(text)).await;
|
||||
}
|
||||
@ -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};
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user