1use rmcp::handler::server::tool::Extension;
15use rmcp::handler::server::wrapper::Parameters;
16use rmcp::model::{CallToolResult, ContentBlock, TextContent};
17use rmcp::{schemars, tool, tool_router, ErrorData as McpError};
18use serde::Deserialize;
19
20use crate::db::pool::get_conn;
21use crate::mcp::auth::McpPrincipal;
22use crate::models::mcp_token::TokenScope;
23
24#[tool_router(router = read_router, vis = "pub")]
25impl crate::mcp::server::YggMcpServer {
26 #[tool(
28 description = "全文搜索已发布文章,作为知识库。返回标题/slug/摘要/标签与匹配 URL。要求 read 作用域。"
29 )]
30 async fn search_posts(
31 &self,
32 Parameters(SearchPostsParams { query, limit }): Parameters<SearchPostsParams>,
33 Extension(parts): Extension<http::request::Parts>,
34 ) -> Result<CallToolResult, McpError> {
35 let principal = require_read(&parts, "search_posts")?;
36
37 let hits = search_published(&query, limit.unwrap_or(50))
38 .await
39 .map_err(|e| mcp_internal("search_posts", &principal, &e))?;
40
41 let text = serde_json::to_string_pretty(&hits)
42 .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?;
43 Ok(CallToolResult::success(vec![ContentBlock::Text(
44 TextContent::new(text),
45 )]))
46 }
47
48 #[tool(
50 description = "按 slug 读取单篇已发布文章全文(标题/摘要/Markdown 正文/标签/时间)。草稿不可见。要求 read 作用域。"
51 )]
52 async fn get_post(
53 &self,
54 Parameters(GetPostParams { slug }): Parameters<GetPostParams>,
55 Extension(parts): Extension<http::request::Parts>,
56 ) -> Result<CallToolResult, McpError> {
57 let principal = require_read(&parts, "get_post")?;
58
59 let post = get_published_by_slug(&slug)
60 .await
61 .map_err(|e| mcp_internal("get_post", &principal, &e))?;
62
63 let text = serde_json::to_string_pretty(&post)
64 .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?;
65 Ok(CallToolResult::success(vec![ContentBlock::Text(
66 TextContent::new(text),
67 )]))
68 }
69
70 #[tool(description = "列出全部标签及其关联的已发布文章数量。要求 read 作用域。")]
72 async fn list_tags(
73 &self,
74 Extension(parts): Extension<http::request::Parts>,
75 ) -> Result<CallToolResult, McpError> {
76 let principal = require_read(&parts, "list_tags")?;
77
78 let tags = list_all_tags()
79 .await
80 .map_err(|e| mcp_internal("list_tags", &principal, &e))?;
81
82 let text = serde_json::to_string_pretty(&tags)
83 .map_err(|e| McpError::internal_error(format!("encode failed: {e}"), None))?;
84 Ok(CallToolResult::success(vec![ContentBlock::Text(
85 TextContent::new(text),
86 )]))
87 }
88}
89
90fn require_read(parts: &http::request::Parts, tool: &str) -> Result<McpPrincipal, McpError> {
94 let principal = parts
95 .extensions
96 .get::<McpPrincipal>()
97 .ok_or_else(|| McpError::invalid_request("missing MCP principal", None))?;
98 if !principal.scope.grants(TokenScope::Read) {
99 return Err(McpError::invalid_request(
100 format!("insufficient_scope: {tool} requires read"),
101 None,
102 ));
103 }
104 Ok(principal.clone())
105}
106
107fn mcp_internal(tool: &str, principal: &McpPrincipal, e: &str) -> McpError {
109 tracing::warn!(
110 tool, user_id = principal.user_id, error = %e, "MCP read tool failed"
111 );
112 McpError::internal_error(format!("{tool} failed: {e}"), None)
113}
114
115#[derive(Debug, Deserialize, schemars::JsonSchema)]
119pub struct SearchPostsParams {
120 pub query: String,
122 #[serde(default)]
124 pub limit: Option<u32>,
125}
126
127#[derive(Debug, Deserialize, schemars::JsonSchema)]
129pub struct GetPostParams {
130 pub slug: String,
132}
133
134#[derive(Debug, serde::Serialize)]
138pub struct SearchHit {
139 pub id: i32,
140 pub title: String,
141 pub slug: String,
142 pub summary: Option<String>,
143 pub tags: Vec<String>,
144 pub url: String,
146}
147
148#[derive(Debug, serde::Serialize)]
150pub struct PostResource {
151 pub id: i32,
152 pub title: String,
153 pub slug: String,
154 pub summary: Option<String>,
155 pub content_md: String,
156 pub tags: Vec<String>,
157 pub created_at: chrono::DateTime<chrono::Utc>,
158 pub published_at: Option<chrono::DateTime<chrono::Utc>>,
159 pub url: String,
161}
162
163#[derive(Debug, serde::Serialize)]
165pub struct TagCount {
166 pub id: i32,
167 pub name: String,
168 pub post_count: i64,
169}
170
171fn clean_tags(row: &tokio_postgres::Row) -> Vec<String> {
175 let mut tags: Vec<String> = row.try_get::<_, Vec<String>>("tags").unwrap_or_default();
176 tags.retain(|t| !t.is_empty());
177 tags
178}
179
180fn post_url(slug: &str) -> String {
182 format!("/post/{slug}")
183}
184
185pub async fn search_published(query: &str, limit: u32) -> Result<Vec<SearchHit>, String> {
190 let q = query.trim();
191 if q.is_empty() || q.chars().count() > 200 {
192 return Ok(Vec::new());
193 }
194 let limit = limit.clamp(1, 50) as i64;
196
197 let client = get_conn().await.map_err(|e| format!("db conn: {e}"))?;
198
199 let escaped = crate::utils::server::escape_like_pattern(q);
200
201 let rows = client
202 .query(
203 "SELECT p.id, p.title, p.slug, p.summary,
204 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
205 FROM posts p
206 LEFT JOIN post_tags pt ON p.id = pt.post_id
207 LEFT JOIN tags t ON pt.tag_id = t.id
208 WHERE p.status = 'published' AND p.deleted_at IS NULL
209 AND p.search_text ILIKE '%' || $1 || '%' ESCAPE '\\'
210 GROUP BY p.id, p.search_text
211 ORDER BY word_similarity(p.search_text, $2) DESC, p.published_at DESC
212 LIMIT $3",
213 &[&escaped, &q, &limit],
214 )
215 .await
216 .map_err(|e| format!("query: {e}"))?;
217
218 let hits = rows
219 .iter()
220 .map(|r| {
221 let slug: String = r.get("slug");
222 SearchHit {
223 id: r.get("id"),
224 title: r.get("title"),
225 slug: slug.clone(),
226 summary: r.get("summary"),
227 tags: clean_tags(r),
228 url: post_url(&slug),
229 }
230 })
231 .collect();
232 Ok(hits)
233}
234
235pub async fn get_published_by_slug(slug: &str) -> Result<Option<PostResource>, String> {
240 let client = get_conn().await.map_err(|e| format!("db conn: {e}"))?;
241
242 let row = client
243 .query_opt(
244 "SELECT p.id, p.title, p.slug, p.summary, p.content_md,
245 p.created_at, p.published_at,
246 COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags
247 FROM posts p
248 LEFT JOIN post_tags pt ON p.id = pt.post_id
249 LEFT JOIN tags t ON pt.tag_id = t.id
250 WHERE p.slug = $1 AND p.status = 'published' AND p.deleted_at IS NULL
251 GROUP BY p.id",
252 &[&slug],
253 )
254 .await
255 .map_err(|e| format!("query: {e}"))?;
256
257 Ok(row.map(|r| {
258 let s: String = r.get("slug");
259 PostResource {
260 id: r.get("id"),
261 title: r.get("title"),
262 slug: s.clone(),
263 summary: r.get("summary"),
264 content_md: r.get("content_md"),
265 tags: clean_tags(&r),
266 created_at: r.get("created_at"),
267 published_at: r.get("published_at"),
268 url: post_url(&s),
269 }
270 }))
271}
272
273pub async fn list_all_tags() -> Result<Vec<TagCount>, String> {
275 let client = get_conn().await.map_err(|e| format!("db conn: {e}"))?;
276
277 let rows = client
278 .query(
279 "SELECT t.id, t.name, COUNT(p.id) as post_count
280 FROM tags t
281 LEFT JOIN post_tags pt ON t.id = pt.tag_id
282 LEFT JOIN posts p ON pt.post_id = p.id AND p.deleted_at IS NULL AND p.status = 'published'
283 GROUP BY t.id, t.name
284 ORDER BY t.name",
285 &[],
286 )
287 .await
288 .map_err(|e| format!("query: {e}"))?;
289
290 Ok(rows
291 .iter()
292 .map(|r| TagCount {
293 id: r.get("id"),
294 name: r.get("name"),
295 post_count: r.get("post_count"),
296 })
297 .collect())
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn post_url_format() {
306 assert_eq!(post_url("hello"), "/post/hello");
307 assert_eq!(post_url("a-b_c"), "/post/a-b_c");
308 }
309
310 #[test]
311 fn search_published_empty_query_returns_empty() {
312 let rt = tokio::runtime::Runtime::new().expect("rt");
315 let empty = rt.block_on(search_published("", 10)).expect("empty ok");
316 assert!(empty.is_empty());
317 let spaces = rt.block_on(search_published(" ", 10)).expect("spaces ok");
318 assert!(spaces.is_empty());
319 let long: String = "x".repeat(201);
320 let long_hit = rt.block_on(search_published(&long, 10)).expect("long ok");
321 assert!(long_hit.is_empty());
322 }
323
324 #[test]
325 fn limit_is_clamped_in_signature_not_query() {
326 assert_eq!(0u32.clamp(1, 50), 1);
328 assert_eq!(51u32.clamp(1, 50), 50);
329 assert_eq!(10u32.clamp(1, 50), 10);
330 }
331}