chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user