Files

229 lines
5.6 KiB
Go

package repo
import (
"context"
"encoding/json"
"fmt"
"net/url"
"path/filepath"
"strings"
"gitea.com/gitea/gitea-mcp/pkg/errors"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/params"
"gitea.com/gitea/gitea-mcp/pkg/to"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
ListRepoStructureToolName = "list_repo_structure"
)
type TreeEntry struct {
Path string `json:"path"`
Type string `json:"type"`
SHA string `json:"sha"`
Size int64 `json:"size,omitempty"`
}
type TreeResponse struct {
SHA string `json:"sha"`
URL string `json:"url,omitempty"`
Tree []TreeEntry `json:"tree"`
Truncated bool `json:"truncated,omitempty"`
}
var (
ListRepoStructureTool = mcp.NewTool(
ListRepoStructureToolName,
mcp.WithDescription("List the complete directory and file structure of a repository using Git tree API. Supports recursive listing and pattern filtering."),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("ref", mcp.Description("Git reference (branch, tag, or commit SHA). Defaults to default branch.")),
mcp.WithString("pattern", mcp.Description("Glob pattern to filter files (e.g., '*.yml', '.gitea/*', 'src/**/*.go')")),
mcp.WithBoolean("recursive", mcp.Description("List contents recursively (default: true)")),
mcp.WithNumber("page", mcp.Description("Page number for pagination (1-based, default: 1)")),
mcp.WithNumber("per_page", mcp.Description("Number of items per page (default: 100, max: 1000)")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListRepoStructureTool,
Handler: ListRepoStructureFn,
})
}
func ListRepoStructureFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListRepoStructureFn")
args := req.GetArguments()
owner, err := params.GetString(args, "owner")
if err != nil {
return to.ErrorResult(err)
}
repo, err := params.GetString(args, "repo")
if err != nil {
return to.ErrorResult(err)
}
ref, _ := args["ref"].(string)
if ref == "" {
ref = "HEAD"
}
pattern, _ := args["pattern"].(string)
recursive := true
if recursiveVal, ok := args["recursive"].(bool); ok {
recursive = recursiveVal
}
page := 1
if pageVal, ok := args["page"].(float64); ok && pageVal > 0 {
page = int(pageVal)
}
perPage := 100
if perPageVal, ok := args["per_page"].(float64); ok && perPageVal > 0 {
perPage = int(perPageVal)
if perPage > 1000 {
perPage = 1000
}
}
query := url.Values{}
if recursive {
query.Set("recursive", "1")
}
query.Set("page", fmt.Sprintf("%d", page))
query.Set("per_page", fmt.Sprintf("%d", perPage))
path := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, ref)
var treeResp TreeResponse
statusCode, err := gitea.DoJSON(ctx, "GET", path, query, nil, &treeResp)
if err != nil {
translatedErr := errors.TranslateError(err, map[string]string{
"operation": "ListRepoStructure",
"owner": owner,
"repo": repo,
"ref": ref,
"status": fmt.Sprintf("%d", statusCode),
})
return to.ErrorResult(translatedErr)
}
filteredEntries := filterEntries(treeResp.Tree, pattern)
result := map[string]any{
"owner": owner,
"repo": repo,
"ref": ref,
"sha": treeResp.SHA,
"truncated": treeResp.Truncated,
"total_count": len(filteredEntries),
"page": page,
"per_page": perPage,
"tree": slimTreeEntries(filteredEntries),
}
if treeResp.Truncated {
result["warning"] = "Tree listing was truncated due to size. Consider using pattern filtering or pagination."
}
resultJSON, err := json.MarshalIndent(result, "", " ")
if err != nil {
return to.ErrorResult(fmt.Errorf("marshal result: %w", err))
}
return to.TextResult(string(resultJSON))
}
func filterEntries(entries []TreeEntry, pattern string) []TreeEntry {
if pattern == "" {
return entries
}
filtered := make([]TreeEntry, 0, len(entries))
for _, entry := range entries {
if matchPattern(entry.Path, pattern) {
filtered = append(filtered, entry)
}
}
return filtered
}
func matchPattern(path, pattern string) bool {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
return true
}
negate := false
if strings.HasPrefix(pattern, "!") {
negate = true
pattern = strings.TrimPrefix(pattern, "!")
}
matched, err := filepath.Match(pattern, path)
if err == nil && matched {
return !negate
}
filename := filepath.Base(path)
matched, err = filepath.Match(pattern, filename)
if err == nil && matched {
return !negate
}
if strings.HasPrefix(pattern, "**/") {
suffix := strings.TrimPrefix(pattern, "**/")
if strings.HasSuffix(path, suffix) {
return !negate
}
parts := strings.Split(path, "/")
for i := range parts {
subPath := strings.Join(parts[i:], "/")
if matched, _ := filepath.Match(suffix, subPath); matched {
return !negate
}
}
}
if strings.HasSuffix(pattern, "/*") || strings.HasSuffix(pattern, "/**") {
dirPrefix := strings.TrimSuffix(pattern, "/*")
dirPrefix = strings.TrimSuffix(dirPrefix, "/**")
if strings.HasPrefix(path, dirPrefix+"/") {
return !negate
}
}
if strings.HasPrefix(path, pattern+"/") || path == pattern {
return !negate
}
return negate
}
func slimTreeEntries(entries []TreeEntry) []map[string]any {
out := make([]map[string]any, 0, len(entries))
for _, e := range entries {
m := map[string]any{
"path": e.Path,
"type": e.Type,
"sha": e.SHA,
}
if e.Type == "blob" && e.Size > 0 {
m["size"] = e.Size
}
out = append(out, m)
}
return out
}