Skip to content

Commit 252c38a

Browse files
authored
Add tool to list projects
1 parent 2d3db3a commit 252c38a

File tree

4 files changed

+369
-0
lines changed

4 files changed

+369
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"annotations": {
3+
"title": "List projects",
4+
"readOnlyHint": true
5+
},
6+
"description": "List Projects for a user or organization",
7+
"inputSchema": {
8+
"properties": {
9+
"after": {
10+
"description": "Cursor for items after (forward pagination)",
11+
"type": "string"
12+
},
13+
"before": {
14+
"description": "Cursor for items before (backwards pagination)",
15+
"type": "string"
16+
},
17+
"owner": {
18+
"description": "Owner",
19+
"type": "string"
20+
},
21+
"owner_type": {
22+
"description": "Owner type",
23+
"enum": [
24+
"user",
25+
"organization"
26+
],
27+
"type": "string"
28+
},
29+
"per_page": {
30+
"description": "Number of results per page (max 100, default: 30)",
31+
"type": "number"
32+
},
33+
"query": {
34+
"description": "Filter projects by a search query (matches title and description)",
35+
"type": "string"
36+
}
37+
},
38+
"required": [
39+
"owner",
40+
"owner_type"
41+
],
42+
"type": "object"
43+
},
44+
"name": "list_projects"
45+
}

pkg/github/projects.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"reflect"
11+
12+
ghErrors "github.com/github/github-mcp-server/pkg/errors"
13+
"github.com/github/github-mcp-server/pkg/translations"
14+
"github.com/google/go-github/v74/github"
15+
"github.com/google/go-querystring/query"
16+
"github.com/mark3labs/mcp-go/mcp"
17+
"github.com/mark3labs/mcp-go/server"
18+
)
19+
20+
func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
21+
return mcp.NewTool("list_projects",
22+
mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or organization")),
23+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), ReadOnlyHint: ToBoolPtr(true)}),
24+
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "organization")),
25+
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.")),
26+
mcp.WithString("query", mcp.Description("Filter projects by a search query (matches title and description)")),
27+
mcp.WithString("before", mcp.Description("Cursor for items before (backwards pagination)")),
28+
mcp.WithString("after", mcp.Description("Cursor for items after (forward pagination)")),
29+
mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")),
30+
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
31+
owner, err := RequiredParam[string](req, "owner")
32+
if err != nil {
33+
return mcp.NewToolResultError(err.Error()), nil
34+
}
35+
ownerType, err := RequiredParam[string](req, "owner_type")
36+
if err != nil {
37+
return mcp.NewToolResultError(err.Error()), nil
38+
}
39+
queryStr, err := OptionalParam[string](req, "query")
40+
if err != nil {
41+
return mcp.NewToolResultError(err.Error()), nil
42+
}
43+
44+
beforeCursor, err := OptionalParam[string](req, "before")
45+
if err != nil {
46+
return mcp.NewToolResultError(err.Error()), nil
47+
}
48+
afterCursor, err := OptionalParam[string](req, "after")
49+
if err != nil {
50+
return mcp.NewToolResultError(err.Error()), nil
51+
}
52+
perPage, err := OptionalIntParamWithDefault(req, "per_page", 30)
53+
if err != nil {
54+
return mcp.NewToolResultError(err.Error()), nil
55+
}
56+
57+
client, err := getClient(ctx)
58+
if err != nil {
59+
return mcp.NewToolResultError(err.Error()), nil
60+
}
61+
62+
var url string = ""
63+
if ownerType == "organization" {
64+
url = fmt.Sprintf("/orgs/%s/projectsV2", owner)
65+
} else {
66+
url = fmt.Sprintf("/users/%s/projectsV2", owner)
67+
}
68+
projects := []github.ProjectV2{}
69+
70+
opts := ListProjectsOptions{PerPage: perPage}
71+
if afterCursor != "" {
72+
opts.After = afterCursor
73+
}
74+
if beforeCursor != "" {
75+
opts.Before = beforeCursor
76+
}
77+
if queryStr != "" {
78+
opts.Query = queryStr
79+
}
80+
url, err = addOptions(url, opts)
81+
if err != nil {
82+
return nil, fmt.Errorf("failed to add options to request: %w", err)
83+
}
84+
85+
httpRequest, err := client.NewRequest("GET", url, nil)
86+
if err != nil {
87+
return nil, fmt.Errorf("failed to create request: %w", err)
88+
}
89+
90+
resp, err := client.Do(ctx, httpRequest, &projects)
91+
if err != nil {
92+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
93+
"failed to list projects",
94+
resp,
95+
err,
96+
), nil
97+
}
98+
defer func() { _ = resp.Body.Close() }()
99+
100+
if resp.StatusCode != http.StatusOK {
101+
body, err := io.ReadAll(resp.Body)
102+
if err != nil {
103+
return nil, fmt.Errorf("failed to read response body: %w", err)
104+
}
105+
return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil
106+
}
107+
r, err := json.Marshal(projects)
108+
if err != nil {
109+
return nil, fmt.Errorf("failed to marshal response: %w", err)
110+
}
111+
112+
return mcp.NewToolResultText(string(r)), nil
113+
}
114+
}
115+
116+
type ListProjectsOptions struct {
117+
// A cursor, as given in the Link header. If specified, the query only searches for events after this cursor.
118+
After string `url:"after,omitempty"`
119+
120+
// A cursor, as given in the Link header. If specified, the query only searches for events before this cursor.
121+
Before string `url:"before,omitempty"`
122+
123+
// For paginated result sets, the number of results to include per page.
124+
PerPage int `url:"per_page,omitempty"`
125+
126+
// Q is an optional query string to filter/search projects (when supported).
127+
Query string `url:"q,omitempty"`
128+
}
129+
130+
// addOptions adds the parameters in opts as URL query parameters to s. opts
131+
// must be a struct whose fields may contain "url" tags.
132+
func addOptions(s string, opts any) (string, error) {
133+
v := reflect.ValueOf(opts)
134+
if v.Kind() == reflect.Ptr && v.IsNil() {
135+
return s, nil
136+
}
137+
138+
u, err := url.Parse(s)
139+
if err != nil {
140+
return s, err
141+
}
142+
143+
qs, err := query.Values(opts)
144+
if err != nil {
145+
return s, err
146+
}
147+
148+
u.RawQuery = qs.Encode()
149+
return u.String(), nil
150+
}

