Skip to content

Commit 3d3cc20

Browse files
authored
Add get_project tool
1 parent 23630b3 commit 3d3cc20

File tree

5 files changed

+245
-0
lines changed

5 files changed

+245
-0
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,11 @@ 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 == organization 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+
661666
- **list_projects** - List projects
662667
- `after`: Cursor for items after (forward pagination) (string, optional)
663668
- `before`: Cursor for items before (backwards pagination) (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 organization",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"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.",
11+
"type": "string"
12+
},
13+
"owner_type": {
14+
"description": "Owner type",
15+
"enum": [
16+
"user",
17+
"organization"
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+
}

pkg/github/projects.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,75 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (
113113
}
114114
}
115115

116+
func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
117+
return mcp.NewTool("get_project",
118+
mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or organization")),
119+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), ReadOnlyHint: ToBoolPtr(true)}),
120+
mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number")),
121+
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "organization")),
122+
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.")),
123+
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
124+
125+
projectNumber, err := RequiredInt(req, "project_number")
126+
if err != nil {
127+
return mcp.NewToolResultError(err.Error()), nil
128+
}
129+
130+
owner, err := RequiredParam[string](req, "owner")
131+
if err != nil {
132+
return mcp.NewToolResultError(err.Error()), nil
133+
}
134+
135+
ownerType, err := RequiredParam[string](req, "owner_type")
136+
if err != nil {
137+
return mcp.NewToolResultError(err.Error()), nil
138+
}
139+
140+
client, err := getClient(ctx)
141+
if err != nil {
142+
return mcp.NewToolResultError(err.Error()), nil
143+
}
144+
145+
var url string
146+
if ownerType == "organization" {
147+
url = fmt.Sprintf("orgs/%s/projectsV2/%d", owner, projectNumber)
148+
} else {
149+
url = fmt.Sprintf("/users/%s/projectsV2/%d", owner, projectNumber)
150+
}
151+
152+
projects := []github.ProjectV2{}
153+
154+
httpRequest, err := client.NewRequest("GET", url, nil)
155+
if err != nil {
156+
return nil, fmt.Errorf("failed to create request: %w", err)
157+
}
158+
159+
resp, err := client.Do(ctx, httpRequest, &projects)
160+
if err != nil {
161+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
162+
"failed to get project",
163+
resp,
164+
err,
165+
), nil
166+
}
167+
defer func() { _ = resp.Body.Close() }()
168+
169+
if resp.StatusCode != http.StatusOK {
170+
body, err := io.ReadAll(resp.Body)
171+
if err != nil {
172+
return nil, fmt.Errorf("failed to read response body: %w", err)
173+
}
174+
return mcp.NewToolResultError(fmt.Sprintf("failed to get project: %s", string(body))), nil
175+
}
176+
r, err := json.Marshal(projects)
177+
if err != nil {
178+
return nil, fmt.Errorf("failed to marshal response: %w", err)
179+
}
180+
181+
return mcp.NewToolResultText(string(r)), nil
182+
}
183+
}
184+
116185
type ListProjectsOptions struct {
117186
// A cursor, as given in the Link header. If specified, the query only searches for events before this cursor.
118187
Before string `url:"before,omitempty"`

pkg/github/projects_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,139 @@ func Test_ListProjects(t *testing.T) {
166166
})
167167
}
168168
}
169+
170+
func Test_GetProject(t *testing.T) {
171+
mockClient := gh.NewClient(nil)
172+
tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper)
173+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
174+
175+
assert.Equal(t, "get_project", tool.Name)
176+
assert.NotEmpty(t, tool.Description)
177+
assert.Contains(t, tool.InputSchema.Properties, "project_number")
178+
assert.Contains(t, tool.InputSchema.Properties, "owner")
179+
assert.Contains(t, tool.InputSchema.Properties, "owner_type")
180+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"})
181+
182+
// Minimal project object for response array
183+
project := []map[string]any{{"id": 123, "title": "Project Title"}}
184+
185+
tests := []struct {
186+
name string
187+
mockedClient *http.Client
188+
requestArgs map[string]interface{}
189+
expectError bool
190+
expectedLength int
191+
expectedErrMsg string
192+
}{
193+
{
194+
name: "success organization project fetch",
195+
mockedClient: mock.NewMockedHTTPClient(
196+
mock.WithRequestMatchHandler(
197+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet},
198+
mockResponse(t, http.StatusOK, project),
199+
),
200+
),
201+
requestArgs: map[string]interface{}{
202+
"project_number": float64(123),
203+
"owner": "octo-org",
204+
"owner_type": "organization",
205+
},
206+
expectError: false,
207+
expectedLength: 1,
208+
},
209+
{
210+
name: "success user project fetch",
211+
mockedClient: mock.NewMockedHTTPClient(
212+
mock.WithRequestMatchHandler(
213+
mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet},
214+
mockResponse(t, http.StatusOK, project),
215+
),
216+
),
217+
requestArgs: map[string]interface{}{
218+
"project_number": float64(456),
219+
"owner": "octocat",
220+
"owner_type": "user",
221+
},
222+
expectError: false,
223+
expectedLength: 1,
224+
},
225+
{
226+
name: "api error",
227+
mockedClient: mock.NewMockedHTTPClient(
228+
mock.WithRequestMatchHandler(
229+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet},
230+
mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
231+
),
232+
),
233+
requestArgs: map[string]interface{}{
234+
"project_number": float64(999),
235+
"owner": "octo-org",
236+
"owner_type": "organization",
237+
},
238+
expectError: true,
239+
expectedErrMsg: "failed to get project", // updated to match implementation
240+
},
241+
{
242+
name: "missing project_number",
243+
mockedClient: mock.NewMockedHTTPClient(),
244+
requestArgs: map[string]interface{}{
245+
"owner": "octo-org",
246+
"owner_type": "organization",
247+
},
248+
expectError: true,
249+
},
250+
{
251+
name: "missing owner",
252+
mockedClient: mock.NewMockedHTTPClient(),
253+
requestArgs: map[string]interface{}{
254+
"project_number": float64(123),
255+
"owner_type": "organization",
256+
},
257+
expectError: true,
258+
},
259+
{
260+
name: "missing owner_type",
261+
mockedClient: mock.NewMockedHTTPClient(),
262+
requestArgs: map[string]interface{}{
263+
"project_number": float64(123),
264+
"owner": "octo-org",
265+
},
266+
expectError: true,
267+
},
268+
}
269+
270+
for _, tc := range tests {
271+
t.Run(tc.name, func(t *testing.T) {
272+
client := gh.NewClient(tc.mockedClient)
273+
_, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper)
274+
request := createMCPRequest(tc.requestArgs)
275+
result, err := handler(context.Background(), request)
276+
277+
require.NoError(t, err)
278+
if tc.expectError {
279+
require.True(t, result.IsError)
280+
text := getTextResult(t, result).Text
281+
if tc.expectedErrMsg != "" {
282+
assert.Contains(t, text, tc.expectedErrMsg)
283+
}
284+
if tc.name == "missing project_number" {
285+
assert.Contains(t, text, "missing required parameter: project_number")
286+
}
287+
if tc.name == "missing owner" {
288+
assert.Contains(t, text, "missing required parameter: owner")
289+
}
290+
if tc.name == "missing owner_type" {
291+
assert.Contains(t, text, "missing required parameter: owner_type")
292+
}
293+
return
294+
}
295+
296+
require.False(t, result.IsError)
297+
textContent := getTextResult(t, result)
298+
var arr []map[string]any
299+
err = json.Unmarshal([]byte(textContent.Text), &arr)
300+
require.NoError(t, err)
301+
assert.Equal(t, tc.expectedLength, len(arr))
302+
})
303+
}
304+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
193193
projects := toolsets.NewToolset("projects", "GitHub Projects related tools").
194194
AddReadTools(
195195
toolsets.NewServerTool(ListProjects(getClient, t)),
196+
toolsets.NewServerTool(GetProject(getClient, t)),
196197
)
197198

198199
// Add toolsets to the group

0 commit comments

Comments
 (0)