From cd82f6f207d8041b440509d79cef8e1a428fceaf Mon Sep 17 00:00:00 2001 From: Skyf0l <59019720+skyf0l@users.noreply.github.com> Date: Sun, 10 May 2026 11:42:01 +0200 Subject: [PATCH] Add package listing and management tools (#170) Adds `package_read` and `package_write` MCP tools for the Gitea Packages API. - `package_read` (read): `list`, `list_versions`, `get` - `package_write` (write): `delete` Package names containing slashes (e.g. container image paths like `my-repo/my-image`) are accepted raw or pre-encoded and URL-encoded correctly without double-encoding. Co-Authored-By: silverwind Co-Authored-By: Claude (Opus 4.7) --- operation/operation.go | 4 + operation/packages/packages.go | 225 ++++++++++++++++ operation/packages/packages_test.go | 381 ++++++++++++++++++++++++++++ operation/packages/slim.go | 43 ++++ 4 files changed, 653 insertions(+) create mode 100644 operation/packages/packages.go create mode 100644 operation/packages/packages_test.go create mode 100644 operation/packages/slim.go diff --git a/operation/operation.go b/operation/operation.go index 665beb1..597e692 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -16,6 +16,7 @@ import ( "gitea.com/gitea/gitea-mcp/operation/label" "gitea.com/gitea/gitea-mcp/operation/milestone" "gitea.com/gitea/gitea-mcp/operation/notification" + "gitea.com/gitea/gitea-mcp/operation/packages" "gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/repo" "gitea.com/gitea/gitea-mcp/operation/search" @@ -54,6 +55,9 @@ func RegisterTool(s *server.MCPServer) { // Milestone Tool s.AddTools(milestone.Tool.Tools()...) + // Package Tool + s.AddTools(packages.Tool.Tools()...) + // Pull Tool s.AddTools(pull.Tool.Tools()...) diff --git a/operation/packages/packages.go b/operation/packages/packages.go new file mode 100644 index 0000000..37b2c66 --- /dev/null +++ b/operation/packages/packages.go @@ -0,0 +1,225 @@ +package packages + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "gitea.com/gitea/gitea-mcp/pkg/annotation" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/params" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + PackageReadToolName = "package_read" + PackageWriteToolName = "package_write" +) + +var ( + PackageReadTool = mcp.NewTool( + PackageReadToolName, + mcp.WithToolAnnotation(annotation.ReadOnly("Read package registry")), + mcp.WithDescription("Read package registry information. Use method 'list' to list all packages of an owner (returns one entry per version, use 'q' or 'type' to filter), 'list_versions' to list versions of a specific package, 'get' to get details of a specific package version."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("list", "list_versions", "get")), + mcp.WithString("owner", mcp.Required(), mcp.Description("package owner (user or org)")), + mcp.WithString("type", mcp.Description("package type, e.g. container, npm, maven, pypi, cargo, generic (optional filter for 'list', required for 'list_versions' and 'get')")), + mcp.WithString("name", mcp.Description("package name, slashes encoded automatically e.g. 'my-repo/my-image' (required for 'list_versions' and 'get')")), + mcp.WithString("version", mcp.Description("package version (required for 'get')")), + mcp.WithString("q", mcp.Description("search query (for 'list')")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("perPage", mcp.Description("results per page (may be capped by the server's MAX_RESPONSE_ITEMS setting, default 50)"), mcp.DefaultNumber(30), mcp.Min(1)), + ) + + PackageWriteTool = mcp.NewTool( + PackageWriteToolName, + mcp.WithToolAnnotation(annotation.Destructive("Delete a package version")), + mcp.WithDescription("Modify the package registry. Use method 'delete' to delete a specific package version. This is destructive and irreversible."), + mcp.WithString("method", mcp.Required(), mcp.Description("operation to perform"), mcp.Enum("delete")), + mcp.WithString("owner", mcp.Required(), mcp.Description("package owner (user or org)")), + mcp.WithString("type", mcp.Required(), mcp.Description("package type, e.g. container, npm, maven, pypi, cargo, generic")), + mcp.WithString("name", mcp.Required(), mcp.Description("package name, slashes encoded automatically e.g. 'my-repo/my-image'")), + mcp.WithString("version", mcp.Required(), mcp.Description("package version")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{ + Tool: PackageReadTool, + Handler: packageReadFn, + }) + Tool.RegisterWrite(server.ServerTool{ + Tool: PackageWriteTool, + Handler: packageWriteFn, + }) +} + +func packageReadFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "list": + return listPackagesFn(ctx, req) + case "list_versions": + return listPackageVersionsFn(ctx, req) + case "get": + return getPackageFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +func packageWriteFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + method, err := params.GetString(args, "method") + if err != nil { + return to.ErrorResult(err) + } + switch method { + case "delete": + return deletePackageVersionFn(ctx, req) + default: + return to.ErrorResult(fmt.Errorf("unknown method: %s", method)) + } +} + +// escapePackageName normalises a package name for use in URL paths. It +// accepts both raw names (my-repo/my-image) and pre-encoded names +// (my-repo%2Fmy-image), decoding first to avoid double-encoding. A literal +// '%' followed by two hex digits in a raw name will be folded into its +// decoded form, but package names typically do not contain '%'. +func escapePackageName(name string) string { + if strings.Contains(name, "%") { + if decoded, err := url.PathUnescape(name); err == nil { + name = decoded + } + } + return url.PathEscape(name) +} + +func listPackagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listPackagesFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + + query := url.Values{} + if typ, ok := args["type"].(string); ok && typ != "" { + query.Set("type", typ) + } + if q, ok := args["q"].(string); ok && q != "" { + query.Set("q", q) + } + page, pageSize := params.GetPagination(args, 30) + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + + var result any + _, err = gitea.DoJSON(ctx, "GET", "packages/"+url.PathEscape(owner), query, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("list packages err: %v", err)) + } + + return to.TextResult(slimPackages(result)) +} + +func listPackageVersionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called listPackageVersionsFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + typ, err := params.GetString(args, "type") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + + query := url.Values{} + page, pageSize := params.GetPagination(args, 30) + query.Set("page", strconv.Itoa(page)) + query.Set("limit", strconv.Itoa(pageSize)) + + var result any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("packages/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name)), query, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("list package versions err: %v", err)) + } + + return to.TextResult(slimPackages(result)) +} + +func getPackageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called getPackageFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + typ, err := params.GetString(args, "type") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + version, err := params.GetString(args, "version") + if err != nil { + return to.ErrorResult(err) + } + + var result any + _, err = gitea.DoJSON(ctx, "GET", fmt.Sprintf("packages/%s/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name), url.PathEscape(version)), nil, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("get package err: %v", err)) + } + + return to.TextResult(slimPackage(result)) +} + +func deletePackageVersionFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called deletePackageVersionFn") + args := req.GetArguments() + owner, err := params.GetString(args, "owner") + if err != nil { + return to.ErrorResult(err) + } + typ, err := params.GetString(args, "type") + if err != nil { + return to.ErrorResult(err) + } + name, err := params.GetString(args, "name") + if err != nil { + return to.ErrorResult(err) + } + version, err := params.GetString(args, "version") + if err != nil { + return to.ErrorResult(err) + } + + _, err = gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("packages/%s/%s/%s/%s", url.PathEscape(owner), url.PathEscape(typ), escapePackageName(name), url.PathEscape(version)), nil, nil, nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete package version err: %v", err)) + } + + return to.TextResult("Package version deleted successfully") +} diff --git a/operation/packages/packages_test.go b/operation/packages/packages_test.go new file mode 100644 index 0000000..38dc8c5 --- /dev/null +++ b/operation/packages/packages_test.go @@ -0,0 +1,381 @@ +package packages + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" + + "github.com/mark3labs/mcp-go/mcp" +) + +func TestPackageReadList(t *testing.T) { + var mu sync.Mutex + var gotQuery map[string]string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotQuery = map[string]string{} + for k, v := range r.URL.Query() { + gotQuery[k] = v[0] + } + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"},"creator":{"login":"admin"}}]`)) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + t.Run("basic list", func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "list", + "owner": "test-org", + } + + result, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + if result.IsError { + t.Fatal("packageReadFn() returned error result") + } + + text := result.Content[0].(mcp.TextContent).Text + var packages []map[string]any + if err := json.Unmarshal([]byte(text), &packages); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + if len(packages) != 1 { + t.Fatalf("expected 1 package, got %d", len(packages)) + } + if packages[0]["name"] != "myrepo/myimage" { + t.Errorf("expected name 'myrepo/myimage', got %v", packages[0]["name"]) + } + if packages[0]["owner"] != "test-org" { + t.Errorf("expected owner 'test-org', got %v", packages[0]["owner"]) + } + }) + + t.Run("with type and query filters", func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "list", + "owner": "test-org", + "type": "container", + "q": "myimage", + } + + _, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if gotQuery["type"] != "container" { + t.Errorf("expected type=container, got %q", gotQuery["type"]) + } + if gotQuery["q"] != "myimage" { + t.Errorf("expected q=myimage, got %q", gotQuery["q"]) + } + }) + + t.Run("with pagination", func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "list", + "owner": "test-org", + "page": float64(2), + "perPage": float64(10), + } + + _, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if gotQuery["page"] != "2" { + t.Errorf("expected page=2, got %q", gotQuery["page"]) + } + if gotQuery["limit"] != "10" { + t.Errorf("expected limit=10, got %q", gotQuery["limit"]) + } + }) +} + +func TestPackageReadListVersions(t *testing.T) { + var mu sync.Mutex + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotPath = r.URL.RawPath + if gotPath == "" { + gotPath = r.URL.Path + } + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`[{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0"},{"id":2,"type":"container","name":"myrepo/myimage","version":"v2.0.0"}]`)) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + tests := []struct { + testName string + name string + }{ + {"raw slash", "myrepo/myimage"}, + {"pre-encoded slash", "myrepo%2Fmyimage"}, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "list_versions", + "owner": "test-org", + "type": "container", + "name": tt.name, + } + + result, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + if result.IsError { + t.Fatal("packageReadFn() returned error result") + } + + mu.Lock() + wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage" + if gotPath != wantPath { + t.Errorf("request path = %q, want %q", gotPath, wantPath) + } + mu.Unlock() + + text := result.Content[0].(mcp.TextContent).Text + var versions []map[string]any + if err := json.Unmarshal([]byte(text), &versions); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + if len(versions) != 2 { + t.Fatalf("expected 2 versions, got %d", len(versions)) + } + }) + } +} + +func TestPackageReadGet(t *testing.T) { + var mu sync.Mutex + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotPath = r.URL.RawPath + if gotPath == "" { + gotPath = r.URL.Path + } + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id":1,"type":"container","name":"myrepo/myimage","version":"v1.0.0","html_url":"http://example.com","created_at":"2025-01-01T00:00:00Z","owner":{"login":"test-org"}}`)) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + tests := []struct { + testName string + name string + }{ + {"raw slash", "myrepo/myimage"}, + {"pre-encoded slash", "myrepo%2Fmyimage"}, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "get", + "owner": "test-org", + "type": "container", + "name": tt.name, + "version": "v1.0.0", + } + + result, err := packageReadFn(ctx, req) + if err != nil { + t.Fatalf("packageReadFn() error: %v", err) + } + if result.IsError { + t.Fatal("packageReadFn() returned error result") + } + + mu.Lock() + wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0" + if gotPath != wantPath { + t.Errorf("request path = %q, want %q", gotPath, wantPath) + } + mu.Unlock() + + text := result.Content[0].(mcp.TextContent).Text + var pkg map[string]any + if err := json.Unmarshal([]byte(text), &pkg); err != nil { + t.Fatalf("failed to unmarshal result: %v", err) + } + if pkg["name"] != "myrepo/myimage" { + t.Errorf("expected name 'myrepo/myimage', got %v", pkg["name"]) + } + if pkg["version"] != "v1.0.0" { + t.Errorf("expected version 'v1.0.0', got %v", pkg["version"]) + } + }) + } +} + +func TestPackageWriteDelete(t *testing.T) { + var mu sync.Mutex + var gotMethod string + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotMethod = r.Method + gotPath = r.URL.RawPath + if gotPath == "" { + gotPath = r.URL.Path + } + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + origHost := flag.Host + flag.Host = srv.URL + defer func() { flag.Host = origHost }() + + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "test-token") + + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "delete", + "owner": "test-org", + "type": "container", + "name": "myrepo/myimage", + "version": "v1.0.0", + } + + result, err := packageWriteFn(ctx, req) + if err != nil { + t.Fatalf("packageWriteFn() error: %v", err) + } + if result.IsError { + t.Fatal("packageWriteFn() returned error result") + } + + mu.Lock() + defer mu.Unlock() + if gotMethod != "DELETE" { + t.Errorf("expected DELETE method, got %q", gotMethod) + } + wantPath := "/api/v1/packages/test-org/container/myrepo%2Fmyimage/v1.0.0" + if gotPath != wantPath { + t.Errorf("request path = %q, want %q", gotPath, wantPath) + } +} + +func TestPackageReadUnknownMethod(t *testing.T) { + ctx := context.Background() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "bogus", + "owner": "test-org", + } + if _, err := packageReadFn(ctx, req); err == nil { + t.Fatal("expected error for unknown method") + } +} + +func TestPackageWriteUnknownMethod(t *testing.T) { + ctx := context.Background() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "method": "bogus", + "owner": "test-org", + } + if _, err := packageWriteFn(ctx, req); err == nil { + t.Fatal("expected error for unknown method") + } +} + +func TestEscapePackageName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"simple name", "mypackage", "mypackage"}, + {"raw slash", "myrepo/myimage", "myrepo%2Fmyimage"}, + {"pre-encoded slash", "myrepo%2Fmyimage", "myrepo%2Fmyimage"}, + {"pre-encoded uppercase", "myrepo%2Fmyimage", "myrepo%2Fmyimage"}, + {"multiple slashes", "a/b/c", "a%2Fb%2Fc"}, + {"pre-encoded multiple slashes", "a%2Fb%2Fc", "a%2Fb%2Fc"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := escapePackageName(tt.input) + if got != tt.expected { + t.Errorf("escapePackageName(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestSlimPackage(t *testing.T) { + raw := map[string]any{ + "id": float64(1), + "type": "container", + "name": "test-repo/test-image", + "version": "v1.0.0", + "html_url": "http://example.com/pkg", + "created_at": "2025-01-01T00:00:00Z", + "owner": map[string]any{"login": "test-org", "id": float64(2), "email": ""}, + "creator": map[string]any{"login": "admin", "id": float64(1)}, + "repository": map[string]any{"full_name": "test-org/test-repo", "id": float64(3)}, + } + + slim := slimPackage(raw) + if slim["owner"] != "test-org" { + t.Errorf("expected owner 'test-org', got %v", slim["owner"]) + } + if slim["creator"] != "admin" { + t.Errorf("expected creator 'admin', got %v", slim["creator"]) + } + if slim["repository"] != "test-org/test-repo" { + t.Errorf("expected repository 'test-org/test-repo', got %v", slim["repository"]) + } + if _, ok := slim["owner"].(map[string]any); ok { + t.Error("expected owner to be a string, not a map") + } +} diff --git a/operation/packages/slim.go b/operation/packages/slim.go new file mode 100644 index 0000000..98f04b9 --- /dev/null +++ b/operation/packages/slim.go @@ -0,0 +1,43 @@ +package packages + +func slimPackage(v any) map[string]any { + m, ok := v.(map[string]any) + if !ok { + return nil + } + out := map[string]any{ + "id": m["id"], + "type": m["type"], + "name": m["name"], + "version": m["version"], + "html_url": m["html_url"], + "created_at": m["created_at"], + } + if owner, ok := m["owner"].(map[string]any); ok { + out["owner"] = owner["login"] + } + if creator, ok := m["creator"].(map[string]any); ok { + out["creator"] = creator["login"] + } + if repo, ok := m["repository"].(map[string]any); ok { + out["repository"] = repo["full_name"] + } + return out +} + +func slimPackages(v any) any { + switch val := v.(type) { + case []any: + out := make([]map[string]any, 0, len(val)) + for _, item := range val { + if slim := slimPackage(item); slim != nil { + out = append(out, slim) + } + } + return out + case map[string]any: + return slimPackage(val) + default: + return v + } +}