diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index c2c6f19f..b3975abb 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -6,10 +6,6 @@ "description": "Get the contents of a file or directory from a GitHub repository", "inputSchema": { "properties": { - "branch": { - "description": "Branch to get contents from", - "type": "string" - }, "owner": { "description": "Repository owner (username or organization)", "type": "string" @@ -18,9 +14,17 @@ "description": "Path to file/directory (directories must end with a slash '/')", "type": "string" }, + "ref": { + "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + "type": "string" + }, "repo": { "description": "Repository name", "type": "string" + }, + "sha": { + "description": "Accepts optional git sha, if sha is specified it will be used instead of ref", + "type": "string" } }, "required": [ diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 3475167b..85fa2d7b 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "github.com/github/github-mcp-server/pkg/raw" @@ -432,8 +433,11 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t mcp.Required(), mcp.Description("Path to file/directory (directories must end with a slash '/')"), ), - mcp.WithString("branch", - mcp.Description("Branch to get contents from"), + mcp.WithString("ref", + mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"), + ), + mcp.WithString("sha", + mcp.Description("Accepts optional git sha, if sha is specified it will be used instead of ref"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -449,17 +453,44 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t if err != nil { return mcp.NewToolResultError(err.Error()), nil } - branch, err := OptionalParam[string](request, "branch") + ref, err := OptionalParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sha, err := OptionalParam[string](request, "sha") if err != nil { return mcp.NewToolResultError(err.Error()), nil } + rawOpts := &raw.RawContentOpts{} + + if strings.HasPrefix(ref, "refs/pull/") { + prNumber := strings.TrimSuffix(strings.TrimPrefix(ref, "refs/pull/"), "/head") + if len(prNumber) > 0 { + // fetch the PR from the API to get the latest commit and use SHA + githubClient, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + prNum, err := strconv.Atoi(prNumber) + if err != nil { + return nil, fmt.Errorf("invalid pull request number: %w", err) + } + pr, _, err := githubClient.PullRequests.Get(ctx, owner, repo, prNum) + if err != nil { + return nil, fmt.Errorf("failed to get pull request: %w", err) + } + sha = pr.GetHead().GetSHA() + ref = "" + } + } + + rawOpts.SHA = sha + rawOpts.Ref = ref + // 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. if path != "" && !strings.HasSuffix(path, "/") { - rawOpts := &raw.RawContentOpts{} - if branch != "" { - rawOpts.Ref = "refs/heads/" + branch - } + rawClient, err := getRawClient(ctx) if err != nil { return mcp.NewToolResultError("failed to get GitHub raw content client"), nil @@ -483,18 +514,24 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t contentType := resp.Header.Get("Content-Type") var resourceURI string - if branch == "" { - // do a safe url join - resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) + switch { + case sha != "": + resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) if err != nil { return nil, fmt.Errorf("failed to create resource URI: %w", err) } - } else { - resourceURI, err = url.JoinPath("repo://", owner, repo, "refs", "heads", branch, "contents", path) + case ref != "": + resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) + if err != nil { + return nil, fmt.Errorf("failed to create resource URI: %w", err) + } + default: + resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) if err != nil { return nil, fmt.Errorf("failed to create resource URI: %w", err) } } + if strings.HasPrefix(contentType, "application") || strings.HasPrefix(contentType, "text") { return mcp.NewToolResultResource("successfully downloaded text file", mcp.TextResourceContents{ URI: resourceURI, @@ -517,8 +554,11 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t return mcp.NewToolResultError("failed to get GitHub client"), nil } + if sha != "" { + ref = sha + } if strings.HasSuffix(path, "/") { - opts := &github.RepositoryContentGetOptions{Ref: branch} + opts := &github.RepositoryContentGetOptions{Ref: ref} _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) if err != nil { return mcp.NewToolResultError("failed to get file contents"), nil diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 3ba0f1aa..3f632120 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -31,7 +31,8 @@ func Test_GetFileContents(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "path") - assert.Contains(t, tool.InputSchema.Properties, "branch") + assert.Contains(t, tool.InputSchema.Properties, "ref") + assert.Contains(t, tool.InputSchema.Properties, "sha") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path"}) // Mock response for raw content @@ -77,10 +78,10 @@ func Test_GetFileContents(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "README.md", - "branch": "main", + "owner": "owner", + "repo": "repo", + "path": "README.md", + "ref": "refs/heads/main", }, expectError: false, expectedResult: mcp.TextResourceContents{ @@ -101,10 +102,10 @@ func Test_GetFileContents(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "test.png", - "branch": "main", + "owner": "owner", + "repo": "repo", + "path": "test.png", + "ref": "refs/heads/main", }, expectError: false, expectedResult: mcp.BlobResourceContents{ @@ -158,10 +159,10 @@ func Test_GetFileContents(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", - "path": "nonexistent.md", - "branch": "main", + "owner": "owner", + "repo": "repo", + "path": "nonexistent.md", + "ref": "refs/heads/main", }, expectError: false, expectedResult: 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."),