Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .githooks/post-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/sh
# roborev post-commit hook v2 - auto-reviews every commit
ROBOREV="${HOME}/.local/bin/roborev"
if [ ! -x "$ROBOREV" ]; then
ROBOREV=$(command -v roborev 2>/dev/null)
[ -z "$ROBOREV" ] || [ ! -x "$ROBOREV" ] && exit 0
fi
"$ROBOREV" enqueue --quiet 2>/dev/null
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ oauth_client*.json

# Local development state
.beads/
.githooks/post-commit

# IDE
.idea/
Expand Down
162 changes: 153 additions & 9 deletions internal/mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/url"
"os"
"path/filepath"
"syscall"
"time"

"github.com/mark3labs/mcp-go/mcp"
Expand Down Expand Up @@ -159,21 +162,162 @@ func (h *handlers) getAttachment(ctx context.Context, req mcp.CallToolRequest) (
return mcp.NewToolResultError(err.Error()), nil
}

resp := struct {
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
ContentBase64 string `json:"content_base64"`
mimeType := att.MimeType
if mimeType == "" {
mimeType = "application/octet-stream"
}

metaObj := struct {
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
}{
Filename: att.Filename,
MimeType: att.MimeType,
Size: att.Size,
ContentBase64: base64.StdEncoding.EncodeToString(data),
Filename: att.Filename,
MimeType: mimeType,
Size: att.Size,
}
metaJSON, err := json.Marshal(metaObj)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("marshal metadata: %v", err)), nil
}

return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(metaJSON),
},
mcp.EmbeddedResource{
Type: "resource",
Resource: mcp.BlobResourceContents{
URI: fmt.Sprintf("attachment:///%d/%s", att.ID, url.PathEscape(att.Filename)),
MIMEType: mimeType,
Blob: base64.StdEncoding.EncodeToString(data),
},
},
},
}, nil
}

func (h *handlers) exportAttachment(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()

id, err := getIDArg(args, "attachment_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

att, err := h.engine.GetAttachment(ctx, id)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("get attachment failed: %v", err)), nil
}
if att == nil {
return mcp.NewToolResultError("attachment not found"), nil
}

if h.attachmentsDir == "" {
return mcp.NewToolResultError("attachments directory not configured"), nil
}

data, err := h.readAttachmentFile(att.ContentHash)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

// Determine destination directory.
destDir, _ := args["destination"].(string)
if destDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("cannot determine home directory: %v", err)), nil
}
destDir = filepath.Join(home, "Downloads")
}

info, err := os.Stat(destDir)
if err != nil || !info.IsDir() {
return mcp.NewToolResultError(fmt.Sprintf("destination directory does not exist: %s", destDir)), nil
}

// Sanitize and deduplicate filename.
filename := sanitizeFilename(filepath.Base(att.Filename))
if filename == "" || filename == "." {
filename = att.ContentHash
}
f, outPath, err := createExclusive(filepath.Join(destDir, filename))
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("write failed: %v", err)), nil
}
_, writeErr := f.Write(data)
closeErr := f.Close()
if writeErr != nil {
os.Remove(outPath)
return mcp.NewToolResultError(fmt.Sprintf("write failed: %v", writeErr)), nil
}
if closeErr != nil {
os.Remove(outPath)
return mcp.NewToolResultError(fmt.Sprintf("write failed: %v", closeErr)), nil
}

resp := struct {
Path string `json:"path"`
Filename string `json:"filename"`
Size int64 `json:"size"`
}{
Path: outPath,
Filename: filepath.Base(outPath),
Size: int64(len(data)),
}
return jsonResult(resp)
}

// sanitizeFilename replaces characters that are invalid in filenames.
func sanitizeFilename(s string) string {
var result []rune
for _, r := range s {
switch r {
case '/', '\\', ':', '*', '?', '"', '<', '>', '|', '\n', '\r', '\t':
result = append(result, '_')
default:
result = append(result, r)
}
}
return string(result)
}

// pathConflict reports whether err indicates the path already exists as a
// file or directory. O_EXCL returns EEXIST for files, but EISDIR when a
// directory occupies the name.
func pathConflict(err error) bool {
return os.IsExist(err) || errors.Is(err, syscall.EISDIR)
}

// createExclusive atomically creates a file that doesn't already exist,
// trying name, name_1, name_2, etc. until it succeeds. Returns the open
// file and the path that was used. Uses O_CREATE|O_EXCL to avoid TOCTOU
// races with symlinks or concurrent writes.
func createExclusive(p string) (*os.File, string, error) {
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err == nil {
return f, p, nil
}
if !pathConflict(err) {
return nil, "", err
}
ext := filepath.Ext(p)
base := p[:len(p)-len(ext)]
for i := 1; ; i++ {
candidate := fmt.Sprintf("%s_%d%s", base, i, ext)
f, err = os.OpenFile(candidate, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
if err == nil {
return f, candidate, nil
}
if !pathConflict(err) {
return nil, "", err
}
}
}

func (h *handlers) listMessages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()

Expand Down
29 changes: 22 additions & 7 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import (

// Tool name constants.
const (
ToolSearchMessages = "search_messages"
ToolGetMessage = "get_message"
ToolGetAttachment = "get_attachment"
ToolListMessages = "list_messages"
ToolGetStats = "get_stats"
ToolAggregate = "aggregate"
ToolSearchMessages = "search_messages"
ToolGetMessage = "get_message"
ToolGetAttachment = "get_attachment"
ToolExportAttachment = "export_attachment"
ToolListMessages = "list_messages"
ToolGetStats = "get_stats"
ToolAggregate = "aggregate"
)

// Common argument helpers for recurring tool option definitions.
Expand Down Expand Up @@ -59,6 +60,7 @@ func Serve(ctx context.Context, engine query.Engine, attachmentsDir string) erro
s.AddTool(searchMessagesTool(), h.searchMessages)
s.AddTool(getMessageTool(), h.getMessage)
s.AddTool(getAttachmentTool(), h.getAttachment)
s.AddTool(exportAttachmentTool(), h.exportAttachment)
s.AddTool(listMessagesTool(), h.listMessages)
s.AddTool(getStatsTool(), h.getStats)
s.AddTool(aggregateTool(), h.aggregate)
Expand Down Expand Up @@ -93,7 +95,7 @@ func getMessageTool() mcp.Tool {

func getAttachmentTool() mcp.Tool {
return mcp.NewTool(ToolGetAttachment,
mcp.WithDescription("Get attachment content by attachment ID. Returns base64-encoded content with metadata. Use get_message first to find attachment IDs."),
mcp.WithDescription("Get attachment content by attachment ID. Returns metadata as text and the file content as an embedded resource blob. Use get_message first to find attachment IDs."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithNumber("attachment_id",
mcp.Required(),
Expand All @@ -102,6 +104,19 @@ func getAttachmentTool() mcp.Tool {
)
}

func exportAttachmentTool() mcp.Tool {
return mcp.NewTool(ToolExportAttachment,
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."),
mcp.WithNumber("attachment_id",
mcp.Required(),
mcp.Description("Attachment ID (from get_message response)"),
),
mcp.WithString("destination",
mcp.Description("Directory to save the file to (default: ~/Downloads)"),
),
)
}

func listMessagesTool() mcp.Tool {
return mcp.NewTool(ToolListMessages,
mcp.WithDescription("List messages with optional filters. Returns message summaries sorted by date."),
Expand Down
Loading