Skip to content

Commit 48a1956

Browse files
committed
Add coder_workspace_edit_file MCP tool
1 parent 6d39077 commit 48a1956

File tree

9 files changed

+438
-2
lines changed

9 files changed

+438
-2
lines changed

agent/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ func (a *agent) apiHandler() http.Handler {
6262
r.Post("/api/v0/list-directory", a.HandleLS)
6363
r.Get("/api/v0/read-file", a.HandleReadFile)
6464
r.Post("/api/v0/write-file", a.HandleWriteFile)
65+
r.Post("/api/v0/edit-file", a.HandleEditFile)
6566
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
6667
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
6768
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)

agent/files.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ import (
1212
"strconv"
1313
"syscall"
1414

15+
"github.com/icholy/replace"
16+
"github.com/spf13/afero"
17+
"golang.org/x/text/transform"
1518
"golang.org/x/xerrors"
1619

1720
"cdr.dev/slog"
1821
"github.com/coder/coder/v2/coderd/httpapi"
1922
"github.com/coder/coder/v2/codersdk"
23+
"github.com/coder/coder/v2/codersdk/workspacesdk"
2024
)
2125

2226
type HTTPResponseCode = int
@@ -165,3 +169,91 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
165169

166170
return 0, nil
167171
}
172+
173+
func (a *agent) HandleEditFile(rw http.ResponseWriter, r *http.Request) {
174+
ctx := r.Context()
175+
176+
query := r.URL.Query()
177+
parser := httpapi.NewQueryParamParser().RequiredNotEmpty("path")
178+
path := parser.String(query, "", "path")
179+
parser.ErrorExcessParams(query)
180+
if len(parser.Errors) > 0 {
181+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
182+
Message: "Query parameters have invalid values.",
183+
Validations: parser.Errors,
184+
})
185+
return
186+
}
187+
188+
var edits workspacesdk.FileEditRequest
189+
if !httpapi.Read(ctx, rw, r, &edits) {
190+
return
191+
}
192+
193+
status, err := a.editFile(path, edits.Edits)
194+
if err != nil {
195+
httpapi.Write(ctx, rw, status, codersdk.Response{
196+
Message: err.Error(),
197+
})
198+
return
199+
}
200+
201+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
202+
Message: fmt.Sprintf("Successfully edited %q", path),
203+
})
204+
}
205+
206+
func (a *agent) editFile(path string, edits []workspacesdk.FileEdit) (int, error) {
207+
if !filepath.IsAbs(path) {
208+
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
209+
}
210+
211+
f, err := a.filesystem.Open(path)
212+
if err != nil {
213+
status := http.StatusInternalServerError
214+
switch {
215+
case errors.Is(err, os.ErrNotExist):
216+
status = http.StatusNotFound
217+
case errors.Is(err, os.ErrPermission):
218+
status = http.StatusForbidden
219+
}
220+
return status, err
221+
}
222+
defer f.Close()
223+
224+
stat, err := f.Stat()
225+
if err != nil {
226+
return http.StatusInternalServerError, err
227+
}
228+
229+
if stat.IsDir() {
230+
return http.StatusBadRequest, xerrors.Errorf("open %s: not a file", path)
231+
}
232+
233+
if len(edits) == 0 {
234+
return http.StatusBadRequest, xerrors.New("must specify at least one edit")
235+
}
236+
237+
transforms := make([]transform.Transformer, len(edits))
238+
for i, edit := range edits {
239+
transforms[i] = replace.String(edit.Search, edit.Replace)
240+
}
241+
242+
tmpfile, err := afero.TempFile(a.filesystem, "", filepath.Base(path))
243+
if err != nil {
244+
return http.StatusInternalServerError, err
245+
}
246+
defer tmpfile.Close()
247+
248+
_, err = io.Copy(tmpfile, replace.Chain(f, transforms...))
249+
if err != nil {
250+
return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err)
251+
}
252+
253+
err = a.filesystem.Rename(tmpfile.Name(), path)
254+
if err != nil {
255+
return http.StatusInternalServerError, err
256+
}
257+
258+
return 0, nil
259+
}

agent/files_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import (
1313

1414
"github.com/spf13/afero"
1515
"github.com/stretchr/testify/require"
16+
"golang.org/x/xerrors"
1617

1718
"github.com/coder/coder/v2/agent"
1819
"github.com/coder/coder/v2/agent/agenttest"
1920
"github.com/coder/coder/v2/coderd/coderdtest"
2021
"github.com/coder/coder/v2/codersdk/agentsdk"
22+
"github.com/coder/coder/v2/codersdk/workspacesdk"
2123
"github.com/coder/coder/v2/testutil"
2224
)
2325

