Files
2026-07-13 12:37:57 +08:00

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
}