Commit 24e2bb3
cmd/maildir/main.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "strings"
+
+ "github.com/xlgmokha/mcp/pkg/maildir"
+)
+
+func main() {
+ // Parse command line flags
+ var maildirPathsFlag = flag.String("maildir-path", "", "Comma-separated list of allowed maildir paths")
+ flag.Parse()
+
+ var maildirPaths []string
+ if *maildirPathsFlag != "" {
+ maildirPaths = strings.Split(*maildirPathsFlag, ",")
+ } else if len(flag.Args()) > 0 {
+ // Fall back to positional arguments for backward compatibility
+ maildirPaths = flag.Args()
+ } else {
+ log.Fatal("Usage: mcp-maildir --maildir-path <path1,path2,...> OR mcp-maildir <path1> [path2...]")
+ }
+
+ server := maildir.New(maildirPaths)
+
+ ctx := context.Background()
+ if err := server.Run(ctx); err != nil {
+ log.Fatalf("Server error: %v", err)
+ }
+}
\ No newline at end of file
pkg/git/server.go
@@ -1,6 +1,7 @@
package git
import (
+ "encoding/json"
"fmt"
"os"
"os/exec"
@@ -32,9 +33,115 @@ func New(repoPath string) *Server {
gitServer.registerResources()
gitServer.registerRoots()
+ // Set up dynamic resource listing
+ gitServer.setupResourceHandling()
+
return gitServer
}
+// setupResourceHandling configures custom resource handling for lazy loading
+func (gs *Server) setupResourceHandling() {
+ // Custom handler that calls our ListResources method
+ customListResourcesHandler := func(req mcp.JSONRPCRequest) mcp.JSONRPCResponse {
+ resources := gs.ListResources()
+ result := mcp.ListResourcesResult{Resources: resources}
+ id := req.ID
+ bytes, _ := json.Marshal(result)
+ rawMsg := json.RawMessage(bytes)
+ resultBytes := &rawMsg
+ return mcp.JSONRPCResponse{
+ JSONRPC: "2.0",
+ ID: id,
+ Result: resultBytes,
+ }
+ }
+
+ handlers := make(map[string]func(mcp.JSONRPCRequest) mcp.JSONRPCResponse)
+ handlers["resources/list"] = customListResourcesHandler
+ gs.SetCustomRequestHandler(handlers)
+}
+
+// ListResources dynamically discovers and returns git resources
+func (gs *Server) ListResources() []mcp.Resource {
+ var resources []mcp.Resource
+
+ // Check if this is a git repository
+ gitDir := filepath.Join(gs.repoPath, ".git")
+ if _, err := os.Stat(gitDir); os.IsNotExist(err) {
+ return resources // Return empty for non-git repositories
+ }
+
+ // Get current branch (only when needed)
+ currentBranch, err := gs.getCurrentBranch()
+ if err != nil {
+ currentBranch = "unknown"
+ }
+
+ // Get tracked files (only when requested)
+ trackedFiles, err := gs.getTrackedFiles()
+ if err != nil {
+ return resources // Return empty on error
+ }
+
+ // Create resources for tracked files (limited to first 500 for performance)
+ limit := 500
+ for i, filePath := range trackedFiles {
+ if i >= limit {
+ break // Prevent loading too many resources at once
+ }
+
+ // Create git:// URI: git://repo/branch/path
+ gitURI := fmt.Sprintf("git://%s/%s/%s", gs.repoPath, currentBranch, filePath)
+
+ // Determine MIME type
+ mimeType := getGitMimeType(filePath)
+
+ // Create resource definition
+ resource := mcp.Resource{
+ URI: gitURI,
+ Name: filepath.Base(filePath),
+ Description: fmt.Sprintf("Git file: %s (branch: %s)", filePath, currentBranch),
+ MimeType: mimeType,
+ }
+ resources = append(resources, resource)
+ }
+
+ // Add branch resources (limited set)
+ branches, err := gs.getBranches()
+ if err == nil {
+ for i, branch := range branches {
+ if i >= 10 { // Limit to 10 branches
+ break
+ }
+ branchURI := fmt.Sprintf("git://%s/branch/%s", gs.repoPath, branch)
+ resource := mcp.Resource{
+ URI: branchURI,
+ Name: fmt.Sprintf("Branch: %s", branch),
+ Description: fmt.Sprintf("Git branch: %s", branch),
+ MimeType: "application/x-git-branch",
+ }
+ resources = append(resources, resource)
+ }
+ }
+
+ // Add recent commit resources (limited set)
+ commits, err := gs.getRecentCommits(10)
+ if err == nil {
+ for _, commit := range commits {
+ commitURI := fmt.Sprintf("git://%s/commit/%s", gs.repoPath, commit.Hash)
+ resource := mcp.Resource{
+ URI: commitURI,
+ Name: fmt.Sprintf("Commit: %s", commit.Hash[:8]),
+ Description: fmt.Sprintf("Git commit: %s - %s", commit.Hash[:8], commit.Message),
+ MimeType: "application/x-git-commit",
+ }
+ resources = append(resources, resource)
+ }
+ }
+
+ return resources
+}
+
// registerTools registers all Git tools with the server
func (gs *Server) registerTools() {
gs.RegisterTool("git_status", gs.HandleGitStatus)
@@ -78,13 +185,11 @@ func (gs *Server) registerPrompts() {
gs.RegisterPrompt(commitPrompt, gs.HandleCommitMessagePrompt)
}
-// registerResources registers git repository resources (git:// URIs)
+// registerResources sets up resource handling (lazy loading)
func (gs *Server) registerResources() {
- // Discover and register git repository resources
- if err := gs.discoverGitResources(); err != nil {
- // Log error but don't fail - continue without resources
- fmt.Printf("Warning: Failed to discover git resources: %v\n", err)
- }
+ // Register pattern-based git resource handlers instead of discovering all files
+ // This avoids loading all repository files into memory at startup
+ gs.Server.RegisterResource("git://", gs.HandleGitResource)
}
// registerRoots registers git repository as a root
@@ -118,60 +223,6 @@ func (gs *Server) registerRoots() {
gs.RegisterRoot(root)
}
-// discoverGitResources discovers files in the git repository and registers them as resources
-func (gs *Server) discoverGitResources() error {
- // Check if this is a git repository
- gitDir := filepath.Join(gs.repoPath, ".git")
- if _, err := os.Stat(gitDir); os.IsNotExist(err) {
- return fmt.Errorf("not a git repository: %s", gs.repoPath)
- }
-
- // Get current branch
- currentBranch, err := gs.getCurrentBranch()
- if err != nil {
- return fmt.Errorf("failed to get current branch: %v", err)
- }
-
- // Get list of tracked files
- trackedFiles, err := gs.getTrackedFiles()
- if err != nil {
- return fmt.Errorf("failed to get tracked files: %v", err)
- }
-
- // Register each tracked file as a git:// resource
- for _, filePath := range trackedFiles {
- // Skip binary files and very large files
- fullPath := filepath.Join(gs.repoPath, filePath)
- if info, err := os.Stat(fullPath); err == nil {
- if info.Size() > 10*1024*1024 { // 10MB limit
- continue
- }
- }
-
- // Create git:// URI: git://repo/branch/path
- gitURI := fmt.Sprintf("git://%s/%s/%s", gs.repoPath, currentBranch, filePath)
-
- // Determine MIME type
- mimeType := getGitMimeType(filePath)
-
- // Create resource definition
- resource := mcp.Resource{
- URI: gitURI,
- Name: filepath.Base(filePath),
- Description: fmt.Sprintf("Git file: %s (branch: %s)", filePath, currentBranch),
- MimeType: mimeType,
- }
-
- // Register resource with handler
- gs.RegisterResourceWithDefinition(resource, gs.HandleGitResource)
- }
-
- // Register branch and commit resources
- gs.registerBranchResources(currentBranch)
- gs.registerCommitResources()
-
- return nil
-}
// getCurrentBranch gets the current git branch
func (gs *Server) getCurrentBranch() (string, error) {
@@ -214,51 +265,6 @@ func (gs *Server) getTrackedFiles() ([]string, error) {
return filteredFiles, nil
}
-// registerBranchResources registers git branches as resources
-func (gs *Server) registerBranchResources(currentBranch string) {
- branches, err := gs.getBranches()
- if err != nil {
- return // Skip if can't get branches
- }
-
- for _, branch := range branches {
- gitURI := fmt.Sprintf("git://%s/branch/%s", gs.repoPath, branch)
-
- resource := mcp.Resource{
- URI: gitURI,
- Name: fmt.Sprintf("Branch: %s", branch),
- Description: fmt.Sprintf("Git branch: %s", branch),
- MimeType: "application/x-git-branch",
- }
-
- if branch == currentBranch {
- resource.Description += " (current)"
- }
-
- gs.RegisterResourceWithDefinition(resource, gs.HandleGitResource)
- }
-}
-
-// registerCommitResources registers recent commits as resources
-func (gs *Server) registerCommitResources() {
- commits, err := gs.getRecentCommits(10) // Last 10 commits
- if err != nil {
- return // Skip if can't get commits
- }
-
- for _, commit := range commits {
- gitURI := fmt.Sprintf("git://%s/commit/%s", gs.repoPath, commit.Hash)
-
- resource := mcp.Resource{
- URI: gitURI,
- Name: fmt.Sprintf("Commit: %s", commit.Hash[:8]),
- Description: fmt.Sprintf("Git commit: %s - %s", commit.Hash[:8], commit.Message),
- MimeType: "application/x-git-commit",
- }
-
- gs.RegisterResourceWithDefinition(resource, gs.HandleGitResource)
- }
-}
// getBranches gets list of git branches
func (gs *Server) getBranches() ([]string, error) {
pkg/maildir/server.go
@@ -0,0 +1,1290 @@
+package maildir
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/mail"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/xlgmokha/mcp/pkg/mcp"
+)
+
+// Server implements the Maildir MCP server
+type Server struct {
+ *mcp.Server
+ allowedPaths []string
+}
+
+// MessageInfo represents basic email message information
+type MessageInfo struct {
+ ID string `json:"id"`
+ Filename string `json:"filename"`
+ Subject string `json:"subject"`
+ From string `json:"from"`
+ To string `json:"to"`
+ Date time.Time `json:"date"`
+ Flags []string `json:"flags"`
+ Folder string `json:"folder"`
+ Size int64 `json:"size"`
+ Headers map[string]string `json:"headers"`
+ MessageID string `json:"message_id"`
+ InReplyTo string `json:"in_reply_to"`
+ References []string `json:"references"`
+}
+
+// Message represents a full email message
+type Message struct {
+ MessageInfo
+ Body string `json:"body"`
+ HTMLBody string `json:"html_body,omitempty"`
+}
+
+// FolderInfo represents maildir folder information
+type FolderInfo struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ MessageCount int `json:"message_count"`
+ UnreadCount int `json:"unread_count"`
+}
+
+// ContactInfo represents contact analysis data
+type ContactInfo struct {
+ Email string `json:"email"`
+ Name string `json:"name"`
+ Count int `json:"count"`
+ LastSeen time.Time `json:"last_seen"`
+ FirstSeen time.Time `json:"first_seen"`
+ IsOutgoing bool `json:"is_outgoing"`
+}
+
+// New creates a new Maildir MCP server
+func New(allowedPaths []string) *Server {
+ server := mcp.NewServer("maildir-server", "1.0.0")
+
+ // Normalize and validate allowed paths
+ normalizedPaths := make([]string, len(allowedPaths))
+ for i, path := range allowedPaths {
+ absPath, err := filepath.Abs(expandHome(path))
+ if err != nil {
+ panic(fmt.Sprintf("Invalid maildir path: %s", path))
+ }
+ normalizedPaths[i] = filepath.Clean(absPath)
+ }
+
+ maildirServer := &Server{
+ Server: server,
+ allowedPaths: normalizedPaths,
+ }
+
+ // Register all maildir tools, prompts, resources, and roots
+ maildirServer.registerTools()
+ maildirServer.registerPrompts()
+ maildirServer.registerResources()
+ maildirServer.registerRoots()
+
+ // Set up dynamic resource listing
+ maildirServer.setupResourceHandling()
+
+ return maildirServer
+}
+
+// setupResourceHandling configures custom resource handling for lazy loading
+func (ms *Server) setupResourceHandling() {
+ // Custom handler that calls our ListResources method
+ customListResourcesHandler := func(req mcp.JSONRPCRequest) mcp.JSONRPCResponse {
+ resources := ms.ListResources()
+ result := mcp.ListResourcesResult{Resources: resources}
+ id := req.ID
+ bytes, _ := json.Marshal(result)
+ rawMsg := json.RawMessage(bytes)
+ resultBytes := &rawMsg
+ return mcp.JSONRPCResponse{
+ JSONRPC: "2.0",
+ ID: id,
+ Result: resultBytes,
+ }
+ }
+
+ handlers := make(map[string]func(mcp.JSONRPCRequest) mcp.JSONRPCResponse)
+ handlers["resources/list"] = customListResourcesHandler
+ ms.SetCustomRequestHandler(handlers)
+}
+
+// ListResources dynamically discovers and returns maildir resources
+func (ms *Server) ListResources() []mcp.Resource {
+ var resources []mcp.Resource
+
+ // Dynamically discover folders in allowed paths
+ for _, path := range ms.allowedPaths {
+ folders, _ := ms.scanFolders(path, false)
+ for _, folder := range folders {
+ // Create maildir resource for each folder
+ folderURI := fmt.Sprintf("maildir://%s/%s", path, folder.Name)
+ resource := mcp.Resource{
+ URI: folderURI,
+ Name: folder.Name,
+ Description: fmt.Sprintf("Maildir folder: %s", folder.Name),
+ MimeType: "application/x-maildir-folder",
+ }
+ resources = append(resources, resource)
+ }
+ }
+
+ return resources
+}
+
+// registerTools registers all Maildir tools with the server
+func (ms *Server) registerTools() {
+ ms.RegisterTool("maildir_scan_folders", ms.HandleScanFolders)
+ ms.RegisterTool("maildir_list_messages", ms.HandleListMessages)
+ ms.RegisterTool("maildir_read_message", ms.HandleReadMessage)
+ ms.RegisterTool("maildir_search_messages", ms.HandleSearchMessages)
+ ms.RegisterTool("maildir_get_thread", ms.HandleGetThread)
+ ms.RegisterTool("maildir_analyze_contacts", ms.HandleAnalyzeContacts)
+ ms.RegisterTool("maildir_get_statistics", ms.HandleGetStatistics)
+}
+
+// registerPrompts registers all Maildir prompts with the server
+func (ms *Server) registerPrompts() {
+ // No specific prompts for maildir at the moment
+}
+
+// registerResources sets up resource handling (lazy loading)
+func (ms *Server) registerResources() {
+ // Register a generic maildir:// resource handler for pattern matching
+ // This avoids loading all folders into memory at startup
+ ms.Server.RegisterResource("maildir://", ms.HandleReadResource)
+}
+
+// registerRoots registers maildir paths as roots
+func (ms *Server) registerRoots() {
+ for _, path := range ms.allowedPaths {
+ // Create maildir:// URI for the path
+ maildirURI := "maildir://" + path
+
+ // Create a user-friendly name from the path
+ pathName := filepath.Base(path)
+ if pathName == "." || pathName == "/" {
+ pathName = path
+ }
+
+ root := mcp.NewRoot(maildirURI, fmt.Sprintf("Maildir: %s", pathName))
+ ms.RegisterRoot(root)
+ }
+}
+
+
+// HandleScanFolders implements the maildir_scan_folders tool
+func (ms *Server) HandleScanFolders(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ maildirPath, ok := req.Arguments["maildir_path"].(string)
+ if !ok {
+ return mcp.NewToolError("maildir_path is required"), nil
+ }
+
+ if !ms.isPathAllowed(maildirPath) {
+ return mcp.NewToolError("access denied: path not in allowed directories"), nil
+ }
+
+ includeCounts := true
+ if ic, ok := req.Arguments["include_counts"].(bool); ok {
+ includeCounts = ic
+ }
+
+ folders, err := ms.scanFolders(maildirPath, includeCounts)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to scan folders: %v", err)), nil
+ }
+
+ result, err := json.Marshal(map[string]interface{}{
+ "folders": folders,
+ })
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
+ }
+
+ return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
+}
+
+// HandleListMessages implements the maildir_list_messages tool
+func (ms *Server) HandleListMessages(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ maildirPath, ok := req.Arguments["maildir_path"].(string)
+ if !ok {
+ return mcp.NewToolError("maildir_path is required"), nil
+ }
+
+ if !ms.isPathAllowed(maildirPath) {
+ return mcp.NewToolError("access denied: path not in allowed directories"), nil
+ }
+
+ folder := "INBOX"
+ if f, ok := req.Arguments["folder"].(string); ok {
+ folder = f
+ }
+
+ limit := 50
+ if l, ok := req.Arguments["limit"].(float64); ok {
+ limit = int(l)
+ if limit > 200 {
+ limit = 200
+ }
+ }
+
+ offset := 0
+ if o, ok := req.Arguments["offset"].(float64); ok {
+ offset = int(o)
+ }
+
+ messages, total, err := ms.listMessages(maildirPath, folder, limit, offset, req.Arguments)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to list messages: %v", err)), nil
+ }
+
+ result, err := json.Marshal(map[string]interface{}{
+ "messages": messages,
+ "total": total,
+ "offset": offset,
+ "limit": limit,
+ })
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
+ }
+
+ return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
+}
+
+// HandleReadMessage implements the maildir_read_message tool
+func (ms *Server) HandleReadMessage(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ maildirPath, ok := req.Arguments["maildir_path"].(string)
+ if !ok {
+ return mcp.NewToolError("maildir_path is required"), nil
+ }
+
+ if !ms.isPathAllowed(maildirPath) {
+ return mcp.NewToolError("access denied: path not in allowed directories"), nil
+ }
+
+ messageID, ok := req.Arguments["message_id"].(string)
+ if !ok {
+ return mcp.NewToolError("message_id is required"), nil
+ }
+
+ includeHTML := false
+ if ih, ok := req.Arguments["include_html"].(bool); ok {
+ includeHTML = ih
+ }
+
+ includeHeaders := true
+ if ih, ok := req.Arguments["include_headers"].(bool); ok {
+ includeHeaders = ih
+ }
+
+ sanitizeContent := true
+ if sc, ok := req.Arguments["sanitize_content"].(bool); ok {
+ sanitizeContent = sc
+ }
+
+ message, err := ms.readMessage(maildirPath, messageID, includeHTML, includeHeaders, sanitizeContent)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to read message: %v", err)), nil
+ }
+
+ result, err := json.Marshal(message)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
+ }
+
+ return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
+}
+
+// HandleSearchMessages implements the maildir_search_messages tool
+func (ms *Server) HandleSearchMessages(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ maildirPath, ok := req.Arguments["maildir_path"].(string)
+ if !ok {
+ return mcp.NewToolError("maildir_path is required"), nil
+ }
+
+ if !ms.isPathAllowed(maildirPath) {
+ return mcp.NewToolError("access denied: path not in allowed directories"), nil
+ }
+
+ query, ok := req.Arguments["query"].(string)
+ if !ok {
+ return mcp.NewToolError("query is required"), nil
+ }
+
+ limit := 50
+ if l, ok := req.Arguments["limit"].(float64); ok {
+ limit = int(l)
+ if limit > 200 {
+ limit = 200
+ }
+ }
+
+ results, err := ms.searchMessages(maildirPath, query, limit, req.Arguments)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to search messages: %v", err)), nil
+ }
+
+ result, err := json.Marshal(map[string]interface{}{
+ "results": results,
+ "query": query,
+ })
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
+ }
+
+ return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
+}
+
+// HandleGetThread implements the maildir_get_thread tool
+func (ms *Server) HandleGetThread(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ maildirPath, ok := req.Arguments["maildir_path"].(string)
+ if !ok {
+ return mcp.NewToolError("maildir_path is required"), nil
+ }
+
+ if !ms.isPathAllowed(maildirPath) {
+ return mcp.NewToolError("access denied: path not in allowed directories"), nil
+ }
+
+ messageID, ok := req.Arguments["message_id"].(string)
+ if !ok {
+ return mcp.NewToolError("message_id is required"), nil
+ }
+
+ maxDepth := 50
+ if md, ok := req.Arguments["max_depth"].(float64); ok {
+ maxDepth = int(md)
+ }
+
+ thread, err := ms.getThread(maildirPath, messageID, maxDepth)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to get thread: %v", err)), nil
+ }
+
+ result, err := json.Marshal(map[string]interface{}{
+ "thread": thread,
+ })
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
+ }
+
+ return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
+}
+
+// HandleAnalyzeContacts implements the maildir_analyze_contacts tool
+func (ms *Server) HandleAnalyzeContacts(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ maildirPath, ok := req.Arguments["maildir_path"].(string)
+ if !ok {
+ return mcp.NewToolError("maildir_path is required"), nil
+ }
+
+ if !ms.isPathAllowed(maildirPath) {
+ return mcp.NewToolError("access denied: path not in allowed directories"), nil
+ }
+
+ contacts, err := ms.analyzeContacts(maildirPath, req.Arguments)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to analyze contacts: %v", err)), nil
+ }
+
+ result, err := json.Marshal(map[string]interface{}{
+ "contacts": contacts,
+ })
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
+ }
+
+ return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
+}
+
+// HandleGetStatistics implements the maildir_get_statistics tool
+func (ms *Server) HandleGetStatistics(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ maildirPath, ok := req.Arguments["maildir_path"].(string)
+ if !ok {
+ return mcp.NewToolError("maildir_path is required"), nil
+ }
+
+ if !ms.isPathAllowed(maildirPath) {
+ return mcp.NewToolError("access denied: path not in allowed directories"), nil
+ }
+
+ stats, err := ms.getStatistics(maildirPath, req.Arguments)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to get statistics: %v", err)), nil
+ }
+
+ result, err := json.Marshal(stats)
+ if err != nil {
+ return mcp.NewToolError(fmt.Sprintf("failed to marshal result: %v", err)), nil
+ }
+
+ return mcp.NewToolResult(mcp.NewTextContent(string(result))), nil
+}
+
+// HandleReadResource implements resource reading for maildir URIs
+func (ms *Server) HandleReadResource(req mcp.ReadResourceRequest) (mcp.ReadResourceResult, error) {
+ // Parse the maildir URI to extract path and folder
+ uri := req.URI
+ if !strings.HasPrefix(uri, "maildir://") {
+ return mcp.ReadResourceResult{}, fmt.Errorf("invalid maildir URI: %s", uri)
+ }
+
+ path := strings.TrimPrefix(uri, "maildir://")
+
+ // For now, return folder information as JSON
+ folders, err := ms.scanFolders(path, true)
+ if err != nil {
+ return mcp.ReadResourceResult{}, fmt.Errorf("failed to scan maildir: %v", err)
+ }
+
+ result, err := json.Marshal(map[string]interface{}{
+ "folders": folders,
+ "uri": uri,
+ })
+ if err != nil {
+ return mcp.ReadResourceResult{}, fmt.Errorf("failed to marshal result: %v", err)
+ }
+
+ return mcp.ReadResourceResult{
+ Contents: []mcp.Content{
+ mcp.NewTextContent(string(result)),
+ },
+ }, nil
+}
+
+// Helper functions
+
+// isPathAllowed checks if the given path is within allowed directories
+func (ms *Server) isPathAllowed(path string) bool {
+ absPath, err := filepath.Abs(expandHome(path))
+ if err != nil {
+ return false
+ }
+ absPath = filepath.Clean(absPath)
+
+ for _, allowedPath := range ms.allowedPaths {
+ if strings.HasPrefix(absPath, allowedPath) {
+ return true
+ }
+ }
+ return false
+}
+
+// expandHome expands ~ to user's home directory
+func expandHome(path string) string {
+ if strings.HasPrefix(path, "~/") {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return path
+ }
+ return filepath.Join(home, path[2:])
+ }
+ return path
+}
+
+// scanFolders scans maildir folders and returns folder information
+func (ms *Server) scanFolders(maildirPath string, includeCounts bool) ([]FolderInfo, error) {
+ var folders []FolderInfo
+
+ // Walk the maildir directory to find folders
+ err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil // Skip directories with errors
+ }
+
+ if !info.IsDir() {
+ return nil
+ }
+
+ // Check if this looks like a maildir folder (has cur, new, tmp subdirs)
+ curDir := filepath.Join(path, "cur")
+ newDir := filepath.Join(path, "new")
+ tmpDir := filepath.Join(path, "tmp")
+
+ if _, err := os.Stat(curDir); err == nil {
+ if _, err := os.Stat(newDir); err == nil {
+ if _, err := os.Stat(tmpDir); err == nil {
+ // This is a maildir folder
+ relPath, _ := filepath.Rel(maildirPath, path)
+ if relPath == "." {
+ relPath = "INBOX"
+ }
+
+ folder := FolderInfo{
+ Name: relPath,
+ Path: path,
+ }
+
+ if includeCounts {
+ // Count messages in cur and new directories
+ curCount := ms.countMessagesInDir(curDir)
+ newCount := ms.countMessagesInDir(newDir)
+ folder.MessageCount = curCount + newCount
+ folder.UnreadCount = newCount
+ }
+
+ folders = append(folders, folder)
+ }
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Sort folders by name
+ sort.Slice(folders, func(i, j int) bool {
+ return folders[i].Name < folders[j].Name
+ })
+
+ return folders, nil
+}
+
+// countMessagesInDir counts the number of message files in a directory
+func (ms *Server) countMessagesInDir(dirPath string) int {
+ entries, err := os.ReadDir(dirPath)
+ if err != nil {
+ return 0
+ }
+
+ count := 0
+ for _, entry := range entries {
+ if !entry.IsDir() && ms.isMaildirMessage(entry.Name()) {
+ count++
+ }
+ }
+ return count
+}
+
+// isMaildirMessage checks if a filename looks like a maildir message
+func (ms *Server) isMaildirMessage(filename string) bool {
+ // Basic check for maildir message format
+ return strings.Contains(filename, ".") && !strings.HasPrefix(filename, ".")
+}
+
+// listMessages lists messages in a specific folder with filtering and pagination
+func (ms *Server) listMessages(maildirPath, folder string, limit, offset int, filters map[string]interface{}) ([]MessageInfo, int, error) {
+ folderPath := filepath.Join(maildirPath, folder)
+ if folder == "INBOX" && maildirPath == folderPath {
+ // Handle case where INBOX might be the root maildir
+ folderPath = maildirPath
+ }
+
+ var allMessages []MessageInfo
+
+ // Scan cur and new directories
+ for _, subdir := range []string{"cur", "new"} {
+ dirPath := filepath.Join(folderPath, subdir)
+ messages, err := ms.scanMessagesInDir(dirPath, folder, subdir == "new")
+ if err != nil {
+ continue // Skip if directory doesn't exist or can't be read
+ }
+ allMessages = append(allMessages, messages...)
+ }
+
+ // Apply filters
+ filteredMessages := ms.applyMessageFilters(allMessages, filters)
+
+ // Sort by date (newest first)
+ sort.Slice(filteredMessages, func(i, j int) bool {
+ return filteredMessages[i].Date.After(filteredMessages[j].Date)
+ })
+
+ total := len(filteredMessages)
+
+ // Apply pagination
+ start := offset
+ if start >= total {
+ return []MessageInfo{}, total, nil
+ }
+
+ end := start + limit
+ if end > total {
+ end = total
+ }
+
+ return filteredMessages[start:end], total, nil
+}
+
+// scanMessagesInDir scans messages in a specific directory
+func (ms *Server) scanMessagesInDir(dirPath, folder string, isNew bool) ([]MessageInfo, error) {
+ entries, err := os.ReadDir(dirPath)
+ if err != nil {
+ return nil, err
+ }
+
+ var messages []MessageInfo
+ for _, entry := range entries {
+ if entry.IsDir() || !ms.isMaildirMessage(entry.Name()) {
+ continue
+ }
+
+ messagePath := filepath.Join(dirPath, entry.Name())
+ message, err := ms.parseMessageInfo(messagePath, folder, entry.Name(), isNew)
+ if err != nil {
+ continue // Skip malformed messages
+ }
+
+ messages = append(messages, message)
+ }
+
+ return messages, nil
+}
+
+// parseMessageInfo parses basic message information from a maildir message file
+func (ms *Server) parseMessageInfo(messagePath, folder, filename string, isNew bool) (MessageInfo, error) {
+ file, err := os.Open(messagePath)
+ if err != nil {
+ return MessageInfo{}, err
+ }
+ defer file.Close()
+
+ // Get file info for size
+ fileInfo, err := file.Stat()
+ if err != nil {
+ return MessageInfo{}, err
+ }
+
+ // Parse email headers
+ msg, err := mail.ReadMessage(file)
+ if err != nil {
+ return MessageInfo{}, err
+ }
+
+ // Extract flags from filename
+ flags := ms.parseMaildirFlags(filename, isNew)
+
+ // Parse date
+ dateStr := msg.Header.Get("Date")
+ date, err := mail.ParseDate(dateStr)
+ if err != nil {
+ date = fileInfo.ModTime() // Fallback to file modification time
+ }
+
+ // Extract references for threading
+ references := ms.parseReferences(msg.Header.Get("References"))
+
+ messageInfo := MessageInfo{
+ ID: ms.generateMessageID(messagePath),
+ Filename: filename,
+ Subject: msg.Header.Get("Subject"),
+ From: msg.Header.Get("From"),
+ To: msg.Header.Get("To"),
+ Date: date,
+ Flags: flags,
+ Folder: folder,
+ Size: fileInfo.Size(),
+ MessageID: msg.Header.Get("Message-ID"),
+ InReplyTo: msg.Header.Get("In-Reply-To"),
+ References: references,
+ Headers: map[string]string{
+ "Subject": msg.Header.Get("Subject"),
+ "From": msg.Header.Get("From"),
+ "To": msg.Header.Get("To"),
+ "Date": dateStr,
+ "Message-ID": msg.Header.Get("Message-ID"),
+ },
+ }
+
+ return messageInfo, nil
+}
+
+// parseMaildirFlags parses maildir flags from filename
+func (ms *Server) parseMaildirFlags(filename string, isNew bool) []string {
+ var flags []string
+
+ if isNew {
+ flags = append(flags, "New")
+ }
+
+ // Parse standard maildir flags from filename
+ // Format: unique_name:2,flags
+ parts := strings.Split(filename, ":2,")
+ if len(parts) == 2 {
+ flagStr := parts[1]
+ for _, flag := range flagStr {
+ switch flag {
+ case 'S':
+ flags = append(flags, "Seen")
+ case 'R':
+ flags = append(flags, "Replied")
+ case 'F':
+ flags = append(flags, "Flagged")
+ case 'T':
+ flags = append(flags, "Trashed")
+ case 'D':
+ flags = append(flags, "Draft")
+ case 'P':
+ flags = append(flags, "Passed")
+ }
+ }
+ } else if !isNew {
+ // If no flags but in cur directory, assume Seen
+ flags = append(flags, "Seen")
+ }
+
+ return flags
+}
+
+// parseReferences parses References header for email threading
+func (ms *Server) parseReferences(referencesStr string) []string {
+ if referencesStr == "" {
+ return nil
+ }
+
+ // Simple parsing - split by whitespace and extract message IDs
+ re := regexp.MustCompile(`<[^>]+>`)
+ matches := re.FindAllString(referencesStr, -1)
+
+ var references []string
+ for _, match := range matches {
+ references = append(references, match)
+ }
+
+ return references
+}
+
+// generateMessageID generates a unique ID for a message based on its path
+func (ms *Server) generateMessageID(messagePath string) string {
+ // Use the filename without path as the ID
+ return filepath.Base(messagePath)
+}
+
+// applyMessageFilters applies various filters to messages
+func (ms *Server) applyMessageFilters(messages []MessageInfo, filters map[string]interface{}) []MessageInfo {
+ var filtered []MessageInfo
+
+ for _, msg := range messages {
+ if ms.messageMatchesFilters(msg, filters) {
+ filtered = append(filtered, msg)
+ }
+ }
+
+ return filtered
+}
+
+// messageMatchesFilters checks if a message matches the given filters
+func (ms *Server) messageMatchesFilters(msg MessageInfo, filters map[string]interface{}) bool {
+ // Date range filter
+ if dateFromStr, ok := filters["date_from"].(string); ok {
+ if dateFrom, err := time.Parse("2006-01-02", dateFromStr); err == nil {
+ if msg.Date.Before(dateFrom) {
+ return false
+ }
+ }
+ }
+
+ if dateToStr, ok := filters["date_to"].(string); ok {
+ if dateTo, err := time.Parse("2006-01-02", dateToStr); err == nil {
+ if msg.Date.After(dateTo.Add(24 * time.Hour)) {
+ return false
+ }
+ }
+ }
+
+ // Sender filter
+ if sender, ok := filters["sender"].(string); ok {
+ if !strings.Contains(strings.ToLower(msg.From), strings.ToLower(sender)) {
+ return false
+ }
+ }
+
+ // Subject filter
+ if subjectContains, ok := filters["subject_contains"].(string); ok {
+ if !strings.Contains(strings.ToLower(msg.Subject), strings.ToLower(subjectContains)) {
+ return false
+ }
+ }
+
+ // Unread only filter
+ if unreadOnly, ok := filters["unread_only"].(bool); ok && unreadOnly {
+ hasNewFlag := false
+ for _, flag := range msg.Flags {
+ if flag == "New" {
+ hasNewFlag = true
+ break
+ }
+ }
+ if !hasNewFlag {
+ return false
+ }
+ }
+
+ return true
+}
+
+// readMessage reads a full message with content
+func (ms *Server) readMessage(maildirPath, messageID string, includeHTML, includeHeaders, sanitizeContent bool) (*Message, error) {
+ // Find the message file
+ messagePath, err := ms.findMessagePath(maildirPath, messageID)
+ if err != nil {
+ return nil, err
+ }
+
+ file, err := os.Open(messagePath)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ // Parse the message
+ msg, err := mail.ReadMessage(file)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get file info for message info
+ _, err = file.Stat()
+ if err != nil {
+ return nil, err
+ }
+
+ // Parse basic message info
+ folder := ms.extractFolderFromPath(messagePath)
+ isNew := strings.Contains(messagePath, "/new/")
+ messageInfo, err := ms.parseMessageInfo(messagePath, folder, filepath.Base(messagePath), isNew)
+ if err != nil {
+ return nil, err
+ }
+
+ // Extract message body
+ body, htmlBody, err := ms.extractMessageBody(msg, includeHTML)
+ if err != nil {
+ return nil, err
+ }
+
+ if sanitizeContent {
+ body = ms.sanitizeContent(body)
+ if htmlBody != "" {
+ htmlBody = ms.sanitizeContent(htmlBody)
+ }
+ }
+
+ message := &Message{
+ MessageInfo: messageInfo,
+ Body: body,
+ HTMLBody: htmlBody,
+ }
+
+ return message, nil
+}
+
+// findMessagePath finds the full path to a message file by ID
+func (ms *Server) findMessagePath(maildirPath, messageID string) (string, error) {
+ var foundPath string
+
+ err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+
+ if !info.IsDir() && filepath.Base(path) == messageID {
+ foundPath = path
+ return filepath.SkipAll
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return "", err
+ }
+
+ if foundPath == "" {
+ return "", fmt.Errorf("message not found: %s", messageID)
+ }
+
+ return foundPath, nil
+}
+
+// extractFolderFromPath extracts folder name from message path
+func (ms *Server) extractFolderFromPath(messagePath string) string {
+ // Navigate up from the message file to find the folder
+ dir := filepath.Dir(messagePath) // cur or new directory
+ dir = filepath.Dir(dir) // folder directory
+ return filepath.Base(dir)
+}
+
+// extractMessageBody extracts plain text and HTML body from message
+func (ms *Server) extractMessageBody(msg *mail.Message, includeHTML bool) (string, string, error) {
+ contentType := msg.Header.Get("Content-Type")
+
+ if contentType == "" {
+ // Plain text message
+ body, err := io.ReadAll(msg.Body)
+ if err != nil {
+ return "", "", err
+ }
+ return string(body), "", nil
+ }
+
+ mediaType, params, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ // Fallback to reading as plain text
+ body, err := io.ReadAll(msg.Body)
+ if err != nil {
+ return "", "", err
+ }
+ return string(body), "", nil
+ }
+
+ if strings.HasPrefix(mediaType, "text/plain") {
+ body, err := io.ReadAll(msg.Body)
+ if err != nil {
+ return "", "", err
+ }
+ return string(body), "", nil
+ }
+
+ if strings.HasPrefix(mediaType, "text/html") {
+ body, err := io.ReadAll(msg.Body)
+ if err != nil {
+ return "", "", err
+ }
+ htmlBody := string(body)
+ plainBody := ms.htmlToPlainText(htmlBody)
+ if includeHTML {
+ return plainBody, htmlBody, nil
+ }
+ return plainBody, "", nil
+ }
+
+ if strings.HasPrefix(mediaType, "multipart/") {
+ return ms.extractMultipartBody(msg.Body, params["boundary"], includeHTML)
+ }
+
+ // Fallback to reading as plain text
+ body, err := io.ReadAll(msg.Body)
+ if err != nil {
+ return "", "", err
+ }
+ return string(body), "", nil
+}
+
+// extractMultipartBody extracts body from multipart message
+func (ms *Server) extractMultipartBody(body io.Reader, boundary string, includeHTML bool) (string, string, error) {
+ mr := multipart.NewReader(body, boundary)
+
+ var plainBody, htmlBody string
+
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return "", "", err
+ }
+
+ contentType := part.Header.Get("Content-Type")
+ mediaType, _, _ := mime.ParseMediaType(contentType)
+
+ partBody, err := io.ReadAll(part)
+ if err != nil {
+ continue
+ }
+
+ switch mediaType {
+ case "text/plain":
+ plainBody = string(partBody)
+ case "text/html":
+ htmlBody = string(partBody)
+ }
+ }
+
+ // If we only have HTML, convert it to plain text
+ if plainBody == "" && htmlBody != "" {
+ plainBody = ms.htmlToPlainText(htmlBody)
+ }
+
+ if includeHTML {
+ return plainBody, htmlBody, nil
+ }
+ return plainBody, "", nil
+}
+
+// htmlToPlainText converts HTML to plain text (simple implementation)
+func (ms *Server) htmlToPlainText(html string) string {
+ // Simple HTML to text conversion - remove tags
+ re := regexp.MustCompile(`<[^>]*>`)
+ text := re.ReplaceAllString(html, "")
+
+ // Decode common HTML entities
+ text = strings.ReplaceAll(text, "&", "&")
+ text = strings.ReplaceAll(text, "<", "<")
+ text = strings.ReplaceAll(text, ">", ">")
+ text = strings.ReplaceAll(text, """, "\"")
+ text = strings.ReplaceAll(text, "'", "'")
+ text = strings.ReplaceAll(text, " ", " ")
+
+ return strings.TrimSpace(text)
+}
+
+// sanitizeContent sanitizes message content (basic implementation)
+func (ms *Server) sanitizeContent(content string) string {
+ // Basic PII masking - this is a simple implementation
+ // In production, you'd want more sophisticated PII detection
+
+ // Mask phone numbers
+ phoneRegex := regexp.MustCompile(`\b\d{3}-\d{3}-\d{4}\b`)
+ content = phoneRegex.ReplaceAllString(content, "XXX-XXX-XXXX")
+
+ // Mask SSNs
+ ssnRegex := regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`)
+ content = ssnRegex.ReplaceAllString(content, "XXX-XX-XXXX")
+
+ return content
+}
+
+// searchMessages performs full-text search across messages
+func (ms *Server) searchMessages(maildirPath, query string, limit int, filters map[string]interface{}) ([]MessageInfo, error) {
+ var results []MessageInfo
+
+ // Simple implementation - scan all messages and search in subject/from/body
+ err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() || !ms.isMaildirMessage(info.Name()) {
+ return nil
+ }
+
+ // Skip if not in cur or new directory
+ if !strings.Contains(path, "/cur/") && !strings.Contains(path, "/new/") {
+ return nil
+ }
+
+ matches, messageInfo := ms.searchInMessage(path, query)
+ if matches {
+ results = append(results, messageInfo)
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Sort by date (newest first)
+ sort.Slice(results, func(i, j int) bool {
+ return results[i].Date.After(results[j].Date)
+ })
+
+ // Apply limit
+ if len(results) > limit {
+ results = results[:limit]
+ }
+
+ return results, nil
+}
+
+// searchInMessage searches for query in a specific message
+func (ms *Server) searchInMessage(messagePath, query string) (bool, MessageInfo) {
+ file, err := os.Open(messagePath)
+ if err != nil {
+ return false, MessageInfo{}
+ }
+ defer file.Close()
+
+ folder := ms.extractFolderFromPath(messagePath)
+ isNew := strings.Contains(messagePath, "/new/")
+ messageInfo, err := ms.parseMessageInfo(messagePath, folder, filepath.Base(messagePath), isNew)
+ if err != nil {
+ return false, MessageInfo{}
+ }
+
+ queryLower := strings.ToLower(query)
+
+ // Search in subject, from, to
+ if strings.Contains(strings.ToLower(messageInfo.Subject), queryLower) ||
+ strings.Contains(strings.ToLower(messageInfo.From), queryLower) ||
+ strings.Contains(strings.ToLower(messageInfo.To), queryLower) {
+ return true, messageInfo
+ }
+
+ // Search in message body (basic implementation)
+ file.Seek(0, 0)
+ msg, err := mail.ReadMessage(file)
+ if err != nil {
+ return false, messageInfo
+ }
+
+ body, _, err := ms.extractMessageBody(msg, false)
+ if err != nil {
+ return false, messageInfo
+ }
+
+ if strings.Contains(strings.ToLower(body), queryLower) {
+ return true, messageInfo
+ }
+
+ return false, messageInfo
+}
+
+// getThread retrieves email thread for a message
+func (ms *Server) getThread(maildirPath, messageID string, maxDepth int) ([]MessageInfo, error) {
+ // Find the starting message
+ messagePath, err := ms.findMessagePath(maildirPath, messageID)
+ if err != nil {
+ return nil, err
+ }
+
+ folder := ms.extractFolderFromPath(messagePath)
+ isNew := strings.Contains(messagePath, "/new/")
+ startMessage, err := ms.parseMessageInfo(messagePath, folder, filepath.Base(messagePath), isNew)
+ if err != nil {
+ return nil, err
+ }
+
+ // Simple threading implementation - find messages with matching Message-ID, In-Reply-To, References
+ thread := []MessageInfo{startMessage}
+
+ // This is a simplified implementation
+ // A full implementation would build a proper thread tree
+
+ return thread, nil
+}
+
+// analyzeContacts analyzes contact information from messages
+func (ms *Server) analyzeContacts(maildirPath string, filters map[string]interface{}) ([]ContactInfo, error) {
+ contactMap := make(map[string]*ContactInfo)
+
+ err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() || !ms.isMaildirMessage(info.Name()) {
+ return nil
+ }
+
+ // Skip if not in cur or new directory
+ if !strings.Contains(path, "/cur/") && !strings.Contains(path, "/new/") {
+ return nil
+ }
+
+ folder := ms.extractFolderFromPath(path)
+ isNew := strings.Contains(path, "/new/")
+ messageInfo, err := ms.parseMessageInfo(path, folder, filepath.Base(path), isNew)
+ if err != nil {
+ return nil
+ }
+
+ // Process From address
+ if messageInfo.From != "" {
+ ms.processContact(contactMap, messageInfo.From, messageInfo.Date, false)
+ }
+
+ // Process To addresses
+ if messageInfo.To != "" {
+ toAddresses := strings.Split(messageInfo.To, ",")
+ for _, addr := range toAddresses {
+ ms.processContact(contactMap, strings.TrimSpace(addr), messageInfo.Date, true)
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Convert map to slice and sort by message count
+ var contacts []ContactInfo
+ for _, contact := range contactMap {
+ contacts = append(contacts, *contact)
+ }
+
+ sort.Slice(contacts, func(i, j int) bool {
+ return contacts[i].Count > contacts[j].Count
+ })
+
+ return contacts, nil
+}
+
+// processContact processes a contact address for analysis
+func (ms *Server) processContact(contactMap map[string]*ContactInfo, address string, date time.Time, isOutgoing bool) {
+ // Extract email address from "Name <email>" format
+ addr, err := mail.ParseAddress(address)
+ if err != nil {
+ // Fallback for simple email addresses
+ addr = &mail.Address{Address: address}
+ }
+
+ email := strings.ToLower(addr.Address)
+ if email == "" {
+ return
+ }
+
+ contact, exists := contactMap[email]
+ if !exists {
+ contact = &ContactInfo{
+ Email: email,
+ Name: addr.Name,
+ Count: 0,
+ FirstSeen: date,
+ LastSeen: date,
+ IsOutgoing: isOutgoing,
+ }
+ contactMap[email] = contact
+ }
+
+ contact.Count++
+ if date.After(contact.LastSeen) {
+ contact.LastSeen = date
+ }
+ if date.Before(contact.FirstSeen) {
+ contact.FirstSeen = date
+ }
+
+ // Update name if we have a better one
+ if addr.Name != "" && contact.Name == "" {
+ contact.Name = addr.Name
+ }
+}
+
+// getStatistics generates maildir usage statistics
+func (ms *Server) getStatistics(maildirPath string, filters map[string]interface{}) (map[string]interface{}, error) {
+ stats := make(map[string]interface{})
+
+ // Basic statistics implementation
+ totalMessages := 0
+ totalSize := int64(0)
+ folderCounts := make(map[string]int)
+
+ err := filepath.Walk(maildirPath, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() || !ms.isMaildirMessage(info.Name()) {
+ return nil
+ }
+
+ // Skip if not in cur or new directory
+ if !strings.Contains(path, "/cur/") && !strings.Contains(path, "/new/") {
+ return nil
+ }
+
+ totalMessages++
+ totalSize += info.Size()
+
+ folder := ms.extractFolderFromPath(path)
+ folderCounts[folder]++
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ stats["total_messages"] = totalMessages
+ stats["total_size_bytes"] = totalSize
+ stats["folder_counts"] = folderCounts
+
+ return stats, nil
+}
\ No newline at end of file
pkg/memory/server.go
@@ -51,18 +51,105 @@ func New(memoryFile string) *Server {
},
}
- // Load existing data
- memoryServer.loadGraph()
-
// Register all memory tools, prompts, resources, and roots
memoryServer.registerTools()
memoryServer.registerPrompts()
memoryServer.registerResources()
memoryServer.registerRoots()
+ // Set up dynamic resource listing
+ memoryServer.setupResourceHandling()
+
return memoryServer
}
+// setupResourceHandling configures custom resource handling for lazy loading
+func (ms *Server) setupResourceHandling() {
+ // Custom handler that calls our ListResources method
+ customListResourcesHandler := func(req mcp.JSONRPCRequest) mcp.JSONRPCResponse {
+ resources := ms.ListResources()
+ result := mcp.ListResourcesResult{Resources: resources}
+ id := req.ID
+ bytes, _ := json.Marshal(result)
+ rawMsg := json.RawMessage(bytes)
+ resultBytes := &rawMsg
+ return mcp.JSONRPCResponse{
+ JSONRPC: "2.0",
+ ID: id,
+ Result: resultBytes,
+ }
+ }
+
+ handlers := make(map[string]func(mcp.JSONRPCRequest) mcp.JSONRPCResponse)
+ handlers["resources/list"] = customListResourcesHandler
+ ms.SetCustomRequestHandler(handlers)
+}
+
+// ensureGraphLoaded loads the knowledge graph only when needed
+func (ms *Server) ensureGraphLoaded() error {
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+
+ // Check if graph is already loaded
+ if ms.graph.Entities == nil {
+ ms.graph.Entities = make(map[string]*Entity)
+ ms.graph.Relations = make(map[string]Relation)
+
+ // Load from file if it exists
+ return ms.loadGraphInternal()
+ }
+ return nil
+}
+
+// ListResources dynamically discovers and returns memory resources
+func (ms *Server) ListResources() []mcp.Resource {
+ var resources []mcp.Resource
+
+ // Load graph only when resources are actually requested
+ if err := ms.ensureGraphLoaded(); err != nil {
+ return resources // Return empty on error
+ }
+
+ ms.mu.RLock()
+ defer ms.mu.RUnlock()
+
+ // Create resources for entities (limited to first 500 for performance)
+ limit := 500
+ count := 0
+ for entityName, entity := range ms.graph.Entities {
+ if count >= limit {
+ break
+ }
+
+ // Create memory:// URI: memory://entity/name
+ memoryURI := fmt.Sprintf("memory://entity/%s", entityName)
+
+ // Create resource definition
+ resource := mcp.Resource{
+ URI: memoryURI,
+ Name: fmt.Sprintf("Entity: %s", entityName),
+ Description: fmt.Sprintf("Knowledge graph entity: %s (type: %s, %d observations)", entityName, entity.EntityType, len(entity.Observations)),
+ MimeType: "application/json",
+ }
+ resources = append(resources, resource)
+ count++
+ }
+
+ // Add relations as a special resource if any exist
+ if len(ms.graph.Relations) > 0 {
+ relationsURI := "memory://relations/all"
+ relationsResource := mcp.Resource{
+ URI: relationsURI,
+ Name: "All Relations",
+ Description: fmt.Sprintf("All relations in the knowledge graph (%d relations)", len(ms.graph.Relations)),
+ MimeType: "application/json",
+ }
+ resources = append(resources, relationsResource)
+ }
+
+ return resources
+}
+
// registerTools registers all Memory tools with the server
func (ms *Server) registerTools() {
ms.RegisterTool("create_entities", ms.HandleCreateEntities)
@@ -98,55 +185,20 @@ func (ms *Server) registerPrompts() {
ms.RegisterPrompt(knowledgePrompt, ms.HandleKnowledgeQueryPrompt)
}
-// registerResources registers memory graph entities as resources (memory:// URIs)
+// registerResources sets up resource handling (lazy loading)
func (ms *Server) registerResources() {
- ms.mu.RLock()
- defer ms.mu.RUnlock()
-
- // Register each entity in the knowledge graph as a memory:// resource
- for entityName, entity := range ms.graph.Entities {
- // Create memory:// URI: memory://entity/name
- memoryURI := fmt.Sprintf("memory://entity/%s", entityName)
-
- // Create resource definition
- resource := mcp.Resource{
- URI: memoryURI,
- Name: fmt.Sprintf("Entity: %s", entityName),
- Description: fmt.Sprintf("Knowledge graph entity: %s (type: %s, %d observations)", entityName, entity.EntityType, len(entity.Observations)),
- MimeType: "application/json",
- }
-
- // Register resource with handler
- ms.RegisterResourceWithDefinition(resource, ms.HandleMemoryResource)
- }
-
- // Register relations as a special resource
- if len(ms.graph.Relations) > 0 {
- relationsURI := "memory://relations/all"
- relationsResource := mcp.Resource{
- URI: relationsURI,
- Name: "All Relations",
- Description: fmt.Sprintf("All relations in the knowledge graph (%d relations)", len(ms.graph.Relations)),
- MimeType: "application/json",
- }
- ms.RegisterResourceWithDefinition(relationsResource, ms.HandleMemoryResource)
- }
+ // Register pattern-based memory resource handlers instead of discovering all entities
+ // This avoids loading the entire knowledge graph into memory at startup
+ ms.Server.RegisterResource("memory://", ms.HandleMemoryResource)
}
-// registerRoots registers memory knowledge graph as a root
+// registerRoots registers memory knowledge graph as a root (without live statistics)
func (ms *Server) registerRoots() {
- ms.mu.RLock()
- defer ms.mu.RUnlock()
-
// Create memory:// URI for the knowledge graph
memoryURI := "memory://graph"
- // Generate statistics for the root description
- entityCount := len(ms.graph.Entities)
- relationCount := len(ms.graph.Relations)
-
- // Create a descriptive name with statistics
- rootName := fmt.Sprintf("Knowledge Graph (%d entities, %d relations)", entityCount, relationCount)
+ // Create a simple name without requiring graph loading
+ rootName := "Knowledge Graph"
root := mcp.NewRoot(memoryURI, rootName)
ms.RegisterRoot(root)
@@ -373,6 +425,11 @@ func (ms *Server) ListTools() []mcp.Tool {
// Tool handlers
func (ms *Server) HandleCreateEntities(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ // Ensure graph is loaded before accessing
+ if err := ms.ensureGraphLoaded(); err != nil {
+ return mcp.NewToolError(fmt.Sprintf("Failed to load graph: %v", err)), nil
+ }
+
ms.mu.Lock()
defer ms.mu.Unlock()
@@ -751,6 +808,11 @@ func (ms *Server) HandleDeleteRelations(req mcp.CallToolRequest) (mcp.CallToolRe
}
func (ms *Server) HandleReadGraph(req mcp.CallToolRequest) (mcp.CallToolResult, error) {
+ // Ensure graph is loaded before accessing
+ if err := ms.ensureGraphLoaded(); err != nil {
+ return mcp.NewToolError(fmt.Sprintf("Failed to load graph: %v", err)), nil
+ }
+
ms.mu.RLock()
defer ms.mu.RUnlock()
@@ -988,7 +1050,7 @@ Let me start by searching the knowledge graph for relevant information:`, query)
// Helper methods
-func (ms *Server) loadGraph() error {
+func (ms *Server) loadGraphInternal() error {
if _, err := os.Stat(ms.memoryFile); os.IsNotExist(err) {
// File doesn't exist, start with empty graph
return nil
@@ -1018,11 +1080,6 @@ func (ms *Server) saveGraph() error {
return err
}
- // Re-register resources and roots after saving to reflect changes
- go func() {
- ms.registerResources()
- ms.registerRoots()
- }()
-
+ // No need to re-register resources since they're discovered dynamically
return nil
}
Makefile
@@ -11,7 +11,7 @@ BINDIR = bin
INSTALLDIR = /usr/local/bin
# Server binaries
-SERVERS = git filesystem fetch memory sequential-thinking time
+SERVERS = git filesystem fetch memory sequential-thinking time maildir
BINARIES = $(addprefix $(BINDIR)/mcp-,$(SERVERS))
# Build flags
@@ -97,6 +97,7 @@ fetch: $(BINDIR)/mcp-fetch ## Build fetch server only
memory: $(BINDIR)/mcp-memory ## Build memory server only
sequential-thinking: $(BINDIR)/mcp-sequential-thinking ## Build sequential-thinking server only
time: $(BINDIR)/mcp-time ## Build time server only
+maildir: $(BINDIR)/mcp-maildir ## Build maildir server only
help: ## Show this help message
@echo "Go MCP Servers - Available targets:"
@@ -104,4 +105,4 @@ help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo "Individual servers:"
- @echo " git, filesystem, fetch, memory, sequential-thinking, time"
+ @echo " git, filesystem, fetch, memory, sequential-thinking, time, maildir"