Skip to content

Commit 2972507

Browse files
committed
Add coder_workspace_read_file MCP tool
1 parent c5282ea commit 2972507

File tree

8 files changed

+526
-0
lines changed

8 files changed

+526
-0
lines changed

agent/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func (a *agent) apiHandler() http.Handler {
6060
r.Get("/api/v0/listening-ports", lp.handler)
6161
r.Get("/api/v0/netcheck", a.HandleNetcheck)
6262
r.Post("/api/v0/list-directory", a.HandleLS)
63+
r.Get("/api/v0/read-file", a.HandleReadFile)
6364
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
6465
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
6566
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)

agent/files.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"mime"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"strconv"
12+
13+
"golang.org/x/xerrors"
14+
15+
"cdr.dev/slog"
16+
"github.com/coder/coder/v2/coderd/httpapi"
17+
"github.com/coder/coder/v2/codersdk"
18+
)
19+
20+
func (a *agent) HandleReadFile(rw http.ResponseWriter, r *http.Request) {
21+
ctx := r.Context()
22+
23+
query := r.URL.Query()
24+
parser := httpapi.NewQueryParamParser().RequiredNotEmpty("path")
25+
path := parser.String(query, "", "path")
26+
offset := parser.PositiveInt64(query, 0, "offset")
27+
limit := parser.PositiveInt64(query, 0, "limit")
28+
parser.ErrorExcessParams(query)
29+
if len(parser.Errors) > 0 {
30+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
31+
Message: "Query parameters have invalid values.",
32+
Validations: parser.Errors,
33+
})
34+
return
35+
}
36+
37+
status, err := a.streamFile(ctx, rw, path, offset, limit)
38+
if err != nil {
39+
httpapi.Write(ctx, rw, status, codersdk.Response{
40+
Message: err.Error(),
41+
})
42+
return
43+
}
44+
}
45+
46+
func (a *agent) streamFile(ctx context.Context, rw http.ResponseWriter, path string, offset, limit int64) (int, error) {
47+
if !filepath.IsAbs(path) {
48+
return http.StatusBadRequest, xerrors.Errorf("file path must be absolute: %q", path)
49+
}
50+
51+
f, err := a.filesystem.Open(path)
52+
if err != nil {
53+
status := http.StatusInternalServerError
54+
switch {
55+
case errors.Is(err, os.ErrNotExist):
56+
status = http.StatusNotFound
57+
case errors.Is(err, os.ErrPermission):
58+
status = http.StatusForbidden
59+
}
60+
return status, xerrors.Errorf("failed to open file %q: %w", path, err)
61+
}
62+
defer f.Close()
63+
64+
stat, err := f.Stat()
65+
if err != nil {
66+
return http.StatusInternalServerError, xerrors.Errorf("failed to stat file %q: %w", path, err)
67+
}
68+
69+
if stat.IsDir() {
70+
return http.StatusBadRequest, xerrors.Errorf("path %q is not a file", path)
71+
}
72+
73+
size := stat.Size()
74+
if limit == 0 {
75+
limit = size
76+
}
77+
bytesRemaining := max(size-offset, 0)
78+
bytesToRead := min(bytesRemaining, limit)
79+
80+
// Relying on just the file name for the mime type for now.
81+
mimeType := mime.TypeByExtension(filepath.Ext(path))
82+
if mimeType == "" {
83+
mimeType = "application/octet-stream"
84+
}
85+
rw.Header().Set("Content-Type", mimeType)
86+
rw.Header().Set("Content-Length", strconv.FormatInt(bytesToRead, 10))
87+
rw.WriteHeader(http.StatusOK)
88+
89+
reader := io.NewSectionReader(f, offset, bytesToRead)
90+
_, err = io.Copy(rw, reader)
91+
if err != nil && !errors.Is(err, io.EOF) && ctx.Err() == nil {
92+
a.logger.Error(ctx, "workspace agent read file", slog.Error(err))
93+
}
94+
95+
return 0, nil
96+
}

