diff --git a/README.md b/README.md index 78195d5827..67943bc5c4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index 85ad887649..37bf3b46bf 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -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" diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index f6737c5df0..5fc541d45d 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -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" diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index aa0509ad46..468f8da92e 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -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"}, }, @@ -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 { @@ -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 @@ -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{ @@ -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 @@ -1098,10 +1143,12 @@ 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{ @@ -1109,7 +1156,9 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool 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 diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index 9795bdc109..8859b4a2ba 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -2,10 +2,15 @@ package github import ( "context" + "crypto/sha1" //nolint:gosec // Git object IDs use SHA-1 by definition. + "encoding/hex" "encoding/json" "fmt" "net/http" + "net/url" + pathpkg "path" "strings" + "unicode/utf8" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/raw" @@ -90,6 +95,269 @@ func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client return createdRef, nil } +const ( + gitSymlinkMode = "120000" + gitSubmoduleMode = "160000" +) + +type repositoryPathMetadata struct { + Type string `json:"type"` + Path string `json:"path"` + SHA string `json:"sha,omitempty"` + Target string `json:"target,omitempty"` + ResolvedTargetPath string `json:"resolved_path,omitempty"` + GitURL string `json:"git_url,omitempty"` + Content string `json:"content,omitempty"` + Note string `json:"note,omitempty"` +} + +type repositoryFileRead struct { + Content []byte + ContentAvailable bool + Metadata *repositoryPathMetadata +} + +type symlinkWriteBlockedError struct { + Error string `json:"error"` + Path string `json:"path"` + Target string `json:"target"` + ResolvedTargetPath string `json:"resolved_path,omitempty"` +} + +func newSymlinkWriteBlockedResult(path, target string) *mcp.CallToolResult { + resolvedTargetPath := resolveRepositorySymlinkTarget(path, target) + payload, _ := json.Marshal(symlinkWriteBlockedError{ + Error: "symlink_write_requires_opt_in", + Path: path, + Target: target, + ResolvedTargetPath: resolvedTargetPath, + }) + recovery := fmt.Sprintf( + `Target is outside this repository. Retarget link: allow_symlink_write=true. Replace with a file: push_files path=%q.`, + path, + ) + if resolvedTargetPath != "" { + recovery = fmt.Sprintf( + `Edit target: create_or_update_file path=%q. Retarget link: allow_symlink_write=true. Replace with a file: push_files path=%q.`, + resolvedTargetPath, + path, + ) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(payload)}, + &mcp.TextContent{Text: recovery}, + }, + IsError: true, + } +} + +func inspectRepositoryFile(ctx context.Context, client *github.Client, owner, repo, ref, path string, file *github.RepositoryContent) (*repositoryFileRead, *github.Response, error) { + if file.GetType() == "submodule" || file.GetSubmoduleGitURL() != "" { + return &repositoryFileRead{Metadata: &repositoryPathMetadata{ + Type: "submodule", Path: path, SHA: file.GetSHA(), GitURL: file.GetSubmoduleGitURL(), + }}, nil, nil + } + + content, available, err := repositoryContentBytes(file) + if err != nil { + return nil, nil, err + } + if file.GetType() == "symlink" { + return &repositoryFileRead{ + Content: content, ContentAvailable: available, + Metadata: newSymlinkReadMetadata(path, file.GetSHA(), file.GetTarget()), + }, nil, nil + } + + if available { + if !looksLikeSHA(file.GetSHA()) { + return nil, nil, fmt.Errorf("contents API returned malformed Git blob SHA %q", file.GetSHA()) + } + if strings.EqualFold(gitBlobSHA(content), file.GetSHA()) { + return &repositoryFileRead{Content: content, ContentAvailable: true}, nil, nil + } + target, resp, err := getVerifiedBlob(ctx, client, owner, repo, file.GetSHA()) + if err != nil { + return nil, resp, err + } + if !validInternalSymlinkTarget(path, target) { + return nil, nil, fmt.Errorf("blob %q is not a valid internal symbolic link target", file.GetSHA()) + } + return &repositoryFileRead{ + Content: content, ContentAvailable: true, + Metadata: newSymlinkReadMetadata(path, file.GetSHA(), string(target)), + }, nil, nil + } + + entry, resp, err := getTreeEntry(ctx, client, owner, repo, ref, path) + if err != nil { + return nil, resp, err + } + if entry == nil || !looksLikeSHA(file.GetSHA()) || !strings.EqualFold(entry.GetSHA(), file.GetSHA()) { + return nil, nil, fmt.Errorf("contents API metadata does not match the Git tree for path %q", path) + } + switch entry.GetMode() { + case gitSymlinkMode: + target, resp, err := getVerifiedBlob(ctx, client, owner, repo, entry.GetSHA()) + if err != nil { + return nil, resp, err + } + return &repositoryFileRead{Metadata: newSymlinkReadMetadata(path, entry.GetSHA(), string(target))}, nil, nil + case gitSubmoduleMode: + return &repositoryFileRead{Metadata: &repositoryPathMetadata{ + Type: "submodule", Path: path, SHA: entry.GetSHA(), + }}, nil, nil + default: + return &repositoryFileRead{}, nil, nil + } +} + +func repositoryContentBytes(file *github.RepositoryContent) ([]byte, bool, error) { + if file.Content == nil { + return []byte{}, file.GetType() != "symlink" && file.GetSize() == 0, nil + } + content, err := file.GetContent() + if err != nil { + return nil, false, fmt.Errorf("failed to decode file content: %w", err) + } + return []byte(content), true, nil +} + +func gitBlobSHA(content []byte) string { + hash := sha1.New() //nolint:gosec // Git object IDs use SHA-1 by definition. + _, _ = fmt.Fprintf(hash, "blob %d\x00", len(content)) + _, _ = hash.Write(content) + return hex.EncodeToString(hash.Sum(nil)) +} + +func getVerifiedBlob(ctx context.Context, client *github.Client, owner, repo, sha string) ([]byte, *github.Response, error) { + if !looksLikeSHA(sha) { + return nil, nil, fmt.Errorf("malformed Git blob SHA %q", sha) + } + content, resp, err := client.Git.GetBlobRaw(ctx, owner, repo, sha) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + return nil, resp, err + } + if !strings.EqualFold(gitBlobSHA(content), sha) { + return nil, nil, fmt.Errorf("blob returned by the Git Blobs API does not match SHA %q", sha) + } + return content, nil, nil +} + +func validInternalSymlinkTarget(path string, target []byte) bool { + return len(target) > 0 && + utf8.Valid(target) && + !strings.ContainsAny(string(target), "\x00\r\n") && + resolveRepositorySymlinkTarget(path, string(target)) != "" +} + +func newSymlinkReadMetadata(path, sha, target string) *repositoryPathMetadata { + return &repositoryPathMetadata{ + Type: "symlink", Path: path, SHA: sha, Target: target, + ResolvedTargetPath: resolveRepositorySymlinkTarget(path, target), + } +} + +func marshalRepositoryPathMetadata(metadata *repositoryPathMetadata, content, note string) string { + result := *metadata + result.Content = content + result.Note = strings.TrimSpace(note) + payload, _ := json.Marshal(result) + return string(payload) +} + +func repositoryReadMessage(read *repositoryFileRead, fallback, note string) string { + if read.Metadata == nil { + return fallback + } + content := "dereferenced_target" + if !read.ContentAvailable { + content = "not_returned" + } + return marshalRepositoryPathMetadata(read.Metadata, content, note) +} + +func symlinkTargetAtPath(ctx context.Context, client *github.Client, owner, repo, treeish, path string) (string, bool, *github.Response, error) { + entry, resp, err := getTreeEntry(ctx, client, owner, repo, treeish, path) + if err != nil { + return "", false, resp, err + } + if entry == nil { + return "", false, nil, fmt.Errorf("path %q exists according to the Contents API but was not found in the Git tree", path) + } + if entry.GetMode() != gitSymlinkMode { + return "", false, nil, nil + } + + target, resp, err := getVerifiedBlob(ctx, client, owner, repo, entry.GetSHA()) + if err != nil { + return "", false, resp, err + } + return string(target), true, nil, nil +} + +func getTreeEntry(ctx context.Context, client *github.Client, owner, repo, treeish, path string) (*github.TreeEntry, *github.Response, error) { + segments := strings.Split(pathpkg.Clean(strings.TrimPrefix(path, "/")), "/") + if len(segments) > 64 { + return nil, nil, fmt.Errorf("path %q exceeds Git tree traversal limit", path) + } + treeish = escapeGitTreeish(treeish) + for i, segment := range segments { + tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeish, false) + if err != nil { + return nil, resp, err + } + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if tree.GetTruncated() { + return nil, resp, fmt.Errorf("git tree %q is truncated", treeish) + } + + var matched *github.TreeEntry + for _, entry := range tree.Entries { + if entry.GetPath() == segment { + matched = entry + break + } + } + if matched == nil { + return nil, nil, nil + } + if i == len(segments)-1 { + return matched, nil, nil + } + if matched.GetType() != "tree" { + return nil, nil, nil + } + treeish = matched.GetSHA() + } + return nil, nil, nil +} + +func escapeGitTreeish(treeish string) string { + segments := strings.Split(treeish, "/") + for i, segment := range segments { + segments[i] = url.PathEscape(segment) + } + return strings.Join(segments, "/") +} + +func resolveRepositorySymlinkTarget(linkPath, target string) string { + if pathpkg.IsAbs(target) { + return "" + } + resolved := pathpkg.Clean(pathpkg.Join(pathpkg.Dir(linkPath), target)) + if resolved == ".." || strings.HasPrefix(resolved, "../") { + return "" + } + return resolved +} + // matchFiles searches for files in the Git tree that match the given path. // It's used when GetContents fails or returns unexpected results. func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 10aef42d67..abfa78b30b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -26,6 +26,69 @@ import ( "github.com/stretchr/testify/require" ) +type repositoryRequestCountingTransport struct { + inner http.RoundTripper + count int +} + +func (t *repositoryRequestCountingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.count++ + return t.inner.RoundTrip(req) +} + +type repositoryReadFixture struct { + contents any + blobs map[string][]byte + trees map[string]*github.Tree + inspect func(*http.Request) +} + +func runRepositoryReadFixture(t *testing.T, fixture repositoryReadFixture, args map[string]any) (*mcp.CallToolResult, int) { + t.Helper() + backend := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "": func(w http.ResponseWriter, r *http.Request) { + if fixture.inspect != nil { + fixture.inspect(r) + } + switch { + case strings.Contains(r.URL.Path, "/contents/"): + mockResponse(t, http.StatusOK, fixture.contents)(w, r) + case strings.Contains(r.URL.Path, "/git/blobs/"): + sha := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + content, ok := fixture.blobs[sha] + require.True(t, ok, "unexpected blob request: %s", sha) + _, _ = w.Write(content) + case strings.Contains(r.URL.Path, "/git/trees/"): + treeish := r.URL.Path[strings.Index(r.URL.Path, "/git/trees/")+len("/git/trees/"):] + tree, ok := fixture.trees[treeish] + require.True(t, ok, "unexpected tree request: %s", treeish) + mockResponse(t, http.StatusOK, tree)(w, r) + default: + http.NotFound(w, r) + } + }, + }) + counter := &repositoryRequestCountingTransport{inner: backend.Transport} + client := mustNewGHClient(t, &http.Client{Transport: counter}) + deps := BaseDeps{Client: client} + tool := GetFileContents(translations.NullTranslationHelper) + handler := tool.Handler(deps) + request := createMCPRequest(args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + return result, counter.count +} + +func repositoryPathMetadataFromResult(t *testing.T, result *mcp.CallToolResult) repositoryPathMetadata { + t.Helper() + require.NotEmpty(t, result.Content) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + var metadata repositoryPathMetadata + require.NoError(t, json.Unmarshal([]byte(text.Text), &metadata)) + return metadata +} + func Test_GetFileContents(t *testing.T) { // Verify tool definition once serverTool := GetFileContents(translations.NullTranslationHelper) @@ -54,7 +117,7 @@ func Test_GetFileContents(t *testing.T) { Type: github.Ptr("file"), Name: github.Ptr("README.md"), Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), + SHA: github.Ptr(gitBlobSHA(mockRawContent)), Size: github.Ptr(42), HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), }, @@ -87,7 +150,7 @@ func Test_GetFileContents(t *testing.T) { fileContent := &github.RepositoryContent{ Name: github.Ptr("README.md"), Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), + SHA: github.Ptr(gitBlobSHA(mockRawContent)), Type: github.Ptr("file"), Content: github.Ptr(string(mockRawContent)), Size: github.Ptr(len(mockRawContent)), @@ -122,7 +185,7 @@ func Test_GetFileContents(t *testing.T) { fileContent := &github.RepositoryContent{ Name: github.Ptr("test.png"), Path: github.Ptr("test.png"), - SHA: github.Ptr("def456"), + SHA: github.Ptr(gitBlobSHA(pngContent)), Type: github.Ptr("file"), Content: github.Ptr(encodedContent), Size: github.Ptr(len(pngContent)), @@ -158,7 +221,7 @@ func Test_GetFileContents(t *testing.T) { fileContent := &github.RepositoryContent{ Name: github.Ptr("document.pdf"), Path: github.Ptr("document.pdf"), - SHA: github.Ptr("pdf123"), + SHA: github.Ptr(gitBlobSHA(pdfContent)), Type: github.Ptr("file"), Content: github.Ptr(encodedContent), Size: github.Ptr(len(pdfContent)), @@ -213,7 +276,7 @@ func Test_GetFileContents(t *testing.T) { fileContent := &github.RepositoryContent{ Name: github.Ptr("README.md"), Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), + SHA: github.Ptr(gitBlobSHA(mockRawContent)), Type: github.Ptr("file"), Content: github.Ptr(encodedContent), Size: github.Ptr(len(mockRawContent)), @@ -297,7 +360,7 @@ func Test_GetFileContents(t *testing.T) { fileContent := &github.RepositoryContent{ Name: github.Ptr("README.md"), Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), + SHA: github.Ptr(gitBlobSHA(mockRawContent)), Type: github.Ptr("file"), Content: github.Ptr(encodedContent), Size: github.Ptr(len(mockRawContent)), @@ -326,13 +389,21 @@ func Test_GetFileContents(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + "GET /repos/owner/repo/git/trees/refs/heads/main": mockResponse(t, http.StatusOK, &github.Tree{ + Entries: []*github.TreeEntry{{ + Path: github.Ptr("large-file.bin"), + Mode: github.Ptr("100644"), + Type: github.Ptr("blob"), + SHA: github.Ptr(strings.Repeat("a", 40)), + }}, + }), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) // File larger than 1MB - Contents API returns metadata but no content fileContent := &github.RepositoryContent{ Name: github.Ptr("large-file.bin"), Path: github.Ptr("large-file.bin"), - SHA: github.Ptr("largesha123"), + SHA: github.Ptr(strings.Repeat("a", 40)), Type: github.Ptr("file"), Size: github.Ptr(2 * 1024 * 1024), // 2MB DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/large-file.bin"), @@ -364,7 +435,7 @@ func Test_GetFileContents(t *testing.T) { fileContent := &github.RepositoryContent{ Name: github.Ptr(".gitkeep"), Path: github.Ptr(".gitkeep"), - SHA: github.Ptr("empty123"), + SHA: github.Ptr(gitBlobSHA(nil)), Type: github.Ptr("file"), Content: nil, Size: github.Ptr(0), @@ -512,6 +583,207 @@ func Test_GetFileContents(t *testing.T) { } } +func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { + commitSHA := strings.Repeat("c", 40) + args := func(path string) map[string]any { + return map[string]any{"owner": "owner", "repo": "repo", "path": path, "sha": commitSHA} + } + + t.Run("normal file remains one request", func(t *testing.T) { + content := []byte("ordinary content") + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("file"), Path: github.Ptr("README.md"), SHA: github.Ptr(gitBlobSHA(content)), + Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + }, + }, args("README.md")) + require.False(t, result.IsError) + assert.Equal(t, 1, requests) + assert.Equal(t, string(content), getResourceResult(t, result).Text) + }) + + for _, tc := range []struct { + name string + content []byte + mime string + }{ + {name: "text", content: []byte("resolved text"), mime: "text/plain; charset=utf-8"}, + {name: "binary", content: []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"), mime: "image/png"}, + } { + t.Run("internal "+tc.name, func(t *testing.T) { + target := "../target/" + tc.name + linkSHA := gitBlobSHA([]byte(target)) + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("file"), Path: github.Ptr("docs/link"), SHA: github.Ptr(linkSHA), + Content: github.Ptr(base64.StdEncoding.EncodeToString(tc.content)), + Encoding: github.Ptr("base64"), Size: github.Ptr(len(tc.content)), + }, + blobs: map[string][]byte{linkSHA: []byte(target)}, + }, args("docs/link")) + require.False(t, result.IsError) + assert.Equal(t, 2, requests) + metadata := repositoryPathMetadataFromResult(t, result) + assert.Equal(t, "symlink", metadata.Type) + assert.Equal(t, "docs/link", metadata.Path) + assert.Equal(t, target, metadata.Target) + assert.Equal(t, "target/"+tc.name, metadata.ResolvedTargetPath) + assert.Equal(t, "dereferenced_target", metadata.Content) + resource := getResourceResult(t, result) + assert.Equal(t, tc.mime, resource.MIMEType) + if tc.name == "text" { + assert.Equal(t, string(tc.content), resource.Text) + } else { + assert.Equal(t, tc.content, resource.Blob) + } + }) + } + + t.Run("explicit dangling or outside link", func(t *testing.T) { + target := "../../outside" + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("symlink"), Path: github.Ptr("docs/link"), + SHA: github.Ptr(gitBlobSHA([]byte(target))), Target: github.Ptr(target), + }, + }, args("docs/link")) + require.False(t, result.IsError) + assert.Equal(t, 1, requests) + metadata := repositoryPathMetadataFromResult(t, result) + assert.Equal(t, target, metadata.Target) + assert.Empty(t, metadata.ResolvedTargetPath) + assert.Equal(t, "not_returned", metadata.Content) + require.Len(t, result.Content, 1) + }) + + t.Run("explicit link returns supplied content", func(t *testing.T) { + target, content := "target.txt", []byte("resolved") + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("symlink"), Path: github.Ptr("docs/link"), + SHA: github.Ptr(gitBlobSHA([]byte(target))), Target: github.Ptr(target), + Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + }, + }, args("docs/link")) + require.False(t, result.IsError) + assert.Equal(t, 1, requests) + assert.Equal(t, "dereferenced_target", repositoryPathMetadataFromResult(t, result).Content) + assert.Equal(t, string(content), getResourceResult(t, result).Text) + }) + + t.Run("submodule is explicit", func(t *testing.T) { + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("submodule"), Path: github.Ptr("vendor/module"), + SHA: github.Ptr(strings.Repeat("d", 40)), SubmoduleGitURL: github.Ptr("https://example.com/module.git"), + }, + }, args("vendor/module")) + require.False(t, result.IsError) + assert.Equal(t, 1, requests) + metadata := repositoryPathMetadataFromResult(t, result) + assert.Equal(t, "submodule", metadata.Type) + assert.Equal(t, "https://example.com/module.git", metadata.GitURL) + }) + + t.Run("malformed and anomalous mismatches fail closed", func(t *testing.T) { + content := []byte("resolved") + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("file"), Path: github.Ptr("bad"), SHA: github.Ptr("bad-sha"), + Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + }, + }, args("bad")) + assert.Equal(t, 1, requests) + assert.Contains(t, getErrorResult(t, result).Text, "malformed Git blob SHA") + + pathBlob := []byte("not\na\ntarget") + pathSHA := gitBlobSHA(pathBlob) + result, requests = runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("file"), Path: github.Ptr("bad"), SHA: github.Ptr(pathSHA), + Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + }, + blobs: map[string][]byte{pathSHA: pathBlob}, + }, args("bad")) + assert.Equal(t, 2, requests) + assert.Contains(t, getErrorResult(t, result).Text, "not a valid internal symbolic link target") + }) +} + +func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { + commitSHA := strings.Repeat("c", 40) + fileSHA := strings.Repeat("a", 40) + const largeSize = 2 * 1024 * 1024 + args := func(path string) map[string]any { + return map[string]any{"owner": "owner", "repo": "repo", "path": path, "sha": commitSHA} + } + + t.Run("normal large file", func(t *testing.T) { + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("file"), Path: github.Ptr("large.bin"), SHA: github.Ptr(fileSHA), Size: github.Ptr(largeSize), + }, + trees: map[string]*github.Tree{commitSHA: {Entries: []*github.TreeEntry{ + {Path: github.Ptr("large.bin"), Mode: github.Ptr("100644"), Type: github.Ptr("blob"), SHA: github.Ptr(fileSHA)}, + }}}, + }, args("large.bin")) + require.False(t, result.IsError) + assert.Equal(t, 2, requests) + _, ok := result.Content[1].(*mcp.ResourceLink) + require.True(t, ok) + }) + + t.Run("internal large link uses exact-path tree descent", func(t *testing.T) { + target := "../target.bin" + linkSHA := gitBlobSHA([]byte(target)) + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("file"), Path: github.Ptr("docs/link"), SHA: github.Ptr(linkSHA), Size: github.Ptr(largeSize), + }, + blobs: map[string][]byte{linkSHA: []byte(target)}, + trees: map[string]*github.Tree{ + commitSHA: {Entries: []*github.TreeEntry{ + {Path: github.Ptr("docs"), Mode: github.Ptr("040000"), Type: github.Ptr("tree"), SHA: github.Ptr("docs-tree")}, + }}, + "docs-tree": {Entries: []*github.TreeEntry{ + {Path: github.Ptr("link"), Mode: github.Ptr(gitSymlinkMode), Type: github.Ptr("blob"), SHA: github.Ptr(linkSHA)}, + }}, + }, + inspect: func(r *http.Request) { + if strings.Contains(r.URL.Path, "/contents/") { + assert.Equal(t, commitSHA, r.URL.Query().Get("ref")) + } + }, + }, args("docs/link")) + require.False(t, result.IsError) + assert.Equal(t, 4, requests) + metadata := repositoryPathMetadataFromResult(t, result) + assert.Equal(t, "target.bin", metadata.ResolvedTargetPath) + assert.Equal(t, "not_returned", metadata.Content) + }) + + t.Run("truncated tree fails closed", func(t *testing.T) { + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: &github.RepositoryContent{ + Type: github.Ptr("file"), Path: github.Ptr("large.bin"), SHA: github.Ptr(fileSHA), Size: github.Ptr(largeSize), + }, + trees: map[string]*github.Tree{commitSHA: {Truncated: github.Ptr(true)}}, + }, args("large.bin")) + assert.Equal(t, 2, requests) + assert.Contains(t, getErrorResult(t, result).Text, "truncated") + }) + + t.Run("directory remains one request", func(t *testing.T) { + result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ + contents: []*github.RepositoryContent{{ + Type: github.Ptr("file"), Path: github.Ptr("docs/readme"), SHA: github.Ptr(fileSHA), + }}, + }, args("docs")) + require.False(t, result.IsError) + assert.Equal(t, 1, requests) + }) +} + func Test_GetFileContents_DirectoryFieldFiltering(t *testing.T) { mockDirContent := []*github.RepositoryContent{ { @@ -647,7 +919,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { fileContent := &github.RepositoryContent{ Name: github.Ptr("README.md"), Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), + SHA: github.Ptr(gitBlobSHA(mockRawContent)), Type: github.Ptr("file"), Content: github.Ptr(encodedContent), Size: github.Ptr(len(mockRawContent)), @@ -740,7 +1012,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { fileContent := &github.RepositoryContent{ Name: github.Ptr("README.md"), Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), + SHA: github.Ptr(gitBlobSHA(mockRawContent)), Type: github.Ptr("file"), Content: github.Ptr(encodedContent), Size: github.Ptr(len(mockRawContent)), @@ -766,6 +1038,32 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") } }) + + t.Run("detected symlink preserves ifc label", func(t *testing.T) { + target, content := []byte("target.txt"), []byte("resolved") + linkSHA := gitBlobSHA(target) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{"private": false}), + GetReposContentsByOwnerByRepoByPath: mockResponse(t, http.StatusOK, &github.RepositoryContent{ + Type: github.Ptr("file"), Path: github.Ptr("link"), SHA: github.Ptr(linkSHA), + Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + }), + GetReposGitBlobsByOwnerByRepoByFileSHA: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(target) + }, + })) + deps := BaseDeps{Client: client, featureChecker: featureCheckerFor(FeatureFlagIFCLabels)} + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", "repo": "repo", "path": "link", "ref": "refs/heads/main", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "symlink", repositoryPathMetadataFromResult(t, result).Type) + require.Contains(t, result.Meta, "ifc") + }) } // Test_GetCommit_IFC_FeatureFlag verifies that the IFC security label is only @@ -1767,6 +2065,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { assert.Contains(t, schema.Properties, "message") assert.Contains(t, schema.Properties, "branch") assert.Contains(t, schema.Properties, "sha") + assert.Contains(t, schema.Properties, "allow_symlink_write") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path", "content", "message", "branch"}) // Setup mock file content response @@ -1790,14 +2089,54 @@ func Test_CreateOrUpdateFile(t *testing.T) { HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), }, } + symlinkTarget := []byte("other.md") + symlinkSHA := gitBlobSHA(symlinkTarget) + mockPathTree := func(mode string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var tree *github.Tree + switch { + case strings.HasSuffix(r.URL.Path, "/main"), strings.HasSuffix(r.URL.Path, "/release/#candidate"): + tree = &github.Tree{ + Entries: []*github.TreeEntry{ + { + Path: github.Ptr("docs"), + Mode: github.Ptr("040000"), + Type: github.Ptr("tree"), + SHA: github.Ptr("docs-tree"), + }, + }, + } + case strings.HasSuffix(r.URL.Path, "/docs-tree"): + entrySHA := strings.Repeat("e", 40) + if mode == gitSymlinkMode { + entrySHA = symlinkSHA + } + tree = &github.Tree{ + Entries: []*github.TreeEntry{ + { + Path: github.Ptr("example.md"), + Mode: github.Ptr(mode), + Type: github.Ptr("blob"), + SHA: github.Ptr(entrySHA), + }, + }, + } + default: + require.FailNow(t, "unexpected tree request", r.URL.Path) + } + mockResponse(t, http.StatusOK, tree)(w, r) + } + } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedContent *github.RepositoryContentResponse - expectedErrMsg string + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedContent *github.RepositoryContentResponse + expectedErrMsg string + expectedErrMsgs []string + expectedRequestCount int }{ { name: "successful file creation", @@ -1825,12 +2164,14 @@ func Test_CreateOrUpdateFile(t *testing.T) { "message": "Add example file", "branch": "main", }, - expectError: false, - expectedContent: mockFileResponse, + expectError: false, + expectedContent: mockFileResponse, + expectedRequestCount: 2, }, { name: "successful file update with SHA", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: mockPathTree("100644"), "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ SHA: github.Ptr("abc123def456"), Type: github.Ptr("file"), @@ -1865,8 +2206,9 @@ func Test_CreateOrUpdateFile(t *testing.T) { "branch": "main", "sha": "abc123def456", }, - expectError: false, - expectedContent: mockFileResponse, + expectError: false, + expectedContent: mockFileResponse, + expectedRequestCount: 4, }, { name: "file creation fails", @@ -1888,12 +2230,14 @@ func Test_CreateOrUpdateFile(t *testing.T) { "message": "Invalid request", "branch": "nonexistent-branch", }, - expectError: true, - expectedErrMsg: "failed to create/update file", + expectError: true, + expectedErrMsg: "failed to create/update file", + expectedRequestCount: 2, }, { name: "sha validation - current sha matches", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: mockPathTree("100644"), "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ SHA: github.Ptr("abc123def456"), Type: github.Ptr("file"), @@ -1928,8 +2272,180 @@ func Test_CreateOrUpdateFile(t *testing.T) { "branch": "main", "sha": "abc123def456", }, - expectError: false, - expectedContent: mockFileResponse, + expectError: false, + expectedContent: mockFileResponse, + expectedRequestCount: 4, + }, + { + name: "rejects symbolic link update without explicit opt-in", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: mockPathTree("120000"), + GetReposGitBlobsByOwnerByRepoByFileSHA: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(symlinkTarget) + }, + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "# Content returned by get_file_contents", + "message": "Update linked file", + "branch": "main", + "sha": "abc123def456", + }, + expectError: true, + expectedErrMsg: `"error":"symlink_write_requires_opt_in"`, + expectedErrMsgs: []string{ + `"target":"other.md"`, + `"resolved_path":"docs/other.md"`, + `create_or_update_file path="docs/other.md"`, + `allow_symlink_write=true`, + `push_files path="docs/example.md"`, + }, + expectedRequestCount: 4, + }, + { + name: "escapes special-character branch before inspecting symlink", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/git/trees/release/#candidate": func(w http.ResponseWriter, r *http.Request) { + assert.Contains(t, r.URL.EscapedPath(), "/git/trees/release/%23candidate") + mockPathTree("120000")(w, r) + }, + GetReposGitTreesByOwnerByRepoByTree: mockPathTree("120000"), + GetReposGitBlobsByOwnerByRepoByFileSHA: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(symlinkTarget) + }, + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "# Content returned by get_file_contents", + "message": "Update linked file", + "branch": "release/#candidate", + "sha": "abc123def456", + }, + expectError: true, + expectedErrMsg: `"error":"symlink_write_requires_opt_in"`, + expectedRequestCount: 4, + }, + { + name: "allows intentional symbolic link update with explicit opt-in", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ + "message": "Change link target", + "content": "b3RoZXIubWQ=", + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{ + "message": "Change link target", + "content": "b3RoZXIubWQ=", + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "other.md", + "message": "Change link target", + "branch": "main", + "sha": "abc123def456", + "allow_symlink_write": true, + }, + expectError: false, + expectedContent: mockFileResponse, + expectedRequestCount: 2, + }, + { + name: "rejects explicit symbolic link response without tree inspection", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("symlink"), + Target: github.Ptr("../../outside"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("symlink"), + Target: github.Ptr("../../outside"), + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "new-target", + "message": "Update linked file", + "branch": "main", + "sha": "abc123def456", + }, + expectError: true, + expectedErrMsg: `"target":"../../outside"`, + expectedErrMsgs: []string{ + `"error":"symlink_write_requires_opt_in"`, + `Target is outside this repository`, + `push_files path="docs/example.md"`, + }, + expectedRequestCount: 1, + }, + { + name: "fails closed when git tree is truncated", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: mockResponse(t, http.StatusOK, &github.Tree{ + Truncated: github.Ptr(true), + }), + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr("abc123def456"), + Type: github.Ptr("file"), + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "updated", + "message": "Update file", + "branch": "main", + "sha": "abc123def456", + }, + expectError: true, + expectedErrMsg: "failed to verify whether file path is a symbolic link", + expectedRequestCount: 2, }, { name: "sha validation - stale sha detected", @@ -1952,8 +2468,9 @@ func Test_CreateOrUpdateFile(t *testing.T) { "branch": "main", "sha": "oldsha123456", }, - expectError: true, - expectedErrMsg: "SHA mismatch: provided SHA oldsha123456 is stale. Current file SHA is newsha999888", + expectError: true, + expectedErrMsg: "SHA mismatch: provided SHA oldsha123456 is stale. Current file SHA is newsha999888", + expectedRequestCount: 1, }, { name: "sha validation - file doesn't exist (404), proceed with create", @@ -1990,8 +2507,9 @@ func Test_CreateOrUpdateFile(t *testing.T) { "branch": "main", "sha": "ignoredsha", }, - expectError: false, - expectedContent: mockFileResponse, + expectError: false, + expectedContent: mockFileResponse, + expectedRequestCount: 2, }, { name: "no sha provided - file exists, rejects update", @@ -2013,8 +2531,9 @@ func Test_CreateOrUpdateFile(t *testing.T) { "message": "Update without SHA", "branch": "main", }, - expectError: true, - expectedErrMsg: "File already exists at docs/example.md", + expectError: true, + expectedErrMsg: "File already exists at docs/example.md", + expectedRequestCount: 1, }, { name: "no sha provided - file doesn't exist, no warning", @@ -2048,15 +2567,17 @@ func Test_CreateOrUpdateFile(t *testing.T) { "message": "Create new file", "branch": "main", }, - expectError: false, - expectedContent: mockFileResponse, + expectError: false, + expectedContent: mockFileResponse, + expectedRequestCount: 2, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := mustNewGHClient(t, tc.mockedClient) + requestCounter := &repositoryRequestCountingTransport{inner: tc.mockedClient.Transport} + client := mustNewGHClient(t, &http.Client{Transport: requestCounter}) deps := BaseDeps{ Client: client, } @@ -2067,13 +2588,25 @@ func Test_CreateOrUpdateFile(t *testing.T) { // Call handler result, err := handler(ContextWithDeps(context.Background(), deps), &request) + if tc.expectedRequestCount > 0 { + assert.Equal(t, tc.expectedRequestCount, requestCounter.count) + } // Verify results if tc.expectError { require.NoError(t, err) require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + var errorText strings.Builder + for _, content := range result.Content { + textContent, ok := content.(*mcp.TextContent) + require.True(t, ok, "expected error content to be TextContent") + errorText.WriteString(textContent.Text) + errorText.WriteByte('\n') + } + assert.Contains(t, errorText.String(), tc.expectedErrMsg) + for _, expectedErrMsg := range tc.expectedErrMsgs { + assert.Contains(t, errorText.String(), expectedErrMsg) + } return }