diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
index 511e48f9..383b8462 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
@@ -606,6 +606,7 @@ public class AgentBindingService implements AgentBindingResolver {
"search",
"browser_use",
"read_file",
+ "send_file",
"write_file",
"edit_file",
"execute_shell_command",
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java
new file mode 100644
index 00000000..b59ada24
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SendFileTool.java
@@ -0,0 +1,163 @@
+package vip.mate.tool.builtin;
+
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.lang.Nullable;
+import org.springframework.stereotype.Component;
+import vip.mate.tool.document.GeneratedFileCache;
+import vip.mate.tool.guard.WorkspacePathGuard;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+
+/**
+ * 内置工具:将服务器上的现有文件发送给用户
+ *
+ * 读取指定路径的文件,放入 {@link GeneratedFileCache} 生成临时下载链接,
+ * 由渠道适配器(飞书/钉钉/Telegram 等)自动检测并作为原生附件发送。
+ *
+ * 与 {@link ReadFileTool} 不同,此工具处理任意文件类型(包括二进制文件),
+ * 目标是将文件作为附件发送而非读取其文本内容。
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Component
+@lombok.RequiredArgsConstructor
+public class SendFileTool {
+
+ private final GeneratedFileCache cache;
+ private final vip.mate.i18n.I18nService i18n;
+
+ private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
+
+ private static final Map EXTENSION_MIME = Map.ofEntries(
+ Map.entry(".pdf", "application/pdf"),
+ Map.entry(".doc", "application/msword"),
+ Map.entry(".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
+ Map.entry(".xls", "application/vnd.ms-excel"),
+ Map.entry(".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
+ Map.entry(".ppt", "application/vnd.ms-powerpoint"),
+ Map.entry(".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
+ Map.entry(".txt", "text/plain"),
+ Map.entry(".csv", "text/csv"),
+ Map.entry(".json", "application/json"),
+ Map.entry(".xml", "application/xml"),
+ Map.entry(".html", "text/html"),
+ Map.entry(".htm", "text/html"),
+ Map.entry(".md", "text/markdown"),
+ Map.entry(".png", "image/png"),
+ Map.entry(".jpg", "image/jpeg"),
+ Map.entry(".jpeg", "image/jpeg"),
+ Map.entry(".gif", "image/gif"),
+ Map.entry(".svg", "image/svg+xml"),
+ Map.entry(".webp", "image/webp"),
+ Map.entry(".mp3", "audio/mpeg"),
+ Map.entry(".wav", "audio/wav"),
+ Map.entry(".ogg", "audio/ogg"),
+ Map.entry(".mp4", "video/mp4"),
+ Map.entry(".avi", "video/x-msvideo"),
+ Map.entry(".mov", "video/quicktime"),
+ Map.entry(".zip", "application/zip"),
+ Map.entry(".tar", "application/x-tar"),
+ Map.entry(".gz", "application/gzip"),
+ Map.entry(".yaml", "text/yaml"),
+ Map.entry(".yml", "text/yaml"),
+ Map.entry(".log", "text/plain")
+ );
+
+ @Tool(description = """
+ Send an existing file from the server to the user as an attachment. \
+ The file is uploaded to the IM channel as a native attachment (not a text link). \
+ Works for any file type: documents (PDF, DOCX, XLSX, PPTX), images, \
+ audio, video, archives, etc. Use this instead of read_file when you \
+ need to send a binary file to the user.""")
+ public String send_file(
+ @ToolParam(description = "Absolute or relative file path on the server") String filePath,
+ @ToolParam(description = "Display name for the file (e.g. 'report.pdf'). Omit to use the original filename", required = false) String fileName,
+ @Nullable ToolContext ctx) {
+
+ try {
+ Path path;
+ try {
+ path = WorkspacePathGuard.validatePath(filePath, ctx);
+ } catch (IllegalArgumentException e) {
+ // Sandbox rejected the literal path. Try chat-upload fallback.
+ Path attachment = ChatUploadResolver.resolve(filePath);
+ if (attachment == null) {
+ return errorResult(filePath, e.getMessage());
+ }
+ path = attachment;
+ }
+
+ if (!Files.exists(path)) {
+ Path attachment = ChatUploadResolver.resolve(filePath);
+ if (attachment == null) {
+ return errorResult(filePath, i18n.msg("tool.read_file.error.not_found", path));
+ }
+ path = attachment;
+ }
+ if (Files.isDirectory(path)) {
+ return errorResult(filePath, i18n.msg("tool.read_file.error.is_directory", path));
+ }
+ if (!Files.isReadable(path)) {
+ return errorResult(filePath, i18n.msg("tool.read_file.error.not_readable", path));
+ }
+
+ long fileSize = Files.size(path);
+ if (fileSize > MAX_FILE_SIZE) {
+ return errorResult(filePath, i18n.msg("tool.send_file.error.too_large",
+ fileSize / 1024 / 1024, MAX_FILE_SIZE / 1024 / 1024));
+ }
+
+ byte[] bytes = Files.readAllBytes(path);
+ String displayName = (fileName != null && !fileName.isBlank()) ? fileName : path.getFileName().toString();
+ String mimeType = resolveMimeType(displayName);
+
+ String url = stash(bytes, displayName, mimeType);
+
+ log.info("[SendFile] Sending {} ({}, {} bytes) via generated file cache",
+ displayName, mimeType, fileSize);
+
+ // Return in the same format as GeneratedFileLink so the channel
+ // adapter's GeneratedFileScrubber detects the URL and sends the
+ // file as a native attachment. The LLM MUST echo the URL in its
+ // reply for the scrubber to pick it up.
+ return displayName + " 已发送:[" + displayName + "](" + url + ")(链接 10 分钟内有效)。\n"
+ + "重要:回答用户时**必须**使用上述相对路径 `" + url + "`,"
+ + "**不要**添加任何 https://、http:// 域名前缀,前端会自动拼接当前主机。";
+
+ } catch (Exception e) {
+ log.error("[SendFile] Failed to send file: {}", e.getMessage(), e);
+ return errorResult(filePath, i18n.msg("tool.send_file.error.failed", e.getMessage()));
+ }
+ }
+
+ private String stash(byte[] bytes, String displayName, String mimeType) {
+ String id = cache.put(bytes, displayName, mimeType);
+ return "/api/v1/files/generated/" + id;
+ }
+
+ private String resolveMimeType(String fileName) {
+ String lower = fileName.toLowerCase();
+ for (Map.Entry entry : EXTENSION_MIME.entrySet()) {
+ if (lower.endsWith(entry.getKey())) {
+ return entry.getValue();
+ }
+ }
+ return "application/octet-stream";
+ }
+
+ private String errorResult(String filePath, String message) {
+ JSONObject result = new JSONObject();
+ result.set("filePath", filePath);
+ result.set("error", true);
+ result.set("message", message);
+ return JSONUtil.toJsonPrettyStr(result);
+ }
+}
diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties
index 3b491805..297cbd6d 100644
--- a/mateclaw-server/src/main/resources/messages.properties
+++ b/mateclaw-server/src/main/resources/messages.properties
@@ -320,3 +320,7 @@ err.wiki.vision.all_failed=\u6240\u6709\u56fe\u7247\u8bc6\u522b provider \u8c03\
# --- Chat: assistant stop / interrupt placeholders ---
chat.stopMarker.userAborted=[\u5df2\u88ab\u7528\u6237\u4e2d\u6b62]
+
+# --- Tool: send_file ---
+tool.send_file.error.too_large=\u6587\u4ef6\u8fc7\u5927\uff1a{0}MB \u8d85\u51fa\u9650\u5236 {1}MB
+tool.send_file.error.failed=\u53d1\u9001\u6587\u4ef6\u5931\u8d25\uff1a{0}
diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties
index 859c5a4c..06d5012f 100644
--- a/mateclaw-server/src/main/resources/messages_en.properties
+++ b/mateclaw-server/src/main/resources/messages_en.properties
@@ -327,3 +327,7 @@ err.wiki.vision.all_failed=All image vision providers failed
# --- Chat: assistant stop / interrupt placeholders ---
chat.stopMarker.userAborted=[Stopped by user]
+
+# --- Tool: send_file ---
+tool.send_file.error.too_large=File too large: {0}MB exceeds limit of {1}MB
+tool.send_file.error.failed=Failed to send file: {0}