From dc7c27cc5e6c20bd1387500951789186610549f0 Mon Sep 17 00:00:00 2001 From: Babbage <42345137+MattBabbage@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:09:02 +0100 Subject: [PATCH 1/3] Improve registry release reliablilty (#1174) --- .github/workflows/docker-publish.yml | 16 ++++++++++++ .github/workflows/registry-releaser.yml | 34 ++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 35bb30eddb..29b1d5effa 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -127,3 +127,19 @@ jobs: # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} + + - name: Trigger registry publication + if: ${{ github.event_name != 'pull_request' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') }} + uses: actions/github-script@v7 + with: + script: | + await github.rest.repos.createDispatchEvent({ + owner: context.repo.owner, + repo: context.repo.repo, + event_type: 'docker-published', + client_payload: { + tag: context.ref.replace('refs/tags/', ''), + sha: context.sha, + image_digest: '${{ steps.build-and-push.outputs.digest }}' + } + }); diff --git a/.github/workflows/registry-releaser.yml b/.github/workflows/registry-releaser.yml index bc517dc972..c03a48d2b0 100644 --- a/.github/workflows/registry-releaser.yml +++ b/.github/workflows/registry-releaser.yml @@ -1,8 +1,8 @@ name: Publish to MCP Registry on: - push: - tags: ["v*"] # Triggers on version tags like v1.0.0 + repository_dispatch: + types: [docker-published] # Triggered after Docker image is published workflow_dispatch: # Allow manual triggering jobs: @@ -23,12 +23,35 @@ jobs: - name: Fetch tags run: | - if [[ "${{ github.ref_type }}" != "tag" ]]; then + if [[ "${{ github.event_name }}" == "repository_dispatch" ]]; then + echo "Triggered by docker-published event for tag: ${{ github.event.client_payload.tag }}" + elif [[ "${{ github.ref_type }}" != "tag" ]]; then git fetch --tags else echo "Skipping tag fetch - already on tag ${{ github.ref_name }}" fi + - name: Wait for Docker image + run: | + if [[ "${{ github.event_name }}" == "repository_dispatch" ]]; then + TAG="${{ github.event.client_payload.tag }}" + elif [[ "${{ github.ref_type }}" == "tag" ]]; then + TAG="${{ github.ref_name }}" + else + TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+' | head -n1) + fi + IMAGE="ghcr.io/github/github-mcp-server:$TAG" + + for i in {1..6}; do + if docker manifest inspect "$IMAGE" &>/dev/null; then + echo "✅ Docker image ready: $TAG" + break + fi + [ $i -eq 6 ] && { echo "❌ Timeout waiting for $TAG after 3 minutes"; exit 1; } + echo "⏳ Waiting for Docker image ($i/6)..." + sleep 30 + done + - name: Install MCP Publisher run: | git clone --quiet https://github.com/modelcontextprotocol/registry publisher-repo @@ -37,7 +60,10 @@ jobs: - name: Update server.json version run: | - if [[ "${{ github.ref_type }}" == "tag" ]]; then + if [[ "${{ github.event_name }}" == "repository_dispatch" ]]; then + TAG_VERSION=$(echo "${{ github.event.client_payload.tag }}" | sed 's/^v//') + echo "Using tag from dispatch: ${{ github.event.client_payload.tag }}" + elif [[ "${{ github.ref_type }}" == "tag" ]]; then TAG_VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//') else LATEST_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$' | head -n 1) From 16b43c9d44a32ba21210f45e2eccb2a6906c0782 Mon Sep 17 00:00:00 2001 From: Roberto Nacu Date: Thu, 9 Oct 2025 10:11:12 +0100 Subject: [PATCH 2/3] Add sort and order parameters to search_repositories tool (#1191) * add sort and order params * test adding server instructions for sorting * improve server instructions * reorder --- README.md | 2 ++ .../__toolsnaps__/search_repositories.snap | 18 ++++++++++++++++++ pkg/github/instructions.go | 5 ++++- pkg/github/search.go | 18 ++++++++++++++++++ pkg/github/search_test.go | 6 ++++++ 5 files changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6242799274..b5f04f3715 100644 --- a/README.md +++ b/README.md @@ -993,9 +993,11 @@ Possible options: - **search_repositories** - Search repositories - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) + - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `query`: Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering. (string, required) + - `sort`: Sort repositories by field, defaults to best match (string, optional) diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index f350c8e2b7..99828380e7 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -11,6 +11,14 @@ "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", "type": "boolean" }, + "order": { + "description": "Sort order", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, "page": { "description": "Page number for pagination (min 1)", "minimum": 1, @@ -25,6 +33,16 @@ "query": { "description": "Repository search query. Examples: 'machine learning in:name stars:\u003e1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering.", "type": "string" + }, + "sort": { + "description": "Sort repositories by field, defaults to best match", + "enum": [ + "stars", + "forks", + "help-wanted-issues", + "updated" + ], + "type": "string" } }, "required": [ diff --git a/pkg/github/instructions.go b/pkg/github/instructions.go index 7eefe53f05..3d58011aa7 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/instructions.go @@ -36,7 +36,10 @@ Tool selection guidance: Context management: 1. Use pagination whenever possible with batches of 5-10 items. - 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task.` + 2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task. + +Tool usage guidance: + 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.` allInstructions := []string{baseInstruction} allInstructions = append(allInstructions, instructions...) diff --git a/pkg/github/search.go b/pkg/github/search.go index 55e4cf8b4b..a735c894b7 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -26,6 +26,14 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF mcp.Required(), mcp.Description("Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering."), ), + mcp.WithString("sort", + mcp.Description("Sort repositories by field, defaults to best match"), + mcp.Enum("stars", "forks", "help-wanted-issues", "updated"), + ), + mcp.WithString("order", + mcp.Description("Sort order"), + mcp.Enum("asc", "desc"), + ), mcp.WithBoolean("minimal_output", mcp.Description("Return minimal repository information (default: true). When false, returns full GitHub API repository objects."), mcp.DefaultBool(true), @@ -37,6 +45,14 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF if err != nil { return mcp.NewToolResultError(err.Error()), nil } + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + order, err := OptionalParam[string](request, "order") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } pagination, err := OptionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -46,6 +62,8 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF return mcp.NewToolResultError(err.Error()), nil } opts := &github.SearchOptions{ + Sort: sort, + Order: order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 91ca45af5a..9ccf176648 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -23,6 +23,8 @@ func Test_SearchRepositories(t *testing.T) { assert.Equal(t, "search_repositories", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "sort") + assert.Contains(t, tool.InputSchema.Properties, "order") assert.Contains(t, tool.InputSchema.Properties, "page") assert.Contains(t, tool.InputSchema.Properties, "perPage") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"}) @@ -66,6 +68,8 @@ func Test_SearchRepositories(t *testing.T) { mock.GetSearchRepositories, expectQueryParams(t, map[string]string{ "q": "golang test", + "sort": "stars", + "order": "desc", "page": "2", "per_page": "10", }).andThen( @@ -75,6 +79,8 @@ func Test_SearchRepositories(t *testing.T) { ), requestArgs: map[string]interface{}{ "query": "golang test", + "sort": "stars", + "order": "desc", "page": float64(2), "perPage": float64(10), }, From bbfc44c552b13152fce3f3a651b87a5bbcc14615 Mon Sep 17 00:00:00 2001 From: Tony Truong Date: Thu, 9 Oct 2025 11:24:18 +0200 Subject: [PATCH 3/3] adding labels tools (#1193) * adding labels tools * fixing ci * update toolsnap * fixing lint * changing comment * fix title --- 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 b5f04f3715..d4806fb84e 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 da82e846dc..61815a482c 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 0000000000..a6b72c4eb2 --- /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 0000000000..216b773ed7 --- /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 0000000000..cb13f8bbb8 --- /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 0000000000..91eae42745 --- /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 0000000000..8e1d7688ed --- /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 9501e33b48..cd0bb28e2d 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 }