Skip to main content

yggdrasil/mcp/tools/
media.rs

1//! MCP 写作用域工具:媒体上传(URL 抓取)。
2//!
3//! Option B 的第三通道:LLM 工具只收 `url: String`(JSON-RPC 纯文本),服务端
4//! 按 SSRF 防护抓取二进制,再走 [`crate::api::upload::process_image_upload`] 共享
5//! 入库流水线。**二进制从不进 JSON-RPC**——彻底绕开 rmcp 4MiB 请求体上限与
6//! base64 的 33% 膨胀 + 上下文窗口烧灼。
7//!
8//! 另有第二通道 `POST /api/mcp/upload`(bearer multipart)供 host/shell 直接 POST
9//! 二进制(如 Claude Code 的 Bash+curl);两条通道共用同一入库流水线。
10//!
11//! SSRF 防护(多层纵深)见 [`crate::api::url_fetch`]:强制 https、解析即锁 IP
12//! 杜绝 DNS rebinding、禁重定向、流式体积上限、超时。
13//!
14//! 本模块仅 `feature = "server"` 编译。
15
16#![cfg(feature = "server")]
17
18use rmcp::handler::server::tool::Extension;
19use rmcp::handler::server::wrapper::Parameters;
20use rmcp::model::CallToolResult;
21use rmcp::{schemars, tool, tool_router, ErrorData as McpError};
22use serde::Deserialize;
23
24use super::common::{internal, ok_json, require_scope};
25use crate::models::mcp_token::TokenScope;
26
27#[tool_router(router = media_router, vis = "pub")]
28impl crate::mcp::server::YggMcpServer {
29    /// 从一个图片 URL 抓取并入库(服务端转 WebP 若更小),返回可直接嵌入
30    /// Markdown 正文的 `/uploads/...` URL。要求 write 作用域。
31    ///
32    /// 仅接受 `https://` URL;服务端做 SSRF 防护(私网/回环/保留段拒绝、
33    /// DNS 锁定防 rebinding、禁重定向、体积上限)。二进制不经 JSON-RPC。
34    #[tool(
35        description = "从图片 URL 抓取并入库(服务端转 WebP 若更小),返回 /uploads/... URL(可直接用于 Markdown 正文 img)。仅接受 https:// URL,支持 JPEG/PNG/GIF/WebP。二进制不经 JSON-RPC。"
36    )]
37    async fn upload_media(
38        &self,
39        Parameters(p): Parameters<UploadMediaParams>,
40        Extension(parts): Extension<http::request::Parts>,
41    ) -> Result<CallToolResult, McpError> {
42        let _principal = require_scope(&parts, "upload_media", TokenScope::Write)?;
43
44        // SSRF 防护抓取 + 共享入库流水线。
45        let outcome = crate::api::url_fetch::fetch_and_ingest(&p.url)
46            .await
47            .map_err(|e| match e {
48                crate::api::url_fetch::FetchError::Invalid(msg)
49                | crate::api::url_fetch::FetchError::BadStatus(msg) => {
50                    McpError::invalid_request(msg, None)
51                }
52                crate::api::url_fetch::FetchError::TooLarge => McpError::invalid_request(
53                    format!(
54                        "文件超过大小限制({} bytes)",
55                        crate::utils::server::MAX_FILE_SIZE
56                    ),
57                    None,
58                ),
59                crate::api::url_fetch::FetchError::Fetch(ctx) => internal(ctx, "url fetch"),
60            })?;
61
62        tracing::info!(
63            "MCP media uploaded via URL: {} ({}x{}, reused={})",
64            outcome.url,
65            outcome.width,
66            outcome.height,
67            outcome.reused
68        );
69
70        ok_json(UploadResult {
71            success: true,
72            url: outcome.url,
73            reused: outcome.reused,
74            width: outcome.width,
75            height: outcome.height,
76            mime: outcome.mime,
77        })
78    }
79}
80
81// ---------------------------------------------------------------------------
82// 参数与输出结构
83// ---------------------------------------------------------------------------
84
85#[derive(Debug, Deserialize, schemars::JsonSchema)]
86pub struct UploadMediaParams {
87    /// 图片的 https URL(服务端抓取,二进制不经 JSON-RPC)。
88    pub url: String,
89    /// 替代文本(alt),目前未持久化,保留供未来扩展。
90    #[serde(default)]
91    #[allow(dead_code)] // 面向未来:客户端可传入,assets 表未存 alt 列
92    pub alt: Option<String>,
93}
94
95#[derive(Debug, serde::Serialize)]
96struct UploadResult {
97    success: bool,
98    url: String,
99    reused: bool,
100    width: u32,
101    height: u32,
102    mime: String,
103}