Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ oauth_client*.json
# Local development state
.beads/
.githooks/post-commit
.mcp.json

# IDE
.idea/
Expand Down
4 changes: 2 additions & 2 deletions cmd/msgvault/cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ var mcpCmd = &cobra.Command{

This allows Claude Desktop (or any MCP client) to query your email archive
using tools like search_messages, get_message, list_messages, get_stats,
and aggregate.
aggregate, and stage_deletion.

Add to Claude Desktop config:
{
Expand Down Expand Up @@ -59,7 +59,7 @@ Add to Claude Desktop config:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

return mcpserver.Serve(ctx, engine, cfg.AttachmentsDir())
return mcpserver.Serve(ctx, engine, cfg.AttachmentsDir(), cfg.Data.DataDir)
},
}

Expand Down
170 changes: 170 additions & 0 deletions internal/mcp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/mark3labs/mcp-go/mcp"
"github.com/wesm/msgvault/internal/deletion"
"github.com/wesm/msgvault/internal/query"
"github.com/wesm/msgvault/internal/search"
)
Expand All @@ -22,6 +23,7 @@ const maxLimit = 1000
type handlers struct {
engine query.Engine
attachmentsDir string
dataDir string
}

// getIDArg extracts a required positive integer ID from the arguments map.
Expand Down Expand Up @@ -299,3 +301,171 @@ func jsonResult(v any) (*mcp.CallToolResult, error) {
}
return mcp.NewToolResultText(string(data)), nil
}

// maxStageDeletionResults limits how many messages can be staged in one call.
const maxStageDeletionResults = 100000

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

// Check for query vs structured filters
queryStr, hasQuery := args["query"].(string)
hasQuery = hasQuery && queryStr != ""

// Check for any structured filter
fromStr, _ := args["from"].(string)
domainStr, _ := args["domain"].(string)
labelStr, _ := args["label"].(string)
hasAttachment, _ := args["has_attachment"].(bool)
afterDate, err := getDateArg(args, "after")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
beforeDate, err := getDateArg(args, "before")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

hasStructuredFilter := fromStr != "" || domainStr != "" || labelStr != "" ||
hasAttachment || afterDate != nil || beforeDate != nil

// Validate: must have either query or structured filters, but not both
if hasQuery && hasStructuredFilter {
return mcp.NewToolResultError("use either 'query' or structured filters (from, domain, label, etc.), not both"), nil
}
if !hasQuery && !hasStructuredFilter {
return mcp.NewToolResultError("must provide either 'query' or at least one filter (from, domain, label, after, before, has_attachment)"), nil
}

var gmailIDs []string
var description string

if hasQuery {
// Query-based search
q := search.Parse(queryStr)

// Try fast search first
results, err := h.engine.SearchFast(ctx, q, query.MessageFilter{}, maxStageDeletionResults, 0)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search failed: %v", err)), nil
}

// Fall back to FTS if no results and query has text terms
if len(results) == 0 && len(q.TextTerms) > 0 {
results, err = h.engine.Search(ctx, q, maxStageDeletionResults, 0)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("search failed: %v", err)), nil
}
}

for _, msg := range results {
gmailIDs = append(gmailIDs, msg.SourceMessageID)
}
description = fmt.Sprintf("query: %s", queryStr)
if len(description) > 50 {
description = description[:50]
}
} else {
// Structured filter
filter := query.MessageFilter{
Sender: fromStr,
Domain: domainStr,
Label: labelStr,
WithAttachmentsOnly: hasAttachment,
After: afterDate,
Before: beforeDate,
}

var err error
gmailIDs, err = h.engine.GetGmailIDsByFilter(ctx, filter)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("filter failed: %v", err)), nil
}

// Build description from filters
var parts []string
if fromStr != "" {
parts = append(parts, fmt.Sprintf("from:%s", fromStr))
}
if domainStr != "" {
parts = append(parts, fmt.Sprintf("domain:%s", domainStr))
}
if labelStr != "" {
parts = append(parts, fmt.Sprintf("label:%s", labelStr))
}
if hasAttachment {
parts = append(parts, "has:attachment")
}
if afterDate != nil {
parts = append(parts, fmt.Sprintf("after:%s", afterDate.Format("2006-01-02")))
}
if beforeDate != nil {
parts = append(parts, fmt.Sprintf("before:%s", beforeDate.Format("2006-01-02")))
}
description = fmt.Sprintf("filter: %s", joinParts(parts, " "))
if len(description) > 50 {
description = description[:50]
}
}

