Skip to main content

yggdrasil/mcp/tools/
posts.rs

1//! MCP 写作用域工具:文章 CRUD。
2//!
3//! bearer-token 鉴权后调用文章业务模块;协议参数与错误在此转换。
4//! 每个写操作后执行与 web 后台完全一致的缓存失效(moka + SSR)。
5//!
6//! 本模块仅 `feature = "server"` 编译;`server.rs` 在最终装配时把 `posts_router`
7//! 组合进单一 `ServerHandler`。
8
9#![cfg(feature = "server")]
10#![allow(clippy::too_many_arguments)]
11
12use rmcp::handler::server::tool::Extension;
13use rmcp::handler::server::wrapper::Parameters;
14use rmcp::model::CallToolResult;
15use rmcp::{schemars, tool, tool_router, ErrorData as McpError};
16use serde::Deserialize;
17
18use super::common::{internal, ok_json, require_scope};
19use crate::api::posts::service::{
20    self, FieldChange, PostAction, PostDraft, PostPatch, UpdatePost, WriteError,
21};
22use crate::db::pool::get_conn;
23use crate::models::mcp_token::TokenScope;
24
25// ---------------------------------------------------------------------------
26// 结构体
27// ---------------------------------------------------------------------------
28
29// ---------------------------------------------------------------------------
30// 工具
31// ---------------------------------------------------------------------------
32
33#[tool_router(router = posts_router, vis = "pub")]
34impl crate::mcp::server::YggMcpServer {
35    #[tool(
36        description = "分页查询当前令牌用户的文章(含草稿)。需要 write 权限;status 可选 draft/published,省略返回全部未删除文章。query 按标题搜索,page 从 1 开始,per_page 为 1..=50。"
37    )]
38    async fn list_posts(
39        &self,
40        Parameters(p): Parameters<ListPostsParams>,
41        Extension(parts): Extension<http::request::Parts>,
42    ) -> Result<CallToolResult, McpError> {
43        let principal = require_scope(&parts, "list_posts", TokenScope::Write)?;
44        list_owned_posts(principal.user_id, p, false).await
45    }
46
47    #[tool(
48        description = "按 ID 读取当前令牌用户的文章,包含草稿、Markdown 正文、封面、标签、状态和 updated_at。需要 write 权限,不读取回收站文章。"
49    )]
50    async fn get_post_by_id(
51        &self,
52        Parameters(p): Parameters<PostIdParams>,
53        Extension(parts): Extension<http::request::Parts>,
54    ) -> Result<CallToolResult, McpError> {
55        let principal = require_scope(&parts, "get_post_by_id", TokenScope::Write)?;
56        let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
57        let row = client.query_opt(
58            "SELECT p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html,
59                p.toc_html, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image,
60                p.word_count, p.reading_time,
61                COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
62                FROM posts p LEFT JOIN post_tags pt ON p.id = pt.post_id
63                LEFT JOIN tags t ON pt.tag_id = t.id
64                WHERE p.id = $1 AND p.author_id = $2 AND p.deleted_at IS NULL GROUP BY p.id",
65            &[&p.post_id, &principal.user_id],
66        ).await.map_err(|e| internal(e, "read post"))?;
67        let row = row.ok_or_else(|| McpError::invalid_request("文章不存在或无权限", None))?;
68        let post = crate::api::posts::helpers::row_to_post_full(&row)
69            .await
70            .map_err(|e| internal(format!("{e:?}"), "read post"))?;
71        ok_json(post)
72    }
73
74    #[tool(
75        description = "分页查询当前令牌用户的回收站文章,按删除时间倒序。支持 status、query、page、per_page,与 list_posts 相同。需要 write 权限。"
76    )]
77    async fn list_trashed_posts(
78        &self,
79        Parameters(p): Parameters<ListPostsParams>,
80        Extension(parts): Extension<http::request::Parts>,
81    ) -> Result<CallToolResult, McpError> {
82        let principal = require_scope(&parts, "list_trashed_posts", TokenScope::Write)?;
83        list_owned_posts(principal.user_id, p, true).await
84    }
85
86    #[tool(
87        description = "恢复当前令牌用户的回收站文章,保留原发布状态。slug 被占用时自动追加数字后缀,返回恢复后的 slug。需要 write 权限。"
88    )]
89    async fn restore_post(
90        &self,
91        Parameters(p): Parameters<PostIdParams>,
92        Extension(parts): Extension<http::request::Parts>,
93    ) -> Result<CallToolResult, McpError> {
94        let principal = require_scope(&parts, "restore_post", TokenScope::Write)?;
95        let result = crate::api::posts::restore_post_impl(p.post_id, Some(principal.user_id))
96            .await
97            .map_err(|e| internal(e, "restore post"))?;
98        ok_json(result)
99    }
100
101    #[tool(
102        description = "创建一篇新文章。渲染 Markdown 为 HTML,同步标签与素材引用。返回 post_id/slug。"
103    )]
104    async fn create_post(
105        &self,
106        Parameters(p): Parameters<CreatePostParams>,
107        Extension(parts): Extension<http::request::Parts>,
108    ) -> Result<CallToolResult, McpError> {
109        let principal = require_scope(&parts, "create_post", TokenScope::Write)?;
110
111        let published_at = match p
112            .published_at
113            .as_deref()
114            .map(str::trim)
115            .filter(|s| !s.is_empty())
116        {
117            Some(date) => Some(parse_date_opt(date).ok_or_else(|| {
118                McpError::invalid_request(
119                    "published_at 格式无效,支持 YYYY-MM-DD 或 ISO 8601",
120                    None,
121                )
122            })?),
123            None => None,
124        };
125        let draft = PostDraft {
126            title: p.title,
127            slug: p.slug,
128            summary: p.summary,
129            content_md: p.content_md,
130            status: p.status,
131            tags: p.tags,
132            cover_image: p.cover_image,
133        };
134        let result = service::create(principal.user_id, draft, published_at)
135            .await
136            .map_err(write_error)?;
137        ok_json(PostResult {
138            success: true,
139            message: "创建成功".into(),
140            post_id: Some(result.post_id),
141            slug: Some(result.slug),
142        })
143    }
144
145    /// 更新指定文章(PATCH 语义:仅更新提供的字段)。要求 write 作用域。
146    /// 仅文章原作者可更新。
147    #[tool(
148        description = "部分更新一篇已有文章(PATCH 语义)。仅更新提供的字段:未提供 content_md 时跳过重新渲染;未提供 summary 时随 content_md 联动(自动提取或保留旧值)。仅文章原作者可更新。可传 expected_updated_at(RFC 3339)检测冲突;成功返回新 updated_at。"
149    )]
150    async fn update_post(
151        &self,
152        Parameters(p): Parameters<UpdatePostParams>,
153        Extension(parts): Extension<http::request::Parts>,
154    ) -> Result<CallToolResult, McpError> {
155        let principal = require_scope(&parts, "update_post", TokenScope::Write)?;
156
157        let expected_updated_at = parse_expected_updated_at(p.expected_updated_at.as_deref())?;
158        let published_at = match p.published_at.as_deref() {
159            Some(date) if date.trim().is_empty() => FieldChange::Clear,
160            Some(date) => FieldChange::Set(parse_date_opt(date).ok_or_else(|| {
161                McpError::invalid_request(
162                    "published_at 格式无效,支持 YYYY-MM-DD 或 ISO 8601",
163                    None,
164                )
165            })?),
166            None => FieldChange::Keep,
167        };
168        let cover_image = match p.cover_image {
169            Some(cover) if cover.trim().is_empty() => FieldChange::Clear,
170            Some(cover) => FieldChange::Set(cover),
171            None => FieldChange::Keep,
172        };
173        let patch = PostPatch {
174            title: p.title,
175            slug: p.slug,
176            summary: p.summary,
177            content_md: p.content_md,
178            status: p.status,
179            tags: p.tags,
180            cover_image,
181            published_at,
182            expected_updated_at,
183        };
184        let result = service::update(principal.user_id, p.post_id, UpdatePost::Patch(patch))
185            .await
186            .map_err(write_error)?;
187        ok_json(
188            serde_json::json!({"success": true, "message": "更新成功", "post_id": result.post_id,
189            "slug": result.slug, "updated_at": result.updated_at}),
190        )
191    }
192
193    /// 发布指定文章(设置 status=published 与 published_at)。要求 write 作用域。
194    #[tool(
195        description = "发布一篇草稿文章。设置 status=published,若首次发布则填充 published_at。"
196    )]
197    async fn publish_post(
198        &self,
199        Parameters(p): Parameters<PostIdParams>,
200        Extension(parts): Extension<http::request::Parts>,
201    ) -> Result<CallToolResult, McpError> {
202        let principal = require_scope(&parts, "publish_post", TokenScope::Write)?;
203        let result = service::act(p.post_id, Some(principal.user_id), PostAction::Publish)
204            .await
205            .map_err(write_error)?;
206        ok_json(PostResult {
207            success: true,
208            message: "发布成功".into(),
209            post_id: Some(result.post_id),
210            slug: Some(result.slug),
211        })
212    }
213
214    /// 将指定文章移入回收站(软删除:设置 deleted_at)。要求 write 作用域。
215    #[tool(description = "将文章移入回收站(软删除)。可通过恢复操作还原。")]
216    async fn trash_post(
217        &self,
218        Parameters(p): Parameters<PostIdParams>,
219        Extension(parts): Extension<http::request::Parts>,
220    ) -> Result<CallToolResult, McpError> {
221        let principal = require_scope(&parts, "trash_post", TokenScope::Write)?;
222        let result = service::act(p.post_id, Some(principal.user_id), PostAction::Trash)
223            .await
224            .map_err(write_error)?;
225        ok_json(PostResult {
226            success: true,
227            message: "已移入回收站".into(),
228            post_id: Some(result.post_id),
229            slug: Some(result.slug),
230        })
231    }
232
233    /// 彻底删除指定文章(物理删除,不可恢复)。要求 write 作用域。
234    #[tool(
235        description = "彻底删除文章(物理删除,不可恢复)。post_tags 关联因外键 CASCADE 自动清理。"
236    )]
237    async fn delete_post(
238        &self,
239        Parameters(p): Parameters<PostIdParams>,
240        Extension(parts): Extension<http::request::Parts>,
241    ) -> Result<CallToolResult, McpError> {
242        let principal = require_scope(&parts, "delete_post", TokenScope::Write)?;
243        let result = service::act(p.post_id, Some(principal.user_id), PostAction::Delete)
244            .await
245            .map_err(write_error)?;
246        ok_json(PostResult {
247            success: true,
248            message: "已彻底删除".into(),
249            post_id: Some(result.post_id),
250            slug: Some(result.slug),
251        })
252    }
253}
254
255/// 把共同业务错误转换为 MCP 错误,数据库细节只写日志。
256fn write_error(error: WriteError) -> McpError {
257    match error {
258        WriteError::Invalid(message) => McpError::invalid_request(message, None),
259        WriteError::Conflict(current_updated_at) => McpError::invalid_request(
260            "conflict: 文章已被修改,请重新读取后合并更改",
261            Some(serde_json::json!({"code":"conflict", "current_updated_at":current_updated_at})),
262        ),
263        WriteError::Failed(error) => internal(format!("{error:?}"), "post write"),
264    }
265}
266
267// ---------------------------------------------------------------------------
268// 参数与输出结构
269// ---------------------------------------------------------------------------
270
271#[derive(Debug, Deserialize, schemars::JsonSchema)]
272pub struct CreatePostParams {
273    /// 文章标题(必填,非空)。
274    pub title: String,
275    /// Markdown 正文(必填,非空)。
276    pub content_md: String,
277    /// 摘要;未提供时自动从正文提取。
278    #[serde(default)]
279    pub summary: Option<String>,
280    /// URL slug;未提供时从标题自动生成。
281    #[serde(default)]
282    pub slug: Option<String>,
283    /// 标签列表。
284    #[serde(default)]
285    pub tags: Vec<String>,
286    /// 状态:`draft`(默认)或 `published`。
287    #[serde(default = "default_status")]
288    pub status: String,
289    /// 封面图 URL。
290    #[serde(default)]
291    pub cover_image: Option<String>,
292    /// 发布时间(ISO 8601 或 YYYY-MM-DD);已发布文章写入 published_at,同时用作 created_at。
293    /// 草稿只设置 created_at;未提供时使用当前时间。
294    #[serde(default)]
295    pub published_at: Option<String>,
296}
297
298#[derive(Debug, Deserialize, schemars::JsonSchema)]
299pub struct UpdatePostParams {
300    /// 上次读取的 updated_at(RFC 3339)。提供时原子校验版本,不匹配返回 conflict;省略保留原有行为。
301    #[serde(default)]
302    pub expected_updated_at: Option<String>,
303    /// 要更新的文章 id。
304    pub post_id: i32,
305    /// 新标题。未提供则不修改(且不联动 slug)。
306    #[serde(default)]
307    pub title: Option<String>,
308    /// 新 Markdown 正文。未提供则不重新渲染(content_html/toc/度量保持不变)。
309    #[serde(default)]
310    pub content_md: Option<String>,
311    /// 新摘要。content_md 变化且未提供时自动从正文提取;content_md 未变且未提供则保留旧值。
312    #[serde(default)]
313    pub summary: Option<String>,
314    /// 新 slug。未提供则不修改;提供则校验格式并自动去重。
315    #[serde(default)]
316    pub slug: Option<String>,
317    /// 新标签列表。未提供则不修改;提供空列表则清空标签(完全替换旧标签)。
318    #[serde(default)]
319    pub tags: Option<Vec<String>>,
320    /// 新状态:`draft` / `published`。未提供则不修改。
321    #[serde(default)]
322    pub status: Option<String>,
323    /// 新封面图 URL。未提供则不修改;空字符串清空封面。
324    #[serde(default)]
325    pub cover_image: Option<String>,
326    /// 发布时间(ISO 8601 或 YYYY-MM-DD);未提供则不修改。
327    #[serde(default)]
328    pub published_at: Option<String>,
329}
330
331#[derive(Debug, Deserialize, schemars::JsonSchema)]
332pub struct PostIdParams {
333    /// 文章 id。
334    pub post_id: i32,
335}
336
337#[derive(Debug, serde::Serialize)]
338struct PostResult {
339    success: bool,
340    message: String,
341    post_id: Option<i32>,
342    slug: Option<String>,
343}
344
345fn default_status() -> String {
346    "draft".to_string()
347}
348
349fn parse_expected_updated_at(
350    value: Option<&str>,
351) -> Result<Option<chrono::DateTime<chrono::Utc>>, McpError> {
352    value
353        .map(|s| {
354            chrono::DateTime::parse_from_rfc3339(s)
355                .map(|date| date.with_timezone(&chrono::Utc))
356                .map_err(|_| McpError::invalid_params("expected_updated_at must be RFC 3339", None))
357        })
358        .transpose()
359}
360
361fn parse_date_opt(s: &str) -> Option<chrono::DateTime<chrono::Utc>> {
362    let s = s.trim();
363    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
364        return Some(dt.with_timezone(&chrono::Utc));
365    }
366    if let Ok(nd) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
367        if let Some(ndt) = nd.and_hms_opt(0, 0, 0) {
368            return Some(chrono::DateTime::from_naive_utc_and_offset(
369                ndt,
370                chrono::Utc,
371            ));
372        }
373    }
374    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
375        return Some(chrono::DateTime::from_naive_utc_and_offset(
376            ndt,
377            chrono::Utc,
378        ));
379    }
380    None
381}
382
383#[derive(Debug, Deserialize, schemars::JsonSchema, Default)]
384pub struct ListPostsParams {
385    pub status: Option<String>,
386    pub query: Option<String>,
387    pub page: Option<i32>,
388    pub per_page: Option<i32>,
389}
390
391impl ListPostsParams {
392    fn normalize(&self) -> Result<(i32, i32, String), McpError> {
393        if self
394            .status
395            .as_deref()
396            .is_some_and(|s| !matches!(s, "draft" | "published"))
397        {
398            return Err(McpError::invalid_params(
399                "status must be draft or published",
400                None,
401            ));
402        }
403        let query: String = self
404            .query
405            .as_deref()
406            .unwrap_or("")
407            .trim()
408            .chars()
409            .take(200)
410            .collect();
411        Ok((
412            self.page.unwrap_or(1).max(1),
413            self.per_page.unwrap_or(20).clamp(1, 50),
414            format!("%{}%", crate::utils::server::escape_like_pattern(&query)),
415        ))
416    }
417}
418
419async fn list_owned_posts(
420    user_id: i32,
421    p: ListPostsParams,
422    trashed: bool,
423) -> Result<CallToolResult, McpError> {
424    let (page, per_page, pattern) = p.normalize()?;
425    let client = get_conn().await.map_err(|e| internal(e, "db connection"))?;
426    let offset = (i64::from(page) - 1) * i64::from(per_page);
427    let limit = i64::from(per_page);
428    let deleted = if trashed { "IS NOT NULL" } else { "IS NULL" };
429    let order = if trashed {
430        "p.deleted_at"
431    } else {
432        "p.created_at"
433    };
434    let condition = format!("p.author_id = $1 AND p.deleted_at {deleted} AND ($2::text IS NULL OR p.status = $2) AND p.title ILIKE $3");
435    let total: i64 = client
436        .query_one(
437            &format!("SELECT COUNT(*) FROM posts p WHERE {condition}"),
438            &[&user_id, &p.status, &pattern],
439        )
440        .await
441        .map_err(|e| internal(e, "count posts"))?
442        .get(0);
443    let rows = client
444        .query(
445            &format!(
446                "SELECT p.id, p.author_id, p.title, p.slug, p.summary, p.status,
447                p.published_at, p.created_at, p.updated_at, p.deleted_at, p.cover_image,
448                p.word_count, p.reading_time,
449                COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{{}}') as tags
450                FROM posts p LEFT JOIN post_tags pt ON p.id = pt.post_id
451                LEFT JOIN tags t ON pt.tag_id = t.id WHERE {condition}
452                GROUP BY p.id ORDER BY {order} DESC, p.id DESC LIMIT $4 OFFSET $5"
453            ),
454            &[&user_id, &p.status, &pattern, &limit, &offset],
455        )
456        .await
457        .map_err(|e| internal(e, "list posts"))?;
458    let posts: Vec<_> = rows
459        .iter()
460        .map(crate::api::posts::helpers::row_to_post_list_item)
461        .collect();
462    ok_json(serde_json::json!({"posts": posts, "total": total, "page": page, "per_page": per_page}))
463}
464
465#[cfg(test)]
466mod tests;