From 5f3ad991228f03435430780fcb04b2edc1a381ed Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 15 May 2026 10:23:31 +0200 Subject: [PATCH] feat(tools): repo_tree, repo_topics_update, file_read dir fix (#14, #15, #18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repo_tree: GET /git/trees/{ref}?recursive=1 — full recursive file tree repo_topics_update: PUT /repos/{owner}/{repo}/topics — replace topic list file_read: detect array response and return descriptive error for dir paths --- cmd/gitea-mcp/main.go | 2 + internal/gitea/files.go | 4 ++ internal/gitea/repos.go | 50 ++++++++++++++++++++ internal/gitea/repos_test.go | 32 +++++++++++++ internal/tools/file_read_test.go | 15 ++++++ internal/tools/repo_topics_update.go | 55 ++++++++++++++++++++++ internal/tools/repo_topics_update_test.go | 35 ++++++++++++++ internal/tools/repo_tree.go | 56 +++++++++++++++++++++++ internal/tools/repo_tree_test.go | 50 ++++++++++++++++++++ 9 files changed, 299 insertions(+) create mode 100644 internal/tools/repo_topics_update.go create mode 100644 internal/tools/repo_topics_update_test.go create mode 100644 internal/tools/repo_tree.go create mode 100644 internal/tools/repo_tree_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index 95e0b35..741e0dc 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -63,6 +63,8 @@ func main() { reg.Register(tools.NewRepoCreate(giteaClient, ownerAllow)) reg.Register(tools.NewRepoUpdate(giteaClient, ownerAllow)) reg.Register(tools.NewRepoMirrorPush(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoTree(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow)) mcpSrv := mcp.NewServer(mcp.ServerOptions{ Registry: reg, diff --git a/internal/gitea/files.go b/internal/gitea/files.go index 2d2e4ad..843f2cf 100644 --- a/internal/gitea/files.go +++ b/internal/gitea/files.go @@ -27,6 +27,10 @@ func (c *Client) GetFileContents(ctx context.Context, owner, repo, path, ref str if err := MapStatus(status, body); err != nil { return nil, err } + // Array response means path is a directory — guide caller to dir_list. + if len(body) > 0 && body[0] == '[' { + return nil, fmt.Errorf("%w: path %q is a directory, not a file — use dir_list", ErrValidation, path) + } var fc FileContents if err := json.Unmarshal(body, &fc); err != nil { return nil, err diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index 77f6043..0d3892c 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -18,6 +18,56 @@ type Repo struct { Template bool `json:"template"` } +type TreeEntry struct { + Path string `json:"path"` + Type string `json:"type"` // "blob" or "tree" + SHA string `json:"sha"` + Size int64 `json:"size"` + URL string `json:"url"` +} + +type Tree struct { + SHA string `json:"sha"` + URL string `json:"url"` + Tree []TreeEntry `json:"tree"` + Truncated bool `json:"truncated"` +} + +func (c *Client) GetTree(ctx context.Context, owner, repo, ref string, recursive bool) (*Tree, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/git/trees/%s", owner, repo, url.PathEscape(ref)) + if recursive { + path += "?recursive=1" + } + body, status, err := c.GetJSON(ctx, path) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var t Tree + if err := json.Unmarshal(body, &t); err != nil { + return nil, err + } + return &t, nil +} + +func (c *Client) UpdateTopics(ctx context.Context, owner, repo string, topics []string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/topics", owner, repo) + body, err := json.Marshal(map[string][]string{"topics": topics}) + if err != nil { + return err + } + resp, status, err := c.PutJSON(ctx, path, body) + if err != nil { + return err + } + if status == 204 { + return nil + } + return MapStatus(status, resp) +} + func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) { if page < 1 { page = 1 diff --git a/internal/gitea/repos_test.go b/internal/gitea/repos_test.go index 8a11f67..a5a0fe4 100644 --- a/internal/gitea/repos_test.go +++ b/internal/gitea/repos_test.go @@ -104,6 +104,38 @@ func TestUpdateRepo(t *testing.T) { assert.Equal(t, "updated", r.Description) } +func TestGetTree(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path) + assert.Equal(t, "1", r.URL.Query().Get("recursive")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi"}],"truncated":false}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + tree, err := c.GetTree(context.Background(), "mathias", "infra", "main", true) + require.NoError(t, err) + assert.Equal(t, "abc", tree.SHA) + require.Len(t, tree.Tree, 2) + assert.Equal(t, "README.md", tree.Tree[0].Path) + assert.Equal(t, "blob", tree.Tree[0].Type) + assert.Equal(t, int64(13), tree.Tree[0].Size) +} + +func TestUpdateTopics(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.UpdateTopics(context.Background(), "mathias", "infra", []string{"go", "mcp", "gitops"}) + require.NoError(t, err) +} + func TestDefaultBranchCachesAcrossCalls(t *testing.T) { var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/tools/file_read_test.go b/internal/tools/file_read_test.go index f49ba24..ae5b146 100644 --- a/internal/tools/file_read_test.go +++ b/internal/tools/file_read_test.go @@ -57,6 +57,21 @@ func TestFileReadToolDefaultBranchResolution(t *testing.T) { assert.Equal(t, "main", result["ref"]) } +func TestFileReadOnDirReturnsDescriptiveError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Gitea returns an array when path is a directory + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"name":"README.md","path":"internal/README.md","type":"file","sha":"abc"}]`)) + })) + defer srv.Close() + + tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"internal","ref":"main"}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "directory") + assert.Contains(t, err.Error(), "dir_list") +} + func TestFileReadAllowlistRejects(t *testing.T) { tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`)) diff --git a/internal/tools/repo_topics_update.go b/internal/tools/repo_topics_update.go new file mode 100644 index 0000000..7a330e9 --- /dev/null +++ b/internal/tools/repo_topics_update.go @@ -0,0 +1,55 @@ +package tools + +import ( + "context" + "encoding/json" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type RepoTopicsUpdate struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoTopicsUpdate(c *gitea.Client, a *allowlist.Allowlist) *RepoTopicsUpdate { + return &RepoTopicsUpdate{c: c, a: a} +} + +func (t *RepoTopicsUpdate) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_topics_update", + Description: "Replace the topic list for a repository.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "topics":{"type":"array","items":{"type":"string"},"description":"Full replacement list. Send [] to clear all topics."} + }, + "required":["owner","name","topics"] + }`), + } +} + +type repoTopicsUpdateArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Topics []string `json:"topics"` +} + +func (t *RepoTopicsUpdate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoTopicsUpdateArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if err := t.c.UpdateTopics(ctx, args.Owner, args.Name, args.Topics); err != nil { + return nil, err + } + return textOK(map[string]any{"status": "updated", "topics": args.Topics}) +} diff --git a/internal/tools/repo_topics_update_test.go b/internal/tools/repo_topics_update_test.go new file mode 100644 index 0000000..d4f4a6f --- /dev/null +++ b/internal/tools/repo_topics_update_test.go @@ -0,0 +1,35 @@ +package tools_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoTopicsUpdateTool(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/topics", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + tool := tools.NewRepoTopicsUpdate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","topics":["go","mcp","gitops"]}`)) + require.NoError(t, err) + assert.Contains(t, string(out), "updated") +} + +func TestRepoTopicsUpdateAllowlistRejects(t *testing.T) { + tool := tools.NewRepoTopicsUpdate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","topics":[]}`)) + require.Error(t, err) +} diff --git a/internal/tools/repo_tree.go b/internal/tools/repo_tree.go new file mode 100644 index 0000000..97ecdb0 --- /dev/null +++ b/internal/tools/repo_tree.go @@ -0,0 +1,56 @@ +package tools + +import ( + "context" + "encoding/json" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +type RepoTree struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoTree(c *gitea.Client, a *allowlist.Allowlist) *RepoTree { + return &RepoTree{c: c, a: a} +} + +func (t *RepoTree) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_tree", + Description: "Get the full recursive file tree for a repo ref (branch, tag, or SHA).", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "ref":{"type":"string","description":"Branch, tag, or commit SHA."} + }, + "required":["owner","name","ref"] + }`), + } +} + +type repoTreeArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Ref string `json:"ref"` +} + +func (t *RepoTree) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoTreeArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + tree, err := t.c.GetTree(ctx, args.Owner, args.Name, args.Ref, true) + if err != nil { + return nil, err + } + return textOK(tree) +} diff --git a/internal/tools/repo_tree_test.go b/internal/tools/repo_tree_test.go new file mode 100644 index 0000000..e9b0df3 --- /dev/null +++ b/internal/tools/repo_tree_test.go @@ -0,0 +1,50 @@ +package tools_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepoTreeTool(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/mathias/infra/git/trees/main", r.URL.Path) + assert.Equal(t, "1", r.URL.Query().Get("recursive")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sha":"abc","url":"http://x","tree":[{"path":"README.md","type":"blob","sha":"def","size":13},{"path":"internal","type":"tree","sha":"ghi","size":0}],"truncated":false}`)) + })) + defer srv.Close() + + tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"sha":"abc"`) + assert.Contains(t, string(out), `"path":"README.md"`) +} + +func TestRepoTreeTool_DefaultsToRecursive(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "1", r.URL.Query().Get("recursive")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sha":"abc","tree":[],"truncated":false}`)) + })) + defer srv.Close() + + tool := tools.NewRepoTree(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","ref":"main"}`)) + require.NoError(t, err) +} + +func TestRepoTreeAllowlistRejects(t *testing.T) { + tool := tools.NewRepoTree(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x","ref":"main"}`)) + require.Error(t, err) +}