Skip to content

Commit a5b63d6

Browse files
committed
Add coder_workspace_ls MCP tool
1 parent 9435706 commit a5b63d6

File tree

6 files changed

+316
-113
lines changed

6 files changed

+316
-113
lines changed

agent/ls.go

Lines changed: 66 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,39 @@ import (
1111
"strings"
1212

1313
"github.com/shirou/gopsutil/v4/disk"
14+
"github.com/spf13/afero"
1415
"golang.org/x/xerrors"
1516

1617
"github.com/coder/coder/v2/coderd/httpapi"
1718
"github.com/coder/coder/v2/codersdk"
19+
"github.com/coder/coder/v2/codersdk/workspacesdk"
1820
)
1921

2022
var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`)
2123

22-
func (*agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
24+
func (a *agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
2325
ctx := r.Context()
2426

25-
var query LSRequest
26-
if !httpapi.Read(ctx, rw, r, &query) {
27+
// An absolute path may be optionally provided, otherwise a path split into an
28+
// array must be provided in the body (which can be relative).
29+
query := r.URL.Query()
30+
parser := httpapi.NewQueryParamParser()
31+
path := parser.String(query, "", "path")
32+
parser.ErrorExcessParams(query)
33+
if len(parser.Errors) > 0 {
34+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
35+
Message: "Query parameters have invalid values.",
36+
Validations: parser.Errors,
37+
})
2738
return
2839
}
2940

30-
resp, err := listFiles(query)
41+
var req workspacesdk.LSRequest
42+
if !httpapi.Read(ctx, rw, r, &req) {
43+
return
44+
}
45+
46+
resp, err := listFiles(a.filesystem, path, req)
3147
if err != nil {
3248
status := http.StatusInternalServerError
3349
switch {
@@ -46,66 +62,74 @@ func (*agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
4662
httpapi.Write(ctx, rw, http.StatusOK, resp)
4763
}
4864

49-
func listFiles(query LSRequest) (LSResponse, error) {
50-
var fullPath []string
51-
switch query.Relativity {
52-
case LSRelativityHome:
53-
home, err := os.UserHomeDir()
54-
if err != nil {
55-
return LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err)
65+
func listFiles(fs afero.Fs, path string, query workspacesdk.LSRequest) (workspacesdk.LSResponse, error) {
66+
absolutePathString := path
67+
if absolutePathString != "" {
68+
if !filepath.IsAbs(path) {
69+
return workspacesdk.LSResponse{}, xerrors.Errorf("path must be absolute: %q", path)
5670
}
57-
fullPath = []string{home}
58-
case LSRelativityRoot:
59-
if runtime.GOOS == "windows" {
60-
if len(query.Path) == 0 {
61-
return listDrives()
71+
} else {
72+
var fullPath []string
73+
switch query.Relativity {
74+
case workspacesdk.LSRelativityHome:
75+
home, err := os.UserHomeDir()
76+
if err != nil {
77+
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err)
6278
}
63-
if !WindowsDriveRegex.MatchString(query.Path[0]) {
64-
return LSResponse{}, xerrors.Errorf("invalid drive letter %q", query.Path[0])
79+
fullPath = []string{home}
80+
case workspacesdk.LSRelativityRoot:
81+
if runtime.GOOS == "windows" {
82+
if len(query.Path) == 0 {
83+
return listDrives()
84+
}
85+
if !WindowsDriveRegex.MatchString(query.Path[0]) {
86+
return workspacesdk.LSResponse{}, xerrors.Errorf("invalid drive letter %q", query.Path[0])
87+
}
88+
} else {
89+
fullPath = []string{"/"}
6590
}
66-
} else {
67-
fullPath = []string{"/"}
91+
default:
92+
return workspacesdk.LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity)
6893
}
69-
default:
70-
return LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity)
71-
}
7294

73-
fullPath = append(fullPath, query.Path...)
74-
fullPathRelative := filepath.Join(fullPath...)
75-
absolutePathString, err := filepath.Abs(fullPathRelative)
76-
if err != nil {
77-
return LSResponse{}, xerrors.Errorf("failed to get absolute path of %q: %w", fullPathRelative, err)
95+
fullPath = append(fullPath, query.Path...)
96+
fullPathRelative := filepath.Join(fullPath...)
97+
var err error
98+
absolutePathString, err = filepath.Abs(fullPathRelative)
99+
if err != nil {
100+
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get absolute path of %q: %w", fullPathRelative, err)
101+
}
78102
}
79103

80104
// codeql[go/path-injection] - The intent is to allow the user to navigate to any directory in their workspace.
81-
f, err := os.Open(absolutePathString)
105+
f, err := fs.Open(absolutePathString)
82106
if err != nil {
83-
return LSResponse{}, xerrors.Errorf("failed to open directory %q: %w", absolutePathString, err)
107+
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to open directory %q: %w", absolutePathString, err)
84108
}
85109
defer f.Close()
86110

87111
stat, err := f.Stat()
88112
if err != nil {
89-
return LSResponse{}, xerrors.Errorf("failed to stat directory %q: %w", absolutePathString, err)
113+
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to stat directory %q: %w", absolutePathString, err)
90114
}
91115

92116
if !stat.IsDir() {
93-
return LSResponse{}, xerrors.Errorf("path %q is not a directory", absolutePathString)
117+
return workspacesdk.LSResponse{}, xerrors.Errorf("path %q is not a directory", absolutePathString)
94118
}
95119

96120
// `contents` may be partially populated even if the operation fails midway.
97-
contents, _ := f.ReadDir(-1)
98-
respContents := make([]LSFile, 0, len(contents))
121+
contents, _ := f.Readdir(-1)
122+
respContents := make([]workspacesdk.LSFile, 0, len(contents))
99123
for _, file := range contents {
100-
respContents = append(respContents, LSFile{
124+
respContents = append(respContents, workspacesdk.LSFile{
101125
Name: file.Name(),
102126
AbsolutePathString: filepath.Join(absolutePathString, file.Name()),
103127
IsDir: file.IsDir(),
104128
})
105129
}
106130

107131
// Sort alphabetically: directories then files
108-
slices.SortFunc(respContents, func(a, b LSFile) int {
132+
slices.SortFunc(respContents, func(a, b workspacesdk.LSFile) int {
109133
if a.IsDir && !b.IsDir {
110134
return -1
111135
}
@@ -117,35 +141,35 @@ func listFiles(query LSRequest) (LSResponse, error) {
117141

118142
absolutePath := pathToArray(absolutePathString)
119143

120-
return LSResponse{
144+
return workspacesdk.LSResponse{
121145
AbsolutePath: absolutePath,
122146
AbsolutePathString: absolutePathString,
123147
Contents: respContents,
124148
}, nil
125149
}
126150

127-
func listDrives() (LSResponse, error) {
151+
func listDrives() (workspacesdk.LSResponse, error) {
128152
// disk.Partitions() will return partitions even if there was a failure to
129153
// get one. Any errored partitions will not be returned.
130154
partitionStats, err := disk.Partitions(true)
131155
if err != nil && len(partitionStats) == 0 {
132156
// Only return the error if there were no partitions returned.
133-
return LSResponse{}, xerrors.Errorf("failed to get partitions: %w", err)
157+
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get partitions: %w", err)
134158
}
135159

136-
contents := make([]LSFile, 0, len(partitionStats))
160+
contents := make([]workspacesdk.LSFile, 0, len(partitionStats))
137161
for _, a := range partitionStats {
138162
// Drive letters on Windows have a trailing separator as part of their name.
139163
// i.e. `os.Open("C:")` does not work, but `os.Open("C:\\")` does.
140164
name := a.Mountpoint + string(os.PathSeparator)
141-
contents = append(contents, LSFile{
165+
contents = append(contents, workspacesdk.LSFile{
142166
Name: name,
143167
AbsolutePathString: name,
144168
IsDir: true,
145169
})
146170
}
147171

148-
return LSResponse{
172+
return workspacesdk.LSResponse{
149173
AbsolutePath: []string{},
150174
AbsolutePathString: "",
151175
Contents: contents,
@@ -163,36 +187,3 @@ func pathToArray(path string) []string {
163187
}
164188
return out
165189
}
166-
167-
type LSRequest struct {
168-
// e.g. [], ["repos", "coder"],
169-
Path []string `json:"path"`
170-
// Whether the supplied path is relative to the user's home directory,
171-
// or the root directory.
172-
Relativity LSRelativity `json:"relativity"`
173-
}
174-
175-
type LSResponse struct {
176-
AbsolutePath []string `json:"absolute_path"`
177-
// Returned so clients can display the full path to the user, and
178-
// copy it to configure file sync
179-
// e.g. Windows: "C:\\Users\\coder"
180-
// Linux: "/home/coder"
181-
AbsolutePathString string `json:"absolute_path_string"`
182-
Contents []LSFile `json:"contents"`
183-
}
184-
185-
type LSFile struct {
186-
Name string `json:"name"`
187-
// e.g. "C:\\Users\\coder\\hello.txt"
188-
// "/home/coder/hello.txt"
189-
AbsolutePathString string `json:"absolute_path_string"`
190-
IsDir bool `json:"is_dir"`
191-
}
192-
193-
type LSRelativity string
194-
195-
const (
196-
LSRelativityRoot LSRelativity = "root"
197-
LSRelativityHome LSRelativity = "home"
198-
)

0 commit comments

Comments
 (0)