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 a4fac300a..7be9d83bb 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..23ee91459
--- /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 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"`
+
+ // Query Limit results to projects of the specified type.
+ 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
}