mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add coder_workspace_write_file MCP tool (#19591)
This commit is contained in:
@@ -43,6 +43,7 @@ const (
|
||||
ToolNameChatGPTSearch = "search"
|
||||
ToolNameChatGPTFetch = "fetch"
|
||||
ToolNameWorkspaceReadFile = "coder_workspace_read_file"
|
||||
ToolNameWorkspaceWriteFile = "coder_workspace_write_file"
|
||||
)
|
||||
|
||||
func NewDeps(client *codersdk.Client, opts ...func(*Deps)) (Deps, error) {
|
||||
@@ -211,6 +212,7 @@ var All = []GenericTool{
|
||||
ChatGPTSearch.Generic(),
|
||||
ChatGPTFetch.Generic(),
|
||||
WorkspaceReadFile.Generic(),
|
||||
WorkspaceWriteFile.Generic(),
|
||||
}
|
||||
|
||||
type ReportTaskArgs struct {
|
||||
@@ -1441,6 +1443,54 @@ var WorkspaceReadFile = Tool[WorkspaceReadFileArgs, WorkspaceReadFileResponse]{
|
||||
},
|
||||
}
|
||||
|
||||
type WorkspaceWriteFileArgs struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Path string `json:"path"`
|
||||
Content []byte `json:"content"`
|
||||
}
|
||||
|
||||
var WorkspaceWriteFile = Tool[WorkspaceWriteFileArgs, codersdk.Response]{
|
||||
Tool: aisdk.Tool{
|
||||
Name: ToolNameWorkspaceWriteFile,
|
||||
Description: `Write 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.",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The base64-encoded bytes to write to the file.",
|
||||
},
|
||||
},
|
||||
Required: []string{"path", "workspace", "content"},
|
||||
},
|
||||
},
|
||||
UserClientOptional: true,
|
||||
Handler: func(ctx context.Context, deps Deps, args WorkspaceWriteFileArgs) (codersdk.Response, error) {
|
||||
conn, err := newAgentConn(ctx, deps.coderClient, args.Workspace)
|
||||
if err != nil {
|
||||
return codersdk.Response{}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reader := bytes.NewReader(args.Content)
|
||||
err = conn.WriteFile(ctx, args.Path, reader)
|
||||
if err != nil {
|
||||
return codersdk.Response{}, err
|
||||
}
|
||||
|
||||
return codersdk.Response{
|
||||
Message: "File written successfully.",
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
// NormalizeWorkspaceInput converts workspace name input to standard format.
|
||||
// Handles the following input formats:
|
||||
// - workspace → workspace
|
||||
|
||||
@@ -555,6 +555,30 @@ func TestTools(t *testing.T) {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WorkspaceWriteFile", 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)
|
||||
|
||||
_, err = testTool(t, toolsdk.WorkspaceWriteFile, tb, toolsdk.WorkspaceWriteFileArgs{
|
||||
Workspace: workspace.Name,
|
||||
Path: "/test/some/path",
|
||||
Content: []byte("content"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := afero.ReadFile(fs, "/test/some/path")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("content"), b)
|
||||
})
|
||||
}
|
||||
|
||||
// TestedTools keeps track of which tools have been tested.
|
||||
|
||||
@@ -61,6 +61,7 @@ type AgentConn interface {
|
||||
ReconnectingPTY(ctx context.Context, id uuid.UUID, height uint16, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error)
|
||||
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
|
||||
SSH(ctx context.Context) (*gonet.TCPConn, error)
|
||||
SSHClient(ctx context.Context) (*ssh.Client, error)
|
||||
SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error)
|
||||
@@ -501,6 +502,27 @@ func (c *agentConn) ReadFile(ctx context.Context, path string, offset, limit int
|
||||
return res.Body, mimeType, nil
|
||||
}
|
||||
|
||||
// WriteFile writes to a file in the workspace.
|
||||
func (c *agentConn) WriteFile(ctx context.Context, path string, reader io.Reader) error {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
res, err := c.apiRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v0/write-file?path=%s", path), reader)
|
||||
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) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
|
||||
@@ -387,3 +387,17 @@ func (mr *MockAgentConnMockRecorder) WatchContainers(ctx, logger any) *gomock.Ca
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchContainers", reflect.TypeOf((*MockAgentConn)(nil).WatchContainers), ctx, logger)
|
||||
}
|
||||
|
||||
// WriteFile mocks base method.
|
||||
func (m *MockAgentConn) WriteFile(ctx context.Context, path string, reader io.Reader) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "WriteFile", ctx, path, reader)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// WriteFile indicates an expected call of WriteFile.
|
||||
func (mr *MockAgentConnMockRecorder) WriteFile(ctx, path, reader any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteFile", reflect.TypeOf((*MockAgentConn)(nil).WriteFile), ctx, path, reader)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user