Skip to content

Commit db36c7b

Browse files
wesmclaude
andcommitted
Add export_attachment MCP tool to save files to disk
Claude Desktop can only display image attachments inline. For PDFs and other file types, add an export_attachment tool that writes the file to the local filesystem (defaults to ~/Downloads) and returns the path. Handles filename sanitization and collision avoidance (_1, _2, etc). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b23936c commit db36c7b

3 files changed

Lines changed: 218 additions & 6 deletions

File tree

internal/mcp/handlers.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,99 @@ func (h *handlers) getAttachment(ctx context.Context, req mcp.CallToolRequest) (
197197
}, nil
198198
}
199199

200+
func (h *handlers) exportAttachment(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
201+
args := req.GetArguments()
202+
203+
id, err := getIDArg(args, "attachment_id")
204+
if err != nil {
205+
return mcp.NewToolResultError(err.Error()), nil
206+
}
207+
208+
att, err := h.engine.GetAttachment(ctx, id)
209+
if err != nil {
210+
return mcp.NewToolResultError(fmt.Sprintf("get attachment failed: %v", err)), nil
211+
}
212+
if att == nil {
213+
return mcp.NewToolResultError("attachment not found"), nil
214+
}
215+
216+
if h.attachmentsDir == "" {
217+
return mcp.NewToolResultError("attachments directory not configured"), nil
218+
}
219+
220+
data, err := h.readAttachmentFile(att.ContentHash)
221+
if err != nil {
222+
return mcp.NewToolResultError(err.Error()), nil
223+
}
224+
225+
// Determine destination directory.
226+
destDir, _ := args["destination"].(string)
227+
if destDir == "" {
228+
home, err := os.UserHomeDir()
229+
if err != nil {
230+
return mcp.NewToolResultError(fmt.Sprintf("cannot determine home directory: %v", err)), nil
231+
}
232+
destDir = filepath.Join(home, "Downloads")
233+
}
234+
235+
info, err := os.Stat(destDir)
236+
if err != nil || !info.IsDir() {
237+
return mcp.NewToolResultError(fmt.Sprintf("destination directory does not exist: %s", destDir)), nil
238+
}
239+
240+
// Sanitize and deduplicate filename.
241+
filename := sanitizeFilename(filepath.Base(att.Filename))
242+
if filename == "" || filename == "." {
243+
filename = att.ContentHash
244+
}
245+
outPath := uniquePath(filepath.Join(destDir, filename))
246+
247+
if err := os.WriteFile(outPath, data, 0644); err != nil {
248+
return mcp.NewToolResultError(fmt.Sprintf("write failed: %v", err)), nil
249+
}
250+
251+
resp := struct {
252+
Path string `json:"path"`
253+
Filename string `json:"filename"`
254+
Size int64 `json:"size"`
255+
}{
256+
Path: outPath,
257+
Filename: filepath.Base(outPath),
258+
Size: int64(len(data)),
259+
}
260+
return jsonResult(resp)
261+
}
262+
263+
// sanitizeFilename replaces characters that are invalid in filenames.
264+
func sanitizeFilename(s string) string {
265+
var result []rune
266+
for _, r := range s {
267+
switch r {
268+
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', '\n', '\r', '\t':
269+
result = append(result, '_')
270+
default:
271+
result = append(result, r)
272+
}
273+
}
274+
return string(result)
275+
}
276+
277+
// uniquePath returns a path that doesn't collide with existing files by
278+
// appending _1, _2, etc. before the extension.
279+
func uniquePath(p string) string {
280+
if _, err := os.Stat(p); os.IsNotExist(err) {
281+
return p
282+
}
283+
ext := filepath.Ext(p)
284+
base := p[:len(p)-len(ext)]
285+
for i := 1; ; i++ {
286+
candidate := fmt.Sprintf("%s_%d%s", base, i, ext)
287+
if _, err := os.Stat(candidate); os.IsNotExist(err) {
288+
return candidate
289+
}
290+
}
291+
}
292+
200293
func (h *handlers) listMessages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
201294
args := req.GetArguments()
202295

internal/mcp/server.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import (
1111

1212
// Tool name constants.
1313
const (
14-
ToolSearchMessages = "search_messages"
15-
ToolGetMessage = "get_message"
16-
ToolGetAttachment = "get_attachment"
17-
ToolListMessages = "list_messages"
18-
ToolGetStats = "get_stats"
19-
ToolAggregate = "aggregate"
14+
ToolSearchMessages = "search_messages"
15+
ToolGetMessage = "get_message"
16+
ToolGetAttachment = "get_attachment"
17+
ToolExportAttachment = "export_attachment"
18+
ToolListMessages = "list_messages"
19+
ToolGetStats = "get_stats"
20+
ToolAggregate = "aggregate"
2021
)
2122

2223
// Common argument helpers for recurring tool option definitions.
@@ -59,6 +60,7 @@ func Serve(ctx context.Context, engine query.Engine, attachmentsDir string) erro
5960
s.AddTool(searchMessagesTool(), h.searchMessages)
6061
s.AddTool(getMessageTool(), h.getMessage)
6162
s.AddTool(getAttachmentTool(), h.getAttachment)
63+
s.AddTool(exportAttachmentTool(), h.exportAttachment)
6264
s.AddTool(listMessagesTool(), h.listMessages)
6365
s.AddTool(getStatsTool(), h.getStats)
6466
s.AddTool(aggregateTool(), h.aggregate)
@@ -102,6 +104,19 @@ func getAttachmentTool() mcp.Tool {
102104
)
103105
}
104106

