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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1297,7 +1297,7 @@ The following sets of tools are available:
- **push_files** - Push files to repository
- **Required OAuth Scopes**: `repo`
- `branch`: Branch to push to (string, required)
- `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required)
- `files`: Array of file objects to push. Each object has path (string), content (string), and an optional mode (string) selecting the Git Data API tree-entry mode. (object[], required)
- `message`: Commit message (string, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/__toolsnaps__/create_or_update_file.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"annotations": {
"title": "Create or update file"
},
"description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit rev-parse \u003cbranch\u003e:\u003cpath to file\u003e\n\nSHA MUST be provided for existing file updates.\n",
"description": "Create or update a single file in a GitHub repository.\nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit rev-parse \u003cbranch\u003e:\u003cpath to file\u003e\n\nSHA MUST be provided for existing file updates.\n\nThis tool always writes the file with mode 100644 because the Contents API has no way to set the executable bit. If you need to publish an executable file, use push_files with a single-entry files array and mode set to 100755 instead.\n",
"inputSchema": {
"properties": {
"branch": {
Expand Down
15 changes: 13 additions & 2 deletions pkg/github/__toolsnaps__/push_files.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,33 @@
"annotations": {
"title": "Push files to repository"
},
"description": "Push multiple files to a GitHub repository in a single commit",
"description": "Push multiple files to a GitHub repository in a single commit.\n\nEach file entry accepts an optional \"mode\" matching the Git Data API tree-entry modes (100644 regular file, 100755 executable, 120000 symlink, 040000 subtree, 160000 submodule). Omit \"mode\" for a regular file.\n\nUse this tool with a single-entry files array when you need to land an executable file (mode 100755); create_or_update_file cannot set the executable bit because the Contents API always writes 100644.",
"inputSchema": {
"properties": {
"branch": {
"description": "Branch to push to",
"type": "string"
},
"files": {
"description": "Array of file objects to push, each object with path (string) and content (string)",
"description": "Array of file objects to push. Each object has path (string), content (string), and an optional mode (string) selecting the Git Data API tree-entry mode.",
"items": {
"additionalProperties": false,
"properties": {
"content": {
"description": "file content",
"type": "string"
},
"mode": {
"description": "Git tree-entry mode. Defaults to 100644 (regular file) when omitted. Use 100755 for an executable file.",
"enum": [
"100644",
"100755",
"120000",
"040000",
"160000"
],
"type": "string"
},
"path": {
"description": "path to the file",
"type": "string"
Expand Down
42 changes: 36 additions & 6 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,13 +358,15 @@ func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTo
ToolsetMetadataRepos,
mcp.Tool{
Name: "create_or_update_file",
Description: t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", `Create or update a single file in a GitHub repository.
Description: t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", `Create or update a single file in a GitHub repository.
If updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.

In order to obtain the SHA of original file version before updating, use the following git command:
git rev-parse <branch>:<path to file>

SHA MUST be provided for existing file updates.

This tool always writes the file with mode 100644 because the Contents API has no way to set the executable bit. If you need to publish an executable file, use push_files with a single-entry files array and mode set to 100755 instead.
`),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"),
Expand Down Expand Up @@ -1293,8 +1295,12 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "push_files",
Description: t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit"),
Name: "push_files",
Description: t("TOOL_PUSH_FILES_DESCRIPTION", `Push multiple files to a GitHub repository in a single commit.

Each file entry accepts an optional "mode" matching the Git Data API tree-entry modes (100644 regular file, 100755 executable, 120000 symlink, 040000 subtree, 160000 submodule). Omit "mode" for a regular file.

