|
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