pkg/github/projects_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/github/github-mcp-server/internal/toolsnaps"
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
gh "github.com/google/go-github/v74/github"
12+
"github.com/migueleliasweb/go-github-mock/src/mock"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func Test_ListProjects(t *testing.T) {
18+
// Verify tool definition and schema once
19+
mockClient := gh.NewClient(nil)
20+
tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper)
21+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
22+
23+
assert.Equal(t, "list_projects", tool.Name)
24+
assert.NotEmpty(t, tool.Description)
25+
assert.Contains(t, tool.InputSchema.Properties, "owner")
26+
assert.Contains(t, tool.InputSchema.Properties, "owner_type")
27+
assert.Contains(t, tool.InputSchema.Properties, "query")
28+
assert.Contains(t, tool.InputSchema.Properties, "before")
29+
assert.Contains(t, tool.InputSchema.Properties, "after")
30+
assert.Contains(t, tool.InputSchema.Properties, "per_page")
31+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"})
32+
33+
// Minimal project objects (fields chosen to likely exist on ProjectV2; test only asserts round-trip JSON array length)
34+
orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}}
35+
userProjects := []map[string]any{{"id": 2, "title": "User Project"}}
36+
37+
tests := []struct {
38+
name string
39+
mockedClient *http.Client
40+
requestArgs map[string]interface{}
41+
expectError bool
42+
expectedLength int
43+
expectedErrMsg string
44+
}{
45+
{
46+
name: "success organization",
47+
mockedClient: mock.NewMockedHTTPClient(
48+
mock.WithRequestMatchHandler(
49+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet},
50+
mockResponse(t, http.StatusOK, orgProjects),
51+
),
52+
),
53+
requestArgs: map[string]interface{}{
54+
"owner": "octo-org",
55+
"owner_type": "organization",
56+
},
57+
expectError: false,
58+
expectedLength: 1,
59+
},
60+
{
61+
name: "success user",
62+
mockedClient: mock.NewMockedHTTPClient(
63+
mock.WithRequestMatchHandler(
64+
mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet},
65+
mockResponse(t, http.StatusOK, userProjects),
66+
),
67+
),
68+
requestArgs: map[string]interface{}{
69+
"owner": "octocat",
70+
"owner_type": "user",
71+
},
72+
expectError: false,
73+
expectedLength: 1,
74+
},
75+
{
76+
name: "success organization with pagination & query",
77+
mockedClient: mock.NewMockedHTTPClient(
78+
mock.WithRequestMatchHandler(
79+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet},
80+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
81+
q := r.URL.Query()
82+
// Assert query params present
83+
if q.Get("after") == "cursor123" && q.Get("per_page") == "50" && q.Get("q") == "roadmap" {
84+
w.WriteHeader(http.StatusOK)
85+
_, _ = w.Write(mock.MustMarshal(orgProjects))
86+
return
87+
}
88+
w.WriteHeader(http.StatusBadRequest)
89+
_, _ = w.Write([]byte(`{"message":"unexpected query params"}`))
90+
}),
91+
),
92+
),
93+
requestArgs: map[string]interface{}{
94+
"owner": "octo-org",
95+
"owner_type": "organization",
96+
"after": "cursor123",
97+
"per_page": float64(50),
98+
"query": "roadmap",
99+
},
100+
expectError: false,
101+
expectedLength: 1,
102+
},
103+
{
104+
name: "api error",
105+
mockedClient: mock.NewMockedHTTPClient(
106+
mock.WithRequestMatchHandler(
107+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet},
108+
mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
109+
),
110+
),
111+
requestArgs: map[string]interface{}{
112+
"owner": "octo-org",
113+
"owner_type": "organization",
114+
},
115+
expectError: true,
116+
expectedErrMsg: "failed to list projects",
117+
},
118+
{
119+
name: "missing owner",
120+
mockedClient: mock.NewMockedHTTPClient(),
121+
requestArgs: map[string]interface{}{
122+
"owner_type": "organization",
123+
},
124+
expectError: true,
125+
},
126+
{
127+
name: "missing owner_type",
128+
mockedClient: mock.NewMockedHTTPClient(),
129+
requestArgs: map[string]interface{}{
130+
"owner": "octo-org",
131+
},
132+
expectError: true,
133+
},
134+
}
135+
136+
for _, tc := range tests {
137+
t.Run(tc.name, func(t *testing.T) {
138+
client := gh.NewClient(tc.mockedClient)
139+
_, handler := ListProjects(stubGetClientFn(client), translations.NullTranslationHelper)
140+
request := createMCPRequest(tc.requestArgs)
141+
result, err := handler(context.Background(), request)
142+
143+
require.NoError(t, err)
144+
if tc.expectError {
145+
require.True(t, result.IsError)
146+
text := getTextResult(t, result).Text
147+
if tc.expectedErrMsg != "" {
148+
assert.Contains(t, text, tc.expectedErrMsg)
149+
}
150+
// Parameter missing cases
151+
if tc.name == "missing owner" {
152+
assert.Contains(t, text, "missing required parameter: owner")
153+
}
154+
if tc.name == "missing owner_type" {
155+
assert.Contains(t, text, "missing required parameter: owner_type")
156+
}
157+
return
158+
}
159+
160+
require.False(t, result.IsError)
161+
textContent := getTextResult(t, result)
162+
var arr []map[string]any
163+
err = json.Unmarshal([]byte(textContent.Text), &arr)
164+
require.NoError(t, err)
165+
assert.Equal(t, tc.expectedLength, len(arr))
166+
})
167+
}
168+
}

pkg/github/tools.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
190190
toolsets.NewServerTool(UpdateGist(getClient, t)),
191191
)
192192

193+
projects := toolsets.NewToolset("projects", "GitHub Projects related tools").
194+
AddReadTools(
195+
toolsets.NewServerTool(ListProjects(getClient, t)),
196+
)
197+
193198
// Add toolsets to the group
194199
tsg.AddToolset(contextTools)
195200
tsg.AddToolset(repos)
@@ -206,6 +211,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
206211
tsg.AddToolset(discussions)
207212
tsg.AddToolset(gists)
208213
tsg.AddToolset(securityAdvisories)
214+
tsg.AddToolset(projects)
209215

210216
return tsg
211217
}

0 commit comments

Comments
 (0)