package github import ( "context" "errors" "fmt" "strings" "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) // CompleteHandler defines function signature for completion handlers type CompleteHandler func(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) // RepositoryResourceArgumentResolvers is a map of argument names to their completion handlers var RepositoryResourceArgumentResolvers = map[string]CompleteHandler{ "owner": completeOwner, "repo": completeRepo, "branch": completeBranch, "sha": completeSHA, "tag": completeTag, "prNumber": completePRNumber, "path": completePath, } // RepositoryResourceCompletionHandler returns a CompletionHandlerFunc for repository resource completions. func RepositoryResourceCompletionHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { if req.Params.Ref.Type != "ref/resource" { return nil, nil // Not a resource completion } argName := req.Params.Argument.Name argValue := req.Params.Argument.Value var resolved map[string]string if req.Params.Context != nil && req.Params.Context.Arguments != nil { resolved = req.Params.Context.Arguments } else { resolved = map[string]string{} } client, err := getClient(ctx) if err != nil { return nil, err } // Argument resolver functions resolvers := RepositoryResourceArgumentResolvers resolver, ok := resolvers[argName] if !ok { return nil, errors.New("no resolver for argument: " + argName) } values, err := resolver(ctx, client, resolved, argValue) if err != nil { return nil, err } if len(values) > 100 { values = values[:100] } return &mcp.CompleteResult{ Completion: mcp.CompletionResultDetails{ Values: values, Total: len(values), HasMore: false, }, }, nil } } // --- Per-argument resolver functions --- func completeOwner(ctx context.Context, client *github.Client, _ map[string]string, argValue string) ([]string, error) { var values []string user, _, err := client.Users.Get(ctx, "") if err == nil && user.GetLogin() != "" { values = append(values, user.GetLogin()) } orgs, _, err := client.Organizations.List(ctx, "", &github.ListOptions{PerPage: 100}) if err != nil { return nil, err } for _, org := range orgs { values = append(values, org.GetLogin()) } // filter values based on argValue and replace values slice if argValue != "" { var filteredValues []string for _, value := range values { if strings.Contains(value, argValue) { filteredValues = append(filteredValues, value) } } values = filteredValues } if len(values) > 100 { values = values[:100] return values, nil // Limit to 100 results } // Else also do a client.Search.Users() if argValue == "" { return values, nil // No need to search if no argValue } users, _, err := client.Search.Users(ctx, argValue, &github.SearchOptions{ListOptions: github.ListOptions{PerPage: 100 - len(values)}}) if err != nil || users == nil { return nil, err } for _, user := range users.Users { values = append(values, user.GetLogin()) } if len(values) > 100 { values = values[:100] } return values, nil } func completeRepo(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) { var values []string owner := resolved["owner"] if owner == "" { return values, errors.New("owner not specified") } query := fmt.Sprintf("org:%s", owner) if argValue != "" { query = fmt.Sprintf("%s %s", query, argValue) } repos, _, err := client.Search.Repositories(ctx, query, &github.SearchOptions{ListOptions: github.ListOptions{PerPage: 100}}) if err != nil || repos == nil { return values, errors.New("failed to get repositories") } // filter repos based on argValue for _, repo := range repos.Repositories { name := repo.GetName() if argValue == "" || strings.HasPrefix(name, argValue) { values = append(values, name) } } return values, nil } func completeBranch(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) { var values []string owner := resolved["owner"] repo := resolved["repo"] if owner == "" || repo == "" { return values, errors.New("owner or repo not specified") } branches, _, _ := client.Repositories.ListBranches(ctx, owner, repo, nil) for _, branch := range branches { if argValue == "" || strings.HasPrefix(branch.GetName(), argValue) { values = append(values, branch.GetName()) } } if len(values) > 100 { values = values[:100] } return values, nil } func completeSHA(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) { var values []string owner := resolved["owner"] repo := resolved["repo"] if owner == "" || repo == "" { return values, errors.New("owner or repo not specified") } commits, _, _ := client.Repositories.ListCommits(ctx, owner, repo, nil) for _, commit := range commits { sha := commit.GetSHA() if argValue == "" || strings.HasPrefix(sha, argValue) { values = append(values, sha) } } if len(values) > 100 { values = values[:100] } return values, nil } func completeTag(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) { owner := resolved["owner"] repo := resolved["repo"] if owner == "" || repo == "" { return nil, errors.New("owner or repo not specified") } tags, _, _ := client.Repositories.ListTags(ctx, owner, repo, nil) var values []string for _, tag := range tags { if argValue == "" || strings.Contains(tag.GetName(), argValue) { values = append(values, tag.GetName()) } } if len(values) > 100 { values = values[:100] } return values, nil } func completePRNumber(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) { var values []string owner := resolved["owner"] repo := resolved["repo"] if owner == "" || repo == "" { return values, errors.New("owner or repo not specified") } prs, _, err := client.Search.Issues(ctx, fmt.Sprintf("repo:%s/%s is:open is:pr", owner, repo), &github.SearchOptions{ListOptions: github.ListOptions{PerPage: 100}}) if err != nil { return values, err } for _, pr := range prs.Issues { num := fmt.Sprintf("%d", pr.GetNumber()) if argValue == "" || strings.HasPrefix(num, argValue) { values = append(values, num) } } if len(values) > 100 { values = values[:100] } return values, nil } func completePath(ctx context.Context, client *github.Client, resolved map[string]string, argValue string) ([]string, error) { owner := resolved["owner"] repo := resolved["repo"] if owner == "" || repo == "" { return nil, errors.New("owner or repo not specified") } refVal := resolved["branch"] if refVal == "" { refVal = resolved["sha"] } if refVal == "" { refVal = resolved["tag"] } if refVal == "" { refVal = "HEAD" } // Determine the prefix to complete (directory path or file path) prefix := argValue if prefix != "" && !strings.HasSuffix(prefix, "/") { lastSlash := strings.LastIndex(prefix, "/") if lastSlash >= 0 { prefix = prefix[:lastSlash+1] } else { prefix = "" } } // Get the tree for the ref (recursive) tree, _, err := client.Git.GetTree(ctx, owner, repo, refVal, true) if err != nil || tree == nil { return nil, errors.New("failed to get file tree") } // Collect immediate children of the prefix (files and directories, no duplicates) dirs := map[string]struct{}{} files := map[string]struct{}{} prefixLen := len(prefix) for _, entry := range tree.Entries { if !strings.HasPrefix(entry.GetPath(), prefix) { continue } rel := entry.GetPath()[prefixLen:] if rel == "" { continue } // Only immediate children slashIdx := strings.Index(rel, "/") if slashIdx >= 0 { // Directory: only add the directory name (with trailing slash), prefixed with full path dirName := prefix + rel[:slashIdx+1] dirs[dirName] = struct{}{} } else if entry.GetType() == "blob" { // File: add as-is, prefixed with full path fileName := prefix + rel files[fileName] = struct{}{} } } // Optionally filter by argValue (if user is typing after last slash) var filter string if argValue != "" { if lastSlash := strings.LastIndex(argValue, "/"); lastSlash >= 0 { filter = argValue[lastSlash+1:] } else { filter = argValue } } var values []string // Add directories first, then files, both filtered for dir := range dirs { // Only filter on the last segment after the last slash if filter == "" { values = append(values, dir) } else { last := dir if idx := strings.LastIndex(strings.TrimRight(dir, "/"), "/"); idx >= 0 { last = dir[idx+1:] } if strings.HasPrefix(last, filter) { values = append(values, dir) } } } for file := range files { if filter == "" { values = append(values, file) } else { last := file if idx := strings.LastIndex(file, "/"); idx >= 0 { last = file[idx+1:] } if strings.HasPrefix(last, filter) { values = append(values, file) } } } if len(values) > 100 { values = values[:100] } return values, nil }