Skip to content

Commit 8f6050a

Browse files
Add list_issue_fields tool (#2445)
* Add list_org_issue_fields tool * Clean up code * complete struct fields & rename option type * Drop created_at/updated_at from IssueField and IssueSingleSelectFieldOption * Address feedback * Address Copilot review: close resp.Body, set expectError=true for missing org test * Adjust to list_issue_fields * Add feature flag * Allow tool to support read:org or repo * Docs * address comments * Add repo_issue_fields flag --------- Co-authored-by: Michael Jacholke <46944669+michaeljacholke@users.noreply.github.com>
1 parent 805ad75 commit 8f6050a

5 files changed

Lines changed: 554 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,12 @@ The following sets of tools are available:
870870
- `title`: Issue title (string, optional)
871871
- `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
872872

873+
- **list_issue_fields** - List issue fields
874+
- **Required OAuth Scopes**: `repo`, `read:org`
875+
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
876+
- `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required)
877+
- `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional)
878+
873879
- **list_issue_types** - List available issue types
874880
- **Required OAuth Scopes**: `read:org`
875881
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "List issue fields"
5+
},
6+
"description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "The account owner of the repository or organization. The name is not case sensitive.",
11+
"type": "string"
12+
},
13+
"repo": {
14+
"description": "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.",
15+
"type": "string"
16+
}
17+
},
18+
"required": [
19+
"owner"
20+
],
21+
"type": "object"
22+
},
23+
"name": "list_issue_fields"
24+
}

pkg/github/issue_fields.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
ghcontext "github.com/github/github-mcp-server/pkg/context"
9+
ghErrors "github.com/github/github-mcp-server/pkg/errors"
10+
"github.com/github/github-mcp-server/pkg/inventory"
11+
"github.com/github/github-mcp-server/pkg/scopes"
12+
"github.com/github/github-mcp-server/pkg/translations"
13+
"github.com/github/github-mcp-server/pkg/utils"
14+
"github.com/google/jsonschema-go/jsonschema"
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
"github.com/shurcooL/githubv4"
17+
)
18+
19+
// IssueField represents a repository issue field definition.
20+
type IssueField struct {
21+
ID string `json:"id"`
22+
Name string `json:"name"`
23+
Description string `json:"description,omitempty"`
24+
DataType string `json:"data_type"`
25+
Visibility string `json:"visibility"`
26+
Options []IssueSingleSelectFieldOption `json:"options,omitempty"`
27+
}
28+
29+
// IssueSingleSelectFieldOption represents an option for a single_select issue field.
30+
type IssueSingleSelectFieldOption struct {
31+
ID string `json:"id"`
32+
Name string `json:"name"`
33+
Description string `json:"description,omitempty"`
34+
Color string `json:"color"`
35+
Priority *int `json:"priority,omitempty"`
36+
}
37+
38+
// issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union.
39+
// Only the fragment matching __typename is populated; read from the matching fragment.
40+
type issueFieldNode struct {
41+
TypeName githubv4.String `graphql:"__typename"`
42+
IssueFieldText struct {
43+
ID githubv4.ID
44+
Name githubv4.String
45+
Description githubv4.String
46+
DataType githubv4.String
47+
Visibility githubv4.String
48+
} `graphql:"... on IssueFieldText"`
49+
IssueFieldNumber struct {
50+
ID githubv4.ID
51+
Name githubv4.String
52+
Description githubv4.String
53+
DataType githubv4.String
54+
Visibility githubv4.String
55+
} `graphql:"... on IssueFieldNumber"`
56+
IssueFieldDate struct {
57+
ID githubv4.ID
58+
Name githubv4.String
59+
Description githubv4.String
60+
DataType githubv4.String
61+
Visibility githubv4.String
62+
} `graphql:"... on IssueFieldDate"`
63+
IssueFieldSingleSelect struct {
64+
ID githubv4.ID
65+
Name githubv4.String
66+
Description githubv4.String
67+
DataType githubv4.String
68+
Visibility githubv4.String
69+
Options []struct {
70+
ID githubv4.ID
71+
Name githubv4.String
72+
Description githubv4.String
73+
Color githubv4.String
74+
Priority *int
75+
}
76+
} `graphql:"... on IssueFieldSingleSelect"`
77+
}
78+
79+
// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository.
80+
type issueFieldsRepoQuery struct {
81+
Repository struct {
82+
IssueFields struct {
83+
Nodes []issueFieldNode
84+
} `graphql:"issueFields(first: 100)"`
85+
} `graphql:"repository(owner: $owner, name: $name)"`
86+
}
87+
88+
// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization.
89+
type issueFieldsOrgQuery struct {
90+
Organization struct {
91+
IssueFields struct {
92+
Nodes []issueFieldNode
93+
} `graphql:"issueFields(first: 100)"`
94+
} `graphql:"organization(login: $login)"`
95+
}
96+
97+
// ListIssueFields creates a tool to list issue field definitions for a repository or organization.
98+
func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
99+
return NewTool(
100+
ToolsetMetadataIssues,
101+
mcp.Tool{
102+
Name: "list_issue_fields",
103+
Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."),
104+
Annotations: &mcp.ToolAnnotations{
105+
Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"),
106+
ReadOnlyHint: true,
107+
},
108+
InputSchema: &jsonschema.Schema{
109+
Type: "object",
110+
Properties: map[string]*jsonschema.Schema{
111+
"owner": {
112+
Type: "string",
113+
Description: "The account owner of the repository or organization. The name is not case sensitive.",
114+
},
115+
"repo": {
116+
Type: "string",
117+
Description: "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.",
118+
},
119+
},
120+
Required: []string{"owner"},
121+
},
122+
},
123+
[]scopes.Scope{scopes.Repo, scopes.ReadOrg},
124+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
125+
owner, err := RequiredParam[string](args, "owner")
126+
if err != nil {
127+
return utils.NewToolResultError(err.Error()), nil, nil
128+
}
129+
repo, err := OptionalParam[string](args, "repo")
130+
if err != nil {
131+
return utils.NewToolResultError(err.Error()), nil, nil
132+
}
133+
134+
gqlClient, err := deps.GetGQLClient(ctx)
135+
if err != nil {
136+
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
137+
}
138+
139+
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields")
140+
var nodes []issueFieldNode
141+
if repo != "" {
142+
var query issueFieldsRepoQuery
143+
vars := map[string]any{
144+
"owner": githubv4.String(owner),
145+
"name": githubv4.String(repo),
146+
}
147+
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
148+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil
149+
}
150+
nodes = query.Repository.IssueFields.Nodes
151+
} else {
152+
var query issueFieldsOrgQuery
153+
vars := map[string]any{
154+
"login": githubv4.String(owner),
155+
}
156+
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
157+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil
158+
}
159+
nodes = query.Organization.IssueFields.Nodes
160+
}
161+
162+
fields := make([]IssueField, 0, len(nodes))
163+
for _, node := range nodes {
164+
var f IssueField
165+
// Read from the fragment matching __typename; the other fragments are zero-valued.
166+
switch string(node.TypeName) {
167+
case "IssueFieldSingleSelect":
168+
opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options))
169+
for _, o := range node.IssueFieldSingleSelect.Options {
170+
opts = append(opts, IssueSingleSelectFieldOption{
171+
ID: fmt.Sprintf("%v", o.ID),
172+
Name: string(o.Name),
173+
Description: string(o.Description),
174+
Color: string(o.Color),
175+
Priority: o.Priority,
176+
})
177+
}
178+
f = IssueField{
179+
ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID),
180+
Name: string(node.IssueFieldSingleSelect.Name),
181+
Description: string(node.IssueFieldSingleSelect.Description),
182+
DataType: string(node.IssueFieldSingleSelect.DataType),
183+
Visibility: string(node.IssueFieldSingleSelect.Visibility),
184+
Options: opts,
185+
}
186+
case "IssueFieldText":
187+
f = IssueField{
188+
ID: fmt.Sprintf("%v", node.IssueFieldText.ID),
189+
Name: string(node.IssueFieldText.Name),
190+
Description: string(node.IssueFieldText.Description),
191+
DataType: string(node.IssueFieldText.DataType),
192+
Visibility: string(node.IssueFieldText.Visibility),
193+
}
194+
case "IssueFieldNumber":
195+
f = IssueField{
196+
ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID),
197+
Name: string(node.IssueFieldNumber.Name),
198+
Description: string(node.IssueFieldNumber.Description),
199+
DataType: string(node.IssueFieldNumber.DataType),
200+
Visibility: string(node.IssueFieldNumber.Visibility),
201+
}
202+
case "IssueFieldDate":
203+
f = IssueField{
204+
ID: fmt.Sprintf("%v", node.IssueFieldDate.ID),
205+
Name: string(node.IssueFieldDate.Name),
206+
Description: string(node.IssueFieldDate.Description),
207+
DataType: string(node.IssueFieldDate.DataType),
208+
Visibility: string(node.IssueFieldDate.Visibility),
209+
}
210+
default:
211+
continue
212+
}
213+
fields = append(fields, f)
214+
}
215+
216+
r, err := json.Marshal(fields)
217+
if err != nil {
218+
return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil
219+
}
220+
221+
return utils.NewToolResultText(string(r)), nil, nil
222+
})
223+
}

0 commit comments

Comments
 (0)