agent/files_test.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package agent_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/spf13/afero"
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/coder/coder/v2/agent"
14+
"github.com/coder/coder/v2/agent/agenttest"
15+
"github.com/coder/coder/v2/coderd/coderdtest"
16+
"github.com/coder/coder/v2/codersdk/agentsdk"
17+
"github.com/coder/coder/v2/testutil"
18+
)
19+
20+
type testFs struct {
21+
afero.Fs
22+
deny string
23+
}
24+
25+
func newTestFs(base afero.Fs, deny string) *testFs {
26+
return &testFs{
27+
Fs: base,
28+
deny: deny,
29+
}
30+
}
31+
32+
func (fs *testFs) Open(name string) (afero.File, error) {
33+
if name == fs.deny {
34+
return nil, os.ErrPermission
35+
}
36+
return fs.Fs.Open(name)
37+
}
38+
39+
func TestReadFile(t *testing.T) {
40+
t.Parallel()
41+
42+
tmpdir := os.TempDir()
43+
noPermsPath := filepath.Join(tmpdir, "no-perms")
44+
//nolint:dogsled
45+
conn, _, _, fs, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, opts *agent.Options) {
46+
opts.Filesystem = newTestFs(opts.Filesystem, noPermsPath)
47+
})
48+
49+
dirPath := filepath.Join(tmpdir, "a-directory")
50+
err := fs.MkdirAll(dirPath, 0o755)
51+
require.NoError(t, err)
52+
53+
filePath := filepath.Join(tmpdir, "file")
54+
err = afero.WriteFile(fs, filePath, []byte("content"), 0o644)
55+
require.NoError(t, err)
56+
57+
imagePath := filepath.Join(tmpdir, "file.png")
58+
err = afero.WriteFile(fs, imagePath, []byte("not really an image"), 0o644)
59+
require.NoError(t, err)
60+
61+
tests := []struct {
62+
name string
63+
path string
64+
limit int64
65+
offset int64
66+
bytes []byte
67+
mimeType string
68+
errCode int
69+
error string
70+
}{
71+
{
72+
name: "NoPath",
73+
path: "",
74+
errCode: http.StatusBadRequest,
75+
error: "\"path\" is required",
76+
},
77+
{
78+
name: "RelativePath",
79+
path: "./relative",
80+
errCode: http.StatusBadRequest,
81+
error: "file path must be absolute",
82+
},
83+
{
84+
name: "RelativePath",
85+
path: "also-relative",
86+
errCode: http.StatusBadRequest,
87+
error: "file path must be absolute",
88+
},
89+
{
90+
name: "NegativeLimit",
91+
path: filePath,
92+
limit: -10,
93+
errCode: http.StatusBadRequest,
94+
error: "value is negative",
95+
},
96+
{
97+
name: "NegativeOffset",
98+
path: filePath,
99+
offset: -10,
100+
errCode: http.StatusBadRequest,
101+
error: "value is negative",
102+
},
103+
{
104+
name: "NonExistent",
105+
path: filepath.Join(tmpdir, "does-not-exist"),
106+
errCode: http.StatusNotFound,
107+
error: "file does not exist",
108+
},
109+
{
110+
name: "IsDir",
111+
path: dirPath,
112+
errCode: http.StatusBadRequest,
113+
error: "is not a file",
114+
},
115+
{
116+
name: "NoPermissions",
117+
path: noPermsPath,
118+
errCode: http.StatusForbidden,
119+
error: "permission denied",
120+
},
121+
{
122+
name: "Defaults",
123+
path: filePath,
124+
bytes: []byte("content"),
125+
},
126+
{
127+
name: "Limit1",
128+
path: filePath,
129+
limit: 1,
130+
bytes: []byte("c"),
131+
},
132+
{
133+
name: "Offset1",
134+
path: filePath,
135+
offset: 1,
136+
bytes: []byte("ontent"),
137+
},
138+
{
139+
name: "Limit1Offset2",
140+
path: filePath,
141+
limit: 1,
142+
offset: 2,
143+
bytes: []byte("n"),
144+
},
145+
{
146+
name: "Limit7Offset0",
147+
path: filePath,
148+
limit: 7,
149+
offset: 0,
150+
bytes: []byte("content"),
151+
},
152+
{
153+
name: "Limit100",
154+
path: filePath,
155+
limit: 100,
156+
bytes: []byte("content"),
157+
},
158+
{
159+
name: "Offset7",
160+
path: filePath,
161+
offset: 7,
162+
bytes: []byte{},
163+
},
164+
{
165+
name: "Offset100",
166+
path: filePath,
167+
offset: 100,
168+
bytes: []byte{},
169+
},
170+
{
171+
name: "MimeTypePng",
172+
path: imagePath,
173+
bytes: []byte("not really an image"),
174+
mimeType: "image/png",
175+
},
176+
}
177+
178+
for _, tt := range tests {
179+
t.Run(tt.name, func(t *testing.T) {
180+
t.Parallel()
181+
182+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
183+
defer cancel()
184+
185+
b, mimeType, err := conn.ReadFile(ctx, tt.path, tt.offset, tt.limit)
186+
if tt.errCode != 0 {
187+
require.Error(t, err)
188+
cerr := coderdtest.SDKError(t, err)
189+
require.Equal(t, tt.errCode, cerr.StatusCode())
190+
require.Contains(t, cerr.Error(), tt.error)
191+
} else {
192+
require.NoError(t, err)
193+
require.Equal(t, tt.bytes, b)
194+
expectedMimeType := tt.mimeType
195+
if expectedMimeType == "" {
196+
expectedMimeType = "application/octet-stream"
197+
}
198+
require.Equal(t, expectedMimeType, mimeType)
199+
}
200+
})
201+
}
202+
}

coderd/httpapi/queryparams.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ func (p *QueryParamParser) PositiveInt32(vals url.Values, def int32, queryParam
120120
return v
121121
}
122122

123+
// PositiveInt64 function checks if the given value is 64-bit and positive.
124+
func (p *QueryParamParser) PositiveInt64(vals url.Values, def int64, queryParam string) int64 {
125+
v, err := parseQueryParam(p, vals, func(v string) (int64, error) {
126+
intValue, err := strconv.ParseInt(v, 10, 64)
127+
if err != nil {
128+
return 0, err
129+
}
130+
if intValue < 0 {
131+
return 0, xerrors.Errorf("value is negative")
132+
}
133+
return intValue, nil
134+
}, def, queryParam)
135+
if err != nil {
136+
p.Errors = append(p.Errors, codersdk.ValidationError{
137+
Field: queryParam,
138+
Detail: fmt.Sprintf("Query param %q must be a valid 64-bit positive integer: %s", queryParam, err.Error()),
139+
})
140+
}
141+
return v
142+
}
143+
123144
// NullableBoolean will return a null sql value if no input is provided.
124145
// SQLc still uses sql.NullBool rather than the generic type. So converting from
125146
// the generic type is required.

0 commit comments

Comments
 (0)