diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index f2a16c5..6d1e976 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -5,10 +5,13 @@ import ( "net/http" "os" + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/config" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/mcp" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" + "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" ) func main() { @@ -20,8 +23,11 @@ func main() { os.Exit(1) } + giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken) + ownerAllow := allowlist.New(cfg.AllowedOwners) + reg := registry.New() - // Tool registration happens in Phase 6+; for now, registry is empty. + reg.Register(tools.NewRepoList(giteaClient, ownerAllow)) mcpSrv := mcp.NewServer(mcp.ServerOptions{ Registry: reg, diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go new file mode 100644 index 0000000..729a2d1 --- /dev/null +++ b/internal/gitea/repos.go @@ -0,0 +1,55 @@ +package gitea + +import ( + "context" + "encoding/json" + "fmt" +) + +type Repo struct { + Name string `json:"name"` + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` + Description string `json:"description"` + Private bool `json:"private"` + CloneURL string `json:"clone_url"` + HTMLURL string `json:"html_url"` +} + +func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 30 + } + path := fmt.Sprintf("/api/v1/users/%s/repos?page=%d&limit=%d", owner, page, limit) + body, status, err := c.GetJSON(ctx, path) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var repos []Repo + if err := json.Unmarshal(body, &repos); err != nil { + return nil, err + } + return repos, 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) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var r Repo + if err := json.Unmarshal(body, &r); err != nil { + return nil, err + } + return &r, nil +} diff --git a/internal/gitea/repos_test.go b/internal/gitea/repos_test.go new file mode 100644 index 0000000..5c1265d --- /dev/null +++ b/internal/gitea/repos_test.go @@ -0,0 +1,30 @@ +package gitea_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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) + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "10", r.URL.Query().Get("limit")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"d","private":true}]`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + repos, err := c.ListRepos(context.Background(), "mathias", 1, 10) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "mathias/infra", repos[0].FullName) + assert.Equal(t, "main", repos[0].DefaultBranch) +} diff --git a/internal/tools/repo_list.go b/internal/tools/repo_list.go new file mode 100644 index 0000000..24523ad --- /dev/null +++ b/internal/tools/repo_list.go @@ -0,0 +1,68 @@ +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 RepoList struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewRepoList(c *gitea.Client, a *allowlist.Allowlist) *RepoList { + return &RepoList{c: c, a: a} +} + +func (t *RepoList) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "repo_list", + Description: "List repos for a given owner.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "page":{"type":"integer","minimum":1}, + "limit":{"type":"integer","minimum":1,"maximum":50} + }, + "required":["owner"] + }`), + } +} + +type repoListArgs struct { + Owner string `json:"owner"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +func (t *RepoList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args repoListArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.Limit == 0 || args.Limit > 50 { + args.Limit = 30 + } + if args.Page == 0 { + args.Page = 1 + } + repos, err := t.c.ListRepos(ctx, args.Owner, args.Page, args.Limit) + if err != nil { + return nil, err + } + 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_list_test.go b/internal/tools/repo_list_test.go new file mode 100644 index 0000000..8a06e39 --- /dev/null +++ b/internal/tools/repo_list_test.go @@ -0,0 +1,34 @@ +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 TestRepoListTool(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}]`)) + })) + defer srv.Close() + + tool := tools.NewRepoList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"full_name":"mathias/infra"`) +} + +func TestRepoListAllowlistRejects(t *testing.T) { + tool := tools.NewRepoList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil"}`)) + require.Error(t, err) +}