1#![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#[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 #[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 #[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 #[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 #[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
255fn 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#[derive(Debug, Deserialize, schemars::JsonSchema)]
272pub struct CreatePostParams {
273 pub title: String,
275 pub content_md: String,
277 #[serde(default)]
279 pub summary: Option<String>,
280 #[serde(default)]
282 pub slug: Option<String>,
283 #[serde(default)]
285 pub tags: Vec<String>,
286 #[serde(default = "default_status")]
288 pub status: String,
289 #[serde(default)]
291 pub cover_image: Option<String>,
292 #[serde(default)]
295 pub published_at: Option<String>,
296}
297
298#[derive(Debug, Deserialize, schemars::JsonSchema)]
299pub struct UpdatePostParams {
300 #[serde(default)]
302 pub expected_updated_at: Option<String>,
303 pub post_id: i32,
305 #[serde(default)]
307 pub title: Option<String>,
308 #[serde(default)]
310 pub content_md: Option<String>,
311 #[serde(default)]
313 pub summary: Option<String>,
314 #[serde(default)]
316 pub slug: Option<String>,
317 #[serde(default)]
319 pub tags: Option<Vec<String>>,
320 #[serde(default)]
322 pub status: Option<String>,
323 #[serde(default)]
325 pub cover_image: Option<String>,
326 #[serde(default)]
328 pub published_at: Option<String>,
329}
330
331#[derive(Debug, Deserialize, schemars::JsonSchema)]
332pub struct PostIdParams {
333 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;