From 5b25f550d388d92161db9a03049572702c8f4005 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Fri, 26 Sep 2025 12:57:57 +0000 Subject: [PATCH 1/8] Add get project fields tool --- README.md | 2 +- .../__toolsnaps__/get_project_field.snap | 43 +++++ .../__toolsnaps__/list_project_fields.snap | 2 +- pkg/github/projects.go | 72 +++++++- pkg/github/projects_test.go | 157 ++++++++++++++++++ 5 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 pkg/github/__toolsnaps__/get_project_field.snap diff --git a/README.md b/README.md index 3b0cd861f..4a5bcc86c 100644 --- a/README.md +++ b/README.md @@ -667,7 +667,7 @@ The following sets of tools are available (all are on by default): - `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) - `owner_type`: Owner type (string, required) - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - - `projectNumber`: The project's number. (string, required) + - `projectNumber`: The project's number. (number, required) - **list_projects** - List projects - `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) diff --git a/pkg/github/__toolsnaps__/get_project_field.snap b/pkg/github/__toolsnaps__/get_project_field.snap new file mode 100644 index 000000000..ff17d83c3 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_project_field.snap @@ -0,0 +1,43 @@ +{ + "annotations": { + "title": "Get project field", + "readOnlyHint": true + }, + "description": "Get Project field for a user or org", + "inputSchema": { + "properties": { + "field_id": { + "description": "The field's id.", + "type": "number" + }, + "owner": { + "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.", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "per_page": { + "description": "Number of results per page (max 100, default: 30)", + "type": "number" + }, + "projectNumber": { + "description": "The project's number.", + "type": "number" + } + }, + "required": [ + "owner_type", + "owner", + "projectNumber", + "field_id" + ], + "type": "object" + }, + "name": "get_project_field" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap index 3a293463e..818be6460 100644 --- a/pkg/github/__toolsnaps__/list_project_fields.snap +++ b/pkg/github/__toolsnaps__/list_project_fields.snap @@ -24,7 +24,7 @@ }, "projectNumber": { "description": "The project's number.", - "type": "string" + "type": "number" } }, "required": [ diff --git a/pkg/github/projects.go b/pkg/github/projects.go index d4ab48844..d4b63880f 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -174,7 +174,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), ReadOnlyHint: ToBoolPtr(true)}), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), 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.")), - mcp.WithString("projectNumber", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("projectNumber", mcp.Required(), mcp.Description("The project's number.")), 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") @@ -247,6 +247,76 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu } } +func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_project_field", + mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), + 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.")), + mcp.WithNumber("projectNumber", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("field_id", mcp.Required(), mcp.Description("The field's id.")), + 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 + } + projectNumber, err := RequiredParam[int64](req, "projectNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fieldID, err := RequiredParam[int64](req, "field_id") + 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 == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) + } + projectField := projectV2Field{} + + 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, &projectField) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project field", + 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 get project field: %s", string(body))), nil + } + r, err := json.Marshal(projectField) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + type projectV2Field struct { ID *int64 `json:"id,omitempty"` // The unique identifier for this field. NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field. diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 1ea19d18b..059e04c38 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -439,3 +439,160 @@ func Test_ListProjectFields(t *testing.T) { }) } } + +func Test_GetProjectField(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_project_field", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "projectNumber") + assert.Contains(t, tool.InputSchema.Properties, "field_id") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "projectNumber", "field_id"}) + + orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"} + userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + }{ + { + name: "success organization field", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgField), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "projectNumber": int64(123), + "field_id": int64(101), + }, + expectedID: 101, + }, + { + name: "success user field", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userField), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "projectNumber": int64(456), + "field_id": int64(202), + }, + expectedID: 202, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "projectNumber": int64(789), + "field_id": int64(303), + }, + expectError: true, + expectedErrMsg: "failed to get project field", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "projectNumber": int64(10), + "field_id": int64(1), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "projectNumber": int64(10), + "field_id": int64(1), + }, + expectError: true, + }, + { + name: "missing projectNumber", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "field_id": int64(1), + }, + expectError: true, + }, + { + name: "missing field_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "projectNumber": int64(10), + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := GetProjectField(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) + } + 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") + } + if tc.name == "missing projectNumber" { + assert.Contains(t, text, "missing required parameter: projectNumber") + } + if tc.name == "missing field_id" { + assert.Contains(t, text, "missing required parameter: field_id") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var field map[string]any + err = json.Unmarshal([]byte(textContent.Text), &field) + require.NoError(t, err) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), field["id"]) + } + }) + } +} From e76e309698ab7f32c917e762899fdd2dfc9fc3a4 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Fri, 26 Sep 2025 13:22:33 +0000 Subject: [PATCH 2/8] Data types --- pkg/github/projects.go | 22 +++++++++++++++++----- pkg/github/projects_test.go | 22 +++++++++++----------- pkg/github/tools.go | 1 + 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index d4b63880f..cad79cf84 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -185,7 +185,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu if err != nil { return mcp.NewToolResultError(err.Error()), nil } - projectNumber, err := RequiredParam[string](req, "projectNumber") + projectNumber, err := RequiredParam[int](req, "projectNumber") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -200,14 +200,13 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu var url string if ownerType == "org" { - url = fmt.Sprintf("orgs/%s/projectsV2/%s/fields", owner, projectNumber) + url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber) } else { - url = fmt.Sprintf("users/%s/projectsV2/%s/fields", owner, projectNumber) + url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber) } projectFields := []projectV2Field{} opts := listProjectsOptions{PerPage: perPage} - if perPage > 0 { opts.PerPage = perPage } @@ -265,7 +264,7 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc if err != nil { return mcp.NewToolResultError(err.Error()), nil } - projectNumber, err := RequiredParam[int64](req, "projectNumber") + projectNumber, err := RequiredParam[int](req, "projectNumber") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -273,6 +272,10 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc 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 @@ -284,6 +287,15 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc } else { url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) } + + opts := listProjectsOptions{PerPage: perPage} + if perPage > 0 { + opts.PerPage = perPage + } + url, err = addOptions(url, opts) + if err != nil { + return nil, fmt.Errorf("failed to add options to request: %w", err) + } projectField := projectV2Field{} httpRequest, err := client.NewRequest("GET", url, nil) diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 059e04c38..b0ec94949 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -330,7 +330,7 @@ func Test_ListProjectFields(t *testing.T) { requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", - "projectNumber": "123", + "projectNumber": 123, }, expectedLength: 1, }, @@ -354,7 +354,7 @@ func Test_ListProjectFields(t *testing.T) { requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", - "projectNumber": "456", + "projectNumber": 456, "per_page": float64(50), }, expectedLength: 1, @@ -370,7 +370,7 @@ func Test_ListProjectFields(t *testing.T) { requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", - "projectNumber": "789", + "projectNumber": 789, }, expectError: true, expectedErrMsg: "failed to list projects", @@ -380,7 +380,7 @@ func Test_ListProjectFields(t *testing.T) { mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner_type": "org", - "projectNumber": "10", + "projectNumber": 10, }, expectError: true, }, @@ -389,7 +389,7 @@ func Test_ListProjectFields(t *testing.T) { mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "octo-org", - "projectNumber": "10", + "projectNumber": 10, }, expectError: true, }, @@ -476,7 +476,7 @@ func Test_GetProjectField(t *testing.T) { requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", - "projectNumber": int64(123), + "projectNumber": 123, "field_id": int64(101), }, expectedID: 101, @@ -492,7 +492,7 @@ func Test_GetProjectField(t *testing.T) { requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", - "projectNumber": int64(456), + "projectNumber": 456, "field_id": int64(202), }, expectedID: 202, @@ -508,7 +508,7 @@ func Test_GetProjectField(t *testing.T) { requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", - "projectNumber": int64(789), + "projectNumber": 789, "field_id": int64(303), }, expectError: true, @@ -519,7 +519,7 @@ func Test_GetProjectField(t *testing.T) { mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner_type": "org", - "projectNumber": int64(10), + "projectNumber": 10, "field_id": int64(1), }, expectError: true, @@ -529,7 +529,7 @@ func Test_GetProjectField(t *testing.T) { mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "octo-org", - "projectNumber": int64(10), + "projectNumber": 10, "field_id": int64(1), }, expectError: true, @@ -550,7 +550,7 @@ func Test_GetProjectField(t *testing.T) { requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", - "projectNumber": int64(10), + "projectNumber": 10, }, expectError: true, }, diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 2de9c23ca..0cb1e04cb 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -195,6 +195,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListProjects(getClient, t)), toolsets.NewServerTool(GetProject(getClient, t)), toolsets.NewServerTool(ListProjectFields(getClient, t)), + toolsets.NewServerTool(GetProjectField(getClient, t)), ) // Add toolsets to the group From fe1dab5105cd5eddb592ac4856c64a99285fd7a0 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Fri, 26 Sep 2025 13:42:46 +0000 Subject: [PATCH 3/8] Docs --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 4a5bcc86c..e1968f999 100644 --- a/README.md +++ b/README.md @@ -663,6 +663,13 @@ The following sets of tools are available (all are on by default): - `owner_type`: Owner type (string, required) - `project_number`: The project's number (number, required) +- **get_project_field** - Get project field + - `field_id`: The field's id. (number, required) + - `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) + - `owner_type`: Owner type (string, required) + - `per_page`: Number of results per page (max 100, default: 30) (number, optional) + - `projectNumber`: The project's number. (number, required) + - **list_project_fields** - List project fields - `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) - `owner_type`: Owner type (string, required) From 822c9303b83e812b4c98a6171c6f123161091cb9 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Fri, 26 Sep 2025 15:01:21 +0000 Subject: [PATCH 4/8] Update projectNumber's type --- README.md | 4 +- .../__toolsnaps__/get_project_field.snap | 4 +- .../__toolsnaps__/list_project_fields.snap | 4 +- pkg/github/projects.go | 10 +- pkg/github/projects_test.go | 92 +++++++++---------- 5 files changed, 57 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index e1968f999..96e495258 100644 --- a/README.md +++ b/README.md @@ -668,13 +668,13 @@ The following sets of tools are available (all are on by default): - `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) - `owner_type`: Owner type (string, required) - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - - `projectNumber`: The project's number. (number, required) + - `project_number`: The project's number. (number, required) - **list_project_fields** - List project fields - `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) - `owner_type`: Owner type (string, required) - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - - `projectNumber`: The project's number. (number, required) + - `project_number`: The project's number. (number, required) - **list_projects** - List projects - `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) diff --git a/pkg/github/__toolsnaps__/get_project_field.snap b/pkg/github/__toolsnaps__/get_project_field.snap index ff17d83c3..fc58c8180 100644 --- a/pkg/github/__toolsnaps__/get_project_field.snap +++ b/pkg/github/__toolsnaps__/get_project_field.snap @@ -26,7 +26,7 @@ "description": "Number of results per page (max 100, default: 30)", "type": "number" }, - "projectNumber": { + "project_number": { "description": "The project's number.", "type": "number" } @@ -34,7 +34,7 @@ "required": [ "owner_type", "owner", - "projectNumber", + "project_number", "field_id" ], "type": "object" diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap index 818be6460..0a2180e2b 100644 --- a/pkg/github/__toolsnaps__/list_project_fields.snap +++ b/pkg/github/__toolsnaps__/list_project_fields.snap @@ -22,7 +22,7 @@ "description": "Number of results per page (max 100, default: 30)", "type": "number" }, - "projectNumber": { + "project_number": { "description": "The project's number.", "type": "number" } @@ -30,7 +30,7 @@ "required": [ "owner_type", "owner", - "projectNumber" + "project_number" ], "type": "object" }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index cad79cf84..8a5148c7a 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -174,7 +174,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), ReadOnlyHint: ToBoolPtr(true)}), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), 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.")), - mcp.WithNumber("projectNumber", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), 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") @@ -185,7 +185,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu if err != nil { return mcp.NewToolResultError(err.Error()), nil } - projectNumber, err := RequiredParam[int](req, "projectNumber") + projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -252,7 +252,7 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), ReadOnlyHint: ToBoolPtr(true)}), mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), 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.")), - mcp.WithNumber("projectNumber", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), mcp.WithNumber("field_id", mcp.Required(), mcp.Description("The field's id.")), 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) { @@ -264,11 +264,11 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc if err != nil { return mcp.NewToolResultError(err.Error()), nil } - projectNumber, err := RequiredParam[int](req, "projectNumber") + projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - fieldID, err := RequiredParam[int64](req, "field_id") + fieldID, err := RequiredInt(req, "field_id") if err != nil { return mcp.NewToolResultError(err.Error()), nil } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index b0ec94949..fdd01632d 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -300,9 +300,9 @@ func Test_ListProjectFields(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner_type") assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "projectNumber") + assert.Contains(t, tool.InputSchema.Properties, "project_number") assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "projectNumber"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) orgFields := []map[string]any{ {"id": 101, "name": "Status", "dataType": "single_select"}, @@ -328,9 +328,9 @@ func Test_ListProjectFields(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "projectNumber": 123, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), }, expectedLength: 1, }, @@ -352,10 +352,10 @@ func Test_ListProjectFields(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - "projectNumber": 456, - "per_page": float64(50), + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "per_page": float64(50), }, expectedLength: 1, }, @@ -368,9 +368,9 @@ func Test_ListProjectFields(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "projectNumber": 789, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), }, expectError: true, expectedErrMsg: "failed to list projects", @@ -379,8 +379,8 @@ func Test_ListProjectFields(t *testing.T) { name: "missing owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ - "owner_type": "org", - "projectNumber": 10, + "owner_type": "org", + "project_number": 10, }, expectError: true, }, @@ -388,13 +388,13 @@ func Test_ListProjectFields(t *testing.T) { name: "missing owner_type", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "projectNumber": 10, + "owner": "octo-org", + "project_number": 10, }, expectError: true, }, { - name: "missing projectNumber", + name: "missing project_number", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "octo-org", @@ -424,8 +424,8 @@ func Test_ListProjectFields(t *testing.T) { if tc.name == "missing owner_type" { assert.Contains(t, text, "missing required parameter: owner_type") } - if tc.name == "missing projectNumber" { - assert.Contains(t, text, "missing required parameter: projectNumber") + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") } return } @@ -449,10 +449,10 @@ func Test_GetProjectField(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.Properties, "owner_type") assert.Contains(t, tool.InputSchema.Properties, "owner") - assert.Contains(t, tool.InputSchema.Properties, "projectNumber") + assert.Contains(t, tool.InputSchema.Properties, "project_number") assert.Contains(t, tool.InputSchema.Properties, "field_id") assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "projectNumber", "field_id"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "field_id"}) orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"} userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"} @@ -474,10 +474,10 @@ func Test_GetProjectField(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "projectNumber": 123, - "field_id": int64(101), + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "field_id": float64(101), }, expectedID: 101, }, @@ -490,10 +490,10 @@ func Test_GetProjectField(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "octocat", - "owner_type": "user", - "projectNumber": 456, - "field_id": int64(202), + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "field_id": float64(202), }, expectedID: 202, }, @@ -506,10 +506,10 @@ func Test_GetProjectField(t *testing.T) { ), ), requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "projectNumber": 789, - "field_id": int64(303), + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + "field_id": float64(303), }, expectError: true, expectedErrMsg: "failed to get project field", @@ -518,9 +518,9 @@ func Test_GetProjectField(t *testing.T) { name: "missing owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner_type": "org", - "projectNumber": 10, - "field_id": int64(1), + "owner_type": "org", + "project_number": float64(10), + "field_id": float64(1), }, expectError: true, }, @@ -528,19 +528,19 @@ func Test_GetProjectField(t *testing.T) { name: "missing owner_type", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "octo-org", - "projectNumber": 10, - "field_id": int64(1), + "owner": "octo-org", + "project_number": float64(10), + "field_id": float64(1), }, expectError: true, }, { - name: "missing projectNumber", + name: "missing project_number", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", - "field_id": int64(1), + "field_id": float64(1), }, expectError: true, }, @@ -548,9 +548,9 @@ func Test_GetProjectField(t *testing.T) { name: "missing field_id", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "projectNumber": 10, + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(10), }, expectError: true, }, @@ -576,8 +576,8 @@ func Test_GetProjectField(t *testing.T) { if tc.name == "missing owner_type" { assert.Contains(t, text, "missing required parameter: owner_type") } - if tc.name == "missing projectNumber" { - assert.Contains(t, text, "missing required parameter: projectNumber") + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") } if tc.name == "missing field_id" { assert.Contains(t, text, "missing required parameter: field_id") From ad5f75ca0d5951c673eac91af8c357b97dc8291f Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Mon, 29 Sep 2025 11:15:54 +0200 Subject: [PATCH 5/8] Add list_project_items tool --- pkg/github/projects.go | 103 ++++++++++++++++++++++++++++++++++++++++- pkg/github/tools.go | 1 + 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 8a5148c7a..f9b46d131 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -34,7 +34,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( if err != nil { return mcp.NewToolResultError(err.Error()), nil } - queryStr, err := OptionalParam[string](req, "query") + queryStr, err := OptionalParam[string](req, "q") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -329,6 +329,92 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc } } +func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_project_items", + mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), + 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.")), + mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithString("query", mcp.Description("Search query to filter items")), + 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 + } + projectNumber, err := RequiredInt(req, "project_number") + 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 + } + queryStr, err := OptionalParam[string](req, "q") + 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 == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) + } + projectItems := []projectV2Item{} + + opts := listProjectsOptions{PerPage: perPage} + if queryStr != "" { + opts.Query = queryStr + } + if perPage > 0 { + opts.PerPage = perPage + } + 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, &projectItems) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project items", + 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(projectItems) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + type projectV2Field struct { ID *int64 `json:"id,omitempty"` // The unique identifier for this field. NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field. @@ -340,6 +426,21 @@ type projectV2Field struct { UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated. } +type projectV2Item struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + ProjectNodeID *string `json:"project_node_id,omitempty"` + ContentNodeID *string `json:"content_node_id,omitempty"` + ProjectURL *string `json:"project_url,omitempty"` + ContentType *string `json:"content_type,omitempty"` + Creator *github.User `json:"creator,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` + ItemURL *string `json:"item_url,omitempty"` + Fields []*projectV2Field `json:"fields,omitempty"` +} + type listProjectsOptions struct { // For paginated result sets, the number of results to include per page. PerPage int `url:"per_page,omitempty"` diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0cb1e04cb..b78f7cc73 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -196,6 +196,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetProject(getClient, t)), toolsets.NewServerTool(ListProjectFields(getClient, t)), toolsets.NewServerTool(GetProjectField(getClient, t)), + toolsets.NewServerTool(ListProjectItems(getClient, t)), ) // Add toolsets to the group From 3521c3592dd07cf266a673cdc5ebdf5b98e166ec Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Mon, 29 Sep 2025 11:23:36 +0200 Subject: [PATCH 6/8] Add get_project_item tool --- README.md | 13 + .../__toolsnaps__/get_project_item.snap | 39 ++ .../__toolsnaps__/list_project_items.snap | 42 +++ pkg/github/projects.go | 76 +++- pkg/github/projects_test.go | 337 +++++++++++++++++- pkg/github/tools.go | 1 + 6 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 pkg/github/__toolsnaps__/get_project_item.snap create mode 100644 pkg/github/__toolsnaps__/list_project_items.snap diff --git a/README.md b/README.md index 96e495258..82572449c 100644 --- a/README.md +++ b/README.md @@ -670,12 +670,25 @@ The following sets of tools are available (all are on by default): - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - `project_number`: The project's number. (number, required) +- **get_project_item** - Get project item + - `item_id`: The item's ID. (number, required) + - `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) + - `owner_type`: Owner type (string, required) + - `project_number`: The project's number. (number, required) + - **list_project_fields** - List project fields - `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) - `owner_type`: Owner type (string, required) - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - `project_number`: The project's number. (number, required) +- **list_project_items** - List project items + - `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) + - `owner_type`: Owner type (string, required) + - `per_page`: Number of results per page (max 100, default: 30) (number, optional) + - `project_number`: The project's number. (number, required) + - `query`: Search query to filter items (string, optional) + - **list_projects** - List projects - `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) - `owner_type`: Owner type (string, required) diff --git a/pkg/github/__toolsnaps__/get_project_item.snap b/pkg/github/__toolsnaps__/get_project_item.snap new file mode 100644 index 000000000..6f8f60935 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_project_item.snap @@ -0,0 +1,39 @@ +{ + "annotations": { + "title": "Get project item", + "readOnlyHint": true + }, + "description": "Get a specific Project item for a user or org", + "inputSchema": { + "properties": { + "item_id": { + "description": "The item's ID.", + "type": "number" + }, + "owner": { + "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.", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "project_number": { + "description": "The project's number.", + "type": "number" + } + }, + "required": [ + "owner_type", + "owner", + "project_number", + "item_id" + ], + "type": "object" + }, + "name": "get_project_item" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap new file mode 100644 index 000000000..09b3267f0 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_project_items.snap @@ -0,0 +1,42 @@ +{ + "annotations": { + "title": "List project items", + "readOnlyHint": true + }, + "description": "List Project items for a user or org", + "inputSchema": { + "properties": { + "owner": { + "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.", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "per_page": { + "description": "Number of results per page (max 100, default: 30)", + "type": "number" + }, + "project_number": { + "description": "The project's number.", + "type": "number" + }, + "query": { + "description": "Search query to filter items", + "type": "string" + } + }, + "required": [ + "owner_type", + "owner", + "project_number" + ], + "type": "object" + }, + "name": "list_project_items" +} \ No newline at end of file diff --git a/pkg/github/projects.go b/pkg/github/projects.go index f9b46d131..14ecaba59 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -223,7 +223,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu resp, err := client.Do(ctx, httpRequest, &projectFields) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", + "failed to list project fields", resp, err, ), nil @@ -235,7 +235,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu 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 + return mcp.NewToolResultError(fmt.Sprintf("failed to list project fields: %s", string(body))), nil } r, err := json.Marshal(projectFields) if err != nil { @@ -404,7 +404,7 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun 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 + return mcp.NewToolResultError(fmt.Sprintf("failed to list project items: %s", string(body))), nil } r, err := json.Marshal(projectItems) if err != nil { @@ -415,6 +415,76 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun } } +func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_project_item", + mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), + 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.")), + mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The item's ID.")), + ), 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 + } + projectNumber, err := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredInt(req, "item_id") + 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 == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + projectItem := projectV2Item{} + + 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, &projectItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + 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 get project item: %s", string(body))), nil + } + r, err := json.Marshal(projectItem) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + type projectV2Field struct { ID *int64 `json:"id,omitempty"` // The unique identifier for this field. NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field. diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index fdd01632d..cc9f73567 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -89,7 +89,7 @@ func Test_ListProjects(t *testing.T) { "owner": "octo-org", "owner_type": "org", "per_page": float64(50), - "query": "roadmap", + "q": "roadmap", }, expectError: false, expectedLength: 1, @@ -373,7 +373,7 @@ func Test_ListProjectFields(t *testing.T) { "project_number": float64(789), }, expectError: true, - expectedErrMsg: "failed to list projects", + expectedErrMsg: "failed to list project fields", }, { name: "missing owner", @@ -596,3 +596,336 @@ func Test_GetProjectField(t *testing.T) { }) } } + +func Test_ListProjectItems(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := ListProjectItems(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_project_items", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) + + orgItems := []map[string]any{ + {"id": 301, "content_type": "Issue", "project_node_id": "PR_1"}, + } + userItems := []map[string]any{ + {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, + {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedLength int + expectedErrMsg string + }{ + { + name: "success organization items", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgItems), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + }, + expectedLength: 1, + }, + { + name: "success user items", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userItems), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + }, + expectedLength: 2, + }, + { + name: "success with pagination and query", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("per_page") == "50" && q.Get("q") == "bug" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgItems)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "per_page": float64(50), + "q": "bug", + }, + expectedLength: 1, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + }, + expectError: true, + expectedErrMsg: "failed to list project items", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner_type": "org", + "project_number": float64(10), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "project_number": float64(10), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := ListProjectItems(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) + } + 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") + } + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var items []map[string]any + err = json.Unmarshal([]byte(textContent.Text), &items) + require.NoError(t, err) + assert.Equal(t, tc.expectedLength, len(items)) + }) + } +} + +func Test_GetProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := GetProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_project_item", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) + + orgItem := map[string]any{ + "id": 301, + "content_type": "Issue", + "project_node_id": "PR_1", + "creator": map[string]any{"login": "octocat"}, + } + userItem := map[string]any{ + "id": 501, + "content_type": "PullRequest", + "project_node_id": "PR_2", + "creator": map[string]any{"login": "jane"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + }{ + { + name: "success organization item", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgItem), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "item_id": float64(301), + }, + expectedID: 301, + }, + { + name: "success user item", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userItem), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "item_id": float64(501), + }, + expectedID: 501, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + "item_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get project item", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(10), + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(10), + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(10), + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := GetProjectItem(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) + } + 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") + } + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + if tc.name == "missing item_id" { + assert.Contains(t, text, "missing required parameter: item_id") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var item map[string]any + err = json.Unmarshal([]byte(textContent.Text), &item) + require.NoError(t, err) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), item["id"]) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b78f7cc73..eb6e657e3 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -197,6 +197,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListProjectFields(getClient, t)), toolsets.NewServerTool(GetProjectField(getClient, t)), toolsets.NewServerTool(ListProjectItems(getClient, t)), + toolsets.NewServerTool(GetProjectItem(getClient, t)), ) // Add toolsets to the group From 004edc013ee8ccc6e6781d7500196576fe89be75 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Mon, 29 Sep 2025 14:07:12 +0200 Subject: [PATCH 7/8] Return minimal project --- pkg/github/minimal_types.go | 89 +++++++++++++++++++++++++++++++++++++ pkg/github/projects.go | 23 +++++++--- pkg/github/projects_test.go | 4 +- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 099b87481..59cab6b43 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -114,8 +114,97 @@ type MinimalResponse struct { URL string `json:"url"` } +type MinimalProject struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + Owner *MinimalUser `json:"owner,omitempty"` + Creator *MinimalUser `json:"creator,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Public *bool `json:"public,omitempty"` + ClosedAt *github.Timestamp `json:"closed_at,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + DeletedAt *github.Timestamp `json:"deleted_at,omitempty"` + Number *int `json:"number,omitempty"` + ShortDescription *string `json:"short_description,omitempty"` + DeletedBy *MinimalUser `json:"deleted_by,omitempty"` +} + +type MinimalProjectItem struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + ProjectNodeID *string `json:"project_node_id,omitempty"` + ContentNodeID *string `json:"content_node_id,omitempty"` + ProjectURL *string `json:"project_url,omitempty"` + ContentType *string `json:"content_type,omitempty"` + Creator *MinimalUser `json:"creator,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` + ItemURL *string `json:"item_url,omitempty"` + Fields []*projectV2Field `json:"fields,omitempty"` +} + // Helper functions +func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { + if fullProject == nil { + return nil + } + + return &MinimalProject{ + ID: github.Ptr(fullProject.GetID()), + NodeID: github.Ptr(fullProject.GetNodeID()), + Owner: convertToMinimalUser(fullProject.GetOwner()), + Creator: convertToMinimalUser(fullProject.GetCreator()), + Title: github.Ptr(fullProject.GetTitle()), + Description: github.Ptr(fullProject.GetDescription()), + Public: github.Ptr(fullProject.GetPublic()), + ClosedAt: github.Ptr(fullProject.GetClosedAt()), + CreatedAt: github.Ptr(fullProject.GetCreatedAt()), + UpdatedAt: github.Ptr(fullProject.GetUpdatedAt()), + DeletedAt: github.Ptr(fullProject.GetDeletedAt()), + Number: github.Ptr(fullProject.GetNumber()), + ShortDescription: github.Ptr(fullProject.GetShortDescription()), + DeletedBy: convertToMinimalUser(fullProject.GetDeletedBy()), + } +} + +func convertToMinimalUser(user *github.User) *MinimalUser { + if user == nil { + return nil + } + + return &MinimalUser{ + Login: user.GetLogin(), + ID: user.GetID(), + ProfileURL: user.GetHTMLURL(), + AvatarURL: user.GetAvatarURL(), + } +} + +func convertToMinimalProjectItem(item *projectV2Item) *MinimalProjectItem { + if item == nil { + return nil + } + + return &MinimalProjectItem{ + ID: item.ID, + NodeID: item.NodeID, + ProjectNodeID: item.ProjectNodeID, + ContentNodeID: item.ContentNodeID, + ProjectURL: item.ProjectURL, + ContentType: item.ContentType, + Creator: convertToMinimalUser(item.Creator), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + ArchivedAt: item.ArchivedAt, + ItemURL: item.ItemURL, + Fields: item.Fields, + } +} + // convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { minimalCommit := MinimalCommit{ diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 14ecaba59..26cd200b2 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -34,7 +34,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( if err != nil { return mcp.NewToolResultError(err.Error()), nil } - queryStr, err := OptionalParam[string](req, "q") + queryStr, err := OptionalParam[string](req, "query") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -54,6 +54,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( url = fmt.Sprintf("users/%s/projectsV2", owner) } projects := []github.ProjectV2{} + minimalProjects := []MinimalProject{} opts := listProjectsOptions{PerPage: perPage} @@ -83,6 +84,10 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( } defer func() { _ = resp.Body.Close() }() + for _, project := range projects { + minimalProjects = append(minimalProjects, *convertToMinimalProject(&project)) + } + if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { @@ -90,7 +95,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( } return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil } - r, err := json.Marshal(projects) + r, err := json.Marshal(minimalProjects) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -159,7 +164,9 @@ func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (to } return mcp.NewToolResultError(fmt.Sprintf("failed to get project: %s", string(body))), nil } - r, err := json.Marshal(project) + + minimalProject := convertToMinimalProject(&project) + r, err := json.Marshal(minimalProject) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -355,7 +362,7 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } - queryStr, err := OptionalParam[string](req, "q") + queryStr, err := OptionalParam[string](req, "query") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -406,7 +413,11 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun } return mcp.NewToolResultError(fmt.Sprintf("failed to list project items: %s", string(body))), nil } - r, err := json.Marshal(projectItems) + minimalProjectItems := []MinimalProjectItem{} + for _, item := range projectItems { + minimalProjectItems = append(minimalProjectItems, *convertToMinimalProjectItem(&item)) + } + r, err := json.Marshal(minimalProjectItems) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -476,7 +487,7 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) } return mcp.NewToolResultError(fmt.Sprintf("failed to get project item: %s", string(body))), nil } - r, err := json.Marshal(projectItem) + r, err := json.Marshal(convertToMinimalProjectItem(&projectItem)) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index cc9f73567..a2582edb5 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -89,7 +89,7 @@ func Test_ListProjects(t *testing.T) { "owner": "octo-org", "owner_type": "org", "per_page": float64(50), - "q": "roadmap", + "query": "roadmap", }, expectError: false, expectedLength: 1, @@ -679,7 +679,7 @@ func Test_ListProjectItems(t *testing.T) { "owner_type": "org", "project_number": float64(123), "per_page": float64(50), - "q": "bug", + "query": "bug", }, expectedLength: 1, }, From 32d78096931eb6291f8b66be30d54de46daa4995 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Mon, 29 Sep 2025 15:13:35 +0200 Subject: [PATCH 8/8] Remove unused per_page --- README.md | 1 - pkg/github/__toolsnaps__/get_project_field.snap | 4 ---- pkg/github/projects.go | 10 ---------- pkg/github/projects_test.go | 1 - 4 files changed, 16 deletions(-) diff --git a/README.md b/README.md index 82572449c..ff3e44ba9 100644 --- a/README.md +++ b/README.md @@ -667,7 +667,6 @@ The following sets of tools are available (all are on by default): - `field_id`: The field's id. (number, required) - `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) - `owner_type`: Owner type (string, required) - - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - `project_number`: The project's number. (number, required) - **get_project_item** - Get project item diff --git a/pkg/github/__toolsnaps__/get_project_field.snap b/pkg/github/__toolsnaps__/get_project_field.snap index fc58c8180..65d6f86f1 100644 --- a/pkg/github/__toolsnaps__/get_project_field.snap +++ b/pkg/github/__toolsnaps__/get_project_field.snap @@ -22,10 +22,6 @@ ], "type": "string" }, - "per_page": { - "description": "Number of results per page (max 100, default: 30)", - "type": "number" - }, "project_number": { "description": "The project's number.", "type": "number" diff --git a/pkg/github/projects.go b/pkg/github/projects.go index d75709394..f3ea0f7e1 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -255,7 +255,6 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc 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.")), mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), mcp.WithNumber("field_id", mcp.Required(), mcp.Description("The field's id.")), - 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 { @@ -273,10 +272,6 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc 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 @@ -289,11 +284,6 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID) } - opts := listProjectsOptions{PerPage: perPage} - url, err = addOptions(url, opts) - if err != nil { - return nil, fmt.Errorf("failed to add options to request: %w", err) - } projectField := projectV2Field{} httpRequest, err := client.NewRequest("GET", url, nil) diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index a2582edb5..a6225ec86 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -451,7 +451,6 @@ func Test_GetProjectField(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "project_number") assert.Contains(t, tool.InputSchema.Properties, "field_id") - assert.Contains(t, tool.InputSchema.Properties, "per_page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "field_id"}) orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"}