diff --git a/README.md b/README.md index f0d31f0be..184ab974e 100644 --- a/README.md +++ b/README.md @@ -1093,7 +1093,8 @@ Possible options: - **get_file_contents** - Get file or directory contents - `owner`: Repository owner (username or organization) (string, required) - `path`: Path to file/directory (string, optional) - - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) + - `raw_content`: If true, returns file content as plain text (for text files) or base64-encoded (for binary files) instead of embedded resource format. Useful for clients that don't support embedded resources. (boolean, optional) + - `ref`: Accepts optional git refs such as refs/tags/{tag}, refs/heads/{branch} or refs/pull/{pr_number}/head (string, optional) - `repo`: Repository name (string, required) - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) diff --git a/docs/remote-server.md b/docs/remote-server.md index 1030911ef..e06d41a75 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Default | ["Default" toolset](../README.md#default-toolset) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | diff --git a/go.mod b/go.mod index 661778fc3..065e5c11a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( require ( github.com/aymerick/douceur v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect github.com/google/go-github/v71 v71.0.0 // indirect diff --git a/go.sum b/go.sum index e422a548c..973b00a74 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index 638452fe7..418a30447 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -20,9 +20,13 @@ "description": "Path to file/directory", "default": "/" }, + "raw_content": { + "type": "boolean", + "description": "If true, returns file content as plain text (for text files) or base64-encoded (for binary files) instead of embedded resource format. Useful for clients that don't support embedded resources." + }, "ref": { "type": "string", - "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`" + "description": "Accepts optional git refs such as refs/tags/{tag}, refs/heads/{branch} or refs/pull/{pr_number}/head" }, "repo": { "type": "string", diff --git a/pkg/github/mimetype.go b/pkg/github/mimetype.go new file mode 100644 index 000000000..92cfe66a2 --- /dev/null +++ b/pkg/github/mimetype.go @@ -0,0 +1,229 @@ +package github + +import ( + "path" + "strings" + + "github.com/gabriel-vasile/mimetype" +) + +// codeExtensionMimeTypes maps common code file extensions to MIME types. +// This is needed because Go's stdlib mime.TypeByExtension has gaps and wrong mappings +// for many code-related extensions (e.g., .ts returns Qt Linguist, .tsx returns nothing). +var codeExtensionMimeTypes = map[string]string{ + // JavaScript/TypeScript + ".ts": "text/typescript", + ".tsx": "text/typescript-jsx", + ".mts": "text/typescript", + ".cts": "text/typescript", + ".js": "text/javascript", + ".jsx": "text/javascript-jsx", + ".mjs": "text/javascript", + ".cjs": "text/javascript", + ".vue": "text/x-vue", + ".svelte": "text/x-svelte", + + // Go + ".go": "text/x-go", + ".mod": "text/x-go-mod", + ".sum": "text/x-go-sum", + ".work": "text/x-go-work", + + // Rust + ".rs": "text/x-rust", + ".toml": "text/x-toml", + + // Python + ".py": "text/x-python", + ".pyi": "text/x-python", + ".pyx": "text/x-cython", + ".pxd": "text/x-cython", + + // Ruby + ".rb": "text/x-ruby", + ".rake": "text/x-ruby", + ".gemspec": "text/x-ruby", + ".erb": "text/x-erb", + + // Java/Kotlin/Scala + ".java": "text/x-java-source", + ".kt": "text/x-kotlin", + ".kts": "text/x-kotlin", + ".scala": "text/x-scala", + ".groovy": "text/x-groovy", + + // C family + ".c": "text/x-c", + ".h": "text/x-c", + ".cpp": "text/x-c++", + ".cc": "text/x-c++", + ".cxx": "text/x-c++", + ".hpp": "text/x-c++", + ".hh": "text/x-c++", + ".hxx": "text/x-c++", + ".m": "text/x-objective-c", + ".mm": "text/x-objective-c++", + + // C#/F# + ".cs": "text/x-csharp", + ".fs": "text/x-fsharp", + + // Swift + ".swift": "text/x-swift", + + // PHP + ".php": "text/x-php", + ".phtml": "text/x-php", + + // Shell scripts + ".sh": "text/x-shellscript", + ".bash": "text/x-shellscript", + ".zsh": "text/x-shellscript", + ".fish": "text/x-shellscript", + + // Config/Data files + ".json": "application/json", + ".yml": "text/yaml", + ".yaml": "text/yaml", + ".xml": "text/xml", + ".ini": "text/x-ini", + ".cfg": "text/x-ini", + ".conf": "text/plain", + ".env": "text/plain", + + // Markup/Documentation + ".md": "text/markdown", + ".markdown": "text/markdown", + ".rst": "text/x-rst", + ".adoc": "text/asciidoc", + ".tex": "text/x-tex", + + // Web + ".html": "text/html", + ".htm": "text/html", + ".css": "text/css", + ".scss": "text/x-scss", + ".sass": "text/x-sass", + ".less": "text/x-less", + + // SQL + ".sql": "text/x-sql", + + // Other languages + ".lua": "text/x-lua", + ".r": "text/x-r", + ".R": "text/x-r", + ".jl": "text/x-julia", + ".ex": "text/x-elixir", + ".exs": "text/x-elixir", + ".erl": "text/x-erlang", + ".hrl": "text/x-erlang", + ".clj": "text/x-clojure", + ".cljs": "text/x-clojure", + ".cljc": "text/x-clojure", + ".hs": "text/x-haskell", + ".lhs": "text/x-haskell", + ".ml": "text/x-ocaml", + ".mli": "text/x-ocaml", + ".nim": "text/x-nim", + ".dart": "text/x-dart", + ".v": "text/x-v", + ".zig": "text/x-zig", + + // Build/Config files + ".dockerfile": "text/x-dockerfile", + ".makefile": "text/x-makefile", + + // Special files + ".gitignore": "text/plain", + ".dockerignore": "text/plain", + ".editorconfig": "text/plain", +} + +// isTextMIME returns true if the MIME type indicates text content. +func isTextMIME(mimeType string) bool { + if strings.HasPrefix(mimeType, "text/") { + return true + } + // Common application/* types that are actually text + textApplicationTypes := []string{ + "application/json", + "application/xml", + "application/javascript", + "application/typescript", + "application/x-sh", + "application/x-shellscript", + } + for _, t := range textApplicationTypes { + if mimeType == t { + return true + } + } + // Types with +json, +xml suffix are text + if strings.HasSuffix(mimeType, "+json") || strings.HasSuffix(mimeType, "+xml") { + return true + } + return false +} + +// inferContentType infers the content type from file extension and optionally content. +// Returns the inferred MIME type and whether it's a text file. +func inferContentType(filePath string, content []byte) (mimeType string, isText bool) { + ext := strings.ToLower(path.Ext(filePath)) + + // Handle special filenames (Dockerfile, Makefile, etc.) + baseName := strings.ToLower(path.Base(filePath)) + if ext == "" { + switch baseName { + case "dockerfile": + return "text/x-dockerfile", true + case "makefile", "gnumakefile": + return "text/x-makefile", true + case "rakefile": + return "text/x-ruby", true + case "gemfile": + return "text/x-ruby", true + case "vagrantfile": + return "text/x-ruby", true + case "procfile": + return "text/plain", true + case "readme", "license", "authors", "changelog", "contributing": + return "text/plain", true + } + } + + // Check our extension map first (more accurate for code files) + if mtype, ok := codeExtensionMimeTypes[ext]; ok { + return mtype, isTextMIME(mtype) + } + + // If we have content, use mimetype library for accurate detection + if len(content) > 0 { + mtype := mimetype.Detect(content) + return mtype.String(), isTextMIME(mtype.String()) + } + + // Fall back to extension-only detection using mimetype library + return inferContentTypeFromExtension(ext) +} + +// inferContentTypeFromExtension infers MIME type from extension only. +// Used when we don't have file content available. +func inferContentTypeFromExtension(ext string) (mimeType string, isText bool) { + ext = strings.ToLower(ext) + + // Check our extension map first + if mtype, ok := codeExtensionMimeTypes[ext]; ok { + return mtype, isTextMIME(mtype) + } + + // Use mimetype library for other extensions + // mimetype.Lookup returns the MIME type for a given extension + mtype := mimetype.Lookup(ext) + if mtype != nil { + return mtype.String(), isTextMIME(mtype.String()) + } + + // Default to binary for unknown types + return "application/octet-stream", false +} diff --git a/pkg/github/mimetype_test.go b/pkg/github/mimetype_test.go new file mode 100644 index 000000000..5e2165129 --- /dev/null +++ b/pkg/github/mimetype_test.go @@ -0,0 +1,411 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInferContentType(t *testing.T) { + tests := []struct { + name string + filePath string + content []byte + wantMimeType string + wantIsText bool + }{ + // JavaScript/TypeScript + { + name: "TypeScript file", + filePath: "src/index.ts", + content: []byte("const foo: string = 'bar';"), + wantMimeType: "text/typescript", + wantIsText: true, + }, + { + name: "TSX file", + filePath: "src/App.tsx", + content: []byte("export const App = () =>
Hello
;"), + wantMimeType: "text/typescript-jsx", + wantIsText: true, + }, + { + name: "JavaScript file", + filePath: "src/index.js", + content: []byte("const foo = 'bar';"), + wantMimeType: "text/javascript", + wantIsText: true, + }, + { + name: "JSX file", + filePath: "src/App.jsx", + content: []byte("export const App = () =>
Hello
;"), + wantMimeType: "text/javascript-jsx", + wantIsText: true, + }, + { + name: "Vue file", + filePath: "src/App.vue", + content: []byte(""), + wantMimeType: "text/x-vue", + wantIsText: true, + }, + + // Go + { + name: "Go file", + filePath: "main.go", + content: []byte("package main\n\nfunc main() {}"), + wantMimeType: "text/x-go", + wantIsText: true, + }, + { + name: "Go mod file", + filePath: "go.mod", + content: []byte("module example.com/project\n\ngo 1.21"), + wantMimeType: "text/x-go-mod", + wantIsText: true, + }, + { + name: "Go sum file", + filePath: "go.sum", + content: []byte("github.com/pkg/errors v0.9.1 h1:..."), + wantMimeType: "text/x-go-sum", + wantIsText: true, + }, + + // Python + { + name: "Python file", + filePath: "script.py", + content: []byte("def main():\n pass"), + wantMimeType: "text/x-python", + wantIsText: true, + }, + { + name: "Python type stub", + filePath: "types.pyi", + content: []byte("def func() -> int: ..."), + wantMimeType: "text/x-python", + wantIsText: true, + }, + + // Config files + { + name: "JSON file", + filePath: "package.json", + content: []byte(`{"name": "test"}`), + wantMimeType: "application/json", + wantIsText: true, + }, + { + name: "YAML file", + filePath: ".github/workflows/ci.yml", + content: []byte("name: CI\non: push"), + wantMimeType: "text/yaml", + wantIsText: true, + }, + { + name: "TOML file", + filePath: "Cargo.toml", + content: []byte("[package]\nname = \"test\""), + wantMimeType: "text/x-toml", + wantIsText: true, + }, + + // Markup/Documentation + { + name: "Markdown file", + filePath: "README.md", + content: []byte("# Title\n\nSome text"), + wantMimeType: "text/markdown", + wantIsText: true, + }, + { + name: "HTML file", + filePath: "index.html", + content: []byte(""), + wantMimeType: "text/html", + wantIsText: true, + }, + + // Special filenames without extensions + { + name: "Dockerfile", + filePath: "Dockerfile", + content: []byte("FROM ubuntu:latest\nRUN apt-get update"), + wantMimeType: "text/x-dockerfile", + wantIsText: true, + }, + { + name: "Makefile", + filePath: "Makefile", + content: []byte("all:\n\techo hello"), + wantMimeType: "text/x-makefile", + wantIsText: true, + }, + { + name: "README without extension", + filePath: "README", + content: []byte("This is a readme file"), + wantMimeType: "text/plain", + wantIsText: true, + }, + { + name: "LICENSE without extension", + filePath: "LICENSE", + content: []byte("MIT License\n\nCopyright..."), + wantMimeType: "text/plain", + wantIsText: true, + }, + + // Binary content detection + { + name: "PNG image", + filePath: "image.png", + content: []byte("\x89PNG\r\n\x1a\n"), + wantMimeType: "image/png", + wantIsText: false, + }, + { + name: "JPEG image", + filePath: "photo.jpg", + content: []byte("\xff\xd8\xff"), + wantMimeType: "image/jpeg", + wantIsText: false, + }, + { + name: "ZIP archive", + filePath: "archive.zip", + content: []byte("PK\x03\x04"), + wantMimeType: "application/zip", + wantIsText: false, + }, + { + name: "ELF binary", + filePath: "program", + content: []byte("\x7fELF"), + wantMimeType: "application/x-elf", + wantIsText: false, + }, + + // Shell scripts with shebang + { + name: "Bash script", + filePath: "script.sh", + content: []byte("#!/bin/bash\necho hello"), + wantMimeType: "text/x-shellscript", + wantIsText: true, + }, + { + name: "Python script with shebang", + filePath: "tool", + content: []byte("#!/usr/bin/env python3\nprint('hello')"), + wantMimeType: "text/x-python", + wantIsText: true, + }, + + // Other languages + { + name: "Rust file", + filePath: "main.rs", + content: []byte("fn main() {\n println!(\"Hello\");\n}"), + wantMimeType: "text/x-rust", + wantIsText: true, + }, + { + name: "Ruby file", + filePath: "app.rb", + content: []byte("puts 'hello'"), + wantMimeType: "text/x-ruby", + wantIsText: true, + }, + { + name: "Java file", + filePath: "Main.java", + content: []byte("public class Main { }"), + wantMimeType: "text/x-java-source", + wantIsText: true, + }, + { + name: "C++ file", + filePath: "main.cpp", + content: []byte("#include \nint main() { }"), + wantMimeType: "text/x-c++", + wantIsText: true, + }, + { + name: "C header file", + filePath: "header.h", + content: []byte("#ifndef HEADER_H\n#define HEADER_H\n#endif"), + wantMimeType: "text/x-c", + wantIsText: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMimeType, gotIsText := inferContentType(tt.filePath, tt.content) + assert.Equal(t, tt.wantMimeType, gotMimeType, "MIME type mismatch") + assert.Equal(t, tt.wantIsText, gotIsText, "isText flag mismatch") + }) + } +} + +func TestInferContentTypeFromExtension(t *testing.T) { + tests := []struct { + name string + ext string + wantMimeType string + wantIsText bool + }{ + // Code files + { + name: "TypeScript", + ext: ".ts", + wantMimeType: "text/typescript", + wantIsText: true, + }, + { + name: "JavaScript", + ext: ".js", + wantMimeType: "text/javascript", + wantIsText: true, + }, + { + name: "Go", + ext: ".go", + wantMimeType: "text/x-go", + wantIsText: true, + }, + { + name: "Python", + ext: ".py", + wantMimeType: "text/x-python", + wantIsText: true, + }, + + // Config files + { + name: "JSON", + ext: ".json", + wantMimeType: "application/json", + wantIsText: true, + }, + { + name: "YAML", + ext: ".yml", + wantMimeType: "text/yaml", + wantIsText: true, + }, + + // Unknown extension should default to binary + { + name: "Unknown extension", + ext: ".xyz", + wantMimeType: "application/octet-stream", + wantIsText: false, + }, + + // Empty extension + { + name: "Empty extension", + ext: "", + wantMimeType: "application/octet-stream", + wantIsText: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMimeType, gotIsText := inferContentTypeFromExtension(tt.ext) + assert.Equal(t, tt.wantMimeType, gotMimeType, "MIME type mismatch") + assert.Equal(t, tt.wantIsText, gotIsText, "isText flag mismatch") + }) + } +} + +func TestIsTextMIME(t *testing.T) { + tests := []struct { + name string + mimeType string + want bool + }{ + // Text types + { + name: "text/plain", + mimeType: "text/plain", + want: true, + }, + { + name: "text/markdown", + mimeType: "text/markdown", + want: true, + }, + { + name: "text/html", + mimeType: "text/html", + want: true, + }, + + // Application types that are text + { + name: "application/json", + mimeType: "application/json", + want: true, + }, + { + name: "application/xml", + mimeType: "application/xml", + want: true, + }, + { + name: "application/javascript", + mimeType: "application/javascript", + want: true, + }, + + // Types with +json suffix + { + name: "application/vnd.api+json", + mimeType: "application/vnd.api+json", + want: true, + }, + + // Types with +xml suffix + { + name: "application/atom+xml", + mimeType: "application/atom+xml", + want: true, + }, + + // Binary types + { + name: "image/png", + mimeType: "image/png", + want: false, + }, + { + name: "application/zip", + mimeType: "application/zip", + want: false, + }, + { + name: "application/pdf", + mimeType: "application/pdf", + want: false, + }, + { + name: "application/octet-stream", + mimeType: "application/octet-stream", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTextMIME(tt.mimeType) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 58ecaf5a9..4bd1e9003 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -539,7 +540,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) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { +func GetFileContents(getClient GetClientFn, _ raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := mcp.Tool{ Name: "get_file_contents", Description: t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository"), @@ -565,12 +566,16 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t }, "ref": { Type: "string", - Description: "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + Description: "Accepts optional git refs such as refs/tags/{tag}, refs/heads/{branch} or refs/pull/{pr_number}/head", }, "sha": { Type: "string", Description: "Accepts optional commit SHA. If specified, it will be used instead of ref", }, + "raw_content": { + Type: "boolean", + Description: "If true, returns file content as plain text (for text files) or base64-encoded (for binary files) instead of embedded resource format. Useful for clients that don't support embedded resources.", + }, }, Required: []string{"owner", "repo"}, }, @@ -597,6 +602,10 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + rawContentMode, err := OptionalParam[bool](args, "raw_content") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } client, err := getClient(ctx) if err != nil { @@ -612,11 +621,9 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t ref = rawOpts.SHA } - var rawAPIResponseCode int - var fileSHA string opts := &github.RepositoryContentGetOptions{Ref: ref} - // Always call GitHub Contents API first to get metadata including SHA and determine if it's a file or directory + // Call GitHub Contents API to get file/directory metadata and content fileContent, dirContent, respContents, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) if respContents != nil { defer func() { _ = respContents.Body.Close() }() @@ -626,12 +633,12 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t // Instead let's try to find it in the Git Tree by matching the end of the path. if err != nil || (fileContent == nil && dirContent == nil) { // Step 1: Get Git Tree recursively - tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true) - if err != nil { + tree, response, treeErr := client.Git.GetTree(ctx, owner, repo, ref, true) + if treeErr != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get git tree", response, - err, + treeErr, ), nil, nil } defer func() { _ = response.Body.Close() }() @@ -640,99 +647,110 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t const maxMatchingFiles = 3 matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) if len(matchingFiles) > 0 { - matchingFilesJSON, err := json.Marshal(matchingFiles) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil + matchingFilesJSON, jsonErr := json.Marshal(matchingFiles) + if jsonErr != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", jsonErr)), nil, nil } - resolvedRefs, err := json.Marshal(rawOpts) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil + resolvedRefs, jsonErr := json.Marshal(rawOpts) + if jsonErr != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", jsonErr)), nil, nil } - return utils.NewToolResultError(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the raw content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("Path not found. Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil } return utils.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, nil } - if fileContent != nil && fileContent.SHA != nil { - fileSHA = *fileContent.SHA + // Handle directory content + if dirContent != nil { + r, jsonErr := json.Marshal(dirContent) + if jsonErr != nil { + return utils.NewToolResultError("failed to marshal response"), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil + } - rawClient, err := getRawClient(ctx) - if err != nil { - return utils.NewToolResultError("failed to get GitHub raw content client"), nil, nil + // Handle file content + if fileContent != nil { + fileSHA := fileContent.GetSHA() + fileSize := fileContent.GetSize() + + // Build resource URI + var resourceURI string + switch { + case sha != "": + resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) + case ref != "": + resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) + default: + resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) } - resp, err := rawClient.GetRawContent(ctx, owner, repo, path, rawOpts) if err != nil { - return utils.NewToolResultError("failed to get raw repository content"), nil, nil + return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode == http.StatusOK { - // If the raw content is found, return it directly - body, err := io.ReadAll(resp.Body) - if err != nil { - return utils.NewToolResultError("failed to read response body"), nil, nil + + // Check if file is too large (Contents API returns nil content for files > 1MB) + content, decodeErr := fileContent.GetContent() + if decodeErr != nil || content == "" { + // File is too large - return metadata with download URL + metadata := map[string]any{ + "sha": fileSHA, + "size": fileSize, + "name": fileContent.GetName(), + "path": fileContent.GetPath(), + "download_url": fileContent.GetDownloadURL(), + "message": fmt.Sprintf("File is too large to display (%d bytes). Use the download_url to fetch the content directly, or consider fetching specific line ranges if supported by your client.", fileSize), } - contentType := resp.Header.Get("Content-Type") - - var resourceURI string - switch { - case sha != "": - resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "contents", path) - if err != nil { - return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) - } - case ref != "": - resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path) - if err != nil { - return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) - } - default: - resourceURI, err = url.JoinPath("repo://", owner, repo, "contents", path) - if err != nil { - return nil, nil, fmt.Errorf("failed to create resource URI: %w", err) - } + metadataJSON, jsonErr := json.Marshal(metadata) + if jsonErr != nil { + return utils.NewToolResultError("failed to marshal file metadata"), nil, nil } + return utils.NewToolResultText(string(metadataJSON)), nil, nil + } + + contentBytes := []byte(content) - // Determine if content is text or binary - isTextContent := strings.HasPrefix(contentType, "text/") || - contentType == "application/json" || - contentType == "application/xml" || - strings.HasSuffix(contentType, "+json") || - strings.HasSuffix(contentType, "+xml") + // Infer content type locally (no extra API call needed!) + contentType, isTextContent := inferContentType(path, contentBytes) + // If raw content mode is enabled, return plain text (for legacy clients) + if rawContentMode { if isTextContent { - result := &mcp.ResourceContents{ - URI: resourceURI, - Text: string(body), - MIMEType: contentType, - } - // Include SHA in the result metadata if fileSHA != "" { - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil, nil + return utils.NewToolResultText(fmt.Sprintf("SHA: %s\n\n%s", fileSHA, content)), nil, nil } - return utils.NewToolResultResource("successfully downloaded text file", result), nil, nil + return utils.NewToolResultText(content), nil, nil + } + // For binary files in raw mode, return base64 encoded content + encoded := base64.StdEncoding.EncodeToString(contentBytes) + if fileSHA != "" { + return utils.NewToolResultText(fmt.Sprintf("SHA: %s\nContent-Type: %s\nEncoding: base64\n\n%s", fileSHA, contentType, encoded)), nil, nil } + return utils.NewToolResultText(fmt.Sprintf("Content-Type: %s\nEncoding: base64\n\n%s", contentType, encoded)), nil, nil + } + // Return as embedded resource (default) + if isTextContent { result := &mcp.ResourceContents{ URI: resourceURI, - Blob: body, + Text: content, MIMEType: contentType, } - // Include SHA in the result metadata if fileSHA != "" { - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil, nil + return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)", fileSHA), result), nil, nil } - return utils.NewToolResultResource("successfully downloaded binary file", result), nil, nil + return utils.NewToolResultResource("successfully downloaded text file", result), nil, nil } - } else if dirContent != nil { - // file content or file SHA is nil which means it's a directory - r, err := json.Marshal(dirContent) - if err != nil { - return utils.NewToolResultError("failed to marshal response"), nil, nil + + // Binary content + result := &mcp.ResourceContents{ + URI: resourceURI, + Blob: contentBytes, + MIMEType: contentType, } - return utils.NewToolResultText(string(r)), nil, nil + if fileSHA != "" { + return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil, nil + } + return utils.NewToolResultResource("successfully downloaded binary file", result), nil, nil } return utils.NewToolResultError("failed to get file contents"), nil, nil @@ -741,7 +759,6 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t return tool, handler } -// ForkRepository creates a tool to fork a repository. func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) { tool := mcp.Tool{ Name: "fork_repository", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 7e76d4230..f680c27ac 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/url" @@ -40,8 +41,17 @@ func Test_GetFileContents(t *testing.T) { assert.Contains(t, schema.Properties, "sha") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) - // Mock response for raw content - mockRawContent := []byte("# Test Repository\n\nThis is a test repository.") + // Mock response for text content + mockTextContent := "# Test Repository\n\nThis is a test repository." + mockTextContentBase64 := base64.StdEncoding.EncodeToString([]byte(mockTextContent)) + + // Mock PNG content (PNG magic bytes) + mockPNGContent := []byte("\x89PNG\r\n\x1a\n" + "fake png data") + mockPNGContentBase64 := base64.StdEncoding.EncodeToString(mockPNGContent) + + // Mock PDF content (PDF magic bytes) + mockPDFContent := []byte("%PDF-1.4 fake pdf data") + mockPDFContentBase64 := base64.StdEncoding.EncodeToString(mockPDFContent) // Setup mock directory content for success case mockDirContent := []*github.RepositoryContent{ @@ -86,22 +96,17 @@ func Test_GetFileContents(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Encoding: github.Ptr("base64"), + Content: github.Ptr(mockTextContentBase64), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }), ), - 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", @@ -112,7 +117,7 @@ func Test_GetFileContents(t *testing.T) { expectError: false, expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/README.md", - Text: "# Test Repository\n\nThis is a test repository.", + Text: mockTextContent, MIMEType: "text/markdown", }, }, @@ -131,22 +136,17 @@ func Test_GetFileContents(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fileContent := &github.RepositoryContent{ - Name: github.Ptr("test.png"), - Path: github.Ptr("test.png"), - SHA: github.Ptr("def456"), - Type: github.Ptr("file"), + Name: github.Ptr("test.png"), + Path: github.Ptr("test.png"), + SHA: github.Ptr("def456"), + Type: github.Ptr("file"), + Encoding: github.Ptr("base64"), + Content: github.Ptr(mockPNGContentBase64), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }), ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(mockRawContent) - }), - ), ), requestArgs: map[string]interface{}{ "owner": "owner", @@ -157,7 +157,7 @@ func Test_GetFileContents(t *testing.T) { expectError: false, expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/test.png", - Blob: mockRawContent, + Blob: mockPNGContent, MIMEType: "image/png", }, }, @@ -176,22 +176,17 @@ func Test_GetFileContents(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fileContent := &github.RepositoryContent{ - Name: github.Ptr("document.pdf"), - Path: github.Ptr("document.pdf"), - SHA: github.Ptr("pdf123"), - Type: github.Ptr("file"), + Name: github.Ptr("document.pdf"), + Path: github.Ptr("document.pdf"), + SHA: github.Ptr("pdf123"), + Type: github.Ptr("file"), + Encoding: github.Ptr("base64"), + Content: github.Ptr(mockPDFContentBase64), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) }), ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write(mockRawContent) - }), - ), ), requestArgs: map[string]interface{}{ "owner": "owner", @@ -202,7 +197,7 @@ func Test_GetFileContents(t *testing.T) { expectError: false, expectedResult: mcp.ResourceContents{ URI: "repo://owner/repo/refs/heads/main/contents/document.pdf", - Blob: mockRawContent, + Blob: mockPDFContent, MIMEType: "application/pdf", }, }, @@ -229,14 +224,6 @@ func Test_GetFileContents(t *testing.T) { mockResponse(t, http.StatusOK, mockDirContent), ), ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{ - "branch": "main", - }).andThen( - mockResponse(t, http.StatusNotFound, nil), - ), - ), ), requestArgs: map[string]interface{}{ "owner": "owner", @@ -264,10 +251,11 @@ func Test_GetFileContents(t *testing.T) { }), ), mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, + mock.GetReposGitTreesByOwnerByRepoByTreeSha, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + w.WriteHeader(http.StatusOK) + // Return empty tree - no matching files + _, _ = w.Write([]byte(`{"sha": "tree123", "tree": []}`)) }), ), ),