Skip to content

Commit 879cb46

Browse files
authored
Flatten schema
1 parent c77ac79 commit 879cb46

5 files changed

Lines changed: 496 additions & 113 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -875,7 +875,7 @@ The following sets of tools are available:
875875
- **Required OAuth Scopes**: `repo`
876876
- `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
877877
- `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional)
878-
- `field_filters`: Filter by custom issue field values. Each entry must specify field_name and exactly one typed value field. (object[], optional)
878+
- `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional)
879879
- `labels`: Filter by labels (string[], optional)
880880
- `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional)
881881
- `owner`: Repository owner (string, required)

pkg/github/__toolsnaps__/list_issues.snap

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,32 +19,21 @@
1919
"type": "string"
2020
},
2121
"field_filters": {
22-
"description": "Filter by custom issue field values. Each entry must specify field_name and exactly one typed value field.",
22+
"description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).",
2323
"items": {
2424
"properties": {
25-
"date_value": {
26-
"description": "For date fields, the date to match (YYYY-MM-DD).",
27-
"type": "string"
28-
},
2925
"field_name": {
30-
"description": "Name of the custom field (e.g. \"Priority\").",
31-
"type": "string"
32-
},
33-
"number_value": {
34-
"description": "For number fields, the numeric value to match.",
35-
"type": "number"
36-
},
37-
"single_select_value": {
38-
"description": "For single-select fields, the option name to match (e.g. \"P1\").",
26+
"description": "Name of the custom field (e.g. \"Priority\"). Case-insensitive.",
3927
"type": "string"
4028
},
41-
"text_value": {
42-
"description": "For text fields, the text value to match.",
29+
"value": {
30+
"description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.",
4331
"type": "string"
4432
}
4533
},
4634
"required": [
47-
"field_name"
35+
"field_name",
36+
"value"
4837
],
4938
"type": "object"
5039
},

pkg/github/issue_fields.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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+
// The monolith enforces ORGANIZATION_ISSUE_FIELDS_LIMIT = 25 fields per organization
81+
type issueFieldsRepoQuery struct {
82+
Repository struct {
83+
IssueFields struct {
84+
Nodes []issueFieldNode
85+
} `graphql:"issueFields(first: 25)"`
86+
} `graphql:"repository(owner: $owner, name: $name)"`
87+
}
88+
89+
// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization.
90+
type issueFieldsOrgQuery struct {
91+
Organization struct {
92+
IssueFields struct {
93+
Nodes []issueFieldNode
94+
} `graphql:"issueFields(first: 25)"`
95+
} `graphql:"organization(login: $login)"`
96+
}
97+
98+
// ListIssueFields creates a tool to list issue field definitions for a repository or organization.
99+
func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
100+
return NewTool(
101+
ToolsetMetadataIssues,
102+
mcp.Tool{
103+
Name: "list_issue_fields",
104+
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."),
105+
Annotations: &mcp.ToolAnnotations{
106+
Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"),
107+
ReadOnlyHint: true,
108+
},
109+
InputSchema: &jsonschema.Schema{
110+
Type: "object",
111+
Properties: map[string]*jsonschema.Schema{
112+
"owner": {
113+
Type: "string",
114+
Description: "The account owner of the repository or organization. The name is not case sensitive.",
115+
},
116+
"repo": {
117+
Type: "string",
118+
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.",
119+
},
120+
},
121+
Required: []string{"owner"},
122+
},
123+
},
124+
[]scopes.Scope{scopes.Repo, scopes.ReadOrg},
125+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
126+
owner, err := RequiredParam[string](args, "owner")
127+
if err != nil {
128+
return utils.NewToolResultError(err.Error()), nil, nil
129+
}
130+
repo, err := OptionalParam[string](args, "repo")
131+
if err != nil {
132+
return utils.NewToolResultError(err.Error()), nil, nil
133+
}
134+
135+
gqlClient, err := deps.GetGQLClient(ctx)
136+
if err != nil {
137+
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
138+
}
139+
140+
fields, err := fetchIssueFields(ctx, gqlClient, owner, repo)
141+
if err != nil {
142+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil
143+
}
144+
145+
r, err := json.Marshal(fields)
146+
if err != nil {
147+
return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil
148+
}
149+
150+
return utils.NewToolResultText(string(r)), nil, nil
151+
})
152+
}
153+
154+
// fetchIssueFields returns the issue field definitions for the given owner.
155+
// If repo is provided, fields are scoped to that repository (inherited from its
156+
// organization); otherwise fields are returned directly from the organization.
157+
func fetchIssueFields(ctx context.Context, gqlClient *githubv4.Client, owner, repo string) ([]IssueField, error) {
158+
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields")
159+
if repo != "" {
160+
var query issueFieldsRepoQuery
161+
vars := map[string]any{
162+
"owner": githubv4.String(owner),
163+
"name": githubv4.String(repo),
164+
}
165+
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
166+
return nil, err
167+
}
168+
return issueFieldsFromNodes(query.Repository.IssueFields.Nodes), nil
169+
}
170+
171+
var query issueFieldsOrgQuery
172+
vars := map[string]any{
173+
"login": githubv4.String(owner),
174+
}
175+
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
176+
return nil, err
177+
}
178+
return issueFieldsFromNodes(query.Organization.IssueFields.Nodes), nil
179+
}
180+
181+
// issueFieldsFromNodes converts GraphQL issue field union nodes into IssueField values.
182+
// Read from the fragment matching __typename; the other fragments are zero-valued.
183+
func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField {
184+
fields := make([]IssueField, 0, len(nodes))
185+
for _, node := range nodes {
186+
var f IssueField
187+
switch string(node.TypeName) {
188+
case "IssueFieldSingleSelect":
189+
opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options))
190+
for _, o := range node.IssueFieldSingleSelect.Options {
191+
opts = append(opts, IssueSingleSelectFieldOption{
192+
ID: fmt.Sprintf("%v", o.ID),
193+
Name: string(o.Name),
194+
Description: string(o.Description),
195+
Color: string(o.Color),
196+
Priority: o.Priority,
197+
})
198+
}
199+
f = IssueField{
200+
ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID),
201+
Name: string(node.IssueFieldSingleSelect.Name),
202+
Description: string(node.IssueFieldSingleSelect.Description),
203+
DataType: string(node.IssueFieldSingleSelect.DataType),
204+
Visibility: string(node.IssueFieldSingleSelect.Visibility),
205+
Options: opts,
206+
}
207+
case "IssueFieldText":
208+
f = IssueField{
209+
ID: fmt.Sprintf("%v", node.IssueFieldText.ID),
210+
Name: string(node.IssueFieldText.Name),
211+
Description: string(node.IssueFieldText.Description),
212+
DataType: string(node.IssueFieldText.DataType),
213+
Visibility: string(node.IssueFieldText.Visibility),
214+
}
215+
case "IssueFieldNumber":
216+
f = IssueField{
217+
ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID),
218+
Name: string(node.IssueFieldNumber.Name),
219+
Description: string(node.IssueFieldNumber.Description),
220+
DataType: string(node.IssueFieldNumber.DataType),
221+
Visibility: string(node.IssueFieldNumber.Visibility),
222+
}
223+
case "IssueFieldDate":
224+
f = IssueField{
225+
ID: fmt.Sprintf("%v", node.IssueFieldDate.ID),
226+
Name: string(node.IssueFieldDate.Name),
227+
Description: string(node.IssueFieldDate.Description),
228+
DataType: string(node.IssueFieldDate.DataType),
229+
Visibility: string(node.IssueFieldDate.Visibility),
230+
}
231+
default:
232+
continue
233+
}
234+
fields = append(fields, f)
235+
}
236+
return fields
237+
}

0 commit comments

Comments
 (0)