Skip to main content

yggdrasil/mcp/tools/
read.rs

1//! MCP 读作用域工具组(知识库)。
2//!
3//! 三个 `read` 作用域工具,均经 `Extension<http::request::Parts>` 读取鉴权中间件
4//! 注入的 `McpPrincipal`,校验 `scope.grants(TokenScope::Read)`:
5//! - `search_posts`:pg_trgm 模糊搜索已发布文章;
6//! - `get_post`:按 slug 取单篇已发布文章全文;
7//! - `list_tags`:标签 + 关联已发布文章数。
8//!
9//! 本模块只声明工具路由;`server.rs`(Main 装配)把 `read_router()` 合并进
10//! 复合 `ServerHandler`。SQL 与 `src/api/posts/{search,read,tags}.rs` 的
11//! server-fn 一致,但不复用后者(它们走 cookie 鉴权的 FullstackContext),
12//! 这里直接走 `crate::db::pool::get_conn()`。
13
14use 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    /// 全文搜索已发布文章(知识库)。返回标题/slug/摘要/标签。
27    #[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    /// 按 slug 取单篇已发布文章(草稿对 read 不可见)。
49    #[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    /// 列出全部标签 + 各自关联的已发布文章数。
71    #[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
90// ── 鉴权辅助 ───────────────────────────────────────────────────────────────
91
92/// 从 request extensions 取 principal 并校验 read 作用域;不足返回 insufficient_scope。
93fn 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
107/// 把内部错误统一包装成 MCP internal_error(带工具名便于排查)。
108fn 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// ── 参数 DTO ───────────────────────────────────────────────────────────────
116
117/// `search_posts` 入参。
118#[derive(Debug, Deserialize, schemars::JsonSchema)]
119pub struct SearchPostsParams {
120    /// 搜索关键词(SQL 通配符会被转义;空串返回空结果)。
121    pub query: String,
122    /// 最多返回条数(1–50,默认 50)。
123    #[serde(default)]
124    pub limit: Option<u32>,
125}
126
127/// `get_post` 入参。
128#[derive(Debug, Deserialize, schemars::JsonSchema)]
129pub struct GetPostParams {
130    /// 文章 slug。
131    pub slug: String,
132}
133
134// ── 输出 DTO ───────────────────────────────────────────────────────────────
135
136/// `search_posts` 命中项(精简,不含正文)。
137#[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    /// 站内文章 URL(`/post/{slug}`),便于客户端/LLM 引用。
145    pub url: String,
146}
147
148/// `get_post` 返回的单篇已发布文章(含正文)。
149#[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    /// 站内文章 URL。
160    pub url: String,
161}
162
163/// `list_tags` 返回的标签项。
164#[derive(Debug, serde::Serialize)]
165pub struct TagCount {
166    pub id: i32,
167    pub name: String,
168    pub post_count: i64,
169}
170
171// ── DB 访问(SQL 镜像 src/api/posts/{search,read,tags}.rs,走连接池) ───────
172
173/// 把行内的标签数组原地清洗(过滤空串),避免二次 Vec 分配。
174fn 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
180/// 站内文章 URL:`/post/{slug}`(MCP 不知晓外部域名,给相对路径;客户端可拼接 APP_BASE_URL)。
181fn post_url(slug: &str) -> String {
182    format!("/post/{slug}")
183}
184
185/// 与 `src/api/posts/search.rs` 一致的 pg_trgm word_similarity 查询。
186///
187/// 仅返回 `status='published' AND deleted_at IS NULL` 的文章。
188/// SQL 通配符 `%`/`_`/`\` 被转义,防止用户输入注入通配符扩大匹配范围(匹配走 search_text 上的 trigram GIN 索引)。
189pub 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    // 钳制 limit 到 [1,50](与 web 端 search 的硬上限一致)。
195    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
235/// 按 slug 取单篇已发布文章(草稿/已删除返回 None)。
236///
237/// SQL 镜像 `src/api/posts/read.rs::get_post_by_slug` 的 published 过滤,
238/// 但省略上下篇导航(MCP 知识库场景不需要),返回 `content_md` 原文。
239pub 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
273/// 全部标签 + 各自关联的已发布文章数(镜像 `src/api/posts/tags.rs::list_tags`)。
274pub 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        // 纯逻辑:空/超长查询直接返回空,不发 DB。
313        // 用一个 fake runtime 验证 early-return(不连库)。
314        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        // limit 钳制发生在 DB 调用前;这里只验证 clamp 算术(不触发 DB)。
327        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}