if len(gmailIDs) == 0 {
return mcp.NewToolResultError("no messages match the specified criteria"), nil
}

// Create deletion manager and manifest
deletionsDir := filepath.Join(h.dataDir, "deletions")
manager, err := deletion.NewManager(deletionsDir)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("create deletion manager: %v", err)), nil
}

manifest := deletion.NewManifest(description, gmailIDs)
manifest.CreatedBy = "mcp"

// Set filter metadata for execution
if fromStr != "" {
manifest.Filters.Senders = []string{fromStr}
}
if domainStr != "" {
manifest.Filters.SenderDomains = []string{domainStr}
}
if labelStr != "" {
manifest.Filters.Labels = []string{labelStr}
}
if afterDate != nil {
manifest.Filters.After = afterDate.Format("2006-01-02")
}
if beforeDate != nil {
manifest.Filters.Before = beforeDate.Format("2006-01-02")
}

if err := manager.SaveManifest(manifest); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("save manifest: %v", err)), nil
}

resp := struct {
BatchID string `json:"batch_id"`
MessageCount int `json:"message_count"`
Status string `json:"status"`
NextStep string `json:"next_step"`
}{
BatchID: manifest.ID,
MessageCount: len(gmailIDs),
Status: string(manifest.Status),
NextStep: "Run 'msgvault delete-staged' to execute deletion, or 'msgvault cancel-deletion " + manifest.ID + "' to cancel",
}

return jsonResult(resp)
}

// joinParts joins strings with a separator.
func joinParts(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, p := range parts[1:] {
result += sep + p
}
return result
}
30 changes: 28 additions & 2 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
ToolListMessages = "list_messages"
ToolGetStats = "get_stats"
ToolAggregate = "aggregate"
ToolStageDeletion = "stage_deletion"
)

// Common argument helpers for recurring tool option definitions.
Expand Down Expand Up @@ -47,21 +48,23 @@ func withBefore() mcp.ToolOption {

// Serve creates an MCP server with email archive tools and serves over stdio.
// It blocks until stdin is closed or the context is cancelled.
func Serve(ctx context.Context, engine query.Engine, attachmentsDir string) error {
// dataDir is the base data directory (e.g., ~/.msgvault) used for deletions.
func Serve(ctx context.Context, engine query.Engine, attachmentsDir, dataDir string) error {
s := server.NewMCPServer(
"msgvault",
"1.0.0",
server.WithToolCapabilities(false),
)

h := &handlers{engine: engine, attachmentsDir: attachmentsDir}
h := &handlers{engine: engine, attachmentsDir: attachmentsDir, dataDir: dataDir}

s.AddTool(searchMessagesTool(), h.searchMessages)
s.AddTool(getMessageTool(), h.getMessage)
s.AddTool(getAttachmentTool(), h.getAttachment)
s.AddTool(listMessagesTool(), h.listMessages)
s.AddTool(getStatsTool(), h.getStats)
s.AddTool(aggregateTool(), h.aggregate)
s.AddTool(stageDeletionTool(), h.stageDeletion)

stdio := server.NewStdioServer(s)
return stdio.Listen(ctx, os.Stdin, os.Stdout)
Expand Down Expand Up @@ -146,3 +149,26 @@ func aggregateTool() mcp.Tool {
withBefore(),
)
}

func stageDeletionTool() mcp.Tool {
return mcp.NewTool(ToolStageDeletion,
mcp.WithDescription("Stage messages for deletion. Use EITHER 'query' (Gmail-style search) OR structured filters (from, domain, label, etc.), not both. Does NOT delete immediately - run 'msgvault delete-staged' CLI command to execute staged deletions."),
mcp.WithString("query",
mcp.Description("Gmail-style search query (e.g. 'from:linkedin subject:job alert'). Cannot be combined with structured filters."),
),
mcp.WithString("from",
mcp.Description("Filter by sender email address"),
),
mcp.WithString("domain",
mcp.Description("Filter by sender domain (e.g. 'linkedin.com')"),
),
mcp.WithString("label",
mcp.Description("Filter by Gmail label (e.g. 'CATEGORY_PROMOTIONS')"),
),
withAfter(),
withBefore(),
mcp.WithBoolean("has_attachment",
mcp.Description("Only messages with attachments"),
),
)
}