Files
amacocian 1c2108dd2b
Release / tag (push) Successful in 2s
CI / build (push) Successful in 3m5s
Fix fmt
2026-06-13 13:40:24 +02:00

225 lines
6.9 KiB
Go

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
}