Skip to content

Commit abf7c47

Browse files
authored
Add tools for Projects V2 (#1114)
* Add get_project tool * Remove pagination for now * Fix url formatting * Minor tweaks * Add list project fields tool * Wording
1 parent 0a1d6db commit abf7c47

File tree

7 files changed

+540
-53
lines changed

7 files changed

+540
-53
lines changed

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -658,10 +658,19 @@ The following sets of tools are available (all are on by default):
658658

659659
<summary>Projects</summary>
660660

661+
- **get_project** - Get project
662+
- `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
663+
- `owner_type`: Owner type (string, required)
664+
- `project_number`: The project's number (number, required)
665+
666+
- **list_project_fields** - List project fields
667+
- `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
668+
- `owner_type`: Owner type (string, required)
669+
- `per_page`: Number of results per page (max 100, default: 30) (number, optional)
670+
- `projectNumber`: The project's number. (string, required)
671+
661672
- **list_projects** - List projects
662-
- `after`: Cursor for items after (forward pagination) (string, optional)
663-
- `before`: Cursor for items before (backwards pagination) (string, optional)
664-
- `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)
673+
- `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
665674
- `owner_type`: Owner type (string, required)
666675
- `per_page`: Number of results per page (max 100, default: 30) (number, optional)
667676
- `query`: Filter projects by a search query (matches title and description) (string, optional)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"annotations": {
3+
"title": "Get project",
4+
"readOnlyHint": true
5+
},
6+
"description": "Get Project for a user or org",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
11+
"type": "string"
12+
},
13+
"owner_type": {
14+
"description": "Owner type",
15+
"enum": [
16+
"user",
17+
"org"
18+
],
19+
"type": "string"
20+
},
21+
"project_number": {
22+
"description": "The project's number",
23+
"type": "number"
24+
}
25+
},
26+
"required": [
27+
"project_number",
28+
"owner_type",
29+
"owner"
30+
],
31+
"type": "object"
32+
},
33+
"name": "get_project"
34+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"annotations": {
3+
"title": "List project fields",
4+
"readOnlyHint": true
5+
},
6+
"description": "List Project fields for a user or org",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
11+
"type": "string"
12+
},
13+
"owner_type": {
14+
"description": "Owner type",
15+
"enum": [
16+
"user",
17+
"org"
18+
],
19+
"type": "string"
20+
},
21+
"per_page": {
22+
"description": "Number of results per page (max 100, default: 30)",
23+
"type": "number"
24+
},
25+
"projectNumber": {
26+
"description": "The project's number.",
27+
"type": "string"
28+
}
29+
},
30+
"required": [
31+
"owner_type",
32+
"owner",
33+
"projectNumber"
34+
],
35+
"type": "object"
36+
},
37+
"name": "list_project_fields"
38+
}

pkg/github/__toolsnaps__/list_projects.snap

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,18 @@
33
"title": "List projects",
44
"readOnlyHint": true
55
},
6-
"description": "List Projects for a user or organization",
6+
"description": "List Projects for a user or org",
77
"inputSchema": {
88
"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-
},
179
"owner": {
18-
"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.",
10+
"description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
1911
"type": "string"
2012
},
2113
"owner_type": {
2214
"description": "Owner type",
2315
"enum": [
2416
"user",
25-
"organization"
17+
"org"
2618
],
2719
"type": "string"
2820
},

pkg/github/projects.go

