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

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

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

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

// 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
Comment thread
kelsey-myers marked this conversation as resolved.
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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue fields", resp, 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
})
}
173 changes: 173 additions & 0 deletions pkg/github/issue_fields_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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",
},
{
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},
},
},
}

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,
Comment thread
kelsey-myers marked this conversation as resolved.
Outdated
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: true,
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: true,
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