Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .container-use/environment.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
"git config --global user.name \"Test User\"",
"git config --global user.email \"test@dagger.com\"",
"curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v1.61.0"
]
],
"Locked": false
}
111 changes: 111 additions & 0 deletions environment/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package environment

import (
"context"
"crypto/sha256"
"fmt"
"strings"
)
Expand Down Expand Up @@ -40,6 +41,78 @@ func (env *Environment) FileWrite(ctx context.Context, explanation, targetFile,
return nil
}

func (env *Environment) FileSearchReplace(ctx context.Context, explanation, targetFile, search, replace, matchID string) error {
contents, err := env.container().File(targetFile).Contents(ctx)
if err != nil {
return err
}

// Find all matches of the search text
matches := []int{}
searchIndex := 0
for {
index := strings.Index(contents[searchIndex:], search)
if index == -1 {
break
}
actualIndex := searchIndex + index
matches = append(matches, actualIndex)
searchIndex = actualIndex + 1
}

if len(matches) == 0 {
return fmt.Errorf("search text not found in file %s", targetFile)
}

// If there are multiple matches and no matchID is provided, return an error with all matches
if len(matches) > 1 && matchID == "" {
var matchDescriptions []string
for i, matchIndex := range matches {
// Generate a unique ID for each match
id := generateMatchID(targetFile, search, replace, i)

// Get context around the match (3 lines before and after)
context := getMatchContext(contents, matchIndex, len(search))

matchDescriptions = append(matchDescriptions, fmt.Sprintf("Match %d (ID: %s):\n%s", i+1, id, context))
}

return fmt.Errorf("multiple matches found for search text in %s. Please specify which_match parameter with one of the following IDs:\n\n%s",
targetFile, strings.Join(matchDescriptions, "\n\n"))
}

// Determine which match to replace
var targetMatchIndex int
if len(matches) == 1 {
targetMatchIndex = matches[0]
} else {
// Find the match with the specified ID
found := false
for i, matchIndex := range matches {
id := generateMatchID(targetFile, search, replace, i)
if id == matchID {
targetMatchIndex = matchIndex
found = true
break
}
}
if !found {
return fmt.Errorf("match ID %s not found", matchID)
}
}

// Replace the specific match
newContents := contents[:targetMatchIndex] + replace + contents[targetMatchIndex+len(search):]

// Apply the changes
err = env.apply(ctx, env.container().WithNewFile(targetFile, newContents))
if err != nil {
return fmt.Errorf("failed applying file edit, skipping git propagation: %w", err)
}
env.Notes.Add("Edit %s", targetFile)
return nil
}

func (env *Environment) FileDelete(ctx context.Context, explanation, targetFile string) error {
err := env.apply(ctx, env.container().WithoutFile(targetFile))
if err != nil {
Expand All @@ -60,3 +133,41 @@ func (env *Environment) FileList(ctx context.Context, path string) (string, erro
}
return out.String(), nil
}

// generateMatchID creates a unique ID for a match based on file, search, replace, and index
func generateMatchID(targetFile, search, replace string, index int) string {
data := fmt.Sprintf("%s:%s:%s:%d", targetFile, search, replace, index)
hash := sha256.Sum256([]byte(data))
return fmt.Sprintf("%x", hash)[:8] // Use first 8 characters of hash
}

// getMatchContext returns the context around a match (3 lines before and after)
func getMatchContext(contents string, matchIndex, matchLength int) string {
lines := strings.Split(contents, "\n")

// Find which line contains the match
currentPos := 0
matchLine := 0
for i, line := range lines {
if currentPos+len(line) >= matchIndex {
matchLine = i
break
}
currentPos += len(line) + 1 // +1 for newline
}

// Get context lines (3 before, match line, 3 after)
start := max(0, matchLine-3)
end := min(len(lines), matchLine+4)

contextLines := make([]string, 0, end-start)
for i := start; i < end; i++ {
prefix := " "
if i == matchLine {
prefix = "> " // Mark the line containing the match
}
contextLines = append(contextLines, fmt.Sprintf("%s%s", prefix, lines[i]))
}

return strings.Join(contextLines, "\n")
}
68 changes: 68 additions & 0 deletions mcpserver/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ func init() {
EnvironmentFileReadTool,
EnvironmentFileListTool,
EnvironmentFileWriteTool,
EnvironmentFilePatchTool,
EnvironmentFileDeleteTool,

EnvironmentAddServiceTool,
Expand Down Expand Up @@ -662,6 +663,73 @@ var EnvironmentFileWriteTool = &Tool{
},
}

var EnvironmentFilePatchTool = &Tool{
Definition: mcp.NewTool("environment_file_patch",
mcp.WithDescription("Find and replace text in a file."),
mcp.WithString("explanation",
mcp.Description("One sentence explanation for why this file is being edited."),
),
mcp.WithString("environment_source",
mcp.Description("Absolute path to the source git repository for the environment."),
mcp.Required(),
),
mcp.WithString("environment_id",
mcp.Description("The ID of the environment for this command. Must call `environment_create` first."),
mcp.Required(),
),
mcp.WithString("target_file",
mcp.Description("Path of the file to write, absolute or relative to the workdir."),
mcp.Required(),
),
mcp.WithString("search_text",
mcp.Description("The text to find and replace."),
mcp.Required(),
),
mcp.WithString("replace_text",
mcp.Description("The text to insert."),
mcp.Required(),
),
mcp.WithString("which_match",
mcp.Description("The ID of the match to replace, if there were multiple matches."),
),
),
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
repo, env, err := openEnvironment(ctx, request)
if err != nil {
return mcp.NewToolResultErrorFromErr("unable to open the environment", err), nil
}

targetFile, err := request.RequireString("target_file")
if err != nil {
return nil, err
}
search, err := request.RequireString("search_text")
if err != nil {
return nil, err
}
replace, err := request.RequireString("replace_text")
if err != nil {
return nil, err
}

if err := env.FileSearchReplace(ctx,
request.GetString("explanation", ""),
targetFile,
search,
replace,
request.GetString("matchID", ""),
); err != nil {
return mcp.NewToolResultErrorFromErr("failed to write file", err), nil
}

if err := repo.Update(ctx, env, request.GetString("explanation", "")); err != nil {
return mcp.NewToolResultErrorFromErr("unable to update the environment", err), nil
}

return mcp.NewToolResultText(fmt.Sprintf("file %s edited successfully and committed to container-use/ remote", targetFile)), nil
},
}

var EnvironmentFileDeleteTool = &Tool{
Definition: mcp.NewTool("environment_file_delete",
mcp.WithDescription("Deletes a file at the specified path."),
Expand Down
Loading