@@ -91,6 +93,13 @@ func (fs *testFs) MkdirAll(name string, mode os.FileMode) error {
9193
return fs.Fs.MkdirAll(name, mode)
9294
}
9395

96+
func (fs *testFs) Rename(oldName, newName string) error {
97+
if err := fs.intercept("rename", newName); err != nil {
98+
return err
99+
}
100+
return fs.Fs.Rename(oldName, newName)
101+
}
102+
94103
func TestReadFile(t *testing.T) {
95104
t.Parallel()
96105

@@ -376,3 +385,159 @@ func TestWriteFile(t *testing.T) {
376385
})
377386
}
378387
}
388+
389+
func TestEditFile(t *testing.T) {
390+
t.Parallel()
391+
392+
tmpdir := os.TempDir()
393+
noPermsFilePath := filepath.Join(tmpdir, "no-perms-file")
394+
failRenameFilePath := filepath.Join(tmpdir, "fail-rename")
395+
//nolint:dogsled
396+
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
397+
opts.Filesystem = newTestFs(opts.Filesystem, func(call, file string) error {
398+
if file == noPermsFilePath {
399+
return os.ErrPermission
400+
} else if file == failRenameFilePath && call == "rename" {
401+
return xerrors.New("rename failed")
402+
}
403+
return nil
404+
})
405+
})
406+
407+
dirPath := filepath.Join(tmpdir, "directory")
408+
err := fs.MkdirAll(dirPath, 0o755)
409+
require.NoError(t, err)
410+
411+
tests := []struct {
412+
name string
413+
path string
414+
contents string
415+
edits []workspacesdk.FileEdit
416+
expected string
417+
errCode int
418+
error string
419+
}{
420+
{
421+
name: "NoPath",
422+
errCode: http.StatusBadRequest,
423+
error: "\"path\" is required",
424+
},
425+
{
426+
name: "RelativePath",
427+
path: "./relative",
428+
errCode: http.StatusBadRequest,
429+
error: "file path must be absolute",
430+
},
431+
{
432+
name: "RelativePath",
433+
path: "also-relative",
434+
errCode: http.StatusBadRequest,
435+
error: "file path must be absolute",
436+
},
437+
{
438+
name: "NonExistent",
439+
path: filepath.Join(tmpdir, "does-not-exist"),
440+
errCode: http.StatusNotFound,
441+
error: "file does not exist",
442+
},
443+
{
444+
name: "IsDir",
445+
path: dirPath,
446+
errCode: http.StatusBadRequest,
447+
error: "not a file",
448+
},
449+
{
450+
name: "NoPermissions",
451+
path: noPermsFilePath,
452+
errCode: http.StatusForbidden,
453+
error: "permission denied",
454+
},
455+
{
456+
name: "NoEdits",
457+
path: filepath.Join(tmpdir, "no-edits"),
458+
contents: "foo bar",
459+
errCode: http.StatusBadRequest,
460+
error: "must specify at least one edit",
461+
},
462+
{
463+
name: "FailRename",
464+
path: failRenameFilePath,
465+
contents: "foo bar",
466+
edits: []workspacesdk.FileEdit{
467+
{
468+
Search: "foo",
469+
Replace: "bar",
470+
},
471+
},
472+
errCode: http.StatusInternalServerError,
473+
error: "rename failed",
474+
},
475+
{
476+
name: "Edit1",
477+
path: filepath.Join(tmpdir, "edit1"),
478+
contents: "foo bar",
479+
edits: []workspacesdk.FileEdit{
480+
{
481+
Search: "foo",
482+
Replace: "bar",
483+
},
484+
},
485+
expected: "bar bar",
486+
},
487+
{
488+
name: "EditEdit", // Edits affect previous edits.
489+
path: filepath.Join(tmpdir, "edit-edit"),
490+
contents: "foo bar",
491+
edits: []workspacesdk.FileEdit{
492+
{
493+
Search: "foo",
494+
Replace: "bar",
495+
},
496+
{
497+
Search: "bar",
498+
Replace: "qux",
499+
},
500+
},
501+
expected: "qux qux",
502+
},
503+
{
504+
name: "Multiline",
505+
path: filepath.Join(tmpdir, "multiline"),
506+
contents: "foo\nbar\nbaz\nqux",
507+
edits: []workspacesdk.FileEdit{
508+
{
509+
Search: "bar\nbaz",
510+
Replace: "frob",
511+
},
512+
},
513+
expected: "foo\nfrob\nqux",
514+
},
515+
}
516+
517+
for _, tt := range tests {
518+
t.Run(tt.name, func(t *testing.T) {
519+
t.Parallel()
520+
521+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
522+
defer cancel()
523+
524+
if tt.contents != "" {
525+
err := afero.WriteFile(fs, tt.path, []byte(tt.contents), 0o644)
526+
require.NoError(t, err)
527+
}
528+
529+
err := conn.EditFile(ctx, tt.path, workspacesdk.FileEditRequest{Edits: tt.edits})
530+
if tt.errCode != 0 {
531+
require.Error(t, err)
532+
cerr := coderdtest.SDKError(t, err)
533+
require.Contains(t, cerr.Error(), tt.error)
534+
require.Equal(t, tt.errCode, cerr.StatusCode())
535+
} else {
536+
require.NoError(t, err)
537+
b, err := afero.ReadFile(fs, tt.path)
538+
require.NoError(t, err)
539+
require.Equal(t, tt.expected, string(b))
540+
}
541+
})
542+
}
543+
}

