From f0ffeb6bdba2b1d18f5874e6c7f4bca0a439ed90 Mon Sep 17 00:00:00 2001 From: MayorFaj Date: Sun, 4 Oct 2026 18:44:10 +0100 Subject: [PATCH 1/3] feat(scopes): add delete:packages OAuth scope Add the delete:packages scope to the supported OAuth scope catalog without requesting it by default, so it is only requested through a per-tool scope challenge when package deletion is used. --- pkg/http/oauth/oauth_test.go | 1 + pkg/scopes/scopes.go | 4 ++++ pkg/scopes/scopes_test.go | 2 ++ 3 files changed, 7 insertions(+) diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 515a0a471f..dcd38855f1 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -676,6 +676,7 @@ func TestSupportedScopes(t *testing.T) { "user:email", "read:packages", "write:packages", + "delete:packages", "read:project", "project", "gist", diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index 2294ac01ef..391e10503c 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -69,6 +69,9 @@ const ( // WritePackages grants write access to packages WritePackages Scope = "write:packages" + // DeletePackages grants permission to delete packages + DeletePackages Scope = "delete:packages" + // Workflow grants permission to update GitHub Actions workflow files Workflow Scope = "workflow" @@ -92,6 +95,7 @@ var oauthScopeDefinitions = []oauthScopeDefinition{ {scope: UserEmail, byDefault: true}, {scope: ReadPackages, byDefault: true}, {scope: WritePackages, byDefault: true}, + {scope: DeletePackages}, {scope: ReadProject, byDefault: true}, {scope: Project, byDefault: true}, {scope: Gist, byDefault: true}, diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index e9deda9953..190ab55668 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -13,6 +13,8 @@ func TestOAuthScopeCatalog(t *testing.T) { assert.Subset(t, supported, defaults) assert.Contains(t, supported, string(DeleteRepo)) assert.NotContains(t, defaults, string(DeleteRepo)) + assert.Contains(t, supported, string(DeletePackages)) + assert.NotContains(t, defaults, string(DeletePackages)) assert.Contains(t, supported, string(Workflow)) assert.NotContains(t, defaults, string(Workflow)) assert.Contains(t, supported, string(Codespace)) From c8a0139e0b33ba622755e9f2709be5b2900c5fee Mon Sep 17 00:00:00 2001 From: MayorFaj Date: Sun, 4 Oct 2026 18:44:10 +0100 Subject: [PATCH 2/3] feat: add GitHub Packages toolset Add an opt-in "packages" toolset with two consolidated tools: - packages_read: list/get packages and package versions for organizations and users (the authenticated user when username is omitted), with package_type/visibility/state filters and pagination. - packages_write: delete packages or individual package versions for organizations and users. Marked destructive and requires read:packages and delete:packages. Closes #1208 --- README.md | 47 ++ docs/remote-server.md | 1 + pkg/github/__toolsnaps__/packages_read.snap | 87 ++++ pkg/github/__toolsnaps__/packages_write.snap | 58 +++ pkg/github/packages.go | 467 +++++++++++++++++++ pkg/github/packages_test.go | 454 ++++++++++++++++++ pkg/github/tools.go | 9 + pkg/octicons/icons/package-dark.png | Bin 0 -> 519 bytes pkg/octicons/icons/package-light.png | Bin 0 -> 827 bytes pkg/octicons/icons_data_uris.txt | 2 + pkg/octicons/required_icons.txt | 1 + 11 files changed, 1126 insertions(+) create mode 100644 pkg/github/__toolsnaps__/packages_read.snap create mode 100644 pkg/github/__toolsnaps__/packages_write.snap create mode 100644 pkg/github/packages.go create mode 100644 pkg/github/packages_test.go create mode 100644 pkg/octicons/icons/package-dark.png create mode 100644 pkg/octicons/icons/package-light.png diff --git a/README.md b/README.md index c1f9857d1a..98043ec026 100644 --- a/README.md +++ b/README.md @@ -597,6 +597,7 @@ The following sets of tools are available: | tag | `labels` | GitHub Labels related tools | | bell | `notifications` | GitHub Notifications related tools | | organization | `orgs` | GitHub Organization related tools | +| package | `packages` | GitHub Packages related tools | | project | `projects` | GitHub Projects related tools | | git-pull-request | `pull_requests` | GitHub Pull Request related tools | | repo | `repos` | GitHub Repository related tools | @@ -1165,6 +1166,52 @@ The following sets of tools are available:
+package Packages + +- **packages_read** - Read package information + - **OAuth Challenge Scopes**: `read:packages` + - `method`: The read operation to perform. + Options are: + - 'list_org_packages' - list packages for an organization. Requires 'org'. Optional filters: 'package_type', 'visibility'. + - 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'. + - 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'. + - 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. + - 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Optional filters: 'package_type', 'visibility'. + - 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'. + - 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'. + - 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. + + Download statistics are not available through the GitHub REST API. (string, required) + - `org`: Organization name. Required for org_* methods. (string, optional) + - `package_name`: Package name. Required for every method except list_org_packages and list_user_packages. (string, optional) + - `package_type`: Package type. Required for every method except list_org_packages and list_user_packages, where it is an optional filter. (string, optional) + - `package_version_id`: Package version ID. Required for methods that target a single version. (number, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `state`: Filter package versions by state (list_*_package_versions only). (string, optional) + - `username`: GitHub username for user_* methods. Omit to target the authenticated user. (string, optional) + - `visibility`: Filter packages by visibility (list_org_packages and list_user_packages only). (string, optional) + +- **packages_write** - Delete packages + - **OAuth Challenge Scopes**: `read:packages`, `delete:packages` + - `method`: The delete operation to perform. + Options are: + - 'delete_org_package' - delete an entire organization package, including all versions. Requires 'org', 'package_type', 'package_name'. + - 'delete_org_package_version' - delete a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. + - 'delete_user_package' - delete an entire user package, including all versions. Requires 'package_type', 'package_name'. Omit 'username' to target the authenticated user. + - 'delete_user_package_version' - delete a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. Omit 'username' to target the authenticated user. + + Deleted packages and versions can be restored from the GitHub web interface within 30 days. (string, required) + - `org`: Organization name. Required for org_* methods. (string, optional) + - `package_name`: Package name. (string, required) + - `package_type`: Package type. (string, required) + - `package_version_id`: Package version ID. Required for methods that target a single version. (number, optional) + - `username`: GitHub username for user_* methods. Omit to target the authenticated user. (string, optional) + +
+ +
+ project Projects - **projects_get** - Get details of GitHub Projects resources diff --git a/docs/remote-server.md b/docs/remote-server.md index be91009148..fad2351a52 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -35,6 +35,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | | bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | | organization
`orgs` | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| package
`packages` | GitHub Packages related tools | https://api.githubcopilot.com/mcp/x/packages | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-packages&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpackages%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/packages/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-packages&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpackages%2Freadonly%22%7D) | | project
`projects` | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | | git-pull-request
`pull_requests` | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | | repo
`repos` | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/packages_read.snap b/pkg/github/__toolsnaps__/packages_read.snap new file mode 100644 index 0000000000..4d03ed8331 --- /dev/null +++ b/pkg/github/__toolsnaps__/packages_read.snap @@ -0,0 +1,87 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Read package information" + }, + "description": "Get information about GitHub Packages owned by organizations and users. Supports listing packages, getting package details, and inspecting package versions.", + "inputSchema": { + "properties": { + "method": { + "description": "The read operation to perform.\nOptions are:\n- 'list_org_packages' - list packages for an organization. Requires 'org'. Optional filters: 'package_type', 'visibility'.\n- 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'.\n- 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'.\n- 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'.\n- 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Optional filters: 'package_type', 'visibility'.\n- 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'.\n- 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'.\n- 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'.\n\nDownload statistics are not available through the GitHub REST API.", + "enum": [ + "list_org_packages", + "get_org_package", + "list_org_package_versions", + "get_org_package_version", + "list_user_packages", + "get_user_package", + "list_user_package_versions", + "get_user_package_version" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required for org_* methods.", + "type": "string" + }, + "package_name": { + "description": "Package name. Required for every method except list_org_packages and list_user_packages.", + "type": "string" + }, + "package_type": { + "description": "Package type. Required for every method except list_org_packages and list_user_packages, where it is an optional filter.", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + }, + "package_version_id": { + "description": "Package version ID. Required for methods that target a single version.", + "type": "number" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "state": { + "description": "Filter package versions by state (list_*_package_versions only).", + "enum": [ + "active", + "deleted" + ], + "type": "string" + }, + "username": { + "description": "GitHub username for user_* methods. Omit to target the authenticated user.", + "type": "string" + }, + "visibility": { + "description": "Filter packages by visibility (list_org_packages and list_user_packages only).", + "enum": [ + "public", + "private", + "internal" + ], + "type": "string" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "name": "packages_read" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/packages_write.snap b/pkg/github/__toolsnaps__/packages_write.snap new file mode 100644 index 0000000000..9b60a4de5b --- /dev/null +++ b/pkg/github/__toolsnaps__/packages_write.snap @@ -0,0 +1,58 @@ +{ + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "readOnlyHint": false, + "title": "Delete packages" + }, + "description": "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope.", + "inputSchema": { + "properties": { + "method": { + "description": "The delete operation to perform.\nOptions are:\n- 'delete_org_package' - delete an entire organization package, including all versions. Requires 'org', 'package_type', 'package_name'.\n- 'delete_org_package_version' - delete a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'.\n- 'delete_user_package' - delete an entire user package, including all versions. Requires 'package_type', 'package_name'. Omit 'username' to target the authenticated user.\n- 'delete_user_package_version' - delete a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. Omit 'username' to target the authenticated user.\n\nDeleted packages and versions can be restored from the GitHub web interface within 30 days.", + "enum": [ + "delete_org_package", + "delete_org_package_version", + "delete_user_package", + "delete_user_package_version" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required for org_* methods.", + "type": "string" + }, + "package_name": { + "description": "Package name.", + "type": "string" + }, + "package_type": { + "description": "Package type.", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + }, + "package_version_id": { + "description": "Package version ID. Required for methods that target a single version.", + "type": "number" + }, + "username": { + "description": "GitHub username for user_* methods. Omit to target the authenticated user.", + "type": "string" + } + }, + "required": [ + "method", + "package_type", + "package_name" + ], + "type": "object" + }, + "name": "packages_write" +} \ No newline at end of file diff --git a/pkg/github/packages.go b/pkg/github/packages.go new file mode 100644 index 0000000000..98d2e7a470 --- /dev/null +++ b/pkg/github/packages.go @@ -0,0 +1,467 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// NOTE: GitHub's REST API for packages does not expose download statistics. +// While download counts are visible on the GitHub web interface, they are not +// included in the API responses. + +var packageTypes = []any{"npm", "maven", "rubygems", "docker", "nuget", "container"} + +// packageCoordinates identifies a package (and optionally a version) owned by +// an organization or a user. An empty user refers to the authenticated user. +type packageCoordinates struct { + owner string + packageType string + packageName string + versionID int64 +} + +func packageOwnerSchema() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "org": { + Type: "string", + Description: "Organization name. Required for org_* methods.", + }, + "username": { + Type: "string", + Description: "GitHub username for user_* methods. Omit to target the authenticated user.", + }, + "package_type": { + Type: "string", + Description: "Package type. Required for every method except list_org_packages and list_user_packages, where it is an optional filter.", + Enum: packageTypes, + }, + "package_name": { + Type: "string", + Description: "Package name. Required for every method except list_org_packages and list_user_packages.", + }, + "package_version_id": { + Type: "number", + Description: "Package version ID. Required for methods that target a single version.", + }, + } +} + +// requirePackageOwner reads the owner for an org_* or user_* method. +func requirePackageOwner(args map[string]any, isOrg bool) (string, error) { + if isOrg { + return RequiredParam[string](args, "org") + } + return OptionalParam[string](args, "username") +} + +// requirePackage reads the owner, package type, and package name, and the +// version ID when withVersion is true. +func requirePackage(args map[string]any, isOrg, withVersion bool) (packageCoordinates, error) { + owner, err := requirePackageOwner(args, isOrg) + if err != nil { + return packageCoordinates{}, err + } + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return packageCoordinates{}, err + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return packageCoordinates{}, err + } + c := packageCoordinates{owner: owner, packageType: packageType, packageName: packageName} + if withVersion { + c.versionID, err = RequiredBigInt(args, "package_version_id") + if err != nil { + return packageCoordinates{}, err + } + } + return c, nil +} + +// describeOwner returns a human-readable owner for error messages. +func describeOwner(owner string, isOrg bool) string { + switch { + case isOrg: + return fmt.Sprintf("organization '%s'", owner) + case owner == "": + return "the authenticated user" + default: + return fmt.Sprintf("user '%s'", owner) + } +} + +// PackagesRead creates a consolidated tool to read GitHub Packages information. +func PackagesRead(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := packageOwnerSchema() + properties["method"] = &jsonschema.Schema{ + Type: "string", + Description: `The read operation to perform. +Options are: +- 'list_org_packages' - list packages for an organization. Requires 'org'. Optional filters: 'package_type', 'visibility'. +- 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'. +- 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'. +- 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. +- 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Optional filters: 'package_type', 'visibility'. +- 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'. +- 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'. +- 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. + +Download statistics are not available through the GitHub REST API.`, + Enum: []any{ + "list_org_packages", "get_org_package", "list_org_package_versions", "get_org_package_version", + "list_user_packages", "get_user_package", "list_user_package_versions", "get_user_package_version", + }, + } + properties["visibility"] = &jsonschema.Schema{ + Type: "string", + Description: "Filter packages by visibility (list_org_packages and list_user_packages only).", + Enum: []any{"public", "private", "internal"}, + } + properties["state"] = &jsonschema.Schema{ + Type: "string", + Description: "Filter package versions by state (list_*_package_versions only).", + Enum: []any{"active", "deleted"}, + } + schema := &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: []string{"method"}, + } + WithPagination(schema) + + return NewTool( + ToolsetMetadataPackages, + mcp.Tool{ + Name: "packages_read", + Description: t("TOOL_PACKAGES_READ_DESCRIPTION", "Get information about GitHub Packages owned by organizations and users. Supports listing packages, getting package details, and inspecting package versions."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PACKAGES_READ_USER_TITLE", "Read package information"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + scopes.RequireAll(scopes.ReadPackages), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch method { + case "list_org_packages": + return listPackages(ctx, client, args, true) + case "list_user_packages": + return listPackages(ctx, client, args, false) + case "get_org_package": + return getPackage(ctx, client, args, true) + case "get_user_package": + return getPackage(ctx, client, args, false) + case "list_org_package_versions": + return listPackageVersions(ctx, client, args, true) + case "list_user_package_versions": + return listPackageVersions(ctx, client, args, false) + case "get_org_package_version": + return getPackageVersion(ctx, client, args, true) + case "get_user_package_version": + return getPackageVersion(ctx, client, args, false) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) +} + +func listPackages(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, error) { + owner, err := requirePackageOwner(args, isOrg) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + packageType, err := OptionalParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + visibility, err := OptionalParam[string](args, "visibility") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + opts := &github.PackageListOptions{ + PackageType: ToStringPtr(packageType), + Visibility: ToStringPtr(visibility), + ListOptions: github.ListOptions{Page: pagination.Page, PerPage: pagination.PerPage}, + } + + var packages []*github.Package + var resp *github.Response + if isOrg { + packages, resp, err = client.Organizations.ListPackages(ctx, owner, opts) + } else { + packages, resp, err = client.Users.ListPackages(ctx, owner, opts) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list packages for %s", describeOwner(owner, isOrg)), + resp, + err, + ), nil, nil + } + + return MarshalledTextResult(packages), nil, nil +} + +func getPackage(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, error) { + c, err := requirePackage(args, isOrg, false) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var pkg *github.Package + var resp *github.Response + if isOrg { + pkg, resp, err = client.Organizations.GetPackage(ctx, c.owner, c.packageType, c.packageName) + } else { + pkg, resp, err = client.Users.GetPackage(ctx, c.owner, c.packageType, c.packageName) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get %s package '%s' for %s", c.packageType, c.packageName, describeOwner(c.owner, isOrg)), + resp, + err, + ), nil, nil + } + + return MarshalledTextResult(pkg), nil, nil +} + +func listPackageVersions(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, error) { + c, err := requirePackage(args, isOrg, false) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + listOptions := github.ListOptions{Page: pagination.Page, PerPage: pagination.PerPage} + + var versions []*github.PackageVersion + var resp *github.Response + switch { + case isOrg: + versions, resp, err = client.Organizations.PackageGetAllVersions(ctx, c.owner, c.packageType, c.packageName, + &github.PackageListOptions{State: ToStringPtr(state), ListOptions: listOptions}) + case c.owner == "": + versions, resp, err = client.Users.ListPackageVersions(ctx, c.packageType, c.packageName, + &github.ListPackageVersionsOptions{State: state, ListOptions: listOptions}) + default: + versions, resp, err = listOtherUserPackageVersions(ctx, client, c, state, listOptions) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list versions of %s package '%s' for %s", c.packageType, c.packageName, describeOwner(c.owner, isOrg)), + resp, + err, + ), nil, nil + } + + return MarshalledTextResult(versions), nil, nil +} + +func getPackageVersion(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, error) { + c, err := requirePackage(args, isOrg, true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var version *github.PackageVersion + var resp *github.Response + if isOrg { + version, resp, err = client.Organizations.PackageGetVersion(ctx, c.owner, c.packageType, c.packageName, c.versionID) + } else { + version, resp, err = client.Users.PackageGetVersion(ctx, c.owner, c.packageType, c.packageName, c.versionID) + } + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get version %d of %s package '%s' for %s", c.versionID, c.packageType, c.packageName, describeOwner(c.owner, isOrg)), + resp, + err, + ), nil, nil + } + + return MarshalledTextResult(version), nil, nil +} + +// listOtherUserPackageVersions lists versions of a package owned by another +// user. go-github's Users.ListUserPackageVersions does not accept list +// options, although the endpoint supports state filtering and pagination. +func listOtherUserPackageVersions(ctx context.Context, client *github.Client, c packageCoordinates, state string, listOptions github.ListOptions) ([]*github.PackageVersion, *github.Response, error) { + query := url.Values{} + if state != "" { + query.Set("state", state) + } + if listOptions.Page > 0 { + query.Set("page", strconv.Itoa(listOptions.Page)) + } + if listOptions.PerPage > 0 { + query.Set("per_page", strconv.Itoa(listOptions.PerPage)) + } + apiURL := fmt.Sprintf("users/%s/packages/%s/%s/versions?%s", + url.PathEscape(c.owner), url.PathEscape(c.packageType), url.PathEscape(c.packageName), query.Encode()) + + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, nil, err + } + var versions []*github.PackageVersion + resp, err := client.Do(req, &versions) + return versions, resp, err +} + +// PackagesWrite creates a consolidated tool to delete GitHub Packages and package versions. +func PackagesWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := packageOwnerSchema() + properties["method"] = &jsonschema.Schema{ + Type: "string", + Description: `The delete operation to perform. +Options are: +- 'delete_org_package' - delete an entire organization package, including all versions. Requires 'org', 'package_type', 'package_name'. +- 'delete_org_package_version' - delete a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. +- 'delete_user_package' - delete an entire user package, including all versions. Requires 'package_type', 'package_name'. Omit 'username' to target the authenticated user. +- 'delete_user_package_version' - delete a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. Omit 'username' to target the authenticated user. + +Deleted packages and versions can be restored from the GitHub web interface within 30 days.`, + Enum: []any{"delete_org_package", "delete_org_package_version", "delete_user_package", "delete_user_package_version"}, + } + properties["package_type"].Description = "Package type." + properties["package_name"].Description = "Package name." + + return NewTool( + ToolsetMetadataPackages, + mcp.Tool{ + Name: "packages_write", + Description: t("TOOL_PACKAGES_WRITE_DESCRIPTION", "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PACKAGES_WRITE_USER_TITLE", "Delete packages"), + ReadOnlyHint: false, + DestructiveHint: new(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: []string{"method", "package_type", "package_name"}, + }, + }, + scopes.RequireAll(scopes.ReadPackages, scopes.DeletePackages), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var isOrg, withVersion bool + switch method { + case "delete_org_package": + isOrg = true + case "delete_org_package_version": + isOrg, withVersion = true, true + case "delete_user_package": + case "delete_user_package_version": + withVersion = true + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + + c, err := requirePackage(args, isOrg, withVersion) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var resp *github.Response + switch { + case isOrg && withVersion: + resp, err = client.Organizations.PackageDeleteVersion(ctx, c.owner, c.packageType, c.packageName, c.versionID) + case isOrg: + resp, err = client.Organizations.DeletePackage(ctx, c.owner, c.packageType, c.packageName) + case withVersion: + resp, err = client.Users.PackageDeleteVersion(ctx, c.owner, c.packageType, c.packageName, c.versionID) + default: + resp, err = client.Users.DeletePackage(ctx, c.owner, c.packageType, c.packageName) + } + + target := fmt.Sprintf("%s package '%s'", c.packageType, c.packageName) + if withVersion { + target = fmt.Sprintf("version %d of %s", c.versionID, target) + } + target = fmt.Sprintf("%s for %s", target, describeOwner(c.owner, isOrg)) + + return handleDeletionResponse(ctx, resp, err, target) + }, + ) +} + +// handleDeletionResponse converts the result of a package deletion API call +// into a tool result. +func handleDeletionResponse(ctx context.Context, resp *github.Response, err error, target string) (*mcp.CallToolResult, any, error) { + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to delete %s", target), resp, err), nil, nil + } + if resp.StatusCode != http.StatusNoContent { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, fmt.Sprintf("failed to delete %s", target), resp, body), nil, nil + } + + return MarshalledTextResult(map[string]any{ + "success": true, + "message": fmt.Sprintf("Deleted %s", target), + }), nil, nil +} diff --git a/pkg/github/packages_test.go b/pkg/github/packages_test.go new file mode 100644 index 0000000000..d95ed8cdb9 --- /dev/null +++ b/pkg/github/packages_test.go @@ -0,0 +1,454 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" +) + +func Test_PackagesRead(t *testing.T) { + toolDef := PackagesRead(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "packages_read", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"method"}) + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + + mockPackages := []*github.Package{{ + ID: new(int64(1)), + Name: new("github-mcp-server"), + PackageType: new("container"), + Visibility: new("public"), + }} + mockPackage := mockPackages[0] + mockVersions := []*github.PackageVersion{ + {ID: new(int64(101)), Name: new("sha256:abc")}, + {ID: new(int64(102)), Name: new("sha256:def")}, + } + mockVersion := mockVersions[0] + + expectQuery := func(t *testing.T, expected map[string]string, body any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + for k, v := range expected { + assert.Equal(t, v, r.URL.Query().Get(k), "query parameter %q", k) + } + mockResponse(t, http.StatusOK, body)(w, r) + } + } + + tests := []struct { + name string + handlers map[string]http.HandlerFunc + args map[string]any + expectError bool + expectedErrMsg string + expectedResult any + }{ + { + name: "list_org_packages with filters and pagination", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages": expectQuery(t, map[string]string{ + "package_type": "container", + "visibility": "public", + "page": "2", + "per_page": "10", + }, mockPackages), + }, + args: map[string]any{ + "method": "list_org_packages", + "org": "github", + "package_type": "container", + "visibility": "public", + "page": float64(2), + "perPage": float64(10), + }, + expectedResult: mockPackages, + }, + { + name: "list_org_packages requires org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_org_packages"}, + expectError: true, + expectedErrMsg: "missing required parameter: org", + }, + { + name: "list_org_packages API error", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }, + args: map[string]any{"method": "list_org_packages", "org": "missing"}, + expectError: true, + expectedErrMsg: "failed to list packages for organization 'missing'", + }, + { + name: "list_user_packages for authenticated user", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages": mockResponse(t, http.StatusOK, mockPackages), + }, + args: map[string]any{"method": "list_user_packages"}, + expectedResult: mockPackages, + }, + { + name: "list_user_packages for named user", + handlers: map[string]http.HandlerFunc{ + "GET /users/{username}/packages": expectQuery(t, map[string]string{"package_type": "npm"}, mockPackages), + }, + args: map[string]any{"method": "list_user_packages", "username": "octocat", "package_type": "npm"}, + expectedResult: mockPackages, + }, + { + name: "get_org_package", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusOK, mockPackage), + }, + args: map[string]any{ + "method": "get_org_package", + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + }, + expectedResult: mockPackage, + }, + { + name: "get_org_package requires package_name", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "get_org_package", "org": "github", "package_type": "container"}, + expectError: true, + expectedErrMsg: "missing required parameter: package_name", + }, + { + name: "get_user_package for named user", + handlers: map[string]http.HandlerFunc{ + "GET /users/{username}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusOK, mockPackage), + }, + args: map[string]any{ + "method": "get_user_package", + "username": "octocat", + "package_type": "container", + "package_name": "github-mcp-server", + }, + expectedResult: mockPackage, + }, + { + name: "get_user_package API error", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages/{package_type}/{package_name}": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }, + args: map[string]any{ + "method": "get_user_package", + "package_type": "npm", + "package_name": "missing", + }, + expectError: true, + expectedErrMsg: "failed to get npm package 'missing' for the authenticated user", + }, + { + name: "list_org_package_versions with state", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}/versions": expectQuery(t, map[string]string{"state": "deleted"}, mockVersions), + }, + args: map[string]any{ + "method": "list_org_package_versions", + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + "state": "deleted", + }, + expectedResult: mockVersions, + }, + { + name: "list_user_package_versions for authenticated user", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages/{package_type}/{package_name}/versions": expectQuery(t, map[string]string{"state": "active"}, mockVersions), + }, + args: map[string]any{ + "method": "list_user_package_versions", + "package_type": "container", + "package_name": "github-mcp-server", + "state": "active", + }, + expectedResult: mockVersions, + }, + { + name: "list_user_package_versions for named user", + handlers: map[string]http.HandlerFunc{ + "GET /users/{username}/packages/{package_type}/{package_name}/versions": expectQuery(t, map[string]string{ + "state": "active", + "page": "2", + "per_page": "5", + }, mockVersions), + }, + args: map[string]any{ + "method": "list_user_package_versions", + "username": "octocat", + "package_type": "container", + "package_name": "github-mcp-server", + "state": "active", + "page": float64(2), + "perPage": float64(5), + }, + expectedResult: mockVersions, + }, + { + name: "get_org_package_version", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}": mockResponse(t, http.StatusOK, mockVersion), + }, + args: map[string]any{ + "method": "get_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + "package_version_id": float64(101), + }, + expectedResult: mockVersion, + }, + { + name: "get_org_package_version requires package_version_id", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "get_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + }, + expectError: true, + expectedErrMsg: "missing required parameter: package_version_id", + }, + { + name: "get_user_package_version for authenticated user", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages/{package_type}/{package_name}/versions/{package_version_id}": mockResponse(t, http.StatusOK, mockVersion), + }, + args: map[string]any{ + "method": "get_user_package_version", + "package_type": "container", + "package_name": "github-mcp-server", + "package_version_id": float64(101), + }, + expectedResult: mockVersion, + }, + { + name: "unknown method", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "invalid"}, + expectError: true, + expectedErrMsg: "unknown method: invalid", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.handlers))} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectError { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError, getTextResult(t, result).Text) + expected, err := json.Marshal(tc.expectedResult) + require.NoError(t, err) + assert.JSONEq(t, string(expected), getTextResult(t, result).Text) + }) + } +} + +func Test_PackagesWrite(t *testing.T) { + toolDef := PackagesWrite(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "packages_write", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint) + require.NotNil(t, toolDef.Tool.Annotations.DestructiveHint) + assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) + assert.ElementsMatch(t, toolDef.ScopeAccess.Scopes, []string{"read:packages", "delete:packages"}) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"method", "package_type", "package_name"}) + + noContent := mockResponse(t, http.StatusNoContent, "") + + tests := []struct { + name string + handlers map[string]http.HandlerFunc + args map[string]any + expectError bool + expectedErrMsg string + expectedMessage string + }{ + { + name: "delete_org_package", + handlers: map[string]http.HandlerFunc{ + "DELETE /orgs/{org}/packages/{package_type}/{package_name}": noContent, + }, + args: map[string]any{ + "method": "delete_org_package", + "org": "github", + "package_type": "container", + "package_name": "old-image", + }, + expectedMessage: "Deleted container package 'old-image' for organization 'github'", + }, + { + name: "delete_org_package requires org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_org_package", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "missing required parameter: org", + }, + { + name: "delete_org_package API error", + handlers: map[string]http.HandlerFunc{ + "DELETE /orgs/{org}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusForbidden, `{"message": "Forbidden"}`), + }, + args: map[string]any{ + "method": "delete_org_package", + "org": "github", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "failed to delete container package 'old-image' for organization 'github'", + }, + { + name: "delete_org_package unexpected status", + handlers: map[string]http.HandlerFunc{ + "DELETE /orgs/{org}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusOK, `{"message": "unexpected"}`), + }, + args: map[string]any{ + "method": "delete_org_package", + "org": "github", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "unexpected status 200", + }, + { + name: "delete_org_package_version", + handlers: map[string]http.HandlerFunc{ + "DELETE /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}": noContent, + }, + args: map[string]any{ + "method": "delete_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "old-image", + "package_version_id": float64(101), + }, + expectedMessage: "Deleted version 101 of container package 'old-image' for organization 'github'", + }, + { + name: "delete_org_package_version requires package_version_id", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "missing required parameter: package_version_id", + }, + { + name: "delete_user_package for authenticated user", + handlers: map[string]http.HandlerFunc{ + "DELETE /user/packages/{package_type}/{package_name}": noContent, + }, + args: map[string]any{ + "method": "delete_user_package", + "package_type": "npm", + "package_name": "old-lib", + }, + expectedMessage: "Deleted npm package 'old-lib' for the authenticated user", + }, + { + name: "delete_user_package for named user", + handlers: map[string]http.HandlerFunc{ + "DELETE /users/{username}/packages/{package_type}/{package_name}": noContent, + }, + args: map[string]any{ + "method": "delete_user_package", + "username": "octocat", + "package_type": "npm", + "package_name": "old-lib", + }, + expectedMessage: "Deleted npm package 'old-lib' for user 'octocat'", + }, + { + name: "delete_user_package_version", + handlers: map[string]http.HandlerFunc{ + "DELETE /user/packages/{package_type}/{package_name}/versions/{package_version_id}": noContent, + }, + args: map[string]any{ + "method": "delete_user_package_version", + "package_type": "npm", + "package_name": "old-lib", + "package_version_id": float64(7), + }, + expectedMessage: "Deleted version 7 of npm package 'old-lib' for the authenticated user", + }, + { + name: "unknown method", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "invalid", + "package_type": "npm", + "package_name": "old-lib", + }, + expectError: true, + expectedErrMsg: "unknown method: invalid", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.handlers))} + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectError { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError, getTextResult(t, result).Text) + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + assert.Equal(t, true, response["success"]) + assert.Equal(t, tc.expectedMessage, response["message"]) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index e3177f668f..c56ec99115 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -119,6 +119,11 @@ var ( Description: "GitHub Gist related tools", Icon: "logo-gist", } + ToolsetMetadataPackages = inventory.ToolsetMetadata{ + ID: "packages", + Description: "GitHub Packages related tools", + Icon: "package", + } ToolsetMetadataSecurityAdvisories = inventory.ToolsetMetadata{ ID: "security_advisories", Description: "Security advisories related tools", @@ -345,6 +350,10 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent CreateGist(t), UpdateGist(t), + // Package tools + PackagesRead(t), + PackagesWrite(t), + // Project tools ProjectsList(t), ProjectsGet(t), diff --git a/pkg/octicons/icons/package-dark.png b/pkg/octicons/icons/package-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4f60b28ceb3efb83e376c9e572960ebbd90e6d25 GIT binary patch literal 519 zcmV+i0{H!jP)*#k4bKj&m1^GuYU;B+Zvy_7O2rQB(n=4*P#0uxxr z7JkOs3T!PC0(xlQdvxBhOY ze%Qc!7oj?n<~sh9x;oliYX;Tsa(ah?NnDMOsgB^AIMqF%YT@lhwst0SpOM5kxF6%- z6V~c?5Nj>8L1bn+_5NU?HT>FF9c~MmI~J|s_Xe-q!|k8*YXDZ8vAH;_rH%jq002ov JPDHLkV1hIE0XzTz literal 0 HcmV?d00001 diff --git a/pkg/octicons/icons/package-light.png b/pkg/octicons/icons/package-light.png new file mode 100644 index 0000000000000000000000000000000000000000..7e3e95a46573fda70312e2b7a3edbfa21394f46c GIT binary patch literal 827 zcmV-B1H}A^P)cwp1W_L2%rFtkU1yM<;+nssxTlQOG)(vYY_Wfqzo8Noy_jZ=~zRh7s z>@5^_kNKms$ds+?uMcm;@lD5V1-iu+$W0cb*GZlPw4-bLQ_{(6wem**+D`bN1HxkT zl!&t=rv!b27LRs_yv`DQQql^Aqe-p&wet)WFgZ1~kIKJK;%P|8+{(1(F|W<=fd$9)0!ZwV^b3GzcPbYLrRb&2Kj8q-wB|8y zX9jU!UqI)Q7P7Q`kid147lP8mmmk^}J?iMqFwQilHII2A^}c<7fIaSN5S07)t71ps{4+Xj5t;Rm$(0sx)|@j_6F zew-{Ec-(b|g?Y?N@-UQr+!s)7_XisTNXS0!-pVwlHII31 zM!kA Date: Mon, 2 Nov 2026 16:24:54 +0000 Subject: [PATCH 3/3] fix(packages): address review feedback with v2 imports, typed trimmed outputs, required package_type, and owner validation Signed-off-by: MayorFaj --- README.md | 15 +- pkg/github/__toolsnaps__/packages_read.snap | 460 ++++++++++++++++++- pkg/github/__toolsnaps__/packages_write.snap | 31 +- pkg/github/packages.go | 149 +++--- pkg/github/packages_test.go | 226 ++++++++- pkg/github/packages_types.go | 177 +++++++ 6 files changed, 957 insertions(+), 101 deletions(-) create mode 100644 pkg/github/packages_types.go diff --git a/README.md b/README.md index a1a21a4172..7ef3cf686e 100644 --- a/README.md +++ b/README.md @@ -1198,24 +1198,25 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - **OAuth Challenge Scopes**: `read:packages` - `method`: The read operation to perform. Options are: - - 'list_org_packages' - list packages for an organization. Requires 'org'. Optional filters: 'package_type', 'visibility'. + - 'list_org_packages' - list packages for an organization. Requires 'org', 'package_type'. Optional filter: 'visibility'. - 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'. - 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'. - 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. - - 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Optional filters: 'package_type', 'visibility'. + - 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Requires 'package_type'. Optional filter: 'visibility'. - 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'. - 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'. - 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. + When 'username' names a user other than the authenticated user, get_user_package, list_user_package_versions, and get_user_package_version only return that user's public packages. Download statistics are not available through the GitHub REST API. (string, required) - - `org`: Organization name. Required for org_* methods. (string, optional) + - `org`: Organization name. Required for org_* methods, and not accepted by user_* methods. (string, optional) - `package_name`: Package name. Required for every method except list_org_packages and list_user_packages. (string, optional) - - `package_type`: Package type. Required for every method except list_org_packages and list_user_packages, where it is an optional filter. (string, optional) + - `package_type`: Package type. (string, required) - `package_version_id`: Package version ID. Required for methods that target a single version. (number, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `state`: Filter package versions by state (list_*_package_versions only). (string, optional) - - `username`: GitHub username for user_* methods. Omit to target the authenticated user. (string, optional) + - `username`: GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods. (string, optional) - `visibility`: Filter packages by visibility (list_org_packages and list_user_packages only). (string, optional) - **packages_write** - Delete packages @@ -1228,11 +1229,11 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - 'delete_user_package_version' - delete a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. Omit 'username' to target the authenticated user. Deleted packages and versions can be restored from the GitHub web interface within 30 days. (string, required) - - `org`: Organization name. Required for org_* methods. (string, optional) + - `org`: Organization name. Required for org_* methods, and not accepted by user_* methods. (string, optional) - `package_name`: Package name. (string, required) - `package_type`: Package type. (string, required) - `package_version_id`: Package version ID. Required for methods that target a single version. (number, optional) - - `username`: GitHub username for user_* methods. Omit to target the authenticated user. (string, optional) + - `username`: GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods. (string, optional)
diff --git a/pkg/github/__toolsnaps__/packages_read.snap b/pkg/github/__toolsnaps__/packages_read.snap index 4d03ed8331..fbdccda384 100644 --- a/pkg/github/__toolsnaps__/packages_read.snap +++ b/pkg/github/__toolsnaps__/packages_read.snap @@ -4,11 +4,11 @@ "readOnlyHint": true, "title": "Read package information" }, - "description": "Get information about GitHub Packages owned by organizations and users. Supports listing packages, getting package details, and inspecting package versions.", + "description": "Get information about GitHub Packages owned by organizations and users. Supports listing packages, getting package details, and inspecting package versions. The GitHub Packages API only accepts classic personal access tokens and OAuth app tokens.", "inputSchema": { "properties": { "method": { - "description": "The read operation to perform.\nOptions are:\n- 'list_org_packages' - list packages for an organization. Requires 'org'. Optional filters: 'package_type', 'visibility'.\n- 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'.\n- 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'.\n- 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'.\n- 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Optional filters: 'package_type', 'visibility'.\n- 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'.\n- 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'.\n- 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'.\n\nDownload statistics are not available through the GitHub REST API.", + "description": "The read operation to perform.\nOptions are:\n- 'list_org_packages' - list packages for an organization. Requires 'org', 'package_type'. Optional filter: 'visibility'.\n- 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'.\n- 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'.\n- 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'.\n- 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Requires 'package_type'. Optional filter: 'visibility'.\n- 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'.\n- 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'.\n- 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'.\n\nWhen 'username' names a user other than the authenticated user, get_user_package, list_user_package_versions, and get_user_package_version only return that user's public packages.\nDownload statistics are not available through the GitHub REST API.", "enum": [ "list_org_packages", "get_org_package", @@ -22,7 +22,7 @@ "type": "string" }, "org": { - "description": "Organization name. Required for org_* methods.", + "description": "Organization name. Required for org_* methods, and not accepted by user_* methods.", "type": "string" }, "package_name": { @@ -30,7 +30,7 @@ "type": "string" }, "package_type": { - "description": "Package type. Required for every method except list_org_packages and list_user_packages, where it is an optional filter.", + "description": "Package type.", "enum": [ "npm", "maven", @@ -65,7 +65,7 @@ "type": "string" }, "username": { - "description": "GitHub username for user_* methods. Omit to target the authenticated user.", + "description": "GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods.", "type": "string" }, "visibility": { @@ -79,9 +79,455 @@ } }, "required": [ - "method" + "method", + "package_type" ], "type": "object" }, - "name": "packages_read" + "name": "packages_read", + "outputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "method": { + "enum": [ + "list_org_packages" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "packages" + ] + }, + { + "properties": { + "method": { + "enum": [ + "get_org_package" + ] + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "package" + ] + }, + { + "properties": { + "method": { + "enum": [ + "list_org_package_versions" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "versions" + ] + }, + { + "properties": { + "method": { + "enum": [ + "get_org_package_version" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "version" + ] + }, + { + "properties": { + "method": { + "enum": [ + "list_user_packages" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "packages" + ] + }, + { + "properties": { + "method": { + "enum": [ + "get_user_package" + ] + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "package" + ] + }, + { + "properties": { + "method": { + "enum": [ + "list_user_package_versions" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "version": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "versions" + ] + }, + { + "properties": { + "method": { + "enum": [ + "get_user_package_version" + ] + }, + "package": { + "not": { + "description": "Any value." + } + }, + "packages": { + "not": { + "description": "Any value." + } + }, + "versions": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "version" + ] + } + ], + "properties": { + "method": { + "description": "The method that produced the corresponding typed output.", + "enum": [ + "list_org_packages", + "get_org_package", + "list_org_package_versions", + "get_org_package_version", + "list_user_packages", + "get_user_package", + "list_user_package_versions", + "get_user_package_version" + ], + "type": "string" + }, + "package": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "owner": { + "description": "Login of the package owner.", + "type": "string" + }, + "package_type": { + "type": "string" + }, + "repository": { + "description": "Full name of the repository linked to the package.", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version_count": { + "description": "Number of versions. Omitted when the API does not report it, as in list results.", + "type": "integer" + }, + "visibility": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "package_type" + ], + "type": "object" + }, + "packages": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "owner": { + "description": "Login of the package owner.", + "type": "string" + }, + "package_type": { + "type": "string" + }, + "repository": { + "description": "Full name of the repository linked to the package.", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version_count": { + "description": "Number of versions. Omitted when the API does not report it, as in list results.", + "type": "integer" + }, + "visibility": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "package_type" + ], + "type": "object" + }, + "type": "array" + }, + "version": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "license": { + "type": "string" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "container": { + "additionalProperties": false, + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tags" + ], + "type": "object" + }, + "package_type": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "versions": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "license": { + "type": "string" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "container": { + "additionalProperties": false, + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tags" + ], + "type": "object" + }, + "package_type": { + "type": "string" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/packages_write.snap b/pkg/github/__toolsnaps__/packages_write.snap index 9b60a4de5b..347fb03846 100644 --- a/pkg/github/__toolsnaps__/packages_write.snap +++ b/pkg/github/__toolsnaps__/packages_write.snap @@ -5,7 +5,7 @@ "readOnlyHint": false, "title": "Delete packages" }, - "description": "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope.", + "description": "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope. The GitHub Packages API only accepts classic personal access tokens and OAuth app tokens.", "inputSchema": { "properties": { "method": { @@ -19,7 +19,7 @@ "type": "string" }, "org": { - "description": "Organization name. Required for org_* methods.", + "description": "Organization name. Required for org_* methods, and not accepted by user_* methods.", "type": "string" }, "package_name": { @@ -43,7 +43,7 @@ "type": "number" }, "username": { - "description": "GitHub username for user_* methods. Omit to target the authenticated user.", + "description": "GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods.", "type": "string" } }, @@ -54,5 +54,28 @@ ], "type": "object" }, - "name": "packages_write" + "name": "packages_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "method": { + "description": "The method that produced the corresponding typed output.", + "enum": [ + "delete_org_package", + "delete_org_package_version", + "delete_user_package", + "delete_user_package_version" + ], + "type": "string" + } + }, + "required": [ + "method", + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/packages.go b/pkg/github/packages.go index 98d2e7a470..d6be00e2b6 100644 --- a/pkg/github/packages.go +++ b/pkg/github/packages.go @@ -8,11 +8,11 @@ import ( "net/url" "strconv" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -24,6 +24,10 @@ import ( var packageTypes = []any{"npm", "maven", "rubygems", "docker", "nuget", "container"} +// packagesAuthNote is appended to tool descriptions because the Packages REST +// API rejects fine-grained personal access tokens and GitHub App tokens. +const packagesAuthNote = "The GitHub Packages API only accepts classic personal access tokens and OAuth app tokens." + // packageCoordinates identifies a package (and optionally a version) owned by // an organization or a user. An empty user refers to the authenticated user. type packageCoordinates struct { @@ -37,15 +41,15 @@ func packageOwnerSchema() map[string]*jsonschema.Schema { return map[string]*jsonschema.Schema{ "org": { Type: "string", - Description: "Organization name. Required for org_* methods.", + Description: "Organization name. Required for org_* methods, and not accepted by user_* methods.", }, "username": { Type: "string", - Description: "GitHub username for user_* methods. Omit to target the authenticated user.", + Description: "GitHub username for user_* methods. Omit to target the authenticated user. Not accepted by org_* methods.", }, "package_type": { Type: "string", - Description: "Package type. Required for every method except list_org_packages and list_user_packages, where it is an optional filter.", + Description: "Package type.", Enum: packageTypes, }, "package_name": { @@ -59,11 +63,27 @@ func packageOwnerSchema() map[string]*jsonschema.Schema { } } -// requirePackageOwner reads the owner for an org_* or user_* method. +// requirePackageOwner reads the owner for an org_* or user_* method, rejecting +// the owner argument that belongs to the other kind of method so that a call +// is never silently redirected to a different owner. func requirePackageOwner(args map[string]any, isOrg bool) (string, error) { if isOrg { + username, err := OptionalParam[string](args, "username") + if err != nil { + return "", err + } + if username != "" { + return "", fmt.Errorf("'username' is not supported by org_* methods; use the user_* methods to target a user") + } return RequiredParam[string](args, "org") } + org, err := OptionalParam[string](args, "org") + if err != nil { + return "", err + } + if org != "" { + return "", fmt.Errorf("'org' is not supported by user_* methods; use the org_* methods to target an organization") + } return OptionalParam[string](args, "username") } @@ -111,20 +131,18 @@ func PackagesRead(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "string", Description: `The read operation to perform. Options are: -- 'list_org_packages' - list packages for an organization. Requires 'org'. Optional filters: 'package_type', 'visibility'. +- 'list_org_packages' - list packages for an organization. Requires 'org', 'package_type'. Optional filter: 'visibility'. - 'get_org_package' - get a package owned by an organization. Requires 'org', 'package_type', 'package_name'. - 'list_org_package_versions' - list versions of an organization package. Requires 'org', 'package_type', 'package_name'. Optional filter: 'state'. - 'get_org_package_version' - get a single version of an organization package. Requires 'org', 'package_type', 'package_name', 'package_version_id'. -- 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Optional filters: 'package_type', 'visibility'. +- 'list_user_packages' - list packages for a user (or the authenticated user when 'username' is omitted). Requires 'package_type'. Optional filter: 'visibility'. - 'get_user_package' - get a package owned by a user. Requires 'package_type', 'package_name'. - 'list_user_package_versions' - list versions of a user package. Requires 'package_type', 'package_name'. Optional filter: 'state'. - 'get_user_package_version' - get a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. +When 'username' names a user other than the authenticated user, get_user_package, list_user_package_versions, and get_user_package_version only return that user's public packages. Download statistics are not available through the GitHub REST API.`, - Enum: []any{ - "list_org_packages", "get_org_package", "list_org_package_versions", "get_org_package_version", - "list_user_packages", "get_user_package", "list_user_package_versions", "get_user_package_version", - }, + Enum: packagesReadMethods, } properties["visibility"] = &jsonschema.Schema{ Type: "string", @@ -139,15 +157,16 @@ Download statistics are not available through the GitHub REST API.`, schema := &jsonschema.Schema{ Type: "object", Properties: properties, - Required: []string{"method"}, + Required: []string{"method", "package_type"}, } WithPagination(schema) - return NewTool( + return NewTool[map[string]any, *PackagesReadOutput]( ToolsetMetadataPackages, mcp.Tool{ - Name: "packages_read", - Description: t("TOOL_PACKAGES_READ_DESCRIPTION", "Get information about GitHub Packages owned by organizations and users. Supports listing packages, getting package details, and inspecting package versions."), + Name: "packages_read", + OutputSchema: packagesReadOutputSchema(), + Description: t("TOOL_PACKAGES_READ_DESCRIPTION", "Get information about GitHub Packages owned by organizations and users. Supports listing packages, getting package details, and inspecting package versions. "+packagesAuthNote), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PACKAGES_READ_USER_TITLE", "Read package information"), ReadOnlyHint: true, @@ -155,7 +174,7 @@ Download statistics are not available through the GitHub REST API.`, InputSchema: schema, }, scopes.RequireAll(scopes.ReadPackages), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *PackagesReadOutput, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -167,22 +186,22 @@ Download statistics are not available through the GitHub REST API.`, } switch method { - case "list_org_packages": - return listPackages(ctx, client, args, true) - case "list_user_packages": - return listPackages(ctx, client, args, false) - case "get_org_package": - return getPackage(ctx, client, args, true) - case "get_user_package": - return getPackage(ctx, client, args, false) - case "list_org_package_versions": - return listPackageVersions(ctx, client, args, true) - case "list_user_package_versions": - return listPackageVersions(ctx, client, args, false) - case "get_org_package_version": - return getPackageVersion(ctx, client, args, true) - case "get_user_package_version": - return getPackageVersion(ctx, client, args, false) + case packagesMethodListOrgPackages: + return listPackages(ctx, client, args, method, true) + case packagesMethodListUserPackages: + return listPackages(ctx, client, args, method, false) + case packagesMethodGetOrgPackage: + return getPackage(ctx, client, args, method, true) + case packagesMethodGetUserPackage: + return getPackage(ctx, client, args, method, false) + case packagesMethodListOrgPackageVersions: + return listPackageVersions(ctx, client, args, method, true) + case packagesMethodListUserVersions: + return listPackageVersions(ctx, client, args, method, false) + case packagesMethodGetOrgPackageVersion: + return getPackageVersion(ctx, client, args, method, true) + case packagesMethodGetUserPackageVersion: + return getPackageVersion(ctx, client, args, method, false) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -190,12 +209,13 @@ Download statistics are not available through the GitHub REST API.`, ) } -func listPackages(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, error) { +func listPackages(ctx context.Context, client *github.Client, args map[string]any, method string, isOrg bool) (*mcp.CallToolResult, *PackagesReadOutput, error) { owner, err := requirePackageOwner(args, isOrg) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - packageType, err := OptionalParam[string](args, "package_type") + // All list-packages endpoints reject requests without package_type. + packageType, err := RequiredParam[string](args, "package_type") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -209,9 +229,10 @@ func listPackages(ctx context.Context, client *github.Client, args map[string]an } opts := &github.PackageListOptions{ - PackageType: ToStringPtr(packageType), + PackageType: new(packageType), Visibility: ToStringPtr(visibility), - ListOptions: github.ListOptions{Page: pagination.Page, PerPage: pagination.PerPage}, + Page: pagination.Page, + PerPage: pagination.PerPage, } var packages []*github.Package @@ -232,10 +253,11 @@ func listPackages(ctx context.Context, client *github.Client, args map[string]an ), nil, nil } - return MarshalledTextResult(packages), nil, nil + out := convertToPackageOutputs(packages) + return MarshalledTextResult(out), &PackagesReadOutput{Method: method, Packages: &out}, nil } -func getPackage(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, error) { +func getPackage(ctx context.Context, client *github.Client, args map[string]any, method string, isOrg bool) (*mcp.CallToolResult, *PackagesReadOutput, error) { c, err := requirePackage(args, isOrg, false) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -259,10 +281,11 @@ func getPackage(ctx context.Context, client *github.Client, args map[string]any, ), nil, nil } - return MarshalledTextResult(pkg), nil, nil + out := convertToPackageOutput(pkg) + return MarshalledTextResult(out), &PackagesReadOutput{Method: method, Package: &out}, nil } -func listPackageVersions(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, error) { +func listPackageVersions(ctx context.Context, client *github.Client, args map[string]any, method string, isOrg bool) (*mcp.CallToolResult, *PackagesReadOutput, error) { c, err := requirePackage(args, isOrg, false) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -300,10 +323,11 @@ func listPackageVersions(ctx context.Context, client *github.Client, args map[st ), nil, nil } - return MarshalledTextResult(versions), nil, nil + out := convertToPackageVersionOutputs(versions) + return MarshalledTextResult(out), &PackagesReadOutput{Method: method, Versions: &out}, nil } -func getPackageVersion(ctx context.Context, client *github.Client, args map[string]any, isOrg bool) (*mcp.CallToolResult, any, error) { +func getPackageVersion(ctx context.Context, client *github.Client, args map[string]any, method string, isOrg bool) (*mcp.CallToolResult, *PackagesReadOutput, error) { c, err := requirePackage(args, isOrg, true) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -327,7 +351,8 @@ func getPackageVersion(ctx context.Context, client *github.Client, args map[stri ), nil, nil } - return MarshalledTextResult(version), nil, nil + out := convertToPackageVersionOutput(version) + return MarshalledTextResult(out), &PackagesReadOutput{Method: method, Version: &out}, nil } // listOtherUserPackageVersions lists versions of a package owned by another @@ -369,16 +394,16 @@ Options are: - 'delete_user_package_version' - delete a single version of a user package. Requires 'package_type', 'package_name', 'package_version_id'. Omit 'username' to target the authenticated user. Deleted packages and versions can be restored from the GitHub web interface within 30 days.`, - Enum: []any{"delete_org_package", "delete_org_package_version", "delete_user_package", "delete_user_package_version"}, + Enum: packagesWriteMethods, } - properties["package_type"].Description = "Package type." properties["package_name"].Description = "Package name." - return NewTool( + return NewTool[map[string]any, *PackagesWriteOutput]( ToolsetMetadataPackages, mcp.Tool{ - Name: "packages_write", - Description: t("TOOL_PACKAGES_WRITE_DESCRIPTION", "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope."), + Name: "packages_write", + OutputSchema: packagesWriteOutputSchema(), + Description: t("TOOL_PACKAGES_WRITE_DESCRIPTION", "Delete GitHub Packages or individual package versions owned by organizations and users. Requires the delete:packages scope. "+packagesAuthNote), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PACKAGES_WRITE_USER_TITLE", "Delete packages"), ReadOnlyHint: false, @@ -391,7 +416,7 @@ Deleted packages and versions can be restored from the GitHub web interface with }, }, scopes.RequireAll(scopes.ReadPackages, scopes.DeletePackages), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *PackagesWriteOutput, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -399,12 +424,12 @@ Deleted packages and versions can be restored from the GitHub web interface with var isOrg, withVersion bool switch method { - case "delete_org_package": + case packagesMethodDeleteOrgPackage: isOrg = true - case "delete_org_package_version": + case packagesMethodDeleteOrgPackageVersion: isOrg, withVersion = true, true - case "delete_user_package": - case "delete_user_package_version": + case packagesMethodDeleteUserPackage: + case packagesMethodDeleteUserPackageVersion: withVersion = true default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil @@ -438,14 +463,14 @@ Deleted packages and versions can be restored from the GitHub web interface with } target = fmt.Sprintf("%s for %s", target, describeOwner(c.owner, isOrg)) - return handleDeletionResponse(ctx, resp, err, target) + return handleDeletionResponse(ctx, resp, err, method, target) }, ) } // handleDeletionResponse converts the result of a package deletion API call // into a tool result. -func handleDeletionResponse(ctx context.Context, resp *github.Response, err error, target string) (*mcp.CallToolResult, any, error) { +func handleDeletionResponse(ctx context.Context, resp *github.Response, err error, method, target string) (*mcp.CallToolResult, *PackagesWriteOutput, error) { if resp != nil { defer func() { _ = resp.Body.Close() }() } @@ -460,8 +485,6 @@ func handleDeletionResponse(ctx context.Context, resp *github.Response, err erro return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, fmt.Sprintf("failed to delete %s", target), resp, body), nil, nil } - return MarshalledTextResult(map[string]any{ - "success": true, - "message": fmt.Sprintf("Deleted %s", target), - }), nil, nil + out := &PackagesWriteOutput{Method: method, Message: fmt.Sprintf("Deleted %s", target)} + return MarshalledTextResult(out), out, nil } diff --git a/pkg/github/packages_test.go b/pkg/github/packages_test.go index d95ed8cdb9..07bd2eca5c 100644 --- a/pkg/github/packages_test.go +++ b/pkg/github/packages_test.go @@ -11,10 +11,23 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" ) +// packagesReadPayloadKeys maps each packages_read method to the property of +// PackagesReadOutput that carries its payload. +var packagesReadPayloadKeys = map[string]string{ + packagesMethodListOrgPackages: "packages", + packagesMethodListUserPackages: "packages", + packagesMethodGetOrgPackage: "package", + packagesMethodGetUserPackage: "package", + packagesMethodListOrgPackageVersions: "versions", + packagesMethodListUserVersions: "versions", + packagesMethodGetOrgPackageVersion: "version", + packagesMethodGetUserPackageVersion: "version", +} + func Test_PackagesRead(t *testing.T) { toolDef := PackagesRead(translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) @@ -25,10 +38,13 @@ func Test_PackagesRead(t *testing.T) { schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") - assert.ElementsMatch(t, schema.Required, []string{"method"}) + assert.ElementsMatch(t, schema.Required, []string{"method", "package_type"}) assert.Contains(t, schema.Properties, "page") assert.Contains(t, schema.Properties, "perPage") + outputSchema, err := toolDef.Tool.OutputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + mockPackages := []*github.Package{{ ID: new(int64(1)), Name: new("github-mcp-server"), @@ -77,31 +93,45 @@ func Test_PackagesRead(t *testing.T) { "page": float64(2), "perPage": float64(10), }, - expectedResult: mockPackages, + expectedResult: convertToPackageOutputs(mockPackages), }, { name: "list_org_packages requires org", handlers: map[string]http.HandlerFunc{}, - args: map[string]any{"method": "list_org_packages"}, + args: map[string]any{"method": "list_org_packages", "package_type": "npm"}, expectError: true, expectedErrMsg: "missing required parameter: org", }, + { + name: "list_org_packages requires package_type", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_org_packages", "org": "github"}, + expectError: true, + expectedErrMsg: "missing required parameter: package_type", + }, + { + name: "list_org_packages rejects username", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_org_packages", "org": "github", "username": "octocat", "package_type": "npm"}, + expectError: true, + expectedErrMsg: "'username' is not supported by org_* methods", + }, { name: "list_org_packages API error", handlers: map[string]http.HandlerFunc{ "GET /orgs/{org}/packages": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), }, - args: map[string]any{"method": "list_org_packages", "org": "missing"}, + args: map[string]any{"method": "list_org_packages", "org": "missing", "package_type": "npm"}, expectError: true, expectedErrMsg: "failed to list packages for organization 'missing'", }, { name: "list_user_packages for authenticated user", handlers: map[string]http.HandlerFunc{ - "GET /user/packages": mockResponse(t, http.StatusOK, mockPackages), + "GET /user/packages": expectQuery(t, map[string]string{"package_type": "container"}, mockPackages), }, - args: map[string]any{"method": "list_user_packages"}, - expectedResult: mockPackages, + args: map[string]any{"method": "list_user_packages", "package_type": "container"}, + expectedResult: convertToPackageOutputs(mockPackages), }, { name: "list_user_packages for named user", @@ -109,7 +139,22 @@ func Test_PackagesRead(t *testing.T) { "GET /users/{username}/packages": expectQuery(t, map[string]string{"package_type": "npm"}, mockPackages), }, args: map[string]any{"method": "list_user_packages", "username": "octocat", "package_type": "npm"}, - expectedResult: mockPackages, + expectedResult: convertToPackageOutputs(mockPackages), + }, + { + name: "list_user_packages with no results", + handlers: map[string]http.HandlerFunc{ + "GET /user/packages": mockResponse(t, http.StatusOK, []*github.Package{}), + }, + args: map[string]any{"method": "list_user_packages", "package_type": "npm"}, + expectedResult: []PackageOutput{}, + }, + { + name: "list_user_packages rejects org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{"method": "list_user_packages", "org": "github", "package_type": "npm"}, + expectError: true, + expectedErrMsg: "'org' is not supported by user_* methods", }, { name: "get_org_package", @@ -122,7 +167,7 @@ func Test_PackagesRead(t *testing.T) { "package_type": "container", "package_name": "github-mcp-server", }, - expectedResult: mockPackage, + expectedResult: convertToPackageOutput(mockPackage), }, { name: "get_org_package requires package_name", @@ -142,7 +187,7 @@ func Test_PackagesRead(t *testing.T) { "package_type": "container", "package_name": "github-mcp-server", }, - expectedResult: mockPackage, + expectedResult: convertToPackageOutput(mockPackage), }, { name: "get_user_package API error", @@ -169,7 +214,7 @@ func Test_PackagesRead(t *testing.T) { "package_name": "github-mcp-server", "state": "deleted", }, - expectedResult: mockVersions, + expectedResult: convertToPackageVersionOutputs(mockVersions), }, { name: "list_user_package_versions for authenticated user", @@ -182,7 +227,7 @@ func Test_PackagesRead(t *testing.T) { "package_name": "github-mcp-server", "state": "active", }, - expectedResult: mockVersions, + expectedResult: convertToPackageVersionOutputs(mockVersions), }, { name: "list_user_package_versions for named user", @@ -202,7 +247,7 @@ func Test_PackagesRead(t *testing.T) { "page": float64(2), "perPage": float64(5), }, - expectedResult: mockVersions, + expectedResult: convertToPackageVersionOutputs(mockVersions), }, { name: "get_org_package_version", @@ -216,7 +261,7 @@ func Test_PackagesRead(t *testing.T) { "package_name": "github-mcp-server", "package_version_id": float64(101), }, - expectedResult: mockVersion, + expectedResult: convertToPackageVersionOutput(mockVersion), }, { name: "get_org_package_version requires package_version_id", @@ -241,12 +286,12 @@ func Test_PackagesRead(t *testing.T) { "package_name": "github-mcp-server", "package_version_id": float64(101), }, - expectedResult: mockVersion, + expectedResult: convertToPackageVersionOutput(mockVersion), }, { name: "unknown method", handlers: map[string]http.HandlerFunc{}, - args: map[string]any{"method": "invalid"}, + args: map[string]any{"method": "invalid", "package_type": "npm"}, expectError: true, expectedErrMsg: "unknown method: invalid", }, @@ -268,9 +313,108 @@ func Test_PackagesRead(t *testing.T) { } require.False(t, result.IsError, getTextResult(t, result).Text) + text := getTextResult(t, result).Text expected, err := json.Marshal(tc.expectedResult) require.NoError(t, err) - assert.JSONEq(t, string(expected), getTextResult(t, result).Text) + assert.JSONEq(t, string(expected), text) + + // The payload must also validate as the method-tagged typed output. + method := tc.args["method"].(string) + var typed map[string]any + require.NoError(t, json.Unmarshal([]byte(`{"method":"`+method+`","`+packagesReadPayloadKeys[method]+`":`+text+`}`), &typed)) + require.NoError(t, outputSchema.Validate(typed)) + }) + } +} + +func Test_PackagesReadTrimsOutput(t *testing.T) { + toolDef := PackagesRead(translations.NullTranslationHelper) + outputSchema, err := toolDef.Tool.OutputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + + rawPackage := `{ + "id": 1, + "name": "github-mcp-server", + "package_type": "container", + "visibility": "public", + "version_count": 3, + "url": "https://api.github.com/orgs/github/packages/container/github-mcp-server", + "html_url": "https://github.com/orgs/github/packages/container/package/github-mcp-server", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-02-03T04:05:06Z", + "owner": {"login": "github", "id": 9919, "type": "Organization", "avatar_url": "https://avatars.example/9919"}, + "repository": {"id": 2, "name": "github-mcp-server", "full_name": "github/github-mcp-server", "owner": {"login": "github"}} + }` + rawVersion := `{ + "id": 101, + "name": "sha256:abc", + "url": "https://api.github.com/orgs/github/packages/container/github-mcp-server/versions/101", + "package_html_url": "https://github.com/orgs/github/packages/container/package/github-mcp-server", + "html_url": "https://github.com/orgs/github/packages/container/github-mcp-server/101", + "license": "MIT", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-02-03T04:05:06Z", + "metadata": {"package_type": "container", "container": {"tags": ["latest", "v1.0.0"]}} + }` + + tests := []struct { + name string + handlers map[string]http.HandlerFunc + args map[string]any + expected string + }{ + { + name: "package keeps owner login and repository full name", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}": mockResponse(t, http.StatusOK, rawPackage), + }, + args: map[string]any{"method": "get_org_package", "org": "github", "package_type": "container", "package_name": "github-mcp-server"}, + expected: `{ + "id": 1, + "name": "github-mcp-server", + "package_type": "container", + "visibility": "public", + "version_count": 3, + "owner": "github", + "repository": "github/github-mcp-server", + "html_url": "https://github.com/orgs/github/packages/container/package/github-mcp-server", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-02-03T04:05:06Z" + }`, + }, + { + name: "version keeps container tags", + handlers: map[string]http.HandlerFunc{ + "GET /orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}": mockResponse(t, http.StatusOK, rawVersion), + }, + args: map[string]any{"method": "get_org_package_version", "org": "github", "package_type": "container", "package_name": "github-mcp-server", "package_version_id": float64(101)}, + expected: `{ + "id": 101, + "name": "sha256:abc", + "license": "MIT", + "html_url": "https://github.com/orgs/github/packages/container/github-mcp-server/101", + "created_at": "2025-01-02T03:04:05Z", + "updated_at": "2025-02-03T04:05:06Z", + "metadata": {"package_type": "container", "container": {"tags": ["latest", "v1.0.0"]}} + }`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.handlers))} + request := createMCPRequest(tc.args) + + result, err := toolDef.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + text := getTextResult(t, result).Text + assert.JSONEq(t, tc.expected, text) + + method := tc.args["method"].(string) + var typed map[string]any + require.NoError(t, json.Unmarshal([]byte(`{"method":"`+method+`","`+packagesReadPayloadKeys[method]+`":`+text+`}`), &typed)) + require.NoError(t, outputSchema.Validate(typed)) }) } } @@ -290,6 +434,9 @@ func Test_PackagesWrite(t *testing.T) { require.True(t, ok, "InputSchema should be *jsonschema.Schema") assert.ElementsMatch(t, schema.Required, []string{"method", "package_type", "package_name"}) + outputSchema, err := toolDef.Tool.OutputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + noContent := mockResponse(t, http.StatusNoContent, "") tests := []struct { @@ -324,6 +471,19 @@ func Test_PackagesWrite(t *testing.T) { expectError: true, expectedErrMsg: "missing required parameter: org", }, + { + name: "delete_org_package rejects username", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_org_package", + "org": "github", + "username": "octocat", + "package_type": "container", + "package_name": "old-image", + }, + expectError: true, + expectedErrMsg: "'username' is not supported by org_* methods", + }, { name: "delete_org_package API error", handlers: map[string]http.HandlerFunc{ @@ -403,6 +563,18 @@ func Test_PackagesWrite(t *testing.T) { }, expectedMessage: "Deleted npm package 'old-lib' for user 'octocat'", }, + { + name: "delete_user_package rejects org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_user_package", + "org": "github", + "package_type": "npm", + "package_name": "old-lib", + }, + expectError: true, + expectedErrMsg: "'org' is not supported by user_* methods", + }, { name: "delete_user_package_version", handlers: map[string]http.HandlerFunc{ @@ -416,6 +588,19 @@ func Test_PackagesWrite(t *testing.T) { }, expectedMessage: "Deleted version 7 of npm package 'old-lib' for the authenticated user", }, + { + name: "delete_user_package_version rejects org", + handlers: map[string]http.HandlerFunc{}, + args: map[string]any{ + "method": "delete_user_package_version", + "org": "github", + "package_type": "npm", + "package_name": "old-lib", + "package_version_id": float64(7), + }, + expectError: true, + expectedErrMsg: "'org' is not supported by user_* methods", + }, { name: "unknown method", handlers: map[string]http.HandlerFunc{}, @@ -447,8 +632,9 @@ func Test_PackagesWrite(t *testing.T) { require.False(t, result.IsError, getTextResult(t, result).Text) var response map[string]any require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) - assert.Equal(t, true, response["success"]) + assert.Equal(t, tc.args["method"], response["method"]) assert.Equal(t, tc.expectedMessage, response["message"]) + require.NoError(t, outputSchema.Validate(response)) }) } } diff --git a/pkg/github/packages_types.go b/pkg/github/packages_types.go new file mode 100644 index 0000000000..24a2755d6d --- /dev/null +++ b/pkg/github/packages_types.go @@ -0,0 +1,177 @@ +package github + +import ( + "encoding/json" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +const ( + packagesMethodListOrgPackages = "list_org_packages" + packagesMethodGetOrgPackage = "get_org_package" + packagesMethodListOrgPackageVersions = "list_org_package_versions" + packagesMethodGetOrgPackageVersion = "get_org_package_version" + packagesMethodListUserPackages = "list_user_packages" + packagesMethodGetUserPackage = "get_user_package" + packagesMethodListUserVersions = "list_user_package_versions" + packagesMethodGetUserPackageVersion = "get_user_package_version" + + packagesMethodDeleteOrgPackage = "delete_org_package" + packagesMethodDeleteOrgPackageVersion = "delete_org_package_version" + packagesMethodDeleteUserPackage = "delete_user_package" + packagesMethodDeleteUserPackageVersion = "delete_user_package_version" +) + +var packagesReadMethods = []any{ + packagesMethodListOrgPackages, packagesMethodGetOrgPackage, packagesMethodListOrgPackageVersions, packagesMethodGetOrgPackageVersion, + packagesMethodListUserPackages, packagesMethodGetUserPackage, packagesMethodListUserVersions, packagesMethodGetUserPackageVersion, +} + +var packagesWriteMethods = []any{ + packagesMethodDeleteOrgPackage, packagesMethodDeleteOrgPackageVersion, packagesMethodDeleteUserPackage, packagesMethodDeleteUserPackageVersion, +} + +// PackageOutput is the trimmed output type for a package. The owner and +// repository objects are reduced to the owner login and repository full name. +type PackageOutput struct { + ID int64 `json:"id"` + Name string `json:"name"` + PackageType string `json:"package_type"` + Visibility string `json:"visibility,omitempty"` + VersionCount *int64 `json:"version_count,omitempty"` + Owner string `json:"owner,omitempty"` + Repository string `json:"repository,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// PackageVersionOutput is the trimmed output type for a package version. +type PackageVersionOutput struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + License string `json:"license,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Metadata *PackageVersionMetadataOutput `json:"metadata,omitempty"` +} + +type PackageVersionMetadataOutput struct { + PackageType string `json:"package_type,omitempty"` + Container *PackageContainerMetadataOutput `json:"container,omitempty"` +} + +type PackageContainerMetadataOutput struct { + Tags []string `json:"tags"` +} + +type PackagesReadOutput struct { + Method string `json:"method"` + Packages *[]PackageOutput `json:"packages,omitempty"` + Package *PackageOutput `json:"package,omitempty"` + Versions *[]PackageVersionOutput `json:"versions,omitempty"` + Version *PackageVersionOutput `json:"version,omitempty"` +} + +type PackagesWriteOutput struct { + Method string `json:"method"` + Message string `json:"message"` +} + +func convertToPackageOutput(pkg *github.Package) PackageOutput { + out := PackageOutput{ + ID: pkg.GetID(), + Name: pkg.GetName(), + PackageType: pkg.GetPackageType(), + Visibility: pkg.GetVisibility(), + VersionCount: pkg.VersionCount, + HTMLURL: pkg.GetHTMLURL(), + CreatedAt: formatMinimalTimestamp(pkg.CreatedAt), + UpdatedAt: formatMinimalTimestamp(pkg.UpdatedAt), + } + if pkg.Owner != nil { + out.Owner = pkg.Owner.GetLogin() + } + if pkg.Repository != nil { + out.Repository = pkg.Repository.GetFullName() + } + return out +} + +func convertToPackageOutputs(packages []*github.Package) []PackageOutput { + out := make([]PackageOutput, 0, len(packages)) + for _, pkg := range packages { + out = append(out, convertToPackageOutput(pkg)) + } + return out +} + +func convertToPackageVersionOutput(version *github.PackageVersion) PackageVersionOutput { + out := PackageVersionOutput{ + ID: version.GetID(), + Name: version.GetName(), + Description: version.GetDescription(), + License: version.GetLicense(), + HTMLURL: version.GetHTMLURL(), + CreatedAt: formatMinimalTimestamp(version.CreatedAt), + UpdatedAt: formatMinimalTimestamp(version.UpdatedAt), + } + // REST responses carry metadata as an object. Anything else (such as the + // webhook array form) is dropped rather than failing the whole call. + var metadata github.PackageMetadata + if len(version.Metadata) > 0 && json.Unmarshal(version.Metadata, &metadata) == nil { + out.Metadata = &PackageVersionMetadataOutput{PackageType: metadata.GetPackageType()} + if metadata.Container != nil { + tags := metadata.Container.Tags + if tags == nil { + tags = []string{} + } + out.Metadata.Container = &PackageContainerMetadataOutput{Tags: tags} + } + } + return out +} + +func convertToPackageVersionOutputs(versions []*github.PackageVersion) []PackageVersionOutput { + out := make([]PackageVersionOutput, 0, len(versions)) + for _, version := range versions { + out = append(out, convertToPackageVersionOutput(version)) + } + return out +} + +func packagesReadOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[PackagesReadOutput]()) + schema.Properties["method"].Enum = packagesReadMethods + schema.Properties["method"].Description = "The method that produced the corresponding typed output." + for _, pkg := range []*jsonschema.Schema{schema.Properties["package"], schema.Properties["packages"].Items} { + pkg.Properties["owner"].Description = "Login of the package owner." + pkg.Properties["repository"].Description = "Full name of the repository linked to the package." + pkg.Properties["version_count"].Description = "Number of versions. Omitted when the API does not report it, as in list results." + } + for _, version := range []*jsonschema.Schema{schema.Properties["version"], schema.Properties["versions"].Items} { + tags := version.Properties["metadata"].Properties["container"].Properties["tags"] + tags.Type, tags.Types = "array", nil + } + constrainMethodOutput(schema, map[string]string{ + packagesMethodListOrgPackages: "packages", + packagesMethodListUserPackages: "packages", + packagesMethodGetOrgPackage: "package", + packagesMethodGetUserPackage: "package", + packagesMethodListOrgPackageVersions: "versions", + packagesMethodListUserVersions: "versions", + packagesMethodGetOrgPackageVersion: "version", + packagesMethodGetUserPackageVersion: "version", + }) + return schema +} + +func packagesWriteOutputSchema() *jsonschema.Schema { + schema := repositoryOutputSchema[PackagesWriteOutput]() + schema.Properties["method"].Enum = packagesWriteMethods + schema.Properties["method"].Description = "The method that produced the corresponding typed output." + return schema +}