Skip to content

Commit f2ba812

Browse files
committed
Add coder_workspace_edit_file MCP tool
1 parent 911efb7 commit f2ba812

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(ctx, r, 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(ctx context.Context, r *http.Request, 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
@@ -15,6 +15,7 @@ import (
1515
"github.com/coder/coder/v2/agent/agenttest"
1616
"github.com/coder/coder/v2/coderd/coderdtest"
1717
"github.com/coder/coder/v2/codersdk/agentsdk"
18+
"github.com/coder/coder/v2/codersdk/workspacesdk"
1819
"github.com/coder/coder/v2/testutil"
1920
)
2021

@@ -72,6 +73,13 @@ func (fs *testFs) MkdirAll(name string, mode os.FileMode) error {
7273
return fs.Fs.MkdirAll(name, mode)
7374
}
7475

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

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

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 {
@@ -1472,6 +1474,69 @@ var WorkspaceWriteFile = Tool[WorkspaceWriteFileArgs, codersdk.Response]{
14721474
},
14731475
}
14741476

1477+
type WorkspaceEditFileArgs struct {
1478+
Workspace string `json:"workspace"`
1479+
Path string `json:"path"`
1480+
Edits []workspacesdk.FileEdit `json:"edits"`
1481+
}
1482+
1483+
var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{
1484+
Tool: aisdk.Tool{
1485+
Name: ToolNameWorkspaceEditFile,
1486+
Description: `Edit a file in a workspace.`,
1487+
Schema: aisdk.Schema{
1488+
Properties: map[string]any{
1489+
"workspace": map[string]any{
1490+
"type": "string",
1491+
"description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.",
1492+
},
1493+
"path": map[string]any{
1494+
"type": "string",
1495+
"description": "The absolute path of the file to write in the workspace.",
1496+
},
1497+
"edits": map[string]any{
1498+
"type": "array",
1499+
"description": "An array of edit operations.",
1500+
"items": []any{
1501+
map[string]any{
1502+
"type": "object",
1503+
"properties": map[string]any{
1504+
"search": map[string]any{
1505+
"type": "string",
1506+
"description": "The old string to replace.",
1507+
},
1508+
"replace": map[string]any{
1509+
"type": "string",
1510+
"description": "The new string that replaces the old string.",
1511+
},
1512+
},
1513+
"required": []string{"search", "replace"},
1514+
},
1515+
},
1516+
},
1517+
},
1518+
Required: []string{"path", "workspace", "edits"},
1519+
},
1520+
},
1521+
UserClientOptional: true,
1522+
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (codersdk.Response, error) {
1523+
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
1524+
if err != nil {
1525+
return codersdk.Response{}, err
1526+
}
1527+
defer conn.Close()
1528+
1529+
err = conn.EditFile(ctx, args.Path, workspacesdk.WorkspaceEditFileRequest{Edits: args.Edits})
1530+
if err != nil {
1531+
return codersdk.Response{}, err
1532+
}
1533+
1534+
return codersdk.Response{
1535+
Message: "File edited successfully.",
1536+
}, nil
1537+
},
1538+
}
1539+
14751540
// NormalizeWorkspaceInput converts workspace name input to standard format.
14761541
// Handles the following input formats:
14771542
// - workspace → workspace

0 commit comments

Comments
 (0)