107+
func exportAttachmentTool() mcp.Tool {
108+
return mcp.NewTool(ToolExportAttachment,
109+
mcp.WithDescription("Save an attachment to the local filesystem. Use this for file types that cannot be displayed inline (e.g. PDFs, documents). Returns the saved file path."),
110+
mcp.WithNumber("attachment_id",
111+
mcp.Required(),
112+
mcp.Description("Attachment ID (from get_message response)"),
113+
),
114+
mcp.WithString("destination",
115+
mcp.Description("Directory to save the file to (default: ~/Downloads)"),
116+
),
117+
)
118+
}
119+
105120
func listMessagesTool() mcp.Tool {
106121
return mcp.NewTool(ToolListMessages,
107122
mcp.WithDescription("List messages with optional filters. Returns message summaries sorted by date."),

internal/mcp/server_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,110 @@ func TestGetAttachment(t *testing.T) {
494494
})
495495
}
496496

497+
type exportResponse struct {
498+
Path string `json:"path"`
499+
Filename string `json:"filename"`
500+
Size int64 `json:"size"`
501+
}
502+
503+
func TestExportAttachment(t *testing.T) {
504+
srcDir := t.TempDir()
505+
hash := "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
506+
content := []byte("hello world PDF content")
507+
createAttachmentFixture(t, srcDir, hash, content)
508+
509+
eng := &querytest.MockEngine{
510+
Attachments: map[int64]*query.AttachmentInfo{
511+
10: {ID: 10, Filename: "report.pdf", MimeType: "application/pdf", Size: int64(len(content)), ContentHash: hash},
512+
},
513+
}
514+
h := &handlers{engine: eng, attachmentsDir: srcDir}
515+
516+
t.Run("export to custom destination", func(t *testing.T) {
517+
destDir := t.TempDir()
518+
resp := runTool[exportResponse](t, "export_attachment", h.exportAttachment, map[string]any{
519+
"attachment_id": float64(10),
520+
"destination": destDir,
521+
})
522+
if resp.Filename != "report.pdf" {
523+
t.Fatalf("unexpected filename: %s", resp.Filename)
524+
}
525+
if resp.Size != int64(len(content)) {
526+
t.Fatalf("unexpected size: %d", resp.Size)
527+
}
528+
wantPath := filepath.Join(destDir, "report.pdf")
529+
if resp.Path != wantPath {
530+
t.Fatalf("unexpected path: %s (want %s)", resp.Path, wantPath)
531+
}
532+
got, err := os.ReadFile(wantPath)
533+
if err != nil {
534+
t.Fatal(err)
535+
}
536+
if string(got) != string(content) {
537+
t.Fatalf("content mismatch")
538+
}
539+
})
540+
541+
t.Run("filename collision appends suffix", func(t *testing.T) {
542+
destDir := t.TempDir()
543+
// Create existing file to force collision.
544+
if err := os.WriteFile(filepath.Join(destDir, "report.pdf"), []byte("old"), 0644); err != nil {
545+
t.Fatal(err)
546+
}
547+
resp := runTool[exportResponse](t, "export_attachment", h.exportAttachment, map[string]any{
548+
"attachment_id": float64(10),
549+
"destination": destDir,
550+
})
551+
if resp.Filename != "report_1.pdf" {
552+
t.Fatalf("expected report_1.pdf, got %s", resp.Filename)
553+
}
554+
// Original file should be untouched.
555+
old, _ := os.ReadFile(filepath.Join(destDir, "report.pdf"))
556+
if string(old) != "old" {
557+
t.Fatal("original file was overwritten")
558+
}
559+
})
560+
561+
t.Run("default destination is ~/Downloads", func(t *testing.T) {
562+
// This test only verifies the handler doesn't error when
563+
// ~/Downloads exists (it does on macOS).
564+
home, err := os.UserHomeDir()
565+
if err != nil {
566+
t.Skip("cannot determine home dir")
567+
}
568+
downloads := filepath.Join(home, "Downloads")
569+
if _, err := os.Stat(downloads); os.IsNotExist(err) {
570+
t.Skip("~/Downloads does not exist")
571+
}
572+
573+
resp := runTool[exportResponse](t, "export_attachment", h.exportAttachment, map[string]any{
574+
"attachment_id": float64(10),
575+
})
576+
if !strings.HasPrefix(resp.Path, downloads) {
577+
t.Fatalf("expected path under ~/Downloads, got %s", resp.Path)
578+
}
579+
// Clean up the file we just wrote.
580+
os.Remove(resp.Path)
581+
})
582+
583+
t.Run("invalid destination", func(t *testing.T) {
584+
runToolExpectError(t, "export_attachment", h.exportAttachment, map[string]any{
585+
"attachment_id": float64(10),
586+
"destination": "/nonexistent/path/that/does/not/exist",
587+
})
588+
})
589+
590+
t.Run("missing attachment_id", func(t *testing.T) {
591+
runToolExpectError(t, "export_attachment", h.exportAttachment, map[string]any{})
592+
})
593+
594+
t.Run("attachment not found", func(t *testing.T) {
595+
runToolExpectError(t, "export_attachment", h.exportAttachment, map[string]any{
596+
"attachment_id": float64(999),
597+
})
598+
})
599+
}
600+
497601
func TestLimitArgClamping(t *testing.T) {
498602
tests := []struct {
499603
name string

0 commit comments

Comments
 (0)