feat(tools): repo_tree, repo_topics_update, file_read dir fix (#14, #15, #18) #22

Merged
mathias merged 1 commits from feat/repo-ux into main 2026-05-15 08:24:35 +00:00
9 changed files with 299 additions and 0 deletions
Showing only changes of commit 5f3ad99122 - Show all commits

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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"}`))

View File

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

View File

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

View File

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

View File

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