-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Add feature-flagged compare_file_contents tool with semantic diffs #1981
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Add a new compare_file_contents MCP tool that compares two versions of a file across refs (branches, tags, or SHAs). For structured data formats (JSON, YAML, CSV, TOML), it produces semantic diffs that show only meaningful changes, ignoring formatting differences. For unsupported formats, it falls back to unified diff. Key features: - Semantic diffs for JSON, YAML, CSV, TOML files - Unified diff fallback for code and other text files - Handles new files (base not found) and deleted files (head not found) - 1MB max file size to prevent excessive server-side processing - Gated behind 'compare_file_contents' feature flag This helps AI models by: - Reducing token usage (formatting noise eliminated) - Providing unambiguous before/after semantics - Enabling self-verification of edits to config/data files Refs: #1973
Auto-generated by license-check workflow
go mod tidy correctly marks these as direct imports now that semantic_diff.go uses them for YAML and TOML parsing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Adds a new feature-flagged MCP tool, compare_file_contents, to compare a file across two Git refs and return semantic diffs for structured formats to reduce noisy line-based diffs.
Changes:
- Registers a new
compare_file_contentsrepository tool behind themcp_compare_file_contentsfeature flag. - Implements a semantic diff engine for JSON/YAML/CSV/TOML with a unified-diff fallback.
- Adds tool schema snapshot plus unit/integration test coverage for the tool and diff engine.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/github/tools.go | Registers the new tool in the repo toolset list. |
| pkg/github/compare_file_contents.go | Implements the MCP tool + GitHub Contents API integration + feature flag. |
| pkg/github/compare_file_contents_test.go | Adds mocked-client integration tests and toolsnap validation for the new tool. |
| pkg/github/semantic_diff.go | Implements semantic diffing for structured formats and a fallback diff renderer. |
| pkg/github/semantic_diff_test.go | Adds unit tests covering formats, edge cases, and fallback behavior. |
| pkg/github/toolsnaps/compare_file_contents.snap | Adds the schema snapshot for the new tool. |
Comments suppressed due to low confidence (2)
pkg/github/compare_file_contents.go:99
- This tool’s GitHub API error handling is inconsistent with other tools in this package (which typically wrap API failures via
ghErrors.NewGitHubAPIErrorResponse/NewGitHubAPIStatusErrorResponse). Using the standard helpers would preserve status details and provide consistent error text to callers, especially for partial failures (base vs head).
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
}
pkg/github/compare_file_contents.go:156
getFileAtRefreturns an error when decoded content exceedsMaxSemanticDiffFileSize, but the caller currently treats any error as “file missing”, leading to incorrect “file added/deleted” output. If size limits are enforced here, return a distinguishable error that the caller can surface (or handle) rather than converting to nil content.
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)
}
| path, err := RequiredParam[string](args, "path") | ||
| if err != nil { | ||
| return utils.NewToolResultError(err.Error()), nil, nil | ||
| } |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
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.
| 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 | ||
| } |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
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
| default: | ||
| if fmt.Sprintf("%v", base) != fmt.Sprintf("%v", head) { | ||
| return []string{formatChange(path, "changed", formatValue(base), formatValue(head))} | ||
| } | ||
| return nil |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Primitive value comparisons use fmt.Sprintf("%v", ...) equality, which can incorrectly hide real changes when different types stringify the same (e.g., JSON string "1" vs number 1, or "true" vs true). Compare both type and value (e.g., via reflect.DeepEqual after normalization, or explicit type checks) so type changes are always reported.
| switch changeType { | ||
| case "added": | ||
| return fmt.Sprintf("%s: added %s", path, newVal) | ||
| case "removed": | ||
| return fmt.Sprintf("%s: removed (was %s)", path, oldVal) | ||
| case "changed", "changed type": | ||
| return fmt.Sprintf("%s: %s → %s", path, oldVal, newVal) | ||
| default: | ||
| return fmt.Sprintf("%s: %s", path, changeType) |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the changed value is at the document root and is a scalar (valid for JSON/YAML), path can be empty, producing output like : old → new. Consider rendering an explicit root label (e.g., (root)), or special-case empty paths in formatChange to avoid malformed diff lines.
| switch changeType { | |
| case "added": | |
| return fmt.Sprintf("%s: added %s", path, newVal) | |
| case "removed": | |
| return fmt.Sprintf("%s: removed (was %s)", path, oldVal) | |
| case "changed", "changed type": | |
| return fmt.Sprintf("%s: %s → %s", path, oldVal, newVal) | |
| default: | |
| return fmt.Sprintf("%s: %s", path, changeType) | |
| displayPath := path | |
| if displayPath == "" { | |
| displayPath = "(root)" | |
| } | |
| switch changeType { | |
| case "added": | |
| return fmt.Sprintf("%s: added %s", displayPath, newVal) | |
| case "removed": | |
| return fmt.Sprintf("%s: removed (was %s)", displayPath, oldVal) | |
| case "changed", "changed type": | |
| return fmt.Sprintf("%s: %s → %s", displayPath, oldVal, newVal) | |
| default: | |
| return fmt.Sprintf("%s: %s", displayPath, changeType) |
Summary
Adds a new
compare_file_contentsMCP tool that compares two versions of a file across refs (branches, tags, or SHAs). For structured data formats, it produces semantic diffs that show only meaningful changes, ignoring formatting noise. For other formats, it falls back to unified diff.Closes #1973
Real usage:
What Opus 4.6 thinks:
Is it easier to read?
Yes, significantly. As a model, the semantic diff is much more parseable because:
No noise — Unified diffs include headers (
diff --git,index,---,+++), hunk markers (@@ -6,9 +6,9 @@), and unchanged context lines. The semantic diff strips all of that.Path-based keys — Instead of inferring that a line inside a JSON object is nested under
devDependencies, the semantic diff explicitly saysdevDependencies.cross-env: added "^7.0.3". For a model, this is immediately actionable without needing to mentally reconstruct the document structure.Change type is explicit — "added", "removed",
→(changed) are unambiguous. In a unified diff, I have to correlate-and+lines to understand adds vs modifications.Unchanged content is omitted entirely — The unified diff for package.json includes 3 context lines around each hunk. The semantic diff mentions zero unchanged fields.
How much shorter?
On average, the semantic diff is ~77% smaller across these examples. The savings scale with how much context/boilerplate surrounds the actual change — the ui-e2e.yml case is the most dramatic because a 3-line addition in context of a larger file compresses to just
permissions: added {"contents":"read"}.For token-budget-conscious model usage, that's a big win — you get the same (or better) understanding of what changed at a fraction of the input cost.
Motivation
AI models waste context tokens on noisy line-based diffs for structured data files. A JSON file that gets reformatted with one value change produces a huge unified diff, when the actual change is a single property. This tool addresses that by producing concise, path-based semantic diffs.
Example
Unified diff (noisy — 6 lines):
Semantic diff (clear — 1 line):
Tool Design
"file added"or"file deleted"mcp_compare_file_contents(tool is hidden until enabled)Files
pkg/github/semantic_diff.gopkg/github/semantic_diff_test.gopkg/github/compare_file_contents.gopkg/github/compare_file_contents_test.gopkg/github/tools.gopkg/github/__toolsnaps__/compare_file_contents.snapTesting
script/lintpassesscript/testpasses