feat: add coder_workspace_read_file MCP tool (#19562)

Follows similarly to the bash tool (and some code to connect to an agent
was extracted from it).

There are two main parts: a new agent endpoint, and then a new MCP tool
that consumes that endpoint.
This commit is contained in:
Asher
2025-09-09 15:12:24 -08:00
committed by GitHub
parent f402ec99eb
commit 4bf63b4068
10 changed files with 678 additions and 116 deletions
+25
View File
@@ -60,6 +60,7 @@ type AgentConn interface {
PrometheusMetrics(ctx context.Context) ([]byte, error)
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)
SSH(ctx context.Context) (*gonet.TCPConn, error)
SSHClient(ctx context.Context) (*ssh.Client, error)
SSHClientOnPort(ctx context.Context, port uint16) (*ssh.Client, error)
@@ -476,6 +477,30 @@ func (c *agentConn) RecreateDevcontainer(ctx context.Context, devcontainerID str
return m, nil
}
// ReadFile reads from a file from the workspace, returning a file reader and
// the mime type.
func (c *agentConn) ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
//nolint:bodyclose // we want to return the body so the caller can stream.
res, err := c.apiRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v0/read-file?path=%s&offset=%d&limit=%d", path, offset, limit), nil)
if err != nil {
return nil, "", xerrors.Errorf("do request: %w", err)
}
if res.StatusCode != http.StatusOK {
// codersdk.ReadBodyAsError will close the body.
return nil, "", codersdk.ReadBodyAsError(res)
}
mimeType := res.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "application/octet-stream"
}
return res.Body, mimeType, 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)
@@ -232,6 +232,22 @@ func (mr *MockAgentConnMockRecorder) PrometheusMetrics(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrometheusMetrics", reflect.TypeOf((*MockAgentConn)(nil).PrometheusMetrics), ctx)
}
// ReadFile mocks base method.
func (m *MockAgentConn) ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ReadFile", ctx, path, offset, limit)
ret0, _ := ret[0].(io.ReadCloser)
ret1, _ := ret[1].(string)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// ReadFile indicates an expected call of ReadFile.
func (mr *MockAgentConnMockRecorder) ReadFile(ctx, path, offset, limit any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockAgentConn)(nil).ReadFile), ctx, path, offset, limit)
}
// ReconnectingPTY mocks base method.
func (m *MockAgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string, initOpts ...workspacesdk.AgentReconnectingPTYInitOption) (net.Conn, error) {
m.ctrl.T.Helper()