From 8c21aee7f9d9f5adeba2391edaa514d998d466bd Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Mon, 22 Sep 2025 07:46:28 +0000 Subject: [PATCH 1/4] Bump go-viper/mapstructure --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 59afea005..73a043f8c 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-viper/mapstructure/v2 v2.3.0 + github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/go-github/v71 v71.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index d2cf6410e..184f3005d 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,8 @@ github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU= github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= -github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= From 2d3db3ad7334a3e4124b7d5ba13c7453936dc780 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Mon, 22 Sep 2025 07:53:38 +0000 Subject: [PATCH 2/4] Update github.com/go-viper/mapstructure/v2 version in licenses --- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index ddd6b7bce..a1239bdfc 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -13,7 +13,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE)) + - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v74/github](https://pkg.go.dev/github.com/google/go-github/v74/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v74.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index ddd6b7bce..a1239bdfc 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -13,7 +13,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE)) + - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v74/github](https://pkg.go.dev/github.com/google/go-github/v74/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v74.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 1fb8d6320..3bf2d852a 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -13,7 +13,7 @@ Some packages may only be included on certain architectures or operating systems - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE)) - - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE)) + - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.4.0/LICENSE)) - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v74/github](https://pkg.go.dev/github.com/google/go-github/v74/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v74.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.1.0/LICENSE)) From c3bad93d5d83b9ae1c2ee671e14a98a0056f4b3f Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Mon, 22 Sep 2025 13:43:21 +0000 Subject: [PATCH 3/4] Add tool to list projects --- README.md | 15 ++ docs/remote-server.md | 1 + pkg/github/__toolsnaps__/list_projects.snap | 45 ++++++ pkg/github/projects.go | 150 +++++++++++++++++ pkg/github/projects_test.go | 168 ++++++++++++++++++++ pkg/github/tools.go | 6 + 6 files changed, 385 insertions(+) create mode 100644 pkg/github/__toolsnaps__/list_projects.snap create mode 100644 pkg/github/projects.go create mode 100644 pkg/github/projects_test.go diff --git a/README.md b/README.md index 891e63a81..6ed566086 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,7 @@ The following sets of tools are available (all are on by default): | `issues` | GitHub Issues related tools | | `notifications` | GitHub Notifications related tools | | `orgs` | GitHub Organization related tools | +| `projects` | GitHub Projects related tools | | `pull_requests` | GitHub Pull Request related tools | | `repos` | GitHub Repository related tools | | `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | @@ -655,6 +656,20 @@ The following sets of tools are available (all are on by default):
+Projects + +- **list_projects** - List projects + - `after`: Cursor for items after (forward pagination) (string, optional) + - `before`: Cursor for items before (backwards pagination) (string, optional) + - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == organization it is the name of the organization. The name is not case sensitive. (string, required) + - `owner_type`: Owner type (string, required) + - `per_page`: Number of results per page (max 100, default: 30) (number, optional) + - `query`: Filter projects by a search query (matches title and description) (string, optional) + +
+ +
+ Pull Requests - **add_comment_to_pending_review** - Add review comment to the requester's latest pending pull request review diff --git a/docs/remote-server.md b/docs/remote-server.md index b6f7fa61d..1c7fc3baa 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -29,6 +29,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | 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) | | 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) | | Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | | Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | | Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/list_projects.snap b/pkg/github/__toolsnaps__/list_projects.snap new file mode 100644 index 000000000..d0fefe0bc --- /dev/null +++ b/pkg/github/__toolsnaps__/list_projects.snap @@ -0,0 +1,45 @@ +{ + "annotations": { + "title": "List projects", + "readOnlyHint": true + }, + "description": "List Projects for a user or organization", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for items after (forward pagination)", + "type": "string" + }, + "before": { + "description": "Cursor for items before (backwards pagination)", + "type": "string" + }, + "owner": { + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == organization it is the name of the organization. The name is not case sensitive.", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "organization" + ], + "type": "string" + }, + "per_page": { + "description": "Number of results per page (max 100, default: 30)", + "type": "number" + }, + "query": { + "description": "Filter projects by a search query (matches title and description)", + "type": "string" + } + }, + "required": [ + "owner_type", + "owner" + ], + "type": "object" + }, + "name": "list_projects" +} \ No newline at end of file diff --git a/pkg/github/projects.go b/pkg/github/projects.go new file mode 100644 index 000000000..3592b42be --- /dev/null +++ b/pkg/github/projects.go @@ -0,0 +1,150 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "reflect" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v74/github" + "github.com/google/go-querystring/query" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_projects", + mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or organization")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "organization")), + mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == organization it is the name of the organization. The name is not case sensitive.")), + mcp.WithString("query", mcp.Description("Filter projects by a search query (matches title and description)")), + mcp.WithString("before", mcp.Description("Cursor for items before (backwards pagination)")), + mcp.WithString("after", mcp.Description("Cursor for items after (forward pagination)")), + mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + queryStr, err := OptionalParam[string](req, "query") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + beforeCursor, err := OptionalParam[string](req, "before") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + afterCursor, err := OptionalParam[string](req, "after") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var url string + if ownerType == "organization" { + url = fmt.Sprintf("/orgs/%s/projectsV2", owner) + } else { + url = fmt.Sprintf("/users/%s/projectsV2", owner) + } + projects := []github.ProjectV2{} + + opts := ListProjectsOptions{PerPage: perPage} + if afterCursor != "" { + opts.After = afterCursor + } + if beforeCursor != "" { + opts.Before = beforeCursor + } + if queryStr != "" { + opts.Query = queryStr + } + url, err = addOptions(url, opts) + if err != nil { + return nil, fmt.Errorf("failed to add options to request: %w", err) + } + + httpRequest, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, httpRequest, &projects) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil + } + r, err := json.Marshal(projects) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +type ListProjectsOptions struct { + // A cursor, as given in the Link header. If specified, the query only searches for events after this cursor. + After string `url:"after,omitempty"` + + // A cursor, as given in the Link header. If specified, the query only searches for events before this cursor. + Before string `url:"before,omitempty"` + + // For paginated result sets, the number of results to include per page. + PerPage int `url:"per_page,omitempty"` + + // Q is an optional query string to filter/search projects (when supported). + Query string `url:"q,omitempty"` +} + +// addOptions adds the parameters in opts as URL query parameters to s. opts +// must be a struct whose fields may contain "url" tags. +func addOptions(s string, opts any) (string, error) { + v := reflect.ValueOf(opts) + if v.Kind() == reflect.Ptr && v.IsNil() { + return s, nil + } + + u, err := url.Parse(s) + if err != nil { + return s, err + } + + qs, err := query.Values(opts) + if err != nil { + return s, err + } + + u.RawQuery = qs.Encode() + return u.String(), nil +} diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go new file mode 100644 index 000000000..3f779a17b --- /dev/null +++ b/pkg/github/projects_test.go @@ -0,0 +1,168 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + gh "github.com/google/go-github/v74/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListProjects(t *testing.T) { + // Verify tool definition and schema once + mockClient := gh.NewClient(nil) + tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_projects", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "before") + assert.Contains(t, tool.InputSchema.Properties, "after") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"}) + + // Minimal project objects (fields chosen to likely exist on ProjectV2; test only asserts round-trip JSON array length) + orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}} + userProjects := []map[string]any{{"id": 2, "title": "User Project"}} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedLength int + expectedErrMsg string + }{ + { + name: "success organization", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgProjects), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "organization", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "success user", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userProjects), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octocat", + "owner_type": "user", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "success organization with pagination & query", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + // Assert query params present + if q.Get("after") == "cursor123" && q.Get("per_page") == "50" && q.Get("q") == "roadmap" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgProjects)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "organization", + "after": "cursor123", + "per_page": float64(50), + "query": "roadmap", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "organization", + }, + expectError: true, + expectedErrMsg: "failed to list projects", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner_type": "organization", + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := ListProjects(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + // Parameter missing cases + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var arr []map[string]any + err = json.Unmarshal([]byte(textContent.Text), &arr) + require.NoError(t, err) + assert.Equal(t, tc.expectedLength, len(arr)) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0f294cef6..7fb5332aa 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -190,6 +190,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(UpdateGist(getClient, t)), ) + projects := toolsets.NewToolset("projects", "GitHub Projects related tools"). + AddReadTools( + toolsets.NewServerTool(ListProjects(getClient, t)), + ) + // Add toolsets to the group tsg.AddToolset(contextTools) tsg.AddToolset(repos) @@ -206,6 +211,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG tsg.AddToolset(discussions) tsg.AddToolset(gists) tsg.AddToolset(securityAdvisories) + tsg.AddToolset(projects) return tsg } From 7437464fc7caefd771e9350eecf88e0e4ffcfeba Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Mon, 22 Sep 2025 14:41:40 +0000 Subject: [PATCH 4/4] Fix ordering --- pkg/github/projects.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 3592b42be..23ee91459 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -114,16 +114,16 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( } type ListProjectsOptions struct { - // A cursor, as given in the Link header. If specified, the query only searches for events after this cursor. - After string `url:"after,omitempty"` - // A cursor, as given in the Link header. If specified, the query only searches for events before this cursor. Before string `url:"before,omitempty"` + // A cursor, as given in the Link header. If specified, the query only searches for events after this cursor. + After string `url:"after,omitempty"` + // For paginated result sets, the number of results to include per page. PerPage int `url:"per_page,omitempty"` - // Q is an optional query string to filter/search projects (when supported). + // Query Limit results to projects of the specified type. Query string `url:"q,omitempty"` }