|
8 | 8 | "io"
|
9 | 9 | "net/http"
|
10 | 10 | "net/url"
|
11 |
| - "strconv" |
12 | 11 | "strings"
|
13 | 12 |
|
14 | 13 | ghErrors "github.com/github/github-mcp-server/pkg/errors"
|
@@ -495,33 +494,18 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
|
495 | 494 | return mcp.NewToolResultError(err.Error()), nil
|
496 | 495 | }
|
497 | 496 |
|
498 |
| - rawOpts := &raw.ContentOpts{} |
499 |
| - |
500 |
| - if strings.HasPrefix(ref, "refs/pull/") { |
501 |
| - prNumber := strings.TrimSuffix(strings.TrimPrefix(ref, "refs/pull/"), "/head") |
502 |
| - if len(prNumber) > 0 { |
503 |
| - // fetch the PR from the API to get the latest commit and use SHA |
504 |
| - githubClient, err := getClient(ctx) |
505 |
| - if err != nil { |
506 |
| - return nil, fmt.Errorf("failed to get GitHub client: %w", err) |
507 |
| - } |
508 |
| - prNum, err := strconv.Atoi(prNumber) |
509 |
| - if err != nil { |
510 |
| - return nil, fmt.Errorf("invalid pull request number: %w", err) |
511 |
| - } |
512 |
| - pr, _, err := githubClient.PullRequests.Get(ctx, owner, repo, prNum) |
513 |
| - if err != nil { |
514 |
| - return nil, fmt.Errorf("failed to get pull request: %w", err) |
515 |
| - } |
516 |
| - sha = pr.GetHead().GetSHA() |
517 |
| - ref = "" |
518 |
| - } |
| 497 | + client, err := getClient(ctx) |
| 498 | + if err != nil { |
| 499 | + return mcp.NewToolResultError("failed to get GitHub client"), nil |
519 | 500 | }
|
520 | 501 |
|
521 |
| - rawOpts.SHA = sha |
522 |
| - rawOpts.Ref = ref |
| 502 | + rawOpts, err := resolveGitReference(ctx, client, owner, repo, ref, sha) |
| 503 | + if err != nil { |
| 504 | + return mcp.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil |
| 505 | + } |
523 | 506 |
|
524 |
| - // If the path is (most likely) not to be a directory, we will first try to get the raw content from the GitHub raw content API. |
| 507 | + // If the path is (most likely) not to be a directory, we will |
| 508 | + // first try to get the raw content from the GitHub raw content API. |
525 | 509 | if path != "" && !strings.HasSuffix(path, "/") {
|
526 | 510 |
|
527 | 511 | rawClient, err := getRawClient(ctx)
|
@@ -580,36 +564,51 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
|
580 | 564 | }
|
581 | 565 | }
|
582 | 566 |
|
583 |
| - client, err := getClient(ctx) |
584 |
| - if err != nil { |
585 |
| - return mcp.NewToolResultError("failed to get GitHub client"), nil |
586 |
| - } |
587 |
| - |
588 |
| - if sha != "" { |
589 |
| - ref = sha |
| 567 | + if rawOpts.SHA != "" { |
| 568 | + ref = rawOpts.SHA |
590 | 569 | }
|
591 | 570 | if strings.HasSuffix(path, "/") {
|
592 | 571 | opts := &github.RepositoryContentGetOptions{Ref: ref}
|
593 | 572 | _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
|
594 |
| - if err != nil { |
595 |
| - return mcp.NewToolResultError("failed to get file contents"), nil |
596 |
| - } |
597 |
| - defer func() { _ = resp.Body.Close() }() |
598 |
| - |
599 |
| - if resp.StatusCode != 200 { |
600 |
| - body, err := io.ReadAll(resp.Body) |
| 573 | + if err == nil && resp.StatusCode == http.StatusOK { |
| 574 | + defer func() { _ = resp.Body.Close() }() |
| 575 | + r, err := json.Marshal(dirContent) |
601 | 576 | if err != nil {
|
602 |
| - return mcp.NewToolResultError("failed to read response body"), nil |
| 577 | + return mcp.NewToolResultError("failed to marshal response"), nil |
603 | 578 | }
|
604 |
| - return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil |
| 579 | + return mcp.NewToolResultText(string(r)), nil |
605 | 580 | }
|
| 581 | + } |
| 582 | + |
| 583 | + // The path does not point to a file or directory. |
| 584 | + // Instead let's try to find it in the Git Tree by matching the end of the path. |
| 585 | + |
| 586 | + // Step 1: Get Git Tree recursively |
| 587 | + tree, resp, err := client.Git.GetTree(ctx, owner, repo, ref, true) |
| 588 | + if err != nil { |
| 589 | + return ghErrors.NewGitHubAPIErrorResponse(ctx, |
| 590 | + "failed to get git tree", |
| 591 | + resp, |
| 592 | + err, |
| 593 | + ), nil |
| 594 | + } |
| 595 | + defer func() { _ = resp.Body.Close() }() |
606 | 596 |
|
607 |
| - r, err := json.Marshal(dirContent) |
| 597 | + // Step 2: Filter tree for matching paths |
| 598 | + const maxMatchingFiles = 3 |
| 599 | + matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) |
| 600 | + if len(matchingFiles) > 0 { |
| 601 | + matchingFilesJSON, err := json.Marshal(matchingFiles) |
| 602 | + if err != nil { |
| 603 | + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil |
| 604 | + } |
| 605 | + resolvedRefs, err := json.Marshal(rawOpts) |
608 | 606 | if err != nil {
|
609 |
| - return mcp.NewToolResultError("failed to marshal response"), nil |
| 607 | + return mcp.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil |
610 | 608 | }
|
611 |
| - return mcp.NewToolResultText(string(r)), nil |
| 609 | + return mcp.NewToolResultText(fmt.Sprintf("Path did not point to a file or directory, but resolved git ref to %s with possible path matches: %s", resolvedRefs, matchingFilesJSON)), nil |
612 | 610 | }
|
| 611 | + |
613 | 612 | return mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil
|
614 | 613 | }
|
615 | 614 | }
|
@@ -1293,3 +1292,74 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
|
1293 | 1292 | return mcp.NewToolResultText(string(r)), nil
|
1294 | 1293 | }
|
1295 | 1294 | }
|
| 1295 | + |
| 1296 | +// filterPaths filters the entries in a GitHub tree to find paths that |
| 1297 | +// match the given suffix. |
| 1298 | +// maxResults limits the number of results returned to first maxResults entries, |
| 1299 | +// a maxResults of -1 means no limit. |
| 1300 | +// It returns a slice of strings containing the matching paths. |
| 1301 | +// Directories are returned with a trailing slash. |
| 1302 | +func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { |
| 1303 | + // Remove trailing slash for matching purposes, but flag whether we |
| 1304 | + // only want directories. |
| 1305 | + dirOnly := false |
| 1306 | + if strings.HasSuffix(path, "/") { |
| 1307 | + dirOnly = true |
| 1308 | + path = strings.TrimSuffix(path, "/") |
| 1309 | + } |
| 1310 | + |
| 1311 | + matchedPaths := []string{} |
| 1312 | + for _, entry := range entries { |
| 1313 | + if len(matchedPaths) == maxResults { |
| 1314 | + break // Limit the number of results to maxResults |
| 1315 | + } |
| 1316 | + if dirOnly && entry.GetType() != "tree" { |
| 1317 | + continue // Skip non-directory entries if dirOnly is true |
| 1318 | + } |
| 1319 | + entryPath := entry.GetPath() |
| 1320 | + if entryPath == "" { |
| 1321 | + continue // Skip empty paths |
| 1322 | + } |
| 1323 | + if strings.HasSuffix(entryPath, path) { |
| 1324 | + if entry.GetType() == "tree" { |
| 1325 | + entryPath += "/" // Return directories with a trailing slash |
| 1326 | + } |
| 1327 | + matchedPaths = append(matchedPaths, entryPath) |
| 1328 | + } |
| 1329 | + } |
| 1330 | + return matchedPaths |
| 1331 | +} |
| 1332 | + |
| 1333 | +// resolveGitReference resolves git references with the following logic: |
| 1334 | +// 1. If SHA is provided, it takes precedence |
| 1335 | +// 2. If neither is provided, use the default branch as ref |
| 1336 | +// 3. Get commit SHA from the ref |
| 1337 | +// Refs can look like `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` |
| 1338 | +// The function returns the resolved ref, commit SHA and any error. |
| 1339 | +func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, error) { |
| 1340 | + // 1. If SHA is provided, use it directly |
| 1341 | + if sha != "" { |
| 1342 | + return &raw.ContentOpts{Ref: "", SHA: sha}, nil |
| 1343 | + } |
| 1344 | + |
| 1345 | + // 2. If neither provided, use the default branch as ref |
| 1346 | + if ref == "" { |
| 1347 | + repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) |
| 1348 | + if err != nil { |
| 1349 | + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) |
| 1350 | + return nil, fmt.Errorf("failed to get repository info: %w", err) |
| 1351 | + } |
| 1352 | + ref = fmt.Sprintf("refs/heads/%s", repoInfo.GetDefaultBranch()) |
| 1353 | + } |
| 1354 | + |
| 1355 | + // 3. Get the SHA from the ref |
| 1356 | + reference, resp, err := githubClient.Git.GetRef(ctx, owner, repo, ref) |
| 1357 | + if err != nil { |
| 1358 | + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference", resp, err) |
| 1359 | + return nil, fmt.Errorf("failed to get reference: %w", err) |
| 1360 | + } |
| 1361 | + sha = reference.GetObject().GetSHA() |
| 1362 | + |
| 1363 | + // Use provided ref, or it will be empty which defaults to the default branch |
| 1364 | + return &raw.ContentOpts{Ref: ref, SHA: sha}, nil |
| 1365 | +} |
0 commit comments