diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index f6203f39f..52b7b07f1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -715,86 +716,72 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool if fileContent != nil && fileContent.SHA != nil { fileSHA = *fileContent.SHA - - rawClient, err := deps.GetRawClient(ctx) + fileSize := fileContent.GetSize() + // Build resource URI for the file using URI templates + pathParts := strings.Split(path, "/") + resourceURI, err := expandRepoResourceURI(owner, repo, sha, ref, pathParts) if err != nil { - return utils.NewToolResultError("failed to get GitHub raw content client"), nil, nil + return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) } - resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) - if err != nil { - return utils.NewToolResultError("failed to get raw repository content"), nil, nil + + // main branch ref passed in ref parameter but it doesn't exist - default branch was used + var successNote string + if fallbackUsed { + successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref) } - defer func() { - _ = resp.Body.Close() - }() - if resp.StatusCode == http.StatusOK { - // If the raw content is found, return it directly - body, err := io.ReadAll(resp.Body) - if err != nil { - return ghErrors.NewGitHubRawAPIErrorResponse(ctx, "failed to get raw repository content", resp, err), nil, nil - } - contentType := resp.Header.Get("Content-Type") - - var resourceURI string - switch { - case sha != "": - resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) - if err != nil { - return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) - } - case ref != "": - resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) - if err != nil { - return nil, 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, nil, fmt.Errorf("failed to create resource URI: %w", err) - } + // For files >= 1MB, return a ResourceLink instead of content + const maxContentSize = 1024 * 1024 // 1MB + if fileSize >= maxContentSize { + size := int64(fileSize) + resourceLink := &mcp.ResourceLink{ + URI: resourceURI, + Name: fileContent.GetName(), + Title: fmt.Sprintf("File: %s", path), + Size: &size, } + return utils.NewToolResultResourceLink( + fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s", + path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote), + resourceLink), nil, nil + } - // main branch ref passed in ref parameter but it doesn't exist - default branch was used - var successNote string - if fallbackUsed { - successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref) - } + // For files < 1MB, get content directly from Contents API + content, err := fileContent.GetContent() + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to decode file content: %s", err)), nil, nil + } - // Determine if content is text or binary - isTextContent := strings.HasPrefix(contentType, "text/") || - contentType == "application/json" || - contentType == "application/xml" || - strings.HasSuffix(contentType, "+json") || - strings.HasSuffix(contentType, "+xml") - - if isTextContent { - result := &mcp.ResourceContents{ - URI: resourceURI, - Text: string(body), - MIMEType: contentType, - } - // Include SHA in the result metadata - if fileSHA != "" { - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA)+successNote, result), nil, nil - } - return utils.NewToolResultResource("successfully downloaded text file"+successNote, result), nil, nil - } + // Detect content type from the actual content bytes, + // mirroring the original approach of using the Content-Type header + // from the raw API response. + contentBytes := []byte(content) + contentType := http.DetectContentType(contentBytes) + + // Determine if content is text or binary based on detected content type + isTextContent := strings.HasPrefix(contentType, "text/") || + contentType == "application/json" || + contentType == "application/xml" || + strings.HasSuffix(contentType, "+json") || + strings.HasSuffix(contentType, "+xml") + if isTextContent { result := &mcp.ResourceContents{ URI: resourceURI, - Blob: body, + Text: content, MIMEType: contentType, } - // Include SHA in the result metadata - if fileSHA != "" { - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA)+successNote, result), nil, nil - } - return utils.NewToolResultResource("successfully downloaded binary file"+successNote, result), nil, nil + return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result), nil, nil } - // Raw API call failed - return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, resp.StatusCode) + // Binary content - encode as base64 blob + blobContent := base64.StdEncoding.EncodeToString(contentBytes) + result := &mcp.ResourceContents{ + URI: resourceURI, + Blob: []byte(blobContent), + MIMEType: contentType, + } + return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result), nil, nil } else if dirContent != nil { // file content or file SHA is nil which means it's a directory r, err := json.Marshal(dirContent) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index d91af8851..7a971d9e9 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -78,19 +78,20 @@ func Test_GetFileContents(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // Base64 encode the content as GitHub API does + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }, - GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, }), requestArgs: map[string]interface{}{ "owner": "owner", @@ -102,29 +103,31 @@ func Test_GetFileContents(t *testing.T) { expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/README.md", Text: "# Test Repository\n\nThis is a test repository.", - MIMEType: "text/markdown", + MIMEType: "text/plain; charset=utf-8", }, }, { - name: "successful file blob content fetch", + name: "successful binary file content fetch (PNG)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // PNG magic bytes followed by some data + pngContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01") + encodedContent := base64.StdEncoding.EncodeToString(pngContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("test.png"), - Path: github.Ptr("test.png"), - SHA: github.Ptr("def456"), - Type: github.Ptr("file"), + Name: github.Ptr("test.png"), + Path: github.Ptr("test.png"), + SHA: github.Ptr("def456"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(pngContent)), + Encoding: github.Ptr("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }, - GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(mockRawContent) - }, }), requestArgs: map[string]interface{}{ "owner": "owner", @@ -135,30 +138,32 @@ func Test_GetFileContents(t *testing.T) { expectError: false, expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/test.png", - Blob: mockRawContent, + Blob: []byte(base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"))), MIMEType: "image/png", }, }, { - name: "successful PDF file content fetch", + name: "successful binary file content fetch (PDF)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // PDF magic bytes + pdfContent := []byte("%PDF-1.4 fake pdf content") + encodedContent := base64.StdEncoding.EncodeToString(pdfContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("document.pdf"), - Path: github.Ptr("document.pdf"), - SHA: github.Ptr("pdf123"), - Type: github.Ptr("file"), + Name: github.Ptr("document.pdf"), + Path: github.Ptr("document.pdf"), + SHA: github.Ptr("pdf123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(pdfContent)), + Encoding: github.Ptr("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }, - GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write(mockRawContent) - }, }), requestArgs: map[string]interface{}{ "owner": "owner", @@ -169,7 +174,7 @@ func Test_GetFileContents(t *testing.T) { expectError: false, expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/document.pdf", - Blob: mockRawContent, + Blob: []byte(base64.StdEncoding.EncodeToString([]byte("%PDF-1.4 fake pdf content"))), MIMEType: "application/pdf", }, }, @@ -200,19 +205,20 @@ func Test_GetFileContents(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // Base64 encode the content as GitHub API does + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }, - GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, }), requestArgs: map[string]interface{}{ "owner": "owner", @@ -224,7 +230,7 @@ func Test_GetFileContents(t *testing.T) { expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/README.md", Text: "# Test Repository\n\nThis is a test repository.", - MIMEType: "text/markdown", + MIMEType: "text/plain; charset=utf-8", }, }, { @@ -239,7 +245,7 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`)) default: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -253,7 +259,7 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`)) default: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -267,7 +273,7 @@ func Test_GetFileContents(t *testing.T) { _, _ = w.Write([]byte(`{"message": "Not Found"}`)) case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`)) default: w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -279,31 +285,24 @@ func Test_GetFileContents(t *testing.T) { }, "GET /repos/owner/repo/git/ref/heads/develop": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`)) }, GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) + // Base64 encode the content as GitHub API does + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }, - "GET /owner/repo/refs/heads/develop/README.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, - "GET /owner/repo/refs%2Fheads%2Fdevelop/README.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, - "GET /owner/repo/abc123def456/README.md": func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }, }), requestArgs: map[string]interface{}{ "owner": "owner", @@ -313,12 +312,45 @@ func Test_GetFileContents(t *testing.T) { }, expectError: false, expectedResult: mcp.ResourceContents{ - URI: "repo://owner/repo/abc123def456/contents/README.md", + URI: "repo://owner/repo/sha/abc123def456abc123def456abc123def456abc1/contents/README.md", Text: "# Test Repository\n\nThis is a test repository.", - MIMEType: "text/markdown", + MIMEType: "text/plain; charset=utf-8", }, expectedMsg: " Note: the provided ref 'main' does not exist, default branch 'refs/heads/develop' was used instead.", }, + { + name: "large file returns ResourceLink", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + // File larger than 1MB - Contents API returns metadata but no content + fileContent := &github.RepositoryContent{ + Name: github.Ptr("large-file.bin"), + Path: github.Ptr("large-file.bin"), + SHA: github.Ptr("largesha123"), + Type: github.Ptr("file"), + Size: github.Ptr(2 * 1024 * 1024), // 2MB + DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/large-file.bin"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "large-file.bin", + "ref": "refs/heads/main", + }, + expectError: false, + expectedResult: &mcp.ResourceLink{ + URI: "repo://owner/repo/refs/heads/main/contents/large-file.bin", + Name: "large-file.bin", + Title: "File: large-file.bin", + }, + }, { name: "content fetch fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -395,6 +427,14 @@ func Test_GetFileContents(t *testing.T) { assert.Equal(t, *expected[i].Path, *content.Path) assert.Equal(t, *expected[i].Type, *content.Type) } + case *mcp.ResourceLink: + // Large file returns a ResourceLink + require.Len(t, result.Content, 2) + resourceLink, ok := result.Content[1].(*mcp.ResourceLink) + require.True(t, ok, "expected Content[1] to be ResourceLink") + assert.Equal(t, expected.URI, resourceLink.URI) + assert.Equal(t, expected.Name, resourceLink.Name) + assert.Equal(t, expected.Title, resourceLink.Title) case mcp.TextContent: textContent := getErrorResult(t, result) require.Equal(t, textContent, expected) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index 28ce63b46..2303bc204 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -257,3 +257,54 @@ func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template } } } + +// expandRepoResourceURI builds a resource URI using the appropriate URI template +// based on the provided parameters (sha, ref, or default). +func expandRepoResourceURI(owner, repo, sha, ref string, pathParts []string) (string, error) { + baseValues := uritemplate.Values{ + "owner": uritemplate.String(owner), + "repo": uritemplate.String(repo), + "path": uritemplate.List(pathParts...), + } + + switch { + case sha != "": + baseValues["sha"] = uritemplate.String(sha) + return repositoryResourceCommitContentURITemplate.Expand(baseValues) + + case ref != "": + // Parse ref to determine which template to use + switch { + case strings.HasPrefix(ref, "refs/heads/"): + branch := strings.TrimPrefix(ref, "refs/heads/") + baseValues["branch"] = uritemplate.String(branch) + return repositoryResourceBranchContentURITemplate.Expand(baseValues) + + case strings.HasPrefix(ref, "refs/tags/"): + tag := strings.TrimPrefix(ref, "refs/tags/") + baseValues["tag"] = uritemplate.String(tag) + return repositoryResourceTagContentURITemplate.Expand(baseValues) + + case strings.HasPrefix(ref, "refs/pull/") && strings.HasSuffix(ref, "/head"): + // Extract PR number from "refs/pull/{number}/head" + prPart := strings.TrimPrefix(ref, "refs/pull/") + prNumber := strings.TrimSuffix(prPart, "/head") + baseValues["prNumber"] = uritemplate.String(prNumber) + return repositoryResourcePrContentURITemplate.Expand(baseValues) + + case looksLikeSHA(ref): + // ref is actually a SHA (e.g., from resolveGitReference) + baseValues["sha"] = uritemplate.String(ref) + return repositoryResourceCommitContentURITemplate.Expand(baseValues) + + default: + // For other refs (like a branch name without refs/heads/ prefix), + // treat it as a branch + baseValues["branch"] = uritemplate.String(ref) + return repositoryResourceBranchContentURITemplate.Expand(baseValues) + } + + default: + return repositoryResourceContentURITemplate.Expand(baseValues) + } +} diff --git a/pkg/utils/result.go b/pkg/utils/result.go index 533fe0573..1bfd800e2 100644 --- a/pkg/utils/result.go +++ b/pkg/utils/result.go @@ -47,3 +47,15 @@ func NewToolResultResource(message string, contents *mcp.ResourceContents) *mcp. IsError: false, } } + +func NewToolResultResourceLink(message string, link *mcp.ResourceLink) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + link, + }, + IsError: false, + } +}