Skip to content

use better raw file handling and return resources #505

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 12, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
29 changes: 28 additions & 1 deletion internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/github/github-mcp-server/pkg/github"
mcplog "github.com/github/github-mcp-server/pkg/log"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
gogithub "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
Expand Down Expand Up @@ -112,8 +113,16 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
return gqlClient, nil // closing over client
}

getRawClient := func(ctx context.Context) (*raw.Client, error) {
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
return raw.NewClient(client, apiHost.rawURL), nil // closing over client
}

// Create default toolsets
tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, cfg.Translator)
tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator)
err = tsg.EnableToolsets(enabledToolsets)

if err != nil {
Expand Down Expand Up @@ -237,6 +246,7 @@ type apiHost struct {
baseRESTURL *url.URL
graphqlURL *url.URL
uploadURL *url.URL
rawURL *url.URL
}

func newDotcomHost() (apiHost, error) {
Expand All @@ -255,10 +265,16 @@ func newDotcomHost() (apiHost, error) {
return apiHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
}

rawURL, err := url.Parse("https://raw.githubusercontent.com/")
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
}

return apiHost{
baseRESTURL: baseRestURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}

Expand Down Expand Up @@ -288,10 +304,16 @@ func newGHECHost(hostname string) (apiHost, error) {
return apiHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
}

rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
}

return apiHost{
baseRESTURL: restURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}

Expand All @@ -315,11 +337,16 @@ func newGHESHost(hostname string) (apiHost, error) {
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
}
rawURL, err := url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname()))
if err != nil {
return apiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
}

return apiHost{
baseRESTURL: restURL,
graphqlURL: gqlURL,
uploadURL: uploadURL,
rawURL: rawURL,
}, nil
}

Expand Down
30 changes: 30 additions & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,36 @@ func getTextResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent {
return textContent
}

func getErrorResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent {
res := getTextResult(t, result)
require.True(t, result.IsError, "expected tool call result to be an error")
return res
}

// getTextResourceResult is a helper function that returns a text result from a tool call.
func getTextResourceResult(t *testing.T, result *mcp.CallToolResult) mcp.TextResourceContents {
t.Helper()
assert.NotNil(t, result)
require.Len(t, result.Content, 2)
content := result.Content[1]
require.IsType(t, mcp.EmbeddedResource{}, content)
resource := content.(mcp.EmbeddedResource)
require.IsType(t, mcp.TextResourceContents{}, resource.Resource)
return resource.Resource.(mcp.TextResourceContents)
}

// getBlobResourceResult is a helper function that returns a blob result from a tool call.
func getBlobResourceResult(t *testing.T, result *mcp.CallToolResult) mcp.BlobResourceContents {
t.Helper()
assert.NotNil(t, result)
require.Len(t, result.Content, 2)
content := result.Content[1]
require.IsType(t, mcp.EmbeddedResource{}, content)
resource := content.(mcp.EmbeddedResource)
require.IsType(t, mcp.BlobResourceContents{}, resource.Resource)
return resource.Resource.(mcp.BlobResourceContents)
}

func TestOptionalParamOK(t *testing.T) {
tests := []struct {
name string
Expand Down
108 changes: 83 additions & 25 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package github

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
Expand Down Expand Up @@ -409,7 +413,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, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
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.WithToolAnnotation(mcp.ToolAnnotation{
Expand All @@ -426,7 +430,7 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc
),
mcp.WithString("path",
mcp.Required(),
mcp.Description("Path to file/directory"),
mcp.Description("Path to file/directory (directories must end with a slash '/')"),
),
mcp.WithString("branch",
mcp.Description("Branch to get contents from"),
Expand All @@ -450,38 +454,92 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc
return mcp.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
// 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
}
resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts)
if err != nil {
return mcp.NewToolResultError("failed to get raw repository content"), nil
}
defer func() {
_ = resp.Body.Close()
}()

if resp.StatusCode != http.StatusOK {
// If the raw content is not found, we will fall back to the GitHub API (in case it is a directory)
} else {
// If the raw content is found, return it directly
body, err := io.ReadAll(resp.Body)
if err != nil {
return mcp.NewToolResultError("failed to read response body"), nil
}
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)
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)
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,
Text: string(body),
MIMEType: contentType,
}), nil
}

return mcp.NewToolResultResource("successfully downloaded binary file", mcp.BlobResourceContents{
URI: resourceURI,
Blob: base64.StdEncoding.EncodeToString(body),
MIMEType: contentType,
}), nil

}
}
opts := &github.RepositoryContentGetOptions{Ref: branch}
fileContent, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get file contents: %w", err)
return mcp.NewToolResultError("failed to get GitHub client"), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != 200 {
body, err := io.ReadAll(resp.Body)
if strings.HasSuffix(path, "/") {
opts := &github.RepositoryContentGetOptions{Ref: branch}
_, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
return mcp.NewToolResultError("failed to get file contents"), nil
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil
}
defer func() { _ = resp.Body.Close() }()

var result interface{}
if fileContent != nil {
result = fileContent
} else {
result = dirContent
}
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
}

r, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
r, err := json.Marshal(dirContent)
if err != nil {
return mcp.NewToolResultError("failed to marshal response"), nil
}
return mcp.NewToolResultText(string(r)), 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
Loading