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("Hello
"),
+ 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": []}`))
}),
),
),