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:
xfy 2026-07-28 11:53:32 +08:00
parent 7b9dfa7851
commit 9eac025b3e
28 changed files with 4133 additions and 113 deletions

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

View 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).

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

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

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

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

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

View 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
View File

@ -5869,6 +5869,7 @@ dependencies = [
"aes-gcm",
"argon2",
"axum",
"base64",
"bollard",
"bytes",
"chrono",

View File

@ -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
View 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 字节随机数 hex64 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);
}
}

View File

@ -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 → LaTeXserver-only

View File

@ -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
View 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-printCLI 是单行 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 / Cursortype = "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();
// --- Clinetype = "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}");
}
}
}

View File

@ -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
View 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"));
}
}

View File

@ -1,126 +1,43 @@
//! MCP 服务器rmcp `ServerHandler` 实现,暴露博客工具
//! MCP 服务器rmcp `ServerHandler` 实现,组合所有工具组
//!
//! T1tracer 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 工具集 + ResourcesT4/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
View 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
View 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. 内容去重CASSHA-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
View 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
View 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
View 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,
/// 最多返回条数150默认 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
View 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
View 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
View 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
View 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: "Clinecline_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;
}

View File

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

View File

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