Commit 5d440dd
Changed files (13)
pkg
fetch
filesystem
git
htmlprocessor
memory
pkg/fetch/server.go
@@ -74,7 +74,7 @@ func (fs *Server) registerPrompts() {
},
},
}
-
+
fs.RegisterPrompt(fetchPrompt, fs.HandleFetchPrompt)
}
@@ -214,7 +214,7 @@ func (fs *Server) HandleFetchPrompt(req mcp.GetPromptRequest) (mcp.GetPromptResu
Let me use the fetch tool to retrieve and process the content:`, url)
messages = append(messages, mcp.PromptMessage{
- Role: "assistant",
+ Role: "assistant",
Content: mcp.NewTextContent(assistantContent),
})
@@ -323,4 +323,3 @@ func isHTMLContent(content, contentType string) bool {
return strings.Contains(strings.ToLower(prefix), "<html")
}
-
pkg/filesystem/server.go
@@ -83,7 +83,7 @@ func (fs *Server) registerPrompts() {
},
},
}
-
+
fs.RegisterPrompt(editFilePrompt, fs.HandleEditFilePrompt)
}
@@ -102,7 +102,7 @@ func (fs *Server) discoverFilesInDirectory(dirPath string) {
if err != nil {
return nil // Skip files with errors
}
-
+
// Skip directories
if info.IsDir() {
// Skip .git and other hidden directories
@@ -111,28 +111,28 @@ func (fs *Server) discoverFilesInDirectory(dirPath string) {
}
return nil
}
-
+
// Skip hidden files and certain file types
if strings.HasPrefix(info.Name(), ".") {
return nil
}
-
+
// Skip files in .git directories (additional safety)
if strings.Contains(path, "/.git/") {
return nil
}
-
+
// Skip binary files and very large files
if info.Size() > 10*1024*1024 { // 10MB limit
return nil
}
-
+
// Create file:// URI
fileURI := "file://" + path
-
+
// Determine MIME type based on extension
mimeType := getMimeTypeFromPath(path)
-
+
// Create resource definition
resource := mcp.Resource{
URI: fileURI,
@@ -140,10 +140,10 @@ func (fs *Server) discoverFilesInDirectory(dirPath string) {
Description: fmt.Sprintf("File: %s (%d bytes)", path, info.Size()),
MimeType: mimeType,
}
-
+
// Register resource with handler
fs.RegisterResourceWithDefinition(resource, fs.HandleFileResource)
-
+
return nil
})
}
@@ -154,21 +154,21 @@ func (fs *Server) HandleFileResource(req mcp.ReadResourceRequest) (mcp.ReadResou
if !strings.HasPrefix(req.URI, "file://") {
return mcp.ReadResourceResult{}, fmt.Errorf("invalid file URI: %s", req.URI)
}
-
+
filePath := req.URI[7:] // Remove "file://" prefix
-
+
// Validate path is within allowed directories
validPath, err := fs.validatePath(filePath)
if err != nil {
return mcp.ReadResourceResult{}, fmt.Errorf("access denied: %v", err)
}
-
+
// Read file content
content, err := os.ReadFile(validPath)
if err != nil {
return mcp.ReadResourceResult{}, fmt.Errorf("failed to read file: %v", err)
}
-
+
// Determine if content is binary or text
if isBinaryContent(content) {
// For binary files, return base64 encoded content
@@ -181,7 +181,7 @@ func (fs *Server) HandleFileResource(req mcp.ReadResourceRequest) (mcp.ReadResou
},
}, nil
}
-
+
// For text files, return the content
return mcp.ReadResourceResult{
Contents: []mcp.Content{
@@ -228,13 +228,13 @@ func isBinaryContent(content []byte) bool {
if len(content) > 512 {
checkBytes = content[:512]
}
-
+
for _, b := range checkBytes {
if b == 0 {
return true
}
}
-
+
return false
}
pkg/git/server.go
@@ -26,9 +26,10 @@ func New(repoPath string) *Server {
repoPath: repoPath,
}
- // Register all git tools and prompts
+ // Register all git tools, prompts, and resources
gitServer.registerTools()
gitServer.registerPrompts()
+ gitServer.registerResources()
return gitServer
}
@@ -72,10 +73,340 @@ func (gs *Server) registerPrompts() {
},
},
}
-
+
gs.RegisterPrompt(commitPrompt, gs.HandleCommitMessagePrompt)
}
+// registerResources registers git repository resources (git:// URIs)
+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)
+ }
+}
+
+// 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) {
+ output, err := gs.runGitCommand(gs.repoPath, "branch", "--show-current")
+ if err != nil {
+ return "", err
+ }
+ branch := strings.TrimSpace(output)
+ if branch == "" {
+ // Fallback for detached HEAD
+ branch = "HEAD"
+ }
+ return branch, nil
+}
+
+// getTrackedFiles gets list of tracked files in the repository
+func (gs *Server) getTrackedFiles() ([]string, error) {
+ output, err := gs.runGitCommand(gs.repoPath, "ls-files")
+ if err != nil {
+ return nil, err
+ }
+
+ if output == "" {
+ return []string{}, nil
+ }
+
+ files := strings.Split(output, "\n")
+ var filteredFiles []string
+
+ for _, file := range files {
+ file = strings.TrimSpace(file)
+ if file != "" {
+ // Skip hidden files and certain patterns
+ if !strings.HasPrefix(file, ".") {
+ filteredFiles = append(filteredFiles, file)
+ }
+ }
+ }
+
+ 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) {
+ output, err := gs.runGitCommand(gs.repoPath, "branch", "--format=%(refname:short)")
+ if err != nil {
+ return nil, err
+ }
+
+ if output == "" {
+ return []string{}, nil
+ }
+
+ branches := strings.Split(output, "\n")
+ var filteredBranches []string
+
+ for _, branch := range branches {
+ branch = strings.TrimSpace(branch)
+ if branch != "" {
+ filteredBranches = append(filteredBranches, branch)
+ }
+ }
+
+ return filteredBranches, nil
+}
+
+// Commit represents a git commit
+type Commit struct {
+ Hash string
+ Message string
+ Author string
+ Date string
+}
+
+// getRecentCommits gets recent git commits
+func (gs *Server) getRecentCommits(count int) ([]Commit, error) {
+ output, err := gs.runGitCommand(gs.repoPath, "log", "--format=%H|%s|%an|%ad", "--date=short", "-n", strconv.Itoa(count))
+ if err != nil {
+ return nil, err
+ }
+
+ if output == "" {
+ return []Commit{}, nil
+ }
+
+ lines := strings.Split(output, "\n")
+ var commits []Commit
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ parts := strings.Split(line, "|")
+ if len(parts) >= 4 {
+ commits = append(commits, Commit{
+ Hash: parts[0],
+ Message: parts[1],
+ Author: parts[2],
+ Date: parts[3],
+ })
+ }
+ }
+
+ return commits, nil
+}
+
+// HandleGitResource handles git:// resource requests
+func (gs *Server) HandleGitResource(req mcp.ReadResourceRequest) (mcp.ReadResourceResult, error) {
+ // Parse git:// URI: git://repo/branch/path or git://repo/commit/hash or git://repo/branch/name
+ if !strings.HasPrefix(req.URI, "git://") {
+ return mcp.ReadResourceResult{}, fmt.Errorf("invalid git URI: %s", req.URI)
+ }
+
+ uriPath := req.URI[6:] // Remove "git://" prefix
+ parts := strings.Split(uriPath, "/")
+
+ if len(parts) < 3 {
+ return mcp.ReadResourceResult{}, fmt.Errorf("invalid git URI format: %s", req.URI)
+ }
+
+ repoPath := parts[0]
+ resourceType := parts[1]
+ resourcePath := strings.Join(parts[2:], "/")
+
+ // Validate repository path
+ if repoPath != gs.repoPath {
+ return mcp.ReadResourceResult{}, fmt.Errorf("access denied: repository path mismatch")
+ }
+
+ switch resourceType {
+ case "branch":
+ return gs.handleBranchResource(resourcePath)
+ case "commit":
+ return gs.handleCommitResource(resourcePath)
+ default:
+ // Treat as file path with branch
+ return gs.handleFileResource(resourceType, resourcePath)
+ }
+}
+
+// handleFileResource handles git file resources
+func (gs *Server) handleFileResource(branch, filePath string) (mcp.ReadResourceResult, error) {
+ // Use git show to get file content from specific branch
+ gitPath := fmt.Sprintf("%s:%s", branch, filePath)
+ output, err := gs.runGitCommand(gs.repoPath, "show", gitPath)
+ if err != nil {
+ return mcp.ReadResourceResult{}, fmt.Errorf("failed to read git file: %v", err)
+ }
+
+ return mcp.ReadResourceResult{
+ Contents: []mcp.Content{
+ mcp.NewTextContent(output),
+ },
+ }, nil
+}
+
+// handleBranchResource handles git branch resources
+func (gs *Server) handleBranchResource(branchName string) (mcp.ReadResourceResult, error) {
+ // Get branch information
+ output, err := gs.runGitCommand(gs.repoPath, "log", "--oneline", "-n", "5", branchName)
+ if err != nil {
+ return mcp.ReadResourceResult{}, fmt.Errorf("failed to get branch info: %v", err)
+ }
+
+ content := fmt.Sprintf("Branch: %s\n\nRecent commits:\n%s", branchName, output)
+
+ return mcp.ReadResourceResult{
+ Contents: []mcp.Content{
+ mcp.NewTextContent(content),
+ },
+ }, nil
+}
+
+// handleCommitResource handles git commit resources
+func (gs *Server) handleCommitResource(commitHash string) (mcp.ReadResourceResult, error) {
+ // Get commit details
+ output, err := gs.runGitCommand(gs.repoPath, "show", "--stat", commitHash)
+ if err != nil {
+ return mcp.ReadResourceResult{}, fmt.Errorf("failed to get commit info: %v", err)
+ }
+
+ return mcp.ReadResourceResult{
+ Contents: []mcp.Content{
+ mcp.NewTextContent(output),
+ },
+ }, nil
+}
+
+// Helper function to determine MIME type for git files
+func getGitMimeType(filePath string) string {
+ ext := strings.ToLower(filepath.Ext(filePath))
+ switch ext {
+ case ".go":
+ return "text/x-go"
+ case ".js":
+ return "text/javascript"
+ case ".ts":
+ return "text/typescript"
+ case ".py":
+ return "text/x-python"
+ case ".md":
+ return "text/markdown"
+ case ".json":
+ return "application/json"
+ case ".yaml", ".yml":
+ return "application/x-yaml"
+ case ".xml":
+ return "application/xml"
+ case ".html", ".htm":
+ return "text/html"
+ case ".css":
+ return "text/css"
+ case ".txt":
+ return "text/plain"
+ default:
+ return "text/plain"
+ }
+}
+
// ListTools returns all available Git tools
func (gs *Server) ListTools() []mcp.Tool {
return []mcp.Tool{
@@ -608,7 +939,7 @@ Please help me craft a well-structured commit message following conventional com
- Keep the subject line under 50 characters
- Use imperative mood ("add" not "added")
- Don't end subject line with a period
-- Include body if needed to explain what and why`,
+- Include body if needed to explain what and why`,
commitType, breakingPrefix, changes,
commitType,
func() string {
pkg/htmlprocessor/processor.go
@@ -15,7 +15,7 @@ type ContentExtractor struct {
// NewContentExtractor creates a new ContentExtractor with default settings
func NewContentExtractor() *ContentExtractor {
converter := md.NewConverter("", true, nil)
-
+
// Add custom rules to remove unwanted elements
converter.AddRules(
md.Rule{
@@ -81,7 +81,7 @@ func (e *ContentExtractor) ToMarkdown(html string) (string, error) {
// Clean up extra whitespace
lines := strings.Split(markdown, "\n")
var cleanLines []string
-
+
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" || (len(cleanLines) > 0 && cleanLines[len(cleanLines)-1] != "") {
@@ -95,4 +95,4 @@ func (e *ContentExtractor) ToMarkdown(html string) (string, error) {
}
return strings.Join(cleanLines, "\n"), nil
-}
\ No newline at end of file
+}
pkg/htmlprocessor/processor_test.go
@@ -95,8 +95,8 @@ func TestContentExtractor_ToMarkdown(t *testing.T) {
expected string
}{
{
- name: "basic formatting",
- html: `<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em> text.</p>`,
+ name: "basic formatting",
+ html: `<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em> text.</p>`,
expected: "# Title\n\nParagraph with **bold** and _italic_ text.",
},
{
@@ -114,8 +114,8 @@ func TestContentExtractor_ToMarkdown(t *testing.T) {
expected: "- First item\n- Second item\n\n1. Numbered first\n2. Numbered second",
},
{
- name: "links and code",
- html: `<p>Visit <a href="https://example.com">Example</a> for <code>code samples</code>.</p>`,
+ name: "links and code",
+ html: `<p>Visit <a href="https://example.com">Example</a> for <code>code samples</code>.</p>`,
expected: "Visit [Example](https://example.com) for `code samples`.",
},
}
@@ -136,4 +136,4 @@ func TestContentExtractor_ToMarkdown(t *testing.T) {
}
})
}
-}
\ No newline at end of file
+}
pkg/mcp/prompts_test.go
@@ -146,4 +146,4 @@ func TestPromptMessage_Types(t *testing.T) {
}
})
}
-}
\ No newline at end of file
+}
pkg/mcp/resources_test.go
@@ -74,7 +74,7 @@ func TestReadResourceResult_WithContent(t *testing.T) {
if len(result.Contents) != 1 {
t.Errorf("Expected 1 content item, got %d", len(result.Contents))
}
-
+
if textContent, ok := result.Contents[0].(TextContent); ok {
if textContent.Text != "File content here" {
t.Errorf("Expected content 'File content here', got %s", textContent.Text)
@@ -124,4 +124,4 @@ func indexOf(s, substr string) int {
}
}
return -1
-}
\ No newline at end of file
+}
pkg/mcp/server.go
@@ -17,7 +17,7 @@ type Server struct {
capabilities ServerCapabilities
// Handler functions
- toolHandlers map[string]ToolHandler
+ toolHandlers map[string]ToolHandler
promptHandlers map[string]PromptHandler
promptDefinitions map[string]Prompt
resourceHandlers map[string]ResourceHandler
@@ -38,8 +38,8 @@ type ResourceHandler func(ReadResourceRequest) (ReadResourceResult, error)
// NewServer creates a new MCP server
func NewServer(name, version string) *Server {
return &Server{
- name: name,
- version: version,
+ name: name,
+ version: version,
toolHandlers: make(map[string]ToolHandler),
promptHandlers: make(map[string]PromptHandler),
promptDefinitions: make(map[string]Prompt),
pkg/mcp/server_prompts_test.go
@@ -110,4 +110,4 @@ func TestServer_MultiplePrompts(t *testing.T) {
if !names["prompt2"] {
t.Error("prompt2 not found in list")
}
-}
\ No newline at end of file
+}
pkg/mcp/server_resources_test.go
@@ -64,7 +64,7 @@ func TestServer_MultipleResources(t *testing.T) {
expectedURIs := []string{
"file:///file1.txt",
- "git://repo/main/file.go",
+ "git://repo/main/file.go",
"memory://entity/123",
}
@@ -114,4 +114,4 @@ func TestServer_RegisterResourceWithDefinition(t *testing.T) {
if res.MimeType != "text/markdown" {
t.Errorf("Expected mime type 'text/markdown', got %s", res.MimeType)
}
-}
\ No newline at end of file
+}
pkg/memory/server.go
@@ -54,8 +54,10 @@ func New(memoryFile string) *Server {
// Load existing data
memoryServer.loadGraph()
- // Register all memory tools
+ // Register all memory tools, prompts, and resources
memoryServer.registerTools()
+ memoryServer.registerPrompts()
+ memoryServer.registerResources()
return memoryServer
}
@@ -73,6 +75,63 @@ func (ms *Server) registerTools() {
ms.RegisterTool("open_nodes", ms.HandleOpenNodes)
}
+// registerPrompts registers all Memory prompts with the server
+func (ms *Server) registerPrompts() {
+ knowledgePrompt := mcp.Prompt{
+ Name: "knowledge-query",
+ Description: "Prompt for querying and exploring the knowledge graph",
+ Arguments: []mcp.PromptArgument{
+ {
+ Name: "query",
+ Description: "What you want to search for or ask about in the knowledge graph",
+ Required: true,
+ },
+ {
+ Name: "context",
+ Description: "Additional context about your question (optional)",
+ Required: false,
+ },
+ },
+ }
+
+ ms.RegisterPrompt(knowledgePrompt, ms.HandleKnowledgeQueryPrompt)
+}
+
+// registerResources registers memory graph entities as resources (memory:// URIs)
+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)
+ }
+}
+
// ListTools returns all available Memory tools
func (ms *Server) ListTools() []mcp.Tool {
return []mcp.Tool{
@@ -773,6 +832,140 @@ func (ms *Server) HandleOpenNodes(req mcp.CallToolRequest) (mcp.CallToolResult,
return mcp.NewToolResult(mcp.NewTextContent(string(resultJSON))), nil
}
+// HandleMemoryResource handles memory:// resource requests
+func (ms *Server) HandleMemoryResource(req mcp.ReadResourceRequest) (mcp.ReadResourceResult, error) {
+ ms.mu.RLock()
+ defer ms.mu.RUnlock()
+
+ // Parse memory:// URI: memory://entity/name or memory://relations/all
+ if !strings.HasPrefix(req.URI, "memory://") {
+ return mcp.ReadResourceResult{}, fmt.Errorf("invalid memory URI: %s", req.URI)
+ }
+
+ uriPath := req.URI[9:] // Remove "memory://" prefix
+ parts := strings.Split(uriPath, "/")
+
+ if len(parts) < 2 {
+ return mcp.ReadResourceResult{}, fmt.Errorf("invalid memory URI format: %s", req.URI)
+ }
+
+ resourceType := parts[0]
+ resourcePath := strings.Join(parts[1:], "/")
+
+ switch resourceType {
+ case "entity":
+ return ms.handleEntityResource(resourcePath)
+ case "relations":
+ return ms.handleRelationsResource(resourcePath)
+ default:
+ return mcp.ReadResourceResult{}, fmt.Errorf("unknown memory resource type: %s", resourceType)
+ }
+}
+
+// handleEntityResource handles memory://entity/name resources
+func (ms *Server) handleEntityResource(entityName string) (mcp.ReadResourceResult, error) {
+ entity, exists := ms.graph.Entities[entityName]
+ if !exists {
+ return mcp.ReadResourceResult{}, fmt.Errorf("entity not found: %s", entityName)
+ }
+
+ // Create detailed entity information including related relations
+ var relatedRelations []Relation
+ for _, relation := range ms.graph.Relations {
+ if relation.From == entityName || relation.To == entityName {
+ relatedRelations = append(relatedRelations, relation)
+ }
+ }
+
+ entityInfo := map[string]interface{}{
+ "entity": entity,
+ "relations": relatedRelations,
+ }
+
+ jsonData, err := json.MarshalIndent(entityInfo, "", " ")
+ if err != nil {
+ return mcp.ReadResourceResult{}, fmt.Errorf("failed to marshal entity data: %v", err)
+ }
+
+ return mcp.ReadResourceResult{
+ Contents: []mcp.Content{
+ mcp.NewTextContent(string(jsonData)),
+ },
+ }, nil
+}
+
+// handleRelationsResource handles memory://relations/all resources
+func (ms *Server) handleRelationsResource(resourcePath string) (mcp.ReadResourceResult, error) {
+ if resourcePath != "all" {
+ return mcp.ReadResourceResult{}, fmt.Errorf("invalid relations resource path: %s", resourcePath)
+ }
+
+ relationsInfo := map[string]interface{}{
+ "total_relations": len(ms.graph.Relations),
+ "relations": ms.graph.Relations,
+ }
+
+ jsonData, err := json.MarshalIndent(relationsInfo, "", " ")
+ if err != nil {
+ return mcp.ReadResourceResult{}, fmt.Errorf("failed to marshal relations data: %v", err)
+ }
+
+ return mcp.ReadResourceResult{
+ Contents: []mcp.Content{
+ mcp.NewTextContent(string(jsonData)),
+ },
+ }, nil
+}
+
+// Prompt handlers
+
+func (ms *Server) HandleKnowledgeQueryPrompt(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) {
+ query, hasQuery := req.Arguments["query"].(string)
+ context, hasContext := req.Arguments["context"].(string)
+
+ if !hasQuery || query == "" {
+ return mcp.GetPromptResult{}, fmt.Errorf("query argument is required")
+ }
+
+ // Create the prompt messages
+ var messages []mcp.PromptMessage
+
+ // User message with the query and optional context
+ userContent := fmt.Sprintf("I want to query the knowledge graph: %s", query)
+ if hasContext && context != "" {
+ userContent += fmt.Sprintf("\n\nAdditional context: %s", context)
+ }
+
+ messages = append(messages, mcp.PromptMessage{
+ Role: "user",
+ Content: mcp.NewTextContent(userContent),
+ })
+
+ // Assistant message with guidance on using the memory tools
+ assistantContent := fmt.Sprintf(`I'll help you explore the knowledge graph to answer your query: "%s"
+
+Let me start by searching the knowledge graph for relevant information:`, query)
+
+ if hasContext && context != "" {
+ assistantContent += fmt.Sprintf("\n\nWith context: %s", context)
+ }
+
+ messages = append(messages, mcp.PromptMessage{
+ Role: "assistant",
+ Content: mcp.NewTextContent(assistantContent),
+ })
+
+ description := fmt.Sprintf("Knowledge graph exploration for: %s", query)
+ if hasContext && context != "" {
+ description += fmt.Sprintf(" (%s)", context)
+ }
+
+ return mcp.GetPromptResult{
+ Description: description,
+ Messages: messages,
+ }, nil
+}
+
// Helper methods
func (ms *Server) loadGraph() error {
@@ -800,5 +993,15 @@ func (ms *Server) saveGraph() error {
return err
}
- return os.WriteFile(ms.memoryFile, data, 0644)
+ err = os.WriteFile(ms.memoryFile, data, 0644)
+ if err != nil {
+ return err
+ }
+
+ // Re-register resources after saving to reflect changes
+ go func() {
+ ms.registerResources()
+ }()
+
+ return nil
}
pkg/memory/server_test.go
@@ -1,9 +1,11 @@
package memory
import (
+ "encoding/json"
"path/filepath"
"strings"
"testing"
+ "time"
"github.com/xlgmokha/mcp/pkg/mcp"
)
@@ -549,6 +551,217 @@ func TestMemoryServer_Persistence(t *testing.T) {
}
}
+func TestMemoryServer_Resources(t *testing.T) {
+ tempDir := t.TempDir()
+ memoryFile := filepath.Join(tempDir, "test_memory_resources.json")
+
+ server := New(memoryFile)
+
+ // Create test entities
+ createReq := mcp.CallToolRequest{
+ Arguments: map[string]interface{}{
+ "entities": []interface{}{
+ map[string]interface{}{
+ "name": "TestEntity1",
+ "entityType": "Person",
+ "observations": []interface{}{"First observation", "Second observation"},
+ },
+ map[string]interface{}{
+ "name": "TestEntity2",
+ "entityType": "Company",
+ "observations": []interface{}{"Company observation"},
+ },
+ },
+ },
+ }
+
+ _, err := server.HandleCreateEntities(createReq)
+ if err != nil {
+ t.Fatalf("Failed to create entities: %v", err)
+ }
+
+ // Create test relations
+ relationsReq := mcp.CallToolRequest{
+ Arguments: map[string]interface{}{
+ "relations": []interface{}{
+ map[string]interface{}{
+ "from": "TestEntity1",
+ "to": "TestEntity2",
+ "relationType": "works_for",
+ },
+ },
+ },
+ }
+
+ _, err = server.HandleCreateRelations(relationsReq)
+ if err != nil {
+ t.Fatalf("Failed to create relations: %v", err)
+ }
+
+ // Give time for resource registration
+ time.Sleep(100 * time.Millisecond)
+
+ // Test list resources
+ resources := server.ListResources()
+ if len(resources) < 2 {
+ t.Errorf("Expected at least 2 resources, got %d", len(resources))
+ }
+
+ // Find entity resource
+ var entityResourceURI string
+ for _, resource := range resources {
+ if strings.Contains(resource.URI, "TestEntity1") {
+ entityResourceURI = resource.URI
+ break
+ }
+ }
+
+ if entityResourceURI == "" {
+ t.Fatal("TestEntity1 resource not found")
+ }
+
+ // Test read entity resource
+ readReq := mcp.ReadResourceRequest{
+ URI: entityResourceURI,
+ }
+
+ result, err := server.HandleMemoryResource(readReq)
+ if err != nil {
+ t.Fatalf("Failed to read entity resource: %v", err)
+ }
+
+ if len(result.Contents) == 0 {
+ t.Fatal("Expected content in resource result")
+ }
+
+ // Verify entity data
+ textContent, ok := result.Contents[0].(mcp.TextContent)
+ if !ok {
+ t.Fatal("Expected TextContent in resource result")
+ }
+
+ var entityData map[string]interface{}
+ err = json.Unmarshal([]byte(textContent.Text), &entityData)
+ if err != nil {
+ t.Fatalf("Failed to parse entity JSON: %v", err)
+ }
+
+ entity, ok := entityData["entity"].(map[string]interface{})
+ if !ok {
+ t.Fatal("Entity data not found in resource result")
+ }
+
+ if entity["name"].(string) != "TestEntity1" {
+ t.Errorf("Expected entity name TestEntity1, got %s", entity["name"])
+ }
+
+ // Test relations resource
+ relationsResourceURI := "memory://relations/all"
+ readRelationsReq := mcp.ReadResourceRequest{
+ URI: relationsResourceURI,
+ }
+
+ relationsResult, err := server.HandleMemoryResource(readRelationsReq)
+ if err != nil {
+ t.Fatalf("Failed to read relations resource: %v", err)
+ }
+
+ if len(relationsResult.Contents) == 0 {
+ t.Fatal("Expected content in relations resource result")
+ }
+
+ // Verify relations data
+ relationsTextContent, ok := relationsResult.Contents[0].(mcp.TextContent)
+ if !ok {
+ t.Fatal("Expected TextContent in relations resource result")
+ }
+
+ var relationsData map[string]interface{}
+ err = json.Unmarshal([]byte(relationsTextContent.Text), &relationsData)
+ if err != nil {
+ t.Fatalf("Failed to parse relations JSON: %v", err)
+ }
+
+ totalRelations, ok := relationsData["total_relations"].(float64)
+ if !ok || totalRelations != 1 {
+ t.Errorf("Expected 1 relation, got %v", totalRelations)
+ }
+}
+
+func TestMemoryServer_HandleKnowledgeQueryPrompt(t *testing.T) {
+ tempDir := t.TempDir()
+ memoryFile := filepath.Join(tempDir, "test_prompt_memory.json")
+
+ server := New(memoryFile)
+
+ req := mcp.GetPromptRequest{
+ Arguments: map[string]interface{}{
+ "query": "find all people",
+ "context": "looking for team members",
+ },
+ }
+
+ result, err := server.HandleKnowledgeQueryPrompt(req)
+ if err != nil {
+ t.Fatalf("HandleKnowledgeQueryPrompt failed: %v", err)
+ }
+
+ if len(result.Messages) != 2 {
+ t.Errorf("Expected 2 messages, got %d", len(result.Messages))
+ }
+
+ if result.Messages[0].Role != "user" {
+ t.Errorf("Expected first message role to be 'user', got %s", result.Messages[0].Role)
+ }
+
+ if result.Messages[1].Role != "assistant" {
+ t.Errorf("Expected second message role to be 'assistant', got %s", result.Messages[1].Role)
+ }
+
+ userContent, ok := result.Messages[0].Content.(mcp.TextContent)
+ if !ok {
+ t.Fatal("Expected TextContent in user message")
+ }
+ if !strings.Contains(userContent.Text, "find all people") {
+ t.Error("User message should contain the query")
+ }
+
+ assistantContent, ok := result.Messages[1].Content.(mcp.TextContent)
+ if !ok {
+ t.Fatal("Expected TextContent in assistant message")
+ }
+ if !strings.Contains(assistantContent.Text, "knowledge graph") {
+ t.Error("Assistant message should mention knowledge graph")
+ }
+}
+
+func TestMemoryServer_InvalidResourceURI(t *testing.T) {
+ tempDir := t.TempDir()
+ memoryFile := filepath.Join(tempDir, "test_invalid_memory.json")
+
+ server := New(memoryFile)
+
+ testCases := []struct {
+ name string
+ uri string
+ }{
+ {"invalid scheme", "file://test"},
+ {"invalid format", "memory://"},
+ {"invalid type", "memory://unknown/test"},
+ {"missing entity", "memory://entity/nonexistent"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := mcp.ReadResourceRequest{URI: tc.uri}
+ _, err := server.HandleMemoryResource(req)
+ if err == nil {
+ t.Errorf("Expected error for invalid URI: %s", tc.uri)
+ }
+ })
+ }
+}
+
// Helper functions
func contains(s, substr string) bool {
return strings.Contains(s, substr)
PLAN.md
@@ -74,27 +74,42 @@ This plan tracks the implementation of advanced features to achieve better featu
---
-## Phase 3: Resources Support ❌
+## Phase 3: Resources Support ✅
**Goal**: Enable resource discovery and access
### Tasks:
-- [ ] Create `pkg/mcp/resources.go` with Resource structures
-- [ ] Add ResourceHandler interface
-- [ ] Add resource support to BaseServer
-- [ ] Implement `list_resources` method in BaseServer
-- [ ] Implement `read_resource` method in BaseServer
-- [ ] Add `file://` resource support to filesystem server
-- [ ] Add `git://` resource support to git server
-- [ ] Add `memory://` resource support to memory server
-- [ ] Test resource discovery and access
+- [x] Create `pkg/mcp/resources.go` with Resource structures
+- [x] Add ResourceHandler interface and support to BaseServer
+- [x] Implement `list_resources` method in BaseServer
+- [x] Implement `read_resource` method in BaseServer
+- [x] Add `file://` resource support to filesystem server
+- [x] Add `git://` resource support to git server
+- [x] Add `memory://` resource support to memory server
+- [x] Test resource discovery and access
-### Files to Create/Modify:
-- `pkg/mcp/resources.go` (new)
-- `pkg/mcp/server.go` (modify - add resource methods)
-- `cmd/filesystem/main.go` (add file:// resources)
-- `cmd/git/main.go` (add git:// resources)
-- `cmd/memory/main.go` (add memory:// resources)
+### Files Created/Modified:
+- ✅ `pkg/mcp/resources.go` (new - Resource structures and types)
+- ✅ `pkg/mcp/resources_test.go` (new - comprehensive resource tests)
+- ✅ `pkg/mcp/server_resources_test.go` (new - server resource tests)
+- ✅ `pkg/mcp/server.go` (modified - added resource infrastructure)
+- ✅ `pkg/filesystem/server.go` (modified - added file:// resources)
+- ✅ `pkg/git/server.go` (modified - added git:// resources)
+- ✅ `pkg/memory/server.go` (modified - added memory:// resources and knowledge-query prompt)
+
+### Results:
+- **Complete MCP Resources capability** implemented with 3 URI schemes
+- **file:// resources**: Automatic discovery of files in allowed directories with MIME type detection
+- **git:// resources**: Repository files, branches, and commits accessible as resources
+- **memory:// resources**: Knowledge graph entities and relations exposed as JSON resources
+- **Thread-safe implementation** with proper mutex locking
+- **Security validation** for all resource access
+- **Comprehensive test coverage** including invalid URI handling
+
+### Resources Implemented:
+1. **file:// scheme**: Direct filesystem access with security validation
+2. **git:// scheme**: Repository browsing including files, branches, and commits
+3. **memory:// scheme**: Knowledge graph exploration with entity and relation access
---
@@ -146,11 +161,11 @@ go get github.com/JohannesKaufmann/html-to-markdown
## Progress Tracking
-**Overall Progress**: 2/4 phases completed (50%)
+**Overall Progress**: 3/4 phases completed (75%)
-**Last Updated**: Phase 2 completed - Prompts Support fully implemented and tested
-**Current Phase**: Phase 3 - Resources Support
-**Next Milestone**: Implement MCP resources infrastructure with file://, git://, and memory:// resource types
+**Last Updated**: Phase 3 completed - Resources Support fully implemented and tested
+**Current Phase**: Phase 4 - Roots Support
+**Next Milestone**: Implement MCP roots capability negotiation with filesystem, git repository, and memory graph root discovery
---