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:
|
| `labels` | GitHub Labels related tools |
|
| `notifications` | GitHub Notifications related tools |
|
| `orgs` | GitHub Organization related tools |
+|
| `packages` | GitHub Packages related tools |
|
| `projects` | GitHub Projects related tools |
|
| `pull_requests` | GitHub Pull Request related tools |
|
| `repos` | GitHub Repository related tools |
@@ -1165,6 +1166,52 @@ The following sets of tools are available:
+
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)
+
+
+
+
+
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
| 
`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) |
| 
`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) |
| 
`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) |
+| 
`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) |
| 
`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) |
| 
`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) |
| 
`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/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/octicons/icons/package-dark.png b/pkg/octicons/icons/package-dark.png
new file mode 100644
index 0000000000..4f60b28ceb
Binary files /dev/null and b/pkg/octicons/icons/package-dark.png differ
diff --git a/pkg/octicons/icons/package-light.png b/pkg/octicons/icons/package-light.png
new file mode 100644
index 0000000000..7e3e95a465
Binary files /dev/null and b/pkg/octicons/icons/package-light.png differ
diff --git a/pkg/octicons/icons_data_uris.txt b/pkg/octicons/icons_data_uris.txt
index b5899f7589..a737b59584 100644
--- a/pkg/octicons/icons_data_uris.txt
+++ b/pkg/octicons/icons_data_uris.txt
@@ -38,6 +38,8 @@ mark-github-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgd
mark-github-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADK0lEQVRIibWVQWhcVRSGv3Pfy0zGdBIMRWk7iziMTpORptpqiNSNG1dNizaFFlxIoYuCC7NwJdiVlhYprtwpighCF9GtuhCRtuCoaWdqGF7HkE4aWm1CpklmJpl3j4vMS99M2k4G9N+8d8659/8u9553H/zPkscVM5lMf03do6iOKbJXIAGgUBJ0GpHvolKfzOfzCx0BEonRWDRemRD0PaC3zSKXED1fLfdcLJUuV9oC0ukXdluxkyq81Ma4VX9Y4x4p5rOzjwSkUvsSdMkVYE+H5oHmjM9IoTA1FyRM8JJIjMbokm83zUW/Vvi5raXwC8rnjWiPdXRyYGCgOyi7wUs0XpkAXmyEa/XV8qmZmZnqc0P7XrXKW2BuKDovgorqLoWMFb4p3rj2I4w7qcHCMSAOctDt7n0X+GiDT6NbrPsXDw50xftzKg7odvcmtXf4DsJTjXBpzbXPzF6/vmgAauoepblbnGTyQLvu2VQmk4kgxEKpvui6cwSCM1Ada5qheq5YzC5tF5DP59dQzjYlZcOzccgyGK65mC+2ax7IWPfLcGxhKARgV7i4vBy70ymgUMj+A9SDWGB3GOCEB/f0VJ/sFJBKjfQS6srAOwD8HR7su/7BTgE2Umn98u9uAgR+ayqpnOkUIK1zZMPTACjyQzOA11ND+z+E8aate4TMs4PD7wu80ezP940nJJMH+ky0XgKeAHkH1TcRXgNywFeoueJN//5T2CCZGT7kWBlV9CSwvwV6n/XuhOddLRuAYjG7hMjHgEH1tEb0BEgWeB44J8aebF2243Nc0fMPMUfgguddLUOoe/r7dlwWJzqGMCxq6q5Wz/jSVQZ+Nb795N69u4thk/6dT7uInNiyYcoU9ZW3FxYWfAjdpp7n1XzjHAbmUJ3wTXQ8gvOZb+oXV1d3zLf6WEcWW3MoJXX9w57n1YKUCdeL+eysOv4oyrQqn65Tv+1adz4WrxzaskXWNs0V5Jq6/is3c7lb4byhRTdzuVvUV0ZQPgDuA6jqlj+fqhvkllE9q+vLL7eab4Afo1RqpJdI9Rhr3ZeCQwuUTqfjvoket7WuS51cjP+5/gWC8y5uIkrtDQAAAABJRU5ErkJggg==
organization-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABLklEQVRIic2VsUoDQRCGvzVWVj5AUHwM38BoFzBo6zMIFlokqOAb+AYp7LTwEbRPZyWYtGIlIiqfhSssYe8uJ2f0a46ZuZ1/Z2duD+aFuqVOrM9Y7RTlDYnAGHgCrmvubRNYDiGsVFWg2q+ZHLWvWhRfqJuwLlkBtae+qtvRHqmjXKyKxQL/DXAM3Eb7rCQ2G3PtgdpW99V2tHtqLxeroqjJ68BpfAIcAYcFsenNLWUzNnFE6p76nH54TY/pJXAHnJcK/HRMQwiPwBWw+u379THNCoQQJsBJYg+LYlX8zVXRJEU9AL4aCqxNue9DCBeNCABDoDXl+wBmFqg6ohYwCBFgkBEsJa1gAnTVl6pF6kFidoG3xNcFxrlFnfh/TXlXd5J3dqOvjAd1o06V/5tPH0lBqyqxKbkAAAAASUVORK5CYII=
organization-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABqElEQVRIidWUsW4TQRCGv9kYFzwCRCDEUuxJsCelyiMAXSSs1Cn8ChQg4QiQeANIpHQoQumg4Q1IGxvJviiLKOIWOgQx8g1FOFux1z58SoTyVzvz7843ezs6uGBJsbjj0oeKbgHXF6qg9NXkzS/dzx9jdm20T/UNwnfg7YItPhA1W8CNuQCEZUR2QvegtUh9m6S/UH02yzeLFKuiKMAmvmGdP7FJ+gjAOt+xzndiXplq0exAP8kVea6DfB9AVF7N8ioBQuj0gRdFfJQd7M7yKgGsvbesddZlwLsQOn2b+AZA6Lb3Jr0yQPyR62ZVVF5SN6sAKE9F5UnUm9C1lZWr0ZrWebVJ2irraOpckras83q69hvW+R+3k7v359+gomr5yXvgUNS8nguoOqZZln1D5ANwcwSNtnKZxvT//CrOU/E3+Cub+AbKrTNJ4WvotvfOBYCyCyxN5IbAPwPKPtESIpuh15bQawsim1PAEo1voPRB16zzP8sOWecfj8/pGvB7lDuNj6cAavKm5GYbYTzzMFTNsyKQPD9UkSGc2VOoyB2r0WZZk5dHfwA6M7v5DAVu0gAAAABJRU5ErkJggg==
+package-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAABzklEQVR4nMTUv2oUXRjH8TPvG0wRTSVIyhBTWSdegEUakZA6WOQGUgna2ViIldhYWOgNaGGXRrHRXlBBRBAMKmIIaYyY/cjB5wmTIZudJajf2QMzvz/nZCDzTJSe4HQpZb2UMiil3Gma5mt6xwITWMcWfsbaCm1izO0OgiW88psNnIu1EVr1ljLfG8zjcWzyFpfSS6oWnsjOpzcUTOMWdrGNKziRfpfqRWY7OrU7nf4BsIzP2MM9nElvFDUbnb3Y42J6++BbvO4jnEy9L7UT3fr7kvo+YXzAAJu4jCb9YdRMZDejW/eQfveA61jE83h+gfOZ6VK9yIjOYuwx/IC4b7CKj/FXPcBMKzsT2iAyq/m2vQ7IC1O4ge/YwbVYO6FVb6rT6X9Aglk8jIy4n03/qAN6fepN07wvpazgWTyvpDeKcWfJ4DDxqOu/vPlT/NUD9kopc/g/hXGJ7tlSyo/U9sHd+A95iQupt8HTuvK5Te1Et/5up37YwHsXoTqT5jr+k7o62lxrBtXucnqHgklcjY+pjuCbONU9oGrh7Ua2diZzn5HEOLgf4+AT1up3EGsttEFkZjr1/mChNfzaVx1uC5k7Fq3h9wav28Ptn/NrAFabsbk4q6WOAAAAAElFTkSuQmCC
+package-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAADAklEQVR4nMRVT2gcZRx9b2azhEgLxdrQg3+Sme5u0nQ8icWrihfxT69VUbwIQmPNqWmFgrbFgxYrgkKJVvQcKb2InrxU6sVs42Z3MtuleigrBVFJqNudefJb9lti1g1rKPb9ZuGb33vv93aW+b7NYUjsLRR2j/mjs8iUreuvD2/E8U3HbQW6xRbITRaj10meBLSja/tT0slrtfJHANpO+J8DwmL0lIizJKcEfYMWj3aIvM4SfFLSCoWjSa38tfMMFTA5NbWPyr9P4mlJCaW5pHb1ouOtwuKBZ0S+RzKUcElsvXltZWXV8f8aEIbhziw39hbJI5RuSXqn7qcfoFJpOc0/MD2dD1J/luQJkaOSznnt9beTJPmjLyAozDwHz/sEwG5Sn6a3dLzRWG46fitMTMyM+6M8JfEVADcz8NVGdemScZ4TwfMXSO4BcPF3pm8MO9zKtOYxr83wgQXH9QJI7JL0C4BndyoXB6Xopc0/4QDQtOYxb2cGcV9fQOeSFrK0fZDAdZIXgtLDlx/aFz3q6M0wzjSmNY95IfW+fX+APe5q5UqyUn4MKV4EdL/v4/JkMbrwQKm012lsbT3jTGNa8zRWK1ecxmHQTlYSL30xPh4t3rML8x4xN8L8oaAUne6+GvMERjLgzNpvON1sLq0547ABnWo2y2to4viDheh8zt55D50AZVi8Lc1dj8sNp91WgKvuIHuC7+y+XisfctwdCeiVkLnlsPAG9O8Y/r8ASSnpBQB819sGfNjhB7T6AgCeB/FCUIp+nCwceNx1h4V5zEvyMJR93BdQry69pjR9HsCY53vfBqVoMQz3BxsDSMg+7t7KNKY1j3ltRr16dbYvwK56vPwV2+vTkI6BeEIjfiWYit69t1jcgY1CANYzzjSmNY95bcbA/4ONZcfBiPJnQNih92smznvUy8Zl4mceZZtuD4TPb7N17Odq9YbzDhXgaqKw/xHf988BPLhpU3yfpumRRvzTD66zrYBuMSzMHJbnnbDtxiw7lcTLX9q6p7hb9fcAI+U69Y3LC/cAAAAASUVORK5CYII=
people-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB20lEQVRIidXUvUuVcRQH8PPc1JYUrKWUDCmwoKa2QsTKoKEwxWhr6G1tqDVqaAn8F6I5oS0ipBp6WZqiAi1bEgIrIpcorD4NHvFye+7Vrkv94Dc853zP93venl/E/36Keg60RsRgROyOiEpEPI+IB0VR/FyzKgYw48/zBv1rJe/HN7zDKNrzDmMqfc2JoAVvk3xjib8zfa/R0ozA4WzFaAPMWGKG8vskLuDIiqK4lMHtDTAdibmY3+9rZrSnGl+piV9YqcpY3jwREUVRdEXEhog4GhGtETGJznrZHchMhhtUcCIxh0p8u/ADV+sFV3KAU2VZYBNmE7OuDsdj3K+XYGAfvua2jGXPOzLz2VzT/Q3iH2GykUCByyU/2dK50iB2B77jWj3ATjxNos+4hfG8E2mDJ+irid2LaXzCljLyQcxjDmctvkW1mFacwwd8wUCV72GKH6+X+TxeYGvd/i3je/AqRfrSNo6F5DldDS6y5LnVkFfFbcPHHGqRtu24i184tQQcytLOrJa8SuR8xh6ssrXhTm5bd+BmDq+tCYH12aYbNfbe3KbrYfH9mPhb8iqy25gusd/Ds0pEbI6ImWYFImI6IrpK7C8jojcwgu5m2dGFYyX2How0y/vvnN8dpHfeBcHNQgAAAABJRU5ErkJggg==
people-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAADWklEQVRIidWTT2hcVRTGf+e+mUn9Q3WMqbbWgJ1JnMw4meiAUkoxIRG1IKK4ELqoVkQXUsVuXbropoq4EWykuBDURV2I0r9GrC0VopnXTGaefYkhDahBOg4SnSTv3eMiTphMJ6Spq57dPd+933e/794DN3rJWkA+n49W54MB4AE1aoyawqVS1xn4PPzfAsnUg48iOgyaaIJ8I7r/5wn3u2sVMM2N7nTvbsQeB42J6nOOrW12bG2zIs8ohFblZHe6d/d1Oejv74/MzlU8lGibCfqKxeKVRrwzm43HAlMAatvviqdHRkaC9QScxkVk021DwAFRfalcGv+xeXN1bq52x5ats8Crf80vnL3yx29TiZ7e59s7tg7Ft3TEc5n09PT0tG08syoiEckBGF04seaVFtuOA1ixuf9OvQP6rqj5avb3SmlHKptdU0BhaT3L1gbLsVpRgMlSYdvNUb1VlKeAqBFzsjObjbcWMNYFCMxNg2sJmE3B4wCOmEK957ru/KVy4UtxnCeBO2OheaOONX9Tk+zJlRXsUsTunLl4sdIIplKp9oC2MYR//FJ3T6uZSKZzZ7Es+OXCIECkOQFVeUFET8UCU0im+w7WMydWeyKwHEboUCuDaw2cKGpFVx76qjmYLI+dRzkE3IvqZ0RrVaK1KsqnCNsROTTpjX3fijyReSip8LCo+aFlRN3d2ZR15COQnUAFOIVwedmbdCI6CMSBc47V/Z7neivkqWxexHwC2h4lmi2VRn9dJZC4Pzcghi9Qaoq8dfstztHR0dFVvyqfz0f/nA9fFNG3gRhqnvbLP30LkOzJfQP0Kzw7WSocW+Vg+ebmAjCjTrhncnz8cqsI6rUjk+80NvgauMex+ojnuV4ynTuMcgD4G/RNv+QO199ArGOGUWrXQg4wVRydCTF7gCVr5Agg/kThIE6YQjkH8mEi1bcPQLp6+h5T9AToy37JPbIeeWN1pXOvqPKBFR2amnBPA2QymdiidY4pMmhCEgbsXqDSZsKPN0IOoIvzR4GqsbK33isWi4vG8hogoaOvG0V2oXK6WCwublTA9/0FgTMIuxr7nuf+AowIDBjgboz6GyVfcSF4wLarAGEcuC8iyj4JuXC9Ak5o3g8j4fnmvpXIe44NWg7kjVX/Ap7dYx0LcmfJAAAAAElFTkSuQmCC
person-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABaElEQVRIidWUvUrDcBTFz79YnRSii4N2cVB8AuvgByKuQqmrILQPoe9SfABxV5wUna2IVLq1k11adffn4A3UNPknjQ56IARyzz3ncj8i/Xe4LCSgIGlD0qp9epJ07Zz7+HEFQBl4YBRNoPwb4u9AB6gA0/bsAy3gDVjLK+6syg4wGxMPLHYPZGp1VGDLWlHxcKrG2UziFDwe4UAvPZyLCHcsgwlPLETYmkSuz6Bp7x0PZy/CzQ6gYENuAUFMfA7o2pB9hXpN1m0VOzbQGXsOTDz/mpqBA05ijizEcZpG4v4CK5IaksqS+pKuJHUtXNLXbAJJd5KOnHPP41S+DbwCL0ANKMZwikAd6AED3y2MVG7ij8BiBn7JuANgOY3sgFurPFU8YtIDbry/DWDXhlfLKj6UW7fc5LsBToE+MJnDYMra1PCR2sDZuOJD+efAt22KXuC8pHZeA8td8FVQBZIJKQCWgMO8+X8Tn12zhtgfmPjeAAAAAElFTkSuQmCC
diff --git a/pkg/octicons/required_icons.txt b/pkg/octicons/required_icons.txt
index ebc5d99048..6b5be3290b 100644
--- a/pkg/octicons/required_icons.txt
+++ b/pkg/octicons/required_icons.txt
@@ -33,6 +33,7 @@ law
logo-gist
mark-github
organization
+package
people
person
project
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))