From bba8eda9acc4c9994b398f5d1c93bbffb7802e5f Mon Sep 17 00:00:00 2001 From: MayorFaj Date: Sun, 12 Oct 2025 19:49:07 +0100 Subject: [PATCH 1/3] Add GitHub Packages tools and tests - Introduced new tools for managing GitHub Packages, including listing, retrieving, and deleting packages and their versions. - Implemented comprehensive unit tests for the new package-related tools to ensure functionality and error handling. - Updated toolset metadata to include the new packages tools in the available tools list. --- .../__toolsnaps__/delete_org_package.snap | 38 + .../delete_org_package_version.snap | 43 + .../__toolsnaps__/delete_user_package.snap | 33 + .../delete_user_package_version.snap | 38 + pkg/github/__toolsnaps__/get_org_package.snap | 38 + .../__toolsnaps__/get_package_version.snap | 43 + .../__toolsnaps__/list_org_packages.snap | 52 + .../__toolsnaps__/list_package_versions.snap | 57 + .../__toolsnaps__/list_user_packages.snap | 52 + pkg/github/packages.go | 641 +++++++++++ pkg/github/packages_test.go | 1005 +++++++++++++++++ pkg/github/tools.go | 20 + 12 files changed, 2060 insertions(+) create mode 100644 pkg/github/__toolsnaps__/delete_org_package.snap create mode 100644 pkg/github/__toolsnaps__/delete_org_package_version.snap create mode 100644 pkg/github/__toolsnaps__/delete_user_package.snap create mode 100644 pkg/github/__toolsnaps__/delete_user_package_version.snap create mode 100644 pkg/github/__toolsnaps__/get_org_package.snap create mode 100644 pkg/github/__toolsnaps__/get_package_version.snap create mode 100644 pkg/github/__toolsnaps__/list_org_packages.snap create mode 100644 pkg/github/__toolsnaps__/list_package_versions.snap create mode 100644 pkg/github/__toolsnaps__/list_user_packages.snap create mode 100644 pkg/github/packages.go create mode 100644 pkg/github/packages_test.go diff --git a/pkg/github/__toolsnaps__/delete_org_package.snap b/pkg/github/__toolsnaps__/delete_org_package.snap new file mode 100644 index 000000000..5c2021c0c --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_org_package.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "title": "Delete organization package", + "readOnlyHint": false + }, + "description": "Delete an entire package from a GitHub organization. This will delete all versions of the package. Requires delete:packages scope.", + "inputSchema": { + "properties": { + "org": { + "description": "Organization name", + "type": "string" + }, + "package_name": { + "description": "Package name", + "type": "string" + }, + "package_type": { + "description": "Package type", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + } + }, + "required": [ + "org", + "package_type", + "package_name" + ], + "type": "object" + }, + "name": "delete_org_package" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_org_package_version.snap b/pkg/github/__toolsnaps__/delete_org_package_version.snap new file mode 100644 index 000000000..c1379bc3f --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_org_package_version.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "title": "Delete organization package version", + "readOnlyHint": false + }, + "description": "Delete a specific version of a package from a GitHub organization. Requires delete:packages scope.", + "inputSchema": { + "properties": { + "org": { + "description": "Organization name", + "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", + "type": "number" + } + }, + "required": [ + "org", + "package_type", + "package_name", + "package_version_id" + ], + "type": "object" + }, + "name": "delete_org_package_version" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_user_package.snap b/pkg/github/__toolsnaps__/delete_user_package.snap new file mode 100644 index 000000000..42d526f2a --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_user_package.snap @@ -0,0 +1,33 @@ +{ + "annotations": { + "title": "Delete user package", + "readOnlyHint": false + }, + "description": "Delete an entire package from the authenticated user's account. This will delete all versions of the package. Requires delete:packages scope.", + "inputSchema": { + "properties": { + "package_name": { + "description": "Package name", + "type": "string" + }, + "package_type": { + "description": "Package type", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + } + }, + "required": [ + "package_type", + "package_name" + ], + "type": "object" + }, + "name": "delete_user_package" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_user_package_version.snap b/pkg/github/__toolsnaps__/delete_user_package_version.snap new file mode 100644 index 000000000..d12f2829e --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_user_package_version.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "title": "Delete user package version", + "readOnlyHint": false + }, + "description": "Delete a specific version of a package from the authenticated user's account. Requires delete:packages scope.", + "inputSchema": { + "properties": { + "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", + "type": "number" + } + }, + "required": [ + "package_type", + "package_name", + "package_version_id" + ], + "type": "object" + }, + "name": "delete_user_package_version" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_org_package.snap b/pkg/github/__toolsnaps__/get_org_package.snap new file mode 100644 index 000000000..70c8b5027 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_org_package.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "title": "Get organization package details", + "readOnlyHint": true + }, + "description": "Get details of a specific package for an organization.", + "inputSchema": { + "properties": { + "org": { + "description": "Organization name", + "type": "string" + }, + "package_name": { + "description": "Package name", + "type": "string" + }, + "package_type": { + "description": "Package type", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + } + }, + "required": [ + "org", + "package_type", + "package_name" + ], + "type": "object" + }, + "name": "get_org_package" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_package_version.snap b/pkg/github/__toolsnaps__/get_package_version.snap new file mode 100644 index 000000000..67d841d15 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_package_version.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "title": "Get package version details", + "readOnlyHint": true + }, + "description": "Get details of a specific package version, including metadata.", + "inputSchema": { + "properties": { + "org": { + "description": "Organization name", + "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", + "type": "number" + } + }, + "required": [ + "org", + "package_type", + "package_name", + "package_version_id" + ], + "type": "object" + }, + "name": "get_package_version" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_org_packages.snap b/pkg/github/__toolsnaps__/list_org_packages.snap new file mode 100644 index 000000000..669d69100 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_org_packages.snap @@ -0,0 +1,52 @@ +{ + "annotations": { + "title": "List organization packages", + "readOnlyHint": true + }, + "description": "List packages for a GitHub organization. Returns package metadata including name, type, visibility, and version count.", + "inputSchema": { + "properties": { + "org": { + "description": "Organization name", + "type": "string" + }, + "package_type": { + "description": "Filter by package type", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + }, + "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" + }, + "visibility": { + "description": "Filter by package visibility", + "enum": [ + "public", + "private", + "internal" + ], + "type": "string" + } + }, + "required": [ + "org" + ], + "type": "object" + }, + "name": "list_org_packages" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_package_versions.snap b/pkg/github/__toolsnaps__/list_package_versions.snap new file mode 100644 index 000000000..c5f51d2cb --- /dev/null +++ b/pkg/github/__toolsnaps__/list_package_versions.snap @@ -0,0 +1,57 @@ +{ + "annotations": { + "title": "List package versions", + "readOnlyHint": true + }, + "description": "List versions of a package for an organization. Each version includes metadata.", + "inputSchema": { + "properties": { + "org": { + "description": "Organization name", + "type": "string" + }, + "package_name": { + "description": "Package name", + "type": "string" + }, + "package_type": { + "description": "Package type", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + }, + "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 by version state", + "enum": [ + "active", + "deleted" + ], + "type": "string" + } + }, + "required": [ + "org", + "package_type", + "package_name" + ], + "type": "object" + }, + "name": "list_package_versions" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_user_packages.snap b/pkg/github/__toolsnaps__/list_user_packages.snap new file mode 100644 index 000000000..f85a06f27 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_user_packages.snap @@ -0,0 +1,52 @@ +{ + "annotations": { + "title": "List user packages", + "readOnlyHint": true + }, + "description": "List packages for a GitHub user. Note: Download statistics are not available via the GitHub REST API.", + "inputSchema": { + "properties": { + "package_type": { + "description": "Filter by package type", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ], + "type": "string" + }, + "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" + }, + "username": { + "description": "GitHub username", + "type": "string" + }, + "visibility": { + "description": "Filter by package visibility", + "enum": [ + "public", + "private", + "internal" + ], + "type": "string" + } + }, + "required": [ + "username" + ], + "type": "object" + }, + "name": "list_user_packages" +} \ No newline at end of file diff --git a/pkg/github/packages.go b/pkg/github/packages.go new file mode 100644 index 000000000..23e83649f --- /dev/null +++ b/pkg/github/packages.go @@ -0,0 +1,641 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v74/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// authenticatedUser represents the empty string used to indicate operations +// should be performed on the authenticated user's account rather than a specific username. +// This is used by GitHub's API when the username parameter is empty. +const authenticatedUser = "" + +// NOTE: GitHub's REST API for packages does not currently expose download statistics. +// While download counts are visible on the GitHub web interface (e.g., github.com/orgs/{org}/packages), +// they are not included in the API responses. + +// handleDeletionResponse handles the common response logic for package deletion operations. +// It checks the status code, reads error messages if any, and returns a standardized success response. +func handleDeletionResponse(resp *github.Response, successMessage string) (*mcp.CallToolResult, error) { + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("deletion failed: %s", string(body))), nil + } + + result := map[string]interface{}{ + "success": true, + "message": successMessage, + } + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// ListOrgPackages creates a tool to list packages for an organization +func ListOrgPackages(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_org_packages", + mcp.WithDescription(t("TOOL_LIST_ORG_PACKAGES_DESCRIPTION", "List packages for a GitHub organization. Returns package metadata including name, type, visibility, and version count.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ORG_PACKAGES_USER_TITLE", "List organization packages"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("Organization name"), + ), + mcp.WithString("package_type", + mcp.Description("Filter by package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("visibility", + mcp.Description("Filter by package visibility"), + mcp.Enum("public", "private", "internal"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageType, err := OptionalParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + visibility, err := OptionalParam[string](request, "visibility") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.PackageListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + // Only set optional parameters if they have values + if packageType != "" { + opts.PackageType = github.Ptr(packageType) + } + if visibility != "" { + opts.Visibility = github.Ptr(visibility) + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + packages, resp, err := client.Organizations.ListPackages(ctx, org, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list packages for organization '%s'", org), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(packages) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetOrgPackage creates a tool to get a specific package for an organization with download statistics +func GetOrgPackage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_org_package", + mcp.WithDescription(t("TOOL_GET_ORG_PACKAGE_DESCRIPTION", "Get details of a specific package for an organization.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_ORG_PACKAGE_USER_TITLE", "Get organization package details"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("Organization name"), + ), + mcp.WithString("package_type", + mcp.Required(), + mcp.Description("Package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("package_name", + mcp.Required(), + mcp.Description("Package name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](request, "package_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + pkg, resp, err := client.Organizations.GetPackage(ctx, org, packageType, packageName) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get package '%s' of type '%s' for organization '%s'", packageName, packageType, org), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(pkg) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListPackageVersions creates a tool to list versions of a package with download statistics +func ListPackageVersions(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_package_versions", + mcp.WithDescription(t("TOOL_LIST_PACKAGE_VERSIONS_DESCRIPTION", "List versions of a package for an organization. Each version includes metadata.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_PACKAGE_VERSIONS_USER_TITLE", "List package versions"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("Organization name"), + ), + mcp.WithString("package_type", + mcp.Required(), + mcp.Description("Package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("package_name", + mcp.Required(), + mcp.Description("Package name"), + ), + mcp.WithString("state", + mcp.Description("Filter by version state"), + mcp.Enum("active", "deleted"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](request, "package_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.PackageListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + // Only set state parameter if it has a value + if state != "" { + opts.State = github.Ptr(state) + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + versions, resp, err := client.Organizations.PackageGetAllVersions(ctx, org, packageType, packageName, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list package versions for package '%s' of type '%s'", packageName, packageType), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(versions) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetPackageVersion creates a tool to get a specific package version with download statistics +func GetPackageVersion(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_package_version", + mcp.WithDescription(t("TOOL_GET_PACKAGE_VERSION_DESCRIPTION", "Get details of a specific package version, including metadata.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PACKAGE_VERSION_USER_TITLE", "Get package version details"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("Organization name"), + ), + mcp.WithString("package_type", + mcp.Required(), + mcp.Description("Package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("package_name", + mcp.Required(), + mcp.Description("Package name"), + ), + mcp.WithNumber("package_version_id", + mcp.Required(), + mcp.Description("Package version ID"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](request, "package_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageVersionID, err := RequiredParam[float64](request, "package_version_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + version, resp, err := client.Organizations.PackageGetVersion(ctx, org, packageType, packageName, int64(packageVersionID)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get package version %d for package '%s'", int64(packageVersionID), packageName), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(version) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// ListUserPackages creates a tool to list packages for a user +func ListUserPackages(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_user_packages", + mcp.WithDescription(t("TOOL_LIST_USER_PACKAGES_DESCRIPTION", "List packages for a GitHub user. Note: Download statistics are not available via the GitHub REST API.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_USER_PACKAGES_USER_TITLE", "List user packages"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("username", + mcp.Required(), + mcp.Description("GitHub username"), + ), + mcp.WithString("package_type", + mcp.Description("Filter by package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("visibility", + mcp.Description("Filter by package visibility"), + mcp.Enum("public", "private", "internal"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + username, err := RequiredParam[string](request, "username") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageType, err := OptionalParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + visibility, err := OptionalParam[string](request, "visibility") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.PackageListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + // Only set optional parameters if they have values + if packageType != "" { + opts.PackageType = github.Ptr(packageType) + } + if visibility != "" { + opts.Visibility = github.Ptr(visibility) + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + packages, resp, err := client.Users.ListPackages(ctx, username, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list packages for user '%s'", username), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(packages) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// DeleteOrgPackage creates a tool to delete an entire package from an organization +// Requires delete:packages scope in addition to read:packages +func DeleteOrgPackage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_org_package", + mcp.WithDescription(t("TOOL_DELETE_ORG_PACKAGE_DESCRIPTION", "Delete an entire package from a GitHub organization. This will delete all versions of the package. Requires delete:packages scope.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_ORG_PACKAGE_USER_TITLE", "Delete organization package"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("Organization name"), + ), + mcp.WithString("package_type", + mcp.Required(), + mcp.Description("Package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("package_name", + mcp.Required(), + mcp.Description("Package name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](request, "package_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Organizations.DeletePackage(ctx, org, packageType, packageName) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to delete package '%s' of type '%s' for organization '%s'", packageName, packageType, org), + resp, + err, + ), nil + } + + return handleDeletionResponse(resp, fmt.Sprintf("Package '%s' deleted successfully from organization '%s'", packageName, org)) + } +} + +// DeleteOrgPackageVersion creates a tool to delete a specific version of a package from an organization +// Requires delete:packages scope in addition to read:packages +func DeleteOrgPackageVersion(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_org_package_version", + mcp.WithDescription(t("TOOL_DELETE_ORG_PACKAGE_VERSION_DESCRIPTION", "Delete a specific version of a package from a GitHub organization. Requires delete:packages scope.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_ORG_PACKAGE_VERSION_USER_TITLE", "Delete organization package version"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("Organization name"), + ), + mcp.WithString("package_type", + mcp.Required(), + mcp.Description("Package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("package_name", + mcp.Required(), + mcp.Description("Package name"), + ), + mcp.WithNumber("package_version_id", + mcp.Required(), + mcp.Description("Package version ID"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](request, "package_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageVersionID, err := RequiredParam[float64](request, "package_version_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Organizations.PackageDeleteVersion(ctx, org, packageType, packageName, int64(packageVersionID)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to delete package version %d of package '%s'", int64(packageVersionID), packageName), + resp, + err, + ), nil + } + + return handleDeletionResponse(resp, fmt.Sprintf("Package version %d deleted successfully from package '%s'", int64(packageVersionID), packageName)) + } +} + +// DeleteUserPackage creates a tool to delete an entire package from the authenticated user's account +// Requires delete:packages scope in addition to read:packages +func DeleteUserPackage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_user_package", + mcp.WithDescription(t("TOOL_DELETE_USER_PACKAGE_DESCRIPTION", "Delete an entire package from the authenticated user's account. This will delete all versions of the package. Requires delete:packages scope.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_USER_PACKAGE_USER_TITLE", "Delete user package"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("package_type", + mcp.Required(), + mcp.Description("Package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("package_name", + mcp.Required(), + mcp.Description("Package name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + packageType, err := RequiredParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](request, "package_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Users.DeletePackage(ctx, authenticatedUser, packageType, packageName) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to delete package '%s' of type '%s'", packageName, packageType), + resp, + err, + ), nil + } + + return handleDeletionResponse(resp, fmt.Sprintf("Package '%s' deleted successfully", packageName)) + } +} + +// DeleteUserPackageVersion creates a tool to delete a specific version of a package from the authenticated user's account +// Requires delete:packages scope in addition to read:packages +func DeleteUserPackageVersion(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_user_package_version", + mcp.WithDescription(t("TOOL_DELETE_USER_PACKAGE_VERSION_DESCRIPTION", "Delete a specific version of a package from the authenticated user's account. Requires delete:packages scope.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_USER_PACKAGE_VERSION_USER_TITLE", "Delete user package version"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("package_type", + mcp.Required(), + mcp.Description("Package type"), + mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), + ), + mcp.WithString("package_name", + mcp.Required(), + mcp.Description("Package name"), + ), + mcp.WithNumber("package_version_id", + mcp.Required(), + mcp.Description("Package version ID"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + packageType, err := RequiredParam[string](request, "package_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](request, "package_name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + packageVersionID, err := RequiredParam[float64](request, "package_version_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + resp, err := client.Users.PackageDeleteVersion(ctx, authenticatedUser, packageType, packageName, int64(packageVersionID)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to delete version %d of package '%s'", int64(packageVersionID), packageName), + resp, + err, + ), nil + } + + return handleDeletionResponse(resp, fmt.Sprintf("Package version %d deleted successfully from package '%s'", int64(packageVersionID), packageName)) + } +} diff --git a/pkg/github/packages_test.go b/pkg/github/packages_test.go new file mode 100644 index 000000000..ab9e3755d --- /dev/null +++ b/pkg/github/packages_test.go @@ -0,0 +1,1005 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v74/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// verifyDeletionSuccess is a helper function to verify deletion operation success. +// It checks that the result is not an error, parses the JSON response, and verifies +// the success status and message content. +func verifyDeletionSuccess(t *testing.T, result *mcp.CallToolResult, err error) { + t.Helper() + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the success result + textContent := getTextResult(t, result) + var response map[string]interface{} + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + assert.True(t, response["success"].(bool)) + assert.Contains(t, response["message"].(string), "deleted successfully") +} + +func Test_ListOrgPackages(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := ListOrgPackages(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_org_packages", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "visibility") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"}) + + // Setup mock packages for success case + mockPackages := []*github.Package{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("github-mcp-server"), + PackageType: github.Ptr("container"), + HTMLURL: github.Ptr("https://github.com/orgs/github/packages/container/package/github-mcp-server"), + Visibility: github.Ptr("public"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-package"), + PackageType: github.Ptr("npm"), + HTMLURL: github.Ptr("https://github.com/orgs/github/packages/npm/package/test-package"), + Visibility: github.Ptr("private"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPackages []*github.Package + expectedErrMsg string + }{ + { + name: "successful packages listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages", Method: "GET"}, + mockPackages, + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + }, + expectError: false, + expectedPackages: mockPackages, + }, + { + name: "successful packages listing with filters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages", Method: "GET"}, + mockPackages, + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "visibility": "public", + }, + expectError: false, + expectedPackages: mockPackages, + }, + { + name: "organization not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages", Method: "GET"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to list packages", + }, + { + name: "missing required parameter org", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListOrgPackages(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result + textContent := getTextResult(t, result) + var returnedPackages []*github.Package + err = json.Unmarshal([]byte(textContent.Text), &returnedPackages) + require.NoError(t, err) + + assert.Equal(t, len(tc.expectedPackages), len(returnedPackages)) + for i, pkg := range returnedPackages { + assert.Equal(t, tc.expectedPackages[i].GetID(), pkg.GetID()) + assert.Equal(t, tc.expectedPackages[i].GetName(), pkg.GetName()) + } + }) + } +} + +func Test_GetOrgPackage(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetOrgPackage(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_org_package", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "package_name") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name"}) + + // Setup mock package for success case + mockPackage := &github.Package{ + ID: github.Ptr(int64(1)), + Name: github.Ptr("github-mcp-server"), + PackageType: github.Ptr("container"), + HTMLURL: github.Ptr("https://github.com/orgs/github/packages/container/package/github-mcp-server"), + Visibility: github.Ptr("public"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPackage *github.Package + expectedErrMsg string + }{ + { + name: "successful package retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "GET"}, + mockPackage, + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + }, + expectError: false, + expectedPackage: mockPackage, + }, + { + name: "package not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "GET"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Package not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to get package", + }, + { + name: "missing required parameters", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "org": "github", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetOrgPackage(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result + textContent := getTextResult(t, result) + var returnedPackage github.Package + err = json.Unmarshal([]byte(textContent.Text), &returnedPackage) + require.NoError(t, err) + + assert.Equal(t, tc.expectedPackage.GetID(), returnedPackage.GetID()) + assert.Equal(t, tc.expectedPackage.GetName(), returnedPackage.GetName()) + assert.Equal(t, tc.expectedPackage.GetPackageType(), returnedPackage.GetPackageType()) + }) + } +} + +func Test_ListPackageVersions(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := ListPackageVersions(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_package_versions", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "package_name") + assert.Contains(t, tool.InputSchema.Properties, "state") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name"}) + + // Setup mock package versions for success case + mockVersions := []*github.PackageVersion{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("v1.0.0"), + }, + { + ID: github.Ptr(int64(124)), + Name: github.Ptr("v1.0.1"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedVersions []*github.PackageVersion + expectedErrMsg string + }{ + { + name: "successful versions listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions", Method: "GET"}, + mockVersions, + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + }, + expectError: false, + expectedVersions: mockVersions, + }, + { + name: "successful versions listing with state filter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions", Method: "GET"}, + mockVersions, + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + "state": "active", + }, + expectError: false, + expectedVersions: mockVersions, + }, + { + name: "package not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions", Method: "GET"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to list package versions", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListPackageVersions(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result + textContent := getTextResult(t, result) + var returnedVersions []*github.PackageVersion + err = json.Unmarshal([]byte(textContent.Text), &returnedVersions) + require.NoError(t, err) + + assert.Equal(t, len(tc.expectedVersions), len(returnedVersions)) + }) + } +} + +func Test_GetPackageVersion(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := GetPackageVersion(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_package_version", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "package_name") + assert.Contains(t, tool.InputSchema.Properties, "package_version_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name", "package_version_id"}) + + // Setup mock package version for success case + mockVersion := &github.PackageVersion{ + ID: github.Ptr(int64(123)), + Name: github.Ptr("v1.0.0"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedVersion *github.PackageVersion + expectedErrMsg string + }{ + { + name: "successful version retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "GET"}, + mockVersion, + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + "package_version_id": float64(123), + }, + expectError: false, + expectedVersion: mockVersion, + }, + { + name: "version not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "GET"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "github-mcp-server", + "package_version_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get package version", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetPackageVersion(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result + textContent := getTextResult(t, result) + var returnedVersion github.PackageVersion + err = json.Unmarshal([]byte(textContent.Text), &returnedVersion) + require.NoError(t, err) + + assert.Equal(t, tc.expectedVersion.GetID(), returnedVersion.GetID()) + assert.Equal(t, tc.expectedVersion.GetName(), returnedVersion.GetName()) + }) + } +} + +func Test_ListUserPackages(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := ListUserPackages(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_user_packages", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "username") + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "visibility") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"username"}) + + // Setup mock packages for success case + mockPackages := []*github.Package{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("my-package"), + PackageType: github.Ptr("npm"), + Visibility: github.Ptr("public"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedPackages []*github.Package + expectedErrMsg string + }{ + { + name: "successful user packages listing", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/users/{username}/packages", Method: "GET"}, + mockPackages, + ), + ), + requestArgs: map[string]interface{}{ + "username": "octocat", + }, + expectError: false, + expectedPackages: mockPackages, + }, + { + name: "user not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{username}/packages", Method: "GET"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "username": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to list packages", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListUserPackages(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result + textContent := getTextResult(t, result) + var returnedPackages []*github.Package + err = json.Unmarshal([]byte(textContent.Text), &returnedPackages) + require.NoError(t, err) + + assert.Equal(t, len(tc.expectedPackages), len(returnedPackages)) + }) + } +} + +func Test_DeleteOrgPackage(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := DeleteOrgPackage(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_org_package", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "package_name") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful package deletion", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "test-package", + }, + expectError: false, + }, + { + name: "package not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Package not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to delete package", + }, + { + name: "insufficient permissions", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden - requires delete:packages scope"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "test-package", + }, + expectError: true, + expectedErrMsg: "failed to delete package", + }, + { + name: "missing required parameters", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "org": "github", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteOrgPackage(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + verifyDeletionSuccess(t, result, err) + }) + } +} + +func Test_DeleteOrgPackageVersion(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := DeleteOrgPackageVersion(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_org_package_version", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "package_name") + assert.Contains(t, tool.InputSchema.Properties, "package_version_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name", "package_version_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful version deletion", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "test-package", + "package_version_id": float64(123), + }, + expectError: false, + }, + { + name: "version not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Version not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "test-package", + "package_version_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to delete package version", + }, + { + name: "insufficient permissions", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden - requires delete:packages scope"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "github", + "package_type": "container", + "package_name": "test-package", + "package_version_id": float64(123), + }, + expectError: true, + expectedErrMsg: "failed to delete package version", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteOrgPackageVersion(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + verifyDeletionSuccess(t, result, err) + }) + } +} + +func Test_DeleteUserPackage(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := DeleteUserPackage(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_user_package", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "package_name") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"package_type", "package_name"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful user package deletion", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "package_type": "npm", + "package_name": "my-package", + }, + expectError: false, + }, + { + name: "package not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Package not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "package_type": "npm", + "package_name": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to delete package", + }, + { + name: "missing required parameters", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "package_type": "npm", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteUserPackage(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + verifyDeletionSuccess(t, result, err) + }) + } +} + +func Test_DeleteUserPackageVersion(t *testing.T) { + mockClient := github.NewClient(nil) + tool, _ := DeleteUserPackageVersion(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_user_package_version", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "package_type") + assert.Contains(t, tool.InputSchema.Properties, "package_name") + assert.Contains(t, tool.InputSchema.Properties, "package_version_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"package_type", "package_name", "package_version_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful user version deletion", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]interface{}{ + "package_type": "npm", + "package_name": "my-package", + "package_version_id": float64(123), + }, + expectError: false, + }, + { + name: "version not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Version not found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "package_type": "npm", + "package_name": "my-package", + "package_version_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to delete version", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := DeleteUserPackageVersion(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + if tc.expectedErrMsg != "" { + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + } + return + } + + verifyDeletionSuccess(t, result, err) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a982060de..ee0e0db71 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -99,6 +99,10 @@ var ( ID: "labels", Description: "GitHub Labels related tools", } + ToolsetMetadataPackages = ToolsetMetadata{ + ID: "packages", + Description: "GitHub Packages related tools for managing and viewing package metadata, versions, and deletion operations", + } ) func AvailableTools() []ToolsetMetadata { @@ -122,6 +126,7 @@ func AvailableTools() []ToolsetMetadata { ToolsetMetadataStargazers, ToolsetMetadataDynamic, ToolsetLabels, + ToolsetMetadataPackages, } } @@ -333,6 +338,20 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG // create or update toolsets.NewServerTool(LabelWrite(getGQLClient, t)), ) + packages := toolsets.NewToolset(ToolsetMetadataPackages.ID, ToolsetMetadataPackages.Description). + AddReadTools( + toolsets.NewServerTool(ListOrgPackages(getClient, t)), + toolsets.NewServerTool(GetOrgPackage(getClient, t)), + toolsets.NewServerTool(ListPackageVersions(getClient, t)), + toolsets.NewServerTool(GetPackageVersion(getClient, t)), + toolsets.NewServerTool(ListUserPackages(getClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(DeleteOrgPackage(getClient, t)), + toolsets.NewServerTool(DeleteOrgPackageVersion(getClient, t)), + toolsets.NewServerTool(DeleteUserPackage(getClient, t)), + toolsets.NewServerTool(DeleteUserPackageVersion(getClient, t)), + ) // Add toolsets to the group tsg.AddToolset(contextTools) tsg.AddToolset(repos) @@ -352,6 +371,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(projects) tsg.AddToolset(stargazers) tsg.AddToolset(labels) + tsg.AddToolset(packages) return tsg } From e58383b7879ea5e3ed5a7531413816f6034f2d20 Mon Sep 17 00:00:00 2001 From: MayorFaj Date: Sun, 12 Oct 2025 19:59:01 +0100 Subject: [PATCH 2/3] Add GitHub Packages tools and documentation to README and remote-server --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++ docs/remote-server.md | 1 + 2 files changed, 61 insertions(+) diff --git a/README.md b/README.md index 2cf7e5de4..219e57df2 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ The following sets of tools are available (all are on by default): | `labels` | GitHub Labels related tools | | `notifications` | GitHub Notifications related tools | | `orgs` | GitHub Organization related tools | +| `packages` | GitHub Packages related tools for managing and viewing package metadata, versions, and deletion operations | | `projects` | GitHub Projects related tools | | `pull_requests` | GitHub Pull Request related tools | | `repos` | GitHub Repository related tools | @@ -746,6 +747,65 @@ The following sets of tools are available (all are on by default):
+Packages + +- **delete_org_package** - Delete organization package + - `org`: Organization name (string, required) + - `package_name`: Package name (string, required) + - `package_type`: Package type (string, required) + +- **delete_org_package_version** - Delete organization package version + - `org`: Organization name (string, required) + - `package_name`: Package name (string, required) + - `package_type`: Package type (string, required) + - `package_version_id`: Package version ID (number, required) + +- **delete_user_package** - Delete user package + - `package_name`: Package name (string, required) + - `package_type`: Package type (string, required) + +- **delete_user_package_version** - Delete user package version + - `package_name`: Package name (string, required) + - `package_type`: Package type (string, required) + - `package_version_id`: Package version ID (number, required) + +- **get_org_package** - Get organization package details + - `org`: Organization name (string, required) + - `package_name`: Package name (string, required) + - `package_type`: Package type (string, required) + +- **get_package_version** - Get package version details + - `org`: Organization name (string, required) + - `package_name`: Package name (string, required) + - `package_type`: Package type (string, required) + - `package_version_id`: Package version ID (number, required) + +- **list_org_packages** - List organization packages + - `org`: Organization name (string, required) + - `package_type`: Filter by package type (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `visibility`: Filter by package visibility (string, optional) + +- **list_package_versions** - List package versions + - `org`: Organization name (string, required) + - `package_name`: Package name (string, required) + - `package_type`: Package type (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `state`: Filter by version state (string, optional) + +- **list_user_packages** - List user packages + - `package_type`: Filter by package type (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `username`: GitHub username (string, required) + - `visibility`: Filter by package visibility (string, optional) + +
+ +
+ Projects - **add_project_item** - Add project item diff --git a/docs/remote-server.md b/docs/remote-server.md index 61815a482..9b87a6061 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -30,6 +30,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) | | Organizations | 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 for managing and viewing package metadata, versions, and deletion operations | 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) | | Repositories | 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) | From f66279e3d1c81dde738118b3357fa2635e0ee714 Mon Sep 17 00:00:00 2001 From: MayorFaj Date: Thu, 4 Dec 2025 15:24:14 +0000 Subject: [PATCH 3/3] Refactor GitHub package tools: consolidate package-related functions under PackagesRead and PackagesWrite - Updated test cases to reflect new function names and structures. - Replaced ListOrgPackages, GetOrgPackage, ListPackageVersions, and GetPackageVersion with PackagesRead. - Replaced ListUserPackages with PackagesWrite. - Adjusted input schema and annotations for read-only and write operations. - Enhanced deletion tests for organization and user packages and their versions. --- README.md | 82 +- docs/remote-server.md | 2 +- .../__toolsnaps__/delete_org_package.snap | 38 - .../delete_org_package_version.snap | 43 - .../__toolsnaps__/delete_user_package.snap | 33 - .../delete_user_package_version.snap | 38 - pkg/github/__toolsnaps__/get_org_package.snap | 38 - .../__toolsnaps__/get_package_version.snap | 43 - .../__toolsnaps__/list_org_packages.snap | 52 - .../__toolsnaps__/list_package_versions.snap | 57 - .../__toolsnaps__/list_user_packages.snap | 52 - pkg/github/__toolsnaps__/packages_read.snap | 83 ++ pkg/github/__toolsnaps__/packages_write.snap | 51 + pkg/github/packages.go | 982 +++++++-------- pkg/github/packages_test.go | 1058 ++++------------- pkg/github/tools.go | 11 +- 16 files changed, 832 insertions(+), 1831 deletions(-) delete mode 100644 pkg/github/__toolsnaps__/delete_org_package.snap delete mode 100644 pkg/github/__toolsnaps__/delete_org_package_version.snap delete mode 100644 pkg/github/__toolsnaps__/delete_user_package.snap delete mode 100644 pkg/github/__toolsnaps__/delete_user_package_version.snap delete mode 100644 pkg/github/__toolsnaps__/get_org_package.snap delete mode 100644 pkg/github/__toolsnaps__/get_package_version.snap delete mode 100644 pkg/github/__toolsnaps__/list_org_packages.snap delete mode 100644 pkg/github/__toolsnaps__/list_package_versions.snap delete mode 100644 pkg/github/__toolsnaps__/list_user_packages.snap create mode 100644 pkg/github/__toolsnaps__/packages_read.snap create mode 100644 pkg/github/__toolsnaps__/packages_write.snap diff --git a/README.md b/README.md index 21639b560..95f1222d0 100644 --- a/README.md +++ b/README.md @@ -868,58 +868,40 @@ Options are: Packages -- **delete_org_package** - Delete organization package - - `org`: Organization name (string, required) - - `package_name`: Package name (string, required) - - `package_type`: Package type (string, required) - -- **delete_org_package_version** - Delete organization package version - - `org`: Organization name (string, required) - - `package_name`: Package name (string, required) - - `package_type`: Package type (string, required) - - `package_version_id`: Package version ID (number, required) - -- **delete_user_package** - Delete user package - - `package_name`: Package name (string, required) - - `package_type`: Package type (string, required) - -- **delete_user_package_version** - Delete user package version - - `package_name`: Package name (string, required) - - `package_type`: Package type (string, required) - - `package_version_id`: Package version ID (number, required) - -- **get_org_package** - Get organization package details - - `org`: Organization name (string, required) - - `package_name`: Package name (string, required) - - `package_type`: Package type (string, required) - -- **get_package_version** - Get package version details - - `org`: Organization name (string, required) - - `package_name`: Package name (string, required) - - `package_type`: Package type (string, required) - - `package_version_id`: Package version ID (number, required) - -- **list_org_packages** - List organization packages - - `org`: Organization name (string, required) - - `package_type`: Filter by package type (string, optional) +- **packages_read** - Read package information + - `method`: Action to specify what package data needs to be retrieved from GitHub. +Possible options: + 1. list_org_packages - List packages for a GitHub organization. Requires 'org' parameter. Supports optional 'package_type' and 'visibility' filters. + 2. get_org_package - Get details of a specific package for an organization. Requires 'org', 'package_type', and 'package_name' parameters. + 3. list_package_versions - List versions of a package for an organization. Requires 'org', 'package_type', and 'package_name' parameters. Supports optional 'state' filter. + 4. get_package_version - Get details of a specific package version. Requires 'org', 'package_type', 'package_name', and 'package_version_id' parameters. + 5. list_user_packages - List packages for a GitHub user. Requires 'username' parameter. Supports optional 'package_type' and 'visibility' filters. + +Note: Download statistics are not available via the GitHub REST API. (string, required) + - `org`: Organization name (required for org-related methods) (string, optional) + - `package_name`: Package name (required for get_org_package, list_package_versions, and get_package_version methods) (string, optional) + - `package_type`: Package type (string, optional) + - `package_version_id`: Package version ID (required for get_package_version method) (number, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `visibility`: Filter by package visibility (string, optional) - -- **list_package_versions** - List package versions - - `org`: Organization name (string, required) - - `package_name`: Package name (string, required) - - `package_type`: Package type (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `state`: Filter by version state (string, optional) - -- **list_user_packages** - List user packages - - `package_type`: Filter by package type (string, optional) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `username`: GitHub username (string, required) - - `visibility`: Filter by package visibility (string, optional) + - `state`: Filter by version state (optional for list_package_versions method) (string, optional) + - `username`: GitHub username (required for list_user_packages method) (string, optional) + - `visibility`: Filter by package visibility (optional for list methods) (string, optional) + +- **packages_write** - Delete operations on packages + - `method`: The write operation to perform on packages. + +Available methods: + 1. delete_org_package - Delete an entire package from an organization. This will delete all versions of the package. Requires 'org', 'package_type', and 'package_name' parameters. + 2. delete_org_package_version - Delete a specific version of a package from an organization. Requires 'org', 'package_type', 'package_name', and 'package_version_id' parameters. + 3. delete_user_package - Delete an entire package from the authenticated user's account. This will delete all versions of the package. Requires 'package_type' and 'package_name' parameters. + 4. delete_user_package_version - Delete a specific version of a package from the authenticated user's account. Requires 'package_type', 'package_name', and 'package_version_id' parameters. + +All operations require delete:packages scope. (string, required) + - `org`: Organization name (required for delete_org_package and delete_org_package_version methods) (string, optional) + - `package_name`: Package name (required for all methods) (string, required) + - `package_type`: Package type (required for all methods) (string, required) + - `package_version_id`: Package version ID (required for delete_org_package_version and delete_user_package_version methods) (number, optional)
diff --git a/docs/remote-server.md b/docs/remote-server.md index 742543d64..bd4405f4f 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Default | ["Default" toolset](../README.md#default-toolset) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/delete_org_package.snap b/pkg/github/__toolsnaps__/delete_org_package.snap deleted file mode 100644 index 5c2021c0c..000000000 --- a/pkg/github/__toolsnaps__/delete_org_package.snap +++ /dev/null @@ -1,38 +0,0 @@ -{ - "annotations": { - "title": "Delete organization package", - "readOnlyHint": false - }, - "description": "Delete an entire package from a GitHub organization. This will delete all versions of the package. Requires delete:packages scope.", - "inputSchema": { - "properties": { - "org": { - "description": "Organization name", - "type": "string" - }, - "package_name": { - "description": "Package name", - "type": "string" - }, - "package_type": { - "description": "Package type", - "enum": [ - "npm", - "maven", - "rubygems", - "docker", - "nuget", - "container" - ], - "type": "string" - } - }, - "required": [ - "org", - "package_type", - "package_name" - ], - "type": "object" - }, - "name": "delete_org_package" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_org_package_version.snap b/pkg/github/__toolsnaps__/delete_org_package_version.snap deleted file mode 100644 index c1379bc3f..000000000 --- a/pkg/github/__toolsnaps__/delete_org_package_version.snap +++ /dev/null @@ -1,43 +0,0 @@ -{ - "annotations": { - "title": "Delete organization package version", - "readOnlyHint": false - }, - "description": "Delete a specific version of a package from a GitHub organization. Requires delete:packages scope.", - "inputSchema": { - "properties": { - "org": { - "description": "Organization name", - "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", - "type": "number" - } - }, - "required": [ - "org", - "package_type", - "package_name", - "package_version_id" - ], - "type": "object" - }, - "name": "delete_org_package_version" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_user_package.snap b/pkg/github/__toolsnaps__/delete_user_package.snap deleted file mode 100644 index 42d526f2a..000000000 --- a/pkg/github/__toolsnaps__/delete_user_package.snap +++ /dev/null @@ -1,33 +0,0 @@ -{ - "annotations": { - "title": "Delete user package", - "readOnlyHint": false - }, - "description": "Delete an entire package from the authenticated user's account. This will delete all versions of the package. Requires delete:packages scope.", - "inputSchema": { - "properties": { - "package_name": { - "description": "Package name", - "type": "string" - }, - "package_type": { - "description": "Package type", - "enum": [ - "npm", - "maven", - "rubygems", - "docker", - "nuget", - "container" - ], - "type": "string" - } - }, - "required": [ - "package_type", - "package_name" - ], - "type": "object" - }, - "name": "delete_user_package" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_user_package_version.snap b/pkg/github/__toolsnaps__/delete_user_package_version.snap deleted file mode 100644 index d12f2829e..000000000 --- a/pkg/github/__toolsnaps__/delete_user_package_version.snap +++ /dev/null @@ -1,38 +0,0 @@ -{ - "annotations": { - "title": "Delete user package version", - "readOnlyHint": false - }, - "description": "Delete a specific version of a package from the authenticated user's account. Requires delete:packages scope.", - "inputSchema": { - "properties": { - "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", - "type": "number" - } - }, - "required": [ - "package_type", - "package_name", - "package_version_id" - ], - "type": "object" - }, - "name": "delete_user_package_version" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_org_package.snap b/pkg/github/__toolsnaps__/get_org_package.snap deleted file mode 100644 index 70c8b5027..000000000 --- a/pkg/github/__toolsnaps__/get_org_package.snap +++ /dev/null @@ -1,38 +0,0 @@ -{ - "annotations": { - "title": "Get organization package details", - "readOnlyHint": true - }, - "description": "Get details of a specific package for an organization.", - "inputSchema": { - "properties": { - "org": { - "description": "Organization name", - "type": "string" - }, - "package_name": { - "description": "Package name", - "type": "string" - }, - "package_type": { - "description": "Package type", - "enum": [ - "npm", - "maven", - "rubygems", - "docker", - "nuget", - "container" - ], - "type": "string" - } - }, - "required": [ - "org", - "package_type", - "package_name" - ], - "type": "object" - }, - "name": "get_org_package" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_package_version.snap b/pkg/github/__toolsnaps__/get_package_version.snap deleted file mode 100644 index 67d841d15..000000000 --- a/pkg/github/__toolsnaps__/get_package_version.snap +++ /dev/null @@ -1,43 +0,0 @@ -{ - "annotations": { - "title": "Get package version details", - "readOnlyHint": true - }, - "description": "Get details of a specific package version, including metadata.", - "inputSchema": { - "properties": { - "org": { - "description": "Organization name", - "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", - "type": "number" - } - }, - "required": [ - "org", - "package_type", - "package_name", - "package_version_id" - ], - "type": "object" - }, - "name": "get_package_version" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_org_packages.snap b/pkg/github/__toolsnaps__/list_org_packages.snap deleted file mode 100644 index 669d69100..000000000 --- a/pkg/github/__toolsnaps__/list_org_packages.snap +++ /dev/null @@ -1,52 +0,0 @@ -{ - "annotations": { - "title": "List organization packages", - "readOnlyHint": true - }, - "description": "List packages for a GitHub organization. Returns package metadata including name, type, visibility, and version count.", - "inputSchema": { - "properties": { - "org": { - "description": "Organization name", - "type": "string" - }, - "package_type": { - "description": "Filter by package type", - "enum": [ - "npm", - "maven", - "rubygems", - "docker", - "nuget", - "container" - ], - "type": "string" - }, - "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" - }, - "visibility": { - "description": "Filter by package visibility", - "enum": [ - "public", - "private", - "internal" - ], - "type": "string" - } - }, - "required": [ - "org" - ], - "type": "object" - }, - "name": "list_org_packages" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_package_versions.snap b/pkg/github/__toolsnaps__/list_package_versions.snap deleted file mode 100644 index c5f51d2cb..000000000 --- a/pkg/github/__toolsnaps__/list_package_versions.snap +++ /dev/null @@ -1,57 +0,0 @@ -{ - "annotations": { - "title": "List package versions", - "readOnlyHint": true - }, - "description": "List versions of a package for an organization. Each version includes metadata.", - "inputSchema": { - "properties": { - "org": { - "description": "Organization name", - "type": "string" - }, - "package_name": { - "description": "Package name", - "type": "string" - }, - "package_type": { - "description": "Package type", - "enum": [ - "npm", - "maven", - "rubygems", - "docker", - "nuget", - "container" - ], - "type": "string" - }, - "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 by version state", - "enum": [ - "active", - "deleted" - ], - "type": "string" - } - }, - "required": [ - "org", - "package_type", - "package_name" - ], - "type": "object" - }, - "name": "list_package_versions" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_user_packages.snap b/pkg/github/__toolsnaps__/list_user_packages.snap deleted file mode 100644 index f85a06f27..000000000 --- a/pkg/github/__toolsnaps__/list_user_packages.snap +++ /dev/null @@ -1,52 +0,0 @@ -{ - "annotations": { - "title": "List user packages", - "readOnlyHint": true - }, - "description": "List packages for a GitHub user. Note: Download statistics are not available via the GitHub REST API.", - "inputSchema": { - "properties": { - "package_type": { - "description": "Filter by package type", - "enum": [ - "npm", - "maven", - "rubygems", - "docker", - "nuget", - "container" - ], - "type": "string" - }, - "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" - }, - "username": { - "description": "GitHub username", - "type": "string" - }, - "visibility": { - "description": "Filter by package visibility", - "enum": [ - "public", - "private", - "internal" - ], - "type": "string" - } - }, - "required": [ - "username" - ], - "type": "object" - }, - "name": "list_user_packages" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/packages_read.snap b/pkg/github/__toolsnaps__/packages_read.snap new file mode 100644 index 000000000..2f817c277 --- /dev/null +++ b/pkg/github/__toolsnaps__/packages_read.snap @@ -0,0 +1,83 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Read package information" + }, + "description": "Get information about GitHub packages for organizations and users. Supports listing packages, getting package details, and inspecting package versions.", + "inputSchema": { + "type": "object", + "required": [ + "method" + ], + "properties": { + "method": { + "type": "string", + "description": "Action to specify what package data needs to be retrieved from GitHub.\nPossible options:\n 1. list_org_packages - List packages for a GitHub organization. Requires 'org' parameter. Supports optional 'package_type' and 'visibility' filters.\n 2. get_org_package - Get details of a specific package for an organization. Requires 'org', 'package_type', and 'package_name' parameters.\n 3. list_package_versions - List versions of a package for an organization. Requires 'org', 'package_type', and 'package_name' parameters. Supports optional 'state' filter.\n 4. get_package_version - Get details of a specific package version. Requires 'org', 'package_type', 'package_name', and 'package_version_id' parameters.\n 5. list_user_packages - List packages for a GitHub user. Requires 'username' parameter. Supports optional 'package_type' and 'visibility' filters.\n\nNote: Download statistics are not available via the GitHub REST API.", + "enum": [ + "list_org_packages", + "get_org_package", + "list_package_versions", + "get_package_version", + "list_user_packages" + ] + }, + "org": { + "type": "string", + "description": "Organization name (required for org-related methods)" + }, + "package_name": { + "type": "string", + "description": "Package name (required for get_org_package, list_package_versions, and get_package_version methods)" + }, + "package_type": { + "type": "string", + "description": "Package type", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ] + }, + "package_version_id": { + "type": "number", + "description": "Package version ID (required for get_package_version method)" + }, + "page": { + "type": "number", + "description": "Page number for pagination (min 1)", + "minimum": 1 + }, + "perPage": { + "type": "number", + "description": "Results per page for pagination (min 1, max 100)", + "minimum": 1, + "maximum": 100 + }, + "state": { + "type": "string", + "description": "Filter by version state (optional for list_package_versions method)", + "enum": [ + "active", + "deleted" + ] + }, + "username": { + "type": "string", + "description": "GitHub username (required for list_user_packages method)" + }, + "visibility": { + "type": "string", + "description": "Filter by package visibility (optional for list methods)", + "enum": [ + "public", + "private", + "internal" + ] + } + } + }, + "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 000000000..07b011cf5 --- /dev/null +++ b/pkg/github/__toolsnaps__/packages_write.snap @@ -0,0 +1,51 @@ +{ + "annotations": { + "title": "Delete operations on packages" + }, + "description": "Delete packages and package versions for organizations and users. All operations require delete:packages scope.", + "inputSchema": { + "type": "object", + "required": [ + "method", + "package_type", + "package_name" + ], + "properties": { + "method": { + "type": "string", + "description": "The write operation to perform on packages.\n\nAvailable methods:\n 1. delete_org_package - Delete an entire package from an organization. This will delete all versions of the package. Requires 'org', 'package_type', and 'package_name' parameters.\n 2. delete_org_package_version - Delete a specific version of a package from an organization. Requires 'org', 'package_type', 'package_name', and 'package_version_id' parameters.\n 3. delete_user_package - Delete an entire package from the authenticated user's account. This will delete all versions of the package. Requires 'package_type' and 'package_name' parameters.\n 4. delete_user_package_version - Delete a specific version of a package from the authenticated user's account. Requires 'package_type', 'package_name', and 'package_version_id' parameters.\n\nAll operations require delete:packages scope.", + "enum": [ + "delete_org_package", + "delete_org_package_version", + "delete_user_package", + "delete_user_package_version" + ] + }, + "org": { + "type": "string", + "description": "Organization name (required for delete_org_package and delete_org_package_version methods)" + }, + "package_name": { + "type": "string", + "description": "Package name (required for all methods)" + }, + "package_type": { + "type": "string", + "description": "Package type (required for all methods)", + "enum": [ + "npm", + "maven", + "rubygems", + "docker", + "nuget", + "container" + ] + }, + "package_version_id": { + "type": "number", + "description": "Package version ID (required for delete_org_package_version and delete_user_package_version methods)" + } + } + }, + "name": "packages_write" +} \ No newline at end of file diff --git a/pkg/github/packages.go b/pkg/github/packages.go index 23e83649f..b27491fde 100644 --- a/pkg/github/packages.go +++ b/pkg/github/packages.go @@ -9,9 +9,10 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v74/github" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v79/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // authenticatedUser represents the empty string used to indicate operations @@ -25,15 +26,15 @@ const authenticatedUser = "" // handleDeletionResponse handles the common response logic for package deletion operations. // It checks the status code, reads error messages if any, and returns a standardized success response. -func handleDeletionResponse(resp *github.Response, successMessage string) (*mcp.CallToolResult, error) { +func handleDeletionResponse(resp *github.Response, successMessage string) (*mcp.CallToolResult, any, error) { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("deletion failed: %s", string(body))), nil + return utils.NewToolResultError(fmt.Sprintf("deletion failed: %s", string(body))), nil, nil } result := map[string]interface{}{ @@ -43,599 +44,506 @@ func handleDeletionResponse(resp *github.Response, successMessage string) (*mcp. r, err := json.Marshal(result) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return mcp.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), nil, nil } -// ListOrgPackages creates a tool to list packages for an organization -func ListOrgPackages(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_org_packages", - mcp.WithDescription(t("TOOL_LIST_ORG_PACKAGES_DESCRIPTION", "List packages for a GitHub organization. Returns package metadata including name, type, visibility, and version count.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_ORG_PACKAGES_USER_TITLE", "List organization packages"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("org", - mcp.Required(), - mcp.Description("Organization name"), - ), - mcp.WithString("package_type", - mcp.Description("Filter by package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("visibility", - mcp.Description("Filter by package visibility"), - mcp.Enum("public", "private", "internal"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - org, err := RequiredParam[string](request, "org") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageType, err := OptionalParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - visibility, err := OptionalParam[string](request, "visibility") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - opts := &github.PackageListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } +// PackagesRead creates a consolidated tool to read package information. +func PackagesRead(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Action to specify what package data needs to be retrieved from GitHub. +Possible options: + 1. list_org_packages - List packages for a GitHub organization. Requires 'org' parameter. Supports optional 'package_type' and 'visibility' filters. + 2. get_org_package - Get details of a specific package for an organization. Requires 'org', 'package_type', and 'package_name' parameters. + 3. list_package_versions - List versions of a package for an organization. Requires 'org', 'package_type', and 'package_name' parameters. Supports optional 'state' filter. + 4. get_package_version - Get details of a specific package version. Requires 'org', 'package_type', 'package_name', and 'package_version_id' parameters. + 5. list_user_packages - List packages for a GitHub user. Requires 'username' parameter. Supports optional 'package_type' and 'visibility' filters. + +Note: Download statistics are not available via the GitHub REST API.`, + Enum: []any{"list_org_packages", "get_org_package", "list_package_versions", "get_package_version", "list_user_packages"}, + }, + "org": { + Type: "string", + Description: "Organization name (required for org-related methods)", + }, + "username": { + Type: "string", + Description: "GitHub username (required for list_user_packages method)", + }, + "package_type": { + Type: "string", + Description: "Package type", + Enum: []any{"npm", "maven", "rubygems", "docker", "nuget", "container"}, + }, + "package_name": { + Type: "string", + Description: "Package name (required for get_org_package, list_package_versions, and get_package_version methods)", + }, + "package_version_id": { + Type: "number", + Description: "Package version ID (required for get_package_version method)", + }, + "visibility": { + Type: "string", + Description: "Filter by package visibility (optional for list methods)", + Enum: []any{"public", "private", "internal"}, + }, + "state": { + Type: "string", + Description: "Filter by version state (optional for list_package_versions method)", + Enum: []any{"active", "deleted"}, + }, + }, + Required: []string{"method"}, + } + WithPagination(schema) - // Only set optional parameters if they have values - if packageType != "" { - opts.PackageType = github.Ptr(packageType) - } - if visibility != "" { - opts.Visibility = github.Ptr(visibility) + return mcp.Tool{ + Name: "packages_read", + Description: t("TOOL_PACKAGES_READ_DESCRIPTION", "Get information about GitHub packages for 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, + }, + func(ctx context.Context, _ *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 := getClient(ctx) if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - packages, resp, err := client.Organizations.ListPackages(ctx, org, opts) + pagination, err := OptionalPaginationParams(args) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list packages for organization '%s'", org), - resp, - err, - ), nil + return utils.NewToolResultError(err.Error()), nil, nil } - defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(packages) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + switch method { + case "list_org_packages": + result, err := listOrgPackagesImpl(ctx, client, args, pagination) + return result, nil, err + case "get_org_package": + result, err := getOrgPackageImpl(ctx, client, args) + return result, nil, err + case "list_package_versions": + result, err := listPackageVersionsImpl(ctx, client, args, pagination) + return result, nil, err + case "get_package_version": + result, err := getPackageVersionImpl(ctx, client, args) + return result, nil, err + case "list_user_packages": + result, err := listUserPackagesImpl(ctx, client, args, pagination) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - - return mcp.NewToolResultText(string(r)), nil } } -// GetOrgPackage creates a tool to get a specific package for an organization with download statistics -func GetOrgPackage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_org_package", - mcp.WithDescription(t("TOOL_GET_ORG_PACKAGE_DESCRIPTION", "Get details of a specific package for an organization.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_ORG_PACKAGE_USER_TITLE", "Get organization package details"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("org", - mcp.Required(), - mcp.Description("Organization name"), - ), - mcp.WithString("package_type", - mcp.Required(), - mcp.Description("Package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("package_name", - mcp.Required(), - mcp.Description("Package name"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - org, err := RequiredParam[string](request, "org") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageType, err := RequiredParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageName, err := RequiredParam[string](request, "package_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func listOrgPackagesImpl(ctx context.Context, client *github.Client, args map[string]any, pagination PaginationParams) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageType, err := OptionalParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + visibility, err := OptionalParam[string](args, "visibility") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + opts := &github.PackageListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } - pkg, resp, err := client.Organizations.GetPackage(ctx, org, packageType, packageName) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get package '%s' of type '%s' for organization '%s'", packageName, packageType, org), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + // Only set optional parameters if they have values + if packageType != "" { + opts.PackageType = github.Ptr(packageType) + } + if visibility != "" { + opts.Visibility = github.Ptr(visibility) + } - r, err := json.Marshal(pkg) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + packages, resp, err := client.Organizations.ListPackages(ctx, org, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list packages for organization '%s'", org), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() - return mcp.NewToolResultText(string(r)), nil - } + r, err := json.Marshal(packages) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil } -// ListPackageVersions creates a tool to list versions of a package with download statistics -func ListPackageVersions(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_package_versions", - mcp.WithDescription(t("TOOL_LIST_PACKAGE_VERSIONS_DESCRIPTION", "List versions of a package for an organization. Each version includes metadata.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_PACKAGE_VERSIONS_USER_TITLE", "List package versions"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("org", - mcp.Required(), - mcp.Description("Organization name"), - ), - mcp.WithString("package_type", - mcp.Required(), - mcp.Description("Package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("package_name", - mcp.Required(), - mcp.Description("Package name"), - ), - mcp.WithString("state", - mcp.Description("Filter by version state"), - mcp.Enum("active", "deleted"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - org, err := RequiredParam[string](request, "org") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageType, err := RequiredParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageName, err := RequiredParam[string](request, "package_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - state, err := OptionalParam[string](request, "state") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func getOrgPackageImpl(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } - opts := &github.PackageListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } + pkg, resp, err := client.Organizations.GetPackage(ctx, org, packageType, packageName) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get package '%s' of type '%s' for organization '%s'", packageName, packageType, org), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() - // Only set state parameter if it has a value - if state != "" { - opts.State = github.Ptr(state) - } + r, err := json.Marshal(pkg) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + return utils.NewToolResultText(string(r)), nil +} - versions, resp, err := client.Organizations.PackageGetAllVersions(ctx, org, packageType, packageName, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list package versions for package '%s' of type '%s'", packageName, packageType), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() +func listPackageVersionsImpl(ctx context.Context, client *github.Client, args map[string]any, pagination PaginationParams) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } - r, err := json.Marshal(versions) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + opts := &github.PackageListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } - return mcp.NewToolResultText(string(r)), nil - } -} + // Only set state parameter if it has a value + if state != "" { + opts.State = github.Ptr(state) + } -// GetPackageVersion creates a tool to get a specific package version with download statistics -func GetPackageVersion(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("get_package_version", - mcp.WithDescription(t("TOOL_GET_PACKAGE_VERSION_DESCRIPTION", "Get details of a specific package version, including metadata.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_PACKAGE_VERSION_USER_TITLE", "Get package version details"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("org", - mcp.Required(), - mcp.Description("Organization name"), - ), - mcp.WithString("package_type", - mcp.Required(), - mcp.Description("Package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("package_name", - mcp.Required(), - mcp.Description("Package name"), - ), - mcp.WithNumber("package_version_id", - mcp.Required(), - mcp.Description("Package version ID"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - org, err := RequiredParam[string](request, "org") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageType, err := RequiredParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageName, err := RequiredParam[string](request, "package_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageVersionID, err := RequiredParam[float64](request, "package_version_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + versions, resp, err := client.Organizations.PackageGetAllVersions(ctx, org, packageType, packageName, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list package versions for package '%s' of type '%s'", packageName, packageType), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + r, err := json.Marshal(versions) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - version, resp, err := client.Organizations.PackageGetVersion(ctx, org, packageType, packageName, int64(packageVersionID)) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get package version %d for package '%s'", int64(packageVersionID), packageName), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + return utils.NewToolResultText(string(r)), nil +} - r, err := json.Marshal(version) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } +func getPackageVersionImpl(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageVersionID, err := RequiredParam[float64](args, "package_version_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } - return mcp.NewToolResultText(string(r)), nil - } -} + version, resp, err := client.Organizations.PackageGetVersion(ctx, org, packageType, packageName, int64(packageVersionID)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get package version %d for package '%s'", int64(packageVersionID), packageName), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() -// ListUserPackages creates a tool to list packages for a user -func ListUserPackages(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("list_user_packages", - mcp.WithDescription(t("TOOL_LIST_USER_PACKAGES_DESCRIPTION", "List packages for a GitHub user. Note: Download statistics are not available via the GitHub REST API.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_USER_PACKAGES_USER_TITLE", "List user packages"), - ReadOnlyHint: ToBoolPtr(true), - }), - mcp.WithString("username", - mcp.Required(), - mcp.Description("GitHub username"), - ), - mcp.WithString("package_type", - mcp.Description("Filter by package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("visibility", - mcp.Description("Filter by package visibility"), - mcp.Enum("public", "private", "internal"), - ), - WithPagination(), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - username, err := RequiredParam[string](request, "username") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageType, err := OptionalParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - visibility, err := OptionalParam[string](request, "visibility") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - pagination, err := OptionalPaginationParams(request) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } + r, err := json.Marshal(version) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } - opts := &github.PackageListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, - } + return utils.NewToolResultText(string(r)), nil +} - // Only set optional parameters if they have values - if packageType != "" { - opts.PackageType = github.Ptr(packageType) - } - if visibility != "" { - opts.Visibility = github.Ptr(visibility) - } +func listUserPackagesImpl(ctx context.Context, client *github.Client, args map[string]any, pagination PaginationParams) (*mcp.CallToolResult, error) { + username, err := RequiredParam[string](args, "username") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageType, err := OptionalParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + visibility, err := OptionalParam[string](args, "visibility") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + opts := &github.PackageListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } - packages, resp, err := client.Users.ListPackages(ctx, username, opts) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list packages for user '%s'", username), - resp, - err, - ), nil - } - defer func() { _ = resp.Body.Close() }() + // Only set optional parameters if they have values + if packageType != "" { + opts.PackageType = github.Ptr(packageType) + } + if visibility != "" { + opts.Visibility = github.Ptr(visibility) + } - r, err := json.Marshal(packages) - if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) - } + packages, resp, err := client.Users.ListPackages(ctx, username, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list packages for user '%s'", username), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() - return mcp.NewToolResultText(string(r)), nil - } + r, err := json.Marshal(packages) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil } -// DeleteOrgPackage creates a tool to delete an entire package from an organization -// Requires delete:packages scope in addition to read:packages -func DeleteOrgPackage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("delete_org_package", - mcp.WithDescription(t("TOOL_DELETE_ORG_PACKAGE_DESCRIPTION", "Delete an entire package from a GitHub organization. This will delete all versions of the package. Requires delete:packages scope.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DELETE_ORG_PACKAGE_USER_TITLE", "Delete organization package"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("org", - mcp.Required(), - mcp.Description("Organization name"), - ), - mcp.WithString("package_type", - mcp.Required(), - mcp.Description("Package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("package_name", - mcp.Required(), - mcp.Description("Package name"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - org, err := RequiredParam[string](request, "org") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageType, err := RequiredParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageName, err := RequiredParam[string](request, "package_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +// PackagesWrite creates a consolidated tool for package deletion operations. +// Requires delete:packages scope in addition to read:packages. +func PackagesWrite(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `The write operation to perform on packages. + +Available methods: + 1. delete_org_package - Delete an entire package from an organization. This will delete all versions of the package. Requires 'org', 'package_type', and 'package_name' parameters. + 2. delete_org_package_version - Delete a specific version of a package from an organization. Requires 'org', 'package_type', 'package_name', and 'package_version_id' parameters. + 3. delete_user_package - Delete an entire package from the authenticated user's account. This will delete all versions of the package. Requires 'package_type' and 'package_name' parameters. + 4. delete_user_package_version - Delete a specific version of a package from the authenticated user's account. Requires 'package_type', 'package_name', and 'package_version_id' parameters. + +All operations require delete:packages scope.`, + Enum: []any{"delete_org_package", "delete_org_package_version", "delete_user_package", "delete_user_package_version"}, + }, + "org": { + Type: "string", + Description: "Organization name (required for delete_org_package and delete_org_package_version methods)", + }, + "package_type": { + Type: "string", + Description: "Package type (required for all methods)", + Enum: []any{"npm", "maven", "rubygems", "docker", "nuget", "container"}, + }, + "package_name": { + Type: "string", + Description: "Package name (required for all methods)", + }, + "package_version_id": { + Type: "number", + Description: "Package version ID (required for delete_org_package_version and delete_user_package_version methods)", + }, + }, + Required: []string{"method", "package_type", "package_name"}, + } - client, err := getClient(ctx) + return mcp.Tool{ + Name: "packages_write", + Description: t("TOOL_PACKAGES_WRITE_DESCRIPTION", "Delete packages and package versions for organizations and users. All operations require delete:packages scope."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PACKAGES_WRITE_USER_TITLE", "Delete operations on packages"), + ReadOnlyHint: false, + }, + InputSchema: schema, + }, + func(ctx context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) + return utils.NewToolResultError(err.Error()), nil, nil } - resp, err := client.Organizations.DeletePackage(ctx, org, packageType, packageName) + client, err := getClient(ctx) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to delete package '%s' of type '%s' for organization '%s'", packageName, packageType, org), - resp, - err, - ), nil + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch method { + case "delete_org_package": + result, err := deleteOrgPackageImpl(ctx, client, args) + return result, nil, err + case "delete_org_package_version": + result, err := deleteOrgPackageVersionImpl(ctx, client, args) + return result, nil, err + case "delete_user_package": + result, err := deleteUserPackageImpl(ctx, client, args) + return result, nil, err + case "delete_user_package_version": + result, err := deleteUserPackageVersionImpl(ctx, client, args) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - - return handleDeletionResponse(resp, fmt.Sprintf("Package '%s' deleted successfully from organization '%s'", packageName, org)) } } -// DeleteOrgPackageVersion creates a tool to delete a specific version of a package from an organization -// Requires delete:packages scope in addition to read:packages -func DeleteOrgPackageVersion(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("delete_org_package_version", - mcp.WithDescription(t("TOOL_DELETE_ORG_PACKAGE_VERSION_DESCRIPTION", "Delete a specific version of a package from a GitHub organization. Requires delete:packages scope.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DELETE_ORG_PACKAGE_VERSION_USER_TITLE", "Delete organization package version"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("org", - mcp.Required(), - mcp.Description("Organization name"), - ), - mcp.WithString("package_type", - mcp.Required(), - mcp.Description("Package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("package_name", - mcp.Required(), - mcp.Description("Package name"), - ), - mcp.WithNumber("package_version_id", - mcp.Required(), - mcp.Description("Package version ID"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - org, err := RequiredParam[string](request, "org") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageType, err := RequiredParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageName, err := RequiredParam[string](request, "package_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageVersionID, err := RequiredParam[float64](request, "package_version_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } +func deleteOrgPackageImpl(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } - resp, err := client.Organizations.PackageDeleteVersion(ctx, org, packageType, packageName, int64(packageVersionID)) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to delete package version %d of package '%s'", int64(packageVersionID), packageName), - resp, - err, - ), nil - } + resp, err := client.Organizations.DeletePackage(ctx, org, packageType, packageName) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to delete package '%s' of type '%s' for organization '%s'", packageName, packageType, org), + resp, + err, + ), nil + } - return handleDeletionResponse(resp, fmt.Sprintf("Package version %d deleted successfully from package '%s'", int64(packageVersionID), packageName)) - } + result, _, err := handleDeletionResponse(resp, fmt.Sprintf("Package '%s' deleted successfully from organization '%s'", packageName, org)) + return result, err } -// DeleteUserPackage creates a tool to delete an entire package from the authenticated user's account -// Requires delete:packages scope in addition to read:packages -func DeleteUserPackage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("delete_user_package", - mcp.WithDescription(t("TOOL_DELETE_USER_PACKAGE_DESCRIPTION", "Delete an entire package from the authenticated user's account. This will delete all versions of the package. Requires delete:packages scope.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DELETE_USER_PACKAGE_USER_TITLE", "Delete user package"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("package_type", - mcp.Required(), - mcp.Description("Package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("package_name", - mcp.Required(), - mcp.Description("Package name"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - packageType, err := RequiredParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageName, err := RequiredParam[string](request, "package_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } +func deleteOrgPackageVersionImpl(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageVersionID, err := RequiredParam[float64](args, "package_version_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } - resp, err := client.Users.DeletePackage(ctx, authenticatedUser, packageType, packageName) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to delete package '%s' of type '%s'", packageName, packageType), - resp, - err, - ), nil - } + resp, err := client.Organizations.PackageDeleteVersion(ctx, org, packageType, packageName, int64(packageVersionID)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to delete package version %d of package '%s'", int64(packageVersionID), packageName), + resp, + err, + ), nil + } - return handleDeletionResponse(resp, fmt.Sprintf("Package '%s' deleted successfully", packageName)) - } + result, _, err := handleDeletionResponse(resp, fmt.Sprintf("Package version %d deleted successfully from package '%s'", int64(packageVersionID), packageName)) + return result, err } -// DeleteUserPackageVersion creates a tool to delete a specific version of a package from the authenticated user's account -// Requires delete:packages scope in addition to read:packages -func DeleteUserPackageVersion(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - return mcp.NewTool("delete_user_package_version", - mcp.WithDescription(t("TOOL_DELETE_USER_PACKAGE_VERSION_DESCRIPTION", "Delete a specific version of a package from the authenticated user's account. Requires delete:packages scope.")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_DELETE_USER_PACKAGE_VERSION_USER_TITLE", "Delete user package version"), - ReadOnlyHint: ToBoolPtr(false), - }), - mcp.WithString("package_type", - mcp.Required(), - mcp.Description("Package type"), - mcp.Enum("npm", "maven", "rubygems", "docker", "nuget", "container"), - ), - mcp.WithString("package_name", - mcp.Required(), - mcp.Description("Package name"), - ), - mcp.WithNumber("package_version_id", - mcp.Required(), - mcp.Description("Package version ID"), - ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - packageType, err := RequiredParam[string](request, "package_type") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageName, err := RequiredParam[string](request, "package_name") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - packageVersionID, err := RequiredParam[float64](request, "package_version_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } +func deleteUserPackageImpl(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, error) { + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } + resp, err := client.Users.DeletePackage(ctx, authenticatedUser, packageType, packageName) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to delete package '%s' of type '%s'", packageName, packageType), + resp, + err, + ), nil + } - resp, err := client.Users.PackageDeleteVersion(ctx, authenticatedUser, packageType, packageName, int64(packageVersionID)) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to delete version %d of package '%s'", int64(packageVersionID), packageName), - resp, - err, - ), nil - } + result, _, err := handleDeletionResponse(resp, fmt.Sprintf("Package '%s' deleted successfully", packageName)) + return result, err +} - return handleDeletionResponse(resp, fmt.Sprintf("Package version %d deleted successfully from package '%s'", int64(packageVersionID), packageName)) - } +func deleteUserPackageVersionImpl(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, error) { + packageType, err := RequiredParam[string](args, "package_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageName, err := RequiredParam[string](args, "package_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + packageVersionID, err := RequiredParam[float64](args, "package_version_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + + resp, err := client.Users.PackageDeleteVersion(ctx, authenticatedUser, packageType, packageName, int64(packageVersionID)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to delete version %d of package '%s'", int64(packageVersionID), packageName), + resp, + err, + ), nil + } + + result, _, err := handleDeletionResponse(resp, fmt.Sprintf("Package version %d deleted successfully from package '%s'", int64(packageVersionID), packageName)) + return result, err } diff --git a/pkg/github/packages_test.go b/pkg/github/packages_test.go index ab9e3755d..1c9b80b41 100644 --- a/pkg/github/packages_test.go +++ b/pkg/github/packages_test.go @@ -8,23 +8,21 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v74/github" - "github.com/mark3labs/mcp-go/mcp" + "github.com/google/go-github/v79/github" + "github.com/google/jsonschema-go/jsonschema" "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // verifyDeletionSuccess is a helper function to verify deletion operation success. -// It checks that the result is not an error, parses the JSON response, and verifies -// the success status and message content. func verifyDeletionSuccess(t *testing.T, result *mcp.CallToolResult, err error) { t.Helper() require.NoError(t, err) require.False(t, result.IsError) - // Parse the success result textContent := getTextResult(t, result) var response map[string]interface{} err = json.Unmarshal([]byte(textContent.Text), &response) @@ -34,34 +32,27 @@ func verifyDeletionSuccess(t *testing.T, result *mcp.CallToolResult, err error) assert.Contains(t, response["message"].(string), "deleted successfully") } -func Test_ListOrgPackages(t *testing.T) { +func Test_PackagesRead(t *testing.T) { mockClient := github.NewClient(nil) - tool, _ := ListOrgPackages(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := PackagesRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_org_packages", tool.Name) + assert.Equal(t, "packages_read", tool.Name) assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "org") - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "visibility") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"}) + assert.True(t, tool.Annotations.ReadOnlyHint, "PackagesRead tool should be read-only") + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "method") + assert.ElementsMatch(t, schema.Required, []string{"method"}) +} - // Setup mock packages for success case +func Test_PackagesRead_ListOrgPackages(t *testing.T) { mockPackages := []*github.Package{ { ID: github.Ptr(int64(1)), Name: github.Ptr("github-mcp-server"), PackageType: github.Ptr("container"), - HTMLURL: github.Ptr("https://github.com/orgs/github/packages/container/package/github-mcp-server"), Visibility: github.Ptr("public"), }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-package"), - PackageType: github.Ptr("npm"), - HTMLURL: github.Ptr("https://github.com/orgs/github/packages/npm/package/test-package"), - Visibility: github.Ptr("private"), - }, } tests := []struct { @@ -73,7 +64,7 @@ func Test_ListOrgPackages(t *testing.T) { expectedErrMsg string }{ { - name: "successful packages listing", + name: "successful list", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.EndpointPattern{Pattern: "/orgs/{org}/packages", Method: "GET"}, @@ -81,13 +72,14 @@ func Test_ListOrgPackages(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "org": "github", + "method": "list_org_packages", + "org": "github", }, expectError: false, expectedPackages: mockPackages, }, { - name: "successful packages listing with filters", + name: "successful list with package_type filter", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatch( mock.EndpointPattern{Pattern: "/orgs/{org}/packages", Method: "GET"}, @@ -95,166 +87,63 @@ func Test_ListOrgPackages(t *testing.T) { ), ), requestArgs: map[string]interface{}{ + "method": "list_org_packages", "org": "github", "package_type": "container", - "visibility": "public", }, expectError: false, expectedPackages: mockPackages, }, { - name: "organization not found", + name: "successful list with visibility filter", mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( + mock.WithRequestMatch( mock.EndpointPattern{Pattern: "/orgs/{org}/packages", Method: "GET"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), + mockPackages, ), ), requestArgs: map[string]interface{}{ - "org": "nonexistent", + "method": "list_org_packages", + "org": "github", + "visibility": "public", }, - expectError: true, - expectedErrMsg: "failed to list packages", + expectError: false, + expectedPackages: mockPackages, }, { - name: "missing required parameter org", + name: "missing org parameter", mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{}, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListOrgPackages(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - if tc.expectedErrMsg != "" { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result - textContent := getTextResult(t, result) - var returnedPackages []*github.Package - err = json.Unmarshal([]byte(textContent.Text), &returnedPackages) - require.NoError(t, err) - - assert.Equal(t, len(tc.expectedPackages), len(returnedPackages)) - for i, pkg := range returnedPackages { - assert.Equal(t, tc.expectedPackages[i].GetID(), pkg.GetID()) - assert.Equal(t, tc.expectedPackages[i].GetName(), pkg.GetName()) - } - }) - } -} - -func Test_GetOrgPackage(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := GetOrgPackage(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_org_package", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "org") - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "package_name") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name"}) - - // Setup mock package for success case - mockPackage := &github.Package{ - ID: github.Ptr(int64(1)), - Name: github.Ptr("github-mcp-server"), - PackageType: github.Ptr("container"), - HTMLURL: github.Ptr("https://github.com/orgs/github/packages/container/package/github-mcp-server"), - Visibility: github.Ptr("public"), - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedPackage *github.Package - expectedErrMsg string - }{ - { - name: "successful package retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "GET"}, - mockPackage, - ), - ), requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "github-mcp-server", + "method": "list_org_packages", }, - expectError: false, - expectedPackage: mockPackage, + expectError: true, }, { - name: "package not found", + name: "organization not found", mockedClient: mock.NewMockedHTTPClient( mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "GET"}, + mock.EndpointPattern{Pattern: "/orgs/{org}/packages", Method: "GET"}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Package not found"}`)) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) }), ), ), requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "nonexistent", + "method": "list_org_packages", + "org": "nonexistent-org", }, expectError: true, - expectedErrMsg: "failed to get package", - }, - { - name: "missing required parameters", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "org": "github", - }, - expectError: true, + expectedErrMsg: "failed to list packages", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := GetOrgPackage(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) + _, handler := PackagesRead(stubGetClientFn(client), translations.NullTranslationHelper) + result, _, err := handler(context.Background(), nil, tc.requestArgs) - // Verify results if tc.expectError { require.NoError(t, err) require.True(t, result.IsError) @@ -268,738 +157,227 @@ func Test_GetOrgPackage(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError) - // Parse the result + // Parse and verify the result textContent := getTextResult(t, result) - var returnedPackage github.Package - err = json.Unmarshal([]byte(textContent.Text), &returnedPackage) + var returnedPackages []*github.Package + err = json.Unmarshal([]byte(textContent.Text), &returnedPackages) require.NoError(t, err) - assert.Equal(t, tc.expectedPackage.GetID(), returnedPackage.GetID()) - assert.Equal(t, tc.expectedPackage.GetName(), returnedPackage.GetName()) - assert.Equal(t, tc.expectedPackage.GetPackageType(), returnedPackage.GetPackageType()) + assert.Len(t, returnedPackages, len(tc.expectedPackages)) + for i, pkg := range returnedPackages { + assert.Equal(t, *tc.expectedPackages[i].ID, *pkg.ID) + assert.Equal(t, *tc.expectedPackages[i].Name, *pkg.Name) + assert.Equal(t, *tc.expectedPackages[i].PackageType, *pkg.PackageType) + assert.Equal(t, *tc.expectedPackages[i].Visibility, *pkg.Visibility) + } }) } } -func Test_ListPackageVersions(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := ListPackageVersions(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_package_versions", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "org") - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "package_name") - assert.Contains(t, tool.InputSchema.Properties, "state") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name"}) - - // Setup mock package versions for success case - mockVersions := []*github.PackageVersion{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("v1.0.0"), - }, - { - ID: github.Ptr(int64(124)), - Name: github.Ptr("v1.0.1"), - }, - } - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedVersions []*github.PackageVersion - expectedErrMsg string - }{ - { - name: "successful versions listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions", Method: "GET"}, - mockVersions, - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "github-mcp-server", - }, - expectError: false, - expectedVersions: mockVersions, - }, - { - name: "successful versions listing with state filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions", Method: "GET"}, - mockVersions, - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "github-mcp-server", - "state": "active", - }, - expectError: false, - expectedVersions: mockVersions, - }, - { - name: "package not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions", Method: "GET"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "nonexistent", - }, - expectError: true, - expectedErrMsg: "failed to list package versions", - }, +func Test_PackagesRead_GetOrgPackage(t *testing.T) { + mockPackage := &github.Package{ + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-package"), } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListPackageVersions(stubGetClientFn(client), translations.NullTranslationHelper) + client := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "GET"}, + mockPackage, + ), + ) - // Create call request - request := createMCPRequest(tc.requestArgs) + ghClient := github.NewClient(client) + _, handler := PackagesRead(stubGetClientFn(ghClient), translations.NullTranslationHelper) - // Call handler - result, err := handler(context.Background(), request) + result, _, err := handler(context.Background(), nil, map[string]interface{}{ + "method": "get_org_package", + "org": "github", + "package_type": "container", + "package_name": "test-package", + }) - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - if tc.expectedErrMsg != "" { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } + require.NoError(t, err) + require.False(t, result.IsError) +} - require.NoError(t, err) - require.False(t, result.IsError) +func Test_PackagesRead_ListPackageVersions(t *testing.T) { + mockVersions := []*github.PackageVersion{ + {ID: github.Ptr(int64(123)), Name: github.Ptr("v1.0.0")}, + } - // Parse the result - textContent := getTextResult(t, result) - var returnedVersions []*github.PackageVersion - err = json.Unmarshal([]byte(textContent.Text), &returnedVersions) - require.NoError(t, err) + client := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions", Method: "GET"}, + mockVersions, + ), + ) - assert.Equal(t, len(tc.expectedVersions), len(returnedVersions)) - }) - } -} + ghClient := github.NewClient(client) + _, handler := PackagesRead(stubGetClientFn(ghClient), translations.NullTranslationHelper) -func Test_GetPackageVersion(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := GetPackageVersion(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + result, _, err := handler(context.Background(), nil, map[string]interface{}{ + "method": "list_package_versions", + "org": "github", + "package_type": "container", + "package_name": "test-package", + }) - assert.Equal(t, "get_package_version", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "org") - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "package_name") - assert.Contains(t, tool.InputSchema.Properties, "package_version_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name", "package_version_id"}) + require.NoError(t, err) + require.False(t, result.IsError) +} - // Setup mock package version for success case +func Test_PackagesRead_GetPackageVersion(t *testing.T) { mockVersion := &github.PackageVersion{ ID: github.Ptr(int64(123)), Name: github.Ptr("v1.0.0"), } - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedVersion *github.PackageVersion - expectedErrMsg string - }{ - { - name: "successful version retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "GET"}, - mockVersion, - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "github-mcp-server", - "package_version_id": float64(123), - }, - expectError: false, - expectedVersion: mockVersion, - }, - { - name: "version not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "GET"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "github-mcp-server", - "package_version_id": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to get package version", - }, - } + client := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "GET"}, + mockVersion, + ), + ) - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := GetPackageVersion(stubGetClientFn(client), translations.NullTranslationHelper) + ghClient := github.NewClient(client) + _, handler := PackagesRead(stubGetClientFn(ghClient), translations.NullTranslationHelper) - // Create call request - request := createMCPRequest(tc.requestArgs) + result, _, err := handler(context.Background(), nil, map[string]interface{}{ + "method": "get_package_version", + "org": "github", + "package_type": "container", + "package_name": "test-package", + "package_version_id": float64(123), + }) - // Call handler - result, err := handler(context.Background(), request) + require.NoError(t, err) + require.False(t, result.IsError) +} - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - if tc.expectedErrMsg != "" { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } +func Test_PackagesRead_ListUserPackages(t *testing.T) { + mockPackages := []*github.Package{ + {ID: github.Ptr(int64(1)), Name: github.Ptr("user-package")}, + } - require.NoError(t, err) - require.False(t, result.IsError) + client := mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.EndpointPattern{Pattern: "/users/{username}/packages", Method: "GET"}, + mockPackages, + ), + ) - // Parse the result - textContent := getTextResult(t, result) - var returnedVersion github.PackageVersion - err = json.Unmarshal([]byte(textContent.Text), &returnedVersion) - require.NoError(t, err) + ghClient := github.NewClient(client) + _, handler := PackagesRead(stubGetClientFn(ghClient), translations.NullTranslationHelper) - assert.Equal(t, tc.expectedVersion.GetID(), returnedVersion.GetID()) - assert.Equal(t, tc.expectedVersion.GetName(), returnedVersion.GetName()) - }) - } + result, _, err := handler(context.Background(), nil, map[string]interface{}{ + "method": "list_user_packages", + "username": "testuser", + }) + + require.NoError(t, err) + require.False(t, result.IsError) } -func Test_ListUserPackages(t *testing.T) { +func Test_PackagesWrite(t *testing.T) { mockClient := github.NewClient(nil) - tool, _ := ListUserPackages(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := PackagesWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_user_packages", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "username") - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "visibility") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"username"}) + assert.Equal(t, "packages_write", tool.Name) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.ElementsMatch(t, schema.Required, []string{"method", "package_type", "package_name"}) + assert.False(t, tool.Annotations.ReadOnlyHint, "PackagesWrite tool should not be read-only") +} - // Setup mock packages for success case - mockPackages := []*github.Package{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("my-package"), - PackageType: github.Ptr("npm"), - Visibility: github.Ptr("public"), - }, - } +func Test_PackagesWrite_DeleteOrgPackage(t *testing.T) { + client := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + ghClient := github.NewClient(client) + _, handler := PackagesWrite(stubGetClientFn(ghClient), translations.NullTranslationHelper) + + result, _, err := handler(context.Background(), nil, map[string]interface{}{ + "method": "delete_org_package", + "org": "github", + "package_type": "container", + "package_name": "test-package", + }) + + verifyDeletionSuccess(t, result, err) +} - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedPackages []*github.Package - expectedErrMsg string - }{ - { - name: "successful user packages listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.EndpointPattern{Pattern: "/users/{username}/packages", Method: "GET"}, - mockPackages, - ), - ), - requestArgs: map[string]interface{}{ - "username": "octocat", - }, - expectError: false, - expectedPackages: mockPackages, - }, - { - name: "user not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/packages", Method: "GET"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "username": "nonexistent", - }, - expectError: true, - expectedErrMsg: "failed to list packages", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := ListUserPackages(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - if tc.expectedErrMsg != "" { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - require.NoError(t, err) - require.False(t, result.IsError) - - // Parse the result - textContent := getTextResult(t, result) - var returnedPackages []*github.Package - err = json.Unmarshal([]byte(textContent.Text), &returnedPackages) - require.NoError(t, err) - - assert.Equal(t, len(tc.expectedPackages), len(returnedPackages)) - }) - } +func Test_PackagesWrite_DeleteOrgPackageVersion(t *testing.T) { + client := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + ghClient := github.NewClient(client) + _, handler := PackagesWrite(stubGetClientFn(ghClient), translations.NullTranslationHelper) + + result, _, err := handler(context.Background(), nil, map[string]interface{}{ + "method": "delete_org_package_version", + "org": "github", + "package_type": "container", + "package_name": "test-package", + "package_version_id": float64(123), + }) + + verifyDeletionSuccess(t, result, err) } -func Test_DeleteOrgPackage(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := DeleteOrgPackage(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_org_package", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "org") - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "package_name") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "successful package deletion", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "test-package", - }, - expectError: false, - }, - { - name: "package not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Package not found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "nonexistent", - }, - expectError: true, - expectedErrMsg: "failed to delete package", - }, - { - name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"message": "Forbidden - requires delete:packages scope"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "test-package", - }, - expectError: true, - expectedErrMsg: "failed to delete package", - }, - { - name: "missing required parameters", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "org": "github", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := DeleteOrgPackage(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - if tc.expectedErrMsg != "" { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - verifyDeletionSuccess(t, result, err) - }) - } +func Test_PackagesWrite_DeleteUserPackage(t *testing.T) { + client := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + ghClient := github.NewClient(client) + _, handler := PackagesWrite(stubGetClientFn(ghClient), translations.NullTranslationHelper) + + result, _, err := handler(context.Background(), nil, map[string]interface{}{ + "method": "delete_user_package", + "package_type": "container", + "package_name": "test-package", + }) + + verifyDeletionSuccess(t, result, err) } -func Test_DeleteOrgPackageVersion(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := DeleteOrgPackageVersion(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_org_package_version", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "org") - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "package_name") - assert.Contains(t, tool.InputSchema.Properties, "package_version_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org", "package_type", "package_name", "package_version_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "successful version deletion", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "test-package", - "package_version_id": float64(123), - }, - expectError: false, - }, - { - name: "version not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Version not found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "test-package", - "package_version_id": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to delete package version", - }, - { - name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"message": "Forbidden - requires delete:packages scope"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "org": "github", - "package_type": "container", - "package_name": "test-package", - "package_version_id": float64(123), - }, - expectError: true, - expectedErrMsg: "failed to delete package version", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := DeleteOrgPackageVersion(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - if tc.expectedErrMsg != "" { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - verifyDeletionSuccess(t, result, err) - }) - } -} - -func Test_DeleteUserPackage(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := DeleteUserPackage(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_user_package", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "package_name") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"package_type", "package_name"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "successful user package deletion", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "package_type": "npm", - "package_name": "my-package", - }, - expectError: false, - }, - { - name: "package not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Package not found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "package_type": "npm", - "package_name": "nonexistent", - }, - expectError: true, - expectedErrMsg: "failed to delete package", - }, - { - name: "missing required parameters", - mockedClient: mock.NewMockedHTTPClient(), - requestArgs: map[string]interface{}{ - "package_type": "npm", - }, - expectError: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := DeleteUserPackage(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - if tc.expectedErrMsg != "" { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - verifyDeletionSuccess(t, result, err) - }) - } -} - -func Test_DeleteUserPackageVersion(t *testing.T) { - mockClient := github.NewClient(nil) - tool, _ := DeleteUserPackageVersion(stubGetClientFn(mockClient), translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "delete_user_package_version", tool.Name) - assert.NotEmpty(t, tool.Description) - assert.Contains(t, tool.InputSchema.Properties, "package_type") - assert.Contains(t, tool.InputSchema.Properties, "package_name") - assert.Contains(t, tool.InputSchema.Properties, "package_version_id") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"package_type", "package_name", "package_version_id"}) - - tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]interface{} - expectError bool - expectedErrMsg string - }{ - { - name: "successful user version deletion", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), - requestArgs: map[string]interface{}{ - "package_type": "npm", - "package_name": "my-package", - "package_version_id": float64(123), - }, - expectError: false, - }, - { - name: "version not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Version not found"}`)) - }), - ), - ), - requestArgs: map[string]interface{}{ - "package_type": "npm", - "package_name": "my-package", - "package_version_id": float64(999), - }, - expectError: true, - expectedErrMsg: "failed to delete version", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Setup client with mock - client := github.NewClient(tc.mockedClient) - _, handler := DeleteUserPackageVersion(stubGetClientFn(client), translations.NullTranslationHelper) - - // Create call request - request := createMCPRequest(tc.requestArgs) - - // Call handler - result, err := handler(context.Background(), request) - - // Verify results - if tc.expectError { - require.NoError(t, err) - require.True(t, result.IsError) - if tc.expectedErrMsg != "" { - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - } - return - } - - verifyDeletionSuccess(t, result, err) - }) - } +func Test_PackagesWrite_DeleteUserPackageVersion(t *testing.T) { + client := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/user/packages/{package_type}/{package_name}/versions/{package_version_id}", Method: "DELETE"}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + ghClient := github.NewClient(client) + _, handler := PackagesWrite(stubGetClientFn(ghClient), translations.NullTranslationHelper) + + result, _, err := handler(context.Background(), nil, map[string]interface{}{ + "method": "delete_user_package_version", + "package_type": "container", + "package_name": "test-package", + "package_version_id": float64(123), + }) + + verifyDeletionSuccess(t, result, err) } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 6ed16b802..77ef8dd22 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -363,17 +363,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ) packages := toolsets.NewToolset(ToolsetMetadataPackages.ID, ToolsetMetadataPackages.Description). AddReadTools( - toolsets.NewServerTool(ListOrgPackages(getClient, t)), - toolsets.NewServerTool(GetOrgPackage(getClient, t)), - toolsets.NewServerTool(ListPackageVersions(getClient, t)), - toolsets.NewServerTool(GetPackageVersion(getClient, t)), - toolsets.NewServerTool(ListUserPackages(getClient, t)), + toolsets.NewServerTool(PackagesRead(getClient, t)), ). AddWriteTools( - toolsets.NewServerTool(DeleteOrgPackage(getClient, t)), - toolsets.NewServerTool(DeleteOrgPackageVersion(getClient, t)), - toolsets.NewServerTool(DeleteUserPackage(getClient, t)), - toolsets.NewServerTool(DeleteUserPackageVersion(getClient, t)), + toolsets.NewServerTool(PackagesWrite(getClient, t)), ) // Add toolsets to the group tsg.AddToolset(contextTools)