From 3d7100530321f2b34a547d0c0cd7b5b65292917c Mon Sep 17 00:00:00 2001 From: Michael Jacholke <46944669+michaeljacholke@users.noreply.github.com> Date: Thu, 7 May 2026 09:55:26 +0100 Subject: [PATCH 01/12] Add list_org_issue_fields tool --- README.md | 5 + .../__toolsnaps__/list_org_issue_fields.snap | 20 +++ pkg/github/issue_fields.go | 107 +++++++++++++ pkg/github/issue_fields_test.go | 151 ++++++++++++++++++ pkg/github/tools.go | 1 + 5 files changed, 284 insertions(+) create mode 100644 pkg/github/__toolsnaps__/list_org_issue_fields.snap create mode 100644 pkg/github/issue_fields.go create mode 100644 pkg/github/issue_fields_test.go diff --git a/README.md b/README.md index 5f9baa780e..a950710dcd 100644 --- a/README.md +++ b/README.md @@ -873,6 +873,11 @@ The following sets of tools are available: - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) +- **list_org_issue_fields** - List organization issue fields + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` + - `org`: The organization name. The name is not case sensitive. (string, required) + - **search_issues** - Search issues - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) diff --git a/pkg/github/__toolsnaps__/list_org_issue_fields.snap b/pkg/github/__toolsnaps__/list_org_issue_fields.snap new file mode 100644 index 0000000000..c4959b0ec9 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_org_issue_fields.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List organization issue fields" + }, + "description": "List issue fields for an organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names.", + "inputSchema": { + "properties": { + "org": { + "description": "The organization name. The name is not case sensitive.", + "type": "string" + } + }, + "required": [ + "org" + ], + "type": "object" + }, + "name": "list_org_issue_fields" +} \ No newline at end of file diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go new file mode 100644 index 0000000000..2810e391f9 --- /dev/null +++ b/pkg/github/issue_fields.go @@ -0,0 +1,107 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// IssueField represents an organization-level issue field definition. +type IssueField struct { + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DataType string `json:"data_type"` + Options []IssueFieldOption `json:"options,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// IssueFieldOption represents an option for a single_select issue field. +type IssueFieldOption struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// ListOrgIssueFields creates a tool to list issue field definitions for an organization. +func ListOrgIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "list_org_issue_fields", + Description: t("TOOL_LIST_ORG_ISSUE_FIELDS_DESCRIPTION", "List issue fields for an organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_ORG_ISSUE_FIELDS_USER_TITLE", "List organization issue fields"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "org": { + Type: "string", + Description: "The organization name. The name is not case sensitive.", + }, + }, + Required: []string{"org"}, + }, + }, + []scopes.Scope{scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + reqURL := fmt.Sprintf("orgs/%s/issue-fields", org) + req, err := client.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + var fields []*IssueField + resp, err := client.Do(ctx, req, &fields) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + // Org doesn't have issue fields enabled — return empty list + result, marshalErr := json.Marshal([]*IssueField{}) + if marshalErr != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", marshalErr), nil, nil + } + return utils.NewToolResultText(string(result)), nil, nil + } + return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", readErr), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue fields", resp, body), nil, nil + } + + r, err := json.Marshal(fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }) +} diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go new file mode 100644 index 0000000000..9389fc98d5 --- /dev/null +++ b/pkg/github/issue_fields_test.go @@ -0,0 +1,151 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v82/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListOrgIssueFields(t *testing.T) { + // Verify tool definition + serverTool := ListOrgIssueFields(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_org_issue_fields", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "org") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"org"}) + + mockIssueFields := []*IssueField{ + { + ID: 1, + NodeID: "IFT_kwDNAd3NAZo", + Name: "DRI", + Description: "Directly responsible individual", + DataType: "text", + CreatedAt: "2024-12-11T14:39:09Z", + UpdatedAt: "2024-12-11T14:39:09Z", + }, + { + ID: 2, + NodeID: "IFSS_kwDNAd3NAZs", + Name: "Priority", + Description: "Level of importance", + DataType: "single_select", + Options: []IssueFieldOption{ + {ID: 1, Name: "High"}, + {ID: 2, Name: "Medium"}, + {ID: 3, Name: "Low"}, + }, + CreatedAt: "2024-12-11T14:39:09Z", + UpdatedAt: "2024-12-11T14:39:09Z", + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedIssueFields []*IssueField + expectedErrMsg string + }{ + { + name: "successful issue fields retrieval", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusOK, mockIssueFields), + }), + requestArgs: map[string]any{ + "org": "testorg", + }, + expectError: false, + expectedIssueFields: mockIssueFields, + }, + { + name: "issue fields not enabled returns empty list", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), + requestArgs: map[string]any{ + "org": "testorg", + }, + expectError: false, + expectedIssueFields: []*IssueField{}, + }, + { + name: "missing org parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusOK, mockIssueFields), + }), + requestArgs: map[string]any{}, + expectError: false, + expectedErrMsg: "missing required parameter: org", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + if result != nil && result.IsError { + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) { + return + } + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + var returnedFields []*IssueField + err = json.Unmarshal([]byte(textContent.Text), &returnedFields) + require.NoError(t, err) + + require.Equal(t, len(tc.expectedIssueFields), len(returnedFields)) + for i, expected := range tc.expectedIssueFields { + assert.Equal(t, expected.ID, returnedFields[i].ID) + assert.Equal(t, expected.Name, returnedFields[i].Name) + assert.Equal(t, expected.DataType, returnedFields[i].DataType) + if expected.Options != nil { + require.Equal(t, len(expected.Options), len(returnedFields[i].Options)) + for j, opt := range expected.Options { + assert.Equal(t, opt.Name, returnedFields[i].Options[j].Name) + } + } + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 559088f6d6..47e4be4ff9 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -208,6 +208,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { SearchIssues(t), ListIssues(t), ListIssueTypes(t), + ListOrgIssueFields(t), IssueWrite(t), AddIssueComment(t), SubIssueWrite(t), From 98b5647c2862eb85e92b07942b4e3df043081084 Mon Sep 17 00:00:00 2001 From: Michael Jacholke <46944669+michaeljacholke@users.noreply.github.com> Date: Thu, 7 May 2026 11:20:52 +0100 Subject: [PATCH 02/12] Clean up code --- pkg/github/issue_fields.go | 11 ----------- pkg/github/issue_fields_test.go | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 2810e391f9..d8275fc003 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -4,10 +4,8 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -87,15 +85,6 @@ func ListOrgIssueFields(t translations.TranslationHelperFunc) inventory.ServerTo } return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return utils.NewToolResultErrorFromErr("failed to read response body", readErr), nil, nil - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue fields", resp, body), nil, nil - } r, err := json.Marshal(fields) if err != nil { diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 9389fc98d5..7979c577a5 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -92,6 +92,28 @@ func Test_ListOrgIssueFields(t *testing.T) { expectError: false, expectedErrMsg: "missing required parameter: org", }, + { + name: "forbidden returns error", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusForbidden, `{"message": "Forbidden"}`), + }), + requestArgs: map[string]any{ + "org": "testorg", + }, + expectError: false, + expectedErrMsg: "failed to list issue fields", + }, + { + name: "internal server error returns error", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusInternalServerError, `{"message": "Internal Server Error"}`), + }), + requestArgs: map[string]any{ + "org": "testorg", + }, + expectError: false, + expectedErrMsg: "failed to list issue fields", + }, } for _, tc := range tests { From df12a08bc001fd8e767b32779070cc4836e5a61f Mon Sep 17 00:00:00 2001 From: Michael Jacholke <46944669+michaeljacholke@users.noreply.github.com> Date: Thu, 7 May 2026 15:10:36 +0100 Subject: [PATCH 03/12] complete struct fields & rename option type --- pkg/github/issue_fields.go | 30 ++++++++++++++++++------------ pkg/github/issue_fields_test.go | 12 ++++++++---- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index d8275fc003..0bb7ca24ba 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -16,20 +16,26 @@ import ( // IssueField represents an organization-level issue field definition. type IssueField struct { - ID int64 `json:"id"` - NodeID string `json:"node_id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - DataType string `json:"data_type"` - Options []IssueFieldOption `json:"options,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + NodeID string `json:"node_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DataType string `json:"data_type"` + Visibility string `json:"visibility"` + Options []IssueSingleSelectFieldOption `json:"options,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } -// IssueFieldOption represents an option for a single_select issue field. -type IssueFieldOption struct { - ID int64 `json:"id"` - Name string `json:"name"` +// IssueSingleSelectFieldOption represents an option for a single_select issue field. +type IssueSingleSelectFieldOption struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Color string `json:"color"` + Priority int64 `json:"priority"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // ListOrgIssueFields creates a tool to list issue field definitions for an organization. diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 7979c577a5..7ad47a8ff3 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -34,6 +34,7 @@ func Test_ListOrgIssueFields(t *testing.T) { Name: "DRI", Description: "Directly responsible individual", DataType: "text", + Visibility: "organization_members_only", CreatedAt: "2024-12-11T14:39:09Z", UpdatedAt: "2024-12-11T14:39:09Z", }, @@ -43,10 +44,11 @@ func Test_ListOrgIssueFields(t *testing.T) { Name: "Priority", Description: "Level of importance", DataType: "single_select", - Options: []IssueFieldOption{ - {ID: 1, Name: "High"}, - {ID: 2, Name: "Medium"}, - {ID: 3, Name: "Low"}, + Visibility: "all", + Options: []IssueSingleSelectFieldOption{ + {ID: 1, Name: "High", Color: "red", Priority: 1, CreatedAt: "2024-12-11T14:39:09Z", UpdatedAt: "2024-12-11T14:39:09Z"}, + {ID: 2, Name: "Medium", Color: "yellow", Priority: 2, CreatedAt: "2024-12-11T14:39:09Z", UpdatedAt: "2024-12-11T14:39:09Z"}, + {ID: 3, Name: "Low", Color: "gray", Priority: 3, CreatedAt: "2024-12-11T14:39:09Z", UpdatedAt: "2024-12-11T14:39:09Z"}, }, CreatedAt: "2024-12-11T14:39:09Z", UpdatedAt: "2024-12-11T14:39:09Z", @@ -161,10 +163,12 @@ func Test_ListOrgIssueFields(t *testing.T) { assert.Equal(t, expected.ID, returnedFields[i].ID) assert.Equal(t, expected.Name, returnedFields[i].Name) assert.Equal(t, expected.DataType, returnedFields[i].DataType) + assert.Equal(t, expected.Visibility, returnedFields[i].Visibility) if expected.Options != nil { require.Equal(t, len(expected.Options), len(returnedFields[i].Options)) for j, opt := range expected.Options { assert.Equal(t, opt.Name, returnedFields[i].Options[j].Name) + assert.Equal(t, opt.Color, returnedFields[i].Options[j].Color) } } } From f5942f3c7fa9f47b48852afc966d145d3aefbe7e Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Fri, 8 May 2026 03:57:51 -0700 Subject: [PATCH 04/12] Drop created_at/updated_at from IssueField and IssueSingleSelectFieldOption --- pkg/github/issue_fields.go | 4 ---- pkg/github/issue_fields_test.go | 10 +++------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 0bb7ca24ba..818ce89d6f 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -23,8 +23,6 @@ type IssueField struct { DataType string `json:"data_type"` Visibility string `json:"visibility"` Options []IssueSingleSelectFieldOption `json:"options,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` } // IssueSingleSelectFieldOption represents an option for a single_select issue field. @@ -34,8 +32,6 @@ type IssueSingleSelectFieldOption struct { Description string `json:"description,omitempty"` Color string `json:"color"` Priority int64 `json:"priority"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` } // ListOrgIssueFields creates a tool to list issue field definitions for an organization. diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 7ad47a8ff3..a1a34ffe99 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -35,8 +35,6 @@ func Test_ListOrgIssueFields(t *testing.T) { Description: "Directly responsible individual", DataType: "text", Visibility: "organization_members_only", - CreatedAt: "2024-12-11T14:39:09Z", - UpdatedAt: "2024-12-11T14:39:09Z", }, { ID: 2, @@ -46,12 +44,10 @@ func Test_ListOrgIssueFields(t *testing.T) { DataType: "single_select", Visibility: "all", Options: []IssueSingleSelectFieldOption{ - {ID: 1, Name: "High", Color: "red", Priority: 1, CreatedAt: "2024-12-11T14:39:09Z", UpdatedAt: "2024-12-11T14:39:09Z"}, - {ID: 2, Name: "Medium", Color: "yellow", Priority: 2, CreatedAt: "2024-12-11T14:39:09Z", UpdatedAt: "2024-12-11T14:39:09Z"}, - {ID: 3, Name: "Low", Color: "gray", Priority: 3, CreatedAt: "2024-12-11T14:39:09Z", UpdatedAt: "2024-12-11T14:39:09Z"}, + {ID: 1, Name: "High", Color: "red", Priority: 1}, + {ID: 2, Name: "Medium", Color: "yellow", Priority: 2}, + {ID: 3, Name: "Low", Color: "gray", Priority: 3}, }, - CreatedAt: "2024-12-11T14:39:09Z", - UpdatedAt: "2024-12-11T14:39:09Z", }, } From 8bd698b0df36e857f913d7d809f8c78ca82b3745 Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Fri, 8 May 2026 04:01:44 -0700 Subject: [PATCH 05/12] Address feedback --- pkg/github/issue_fields.go | 3 ++- pkg/github/issue_fields_test.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 818ce89d6f..9900209717 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" + ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -85,7 +86,7 @@ func ListOrgIssueFields(t translations.TranslationHelperFunc) inventory.ServerTo } return utils.NewToolResultText(string(result)), nil, nil } - return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue fields", resp, err), nil, nil } r, err := json.Marshal(fields) diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index a1a34ffe99..a81f4e4f06 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -98,7 +98,7 @@ func Test_ListOrgIssueFields(t *testing.T) { requestArgs: map[string]any{ "org": "testorg", }, - expectError: false, + expectError: true, expectedErrMsg: "failed to list issue fields", }, { @@ -109,7 +109,7 @@ func Test_ListOrgIssueFields(t *testing.T) { requestArgs: map[string]any{ "org": "testorg", }, - expectError: false, + expectError: true, expectedErrMsg: "failed to list issue fields", }, } From a6d1f6ea2eeb975ed81bf93a2ab9a549f68c54a6 Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Fri, 8 May 2026 04:48:03 -0700 Subject: [PATCH 06/12] Address Copilot review: close resp.Body, set expectError=true for missing org test --- pkg/github/issue_fields.go | 3 +++ pkg/github/issue_fields_test.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 9900209717..e229d8ce79 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -77,6 +77,9 @@ func ListOrgIssueFields(t translations.TranslationHelperFunc) inventory.ServerTo var fields []*IssueField resp, err := client.Do(ctx, req, &fields) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { // Org doesn't have issue fields enabled — return empty list diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index a81f4e4f06..3be74b38f0 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -87,7 +87,7 @@ func Test_ListOrgIssueFields(t *testing.T) { "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusOK, mockIssueFields), }), requestArgs: map[string]any{}, - expectError: false, + expectError: true, expectedErrMsg: "missing required parameter: org", }, { From abc5398a7a4b0b1937675a7b98bbbad0ce5b056d Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 02:33:21 -0700 Subject: [PATCH 07/12] Adjust to list_issue_fields --- README.md | 10 +- .../__toolsnaps__/list_issue_fields.snap | 25 +++ .../__toolsnaps__/list_org_issue_fields.snap | 20 -- pkg/github/issue_fields.go | 148 ++++++++++--- pkg/github/issue_fields_test.go | 202 ++++++++++-------- pkg/github/tools.go | 2 +- 6 files changed, 262 insertions(+), 145 deletions(-) create mode 100644 pkg/github/__toolsnaps__/list_issue_fields.snap delete mode 100644 pkg/github/__toolsnaps__/list_org_issue_fields.snap diff --git a/README.md b/README.md index 37f53474fd..a1d465d482 100644 --- a/README.md +++ b/README.md @@ -874,6 +874,11 @@ The following sets of tools are available: - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) +- **list_issue_fields** - List repository issue fields + - **Required OAuth Scopes**: `repo` + - `owner`: The account owner of the repository. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. The name is not case sensitive. (string, required) + - **list_issue_types** - List available issue types - **Required OAuth Scopes**: `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` @@ -891,11 +896,6 @@ The following sets of tools are available: - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) -- **list_org_issue_fields** - List organization issue fields - - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - - `org`: The organization name. The name is not case sensitive. (string, required) - - **search_issues** - Search issues - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap new file mode 100644 index 0000000000..20e65a8ac7 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issue_fields.snap @@ -0,0 +1,25 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List repository issue fields" + }, + "description": "List issue fields for a repository. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names.", + "inputSchema": { + "properties": { + "owner": { + "description": "The account owner of the repository. The name is not case sensitive.", + "type": "string" + }, + "repo": { + "description": "The name of the repository. The name is not case sensitive.", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_issue_fields" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_org_issue_fields.snap b/pkg/github/__toolsnaps__/list_org_issue_fields.snap deleted file mode 100644 index c4959b0ec9..0000000000 --- a/pkg/github/__toolsnaps__/list_org_issue_fields.snap +++ /dev/null @@ -1,20 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List organization issue fields" - }, - "description": "List issue fields for an organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names.", - "inputSchema": { - "properties": { - "org": { - "description": "The organization name. The name is not case sensitive.", - "type": "string" - } - }, - "required": [ - "org" - ], - "type": "object" - }, - "name": "list_org_issue_fields" -} \ No newline at end of file diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index e229d8ce79..6d17588a6d 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -4,21 +4,19 @@ import ( "context" "encoding/json" "fmt" - "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" ) -// IssueField represents an organization-level issue field definition. +// IssueField represents a repository issue field definition. type IssueField struct { - ID int64 `json:"id"` - NodeID string `json:"node_id"` + ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` DataType string `json:"data_type"` @@ -28,68 +26,148 @@ type IssueField struct { // IssueSingleSelectFieldOption represents an option for a single_select issue field. type IssueSingleSelectFieldOption struct { - ID int64 `json:"id"` + ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Color string `json:"color"` - Priority int64 `json:"priority"` + Priority *int `json:"priority,omitempty"` } -// ListOrgIssueFields creates a tool to list issue field definitions for an organization. -func ListOrgIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { +// issueFieldsQuery is the GraphQL query for listing issue fields on a repository. +type issueFieldsQuery struct { + Repository struct { + IssueFields struct { + Nodes []struct { + TypeName githubv4.String `graphql:"__typename"` + // All field types share these fields; any populated fragment gives the same values. + IssueFieldText struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + Options []struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + Color githubv4.String + Priority *int + } + } `graphql:"... on IssueFieldSingleSelect"` + } + } `graphql:"issueFields(first: 100)"` + } `graphql:"repository(owner: $owner, name: $name)"` +} + +// ListIssueFields creates a tool to list issue field definitions for a repository. +func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ - Name: "list_org_issue_fields", - Description: t("TOOL_LIST_ORG_ISSUE_FIELDS_DESCRIPTION", "List issue fields for an organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names."), + Name: "list_issue_fields", + Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_ORG_ISSUE_FIELDS_USER_TITLE", "List organization issue fields"), + Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List repository issue fields"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ - "org": { + "owner": { Type: "string", - Description: "The organization name. The name is not case sensitive.", + Description: "The account owner of the repository. The name is not case sensitive.", + }, + "repo": { + Type: "string", + Description: "The name of the repository. The name is not case sensitive.", }, }, - Required: []string{"org"}, + Required: []string{"owner", "repo"}, }, }, - []scopes.Scope{scopes.ReadOrg}, + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - org, err := RequiredParam[string](args, "org") + owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - - client, err := deps.GetClient(ctx) + repo, err := RequiredParam[string](args, "repo") if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultError(err.Error()), nil, nil } - reqURL := fmt.Sprintf("orgs/%s/issue-fields", org) - req, err := client.NewRequest(http.MethodGet, reqURL, nil) + gqlClient, err := deps.GetGQLClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil } - var fields []*IssueField - resp, err := client.Do(ctx, req, &fields) - if resp != nil { - defer func() { _ = resp.Body.Close() }() + var query issueFieldsQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), } - if err != nil { - if resp != nil && resp.StatusCode == http.StatusNotFound { - // Org doesn't have issue fields enabled — return empty list - result, marshalErr := json.Marshal([]*IssueField{}) - if marshalErr != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", marshalErr), nil, nil + if err := gqlClient.Query(ctx, &query, vars); err != nil { + return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil + } + + fields := make([]IssueField, 0, len(query.Repository.IssueFields.Nodes)) + for _, node := range query.Repository.IssueFields.Nodes { + var f IssueField + // Use TypeName to discriminate; shurcooL populates all fragment structs with the + // same shared field values, so any non-SingleSelect struct gives the correct data. + switch string(node.TypeName) { + case "IssueFieldSingleSelect": + opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options)) + for _, o := range node.IssueFieldSingleSelect.Options { + opts = append(opts, IssueSingleSelectFieldOption{ + ID: fmt.Sprintf("%v", o.ID), + Name: string(o.Name), + Description: string(o.Description), + Color: string(o.Color), + Priority: o.Priority, + }) + } + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID), + Name: string(node.IssueFieldSingleSelect.Name), + Description: string(node.IssueFieldSingleSelect.Description), + DataType: string(node.IssueFieldSingleSelect.DataType), + Visibility: string(node.IssueFieldSingleSelect.Visibility), + Options: opts, + } + case "IssueFieldText", "IssueFieldNumber", "IssueFieldDate": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldText.ID), + Name: string(node.IssueFieldText.Name), + Description: string(node.IssueFieldText.Description), + DataType: string(node.IssueFieldText.DataType), + Visibility: string(node.IssueFieldText.Visibility), } - return utils.NewToolResultText(string(result)), nil, nil + default: + continue } - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue fields", resp, err), nil, nil + fields = append(fields, f) } r, err := json.Marshal(fields) diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 3be74b38f0..9848e4660e 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -3,127 +3,169 @@ package github import ( "context" "encoding/json" - "net/http" - "strings" "testing" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func Test_ListOrgIssueFields(t *testing.T) { +func Test_ListIssueFields(t *testing.T) { // Verify tool definition - serverTool := ListOrgIssueFields(translations.NullTranslationHelper) + serverTool := ListIssueFields(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "list_org_issue_fields", tool.Name) + assert.Equal(t, "list_issue_fields", tool.Name) assert.NotEmpty(t, tool.Description) assert.True(t, tool.Annotations.ReadOnlyHint) - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "org") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"org"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) - mockIssueFields := []*IssueField{ - { - ID: 1, - NodeID: "IFT_kwDNAd3NAZo", - Name: "DRI", - Description: "Directly responsible individual", - DataType: "text", - Visibility: "organization_members_only", - }, - { - ID: 2, - NodeID: "IFSS_kwDNAd3NAZs", - Name: "Priority", - Description: "Level of importance", - DataType: "single_select", - Visibility: "all", - Options: []IssueSingleSelectFieldOption{ - {ID: 1, Name: "High", Color: "red", Priority: 1}, - {ID: 2, Name: "Medium", Color: "yellow", Priority: 2}, - {ID: 3, Name: "Low", Color: "gray", Priority: 3}, - }, - }, + queryStruct := issueFieldsQuery{} + defaultVars := map[string]any{ + "owner": githubv4.String("testowner"), + "name": githubv4.String("testrepo"), } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedIssueFields []*IssueField - expectedErrMsg string + name string + requestArgs map[string]any + gqlResponse githubv4mock.GQLResponse + expectError bool + expectedFields []IssueField + expectedErrMsg string }{ { - name: "successful issue fields retrieval", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusOK, mockIssueFields), - }), + name: "no fields returns empty list", requestArgs: map[string]any{ - "org": "testorg", + "owner": "testowner", + "repo": "testrepo", }, - expectError: false, - expectedIssueFields: mockIssueFields, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{}, + }, + }, + }), + expectedFields: []IssueField{}, }, { - name: "issue fields not enabled returns empty list", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - }), + name: "text field returned", requestArgs: map[string]any{ - "org": "testorg", + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "name": "DRI", + "description": "Directly responsible individual", + "dataType": "TEXT", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + { + ID: "IFT_1", + Name: "DRI", + Description: "Directly responsible individual", + DataType: "TEXT", + Visibility: "ORG_ONLY", + }, }, - expectError: false, - expectedIssueFields: []*IssueField{}, }, { - name: "missing org parameter", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusOK, mockIssueFields), + name: "single_select field with options returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "name": "Priority", + "description": "Level of importance", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{ + "id": "OPT_1", + "name": "High", + "color": "red", + }, + map[string]any{ + "id": "OPT_2", + "name": "Low", + "color": "blue", + }, + }, + }, + }, + }, + }, }), - requestArgs: map[string]any{}, - expectError: true, - expectedErrMsg: "missing required parameter: org", + expectedFields: []IssueField{ + { + ID: "IFSS_1", + Name: "Priority", + Description: "Level of importance", + DataType: "SINGLE_SELECT", + Visibility: "ALL", + Options: []IssueSingleSelectFieldOption{ + {ID: "OPT_1", Name: "High", Color: "red"}, + {ID: "OPT_2", Name: "Low", Color: "blue"}, + }, + }, + }, }, { - name: "forbidden returns error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusForbidden, `{"message": "Forbidden"}`), - }), + name: "missing owner parameter", requestArgs: map[string]any{ - "org": "testorg", + "repo": "testrepo", }, + gqlResponse: githubv4mock.DataResponse(map[string]any{}), expectError: true, - expectedErrMsg: "failed to list issue fields", + expectedErrMsg: "missing required parameter: owner", }, { - name: "internal server error returns error", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusInternalServerError, `{"message": "Internal Server Error"}`), - }), + name: "missing repo parameter", requestArgs: map[string]any{ - "org": "testorg", + "owner": "testowner", }, + gqlResponse: githubv4mock.DataResponse(map[string]any{}), expectError: true, - expectedErrMsg: "failed to list issue fields", + expectedErrMsg: "missing required parameter: repo", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - deps := BaseDeps{ - Client: client, - } + mockedHTTPClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher(queryStruct, defaultVars, tc.gqlResponse), + ) + gqlClient := githubv4.NewClient(mockedHTTPClient) + deps := BaseDeps{GQLClient: gqlClient} handler := serverTool.Handler(deps) request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) if tc.expectError { @@ -138,24 +180,16 @@ func Test_ListOrgIssueFields(t *testing.T) { return } - if result != nil && result.IsError { - errorContent := getErrorResult(t, result) - if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) { - return - } - } - require.NoError(t, err) require.NotNil(t, result) require.False(t, result.IsError) textContent := getTextResult(t, result) - var returnedFields []*IssueField + var returnedFields []IssueField err = json.Unmarshal([]byte(textContent.Text), &returnedFields) require.NoError(t, err) - - require.Equal(t, len(tc.expectedIssueFields), len(returnedFields)) - for i, expected := range tc.expectedIssueFields { + require.Equal(t, len(tc.expectedFields), len(returnedFields)) + for i, expected := range tc.expectedFields { assert.Equal(t, expected.ID, returnedFields[i].ID) assert.Equal(t, expected.Name, returnedFields[i].Name) assert.Equal(t, expected.DataType, returnedFields[i].DataType) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0bccc2dba3..6e08de7e62 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -209,7 +209,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { SearchIssues(t), ListIssues(t), ListIssueTypes(t), - ListOrgIssueFields(t), + ListIssueFields(t), IssueWrite(t), AddIssueComment(t), SubIssueWrite(t), From 0fd7142126cb492ae0e1f93d324081962fd16f07 Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 03:54:36 -0700 Subject: [PATCH 08/12] Add feature flag --- pkg/github/issue_fields.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 6d17588a6d..f8a10df230 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -127,7 +128,8 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool "owner": githubv4.String(owner), "name": githubv4.String(repo), } - if err := gqlClient.Query(ctx, &query, vars); err != nil { + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields") + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil } From 20d45e2a1b3ed569209cb5cfd9487d91b125125d Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 04:38:16 -0700 Subject: [PATCH 09/12] Allow tool to support read:org or repo --- .../__toolsnaps__/list_issue_fields.snap | 11 +- pkg/github/issue_fields.go | 145 ++++++++++-------- pkg/github/issue_fields_test.go | 59 +++++-- 3 files changed, 136 insertions(+), 79 deletions(-) diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap index 20e65a8ac7..0eec8bc9e1 100644 --- a/pkg/github/__toolsnaps__/list_issue_fields.snap +++ b/pkg/github/__toolsnaps__/list_issue_fields.snap @@ -1,23 +1,22 @@ { "annotations": { "readOnlyHint": true, - "title": "List repository issue fields" + "title": "List issue fields" }, - "description": "List issue fields for a repository. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names.", + "description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.", "inputSchema": { "properties": { "owner": { - "description": "The account owner of the repository. The name is not case sensitive.", + "description": "The account owner of the repository or organization. The name is not case sensitive.", "type": "string" }, "repo": { - "description": "The name of the repository. The name is not case sensitive.", + "description": "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.", "type": "string" } }, "required": [ - "owner", - "repo" + "owner" ], "type": "object" }, diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index f8a10df230..8691d1a7d5 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -34,62 +34,74 @@ type IssueSingleSelectFieldOption struct { Priority *int `json:"priority,omitempty"` } -// issueFieldsQuery is the GraphQL query for listing issue fields on a repository. -type issueFieldsQuery struct { +// issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union. +type issueFieldNode struct { + TypeName githubv4.String `graphql:"__typename"` + // All field types share these fields; any populated fragment gives the same values. + IssueFieldText struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + Options []struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + Color githubv4.String + Priority *int + } + } `graphql:"... on IssueFieldSingleSelect"` +} + +// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository. +type issueFieldsRepoQuery struct { Repository struct { IssueFields struct { - Nodes []struct { - TypeName githubv4.String `graphql:"__typename"` - // All field types share these fields; any populated fragment gives the same values. - IssueFieldText struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String - } `graphql:"... on IssueFieldText"` - IssueFieldNumber struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String - } `graphql:"... on IssueFieldNumber"` - IssueFieldDate struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String - } `graphql:"... on IssueFieldDate"` - IssueFieldSingleSelect struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String - Options []struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - Color githubv4.String - Priority *int - } - } `graphql:"... on IssueFieldSingleSelect"` - } + Nodes []issueFieldNode } `graphql:"issueFields(first: 100)"` } `graphql:"repository(owner: $owner, name: $name)"` } -// ListIssueFields creates a tool to list issue field definitions for a repository. +// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization. +type issueFieldsOrgQuery struct { + Organization struct { + IssueFields struct { + Nodes []issueFieldNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"organization(login: $login)"` +} + +// ListIssueFields creates a tool to list issue field definitions for a repository or organization. func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_fields", - Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names."), + Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List repository issue fields"), + Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ @@ -97,23 +109,23 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool Properties: map[string]*jsonschema.Schema{ "owner": { Type: "string", - Description: "The account owner of the repository. The name is not case sensitive.", + Description: "The account owner of the repository or organization. The name is not case sensitive.", }, "repo": { Type: "string", - Description: "The name of the repository. The name is not case sensitive.", + Description: "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.", }, }, - Required: []string{"owner", "repo"}, + Required: []string{"owner"}, }, }, - []scopes.Scope{scopes.Repo}, + []scopes.Scope{scopes.Repo, scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - repo, err := RequiredParam[string](args, "repo") + repo, err := OptionalParam[string](args, "repo") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -123,21 +135,34 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil } - var query issueFieldsQuery - vars := map[string]any{ - "owner": githubv4.String(owner), - "name": githubv4.String(repo), - } ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields") - if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { - return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil + var nodes []issueFieldNode + if repo != "" { + var query issueFieldsRepoQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil + } + nodes = query.Repository.IssueFields.Nodes + } else { + var query issueFieldsOrgQuery + vars := map[string]any{ + "login": githubv4.String(owner), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil + } + nodes = query.Organization.IssueFields.Nodes } - fields := make([]IssueField, 0, len(query.Repository.IssueFields.Nodes)) - for _, node := range query.Repository.IssueFields.Nodes { + fields := make([]IssueField, 0, len(nodes)) + for _, node := range nodes { var f IssueField - // Use TypeName to discriminate; shurcooL populates all fragment structs with the - // same shared field values, so any non-SingleSelect struct gives the correct data. + // Use TypeName to discriminate; shurcooL populates all matching fragment structs, + // so any non-SingleSelect struct gives the correct shared field values. switch string(node.TypeName) { case "IssueFieldSingleSelect": opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options)) diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 9848e4660e..4d2a371d54 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -25,21 +25,29 @@ func Test_ListIssueFields(t *testing.T) { assert.True(t, tool.Annotations.ReadOnlyHint) assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner"}) + assert.ElementsMatch(t, serverTool.RequiredScopes, []string{"repo", "read:org"}) + assert.ElementsMatch(t, serverTool.AcceptedScopes, []string{"repo", "read:org", "write:org", "admin:org"}) - queryStruct := issueFieldsQuery{} + queryStruct := issueFieldsRepoQuery{} defaultVars := map[string]any{ "owner": githubv4.String("testowner"), "name": githubv4.String("testrepo"), } + orgQueryStruct := issueFieldsOrgQuery{} + defaultOrgVars := map[string]any{ + "login": githubv4.String("testowner"), + } tests := []struct { - name string - requestArgs map[string]any - gqlResponse githubv4mock.GQLResponse - expectError bool - expectedFields []IssueField - expectedErrMsg string + name string + requestArgs map[string]any + mockQueryStruct any + mockVars map[string]any + gqlResponse githubv4mock.GQLResponse + expectError bool + expectedFields []IssueField + expectedErrMsg string }{ { name: "no fields returns empty list", @@ -146,20 +154,45 @@ func Test_ListIssueFields(t *testing.T) { expectedErrMsg: "missing required parameter: owner", }, { - name: "missing repo parameter", + name: "no repo returns org-level fields", requestArgs: map[string]any{ "owner": "testowner", }, - gqlResponse: githubv4mock.DataResponse(map[string]any{}), - expectError: true, - expectedErrMsg: "missing required parameter: repo", + mockQueryStruct: orgQueryStruct, + mockVars: defaultOrgVars, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "name": "DRI", + "dataType": "TEXT", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFT_1", Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, + }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + qs := tc.mockQueryStruct + if qs == nil { + qs = queryStruct + } + vars := tc.mockVars + if vars == nil { + vars = defaultVars + } mockedHTTPClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher(queryStruct, defaultVars, tc.gqlResponse), + githubv4mock.NewQueryMatcher(qs, vars, tc.gqlResponse), ) gqlClient := githubv4.NewClient(mockedHTTPClient) deps := BaseDeps{GQLClient: gqlClient} From 2bbee8b4177257745985c7df4c1dd385b2d6072e Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 04:39:58 -0700 Subject: [PATCH 10/12] Docs --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a1d465d482..9962fb801d 100644 --- a/README.md +++ b/README.md @@ -874,10 +874,11 @@ The following sets of tools are available: - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) -- **list_issue_fields** - List repository issue fields - - **Required OAuth Scopes**: `repo` - - `owner`: The account owner of the repository. The name is not case sensitive. (string, required) - - `repo`: The name of the repository. The name is not case sensitive. (string, required) +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) - **list_issue_types** - List available issue types - **Required OAuth Scopes**: `read:org` From 892213216e9396e93a43abc95d8e2952e858675a Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 04:57:18 -0700 Subject: [PATCH 11/12] address comments --- pkg/github/issue_fields.go | 30 +++++++++++++---- pkg/github/issue_fields_test.go | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 8691d1a7d5..ba4ea13c66 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -6,6 +6,7 @@ import ( "fmt" ghcontext "github.com/github/github-mcp-server/pkg/context" + ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -35,9 +36,9 @@ type IssueSingleSelectFieldOption struct { } // issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union. +// Only the fragment matching __typename is populated; read from the matching fragment. type issueFieldNode struct { - TypeName githubv4.String `graphql:"__typename"` - // All field types share these fields; any populated fragment gives the same values. + TypeName githubv4.String `graphql:"__typename"` IssueFieldText struct { ID githubv4.ID Name githubv4.String @@ -144,7 +145,7 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool "name": githubv4.String(repo), } if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { - return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil } nodes = query.Repository.IssueFields.Nodes } else { @@ -153,7 +154,7 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool "login": githubv4.String(owner), } if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { - return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil } nodes = query.Organization.IssueFields.Nodes } @@ -161,8 +162,7 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool fields := make([]IssueField, 0, len(nodes)) for _, node := range nodes { var f IssueField - // Use TypeName to discriminate; shurcooL populates all matching fragment structs, - // so any non-SingleSelect struct gives the correct shared field values. + // Read from the fragment matching __typename; the other fragments are zero-valued. switch string(node.TypeName) { case "IssueFieldSingleSelect": opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options)) @@ -183,7 +183,7 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool Visibility: string(node.IssueFieldSingleSelect.Visibility), Options: opts, } - case "IssueFieldText", "IssueFieldNumber", "IssueFieldDate": + case "IssueFieldText": f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldText.ID), Name: string(node.IssueFieldText.Name), @@ -191,6 +191,22 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool DataType: string(node.IssueFieldText.DataType), Visibility: string(node.IssueFieldText.Visibility), } + case "IssueFieldNumber": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID), + Name: string(node.IssueFieldNumber.Name), + Description: string(node.IssueFieldNumber.Description), + DataType: string(node.IssueFieldNumber.DataType), + Visibility: string(node.IssueFieldNumber.Visibility), + } + case "IssueFieldDate": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldDate.ID), + Name: string(node.IssueFieldDate.Name), + Description: string(node.IssueFieldDate.Description), + DataType: string(node.IssueFieldDate.DataType), + Visibility: string(node.IssueFieldDate.Visibility), + } default: continue } diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 4d2a371d54..238c0455b2 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -179,6 +179,66 @@ func Test_ListIssueFields(t *testing.T) { {ID: "IFT_1", Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, }, }, + { + name: "number field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "name": "Engineering Staffing", + "dataType": "NUMBER", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFN_1", Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "date field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldDate", + "id": "IFD_1", + "name": "Target Date", + "dataType": "DATE", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFD_1", Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "graphql error returns failure", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.ErrorResponse("boom"), + expectError: true, + expectedErrMsg: "failed to list issue fields", + }, } for _, tc := range tests { From 46e60da3e4f24c319bb9011acc503147344a7269 Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Thu, 21 May 2026 02:01:05 -0700 Subject: [PATCH 12/12] Add repo_issue_fields flag --- pkg/github/issue_fields.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index ba4ea13c66..0649e47141 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -136,7 +136,7 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil } - ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields") + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") var nodes []issueFieldNode if repo != "" { var query issueFieldsRepoQuery