From c4af331033df02dfa6d609cc948d2aed28da5e6b Mon Sep 17 00:00:00 2001 From: tonytrg Date: Wed, 8 Oct 2025 16:57:42 +0200 Subject: [PATCH 1/6] adding labels tools --- README.md | 45 ++ docs/remote-server.md | 1 + pkg/github/__toolsnaps__/get_label.snap | 30 ++ pkg/github/__toolsnaps__/list_label.snap | 29 ++ pkg/github/__toolsnaps__/write_label.snap | 52 +++ pkg/github/labels.go | 460 ++++++++++++++++++ pkg/github/labels_test.go | 542 ++++++++++++++++++++++ pkg/github/tools.go | 21 +- 8 files changed, 1179 insertions(+), 1 deletion(-) create mode 100644 pkg/github/__toolsnaps__/get_label.snap create mode 100644 pkg/github/__toolsnaps__/list_label.snap create mode 100644 pkg/github/__toolsnaps__/write_label.snap create mode 100644 pkg/github/labels.go create mode 100644 pkg/github/labels_test.go diff --git a/README.md b/README.md index 624279927..9bea2d271 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 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** - Get Label from a repository. + - `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 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 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** - Get Label from a repository. + - `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 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..3f2a590c5 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "title": "Get 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..9a4f9df8b --- /dev/null +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -0,0 +1,29 @@ +{ + "annotations": { + "title": "Get 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..2740c3f5d --- /dev/null +++ b/pkg/github/__toolsnaps__/write_label.snap @@ -0,0 +1,52 @@ +{ + "annotations": { + "title": "Write operations on labels - create, update, delete", + "readOnlyHint": false + }, + "description": "Create, update, or delete repository labels in GitHub.", + "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..e26892d4f --- /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 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_GET_LABEL_TITLE", "Get 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), + } + + 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 + + } else { + 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.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_WRITE_LABEL_TITLE", "Write operations on 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..58c1d8566 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 } From e45da8db4783732d6c0d1d4ffbce16d22b01aac1 Mon Sep 17 00:00:00 2001 From: tonytrg Date: Thu, 9 Oct 2025 08:57:07 +0200 Subject: [PATCH 2/6] fixing ci --- README.md | 4 ++-- pkg/github/__toolsnaps__/write_label.snap | 4 ++-- pkg/github/labels.go | 4 ++-- pkg/github/tools.go | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9bea2d271..2274d36d1 100644 --- a/README.md +++ b/README.md @@ -668,7 +668,7 @@ 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 labels +- **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) @@ -693,7 +693,7 @@ The following sets of tools are available (all are on by default): - `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 labels +- **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) diff --git a/pkg/github/__toolsnaps__/write_label.snap b/pkg/github/__toolsnaps__/write_label.snap index 2740c3f5d..c3663fa1d 100644 --- a/pkg/github/__toolsnaps__/write_label.snap +++ b/pkg/github/__toolsnaps__/write_label.snap @@ -1,9 +1,9 @@ { "annotations": { - "title": "Write operations on labels - create, update, delete", + "title": "Write operations on repository labels.", "readOnlyHint": false }, - "description": "Create, update, or delete repository labels in GitHub.", + "description": "Perform write operations on repository labels.", "inputSchema": { "properties": { "color": { diff --git a/pkg/github/labels.go b/pkg/github/labels.go index e26892d4f..f97aecb25 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -243,9 +243,9 @@ func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun 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.")), + 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 labels"), + Title: t("TOOL_WRITE_LABEL_TITLE", "Write operations on repository labels."), ReadOnlyHint: ToBoolPtr(false), }), mcp.WithString("method", diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 58c1d8566..cd0bb28e2 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -96,7 +96,7 @@ var ( 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", + ID: "labels", Description: "GitHub Labels related tools", } ) From e304034fd5a08d32e61f246cc7d3ef47b3f4580e Mon Sep 17 00:00:00 2001 From: tonytrg Date: Thu, 9 Oct 2025 09:12:31 +0200 Subject: [PATCH 3/6] update toolsnap --- pkg/github/__toolsnaps__/write_label.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/__toolsnaps__/write_label.snap b/pkg/github/__toolsnaps__/write_label.snap index c3663fa1d..cb13f8bbb 100644 --- a/pkg/github/__toolsnaps__/write_label.snap +++ b/pkg/github/__toolsnaps__/write_label.snap @@ -3,7 +3,7 @@ "title": "Write operations on repository labels.", "readOnlyHint": false }, - "description": "Perform write operations on repository labels.", + "description": "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.", "inputSchema": { "properties": { "color": { From f3196672b1b2c59d5182ad838acb8805bb0e5202 Mon Sep 17 00:00:00 2001 From: tonytrg Date: Thu, 9 Oct 2025 09:18:56 +0200 Subject: [PATCH 4/6] fixing lint --- pkg/github/labels.go | 78 ++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/pkg/github/labels.go b/pkg/github/labels.go index f97aecb25..2738413b8 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -160,7 +160,7 @@ func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), - "issueNumber": githubv4.Int(issueNumber), + "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers } if err := client.Query(ctx, &query, vars); err != nil { @@ -190,52 +190,52 @@ func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun return mcp.NewToolResultText(string(out)), nil - } else { - 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), - } + 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)"` + } - if err := client.Query(ctx, &query, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil - } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } - 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), - } - } + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil + } - response := map[string]any{ - "labels": labels, - "totalCount": int(query.Repository.Labels.TotalCount), + 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), } + } - out, err := json.Marshal(response) - if err != nil { - return nil, fmt.Errorf("failed to marshal labels: %w", err) - } + response := map[string]any{ + "labels": labels, + "totalCount": int(query.Repository.Labels.TotalCount), + } - return mcp.NewToolResultText(string(out)), nil + out, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal labels: %w", err) } + + return mcp.NewToolResultText(string(out)), nil } } From eece97541aeaeae6ee057b0a2ba0d5561f9e9df2 Mon Sep 17 00:00:00 2001 From: tonytrg Date: Thu, 9 Oct 2025 10:29:15 +0200 Subject: [PATCH 5/6] changing comment --- pkg/github/__toolsnaps__/list_label.snap | 2 +- pkg/github/labels.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap index 9a4f9df8b..216b773ed 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -1,6 +1,6 @@ { "annotations": { - "title": "Get labels from a repository.", + "title": "List labels from a repository.", "readOnlyHint": true }, "description": "List labels from a repository or an issue", diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 2738413b8..8c6232527 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -103,7 +103,7 @@ func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun "list_label", mcp.WithDescription(t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository or an issue")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_LABEL_TITLE", "Get labels from a repository."), + Title: t("TOOL_GET_LABEL_TITLE", "List labels from a repository."), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", From 773cf305dc207e7b98bf3871e31168341002c288 Mon Sep 17 00:00:00 2001 From: tonytrg Date: Thu, 9 Oct 2025 10:51:15 +0200 Subject: [PATCH 6/6] fix title --- README.md | 8 ++++---- pkg/github/__toolsnaps__/get_label.snap | 2 +- pkg/github/labels.go | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2274d36d1..8d146676c 100644 --- a/README.md +++ b/README.md @@ -600,7 +600,7 @@ 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 Label from a repository. +- **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) @@ -619,7 +619,7 @@ 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** - Get Label from a repository. +- **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) @@ -683,12 +683,12 @@ The following sets of tools are available (all are on by default): Labels -- **get_label** - Get Label from a repository. +- **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** - Get Label from a repository. +- **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) diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap index 3f2a590c5..a6b72c4eb 100644 --- a/pkg/github/__toolsnaps__/get_label.snap +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -1,6 +1,6 @@ { "annotations": { - "title": "Get Label from a repository.", + "title": "Get a specific label from a repository.", "readOnlyHint": true }, "description": "Get a specific label from a repository.", diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 8c6232527..91eae4274 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -19,7 +19,7 @@ func GetLabel(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) "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 Label from a repository."), + Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner", @@ -103,7 +103,7 @@ func ListLabels(getGQLClient GetGQLClientFn, t translations.TranslationHelperFun "list_label", mcp.WithDescription(t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository or an issue")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_GET_LABEL_TITLE", "List labels from a repository."), + Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("owner",