Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
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
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"
}
102 changes: 102 additions & 0 deletions pkg/github/issue_fields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package github

import (
"context"
"encoding/json"
"fmt"
"net/http"

"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"`
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.
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.
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
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
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
}

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
})
}
177 changes: 177 additions & 0 deletions pkg/github/issue_fields_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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",
Visibility: "organization_members_only",
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",
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",
},
}

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",
},
{
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 {
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)
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)
}
}
}
})
}
}
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