-
Notifications
You must be signed in to change notification settings - Fork 4.1k
Add list_org_issue_fields tool #2438
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
| } |
| 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) | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one? google/go-github#4077 |
||
| 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 | ||
| }) | ||
| } | ||
| 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) | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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