diff --git a/README.md b/README.md index 5f9baa780e..a950710dcd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pkg/github/__toolsnaps__/list_org_issue_fields.snap b/pkg/github/__toolsnaps__/list_org_issue_fields.snap new file mode 100644 index 0000000000..c4959b0ec9 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_org_issue_fields.snap @@ -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" +} \ No newline at end of file diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go new file mode 100644 index 0000000000..0bb7ca24ba --- /dev/null +++ b/pkg/github/issue_fields.go @@ -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) + 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 + }) +} diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go new file mode 100644 index 0000000000..7ad47a8ff3 --- /dev/null +++ b/pkg/github/issue_fields_test.go @@ -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) + } + } + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 559088f6d6..47e4be4ff9 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -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),