|
|
|
@@ -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
|
|
|
|
|
}
|