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 1/3] 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 2/3] 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 3/3] 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) } } }