Skip to content

Commit d82a0ff

Browse files
committed
Add coder_workspace_edit_file MCP tool
1 parent d231994 commit d82a0ff

File tree

9 files changed

+414
-0
lines changed

9 files changed

+414
-0
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
"strings"
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
func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
@@ -164,3 +168,91 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (in
164168

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

agent/files_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/coder/coder/v2/agent/agenttest"
1818
"github.com/coder/coder/v2/coderd/coderdtest"
1919
"github.com/coder/coder/v2/codersdk/agentsdk"
20+
"github.com/coder/coder/v2/codersdk/workspacesdk"
2021
"github.com/coder/coder/v2/testutil"
2122
)
2223

@@ -74,6 +75,13 @@ func (fs *testFs) MkdirAll(name string, mode os.FileMode) error {
7475
return fs.Fs.MkdirAll(name, mode)
7576
}
7677

78+
func (fs *testFs) Rename(oldName, newName string) error {
79+
if err := fs.intercept("rename", newName); err != nil {
80+
return err
81+
}
82+
return fs.Fs.Rename(oldName, newName)
83+
}
84+
7785
func TestReadFile(t *testing.T) {
7886
t.Parallel()
7987

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

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)