Skip to content

Commit 8ddab61

Browse files
authored
feat: Add directory tree tool (mark3labs#8)
Co-authored-by: tomholford <tomholford@users.noreply.github.com>
1 parent 21b46d7 commit 8ddab61

File tree

2 files changed

+248
-0
lines changed

2 files changed

+248
-0
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Go server implementing Model Context Protocol (MCP) for filesystem operations.
1111
- Move files/directories
1212
- Search files
1313
- Get file metadata
14+
- Generate directory tree structures
1415

1516
**Note**: The server will only allow operations within directories specified via `args`.
1617

@@ -74,6 +75,15 @@ Go server implementing Model Context Protocol (MCP) for filesystem operations.
7475
- Type (file/directory)
7576
- Permissions
7677

78+
- **tree**
79+
- Returns a hierarchical JSON representation of a directory structure
80+
- Inputs:
81+
- `path` (string): Directory to traverse (required)
82+
- `depth` (number): Maximum depth to traverse (default: 3)
83+
- `follow_symlinks` (boolean): Whether to follow symbolic links (default: false)
84+
- Returns formatted JSON with file/directory hierarchy
85+
- Includes file metadata (name, path, size, modified time)
86+
7787
- **list_allowed_directories**
7888
- List all directories the server is allowed to access
7989
- No input required

main.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"context"
55
"encoding/base64"
6+
"encoding/json"
67
"fmt"
78
"log"
89
"mime"
@@ -33,6 +34,16 @@ type FileInfo struct {
3334
Permissions string `json:"permissions"`
3435
}
3536

37+
// FileNode represents a node in the file tree
38+
type FileNode struct {
39+
Name string `json:"name"`
40+
Path string `json:"path"`
41+
Type string `json:"type"` // "file" or "directory"
42+
Size int64 `json:"size,omitempty"`
43+
Modified time.Time `json:"modified,omitempty"`
44+
Children []*FileNode `json:"children,omitempty"`
45+
}
46+
3647
type FilesystemServer struct {
3748
allowedDirs []string
3849
server *server.MCPServer
@@ -161,6 +172,21 @@ func NewFilesystemServer(allowedDirs []string) (*FilesystemServer, error) {
161172
mcp.WithDescription("Returns the list of directories that this server is allowed to access."),
162173
), s.handleListAllowedDirectories)
163174

175+
s.server.AddTool(mcp.NewTool(
176+
"tree",
177+
mcp.WithDescription("Returns a hierarchical JSON representation of a directory structure."),
178+
mcp.WithString("path",
179+
mcp.Description("Path of the directory to traverse"),
180+
mcp.Required(),
181+
),
182+
mcp.WithNumber("depth",
183+
mcp.Description("Maximum depth to traverse (default: 3)"),
184+
),
185+
mcp.WithBoolean("follow_symlinks",
186+
mcp.Description("Whether to follow symbolic links (default: false)"),
187+
),
188+
), s.handleTree)
189+
164190
return s, nil
165191
}
166192

@@ -192,6 +218,85 @@ func (s *FilesystemServer) isPathInAllowedDirs(path string) bool {
192218
return false
193219
}
194220

221+
// buildTree builds a tree representation of the filesystem starting at the given path
222+
func (s *FilesystemServer) buildTree(path string, maxDepth int, currentDepth int, followSymlinks bool) (*FileNode, error) {
223+
// Validate the path
224+
validPath, err := s.validatePath(path)
225+
if err != nil {
226+
return nil, err
227+
}
228+
229+
// Get file info
230+
info, err := os.Stat(validPath)
231+
if err != nil {
232+
return nil, err
233+
}
234+
235+
// Create the node
236+
node := &FileNode{
237+
Name: filepath.Base(validPath),
238+
Path: validPath,
239+
Modified: info.ModTime(),
240+
}
241+
242+
// Set type and size
243+
if info.IsDir() {
244+
node.Type = "directory"
245+
246+
// If we haven't reached the max depth, process children
247+
if currentDepth < maxDepth {
248+
// Read directory entries
249+
entries, err := os.ReadDir(validPath)
250+
if err != nil {
251+
return nil, err
252+
}
253+
254+
// Process each entry
255+
for _, entry := range entries {
256+
entryPath := filepath.Join(validPath, entry.Name())
257+
258+
// Handle symlinks
259+
if entry.Type()&os.ModeSymlink != 0 {
260+
if !followSymlinks {
261+
// Skip symlinks if not following them
262+
continue
263+
}
264+
265+
// Resolve symlink
266+
linkDest, err := filepath.EvalSymlinks(entryPath)
267+
if err != nil {
268+
// Skip invalid symlinks
269+
continue
270+
}
271+
272+
// Validate the symlink destination is within allowed directories
273+
if !s.isPathInAllowedDirs(linkDest) {
274+
// Skip symlinks pointing outside allowed directories
275+
continue
276+
}
277+
278+
entryPath = linkDest
279+
}
280+
281+
// Recursively build child node
282+
childNode, err := s.buildTree(entryPath, maxDepth, currentDepth+1, followSymlinks)
283+
if err != nil {
284+
// Skip entries with errors
285+
continue
286+
}
287+
288+
// Add child to the current node
289+
node.Children = append(node.Children, childNode)
290+
}
291+
}
292+
} else {
293+
node.Type = "file"
294+
node.Size = info.Size()
295+
}
296+
297+
return node, nil
298+
}
299+
195300
func (s *FilesystemServer) validatePath(requestedPath string) (string, error) {
196301
// Always convert to absolute path first
197302
abs, err := filepath.Abs(requestedPath)
@@ -1259,6 +1364,139 @@ func (s *FilesystemServer) handleSearchFiles(
12591364
}, nil
12601365
}
12611366

