Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ require (
github.com/lithammer/fuzzysearch v1.1.8
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
Expand All @@ -50,5 +50,5 @@ require (
golang.org/x/text v0.28.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/yaml.v3 v3.0.1
)
40 changes: 40 additions & 0 deletions pkg/github/__toolsnaps__/compare_file_contents.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"annotations": {
"readOnlyHint": true,
"title": "Compare file contents between revisions"
},
"description": "Compare two versions of a file in a GitHub repository.\nFor structured formats (JSON, YAML, CSV, TOML), produces a semantic diff that shows only meaningful changes, ignoring formatting differences.\nFor other file types, produces a standard unified diff.\nThis is useful for understanding what actually changed between two versions of a file, especially for configuration files and data files where reformatting can obscure real changes.",
"inputSchema": {
"properties": {
"base": {
"description": "Base ref to compare from (commit SHA, branch name, or tag name)",
"type": "string"
},
"head": {
"description": "Head ref to compare to (commit SHA, branch name, or tag name)",
"type": "string"
},
"owner": {
"description": "Repository owner (username or organization)",
"type": "string"
},
"path": {
"description": "Path to the file to compare",
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo",
"path",
"base",
"head"
],
"type": "object"
},
"name": "compare_file_contents"
}
159 changes: 159 additions & 0 deletions pkg/github/compare_file_contents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package github

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

// FeatureFlagCompareFileContents is the feature flag for the compare_file_contents tool.
const FeatureFlagCompareFileContents = "mcp_compare_file_contents"

// CompareFileContents creates a tool to compare two versions of a file in a GitHub repository.
// For supported formats (JSON, YAML, CSV, TOML), it produces semantic diffs showing
// only meaningful changes. For other formats, it falls back to unified diff.
func CompareFileContents(t translations.TranslationHelperFunc) inventory.ServerTool {
tool := NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "compare_file_contents",
Description: t("TOOL_COMPARE_FILE_CONTENTS_DESCRIPTION", `Compare two versions of a file in a GitHub repository.
For structured formats (JSON, YAML, CSV, TOML), produces a semantic diff that shows only meaningful changes, ignoring formatting differences.
For other file types, produces a standard unified diff.
This is useful for understanding what actually changed between two versions of a file, especially for configuration files and data files where reformatting can obscure real changes.`),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_COMPARE_FILE_CONTENTS_USER_TITLE", "Compare file contents between revisions"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"path": {
Type: "string",
Description: "Path to the file to compare",
},
"base": {
Type: "string",
Description: "Base ref to compare from (commit SHA, branch name, or tag name)",
},
"head": {
Type: "string",
Description: "Head ref to compare to (commit SHA, branch name, or tag name)",
},
},
Required: []string{"owner", "repo", "path", "base", "head"},
},
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
path, err := RequiredParam[string](args, "path")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
Comment on lines +75 to +78
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path parameter isn't normalized (e.g., leading /). Other repo tools trim a leading slash before calling the GitHub Contents API; without that, compare_file_contents will fail for inputs like /config.json even though get_file_contents accepts them. Consider trimming a leading / (and keeping the original for display if needed) before calling getFileAtRef / SemanticDiff.

Copilot uses AI. Check for mistakes.
base, err := RequiredParam[string](args, "base")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
head, err := RequiredParam[string](args, "head")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

client, err := deps.GetClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

baseContent, baseErr := getFileAtRef(ctx, client, owner, repo, path, base)
headContent, headErr := getFileAtRef(ctx, client, owner, repo, path, head)

// If both sides fail, report the errors
if baseErr != nil && headErr != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to get file at both refs: base %q: %s, head %q: %s", base, baseErr, head, headErr)), nil, nil
}

// A nil content with no error won't happen from getFileAtRef,
// but a non-nil error on one side means the file doesn't exist at that ref.
// Pass nil to SemanticDiff to indicate added/deleted file.
if baseErr != nil {
baseContent = nil
}
if headErr != nil {
headContent = nil
}
Comment on lines +93 to +109
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

baseErr / headErr are treated as “file doesn’t exist at that ref” unconditionally. This will misreport results for non-404 failures (rate limit, auth, network, directory path, oversized file, etc.) by turning them into “file added/deleted”. Only interpret a GitHub 404 (not found) as “missing at ref”; for any other error, return an error result (and keep the existing “both refs failed” case for two 404s / mixed outcomes as appropriate).

This issue also appears in the following locations of the same file:

  • line 88
  • line 149

Copilot uses AI. Check for mistakes.

result := SemanticDiff(path, baseContent, headContent)

output, err := json.Marshal(result)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal diff result: %w", err)
}

return utils.NewToolResultText(string(output)), nil, nil
},
)
tool.FeatureFlagEnable = FeatureFlagCompareFileContents
return tool
}

// getFileAtRef fetches file content from a GitHub repository at a specific ref.
func getFileAtRef(ctx context.Context, client *github.Client, owner, repo, path, ref string) ([]byte, error) {
opts := &github.RepositoryContentGetOptions{Ref: ref}
fileContent, _, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
if err != nil {
return nil, err
}
if resp == nil {
return nil, fmt.Errorf("no response received")
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}

if fileContent == nil {
return nil, fmt.Errorf("path %q is a directory, not a file", path)
}

content, err := fileContent.GetContent()
if err != nil {
return nil, fmt.Errorf("failed to decode file content: %w", err)
}

if len(content) > MaxSemanticDiffFileSize {
return nil, fmt.Errorf("file exceeds maximum size of %d bytes", MaxSemanticDiffFileSize)
}

return []byte(content), nil
}
Loading