diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index 1aad08db2..9fa74c3c6 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -3,7 +3,11 @@ package github import ( "context" "encoding/base64" + "errors" + "fmt" + "io" "mime" + "net/http" "path/filepath" "strings" @@ -13,110 +17,185 @@ import ( "github.com/mark3labs/mcp-go/server" ) -// getRepositoryContent defines the resource template and handler for the Repository Content API. -func getRepositoryContent(client *github.Client, t translations.TranslationHelperFunc) (mainTemplate mcp.ResourceTemplate, reftemplate mcp.ResourceTemplate, shaTemplate mcp.ResourceTemplate, tagTemplate mcp.ResourceTemplate, prTemplate mcp.ResourceTemplate, handler server.ResourceTemplateHandlerFunc) { - +// getRepositoryResourceContent defines the resource template and handler for getting repository content. +func getRepositoryResourceContent(client *github.Client, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) { return mcp.NewResourceTemplate( "repo://{owner}/{repo}/contents{/path*}", // Resource template t("RESOURCE_REPOSITORY_CONTENT_DESCRIPTION", "Repository Content"), - ), mcp.NewResourceTemplate( + ), + repositoryResourceContentsHandler(client) +} + +// getRepositoryContent defines the resource template and handler for getting repository content for a branch. +func getRepositoryResourceBranchContent(client *github.Client, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) { + return mcp.NewResourceTemplate( "repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}", // Resource template t("RESOURCE_REPOSITORY_CONTENT_BRANCH_DESCRIPTION", "Repository Content for specific branch"), - ), mcp.NewResourceTemplate( + ), + repositoryResourceContentsHandler(client) +} + +// getRepositoryResourceCommitContent defines the resource template and handler for getting repository content for a commit. +func getRepositoryResourceCommitContent(client *github.Client, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) { + return mcp.NewResourceTemplate( "repo://{owner}/{repo}/sha/{sha}/contents{/path*}", // Resource template t("RESOURCE_REPOSITORY_CONTENT_COMMIT_DESCRIPTION", "Repository Content for specific commit"), - ), mcp.NewResourceTemplate( + ), + repositoryResourceContentsHandler(client) +} + +// getRepositoryResourceTagContent defines the resource template and handler for getting repository content for a tag. +func getRepositoryResourceTagContent(client *github.Client, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) { + return mcp.NewResourceTemplate( "repo://{owner}/{repo}/refs/tags/{tag}/contents{/path*}", // Resource template t("RESOURCE_REPOSITORY_CONTENT_TAG_DESCRIPTION", "Repository Content for specific tag"), - ), mcp.NewResourceTemplate( + ), + repositoryResourceContentsHandler(client) +} + +// getRepositoryResourcePrContent defines the resource template and handler for getting repository content for a pull request. +func getRepositoryResourcePrContent(client *github.Client, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, server.ResourceTemplateHandlerFunc) { + return mcp.NewResourceTemplate( "repo://{owner}/{repo}/refs/pull/{pr_number}/head/contents{/path*}", // Resource template t("RESOURCE_REPOSITORY_CONTENT_PR_DESCRIPTION", "Repository Content for specific pull request"), - ), func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - // Extract parameters from request.Params.URI + ), + repositoryResourceContentsHandler(client) +} - owner := request.Params.Arguments["owner"].([]string)[0] - repo := request.Params.Arguments["repo"].([]string)[0] - // path should be a joined list of the path parts - path := strings.Join(request.Params.Arguments["path"].([]string), "/") +func repositoryResourceContentsHandler(client *github.Client) func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + return func(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { + // the matcher will give []string with one elemenent + // https://github.com/mark3labs/mcp-go/pull/54 + o, ok := request.Params.Arguments["owner"].([]string) + if !ok || len(o) == 0 { + return nil, errors.New("owner is required") + } + owner := o[0] - opts := &github.RepositoryContentGetOptions{} + r, ok := request.Params.Arguments["repo"].([]string) + if !ok || len(r) == 0 { + return nil, errors.New("repo is required") + } + repo := r[0] - sha, ok := request.Params.Arguments["sha"].([]string) - if ok { - opts.Ref = sha[0] - } + // path should be a joined list of the path parts + path := "" + p, ok := request.Params.Arguments["path"].([]string) + if ok { + path = strings.Join(p, "/") + } - branch, ok := request.Params.Arguments["branch"].([]string) - if ok { - opts.Ref = "refs/heads/" + branch[0] - } + opts := &github.RepositoryContentGetOptions{} - tag, ok := request.Params.Arguments["tag"].([]string) - if ok { - opts.Ref = "refs/tags/" + tag[0] - } - prNumber, ok := request.Params.Arguments["pr_number"].([]string) - if ok { - opts.Ref = "refs/pull/" + prNumber[0] + "/head" - } + sha, ok := request.Params.Arguments["sha"].([]string) + if ok && len(sha) > 0 { + opts.Ref = sha[0] + } - // Use the GitHub client to fetch repository content - fileContent, directoryContent, _, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) - if err != nil { - return nil, err - } + branch, ok := request.Params.Arguments["branch"].([]string) + if ok && len(branch) > 0 { + opts.Ref = "refs/heads/" + branch[0] + } + + tag, ok := request.Params.Arguments["tag"].([]string) + if ok && len(tag) > 0 { + opts.Ref = "refs/tags/" + tag[0] + } + prNumber, ok := request.Params.Arguments["pr_number"].([]string) + if ok && len(prNumber) > 0 { + opts.Ref = "refs/pull/" + prNumber[0] + "/head" + } - if directoryContent != nil { - // Process the directory content and return it as resource contents - var resources []mcp.ResourceContents - for _, entry := range directoryContent { - mimeType := "text/directory" - if entry.GetType() == "file" { - mimeType = mime.TypeByExtension(filepath.Ext(entry.GetName())) + fileContent, directoryContent, _, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) + if err != nil { + return nil, err + } + + if directoryContent != nil { + var resources []mcp.ResourceContents + for _, entry := range directoryContent { + mimeType := "text/directory" + if entry.GetType() == "file" { + // this is system dependent, and a best guess + ext := filepath.Ext(entry.GetName()) + mimeType = mime.TypeByExtension(ext) + if ext == ".md" { + mimeType = "text/markdown" } - resources = append(resources, mcp.TextResourceContents{ - URI: entry.GetHTMLURL(), - MIMEType: mimeType, - Text: entry.GetName(), - }) + } + resources = append(resources, mcp.TextResourceContents{ + URI: entry.GetHTMLURL(), + MIMEType: mimeType, + Text: entry.GetName(), + }) + + } + return resources, nil + } + if fileContent != nil { + if fileContent.Content != nil { + // download the file content from fileContent.GetDownloadURL() and use the content-type header to determine the MIME type + // and return the content as a blob unless it is a text file, where you can return the content as text + req, err := http.NewRequest("GET", fileContent.GetDownloadURL(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) } - return resources, nil - } else if fileContent != nil { - // Process the file content and return it as a binary resource + resp, err := client.Client().Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer func() { _ = resp.Body.Close() }() - if fileContent.Content != nil { - decodedContent, err := fileContent.GetContent() + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read response body: %w", err) } + return nil, fmt.Errorf("failed to fetch file content: %s", string(body)) + } - mimeType := mime.TypeByExtension(filepath.Ext(fileContent.GetName())) - - // Check if the file is text-based - if strings.HasPrefix(mimeType, "text") { - // Return as TextResourceContents - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: request.Params.URI, - MIMEType: mimeType, - Text: decodedContent, - }, - }, nil + ext := filepath.Ext(fileContent.GetName()) + mimeType := resp.Header.Get("Content-Type") + if ext == ".md" { + mimeType = "text/markdown" + } else if mimeType == "" { + // backstop to the file extension if the content type is not set + mimeType = mime.TypeByExtension(filepath.Ext(fileContent.GetName())) + } + + // if the content is a string, return it as text + if strings.HasPrefix(mimeType, "text") { + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to parse the response body: %w", err) } - // Otherwise, return as BlobResourceContents return []mcp.ResourceContents{ - mcp.BlobResourceContents{ + mcp.TextResourceContents{ URI: request.Params.URI, MIMEType: mimeType, - Blob: base64.StdEncoding.EncodeToString([]byte(decodedContent)), // Encode content as Base64 + Text: string(content), }, }, nil } - } + // otherwise, read the content and encode it as base64 + decodedContent, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to parse the response body: %w", err) + } - return nil, nil + return []mcp.ResourceContents{ + mcp.BlobResourceContents{ + URI: request.Params.URI, + MIMEType: mimeType, + Blob: base64.StdEncoding.EncodeToString(decodedContent), // Encode content as Base64 + }, + }, nil + } } + + return nil, errors.New("no repository resource content found") + } } diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go new file mode 100644 index 000000000..0a5b0b0f0 --- /dev/null +++ b/pkg/github/repository_resource_test.go @@ -0,0 +1,283 @@ +package github + +import ( + "context" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v69/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/require" +) + +var GetRawReposContentsByOwnerByRepoByPath mock.EndpointPattern = mock.EndpointPattern{ + Pattern: "/{owner}/{repo}/main/{path:.+}", + Method: "GET", +} + +func Test_repositoryResourceContentsHandler(t *testing.T) { + mockDirContent := []*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"), + }, + { + Type: github.Ptr("dir"), + Name: github.Ptr("src"), + Path: github.Ptr("src"), + SHA: github.Ptr("def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), + DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/src"), + }, + } + expectedDirContent := []mcp.TextResourceContents{ + { + URI: "https://github.com/owner/repo/blob/main/README.md", + MIMEType: "text/markdown", + Text: "README.md", + }, + { + URI: "https://github.com/owner/repo/tree/main/src", + MIMEType: "text/directory", + Text: "src", + }, + } + + mockTextContent := &github.RepositoryContent{ + Type: github.Ptr("file"), + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + Content: github.Ptr("# Test Repository\n\nThis is a test repository."), + 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"), + } + + mockFileContent := &github.RepositoryContent{ + Type: github.Ptr("file"), + Name: github.Ptr("data.png"), + Path: github.Ptr("data.png"), + Content: github.Ptr("IyBUZXN0IFJlcG9zaXRvcnkKClRoaXMgaXMgYSB0ZXN0IHJlcG9zaXRvcnku"), // Base64 encoded "# Test Repository\n\nThis is a test repository." + SHA: github.Ptr("abc123"), + Size: github.Ptr(42), + HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/data.png"), + DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/data.png"), + } + + expectedFileContent := []mcp.BlobResourceContents{ + { + Blob: "IyBUZXN0IFJlcG9zaXRvcnkKClRoaXMgaXMgYSB0ZXN0IHJlcG9zaXRvcnku", + MIMEType: "image/png", + URI: "", + }, + } + + expectedTextContent := []mcp.TextResourceContents{ + { + Text: "# Test Repository\n\nThis is a test repository.", + MIMEType: "text/markdown", + URI: "", + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError string + expectedResult any + expectedErrMsg string + }{ + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposContentsByOwnerByRepoByPath, + mockFileContent, + ), + ), + requestArgs: map[string]any{}, + expectError: "owner is required", + }, + { + name: "missing repo", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposContentsByOwnerByRepoByPath, + mockFileContent, + ), + ), + requestArgs: map[string]any{ + "owner": []string{"owner"}, + }, + expectError: "repo is required", + }, + { + name: "successful blob content fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposContentsByOwnerByRepoByPath, + mockFileContent, + ), + mock.WithRequestMatchHandler( + GetRawReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + // as this is given as a png, it will return the content as a blob + _, err := w.Write([]byte("# Test Repository\n\nThis is a test repository.")) + require.NoError(t, err) + }), + ), + ), + requestArgs: map[string]any{ + "owner": []string{"owner"}, + "repo": []string{"repo"}, + "path": []string{"data.png"}, + "branch": []string{"main"}, + }, + expectedResult: expectedFileContent, + }, + { + name: "successful text content fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposContentsByOwnerByRepoByPath, + mockTextContent, + ), + mock.WithRequestMatch( + GetRawReposContentsByOwnerByRepoByPath, + []byte("# Test Repository\n\nThis is a test repository."), + ), + ), + requestArgs: map[string]any{ + "owner": []string{"owner"}, + "repo": []string{"repo"}, + "path": []string{"README.md"}, + "branch": []string{"main"}, + }, + expectedResult: expectedTextContent, + }, + { + name: "successful directory content fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposContentsByOwnerByRepoByPath, + mockDirContent, + ), + ), + requestArgs: map[string]any{ + "owner": []string{"owner"}, + "repo": []string{"repo"}, + "path": []string{"src"}, + }, + expectedResult: expectedDirContent, + }, + { + name: "no data", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposContentsByOwnerByRepoByPath, + ), + ), + requestArgs: map[string]any{ + "owner": []string{"owner"}, + "repo": []string{"repo"}, + "path": []string{"src"}, + }, + expectedResult: nil, + expectError: "no repository resource content found", + }, + { + name: "empty data", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposContentsByOwnerByRepoByPath, + []*github.RepositoryContent{}, + ), + ), + requestArgs: map[string]any{ + "owner": []string{"owner"}, + "repo": []string{"repo"}, + "path": []string{"src"}, + }, + expectedResult: nil, + }, + { + name: "content fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": []string{"owner"}, + "repo": []string{"repo"}, + "path": []string{"nonexistent.md"}, + "branch": []string{"main"}, + }, + expectError: "404 Not Found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + handler := repositoryResourceContentsHandler(client) + + request := mcp.ReadResourceRequest{ + Params: struct { + URI string `json:"uri"` + Arguments map[string]any `json:"arguments,omitempty"` + }{ + Arguments: tc.requestArgs, + }, + } + + resp, err := handler(context.TODO(), request) + + if tc.expectError != "" { + require.ErrorContains(t, err, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.ElementsMatch(t, resp, tc.expectedResult) + }) + } +} + +func Test_getRepositoryResourceContent(t *testing.T) { + tmpl, _ := getRepositoryResourceContent(nil, translations.NullTranslationHelper) + require.Equal(t, "repo://{owner}/{repo}/contents{/path*}", tmpl.URITemplate.Raw()) +} + +func Test_getRepositoryResourceBranchContent(t *testing.T) { + tmpl, _ := getRepositoryResourceBranchContent(nil, translations.NullTranslationHelper) + require.Equal(t, "repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}", tmpl.URITemplate.Raw()) +} +func Test_getRepositoryResourceCommitContent(t *testing.T) { + tmpl, _ := getRepositoryResourceCommitContent(nil, translations.NullTranslationHelper) + require.Equal(t, "repo://{owner}/{repo}/sha/{sha}/contents{/path*}", tmpl.URITemplate.Raw()) +} + +func Test_getRepositoryResourceTagContent(t *testing.T) { + tmpl, _ := getRepositoryResourceTagContent(nil, translations.NullTranslationHelper) + require.Equal(t, "repo://{owner}/{repo}/refs/tags/{tag}/contents{/path*}", tmpl.URITemplate.Raw()) +} + +func Test_getRepositoryResourcePrContent(t *testing.T) { + tmpl, _ := getRepositoryResourcePrContent(nil, translations.NullTranslationHelper) + require.Equal(t, "repo://{owner}/{repo}/refs/pull/{pr_number}/head/contents{/path*}", tmpl.URITemplate.Raw()) +} diff --git a/pkg/github/server.go b/pkg/github/server.go index ce39c87e9..d652dde05 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -25,13 +25,11 @@ func NewServer(client *github.Client, readOnly bool, t translations.TranslationH server.WithLogging()) // Add GitHub Resources - defaultTemplate, branchTemplate, tagTemplate, shaTemplate, prTemplate, handler := getRepositoryContent(client, t) - - s.AddResourceTemplate(defaultTemplate, handler) - s.AddResourceTemplate(branchTemplate, handler) - s.AddResourceTemplate(tagTemplate, handler) - s.AddResourceTemplate(shaTemplate, handler) - s.AddResourceTemplate(prTemplate, handler) + s.AddResourceTemplate(getRepositoryResourceContent(client, t)) + s.AddResourceTemplate(getRepositoryResourceBranchContent(client, t)) + s.AddResourceTemplate(getRepositoryResourceCommitContent(client, t)) + s.AddResourceTemplate(getRepositoryResourceTagContent(client, t)) + s.AddResourceTemplate(getRepositoryResourcePrContent(client, t)) // Add GitHub tools - Issues s.AddTool(getIssue(client, t))
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: