556 lines
16 KiB
Go
556 lines
16 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
ghErrors "github.com/github/github-mcp-server/pkg/errors"
|
|
"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/go-github/v89/github"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
// uiGetMaxPages bounds how many pages each ui_get pagination loop will fetch.
|
|
// ui_get backs a synchronous UI picker (dropdowns for labels/assignees/etc. in
|
|
// the MCP App issue/PR write surfaces), so responsiveness matters more than
|
|
// completeness. At PerPage 100 this caps a call at ~1000 items and a bounded
|
|
// number of API round-trips, keeping latency predictable on very large
|
|
// repos/orgs. Results past the cap are truncated and surfaced via a "has_more"
|
|
// flag, which is acceptable because the picker pairs truncation with typeahead.
|
|
const uiGetMaxPages = 10
|
|
|
|
// UIGet creates a tool to fetch UI data for MCP Apps.
|
|
func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataContext, // Use context toolset so it's always available
|
|
mcp.Tool{
|
|
Name: "ui_get",
|
|
Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"),
|
|
ReadOnlyHint: true,
|
|
},
|
|
// ui_get only backs MCP App views; declaring app-only visibility keeps
|
|
// it out of the agent's tool list while remaining callable by the views
|
|
// via tools/call (per the MCP Apps 2026-01-26 spec).
|
|
Meta: mcp.Meta{
|
|
"ui": map[string]any{
|
|
"visibility": []string{"app"},
|
|
},
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"method": {
|
|
Type: "string",
|
|
Enum: []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"},
|
|
Description: "The type of data to fetch",
|
|
},
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "Repository owner (required for all methods)",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)",
|
|
},
|
|
},
|
|
Required: []string{"method", "owner"},
|
|
},
|
|
},
|
|
[]scopes.Scope{scopes.Repo, scopes.ReadOrg},
|
|
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
method, err := RequiredParam[string](args, "method")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
owner, err := RequiredParam[string](args, "owner")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
switch method {
|
|
case "labels":
|
|
return uiGetLabels(ctx, deps, args, owner)
|
|
case "assignees":
|
|
return uiGetAssignees(ctx, deps, args, owner)
|
|
case "milestones":
|
|
return uiGetMilestones(ctx, deps, args, owner)
|
|
case "issue_types":
|
|
return uiGetIssueTypes(ctx, deps, owner)
|
|
case "branches":
|
|
return uiGetBranches(ctx, deps, args, owner)
|
|
case "issue_fields":
|
|
return uiGetIssueFields(ctx, deps, args, owner)
|
|
case "reviewers":
|
|
return uiGetReviewers(ctx, deps, args, owner)
|
|
default:
|
|
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
|
|
}
|
|
})
|
|
st.FeatureFlagEnable = MCPAppsFeatureFlag
|
|
return st
|
|
}
|
|
|
|
func uiGetLabels(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
|
|
repo, err := RequiredParam[string](args, "repo")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
client, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
|
|
var query struct {
|
|
Repository struct {
|
|
Labels struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
Name githubv4.String
|
|
Color githubv4.String
|
|
Description githubv4.String
|
|
}
|
|
TotalCount githubv4.Int
|
|
PageInfo struct {
|
|
HasNextPage githubv4.Boolean
|
|
EndCursor githubv4.String
|
|
}
|
|
} `graphql:"labels(first: 100, after: $cursor)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
|
|
vars := map[string]any{
|
|
"owner": githubv4.String(owner),
|
|
"repo": githubv4.String(repo),
|
|
"cursor": (*githubv4.String)(nil),
|
|
}
|
|
|
|
labels := make([]map[string]any, 0)
|
|
var totalCount int
|
|
hasMore := false
|
|
for page := 1; ; page++ {
|
|
if err := client.Query(ctx, &query, vars); err != nil {
|
|
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil
|
|
}
|
|
for _, labelNode := range query.Repository.Labels.Nodes {
|
|
labels = append(labels, map[string]any{
|
|
"id": fmt.Sprintf("%v", labelNode.ID),
|
|
"name": string(labelNode.Name),
|
|
"color": string(labelNode.Color),
|
|
"description": string(labelNode.Description),
|
|
})
|
|
}
|
|
totalCount = int(query.Repository.Labels.TotalCount)
|
|
if !query.Repository.Labels.PageInfo.HasNextPage {
|
|
break
|
|
}
|
|
if page >= uiGetMaxPages {
|
|
hasMore = true
|
|
break
|
|
}
|
|
vars["cursor"] = githubv4.NewString(query.Repository.Labels.PageInfo.EndCursor)
|
|
}
|
|
|
|
response := map[string]any{
|
|
"labels": labels,
|
|
"totalCount": totalCount,
|
|
"has_more": hasMore,
|
|
}
|
|
|
|
out, err := json.Marshal(response)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal labels: %w", err)
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func uiGetAssignees(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
|
|
repo, err := RequiredParam[string](args, "repo")
|
|
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
|
|
}
|
|
|
|
opts := &github.ListOptions{PerPage: 100}
|
|
var allAssignees []*github.User
|
|
hasMore := false
|
|
|
|
for page := 1; ; page++ {
|
|
assignees, resp, err := client.Issues.ListAssignees(ctx, owner, repo, opts)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list assignees", resp, err), nil, nil
|
|
}
|
|
allAssignees = append(allAssignees, assignees...)
|
|
if resp != nil && resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
if page >= uiGetMaxPages {
|
|
hasMore = true
|
|
break
|
|
}
|
|
opts.Page = resp.NextPage
|
|
}
|
|
|
|
result := make([]map[string]string, len(allAssignees))
|
|
for i, u := range allAssignees {
|
|
result[i] = map[string]string{
|
|
"login": u.GetLogin(),
|
|
"avatar_url": u.GetAvatarURL(),
|
|
}
|
|
}
|
|
|
|
out, err := json.Marshal(map[string]any{
|
|
"assignees": result,
|
|
"totalCount": len(result),
|
|
"has_more": hasMore,
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal assignees", err), nil, nil
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func uiGetMilestones(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
|
|
repo, err := RequiredParam[string](args, "repo")
|
|
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
|
|
}
|
|
|
|
opts := &github.MilestoneListOptions{
|
|
State: "open",
|
|
ListOptions: github.ListOptions{PerPage: 100},
|
|
}
|
|
|
|
var allMilestones []*github.Milestone
|
|
hasMore := false
|
|
for page := 1; ; page++ {
|
|
milestones, resp, err := client.Issues.ListMilestones(ctx, owner, repo, opts)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list milestones", resp, err), nil, nil
|
|
}
|
|
allMilestones = append(allMilestones, milestones...)
|
|
if resp != nil && resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
if page >= uiGetMaxPages {
|
|
hasMore = true
|
|
break
|
|
}
|
|
opts.Page = resp.NextPage
|
|
}
|
|
|
|
result := make([]map[string]any, len(allMilestones))
|
|
for i, m := range allMilestones {
|
|
dueOn := ""
|
|
if m.DueOn != nil {
|
|
dueOn = m.GetDueOn().Format("2006-01-02")
|
|
}
|
|
result[i] = map[string]any{
|
|
"number": m.GetNumber(),
|
|
"title": m.GetTitle(),
|
|
"description": m.GetDescription(),
|
|
"state": m.GetState(),
|
|
"open_issues": m.GetOpenIssues(),
|
|
"due_on": dueOn,
|
|
}
|
|
}
|
|
|
|
out, err := json.Marshal(map[string]any{
|
|
"milestones": result,
|
|
"totalCount": len(result),
|
|
"has_more": hasMore,
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal milestones", err), nil, nil
|
|
}
|
|
|
|
return utils.NewToolResultText(string(out)), nil, nil
|
|
}
|
|
|
|
func uiGetIssueTypes(ctx context.Context, deps ToolDependencies, owner string) (*mcp.CallToolResult, any, error) {
|
|
client, err := deps.GetClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
|
|
}
|
|
|
|
issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", resp, err), nil, nil
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
|
|
}
|
|
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil
|
|
}
|
|
|
|
r, err := json.Marshal(issueTypes)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil
|
|
}
|
|
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
}
|
|
|
|
func uiGetBranches(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
|
|
repo, err := RequiredParam[string](args, "repo")
|
|
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
|
|
}
|
|
|
|
opts := &github.BranchListOptions{
|
|
ListOptions: github.ListOptions{PerPage: 100},
|
|
}
|
|
|
|
var allBranches []*github.Branch
|
|
hasMore := false
|
|
for page := 1; ; page++ {
|
|
branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", resp, err), nil, nil
|
|
}
|
|
allBranches = append(allBranches, branches...)
|
|
if resp != nil && resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
if page >= uiGetMaxPages {
|
|
hasMore = true
|
|
break
|
|
}
|
|
opts.Page = resp.NextPage
|
|
}
|
|
|
|
minimalBranches := make([]MinimalBranch, 0, len(allBranches))
|
|
for _, branch := range allBranches {
|
|
minimalBranches = append(minimalBranches, convertToMinimalBranch(branch))
|
|
}
|
|
|
|
r, err := json.Marshal(map[string]any{
|
|
"branches": minimalBranches,
|
|
"totalCount": len(minimalBranches),
|
|
"has_more": hasMore,
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
|
|
}
|
|
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
}
|
|
|
|
func uiGetIssueFields(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
|
|
repo, err := RequiredParam[string](args, "repo")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
gqlClient, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
|
|
}
|
|
|
|
fields, err := fetchIssueFields(ctx, gqlClient, owner, repo)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil
|
|
}
|
|
|
|
return marshalUIGetIssueFields(fields)
|
|
}
|
|
|
|
func marshalUIGetIssueFields(fields []IssueField) (*mcp.CallToolResult, any, error) {
|
|
resultFields := make([]map[string]any, 0, len(fields))
|
|
for _, field := range fields {
|
|
if !uiSupportedIssueFieldDataType(field.DataType) {
|
|
continue
|
|
}
|
|
|
|
fieldResult := map[string]any{
|
|
"id": field.ID,
|
|
"name": field.Name,
|
|
"data_type": field.DataType,
|
|
"description": field.Description,
|
|
}
|
|
|
|
if field.DataType == "single_select" {
|
|
fieldOptions := append([]IssueSingleSelectFieldOption(nil), field.Options...)
|
|
sort.SliceStable(fieldOptions, func(i, j int) bool {
|
|
left, leftOK := issueFieldOptionPriority(fieldOptions[i])
|
|
right, rightOK := issueFieldOptionPriority(fieldOptions[j])
|
|
if leftOK != rightOK {
|
|
return leftOK
|
|
}
|
|
return left < right
|
|
})
|
|
|
|
options := make([]map[string]string, 0, len(fieldOptions))
|
|
for _, option := range fieldOptions {
|
|
options = append(options, map[string]string{
|
|
"name": option.Name,
|
|
"description": option.Description,
|
|
"color": option.Color,
|
|
})
|
|
}
|
|
fieldResult["options"] = options
|
|
}
|
|
|
|
resultFields = append(resultFields, fieldResult)
|
|
}
|
|
|
|
r, err := json.Marshal(map[string]any{
|
|
"fields": resultFields,
|
|
"totalCount": len(resultFields),
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil
|
|
}
|
|
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
}
|
|
|
|
func uiSupportedIssueFieldDataType(dataType string) bool {
|
|
switch dataType {
|
|
case "text", "number", "date", "single_select":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func issueFieldOptionPriority(option IssueSingleSelectFieldOption) (int, bool) {
|
|
if option.Priority == nil {
|
|
return 0, false
|
|
}
|
|
return *option.Priority, true
|
|
}
|
|
|
|
func uiGetReviewers(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) {
|
|
repo, err := RequiredParam[string](args, "repo")
|
|
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
|
|
}
|
|
|
|
collaboratorOpts := &github.ListCollaboratorsOptions{
|
|
Affiliation: "all",
|
|
ListOptions: github.ListOptions{PerPage: 100},
|
|
}
|
|
var allCollaborators []*github.User
|
|
hasMore := false
|
|
for page := 1; ; page++ {
|
|
collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, collaboratorOpts)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewers", resp, err), nil, nil
|
|
}
|
|
allCollaborators = append(allCollaborators, collaborators...)
|
|
if resp != nil && resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
if page >= uiGetMaxPages {
|
|
hasMore = true
|
|
break
|
|
}
|
|
collaboratorOpts.Page = resp.NextPage
|
|
}
|
|
|
|
teamOpts := &github.ListOptions{PerPage: 100}
|
|
var allTeams []*github.Team
|
|
for page := 1; ; page++ {
|
|
teams, resp, err := client.Repositories.ListTeams(ctx, owner, repo, teamOpts)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewer teams", resp, err), nil, nil
|
|
}
|
|
allTeams = append(allTeams, teams...)
|
|
if resp != nil && resp.Body != nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
if page >= uiGetMaxPages {
|
|
hasMore = true
|
|
break
|
|
}
|
|
teamOpts.Page = resp.NextPage
|
|
}
|
|
|
|
users := make([]map[string]string, 0, len(allCollaborators))
|
|
for _, user := range allCollaborators {
|
|
login := user.GetLogin()
|
|
if user.GetType() == "Bot" || strings.HasSuffix(login, "[bot]") {
|
|
continue
|
|
}
|
|
users = append(users, map[string]string{
|
|
"login": login,
|
|
"avatar_url": user.GetAvatarURL(),
|
|
})
|
|
}
|
|
|
|
teams := make([]map[string]string, len(allTeams))
|
|
for i, team := range allTeams {
|
|
teams[i] = map[string]string{
|
|
"slug": team.GetSlug(),
|
|
"name": team.GetName(),
|
|
"org": owner,
|
|
}
|
|
}
|
|
|
|
r, err := json.Marshal(map[string]any{
|
|
"users": users,
|
|
"teams": teams,
|
|
"totalCount": len(users) + len(teams),
|
|
"has_more": hasMore,
|
|
})
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal reviewers", err), nil, nil
|
|
}
|
|
|
|
return utils.NewToolResultText(string(r)), nil, nil
|
|
}
|