Skip to content

Commit 40a8495

Browse files
committed
Add coder_workspace_edit_files MCP tool
1 parent 276da34 commit 40a8495

File tree

2 files changed

+137
-0
lines changed

2 files changed

+137
-0
lines changed

codersdk/toolsdk/toolsdk.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const (
4545
ToolNameWorkspaceReadFile = "coder_workspace_read_file"
4646
ToolNameWorkspaceWriteFile = "coder_workspace_write_file"
4747
ToolNameWorkspaceEditFile = "coder_workspace_edit_file"
48+
ToolNameWorkspaceEditFiles = "coder_workspace_edit_files"
4849
)
4950

5051
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
@@ -215,6 +216,7 @@ var All = []GenericTool{
215216
WorkspaceReadFile.Generic(),
216217
WorkspaceWriteFile.Generic(),
217218
WorkspaceEditFile.Generic(),
219+
WorkspaceEditFiles.Generic(),
218220
}
219221

220222
type ReportTaskArgs struct {
@@ -1563,6 +1565,80 @@ var WorkspaceEditFile = Tool[WorkspaceEditFileArgs, codersdk.Response]{
15631565
},
15641566
}
15651567

1568+
type WorkspaceEditFilesArgs struct {
1569+
Workspace string `json:"workspace"`
1570+
Files []workspacesdk.FileEdits `json:"files"`
1571+
}
1572+
1573+
var WorkspaceEditFiles = Tool[WorkspaceEditFilesArgs, codersdk.Response]{
1574+
Tool: aisdk.Tool{
1575+
Name: ToolNameWorkspaceEditFiles,
1576+
Description: `Edit one or more files in a workspace.`,
1577+
Schema: aisdk.Schema{
1578+
Properties: map[string]any{
1579+
"workspace": map[string]any{
1580+
"type": "string",
1581+
"description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.",
1582+
},
1583+
"files": map[string]any{
1584+
"type": "array",
1585+
"description": "An array of files to edit.",
1586+
"items": []any{
1587+
map[string]any{
1588+
"type": "object",
1589+
"properties": map[string]any{
1590+
"path": map[string]any{
1591+
"type": "string",
1592+
"description": "The absolute path of the file to write in the workspace.",
1593+
},
1594+
"edits": map[string]any{
1595+
"type": "array",
1596+
"description": "An array of edit operations.",
1597+
"items": []any{
1598+
map[string]any{
1599+
"type": "object",
1600+
"properties": map[string]any{
1601+
"search": map[string]any{
1602+
"type": "string",
1603+
"description": "The old string to replace.",
1604+
},
1605+
"replace": map[string]any{
1606+
"type": "string",
1607+
"description": "The new string that replaces the old string.",
1608+
},
1609+
},
1610+
"required": []string{"search", "replace"},
1611+
},
1612+
},
1613+
},
1614+
"required": []string{"path", "edits"},
1615+
},
1616+
},
1617+
},
1618+
},
1619+
},
1620+
Required: []string{"workspace", "files"},
1621+
},
1622+
},
1623+
UserClientOptional: true,
1624+
Handler: func(ctx context.Context, deps Deps, args WorkspaceEditFilesArgs) (codersdk.Response, error) {
1625+
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
1626+
if err != nil {
1627+
return codersdk.Response{}, err
1628+
}
1629+
defer conn.Close()
1630+
1631+
err = conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files})
1632+
if err != nil {
1633+
return codersdk.Response{}, err
1634+
}
1635+
1636+
return codersdk.Response{
1637+
Message: "File(s) edited successfully.",
1638+
}, nil
1639+
},
1640+
}
1641+
15661642
// NormalizeWorkspaceInput converts workspace name input to standard format.
15671643
// Handles the following input formats:
15681644
// - workspace → workspace

codersdk/toolsdk/toolsdk_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,67 @@ func TestTools(t *testing.T) {
620620
require.NoError(t, err)
621621
require.Equal(t, "bar bar", string(b))
622622
})
623+
624+
t.Run("WorkspaceEditFiles", func(t *testing.T) {
625+
t.Parallel()
626+
627+
client, workspace, agentToken := setupWorkspaceForAgent(t)
628+
fs := afero.NewMemMapFs()
629+
_ = agenttest.New(t, client.URL, agentToken, func(opts *agent.Options) {
630+
opts.Filesystem = fs
631+
})
632+
coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
633+
tb, err := toolsdk.NewDeps(client)
634+
require.NoError(t, err)
635+
636+
tmpdir := os.TempDir()
637+
filePath1 := filepath.Join(tmpdir, "edit1")
638+
err = afero.WriteFile(fs, filePath1, []byte("foo1 bar1"), 0o644)
639+
require.NoError(t, err)
640+
641+
filePath2 := filepath.Join(tmpdir, "edit2")
642+
err = afero.WriteFile(fs, filePath2, []byte("foo2 bar2"), 0o644)
643+
require.NoError(t, err)
644+
645+
_, err = testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{
646+
Workspace: workspace.Name,
647+
})
648+
require.Error(t, err)
649+
require.Contains(t, err.Error(), "must specify at least one file")
650+
651+
_, err = testTool(t, toolsdk.WorkspaceEditFiles, tb, toolsdk.WorkspaceEditFilesArgs{
652+
Workspace: workspace.Name,
653+
Files: []workspacesdk.FileEdits{
654+
{
655+
Path: filePath1,
656+
Edits: []workspacesdk.FileEdit{
657+
{
658+
Search: "foo1",
659+
Replace: "bar1",
660+
},
661+
},
662+
},
663+
{
664+
Path: filePath2,
665+
Edits: []workspacesdk.FileEdit{
666+
{
667+
Search: "foo2",
668+
Replace: "bar2",
669+
},
670+
},
671+
},
672+
},
673+
})
674+
require.NoError(t, err)
675+
676+
b, err := afero.ReadFile(fs, filePath1)
677+
require.NoError(t, err)
678+
require.Equal(t, "bar1 bar1", string(b))
679+
680+
b, err = afero.ReadFile(fs, filePath2)
681+
require.NoError(t, err)
682+
require.Equal(t, "bar2 bar2", string(b))
683+
})
623684
}
624685

625686
// TestedTools keeps track of which tools have been tested.

0 commit comments

Comments
 (0)