1367+
func (s *FilesystemServer) handleTree(
1368+
ctx context.Context,
1369+
request mcp.CallToolRequest,
1370+
) (*mcp.CallToolResult, error) {
1371+
path, ok := request.Params.Arguments["path"].(string)
1372+
if !ok {
1373+
return nil, fmt.Errorf("path must be a string")
1374+
}
1375+
1376+
// Handle empty or relative paths like "." or "./" by converting to absolute path
1377+
if path == "." || path == "./" {
1378+
// Get current working directory
1379+
cwd, err := os.Getwd()
1380+
if err != nil {
1381+
return &mcp.CallToolResult{
1382+
Content: []mcp.Content{
1383+
mcp.TextContent{
1384+
Type: "text",
1385+
Text: fmt.Sprintf("Error resolving current directory: %v", err),
1386+
},
1387+
},
1388+
IsError: true,
1389+
}, nil
1390+
}
1391+
path = cwd
1392+
}
1393+
1394+
// Extract depth parameter (optional, default: 3)
1395+
depth := 3 // Default value
1396+
if depthParam, ok := request.Params.Arguments["depth"]; ok {
1397+
if d, ok := depthParam.(float64); ok {
1398+
depth = int(d)
1399+
}
1400+
}
1401+
1402+
// Extract follow_symlinks parameter (optional, default: false)
1403+
followSymlinks := false // Default value
1404+
if followParam, ok := request.Params.Arguments["follow_symlinks"]; ok {
1405+
if f, ok := followParam.(bool); ok {
1406+
followSymlinks = f
1407+
}
1408+
}
1409+
1410+
// Validate the path is within allowed directories
1411+
validPath, err := s.validatePath(path)
1412+
if err != nil {
1413+
return &mcp.CallToolResult{
1414+
Content: []mcp.Content{
1415+
mcp.TextContent{
1416+
Type: "text",
1417+
Text: fmt.Sprintf("Error: %v", err),
1418+
},
1419+
},
1420+
IsError: true,
1421+
}, nil
1422+
}
1423+
1424+
// Check if it's a directory
1425+
info, err := os.Stat(validPath)
1426+
if err != nil {
1427+
return &mcp.CallToolResult{
1428+
Content: []mcp.Content{
1429+
mcp.TextContent{
1430+
Type: "text",
1431+
Text: fmt.Sprintf("Error: %v", err),
1432+
},
1433+
},
1434+
IsError: true,
1435+
}, nil
1436+
}
1437+
1438+
if !info.IsDir() {
1439+
return &mcp.CallToolResult{
1440+
Content: []mcp.Content{
1441+
mcp.TextContent{
1442+
Type: "text",
1443+
Text: "Error: The specified path is not a directory",
1444+
},
1445+
},
1446+
IsError: true,
1447+
}, nil
1448+
}
1449+
1450+
// Build the tree structure
1451+
tree, err := s.buildTree(validPath, depth, 0, followSymlinks)
1452+
if err != nil {
1453+
return &mcp.CallToolResult{
1454+
Content: []mcp.Content{
1455+
mcp.TextContent{
1456+
Type: "text",
1457+
Text: fmt.Sprintf("Error building directory tree: %v", err),
1458+
},
1459+
},
1460+
IsError: true,
1461+
}, nil
1462+
}
1463+
1464+
// Convert to JSON
1465+
jsonData, err := json.MarshalIndent(tree, "", " ")
1466+
if err != nil {
1467+
return &mcp.CallToolResult{
1468+
Content: []mcp.Content{
1469+
mcp.TextContent{
1470+
Type: "text",
1471+
Text: fmt.Sprintf("Error generating JSON: %v", err),
1472+
},
1473+
},
1474+
IsError: true,
1475+
}, nil
1476+
}
1477+
1478+
// Create resource URI for the directory
1479+
resourceURI := pathToResourceURI(validPath)
1480+
1481+
// Return the result
1482+
return &mcp.CallToolResult{
1483+
Content: []mcp.Content{
1484+
mcp.TextContent{
1485+
Type: "text",
1486+
Text: fmt.Sprintf("Directory tree for %s (max depth: %d):\n\n%s", validPath, depth, string(jsonData)),
1487+
},
1488+
mcp.EmbeddedResource{
1489+
Type: "resource",
1490+
Resource: mcp.TextResourceContents{
1491+
URI: resourceURI,
1492+
MIMEType: "application/json",
1493+
Text: string(jsonData),
1494+
},
1495+
},
1496+
},
1497+
}, nil
1498+
}
1499+
12621500
func (s *FilesystemServer) handleGetFileInfo(
12631501
ctx context.Context,
12641502
request mcp.CallToolRequest,

0 commit comments

Comments
 (0)