Skip to content

Commit 30330ab

Browse files
authored
feat: add coder_workspace_edit_file MCP tool (#19629)
1 parent 1e2b66f commit 30330ab

File tree

9 files changed

+780
-2
lines changed

9 files changed

+780
-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-files", a.HandleEditFiles)
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: 106 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,105 @@ func (a *agent) writeFile(ctx context.Context, r *http.Request, path string) (HT
165169

166170
return 0, nil
167171
}
172+
173+
func (a *agent) HandleEditFiles(rw http.ResponseWriter, r *http.Request) {
174+
ctx := r.Context()
175+
176+
var req workspacesdk.FileEditRequest
177+
if !httpapi.Read(ctx, rw, r, &req) {
178+
return
179+
}
180+
181+
if len(req.Files) == 0 {
182+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
183+
Message: "must specify at least one file",
184+
})
185+
return
186+
}
187+
188+
var combinedErr error
189+
status := http.StatusOK
190+
for _, edit := range req.Files {
191+
s, err := a.editFile(r.Context(), edit.Path, edit.Edits)
192+
// Keep the highest response status, so 500 will be preferred over 400, etc.
193+
if s > status {
194+
status = s
195+
}
196+
if err != nil {
197+
combinedErr = errors.Join(combinedErr, err)
198+
}
199+
}
200+
201+
if combinedErr != nil {
202+
httpapi.Write(ctx, rw, status, codersdk.Response{
203+
Message: combinedErr.Error(),
204+
})
205+
return
206+
}
207+
208+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
209+
Message: "Successfully edited file(s)",
210+
})
211+
}
212+
213+
func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.FileEdit) (int, error) {
214+
if path == "" {
215+
return http.StatusBadRequest, xerrors.New("\"path\" is required")
216+
}
217+
218+
if !filepath.IsAbs(path) {
219+
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
220+
}
221+
222+
if len(edits) == 0 {
223+
return http.StatusBadRequest, xerrors.New("must specify at least one edit")
224+
}
225+
226+
f, err := a.filesystem.Open(path)
227+
if err != nil {
228+
status := http.StatusInternalServerError
229+
switch {
230+
case errors.Is(err, os.ErrNotExist):
231+
status = http.StatusNotFound
232+
case errors.Is(err, os.ErrPermission):
233+
status = http.StatusForbidden
234+
}
235+
return status, err
236+
}
237+
defer f.Close()
238+
239+
stat, err := f.Stat()
240+
if err != nil {
241+
return http.StatusInternalServerError, err
242+
}
243+
244+
if stat.IsDir() {
245+
return http.StatusBadRequest, xerrors.Errorf("open %s: not a file", path)
246+
}
247+
248+
transforms := make([]transform.Transformer, len(edits))
249+
for i, edit := range edits {
250+
transforms[i] = replace.String(edit.Search, edit.Replace)
251+
}
252+
253+
tmpfile, err := afero.TempFile(a.filesystem, "", filepath.Base(path))
254+
if err != nil {
255+
return http.StatusInternalServerError, err
256+
}
257+
defer tmpfile.Close()
258+
259+
_, err = io.Copy(tmpfile, replace.Chain(f, transforms...))
260+
if err != nil {
261+
if rerr := a.filesystem.Remove(tmpfile.Name()); rerr != nil {
262+
a.logger.Warn(ctx, "unable to clean up temp file", slog.Error(rerr))
263+
}
264+
return http.StatusInternalServerError, xerrors.Errorf("edit %s: %w", path, err)
265+
}
266+
267+
err = a.filesystem.Rename(tmpfile.Name(), path)
268+
if err != nil {
269+
return http.StatusInternalServerError, err
270+
}
271+
272+
return 0, nil
273+
}

0 commit comments

Comments
 (0)