From 7600a707bc78707291003227c49dc378a6a97952 Mon Sep 17 00:00:00 2001 From: jasonobrown Date: Sun, 6 Sep 2026 12:24:44 -0500 Subject: [PATCH 1/4] feat: add exact patch-safe file update tool --- pkg/github/file_patch.go | 314 ++++++++++++++++++++++++++++++++++ pkg/github/file_patch_test.go | 47 +++++ 2 files changed, 361 insertions(+) create mode 100644 pkg/github/file_patch.go create mode 100644 pkg/github/file_patch_test.go diff --git a/pkg/github/file_patch.go b/pkg/github/file_patch.go new file mode 100644 index 0000000000..375311e845 --- /dev/null +++ b/pkg/github/file_patch.go @@ -0,0 +1,314 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "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/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + maxPatchFileBytes = 4 * 1024 * 1024 + maxPatchInputBytes = 256 * 1024 + maxPatchEdits = 100 +) + +type exactTextEdit struct { + OldText string + NewText string + ExpectedOccurrences int +} + +func applyExactTextEdits(content string, edits []exactTextEdit) (string, error) { + if len(edits) == 0 { + return "", fmt.Errorf("at least one edit is required") + } + if len(edits) > maxPatchEdits { + return "", fmt.Errorf("too many edits: got %d, maximum is %d", len(edits), maxPatchEdits) + } + + patchBytes := 0 + updated := content + for i, edit := range edits { + if edit.OldText == "" { + return "", fmt.Errorf("edit %d old_text must not be empty", i) + } + if edit.ExpectedOccurrences < 1 { + return "", fmt.Errorf("edit %d expected_occurrences must be at least 1", i) + } + patchBytes += len(edit.OldText) + len(edit.NewText) + if patchBytes > maxPatchInputBytes { + return "", fmt.Errorf("patch input exceeds %d bytes", maxPatchInputBytes) + } + + actual := strings.Count(updated, edit.OldText) + if actual != edit.ExpectedOccurrences { + return "", fmt.Errorf("edit %d occurrence mismatch: expected %d exact matches, found %d", i, edit.ExpectedOccurrences, actual) + } + updated = strings.Replace(updated, edit.OldText, edit.NewText, edit.ExpectedOccurrences) + } + + if updated == content { + return "", fmt.Errorf("patch produced no content change") + } + return updated, nil +} + +func parseExactTextEdits(args map[string]any) ([]exactTextEdit, error) { + raw, ok := args["edits"] + if !ok { + return nil, fmt.Errorf("missing required parameter: edits") + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("edits must be an array") + } + if len(items) == 0 { + return nil, fmt.Errorf("edits must contain at least one item") + } + if len(items) > maxPatchEdits { + return nil, fmt.Errorf("too many edits: got %d, maximum is %d", len(items), maxPatchEdits) + } + + edits := make([]exactTextEdit, 0, len(items)) + for i, item := range items { + obj, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("edit %d must be an object", i) + } + oldText, ok := obj["old_text"].(string) + if !ok { + return nil, fmt.Errorf("edit %d old_text must be a string", i) + } + newText, ok := obj["new_text"].(string) + if !ok { + return nil, fmt.Errorf("edit %d new_text must be a string", i) + } + expected := 1 + if value, exists := obj["expected_occurrences"]; exists { + switch typed := value.(type) { + case float64: + if typed < 1 || typed != float64(int(typed)) { + return nil, fmt.Errorf("edit %d expected_occurrences must be a positive integer", i) + } + expected = int(typed) + case int: + if typed < 1 { + return nil, fmt.Errorf("edit %d expected_occurrences must be a positive integer", i) + } + expected = typed + default: + return nil, fmt.Errorf("edit %d expected_occurrences must be a positive integer", i) + } + } + edits = append(edits, exactTextEdit{OldText: oldText, NewText: newText, ExpectedOccurrences: expected}) + } + return edits, nil +} + +// ApplyFilePatch creates a tool that applies exact text edits to an existing file without +// requiring the MCP client to transmit the complete replacement file. +func ApplyFilePatch(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "apply_file_patch", + Description: t("TOOL_APPLY_FILE_PATCH_DESCRIPTION", `Apply bounded exact text edits to one existing UTF-8 file in a GitHub repository. The server fetches the current blob directly, so the caller does not need to resend the complete file. The update is bound to both the expected branch HEAD and expected file blob SHA. Each old_text must match exactly the declared number of occurrences; fuzzy patching is never performed. The branch ref is updated without force, so concurrent branch movement fails closed.`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_APPLY_FILE_PATCH_USER_TITLE", "Apply file patch"), + ReadOnlyHint: false, + }, + 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: "Repository-relative path of the existing UTF-8 file to patch"}, + "branch": {Type: "string", Description: "Branch to update"}, + "expected_head_sha": {Type: "string", Description: "Exact commit SHA the branch must currently point to"}, + "expected_blob_sha": {Type: "string", Description: "Exact blob SHA of the file being patched"}, + "message": {Type: "string", Description: "Commit message"}, + "edits": { + Type: "array", + Description: "Ordered exact text replacements. Each old_text must match exactly expected_occurrences times at that step (default 1).", + MinItems: jsonschema.Ptr(1), + MaxItems: jsonschema.Ptr(maxPatchEdits), + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "old_text": {Type: "string", Description: "Exact text that must already exist; must not be empty"}, + "new_text": {Type: "string", Description: "Replacement text"}, + "expected_occurrences": {Type: "number", Description: "Exact positive integer number of non-overlapping matches required before replacement; defaults to 1", Minimum: jsonschema.Ptr(1.0), Default: json.RawMessage("1")}, + }, + Required: []string{"old_text", "new_text"}, + }, + }, + }, + Required: []string{"owner", "repo", "path", "branch", "expected_head_sha", "expected_blob_sha", "message", "edits"}, + }, + }, + scopes.RequireAll(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 + } + path, err = validateRelativePath(path) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid path: %s", err)), nil, nil + } + branch, err := RequiredParam[string](args, "branch") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + expectedHead, err := RequiredParam[string](args, "expected_head_sha") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + expectedBlob, err := RequiredParam[string](args, "expected_blob_sha") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + message, err := RequiredParam[string](args, "message") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + edits, err := parseExactTextEdits(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if !looksLikeSHA(expectedHead) || !looksLikeSHA(expectedBlob) { + return utils.NewToolResultError("expected_head_sha and expected_blob_sha must be 40-character Git SHA-1 values"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get branch reference", resp, err), nil, nil + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + currentHead := ref.GetObject().GetSHA() + if !strings.EqualFold(currentHead, expectedHead) { + return utils.NewToolResultError(fmt.Sprintf("branch HEAD mismatch: expected %s, current %s", expectedHead, currentHead)), nil, nil + } + + baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, expectedHead) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get expected base commit", resp, err), nil, nil + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if baseCommit.Tree == nil || baseCommit.Tree.SHA == nil { + return utils.NewToolResultError("expected base commit has no tree SHA"), nil, nil + } + + entry, resp, err := getTreeEntry(ctx, client, owner, repo, baseCommit.Tree.GetSHA(), path) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to resolve file in expected tree", resp, err), nil, nil + } + if entry == nil || entry.GetType() != "blob" { + return utils.NewToolResultError(fmt.Sprintf("path %s is not an existing file blob", path)), nil, nil + } + if entry.GetMode() == gitSymlinkMode { + return newSymlinkWriteBlockedResult(path, ""), nil, nil + } + if entry.GetMode() != "100644" && entry.GetMode() != "100755" { + return utils.NewToolResultError(fmt.Sprintf("unsupported file mode %s at %s", entry.GetMode(), path)), nil, nil + } + if !strings.EqualFold(entry.GetSHA(), expectedBlob) { + return utils.NewToolResultError(fmt.Sprintf("blob SHA mismatch: expected %s, current %s", expectedBlob, entry.GetSHA())), nil, nil + } + + original, resp, err := getVerifiedBlob(ctx, client, owner, repo, entry.GetSHA()) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to read expected file blob", resp, err), nil, nil + } + if len(original) > maxPatchFileBytes { + return utils.NewToolResultError(fmt.Sprintf("file is %d bytes; maximum patchable size is %d bytes", len(original), maxPatchFileBytes)), nil, nil + } + if !utf8.Valid(original) { + return utils.NewToolResultError("file is not valid UTF-8 text"), nil, nil + } + + patched, err := applyExactTextEdits(string(original), edits) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(patched) > maxPatchFileBytes { + return utils.NewToolResultError(fmt.Sprintf("patched file would be %d bytes; maximum is %d bytes", len(patched), maxPatchFileBytes)), nil, nil + } + + blob, resp, err := client.Git.CreateBlob(ctx, owner, repo, &github.Blob{Content: github.Ptr(patched), Encoding: github.Ptr("utf-8")}) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create patched blob", resp, err), nil, nil + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, baseCommit.Tree.GetSHA(), []*github.TreeEntry{{Path: github.Ptr(path), Mode: github.Ptr(entry.GetMode()), Type: github.Ptr("blob"), SHA: blob.SHA}}) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create patched tree", resp, err), nil, nil + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, github.Commit{Message: github.Ptr(message), Tree: newTree, Parents: []*github.Commit{{SHA: github.Ptr(expectedHead)}}}, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create patch commit", resp, err), nil, nil + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + _, resp, err = client.Git.UpdateRef(ctx, owner, repo, ref.GetRef(), github.UpdateRef{SHA: newCommit.GetSHA(), Force: github.Ptr(false)}) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update branch reference; the branch may have moved", resp, err), nil, nil + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + result := map[string]any{ + "owner": owner, "repo": repo, "branch": branch, "path": path, + "previous_head_sha": expectedHead, "commit_sha": newCommit.GetSHA(), "tree_sha": newTree.GetSHA(), + "previous_blob_sha": expectedBlob, "blob_sha": blob.GetSHA(), + "before_size": len(original), "after_size": len(patched), "edits_applied": len(edits), + } + return MarshalledTextResult(result), nil, nil + }, + ) + tool.ScopeAccess = scopes.DynamicChallenge( + []scopes.Scope{scopes.Repo, scopes.Workflow}, + tool.ScopeAccess.Visible, + workflowScopeChallengeForPath, + ) + return tool +} diff --git a/pkg/github/file_patch_test.go b/pkg/github/file_patch_test.go new file mode 100644 index 0000000000..ec953b65b0 --- /dev/null +++ b/pkg/github/file_patch_test.go @@ -0,0 +1,47 @@ +package github + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyExactTextEdits(t *testing.T) { + t.Run("applies ordered exact replacements", func(t *testing.T) { + got, err := applyExactTextEdits("alpha beta beta gamma", []exactTextEdit{ + {OldText: "alpha", NewText: "ALPHA", ExpectedOccurrences: 1}, + {OldText: "beta", NewText: "BETA", ExpectedOccurrences: 2}, + }) + require.NoError(t, err) + assert.Equal(t, "ALPHA BETA BETA gamma", got) + }) + + t.Run("fails when exact occurrence count differs", func(t *testing.T) { + _, err := applyExactTextEdits("one two two", []exactTextEdit{ + {OldText: "two", NewText: "TWO", ExpectedOccurrences: 1}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected 1 exact matches, found 2") + }) + + t.Run("rejects empty old text", func(t *testing.T) { + _, err := applyExactTextEdits("abc", []exactTextEdit{{OldText: "", NewText: "x", ExpectedOccurrences: 1}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "old_text must not be empty") + }) + + t.Run("rejects no-op patch", func(t *testing.T) { + _, err := applyExactTextEdits("abc", []exactTextEdit{{OldText: "abc", NewText: "abc", ExpectedOccurrences: 1}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no content change") + }) + + t.Run("bounds patch payload", func(t *testing.T) { + tooLarge := strings.Repeat("x", maxPatchInputBytes+1) + _, err := applyExactTextEdits(tooLarge, []exactTextEdit{{OldText: tooLarge, NewText: "y", ExpectedOccurrences: 1}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "patch input exceeds") + }) +} From d43eb111f3f001108291f08a3b0dd5310a43863d Mon Sep 17 00:00:00 2001 From: jasonobrown Date: Sun, 6 Sep 2026 12:25:40 -0500 Subject: [PATCH 2/4] feat: register patch-safe file update tool --- pkg/github/tools.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 6764edfc26..fc23781e54 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -237,6 +237,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent GetLatestRelease(t), GetReleaseByTag(t), CreateOrUpdateFile(t), + ApplyFilePatch(t), CreateRepository(t), DeleteRepository(t), ForkRepository(t), @@ -550,8 +551,7 @@ func GetDefaultToolsetIDs() []string { } // RemoteOnlyToolsets returns toolset metadata for toolsets that are only -// available in the remote MCP server. These are documented but not registered -// in the local server. +// available in the remote MCP server but are documented here for consistency. func RemoteOnlyToolsets() []inventory.ToolsetMetadata { return []inventory.ToolsetMetadata{ ToolsetMetadataCopilotSpaces, From d181e2763ac150cdbf15255fe2847f8d49cc7ee7 Mon Sep 17 00:00:00 2001 From: jasonobrown Date: Sun, 6 Sep 2026 12:27:57 -0500 Subject: [PATCH 3/4] fix: use go-github blob value API --- pkg/github/file_patch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/file_patch.go b/pkg/github/file_patch.go index 375311e845..c05c45a00d 100644 --- a/pkg/github/file_patch.go +++ b/pkg/github/file_patch.go @@ -264,7 +264,7 @@ func ApplyFilePatch(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(fmt.Sprintf("patched file would be %d bytes; maximum is %d bytes", len(patched), maxPatchFileBytes)), nil, nil } - blob, resp, err := client.Git.CreateBlob(ctx, owner, repo, &github.Blob{Content: github.Ptr(patched), Encoding: github.Ptr("utf-8")}) + blob, resp, err := client.Git.CreateBlob(ctx, owner, repo, github.Blob{Content: github.Ptr(patched), Encoding: github.Ptr("utf-8")}) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create patched blob", resp, err), nil, nil } From 6e324d00c6e94317627cba8ee78b848fd835f57c Mon Sep 17 00:00:00 2001 From: jasonobrown Date: Sun, 6 Sep 2026 12:29:51 -0500 Subject: [PATCH 4/4] chore: keep tool registration diff minimal --- pkg/github/tools.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index fc23781e54..8f8a12c21b 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -551,7 +551,8 @@ func GetDefaultToolsetIDs() []string { } // RemoteOnlyToolsets returns toolset metadata for toolsets that are only -// available in the remote MCP server but are documented here for consistency. +// available in the remote MCP server. These are documented but not registered +// in the local server. func RemoteOnlyToolsets() []inventory.ToolsetMetadata { return []inventory.ToolsetMetadata{ ToolsetMetadataCopilotSpaces,