Skip to content

Commit a7e7a09

Browse files
use better raw file handling and return resources
1 parent c423a52 commit a7e7a09

File tree

20 files changed

+809
-283
lines changed

20 files changed

+809
-283
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/github/github-mcp-server
33
go 1.23.7
44

55
require (
6+
github.com/aws/smithy-go v1.22.3
67
github.com/google/go-github/v72 v72.0.0
78
github.com/josephburnett/jd v1.9.2
89
github.com/mark3labs/mcp-go v0.31.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
2+
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
13
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
24
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
35
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

internal/ghmcp/server.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/github/github-mcp-server/pkg/github"
1616
mcplog "github.com/github/github-mcp-server/pkg/log"
17+
"github.com/github/github-mcp-server/pkg/raw"
1718
"github.com/github/github-mcp-server/pkg/translations"
1819
gogithub "github.com/google/go-github/v72/github"
1920
"github.com/mark3labs/mcp-go/mcp"
@@ -112,8 +113,16 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
112113
return gqlClient, nil // closing over client
113114
}
114115

116+
getRawClient := func(ctx context.Context) (*raw.Client, error) {
117+
client, err := getClient(ctx)
118+
if err != nil {
119+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
120+
}
121+
return raw.NewClient(client, apiHost.rawURL), nil // closing over client
122+
}
123+
115124
// Create default toolsets
116-
tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, cfg.Translator)
125+
tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator)
117126
err = tsg.EnableToolsets(enabledToolsets)
118127

119128
if err != nil {
@@ -237,6 +246,7 @@ type apiHost struct {
237246
baseRESTURL *url.URL
238247
graphqlURL *url.URL
239248
uploadURL *url.URL
249+
rawURL *url.URL
240250
}
241251

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

268+
rawURL, err := url.Parse("https://raw.githubusercontent.com/")
269+
if err != nil {
270+
return apiHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
271+
}
272+
258273
return apiHost{
259274
baseRESTURL: baseRestURL,
260275
graphqlURL: gqlURL,
261276
uploadURL: uploadURL,
277+
rawURL: rawURL,
262278
}, nil
263279
}
264280

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

307+
rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
308+
if err != nil {
309+
return apiHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
310+
}
311+
291312
return apiHost{
292313
baseRESTURL: restURL,
293314
graphqlURL: gqlURL,
294315
uploadURL: uploadURL,
316+
rawURL: rawURL,
295317
}, nil
296318
}
297319

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

319345
return apiHost{
320346
baseRESTURL: restURL,
321347
graphqlURL: gqlURL,
322348
uploadURL: uploadURL,
349+
rawURL: rawURL,
323350
}, nil
324351
}
325352

pkg/github/helper_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,36 @@ func getTextResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent {
132132
return textContent
133133
}
134134

