Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion pkg/github/__toolsnaps__/get_file_contents.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"title": "Get file or directory contents",
"readOnlyHint": true
},
"description": "Get the contents of a file or directory from a GitHub repository",
"description": "Get the contents of a file or directory from a GitHub repository. Set `include_sha` to `true` to return file metadata (including SHA, size, type) instead of raw content.",
"inputSchema": {
"properties": {
"owner": {
Expand All @@ -25,6 +25,10 @@
"sha": {
"description": "Accepts optional git sha, if sha is specified it will be used instead of ref",
"type": "string"
},
"include_sha": {
"description": "Whether to return file metadata (including SHA, size, type) instead of raw content",
"type": "boolean"
}
},
"required": [
Expand Down
56 changes: 40 additions & 16 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository.
func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_file_contents",
mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository")),
mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository. Set `include_sha` to `true` to return file metadata (including SHA, size, type) instead of raw content.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"),
ReadOnlyHint: ToBoolPtr(true),
Expand All @@ -468,7 +468,10 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
mcp.WithString("sha",
mcp.Description("Accepts optional git sha, if sha is specified it will be used instead of ref"),
),
),
mcp.WithBoolean("include_sha",
mcp.Description("Whether to return file metadata (including SHA, size, type) instead of raw content"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
if err != nil {
Expand All @@ -490,6 +493,10 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
includeSha, err := OptionalParam[bool](request, "include_sha")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

rawOpts := &raw.RawContentOpts{}

Expand Down Expand Up @@ -518,7 +525,8 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
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, "/") {
// Skip raw content if include_sha is true
if !includeSha && path != "" && !strings.HasSuffix(path, "/") {

rawClient, err := getRawClient(ctx)
if err != nil {
Expand Down Expand Up @@ -586,28 +594,44 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
if sha != "" {
ref = sha
}
if strings.HasSuffix(path, "/") {
opts := &github.RepositoryContentGetOptions{Ref: ref}
_, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
// Try to get file/directory contents using GitHub API
opts := &github.RepositoryContentGetOptions{Ref: ref}
fileContent, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get file contents",
resp,
err,
), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != 200 {
body, err := io.ReadAll(resp.Body)
if err != nil {
return mcp.NewToolResultError("failed to get file contents"), nil
return mcp.NewToolResultError("failed to read response body"), nil
}
defer func() { _ = resp.Body.Close() }()
return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil
}

if resp.StatusCode != 200 {
body, err := io.ReadAll(resp.Body)
if err != nil {
return mcp.NewToolResultError("failed to read response body"), nil
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil
// Handle directory contents
if dirContent != nil {
r, err := json.Marshal(dirContent)
if err != nil {
return mcp.NewToolResultError("failed to marshal directory contents"), nil
}
return mcp.NewToolResultText(string(r)), nil
}

r, err := json.Marshal(dirContent)
// Handle file contents
if fileContent != nil {
r, err := json.Marshal(fileContent)
if err != nil {
return mcp.NewToolResultError("failed to marshal response"), nil
return mcp.NewToolResultError("failed to marshal file contents"), nil
}
return mcp.NewToolResultText(string(r)), nil
}

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
}
}
Expand Down
106 changes: 102 additions & 4 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,25 @@ func Test_GetFileContents(t *testing.T) {
assert.Contains(t, tool.InputSchema.Properties, "path")
assert.Contains(t, tool.InputSchema.Properties, "ref")
assert.Contains(t, tool.InputSchema.Properties, "sha")
assert.Contains(t, tool.InputSchema.Properties, "include_sha")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path"})

// Mock response for raw content
mockRawContent := []byte("# Test Repository\n\nThis is a test repository.")

// Setup mock file content for include_sha test
mockFileContent := &github.RepositoryContent{
Type: github.Ptr("file"),
Name: github.Ptr("README.md"),
Path: github.Ptr("README.md"),
SHA: github.Ptr("abc123"),
Size: github.Ptr(42),
HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"),
DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"),
Content: github.Ptr(base64.StdEncoding.EncodeToString(mockRawContent)),
Encoding: github.Ptr("base64"),
}

// Setup mock directory content for success case
mockDirContent := []*github.RepositoryContent{
{
Expand Down Expand Up @@ -140,6 +154,68 @@ func Test_GetFileContents(t *testing.T) {
expectError: false,
expectedResult: mockDirContent,
},
{
name: "successful file metadata fetch with include_sha",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposContentsByOwnerByRepoByPath,
expectQueryParams(t, map[string]string{"ref": "refs/heads/main"}).andThen(
mockResponse(t, http.StatusOK, mockFileContent),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"path": "README.md",
"ref": "refs/heads/main",
"include_sha": true,
},
expectError: false,
expectedResult: mockFileContent,
},
{
name: "successful text content fetch with include_sha false",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
raw.GetRawReposContentsByOwnerByRepoByBranchByPath,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/markdown")
_, _ = w.Write(mockRawContent)
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"path": "README.md",
"ref": "refs/heads/main",
"include_sha": false,
},
expectError: false,
expectedResult: mcp.TextResourceContents{
URI: "repo://owner/repo/refs/heads/main/contents/README.md",
Text: "# Test Repository\n\nThis is a test repository.",
MIMEType: "text/markdown",
},
},
{
name: "successful directory metadata fetch with include_sha",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposContentsByOwnerByRepoByPath,
mockResponse(t, http.StatusOK, mockDirContent),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"path": "src/",
"include_sha": true,
},
expectError: false,
expectedResult: mockDirContent,
},
{
name: "content fetch fails",
mockedClient: mock.NewMockedHTTPClient(
Expand All @@ -165,7 +241,8 @@ func Test_GetFileContents(t *testing.T) {
"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."),
expectedResult: nil,
expectedErrMsg: "failed to get file contents",
},
}

Expand All @@ -190,6 +267,17 @@ func Test_GetFileContents(t *testing.T) {
}

require.NoError(t, err)

// Check for tool errors (API errors that return as tool results)
if tc.expectedErrMsg != "" {
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}

// Process successful results
require.False(t, result.IsError)
// Use the correct result helper based on the expected type
switch expected := tc.expectedResult.(type) {
case mcp.TextResourceContents:
Expand All @@ -210,9 +298,19 @@ func Test_GetFileContents(t *testing.T) {
assert.Equal(t, *expected[i].Path, *content.Path)
assert.Equal(t, *expected[i].Type, *content.Type)
}
case mcp.TextContent:
textContent := getErrorResult(t, result)
require.Equal(t, textContent, expected)
case *github.RepositoryContent:
// File metadata fetch returns a text result (JSON object)
textContent := getTextResult(t, result)
var returnedContent github.RepositoryContent
err = json.Unmarshal([]byte(textContent.Text), &returnedContent)
require.NoError(t, err)
assert.Equal(t, *expected.Name, *returnedContent.Name)
assert.Equal(t, *expected.Path, *returnedContent.Path)
assert.Equal(t, *expected.SHA, *returnedContent.SHA)
assert.Equal(t, *expected.Size, *returnedContent.Size)
if expected.Content != nil {
assert.Equal(t, *expected.Content, *returnedContent.Content)
}
}
})
}
Expand Down