Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,7 @@ The following sets of tools are available:

- **create_or_update_file** - Create or update file
- **Required OAuth Scopes**: `repo`
- `allow_symlink_write`: Set true to update a symbolic link itself; content must be its new target path. (boolean, optional)
- `branch`: Branch to create/update the file in (string, required)
- `content`: 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. (string, required)
- `message`: Commit message (string, required)
Expand Down
5 changes: 5 additions & 0 deletions pkg/github/__toolsnaps__/create_or_update_file.snap
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
"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",
"inputSchema": {
"properties": {
"allow_symlink_write": {
"default": false,
"description": "Set true to update a symbolic link itself; content must be its new target path.",
"type": "boolean"
},
"branch": {
"description": "Branch to create/update the file in",
"type": "string"
Expand Down
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const (
ListCollaborators = "GET /repos/{owner}/{repo}/collaborators"

// Git endpoints
GetReposGitBlobsByOwnerByRepoByFileSHA = "GET /repos/{owner}/{repo}/git/blobs/{file_sha}"
GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}"
GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref:.*}"
PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs"
Expand Down
75 changes: 62 additions & 13 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,11 @@ SHA MUST be provided for existing file updates.
Type: "string",
Description: "The blob SHA of the file being replaced. Required if the file already exists.",
},
"allow_symlink_write": {
Type: "boolean",
Description: "Set true to update a symbolic link itself; content must be its new target path.",
Default: json.RawMessage("false"),
},
},
Required: []string{"owner", "repo", "path", "content", "message", "branch"},
},
Expand Down Expand Up @@ -501,6 +506,11 @@ SHA MUST be provided for existing file updates.
opts.SHA = github.Ptr(sha)
}

allowSymlinkWrite, err := OptionalParam[bool](args, "allow_symlink_write")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

// Create or update the file
client, err := deps.GetClient(ctx)
if err != nil {
Expand Down Expand Up @@ -541,6 +551,22 @@ SHA MUST be provided for existing file updates.
"Pull the latest changes and use git rev-parse %s:%s to get the current SHA.",
sha, currentSHA, branch, path)), nil, nil
}
if !allowSymlinkWrite {
if existingFile.GetType() == "symlink" {
return newSymlinkWriteBlockedResult(path, existingFile.GetTarget()), nil, nil
}
symlinkTarget, isSymlink, respTree, err := symlinkTargetAtPath(ctx, client, owner, repo, branch, path)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to verify whether file path is a symbolic link",
respTree,
err,
), nil, nil
}
if isSymlink {
return newSymlinkWriteBlockedResult(path, symlinkTarget), nil, nil
}
}
}
} else {
// No SHA provided - check if file already exists
Expand Down Expand Up @@ -1045,23 +1071,39 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
if fallbackUsed {
successNote = fmt.Sprintf(" Note: the provided ref '%s' does not exist, default branch '%s' was used instead.", originalRef, rawOpts.Ref)
}
const maxContentSize = 1024 * 1024 // 1MB

read, respInspect, err := inspectRepositoryFile(ctx, client, owner, repo, ref, path, fileContent)
if err != nil {
if respInspect != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to inspect repository file", respInspect, err), nil, nil
}
return utils.NewToolResultError(fmt.Sprintf("failed to inspect repository file: %s", err)), nil, nil
}
if read.Metadata != nil && read.Metadata.Type == "submodule" {
return attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "", successNote))), nil, nil
}
if read.Metadata != nil && fileContent.GetType() == "symlink" && !read.ContentAvailable {
return attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "not_returned", successNote))), nil, nil
}

// Empty files (0 bytes) have no content to decode; return
// them directly as empty text to avoid errors from
// GetContent when the API returns null content with a
// base64 encoding field, and to avoid DetectContentType
// misclassifying them as binary.
if fileSize == 0 {
if read.ContentAvailable && len(read.Content) == 0 {
result := &mcp.ResourceContents{
URI: resourceURI,
Text: "",
MIMEType: "text/plain",
}
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
message := fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote)
message = repositoryReadMessage(read, message, successNote)
return attachIFC(utils.NewToolResultResource(message, result)), nil, nil
}

// For files >= 1MB, return a ResourceLink instead of content
const maxContentSize = 1024 * 1024 // 1MB
if fileSize >= maxContentSize {
size := int64(fileSize)
resourceLink := &mcp.ResourceLink{
Expand All @@ -1070,22 +1112,25 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
Title: fmt.Sprintf("File: %s", path),
Size: &size,
}
message := fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s",
path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote)
if read.Metadata != nil {
resourceLink.Title = fmt.Sprintf("Dereferenced target %s via symlink %s", read.Metadata.ResolvedTargetPath, path)
}
message = repositoryReadMessage(read, message, successNote)
return attachIFC(utils.NewToolResultResourceLink(
fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s",
path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote),
message,
resourceLink)), nil, nil
}

// For files < 1MB, get content directly from Contents API
content, err := fileContent.GetContent()
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to decode file content: %s", err)), nil, nil
if !read.ContentAvailable {
return utils.NewToolResultError("failed to inspect repository file: content unavailable"), nil, nil
}

// Detect content type from the actual content bytes,
// mirroring the original approach of using the Content-Type header
// from the raw API response.
contentBytes := []byte(content)
contentBytes := read.Content
contentType := http.DetectContentType(contentBytes)

// Determine if content is text or binary based on detected content type
Expand All @@ -1098,18 +1143,22 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
if isTextContent {
result := &mcp.ResourceContents{
URI: resourceURI,
Text: content,
Text: string(contentBytes),
MIMEType: contentType,
}
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
message := fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote)
message = repositoryReadMessage(read, message, successNote)
return attachIFC(utils.NewToolResultResource(message, result)), nil, nil
}

result := &mcp.ResourceContents{
URI: resourceURI,
Blob: contentBytes,
MIMEType: contentType,
}
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
message := fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote)
message = repositoryReadMessage(read, message, successNote)
return attachIFC(utils.NewToolResultResource(message, result)), nil, nil
} else if dirContent != nil {
// file content or file SHA is nil which means it's a directory
filtered := false
Expand Down
Loading
Loading