codersdk/toolsdk/toolsdk.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const (
4444
ToolNameChatGPTFetch = "fetch"
4545
ToolNameWorkspaceReadFile = "coder_workspace_read_file"
4646
ToolNameWorkspaceWriteFile = "coder_workspace_write_file"
47+
ToolNameWorkspaceEditFile = "coder_workspace_edit_file"
4748
)
4849

4950
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
@@ -213,6 +214,7 @@ var All = []GenericTool{
213214
ChatGPTFetch.Generic(),
214215
WorkspaceReadFile.Generic(),
215216
WorkspaceWriteFile.Generic(),
217+
WorkspaceEditFile.Generic(),
216218
}
217219

218220
type ReportTaskArgs struct {
@@ -1491,6 +1493,69 @@ var WorkspaceWriteFile = Tool[WorkspaceWriteFileArgs, codersdk.Response]{
14911493
},
14921494
}
14931495

1496+
type WorkspaceEditFileArgs struct {
1497+
Workspace string `json:"workspace"`
1498+
Path string `json:"path"`
1499+
Edits []workspacesdk.FileEdit `json:"edits"`
1500+
}
1501+
1502+
var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{
1503+
Tool: aisdk.Tool{
1504+
Name: ToolNameWorkspaceEditFile,
1505+
Description: `Edit a file in a workspace.`,
1506+
Schema: aisdk.Schema{
1507+
Properties: map[string]any{
1508+
"workspace": map[string]any{
1509+
"type": "string",
1510+
"description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.",
1511+
},
1512+
"path": map[string]any{
1513+
"type": "string",
1514+
"description": "The absolute path of the file to write in the workspace.",
1515+
},
1516+
"edits": map[string]any{
1517+
"type": "array",
1518+
"description": "An array of edit operations.",
1519+
"items": []any{
1520+
map[string]any{
1521+
"type": "object",
1522+
"properties": map[string]any{
1523+
"search": map[string]any{
1524+
"type": "string",
1525+
"description": "The old string to replace.",
1526+
},
1527+
"replace": map[string]any{
1528+
"type": "string",
1529+
"description": "The new string that replaces the old string.",
1530+
},
1531+
},
1532+
"required": []string{"search", "replace"},
1533+
},
1534+
},
1535+
},
1536+
},
1537+
Required: []string{"path", "workspace", "edits"},
1538+
},
1539+
},
1540+
UserClientOptional: true,
1541+
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (codersdk.Response, error) {
1542+
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
1543+
if err != nil {
1544+
return codersdk.Response{}, err
1545+
}
1546+
defer conn.Close()
1547+
1548+
err = conn.EditFile(ctx, args.Path, workspacesdk.FileEditRequest{Edits: args.Edits})
1549+
if err != nil {
1550+
return codersdk.Response{}, err
1551+
}
1552+
1553+
return codersdk.Response{
1554+
Message: "File edited successfully.",
1555+
}, nil
1556+
},
1557+
}
1558+
14941559
// NormalizeWorkspaceInput converts workspace name input to standard format.
14951560
// Handles the following input formats:
14961561
// - workspace → workspace

0 commit comments

Comments
 (0)