Skip to content

Commit c9b31b0

Browse files
committed
fix: support binary file content via base64 encoding in create_or_update_file and push_files
JSON tool arguments must be valid UTF-8, so binary file content (e.g. images) could not be written through create_or_update_file or push_files: the content string was passed through verbatim, corrupting non-UTF-8 bytes. Add an optional encoding parameter (utf-8, the default, or base64) to both tools. For base64 content, create_or_update_file decodes it once before handing the raw bytes to the Contents API. push_files uploads decoded content as a git blob and references it by SHA in the tree, because the Trees API rejects TreeEntry content that is not valid UTF-8. Invalid base64 and unsupported encoding values return clear tool errors. Closes #3312
1 parent 85598ba commit c9b31b0

5 files changed

Lines changed: 301 additions & 18 deletions

File tree

‎pkg/github/__toolsnaps__/create_or_update_file.snap‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,16 @@
1717
"type": "string"
1818
},
1919
"content": {
20-
"description": "Content of the file, exactly as it should appear once written. Do not base64-encode it; this server does that before calling the REST API.",
20+
"description": "Content of the file, exactly as it should appear once written. Do not base64-encode it; this server does that before calling the REST API. If the content is binary (for example an image), pass it base64-encoded together with encoding \"base64\".",
21+
"type": "string"
22+
},
23+
"encoding": {
24+
"default": "utf-8",
25+
"description": "Encoding of the content parameter. \"utf-8\" (default) writes the content as-is. \"base64\" decodes the content once and writes the resulting raw bytes; use it for binary files such as images, whose bytes cannot be represented in a JSON string.",
26+
"enum": [
27+
"utf-8",
28+
"base64"
29+
],
2130
"type": "string"
2231
},
2332
"message": {

‎pkg/github/__toolsnaps__/push_files.snap‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,21 @@
1212
"type": "string"
1313
},
1414
"files": {
15-
"description": "Array of file objects to push, each object with path (string) and content (string)",
15+
"description": "Array of file objects to push, each with path (string), content (string), and optional encoding (\"utf-8\" or \"base64\"; defaults to \"utf-8\")",
1616
"items": {
1717
"additionalProperties": false,
1818
"properties": {
1919
"content": {
20-
"description": "file content",
20+
"description": "file content; when encoding is \"base64\", this is the base64-encoded content of the file",
21+
"type": "string"
22+
},
23+
"encoding": {
24+
"default": "utf-8",
25+
"description": "Encoding of the file content. \"utf-8\" (default) writes the content as-is. \"base64\" decodes the content once and writes the resulting raw bytes; use it for binary files such as images, whose bytes cannot be represented in a JSON string.",
26+
"enum": [
27+
"utf-8",
28+
"base64"
29+
],
2130
"type": "string"
2231
},
2332
"path": {

‎pkg/github/helper_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const (
4646

4747
// Git endpoints
4848
GetReposGitBlobsByOwnerByRepoByFileSHA = "GET /repos/{owner}/{repo}/git/blobs/{file_sha}"
49+
PostReposGitBlobsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/blobs"
4950
GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}"
5051
GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref:.*}"
5152
PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs"

‎pkg/github/repositories.go‎

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,13 @@ SHA MUST be provided for existing file updates.
437437
},
438438
"content": {
439439
Type: "string",
440-
Description: "Content of the file, exactly as it should appear once written. Do not base64-encode it; this server does that before calling the REST API.",
440+
Description: "Content of the file, exactly as it should appear once written. Do not base64-encode it; this server does that before calling the REST API. If the content is binary (for example an image), pass it base64-encoded together with encoding \"base64\".",
441+
},
442+
"encoding": {
443+
Type: "string",
444+
Description: "Encoding of the content parameter. \"utf-8\" (default) writes the content as-is. \"base64\" decodes the content once and writes the resulting raw bytes; use it for binary files such as images, whose bytes cannot be represented in a JSON string.",
445+
Enum: []any{"utf-8", "base64"},
446+
Default: json.RawMessage(`"utf-8"`),
441447
},
442448
"message": {
443449
Type: "string",
@@ -492,7 +498,22 @@ SHA MUST be provided for existing file updates.
492498
}
493499

494500
// json.Marshal encodes byte arrays with base64, which is required for the API.
495-
contentBytes := []byte(content)
501+
encoding, err := OptionalParam[string](args, "encoding")
502+
if err != nil {
503+
return utils.NewToolResultError(err.Error()), nil, nil
504+
}
505+
var contentBytes []byte
506+
switch encoding {
507+
case "", "utf-8":
508+
contentBytes = []byte(content)
509+
case "base64":
510+
contentBytes, err = base64.StdEncoding.DecodeString(content)
511+
if err != nil {
512+
return utils.NewToolResultError(fmt.Sprintf("content parameter is not valid base64: %v", err)), nil, nil
513+
}
514+
default:
515+
return utils.NewToolResultError(fmt.Sprintf("invalid encoding %q: must be \"utf-8\" or \"base64\"", encoding)), nil, nil
516+
}
496517

497518
// Create the file options
498519
opts := &github.RepositoryContentFileOptions{
@@ -1636,7 +1657,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
16361657
},
16371658
"files": {
16381659
Type: "array",
1639-
Description: "Array of file objects to push, each object with path (string) and content (string)",
1660+
Description: "Array of file objects to push, each with path (string), content (string), and optional encoding (\"utf-8\" or \"base64\"; defaults to \"utf-8\")",
16401661
Items: &jsonschema.Schema{
16411662
Type: "object",
16421663
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
@@ -1647,7 +1668,13 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
16471668
},
16481669
"content": {
16491670
Type: "string",
1650-
Description: "file content",
1671+
Description: "file content; when encoding is \"base64\", this is the base64-encoded content of the file",
1672+
},
1673+
"encoding": {
1674+
Type: "string",
1675+
Description: "Encoding of the file content. \"utf-8\" (default) writes the content as-is. \"base64\" decodes the content once and writes the resulting raw bytes; use it for binary files such as images, whose bytes cannot be represented in a JSON string.",
1676+
Enum: []any{"utf-8", "base64"},
1677+
Default: json.RawMessage(`"utf-8"`),
16511678
},
16521679
},
16531680
Required: []string{"path", "content"},
@@ -1680,6 +1707,11 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
16801707
return utils.NewToolResultError(err.Error()), nil, nil
16811708
}
16821709

1710+
client, err := deps.GetClient(ctx)
1711+
if err != nil {
1712+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
1713+
}
1714+
16831715
// Parse files parameter - this should be an array of objects with path and content
16841716
filesObj, ok := args["files"].([]any)
16851717
if !ok {
@@ -1707,17 +1739,45 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
17071739
return utils.NewToolResultError("each file must have content"), nil, nil
17081740
}
17091741

1710-
entries = append(entries, &github.TreeEntry{
1711-
Path: github.Ptr(filePath),
1712-
Mode: github.Ptr("100644"),
1713-
Type: github.Ptr("blob"),
1714-
Content: github.Ptr(content),
1715-
})
1716-
}
1717-
1718-
client, err := deps.GetClient(ctx)
1719-
if err != nil {
1720-
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
1742+
encoding, _ := fileMap["encoding"].(string)
1743+
switch encoding {
1744+
case "", "utf-8":
1745+
entries = append(entries, &github.TreeEntry{
1746+
Path: github.Ptr(filePath),
1747+
Mode: github.Ptr("100644"),
1748+
Type: github.Ptr("blob"),
1749+
Content: github.Ptr(content),
1750+
})
1751+
case "base64":
1752+
// The Trees API rejects TreeEntry content that is not valid UTF-8,
1753+
// so binary files are uploaded as blobs and referenced by SHA.
1754+
decoded, err := base64.StdEncoding.DecodeString(content)
1755+
if err != nil {
1756+
return utils.NewToolResultError(fmt.Sprintf("file %s content is not valid base64: %v", filePath, err)), nil, nil
1757+
}
1758+
blob, resp, err := client.Git.CreateBlob(ctx, owner, repo, github.Blob{
1759+
Content: github.Ptr(base64.StdEncoding.EncodeToString(decoded)),
1760+
Encoding: github.Ptr("base64"),
1761+
})
1762+
if err != nil {
1763+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1764+
fmt.Sprintf("failed to create blob for file %s", filePath),
1765+
resp,
1766+
err,
1767+
), nil, nil
1768+
}
1769+
if resp != nil && resp.Body != nil {
1770+
_ = resp.Body.Close()
1771+
}
1772+
entries = append(entries, &github.TreeEntry{
1773+
Path: github.Ptr(filePath),
1774+
Mode: github.Ptr("100644"),
1775+
Type: github.Ptr("blob"),
1776+
SHA: blob.SHA,
1777+
})
1778+
default:
1779+
return utils.NewToolResultError(fmt.Sprintf("file %s has invalid encoding %q: must be \"utf-8\" or \"base64\"", filePath, encoding)), nil, nil
1780+
}
17211781
}
17221782

17231783
// Get the reference for the branch

0 commit comments

Comments
 (0)