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))