Use this tool with a single-entry files array when you need to land an executable file (mode 100755); create_or_update_file cannot set the executable bit because the Contents API always writes 100644.`),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"),
ReadOnlyHint: false,
Expand All @@ -1316,7 +1322,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
},
"files": {
Type: "array",
Description: "Array of file objects to push, each object with path (string) and content (string)",
Description: "Array of file objects to push. Each object has path (string), content (string), and an optional mode (string) selecting the Git Data API tree-entry mode.",
Items: &jsonschema.Schema{
Type: "object",
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
Expand All @@ -1329,6 +1335,11 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
Type: "string",
Description: "file content",
},
"mode": {
Type: "string",
Description: "Git tree-entry mode. Defaults to 100644 (regular file) when omitted. Use 100755 for an executable file.",
Enum: []any{"100644", "100755", "120000", "040000", "160000"},
},
},
Required: []string{"path", "content"},
},
Expand Down Expand Up @@ -1442,6 +1453,14 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
// Create tree entries for all files (or remaining files if empty repo)
var entries []*github.TreeEntry

allowedModes := map[string]struct{}{
"100644": {}, // regular file
"100755": {}, // executable file
"120000": {}, // symlink
"040000": {}, // subtree
"160000": {}, // submodule
}

for _, file := range filesObj {
fileMap, ok := file.(map[string]any)
if !ok {
Expand All @@ -1458,10 +1477,21 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
return utils.NewToolResultError("each file must have content"), nil, nil
}

// Create a tree entry for the file
mode := "100644"
if rawMode, present := fileMap["mode"]; present && rawMode != nil {
modeStr, ok := rawMode.(string)
if !ok {
return utils.NewToolResultError(fmt.Sprintf("file %q: mode must be a string", path)), nil, nil
}
if _, valid := allowedModes[modeStr]; !valid {
return utils.NewToolResultError(fmt.Sprintf("file %q: mode %q is not one of 100644, 100755, 120000, 040000, 160000", path, modeStr)), nil, nil
}
mode = modeStr
}

entries = append(entries, &github.TreeEntry{
Path: github.Ptr(path),
Mode: github.Ptr("100644"), // Regular file mode
Mode: github.Ptr(mode),
Type: github.Ptr("blob"),
Content: github.Ptr(content),
})
Expand Down
118 changes: 118 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2558,6 +2558,124 @@ func Test_PushFiles(t *testing.T) {
expectError: false,
expectedErrMsg: "failed to initialize repository",
},
{
name: "successful push with executable mode",
mockedClient: NewMockedHTTPClient(
WithRequestMatch(
GetReposGitRefByOwnerByRepoByRef,
mockRef,
),
WithRequestMatch(
GetReposGitCommitsByOwnerByRepoByCommitSHA,
mockCommit,
),
WithRequestMatchHandler(
PostReposGitTreesByOwnerByRepo,
expectRequestBody(t, map[string]any{
"base_tree": "def456",
"tree": []any{
map[string]any{
"path": "scripts/build.sh",
"mode": "100755",
"type": "blob",
"content": "#!/usr/bin/env bash\necho hi\n",
},
map[string]any{
"path": "README.md",
"mode": "100644",
"type": "blob",
"content": "# README\n",
},
},
}).andThen(
mockResponse(t, http.StatusCreated, mockTree),
),
),
WithRequestMatch(
PostReposGitCommitsByOwnerByRepo,
mockNewCommit,
),
WithRequestMatch(
PatchReposGitRefsByOwnerByRepoByRef,
mockUpdatedRef,
),
),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"branch": "main",
"files": []any{
map[string]any{
"path": "scripts/build.sh",
"content": "#!/usr/bin/env bash\necho hi\n",
"mode": "100755",
},
map[string]any{
"path": "README.md",
"content": "# README\n",
},
},
"message": "add build script",
},
expectError: false,
expectedRef: mockUpdatedRef,
},
{
name: "rejects invalid file mode",
mockedClient: NewMockedHTTPClient(
WithRequestMatch(
GetReposGitRefByOwnerByRepoByRef,
mockRef,
),
WithRequestMatch(
GetReposGitCommitsByOwnerByRepoByCommitSHA,
mockCommit,
),
),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"branch": "main",
"files": []any{
map[string]any{
"path": "scripts/build.sh",
"content": "#!/usr/bin/env bash\n",
"mode": "0755",
},
},
"message": "add build script",
},
expectError: false,
expectedErrMsg: `mode "0755" is not one of`,
},
{
name: "rejects non-string file mode",
mockedClient: NewMockedHTTPClient(
WithRequestMatch(
GetReposGitRefByOwnerByRepoByRef,
mockRef,
),
WithRequestMatch(
GetReposGitCommitsByOwnerByRepoByCommitSHA,
mockCommit,
),
),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"branch": "main",
"files": []any{
map[string]any{
"path": "scripts/build.sh",
"content": "#!/usr/bin/env bash\n",
"mode": 33261,
},
},
"message": "add build script",
},
expectError: false,
expectedErrMsg: "mode must be a string",
},
}

for _, tc := range tests {
Expand Down