Lines changed: 166 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,11 @@ import (
1919

2020
func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
2121
return mcp.NewTool("list_projects",
22-
mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or organization")),
22+
mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")),
2323
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.")),
24+
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
25+
mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")),
2626
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)")),
2927
mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")),
3028
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
3129
owner, err := RequiredParam[string](req, "owner")
@@ -40,16 +38,87 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
4038
if err != nil {
4139
return mcp.NewToolResultError(err.Error()), nil
4240
}
41+
perPage, err := OptionalIntParamWithDefault(req, "per_page", 30)
42+
if err != nil {
43+
return mcp.NewToolResultError(err.Error()), nil
44+
}
45+
client, err := getClient(ctx)
46+
if err != nil {
47+
return mcp.NewToolResultError(err.Error()), nil
48+
}
4349

44-
beforeCursor, err := OptionalParam[string](req, "before")
50+
var url string
51+
if ownerType == "org" {
52+
url = fmt.Sprintf("orgs/%s/projectsV2", owner)
53+
} else {
54+
url = fmt.Sprintf("users/%s/projectsV2", owner)
55+
}
56+
projects := []github.ProjectV2{}
57+
58+
opts := listProjectsOptions{PerPage: perPage}
59+
60+
if queryStr != "" {
61+
opts.Query = queryStr
62+
}
63+
if perPage > 0 {
64+
opts.PerPage = perPage
65+
}
66+
url, err = addOptions(url, opts)
67+
if err != nil {
68+
return nil, fmt.Errorf("failed to add options to request: %w", err)
69+
}
70+
71+
httpRequest, err := client.NewRequest("GET", url, nil)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to create request: %w", err)
74+
}
75+
76+
resp, err := client.Do(ctx, httpRequest, &projects)
77+
if err != nil {
78+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
79+
"failed to list projects",
80+
resp,
81+
err,
82+
), nil
83+
}
84+
defer func() { _ = resp.Body.Close() }()
85+
86+
if resp.StatusCode != http.StatusOK {
87+
body, err := io.ReadAll(resp.Body)
88+
if err != nil {
89+
return nil, fmt.Errorf("failed to read response body: %w", err)
90+
}
91+
return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil
92+
}
93+
r, err := json.Marshal(projects)
94+
if err != nil {
95+
return nil, fmt.Errorf("failed to marshal response: %w", err)
96+
}
97+
98+
return mcp.NewToolResultText(string(r)), nil
99+
}
100+
}
101+
102+
func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
103+
return mcp.NewTool("get_project",
104+
mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")),
105+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), ReadOnlyHint: ToBoolPtr(true)}),
106+
mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number")),
107+
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
108+
mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")),
109+
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
110+
111+
projectNumber, err := RequiredInt(req, "project_number")
45112
if err != nil {
46113
return mcp.NewToolResultError(err.Error()), nil
47114
}
48-
afterCursor, err := OptionalParam[string](req, "after")
115+
116+
owner, err := RequiredParam[string](req, "owner")
49117
if err != nil {
50118
return mcp.NewToolResultError(err.Error()), nil
51119
}
52-
perPage, err := OptionalIntParamWithDefault(req, "per_page", 30)
120+
121+
ownerType, err := RequiredParam[string](req, "owner_type")
53122
if err != nil {
54123
return mcp.NewToolResultError(err.Error()), nil
55124
}
@@ -60,22 +129,87 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
60129
}
61130

