diff --git a/README.md b/README.md index 624279927..8d146676c 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,7 @@ The following sets of tools are available (all are on by default): | `experiments` | Experimental features that are not considered stable yet | | `gists` | GitHub Gist related tools | | `issues` | GitHub Issues related tools | +| `labels` | GitHub Labels related tools | | `notifications` | GitHub Notifications related tools | | `orgs` | GitHub Organization related tools | | `projects` | GitHub Projects related tools | @@ -599,6 +600,11 @@ The following sets of tools are available (all are on by default): - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) +- **get_label** - Get a specific label from a repository. + - `name`: Label name. (string, required) + - `owner`: Repository owner (username or organization name) (string, required) + - `repo`: Repository name (string, required) + - **list_issue_types** - List available issue types - `owner`: The organization owner of the repository (string, required) @@ -613,6 +619,11 @@ The following sets of tools are available (all are on by default): - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) +- **list_label** - List labels from a repository or an issue + - `issue_number`: Issue number - if provided, lists labels on the specific issue (number, optional) + - `owner`: Repository owner (username or organization name) - required for all operations (string, required) + - `repo`: Repository name - required for all operations (string, required) + - **list_sub_issues** - List sub-issues - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) @@ -657,6 +668,40 @@ The following sets of tools are available (all are on by default): - `title`: New title (string, optional) - `type`: New issue type (string, optional) +- **write_label** - Write operations on repository labels. + - `color`: Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'. (string, optional) + - `description`: Label description text. Optional for 'create' and 'update'. (string, optional) + - `method`: Operation to perform: 'create', 'update', or 'delete' (string, required) + - `name`: Label name - required for all operations (string, required) + - `new_name`: New name for the label (used only with 'update' method to rename) (string, optional) + - `owner`: Repository owner (username or organization name) (string, required) + - `repo`: Repository name (string, required) + + + +
+ +Labels + +- **get_label** - Get a specific label from a repository. + - `name`: Label name. (string, required) + - `owner`: Repository owner (username or organization name) (string, required) + - `repo`: Repository name (string, required) + +- **list_label** - List labels from a repository or an issue + - `issue_number`: Issue number - if provided, lists labels on the specific issue (number, optional) + - `owner`: Repository owner (username or organization name) - required for all operations (string, required) + - `repo`: Repository name - required for all operations (string, required) + +- **write_label** - Write operations on repository labels. + - `color`: Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'. (string, optional) + - `description`: Label description text. Optional for 'create' and 'update'. (string, optional) + - `method`: Operation to perform: 'create', 'update', or 'delete' (string, required) + - `name`: Label name - required for all operations (string, required) + - `new_name`: New name for the label (used only with 'update' method to rename) (string, optional) + - `owner`: Repository owner (username or organization name) (string, required) + - `repo`: Repository name (string, required) +
diff --git a/docs/remote-server.md b/docs/remote-server.md index da82e846d..61815a482 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -27,6 +27,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | | Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | | Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | +| 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) | | 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) | diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap new file mode 100644 index 000000000..a6b72c4eb --- /dev/null +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "title": "Get a specific label from a repository.", + "readOnlyHint": true + }, + "description": "Get a specific label from a repository.", + "inputSchema": { + "properties": { + "name": { + "description": "Label name.", + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization name)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "name" + ], + "type": "object" + }, + "name": "get_label" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap new file mode 100644 index 000000000..216b773ed --- /dev/null +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -0,0 +1,29 @@ +{ + "annotations": { + "title": "List labels from a repository.", + "readOnlyHint": true + }, + "description": "List labels from a repository or an issue", + "inputSchema": { + "properties": { + "issue_number": { + "description": "Issue number - if provided, lists labels on the specific issue", + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization name) - required for all operations", + "type": "string" + }, + "repo": { + "description": "Repository name - required for all operations", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_label" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/write_label.snap b/pkg/github/__toolsnaps__/write_label.snap new file mode 100644 index 000000000..cb13f8bbb --- /dev/null +++ b/pkg/github/__toolsnaps__/write_label.snap @@ -0,0 +1,52 @@ +{ + "annotations": { + "title": "Write operations on repository labels.", + "readOnlyHint": false + }, + "description": "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.", + "inputSchema": { + "properties": { + "color": { + "description": "Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'.", + "type": "string" + }, + "description": { + "description": "Label description text. Optional for 'create' and 'update'.", + "type": "string" + }, + "method": { + "description": "Operation to perform: 'create', 'update', or 'delete'", + "enum": [ + "create", + "update", + "delete" + ], + "type": "string" + }, + "name": { + "description": "Label name - required for all operations", + "type": "string" + }, + "new_name": { + "description": "New name for the label (used only with 'update' method to rename)", + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization name)", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo", + "name" + ], + "type": "object" + }, + "name": "write_label" +} \ No newline at end of file diff --git a/pkg/github/labels.go b/pkg/github/labels.go new file mode 100644 index 000000000..91eae4274 --- /dev/null +++ b/pkg/github/labels.go @@ -0,0 +1,460 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" +) + +// GetLabel retrieves a specific label by name from a GitHub repository +func GetLabel(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool( + "get_label", + mcp.WithDescription(t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization name)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Label name."), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + name, err := RequiredParam[string](request, "name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var query struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "name": githubv4.String(name), + } + + client, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find label", err), nil + } + + if query.Repository.Label.Name == "" { + return mcp.NewToolResultError(fmt.Sprintf("label '%s' not found in %s/%s", name, owner, repo)), nil + } + + label := map[string]any{ + "id": fmt.Sprintf("%v", query.Repository.Label.ID), + "name": string(query.Repository.Label.Name), + "color": string(query.Repository.Label.Color), + "description": string(query.Repository.Label.Description), + } + + out, err := json.Marshal(label) + if err != nil { + return nil, fmt.Errorf("failed to marshal label: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +// ListLabels lists labels from a repository or an issue +func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool( + "list_label", + mcp.WithDescription(t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository or an issue")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization name) - required for all operations"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name - required for all operations"), + ), + mcp.WithNumber("issue_number", + mcp.Description("Issue number - if provided, lists labels on the specific issue"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + issueNumber, err := OptionalIntParam(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + if issueNumber != 0 { + // Get current labels on the issue using GraphQL + var query struct { + Repository struct { + Issue struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + } `graphql:"labels(first: 100)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers + } + + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get issue labels", err), nil + } + + // Extract label information + issueLabels := make([]map[string]any, len(query.Repository.Issue.Labels.Nodes)) + for i, label := range query.Repository.Issue.Labels.Nodes { + issueLabels[i] = map[string]any{ + "id": fmt.Sprintf("%v", label.ID), + "name": string(label.Name), + "color": string(label.Color), + "description": string(label.Description), + } + } + + response := map[string]any{ + "labels": issueLabels, + "totalCount": int(query.Repository.Issue.Labels.TotalCount), + } + + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + + } + + var query struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + } `graphql:"labels(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil + } + + labels := make([]map[string]any, len(query.Repository.Labels.Nodes)) + for i, labelNode := range query.Repository.Labels.Nodes { + labels[i] = map[string]any{ + "id": fmt.Sprintf("%v", labelNode.ID), + "name": string(labelNode.Name), + "color": string(labelNode.Color), + "description": string(labelNode.Description), + } + } + + response := map[string]any{ + "labels": labels, + "totalCount": int(query.Repository.Labels.TotalCount), + } + + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal labels: %w", err) + } + + return mcp.NewToolResultText(string(out)), nil + } +} + +// WriteLabel handles create, update, and delete operations for GitHub labels +func WriteLabel(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool( + "write_label", + mcp.WithDescription(t("TOOL_WRITE_LABEL_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_WRITE_LABEL_TITLE", "Write operations on repository labels."), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("method", + mcp.Required(), + mcp.Description("Operation to perform: 'create', 'update', or 'delete'"), + mcp.Enum("create", "update", "delete"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner (username or organization name)"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Label name - required for all operations"), + ), + mcp.WithString("new_name", + mcp.Description("New name for the label (used only with 'update' method to rename)"), + ), + mcp.WithString("color", + mcp.Description("Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'."), + ), + mcp.WithString("description", + mcp.Description("Label description text. Optional for 'create' and 'update'."), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Get and validate required parameters + method, err := RequiredParam[string](request, "method") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + method = strings.ToLower(method) + + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + name, err := RequiredParam[string](request, "name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Get optional parameters + newName, _ := OptionalParam[string](request, "new_name") + color, _ := OptionalParam[string](request, "color") + description, _ := OptionalParam[string](request, "description") + + client, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch method { + case "create": + // Validate required params for create + if color == "" { + return mcp.NewToolResultError("color is required for create"), nil + } + + // Get repository ID + repoID, err := getRepositoryID(ctx, client, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find repository", err), nil + } + + input := githubv4.CreateLabelInput{ + RepositoryID: repoID, + Name: githubv4.String(name), + Color: githubv4.String(color), + } + if description != "" { + d := githubv4.String(description) + input.Description = &d + } + + var mutation struct { + CreateLabel struct { + Label struct { + Name githubv4.String + ID githubv4.ID + } + } `graphql:"createLabel(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to create label", err), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("label '%s' created successfully", mutation.CreateLabel.Label.Name)), nil + + case "update": + // Validate required params for update + if newName == "" && color == "" && description == "" { + return mcp.NewToolResultError("at least one of new_name, color, or description must be provided for update"), nil + } + + // Get the label ID + labelID, err := getLabelID(ctx, client, owner, repo, name) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + input := githubv4.UpdateLabelInput{ + ID: labelID, + } + if newName != "" { + n := githubv4.String(newName) + input.Name = &n + } + if color != "" { + c := githubv4.String(color) + input.Color = &c + } + if description != "" { + d := githubv4.String(description) + input.Description = &d + } + + var mutation struct { + UpdateLabel struct { + Label struct { + Name githubv4.String + ID githubv4.ID + } + } `graphql:"updateLabel(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to update label", err), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("label '%s' updated successfully", mutation.UpdateLabel.Label.Name)), nil + + case "delete": + // Get the label ID + labelID, err := getLabelID(ctx, client, owner, repo, name) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + input := githubv4.DeleteLabelInput{ + ID: labelID, + } + + var mutation struct { + DeleteLabel struct { + ClientMutationID githubv4.String + } `graphql:"deleteLabel(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to delete label", err), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("label '%s' deleted successfully", name)), nil + + default: + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s. Supported methods are: create, update, delete", method)), nil + } + } +} + +// Helper function to get repository ID +func getRepositoryID(ctx context.Context, client *githubv4.Client, owner, repo string) (githubv4.ID, error) { + var repoQuery struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := client.Query(ctx, &repoQuery, vars); err != nil { + return "", err + } + return repoQuery.Repository.ID, nil +} + +// Helper function to get label by name +func getLabelID(ctx context.Context, client *githubv4.Client, owner, repo, labelName string) (githubv4.ID, error) { + var query struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "name": githubv4.String(labelName), + } + if err := client.Query(ctx, &query, vars); err != nil { + return "", err + } + if query.Repository.Label.Name == "" { + return "", fmt.Errorf("label '%s' not found in %s/%s", labelName, owner, repo) + } + return query.Repository.Label.ID, nil +} diff --git a/pkg/github/labels_test.go b/pkg/github/labels_test.go new file mode 100644 index 000000000..8e1d7688e --- /dev/null +++ b/pkg/github/labels_test.go @@ -0,0 +1,542 @@ +package github + +import ( + "context" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetLabel(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := GetLabel(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_label", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "name") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "name"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful label retrieval", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "name": "bug", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "name": githubv4.String("bug"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("test-label-id"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "label not found", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "name": "nonexistent", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "name": githubv4.String("nonexistent"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID(""), + "name": githubv4.String(""), + "color": githubv4.String(""), + "description": githubv4.String(""), + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "label 'nonexistent' not found in owner/repo", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + _, handler := GetLabel(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + assert.NotNil(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError) + if tc.expectedToolErrMsg != "" { + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + } + } else { + assert.False(t, result.IsError) + } + }) + } +} + +func TestListLabels(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := ListLabels(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_label", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful repository labels listing", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + } `graphql:"labels(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "labels": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("label-1"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + map[string]any{ + "id": githubv4.ID("label-2"), + "name": githubv4.String("enhancement"), + "color": githubv4.String("a2eeef"), + "description": githubv4.String("New feature or request"), + }, + }, + "totalCount": githubv4.Int(2), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "successful issue labels listing", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + } `graphql:"labels(first: 100)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "labels": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("label-1"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + }, + "totalCount": githubv4.Int(1), + }, + }, + }, + }), + ), + ), + expectToolError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + _, handler := ListLabels(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + assert.NotNil(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError) + if tc.expectedToolErrMsg != "" { + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + } + } else { + assert.False(t, result.IsError) + } + }) + } +} + +func TestWriteLabel(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := WriteLabel(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "write_label", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "method") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "name") + assert.Contains(t, tool.InputSchema.Properties, "new_name") + assert.Contains(t, tool.InputSchema.Properties, "color") + assert.Contains(t, tool.InputSchema.Properties, "description") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "name"}) + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful label creation", + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "name": "new-label", + "color": "f29513", + "description": "A new test label", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + CreateLabel struct { + Label struct { + Name githubv4.String + ID githubv4.ID + } + } `graphql:"createLabel(input: $input)"` + }{}, + githubv4.CreateLabelInput{ + RepositoryID: githubv4.ID("test-repo-id"), + Name: githubv4.String("new-label"), + Color: githubv4.String("f29513"), + Description: func() *githubv4.String { s := githubv4.String("A new test label"); return &s }(), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createLabel": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("new-label-id"), + "name": githubv4.String("new-label"), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "create label without color", + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "name": "new-label", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "color is required for create", + }, + { + name: "successful label update", + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "name": "bug", + "new_name": "defect", + "color": "ff0000", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "name": githubv4.String("bug"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("bug-label-id"), + "name": githubv4.String("bug"), + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateLabel struct { + Label struct { + Name githubv4.String + ID githubv4.ID + } + } `graphql:"updateLabel(input: $input)"` + }{}, + githubv4.UpdateLabelInput{ + ID: githubv4.ID("bug-label-id"), + Name: func() *githubv4.String { s := githubv4.String("defect"); return &s }(), + Color: func() *githubv4.String { s := githubv4.String("ff0000"); return &s }(), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateLabel": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("bug-label-id"), + "name": githubv4.String("defect"), + }, + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "update label without any changes", + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "name": "bug", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "at least one of new_name, color, or description must be provided for update", + }, + { + name: "successful label deletion", + requestArgs: map[string]any{ + "method": "delete", + "owner": "owner", + "repo": "repo", + "name": "bug", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Label struct { + ID githubv4.ID + Name githubv4.String + } `graphql:"label(name: $name)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "name": githubv4.String("bug"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "label": map[string]any{ + "id": githubv4.ID("bug-label-id"), + "name": githubv4.String("bug"), + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + DeleteLabel struct { + ClientMutationID githubv4.String + } `graphql:"deleteLabel(input: $input)"` + }{}, + githubv4.DeleteLabelInput{ + ID: githubv4.ID("bug-label-id"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "deleteLabel": map[string]any{ + "clientMutationId": githubv4.String("test-mutation-id"), + }, + }), + ), + ), + expectToolError: false, + }, + { + name: "invalid method", + requestArgs: map[string]any{ + "method": "invalid", + "owner": "owner", + "repo": "repo", + "name": "bug", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedToolErrMsg: "unknown method: invalid", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + _, handler := WriteLabel(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + assert.NotNil(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError) + if tc.expectedToolErrMsg != "" { + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + } + } else { + assert.False(t, result.IsError) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 9501e33b4..cd0bb28e2 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -95,6 +95,10 @@ var ( ID: "dynamic", Description: "Discover GitHub MCP tools that can help achieve tasks by enabling additional sets of tools, you can control the enablement of any toolset to access its tools when this toolset is enabled.", } + ToolsetLabels = ToolsetMetadata{ + ID: "labels", + Description: "GitHub Labels related tools", + } ) func AvailableTools() []ToolsetMetadata { @@ -117,6 +121,7 @@ func AvailableTools() []ToolsetMetadata { ToolsetMetadataProjects, ToolsetMetadataStargazers, ToolsetMetadataDynamic, + ToolsetLabels, } } @@ -172,6 +177,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetIssueComments(getClient, t)), toolsets.NewServerTool(ListIssueTypes(getClient, t)), toolsets.NewServerTool(ListSubIssues(getClient, t)), + toolsets.NewServerTool(GetLabel(getGQLClient, t)), + toolsets.NewServerTool(ListLabels(getGQLClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateIssue(getClient, t)), @@ -181,6 +188,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(AddSubIssue(getClient, t)), toolsets.NewServerTool(RemoveSubIssue(getClient, t)), toolsets.NewServerTool(ReprioritizeSubIssue(getClient, t)), + toolsets.NewServerTool(WriteLabel(getGQLClient, t)), ).AddPrompts( toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), @@ -317,7 +325,17 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(StarRepository(getClient, t)), toolsets.NewServerTool(UnstarRepository(getClient, t)), ) - + labels := toolsets.NewToolset(ToolsetLabels.ID, ToolsetLabels.Description). + AddReadTools( + // get + toolsets.NewServerTool(GetLabel(getGQLClient, t)), + // list labels on repo or issue + toolsets.NewServerTool(ListLabels(getGQLClient, t)), + ). + AddWriteTools( + // create or update + toolsets.NewServerTool(WriteLabel(getGQLClient, t)), + ) // Add toolsets to the group tsg.AddToolset(contextTools) tsg.AddToolset(repos) @@ -336,6 +354,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(securityAdvisories) tsg.AddToolset(projects) tsg.AddToolset(stargazers) + tsg.AddToolset(labels) return tsg }