mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add coder_workspace_edit_file MCP tool (#19629)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user