135+
func getErrorResult(t *testing.T, result *mcp.CallToolResult) mcp.TextContent {
136+
res := getTextResult(t, result)
137+
require.True(t, result.IsError, "expected tool call result to be an error")
138+
return res
139+
}
140+
141+
// getTextResourceResultis a helper function that returns a text result from a tool call.
142+
func getTextResourceResult(t *testing.T, result *mcp.CallToolResult) mcp.TextResourceContents {
143+
t.Helper()
144+
assert.NotNil(t, result)
145+
require.Len(t, result.Content, 2)
146+
content := result.Content[1]
147+
require.IsType(t, mcp.EmbeddedResource{}, content)
148+
resource := content.(mcp.EmbeddedResource)
149+
require.IsType(t, mcp.TextResourceContents{}, resource.Resource)
150+
return resource.Resource.(mcp.TextResourceContents)
151+
}
152+
153+
// getBlobResourceResult is a helper function that returns a blob result from a tool call.
154+
func getBlobResourceResult(t *testing.T, result *mcp.CallToolResult) mcp.BlobResourceContents {
155+
t.Helper()
156+
assert.NotNil(t, result)
157+
require.Len(t, result.Content, 2)
158+
content := result.Content[1]
159+
require.IsType(t, mcp.EmbeddedResource{}, content)
160+
resource := content.(mcp.EmbeddedResource)
161+
require.IsType(t, mcp.BlobResourceContents{}, resource.Resource)
162+
return resource.Resource.(mcp.BlobResourceContents)
163+
}
164+
135165
func TestOptionalParamOK(t *testing.T) {
136166
tests := []struct {
137167
name string

pkg/github/repositories.go

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ package github
22

33
import (
44
"context"
5+
"encoding/base64"
56
"encoding/json"
67
"fmt"
78
"io"
89
"net/http"
10+
"net/url"
11+
"strings"
912

13+
"github.com/github/github-mcp-server/pkg/raw"
1014
"github.com/github/github-mcp-server/pkg/translations"
1115
"github.com/google/go-github/v72/github"
1216
"github.com/mark3labs/mcp-go/mcp"
@@ -409,7 +413,7 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
409413
}
410414

411415
// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository.
412-
func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
416+
func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
413417
return mcp.NewTool("get_file_contents",
414418
mcp.WithDescription(t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository")),
415419
mcp.WithToolAnnotation(mcp.ToolAnnotation{
@@ -426,7 +430,7 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc
426430
),
427431
mcp.WithString("path",
428432
mcp.Required(),
429-
mcp.Description("Path to file/directory"),
433+
mcp.Description("Path to file/directory (directories must end with a slash '/')"),
430434
),
431435
mcp.WithString("branch",
432436
mcp.Description("Branch to get contents from"),
@@ -450,38 +454,89 @@ func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc
450454
return mcp.NewToolResultError(err.Error()), nil
451455
}
452456

453-
client, err := getClient(ctx)
454-
if err != nil {
455-
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
457+
// 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.
458+
if path != "" && !strings.HasSuffix(path, "/") {
459+
rawOpts := &raw.RawContentOpts{}
460+
if branch != "" {
461+
rawOpts.Ref = "refs/heads/" + branch
462+
}
463+
rawClient, err := getRawClient(ctx)
464+
if err != nil {
465+
return mcp.NewToolResultError("failed to get GitHub raw content client"), nil
466+
}
467+
resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts)
468+
if err != nil {
469+
return mcp.NewToolResultError("failed to get raw repository content"), nil
470+
}
471+
472+
if resp.StatusCode != http.StatusOK {
473+
// If the raw content is not found, we will fall back to the GitHub API (in case it is a directory)
474+
} else {
475+
// If the raw content is found, return it directly
476+
body, err := io.ReadAll(resp.Body)
477+
if err != nil {
478+
return mcp.NewToolResultError("failed to read response body"), nil
479+
}
480+
contentType := resp.Header.Get("Content-Type")
481+
482+
var resourceURI string
483+
if branch == "" {
484+
// do a safe url join
485+
resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path)
486+
if err != nil {
487+
return nil, fmt.Errorf("failed to create resource URI: %w", err)
488+
}
489+
} else {
490+
resourceURI, err = url.JoinPath("repo://", owner, repo, "refs", "heads", branch, "contents", path)
491+
if err != nil {
492+
return nil, fmt.Errorf("failed to create resource URI: %w", err)
493+
}
494+
}
495+
if strings.HasPrefix(contentType, "application") || strings.HasPrefix(contentType, "text") {
496+
return mcp.NewToolResultResource("successfully downloaded text file", mcp.TextResourceContents{
497+
URI: resourceURI,
498+
Text: string(body),
499+
MIMEType: contentType,
500+
}), nil
501+
}
502+
503+
return mcp.NewToolResultResource("successfully downloaded binary file", mcp.BlobResourceContents{
504+
URI: resourceURI,
505+
Blob: base64.StdEncoding.EncodeToString(body),
506+
MIMEType: contentType,
507+
}), nil
508+
509+
}
456510
}
457-
opts := &github.RepositoryContentGetOptions{Ref: branch}
458-
fileContent, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
511+
512+
client, err := getClient(ctx)
459513
if err != nil {
460-
return nil, fmt.Errorf("failed to get file contents: %w", err)
514+
return mcp.NewToolResultError("failed to get GitHub client"), nil
461515
}
462-
defer func() { _ = resp.Body.Close() }()
463516

464-
if resp.StatusCode != 200 {
465-
body, err := io.ReadAll(resp.Body)
517+
if strings.HasSuffix(path, "/") {
518+
opts := &github.RepositoryContentGetOptions{Ref: branch}
519+
_, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
466520
if err != nil {
467-
return nil, fmt.Errorf("failed to read response body: %w", err)
521+
return mcp.NewToolResultError("failed to get file contents"), nil
468522
}
469-
return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil
470-
}
523+
defer func() { _ = resp.Body.Close() }()
471524

472-
var result interface{}
473-
if fileContent != nil {
474-
result = fileContent
475-
} else {
476-
result = dirContent
477-
}
525+
if resp.StatusCode != 200 {
526+
body, err := io.ReadAll(resp.Body)
527+
if err != nil {
528+
return mcp.NewToolResultError("failed to read response body"), nil
529+
}
530+
return mcp.NewToolResultError(fmt.Sprintf("failed to get file contents: %s", string(body))), nil
531+
}
478532

479-
r, err := json.Marshal(result)
480-
if err != nil {
481-
return nil, fmt.Errorf("failed to marshal response: %w", err)
533+
r, err := json.Marshal(dirContent)
534+
if err != nil {
535+
return mcp.NewToolResultError("failed to marshal response"), nil
536+
}
537+
return mcp.NewToolResultText(string(r)), nil
482538
}
483-
484-
return mcp.NewToolResultText(string(r)), nil
539+
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
485540
}
486541
}
487542

0 commit comments

Comments
 (0)