Skip to content

Commit 3d71005

Browse files
Add list_org_issue_fields tool
1 parent 2dab994 commit 3d71005

5 files changed

Lines changed: 284 additions & 0 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,11 @@ The following sets of tools are available:
873873
- `since`: Filter by date (ISO 8601 timestamp) (string, optional)
874874
- `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional)
875875

876+
- **list_org_issue_fields** - List organization issue fields
877+
- **Required OAuth Scopes**: `read:org`
878+
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`
879+
- `org`: The organization name. The name is not case sensitive. (string, required)
880+
876881
- **search_issues** - Search issues
877882
- **Required OAuth Scopes**: `repo`
878883
- `order`: Sort order (string, optional)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "List organization issue fields"
5+
},
6+
"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.",
7+
"inputSchema": {
8+
"properties": {
9+
"org": {
10+
"description": "The organization name. The name is not case sensitive.",
11+
"type": "string"
12+
}
13+
},
14+
"required": [
15+
"org"
16+
],
17+
"type": "object"
18+
},
19+
"name": "list_org_issue_fields"
20+
}

pkg/github/issue_fields.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
10+
ghErrors "github.com/github/github-mcp-server/pkg/errors"
11+
"github.com/github/github-mcp-server/pkg/inventory"
12+
"github.com/github/github-mcp-server/pkg/scopes"
13+
"github.com/github/github-mcp-server/pkg/translations"
14+
"github.com/github/github-mcp-server/pkg/utils"
15+
"github.com/google/jsonschema-go/jsonschema"
16+
"github.com/modelcontextprotocol/go-sdk/mcp"
17+
)
18+
19+
// IssueField represents an organization-level issue field definition.
20+
type IssueField struct {
21+
ID int64 `json:"id"`
22+
NodeID string `json:"node_id"`
23+
Name string `json:"name"`
24+
Description string `json:"description,omitempty"`
25+
DataType string `json:"data_type"`
26+
Options []IssueFieldOption `json:"options,omitempty"`
27+
CreatedAt string `json:"created_at"`
28+
UpdatedAt string `json:"updated_at"`
29+
}
30+
31+
// IssueFieldOption represents an option for a single_select issue field.
32+
type IssueFieldOption struct {
33+
ID int64 `json:"id"`
34+
Name string `json:"name"`
35+
}
36+
37+
// ListOrgIssueFields creates a tool to list issue field definitions for an organization.
38+
func ListOrgIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
39+
return NewTool(
40+
ToolsetMetadataIssues,
41+
mcp.Tool{
42+
Name: "list_org_issue_fields",
43+
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."),
44+
Annotations: &mcp.ToolAnnotations{
45+
Title: t("TOOL_LIST_ORG_ISSUE_FIELDS_USER_TITLE", "List organization issue fields"),
46+
ReadOnlyHint: true,
47+
},
48+
InputSchema: &jsonschema.Schema{
49+
Type: "object",
50+
Properties: map[string]*jsonschema.Schema{
51+
"org": {
52+
Type: "string",
53+
Description: "The organization name. The name is not case sensitive.",
54+
},
55+
},
56+
Required: []string{"org"},
57+
},
58+
},
59+
[]scopes.Scope{scopes.ReadOrg},
60+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
61+
org, err := RequiredParam[string](args, "org")
62+
if err != nil {
63+
return utils.NewToolResultError(err.Error()), nil, nil
64+
}
65+
66+
client, err := deps.GetClient(ctx)
67+
if err != nil {
68+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
69+
}
70+
71+
reqURL := fmt.Sprintf("orgs/%s/issue-fields", org)
72+
req, err := client.NewRequest(http.MethodGet, reqURL, nil)
73+
if err != nil {
74+
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
75+
}
76+
77+
var fields []*IssueField
78+
resp, err := client.Do(ctx, req, &fields)
79+
if err != nil {
80+
if resp != nil && resp.StatusCode == http.StatusNotFound {
81+
// Org doesn't have issue fields enabled — return empty list
82+
result, marshalErr := json.Marshal([]*IssueField{})
83+
if marshalErr != nil {
84+
return utils.NewToolResultErrorFromErr("failed to marshal response", marshalErr), nil, nil
85+
}
86+
return utils.NewToolResultText(string(result)), nil, nil
87+
}
88+
return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil
89+
}
90+
defer func() { _ = resp.Body.Close() }()
91+
92+
if resp.StatusCode != http.StatusOK {
93+
body, readErr := io.ReadAll(resp.Body)
94+
if readErr != nil {
95+
return utils.NewToolResultErrorFromErr("failed to read response body", readErr), nil, nil
96+
}
97+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue fields", resp, body), nil, nil
98+
}
99+
100+
r, err := json.Marshal(fields)
101+
if err != nil {
102+
return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil
103+
}
104+
105+
return utils.NewToolResultText(string(r)), nil, nil
106+
})
107+
}