62131
var url string
63-
if ownerType == "organization" {
64-
url = fmt.Sprintf("/orgs/%s/projectsV2", owner)
132+
if ownerType == "org" {
133+
url = fmt.Sprintf("orgs/%s/projectsV2/%d", owner, projectNumber)
65134
} else {
66-
url = fmt.Sprintf("/users/%s/projectsV2", owner)
135+
url = fmt.Sprintf("users/%s/projectsV2/%d", owner, projectNumber)
67136
}
68-
projects := []github.ProjectV2{}
69137

70-
opts := ListProjectsOptions{PerPage: perPage}
71-
if afterCursor != "" {
72-
opts.After = afterCursor
138+
project := github.ProjectV2{}
139+
140+
httpRequest, err := client.NewRequest("GET", url, nil)
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to create request: %w", err)
73143
}
74-
if beforeCursor != "" {
75-
opts.Before = beforeCursor
144+
145+
resp, err := client.Do(ctx, httpRequest, &project)
146+
if err != nil {
147+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
148+
"failed to get project",
149+
resp,
150+
err,
151+
), nil
76152
}
77-
if queryStr != "" {
78-
opts.Query = queryStr
153+
defer func() { _ = resp.Body.Close() }()
154+
155+
if resp.StatusCode != http.StatusOK {
156+
body, err := io.ReadAll(resp.Body)
157+
if err != nil {
158+
return nil, fmt.Errorf("failed to read response body: %w", err)
159+
}
160+
return mcp.NewToolResultError(fmt.Sprintf("failed to get project: %s", string(body))), nil
161+
}
162+
r, err := json.Marshal(project)
163+
if err != nil {
164+
return nil, fmt.Errorf("failed to marshal response: %w", err)
165+
}
166+
167+
return mcp.NewToolResultText(string(r)), nil
168+
}
169+
}
170+
171+
func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
172+
return mcp.NewTool("list_project_fields",
173+
mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")),
174+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), ReadOnlyHint: ToBoolPtr(true)}),
175+
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
176+
mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")),
177+
mcp.WithString("projectNumber", mcp.Required(), mcp.Description("The project's number.")),
178+
mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")),
179+
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
180+
owner, err := RequiredParam[string](req, "owner")
181+
if err != nil {
182+
return mcp.NewToolResultError(err.Error()), nil
183+
}
184+
ownerType, err := RequiredParam[string](req, "owner_type")
185+
if err != nil {
186+
return mcp.NewToolResultError(err.Error()), nil
187+
}
188+
projectNumber, err := RequiredParam[string](req, "projectNumber")
189+
if err != nil {
190+
return mcp.NewToolResultError(err.Error()), nil
191+
}
192+
perPage, err := OptionalIntParamWithDefault(req, "per_page", 30)
193+
if err != nil {
194+
return mcp.NewToolResultError(err.Error()), nil
195+
}
196+
client, err := getClient(ctx)
197+
if err != nil {
198+
return mcp.NewToolResultError(err.Error()), nil
199+
}
200+
201+
var url string
202+
if ownerType == "org" {
203+
url = fmt.Sprintf("orgs/%s/projectsV2/%s/fields", owner, projectNumber)
204+
} else {
205+
url = fmt.Sprintf("users/%s/projectsV2/%s/fields", owner, projectNumber)
206+
}
207+
projectFields := []projectV2Field{}
208+
209+
opts := listProjectsOptions{PerPage: perPage}
210+
211+
if perPage > 0 {
212+
opts.PerPage = perPage
79213
}
80214
url, err = addOptions(url, opts)
81215
if err != nil {
@@ -87,7 +221,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
87221
return nil, fmt.Errorf("failed to create request: %w", err)
88222
}
89223

90-
resp, err := client.Do(ctx, httpRequest, &projects)
224+
resp, err := client.Do(ctx, httpRequest, &projectFields)
91225
if err != nil {
92226
return ghErrors.NewGitHubAPIErrorResponse(ctx,
93227
"failed to list projects",
@@ -104,7 +238,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
104238
}
105239
return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil
106240
}
107-
r, err := json.Marshal(projects)
241+
r, err := json.Marshal(projectFields)
108242
if err != nil {
109243
return nil, fmt.Errorf("failed to marshal response: %w", err)
110244
}
@@ -113,13 +247,18 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
113247
}
114248
}
115249

116-
type ListProjectsOptions struct {
117-
// A cursor, as given in the Link header. If specified, the query only searches for events before this cursor.
118-
Before string `url:"before,omitempty"`
119-
120-
// A cursor, as given in the Link header. If specified, the query only searches for events after this cursor.
121-
After string `url:"after,omitempty"`
250+
type projectV2Field struct {
251+
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
252+
NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field.
253+
Name string `json:"name,omitempty"` // The display name of the field.
254+
DataType string `json:"dataType,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select").
255+
URL string `json:"url,omitempty"` // The API URL for this field.
256+
Options []*any `json:"options,omitempty"` // Available options for single_select and multi_select fields.
257+
CreatedAt *github.Timestamp `json:"created_at,omitempty"` // The time when this field was created.
258+
UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated.
259+
}
122260

261+
type listProjectsOptions struct {
123262
// For paginated result sets, the number of results to include per page.
124263
PerPage int `url:"per_page,omitempty"`
125264

0 commit comments

Comments
 (0)