diff --git a/cmd/gitea-mcp/tools.go b/cmd/gitea-mcp/tools.go index 0462a69..613562b 100644 --- a/cmd/gitea-mcp/tools.go +++ b/cmd/gitea-mcp/tools.go @@ -13,6 +13,7 @@ func registerTools(server *mcp.Server, api *giteaAPI) { registerUserTools(server, api) registerRepoTools(server, api) registerContentTools(server, api) + registerTreeTools(server, api) registerCommitTools(server, api) registerIssueTools(server, api) registerPullTools(server, api) diff --git a/cmd/gitea-mcp/tools_tree.go b/cmd/gitea-mcp/tools_tree.go new file mode 100644 index 0000000..266c0d9 --- /dev/null +++ b/cmd/gitea-mcp/tools_tree.go @@ -0,0 +1,224 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func registerTreeTools(server *mcp.Server, api *giteaAPI) { + mcp.AddTool(server, &mcp.Tool{ + Name: "list_dir", + Description: "List the entries of a directory in a Gitea repository at a given ref. Path empty (or '.') lists the repository root. Returns one level only; use get_tree for recursive listings.", + }, listDirHandler(api)) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_tree", + Description: "List every file/dir under a path in a Gitea repository at a given ref. Resolves the ref to a tree SHA and walks it (recursive by default). Capped at 2000 entries.", + }, getTreeHandler(api)) +} + +type listDirInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path,omitempty" jsonschema:"directory path inside the repository; empty or '.' for repo root"` + Ref string `json:"ref,omitempty" jsonschema:"optional branch / tag / commit SHA (default: repository default branch)"` +} +type dirEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + Size int64 `json:"size,omitempty"` + SHA string `json:"sha,omitempty"` +} +type listDirOutput struct { + Path string `json:"path"` + Ref string `json:"ref,omitempty"` + Entries []dirEntry `json:"entries"` +} + +func listDirHandler(api *giteaAPI) mcp.ToolHandlerFor[listDirInput, listDirOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in listDirInput) (*mcp.CallToolResult, listDirOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, listDirOutput{}, fmt.Errorf("owner and repo are required") + } + dir := strings.Trim(in.Path, "/") + if dir == "." { + dir = "" + } + q := url.Values{} + setIf(q, "ref", in.Ref) + path := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), escapePathSegments(dir)) + // Gitea returns an object for files and an array for directories. + // Try the array shape first; if it decodes to a single non-empty + // entry that looks like a file, surface a clear error. + var raw []struct { + Type string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + SHA string `json:"sha"` + } + if err := api.get(ctx, path, q, &raw); err != nil { + return nil, listDirOutput{}, err + } + out := listDirOutput{Path: dir, Ref: in.Ref, Entries: make([]dirEntry, 0, len(raw))} + for _, e := range raw { + out.Entries = append(out.Entries, dirEntry{ + Name: e.Name, Path: e.Path, Type: e.Type, Size: e.Size, SHA: e.SHA, + }) + } + return nil, out, nil + } +} + +const maxTreeEntries = 2000 + +type getTreeInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Ref string `json:"ref,omitempty" jsonschema:"branch / tag / commit SHA (default: repository default branch)"` + Recursive *bool `json:"recursive,omitempty" jsonschema:"walk subtrees (default: true)"` +} +type treeEntry struct { + Path string `json:"path"` + Type string `json:"type"` // "blob" or "tree" + Size int64 `json:"size,omitempty"` + SHA string `json:"sha"` +} +type getTreeOutput struct { + Ref string `json:"ref"` + CommitSHA string `json:"commit_sha"` + TreeSHA string `json:"tree_sha"` + Entries []treeEntry `json:"entries"` + Truncated bool `json:"truncated,omitempty"` +} + +func getTreeHandler(api *giteaAPI) mcp.ToolHandlerFor[getTreeInput, getTreeOutput] { + return func(ctx context.Context, _ *mcp.CallToolRequest, in getTreeInput) (*mcp.CallToolResult, getTreeOutput, error) { + if in.Owner == "" || in.Repo == "" { + return nil, getTreeOutput{}, fmt.Errorf("owner and repo are required") + } + ref := in.Ref + if ref == "" { + var repoInfo struct { + DefaultBranch string `json:"default_branch"` + } + rpath := fmt.Sprintf("/api/v1/repos/%s/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo)) + if err := api.get(ctx, rpath, nil, &repoInfo); err != nil { + return nil, getTreeOutput{}, fmt.Errorf("resolve default branch: %w", err) + } + ref = repoInfo.DefaultBranch + } + // Resolve ref → commit SHA. + commitSHA := "" + if isHexSHA(ref) { + commitSHA = ref + } else { + var br struct { + Commit struct { + ID string `json:"id"` + } `json:"commit"` + } + bpath := fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), url.PathEscape(ref)) + if err := api.get(ctx, bpath, nil, &br); err != nil { + return nil, getTreeOutput{}, fmt.Errorf("resolve branch %q: %w", ref, err) + } + commitSHA = br.Commit.ID + if commitSHA == "" { + return nil, getTreeOutput{}, fmt.Errorf("branch %q has no commit", ref) + } + } + // Resolve commit → tree SHA. + var commit struct { + RepoCommit struct { + Tree struct { + SHA string `json:"sha"` + } `json:"tree"` + } `json:"commit"` + } + cpath := fmt.Sprintf("/api/v1/repos/%s/%s/git/commits/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), url.PathEscape(commitSHA)) + if err := api.get(ctx, cpath, nil, &commit); err != nil { + return nil, getTreeOutput{}, fmt.Errorf("resolve commit %s: %w", commitSHA, err) + } + treeSHA := commit.RepoCommit.Tree.SHA + if treeSHA == "" { + return nil, getTreeOutput{}, fmt.Errorf("gitea returned commit %q without a tree sha", commitSHA) + } + recursive := true + if in.Recursive != nil { + recursive = *in.Recursive + } + out := getTreeOutput{ + Ref: ref, CommitSHA: commitSHA, TreeSHA: treeSHA, + Entries: make([]treeEntry, 0, 256), + } + // Gitea paginates /git/trees. Page through until we hit the cap + // or run out. + const perPage = 1000 + tpath := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", + url.PathEscape(in.Owner), url.PathEscape(in.Repo), url.PathEscape(treeSHA)) + for pageNum := 1; ; pageNum++ { + q := url.Values{ + "page": []string{strconv.Itoa(pageNum)}, + "per_page": []string{strconv.Itoa(perPage)}, + } + if recursive { + q.Set("recursive", "true") + } + var resp struct { + Truncated bool `json:"truncated"` + Tree []struct { + Path string `json:"path"` + Type string `json:"type"` + Size int64 `json:"size"` + SHA string `json:"sha"` + } `json:"tree"` + } + if err := api.get(ctx, tpath, q, &resp); err != nil { + return nil, getTreeOutput{}, err + } + for _, e := range resp.Tree { + if len(out.Entries) >= maxTreeEntries { + out.Truncated = true + return nil, out, nil + } + out.Entries = append(out.Entries, treeEntry{ + Path: e.Path, Type: e.Type, Size: e.Size, SHA: e.SHA, + }) + } + if resp.Truncated { + out.Truncated = true + } + if len(resp.Tree) < perPage { + break + } + } + return nil, out, nil + } +} + +// isHexSHA reports whether s looks like a 7-40 char hex git SHA. +func isHexSHA(s string) bool { +if len(s) < 7 || len(s) > 40 { +return false +} +for _, r := range s { +switch { +case r >= '0' && r <= '9': +case r >= 'a' && r <= 'f': +case r >= 'A' && r <= 'F': +default: +return false +} +} +return true +} diff --git a/docs/gitea-mcp.md b/docs/gitea-mcp.md index 12857c2..8cf38c5 100644 --- a/docs/gitea-mcp.md +++ b/docs/gitea-mcp.md @@ -169,6 +169,8 @@ Tools exposed (all read-only; write tools land in a follow-up): |---------------------|-------------------------------------------------| | `list_repos` | Search/list repos accessible to the user. | | `get_file` | Read file contents (≤ 256 KiB, truncated tail). | +| `list_dir` | List one directory level at a given ref. | +| `get_tree` | Recursive listing of files/dirs (≤ 2000 entries) at a given ref. | | `file_history` | Commits touching a specific file. | | `list_branches` | Branches of a repo. | | `get_branch` | One branch's details + tip SHA. |