feat: add coder_workspace_edit_file MCP tool (#19629)

This commit is contained in:
Asher
2025-09-12 15:36:14 -08:00
committed by GitHub
parent 1e2b66fb20
commit 30330abaea
9 changed files with 780 additions and 2 deletions
+148
View File
@@ -44,6 +44,8 @@ const (
ToolNameChatGPTFetch = "fetch"
ToolNameWorkspaceReadFile = "coder_workspace_read_file"
ToolNameWorkspaceWriteFile = "coder_workspace_write_file"
ToolNameWorkspaceEditFile = "coder_workspace_edit_file"
ToolNameWorkspaceEditFiles = "coder_workspace_edit_files"
)
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
@@ -213,6 +215,8 @@ var All = []GenericTool{
ChatGPTFetch.Generic(),
WorkspaceReadFile.Generic(),
WorkspaceWriteFile.Generic(),
WorkspaceEditFile.Generic(),
WorkspaceEditFiles.Generic(),
}
type ReportTaskArgs struct {
@@ -1491,6 +1495,150 @@ var WorkspaceWriteFile = Tool[WorkspaceWriteFileArgs, codersdk.Response]{
},
}
type WorkspaceEditFileArgs struct {
Workspace string `json:"workspace"`
Path string `json:"path"`
Edits []workspacesdk.FileEdit `json:"edits"`
}
var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceEditFile,
Description: `Edit a file in a workspace.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.",
},
"path": map[string]any{
"type": "string",
"description": "The absolute path of the file to write in the workspace.",
},
"edits": map[string]any{
"type": "array",
"description": "An array of edit operations.",
"items": []any{
map[string]any{
"type": "object",
"properties": map[string]any{
"search": map[string]any{
"type": "string",
"description": "The old string to replace.",
},
"replace": map[string]any{
"type": "string",
"description": "The new string that replaces the old string.",
},
},
"required": []string{"search", "replace"},
},
},
},
},
Required: []string{"path", "workspace", "edits"},
},
},
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFileArgs) (codersdk.Response, error) {
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
if err != nil {
return codersdk.Response{}, err
}
defer conn.Close()
err = conn.EditFiles(ctx, workspacesdk.FileEditRequest{
Files: []workspacesdk.FileEdits{
{
Path: args.Path,
Edits: args.Edits,
},
},
})
if err != nil {
return codersdk.Response{}, err
}
return codersdk.Response{
Message: "File edited successfully.",
}, nil
},
}
type WorkspaceEditFilesArgs struct {
Workspace string `json:"workspace"`
Files []workspacesdk.FileEdits `json:"files"`
}
var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, codersdk.Response]{
Tool: aisdk.Tool{
Name: ToolNameWorkspaceEditFiles,
Description: `Edit one or more files in a workspace.`,
Schema: aisdk.Schema{
Properties: map[string]any{
"workspace": map[string]any{
"type": "string",
"description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.",
},
"files": map[string]any{
"type": "array",
"description": "An array of files to edit.",
"items": []any{
map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "The absolute path of the file to write in the workspace.",
},
"edits": map[string]any{
"type": "array",
"description": "An array of edit operations.",
"items": []any{
map[string]any{
"type": "object",
"properties": map[string]any{
"search": map[string]any{
"type": "string",
"description": "The old string to replace.",
},
"replace": map[string]any{
"type": "string",
"description": "The new string that replaces the old string.",
},
},
"required": []string{"search", "replace"},
},
},
},
"required": []string{"path", "edits"},
},
},
},
},
},
Required: []string{"workspace", "files"},
},
},
UserClientOptional: true,
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFilesArgs) (codersdk.Response, error) {
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
if err != nil {
return codersdk.Response{}, err
}
defer conn.Close()
err = conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files})
if err != nil {
return codersdk.Response{}, err
}
return codersdk.Response{
Message: "File(s) edited successfully.",
}, nil
},
}
// NormalizeWorkspaceInput converts workspace name input to standard format.
// Handles the following input formats:
// - workspace → workspace
+102
View File
@@ -28,6 +28,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/toolsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
@@ -579,6 +580,107 @@ func TestTools(t *testing.T) {
require.NoError(t, err)
require.Equal(t, []byte("content"), b)
})
t.Run("WorkspaceEditFile", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForAgent(t)
fs := afero.NewMemMapFs()
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
opts.Filesystem = fs
})
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
tb, err := toolsdk.NewDeps(client)
require.NoError(t, err)
tmpdir := os.TempDir()
filePath := filepath.Join(tmpdir, "edit")
err = afero.WriteFile(fs, filePath, []byte("foo bar"), 0o644)
require.NoError(t, err)
_, err = testTool(t, toolsdk.WorkspaceEditFile, tb, toolsdk.WorkspaceEditFileArgs{
Workspace: workspace.Name,
Path: filePath,
})
require.Error(t, err)
require.Contains(t, err.Error(), "must specify at least one edit")
_, err = testTool(t, toolsdk.WorkspaceEditFile, tb, toolsdk.WorkspaceEditFileArgs{
Workspace: workspace.Name,
Path: filePath,
Edits: []workspacesdk.FileEdit{
{
Search: "foo",
Replace: "bar",
},
},
})
require.NoError(t, err)
b, err := afero.ReadFile(fs, filePath)
require.NoError(t, err)
require.Equal(t, "bar bar", string(b))
})
t.Run("WorkspaceEditFiles", func(t *testing.T) {
t.Parallel()
client, workspace, agentToken := setupWorkspaceForAgent(t)
fs := afero.NewMemMapFs()
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
opts.Filesystem = fs
})
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
tb, err := toolsdk.NewDeps(client)
require.NoError(t, err)
tmpdir := os.TempDir()
filePath1 := filepath.Join(tmpdir, "edit1")
err = afero.WriteFile(fs, filePath1, []byte("foo1 bar1"), 0o644)
require.NoError(t, err)
filePath2 := filepath.Join(tmpdir, "edit2")
err = afero.WriteFile(fs, filePath2, []byte("foo2 bar2"), 0o644)
require.NoError(t, err)
_, err = testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{
Workspace: workspace.Name,
})
require.Error(t, err)
require.Contains(t, err.Error(), "must specify at least one file")
_, err = testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{
Workspace: workspace.Name,
Files: []workspacesdk.FileEdits{
{
Path: filePath1,
Edits: []workspacesdk.FileEdit{
{
Search: "foo1",
Replace: "bar1",
},
},
},
{
Path: filePath2,
Edits: []workspacesdk.FileEdit{
{
Search: "foo2",
Replace: "bar2",
},
},
},
},
})
require.NoError(t, err)
b, err := afero.ReadFile(fs, filePath1)
require.NoError(t, err)
require.Equal(t, "bar1 bar1", string(b))
b, err = afero.ReadFile(fs, filePath2)
require.NoError(t, err)
require.Equal(t, "bar2 bar2", string(b))
})
}
// TestedTools keeps track of which tools have been tested.
+59 -2
View File
@@ -1,6 +1,7 @@
package workspacesdk
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
@@ -62,6 +63,7 @@ type AgentConn interface {
RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error)
ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error)
WriteFile(ctx context.Context, path string, reader io.Reader) error
EditFiles(ctx context.Context, edits FileEditRequest) error
SSH(ctx context.Context) (*gonet.TCPConn, error)
SSHClient(ctx context.Context) (*ssh.Client, error)
SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error)
@@ -523,15 +525,70 @@ func (c *agentConn) WriteFile(ctx context.Context, path string, reader io.Reader
return nil
}
type FileEdit struct {
Search string `json:"search"`
Replace string `json:"replace"`
}
type FileEdits struct {
Path string `json:"path"`
Edits []FileEdit `json:"edits"`
}
type FileEditRequest struct {
Files []FileEdits `json:"files"`
}
// EditFiles performs search and replace edits on one or more files.
func (c *agentConn) EditFiles(ctx context.Context, edits FileEditRequest) error {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/edit-files", edits)
if err != nil {
return xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return codersdk.ReadBodyAsError(res)
}
var m codersdk.Response
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
return xerrors.Errorf("decode response body: %w", err)
}
return nil
}
// apiRequest makes a request to the workspace agent's HTTP API server.
func (c *agentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
func (c *agentConn) apiRequest(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort))
url := fmt.Sprintf("http://%s%s", host, path)
req, err := http.NewRequestWithContext(ctx, method, url, body)
var r io.Reader
if body != nil {
switch data := body.(type) {
case io.Reader:
r = data
case []byte:
r = bytes.NewReader(data)
default:
// Assume JSON in all other cases.
buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
err := enc.Encode(body)
if err != nil {
return nil, xerrors.Errorf("encode body: %w", err)
}
r = buf
}
}
req, err := http.NewRequestWithContext(ctx, method, url, r)
if err != nil {
return nil, xerrors.Errorf("new http api request to %q: %w", url, err)
}
@@ -141,6 +141,20 @@ func (mr *MockAgentConnMockRecorder) DialContext(ctx, network, addr any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DialContext", reflect.TypeOf((*MockAgentConn)(nil).DialContext), ctx, network, addr)
}
// EditFiles mocks base method.
func (m *MockAgentConn) EditFiles(ctx context.Context, edits workspacesdk.FileEditRequest) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EditFiles", ctx, edits)
ret0, _ := ret[0].(error)
return ret0
}
// EditFiles indicates an expected call of EditFiles.
func (mr *MockAgentConnMockRecorder) EditFiles(ctx, edits any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditFiles", reflect.TypeOf((*MockAgentConn)(nil).EditFiles), ctx, edits)
}
// GetPeerDiagnostics mocks base method.
func (m *MockAgentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics {
m.ctrl.T.Helper()