From 61cce37ff5e646994ad942f672ef1efaefef7df8 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Mon, 4 May 2026 22:44:44 +0200 Subject: [PATCH] feat(tools): repo_search with allowlist post-filter Co-Authored-By: Claude Sonnet 4.6 --- cmd/gitea-mcp/main.go | 1 + internal/gitea/repos.go | 32 +++++++++++ internal/gitea/repos_test.go | 17 ++++++ internal/tools/repo_search.go | 92 ++++++++++++++++++++++++++++++ internal/tools/repo_search_test.go | 61 ++++++++++++++++++++ 5 files changed, 203 insertions(+) create mode 100644 internal/tools/repo_search.go create mode 100644 internal/tools/repo_search_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index fb8e013..f5ab79f 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -35,6 +35,7 @@ func main() { reg.Register(tools.NewPRGet(giteaClient, ownerAllow)) reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL)) reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow)) + reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow)) mcpSrv := mcp.NewServer(mcp.ServerOptions{ Registry: reg, diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index 729a2d1..a5fefe0 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" ) type Repo struct { @@ -38,6 +39,37 @@ func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ( return repos, nil } +type repoSearchEnvelope struct { + Data []Repo `json:"data"` + OK bool `json:"ok"` +} + +func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit int) ([]Repo, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 30 + } + path := fmt.Sprintf("/api/v1/repos/search?q=%s&page=%d&limit=%d", + url.QueryEscape(q), page, limit) + if owner != "" { + path += "&owner=" + url.QueryEscape(owner) + } + body, status, err := c.GetJSON(ctx, path) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var env repoSearchEnvelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, err + } + return env.Data, nil +} + func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) { path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name) body, status, err := c.GetJSON(ctx, path) diff --git a/internal/gitea/repos_test.go b/internal/gitea/repos_test.go index 5c1265d..ea4a197 100644 --- a/internal/gitea/repos_test.go +++ b/internal/gitea/repos_test.go @@ -11,6 +11,23 @@ import ( "github.com/stretchr/testify/require" ) +func TestSearchRepos(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/search", r.URL.Path) + assert.Equal(t, "infra", r.URL.Query().Get("q")) + assert.Equal(t, "mathias", r.URL.Query().Get("owner")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + repos, err := c.SearchRepos(context.Background(), "infra", "mathias", 1, 30) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "mathias/infra", repos[0].FullName) +} + func TestListRepos(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/v1/users/mathias/repos", r.URL.Path) diff --git a/internal/tools/repo_search.go b/internal/tools/repo_search.go new file mode 100644 index 0000000..26d8713 --- /dev/null +++ b/internal/tools/repo_search.go @@ -0,0 +1,92 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "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 RepoSearch struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoSearch(c *gitea.Client, a *allowlist.Allowlist) *RepoSearch { + return &RepoSearch{c: c, a: a} +} + +func (t *RepoSearch) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_search", + Description: "Search repos by query string. Filters results by owner allowlist.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "q":{"type":"string"}, + "owner":{"type":"string"}, + "page":{"type":"integer","minimum":1}, + "limit":{"type":"integer","minimum":1,"maximum":50} + }, + "required":["q"] + }`), + } +} + +type repoSearchArgs struct { + Q string `json:"q"` + Owner string `json:"owner"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoSearchArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if args.Q == "" { + return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation) + } + if args.Owner != "" { + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + } + if args.Page < 1 { + args.Page = 1 + } + if args.Limit < 1 || args.Limit > 50 { + args.Limit = 30 + } + + repos, err := t.c.SearchRepos(ctx, args.Q, args.Owner, args.Page, args.Limit) + if err != nil { + return nil, err + } + + // Post-filter when owner not specified — only allowlisted owners survive. + if args.Owner == "" { + filtered := make([]gitea.Repo, 0, len(repos)) + for _, r := range repos { + parts := strings.SplitN(r.FullName, "/", 2) + if len(parts) != 2 { + continue + } + if t.a.Check(parts[0]) == nil { + filtered = append(filtered, r) + } + } + repos = filtered + } + + out := map[string]any{"repos": repos} + if len(repos) == args.Limit { + out["next_page"] = args.Page + 1 + } + return textOK(out) +} diff --git a/internal/tools/repo_search_test.go b/internal/tools/repo_search_test.go new file mode 100644 index 0000000..163dcb9 --- /dev/null +++ b/internal/tools/repo_search_test.go @@ -0,0 +1,61 @@ +package tools_test + +import ( + "context" + "encoding/json" + "errors" + "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 TestRepoSearchWithOwner(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/search", r.URL.Path) + assert.Equal(t, "infra", r.URL.Query().Get("q")) + assert.Equal(t, "mathias", r.URL.Query().Get("owner")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`)) + })) + defer srv.Close() + + tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"mathias"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"full_name":"mathias/infra"`) +} + +func TestRepoSearchPostFiltersWithoutOwner(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // No owner param expected when owner is empty + assert.Empty(t, r.URL.Query().Get("owner")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"name":"x","full_name":"mathias/x"},{"name":"y","full_name":"evil/y"}],"ok":true}`)) + })) + defer srv.Close() + + tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"x"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"mathias/x"`) + assert.NotContains(t, string(out), `"evil/y"`) +} + +func TestRepoSearchAllowlistRejectsExplicitOwner(t *testing.T) { + tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"evil"}`)) + require.Error(t, err) +} + +func TestRepoSearchRequiresQ(t *testing.T) { + tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{}`)) + require.Error(t, err) + assert.True(t, errors.Is(err, gitea.ErrValidation)) +}