pkg/github/issue_fields_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
10+
"github.com/github/github-mcp-server/internal/toolsnaps"
11+
"github.com/github/github-mcp-server/pkg/translations"
12+
"github.com/google/go-github/v82/github"
13+
"github.com/google/jsonschema-go/jsonschema"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func Test_ListOrgIssueFields(t *testing.T) {
19+
// Verify tool definition
20+
serverTool := ListOrgIssueFields(translations.NullTranslationHelper)
21+
tool := serverTool.Tool
22+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
23+
24+
assert.Equal(t, "list_org_issue_fields", tool.Name)
25+
assert.NotEmpty(t, tool.Description)
26+
assert.True(t, tool.Annotations.ReadOnlyHint)
27+
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "org")
28+
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"org"})
29+
30+
mockIssueFields := []*IssueField{
31+
{
32+
ID: 1,
33+
NodeID: "IFT_kwDNAd3NAZo",
34+
Name: "DRI",
35+
Description: "Directly responsible individual",
36+
DataType: "text",
37+
CreatedAt: "2024-12-11T14:39:09Z",
38+
UpdatedAt: "2024-12-11T14:39:09Z",
39+
},
40+
{
41+
ID: 2,
42+
NodeID: "IFSS_kwDNAd3NAZs",
43+
Name: "Priority",
44+
Description: "Level of importance",
45+
DataType: "single_select",
46+
Options: []IssueFieldOption{
47+
{ID: 1, Name: "High"},
48+
{ID: 2, Name: "Medium"},
49+
{ID: 3, Name: "Low"},
50+
},
51+
CreatedAt: "2024-12-11T14:39:09Z",
52+
UpdatedAt: "2024-12-11T14:39:09Z",
53+
},
54+
}
55+
56+
tests := []struct {
57+
name string
58+
mockedClient *http.Client
59+
requestArgs map[string]any
60+
expectError bool
61+
expectedIssueFields []*IssueField
62+
expectedErrMsg string
63+
}{
64+
{
65+
name: "successful issue fields retrieval",
66+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
67+
"GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusOK, mockIssueFields),
68+
}),
69+
requestArgs: map[string]any{
70+
"org": "testorg",
71+
},
72+
expectError: false,
73+
expectedIssueFields: mockIssueFields,
74+
},
75+
{
76+
name: "issue fields not enabled returns empty list",
77+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
78+
"GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`),
79+
}),
80+
requestArgs: map[string]any{
81+
"org": "testorg",
82+
},
83+
expectError: false,
84+
expectedIssueFields: []*IssueField{},
85+
},
86+
{
87+
name: "missing org parameter",
88+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
89+
"GET /orgs/testorg/issue-fields": mockResponse(t, http.StatusOK, mockIssueFields),
90+
}),
91+
requestArgs: map[string]any{},
92+
expectError: false,
93+
expectedErrMsg: "missing required parameter: org",
94+
},
95+
}
96+
97+
for _, tc := range tests {
98+
t.Run(tc.name, func(t *testing.T) {
99+
client := github.NewClient(tc.mockedClient)
100+
deps := BaseDeps{
101+
Client: client,
102+
}
103+
handler := serverTool.Handler(deps)
104+
105+
request := createMCPRequest(tc.requestArgs)
106+
107+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
108+
109+
if tc.expectError {
110+
if err != nil {
111+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
112+
return
113+
}
114+
require.NotNil(t, result)
115+
require.True(t, result.IsError)
116+
errorContent := getErrorResult(t, result)
117+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
118+
return
119+
}
120+
121+
if result != nil && result.IsError {
122+
errorContent := getErrorResult(t, result)
123+
if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) {
124+
return
125+
}
126+
}
127+
128+
require.NoError(t, err)
129+
require.NotNil(t, result)
130+
require.False(t, result.IsError)
131+
textContent := getTextResult(t, result)
132+
133+
var returnedFields []*IssueField
134+
err = json.Unmarshal([]byte(textContent.Text), &returnedFields)
135+
require.NoError(t, err)
136+
137+
require.Equal(t, len(tc.expectedIssueFields), len(returnedFields))
138+
for i, expected := range tc.expectedIssueFields {
139+
assert.Equal(t, expected.ID, returnedFields[i].ID)
140+
assert.Equal(t, expected.Name, returnedFields[i].Name)
141+
assert.Equal(t, expected.DataType, returnedFields[i].DataType)
142+
if expected.Options != nil {
143+
require.Equal(t, len(expected.Options), len(returnedFields[i].Options))
144+
for j, opt := range expected.Options {
145+
assert.Equal(t, opt.Name, returnedFields[i].Options[j].Name)
146+
}
147+
}
148+
}
149+
})
150+
}
151+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
208208
SearchIssues(t),
209209
ListIssues(t),
210210
ListIssueTypes(t),
211+
ListOrgIssueFields(t),
211212
IssueWrite(t),
212213
AddIssueComment(t),
213214
SubIssueWrite(t),

0 commit comments

Comments
 (0)