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