Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add list_org_issue_fields tool
  • Loading branch information
michaeljacholke committed May 7, 2026
commit 3d7100530321f2b34a547d0c0cd7b5b65292917c
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm can you check pls the rest endpoint? I think repo should be there too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a look and the endpoint is org-scoped so I don't think we need the repo param

- `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)
Expand Down
20 changes: 20 additions & 0 deletions pkg/github/__toolsnaps__/list_org_issue_fields.snap
Original file line number Diff line number Diff line change
@@ -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"
}
107 changes: 107 additions & 0 deletions pkg/github/issue_fields.go
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might add multiselct

Suggested change
type IssueFieldOption struct {
type IssueSingleSelectFieldOption struct {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 - df12a08

ID int64 `json:"id"`
Name string `json:"name"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please double check this with the rest schema. I think color is there too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, updated - df12a08

}

// 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Using raw client.NewRequest/client.Do here because the issue-fields API isn't yet in go-github.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair. Pls link the go github issue that is tracking updating to the new rest version. It's from March or so. We should have it in mind to update this once the lib is updated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}
Comment on lines +82 to +93
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
})
}
151 changes: 151 additions & 0 deletions pkg/github/issue_fields_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading