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 鉴权的请求上下文),
12//! 这里直接走 `crate::db::pool::get_conn()`。
13
14use 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    /// 全文搜索已发布文章(知识库)。返回标题/slug/摘要/标签。
28    #[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    /// 按 slug 取单篇已发布文章(草稿对 read 不可见)。
45    #[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    /// 列出全部标签 + 各自关联的已发布文章数。
62    #[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
76// ── 鉴权辅助 ───────────────────────────────────────────────────────────────
77
78/// 从 request extensions 取 principal 并校验 read 作用域;不足返回 insufficient_scope。
79fn 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// ── 参数 DTO ───────────────────────────────────────────────────────────────
94
95/// `search_posts` 入参。
96#[derive(Debug, Deserialize, schemars::JsonSchema)]
97pub struct SearchPostsParams {
98    /// 搜索关键词(SQL 通配符会被转义;空串返回空结果)。
99    pub query: String,
100    /// 最多返回条数(1–50,默认 50)。
101    #[serde(default)]
102    pub limit: Option<u32>,
103}
104
105/// `get_post` 入参。
106#[derive(Debug, Deserialize, schemars::JsonSchema)]
107pub struct GetPostParams {
108    /// 文章 slug。
109    pub slug: String,
110}
111
112// ── 输出 DTO ───────────────────────────────────────────────────────────────
113
114/// `search_posts` 命中项(精简,不含正文)。
115#[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    /// 站内文章 URL(`/post/{slug}`),便于客户端/LLM 引用。
123    pub url: String,
124}
125
126/// `get_post` 返回的单篇已发布文章(含正文)。
127#[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    /// 站内文章 URL。
138    pub url: String,
139}
140
141/// `list_tags` 返回的标签项。
142#[derive(Debug, serde::Serialize)]
143pub struct TagCount {
144    pub id: i32,
145    pub name: String,
146    pub post_count: i64,
147}
148
149// ── DB 访问(SQL 镜像 src/api/posts/{search,read,tags}.rs,走连接池) ───────
150
151/// 把行内的标签数组原地清洗(过滤空串),避免二次 Vec 分配。
152fn 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
158/// 站内文章 URL:`/post/{slug}`(MCP 不知晓外部域名,给相对路径;客户端可拼接 APP_BASE_URL)。
159fn post_url(slug: &str) -> String {
160    format!("/post/{slug}")
161}
162
163/// 与 `src/api/posts/search.rs` 一致的 pg_trgm word_similarity 查询。
164///
165/// 仅返回 `status='published' AND deleted_at IS NULL` 的文章。
166/// SQL 通配符 `%`/`_`/`\` 被转义,防止用户输入注入通配符扩大匹配范围(匹配走 search_text 上的 trigram GIN 索引)。
167pub 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    // 钳制 limit 到 [1,50](与 web 端 search 的硬上限一致)。
173    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
213/// 按 slug 取单篇已发布文章(草稿/已删除返回 None)。
214///
215/// SQL 镜像 `src/api/posts/read.rs::get_post_by_slug` 的 published 过滤,
216/// 但省略上下篇导航(MCP 知识库场景不需要),返回 `content_md` 原文。
217pub 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
251/// 全部标签 + 各自关联的已发布文章数(镜像 `src/api/posts/tags.rs::list_tags`)。
252pub 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        // 纯逻辑:空/超长查询直接返回空,不发 DB。
291        // 用一个 fake runtime 验证 early-return(不连库)。
292        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        // limit 钳制发生在 DB 调用前;这里只验证 clamp 算术(不触发 DB)。
305        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}