Skip to content

Commit 771690e

Browse files
authored
Merge pull request #8 from aniongithub/feature-edit-tools
feat: add file editing tools for all backends
2 parents aa4d1fc + dd90ba7 commit 771690e

11 files changed

Lines changed: 780 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ When AI agents write code, they need to run it somewhere. Today that means your
1919

2020
The [devcontainer spec](https://containers.dev/) already defines reproducible, container-based dev environments. Every major project ships a `.devcontainer/devcontainer.json`. But AI agents can't use them — until now.
2121

22-
`devcontainer-mcp` exposes **33 MCP tools** that let any AI agent:
22+
`devcontainer-mcp` exposes **45 MCP tools** that let any AI agent:
2323

2424
1. **Spin up** a dev container from any repo — locally, on a cloud VM, or in Codespaces
2525
2. **Run commands** inside the container — builds, tests, linting, anything
@@ -98,7 +98,7 @@ Codespaces tools require an auth handle (e.g. `"github-aniongithub"`). The MCP s
9898

9999
Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes**
100100

101-
## MCP Tools (33 total)
101+
## MCP Tools (45 total)
102102

103103
### Auth (4 tools)
104104

@@ -109,7 +109,7 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes**
109109
| `auth_select` | Switch the active account for a provider |
110110
| `auth_logout` | Revoke credentials for an account |
111111

112-
### DevPod (15 tools)
112+
### DevPod (19 tools)
113113

114114
| Tool | Description |
115115
|------|-------------|
@@ -128,8 +128,12 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes**
128128
| `devpod_context_use` | Switch to a different context |
129129
| `devpod_container_inspect` | Docker inspect — labels, ports, mounts, state |
130130
| `devpod_container_logs` | Stream container logs via Docker API |
131+
| `devpod_file_read` | Read file content with optional line range |
132+
| `devpod_file_write` | Create or overwrite a file (auto-creates parent dirs) |
133+
| `devpod_file_edit` | Surgical string replacement — old_str → new_str |
134+
| `devpod_file_list` | List directory contents (non-hidden, 2 levels deep) |
131135

132-
### devcontainer CLI (7 tools)
136+
### devcontainer CLI (11 tools)
133137

134138
| Tool | Description |
135139
|------|-------------|
@@ -140,8 +144,12 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes**
140144
| `devcontainer_stop` | Stop a dev container (via Docker API) |
141145
| `devcontainer_remove` | Remove a dev container and its resources |
142146
| `devcontainer_status` | Get dev container state by workspace folder |
147+
| `devcontainer_file_read` | Read file content with optional line range |
148+
| `devcontainer_file_write` | Create or overwrite a file (auto-creates parent dirs) |
149+
| `devcontainer_file_edit` | Surgical string replacement — old_str → new_str |
150+
| `devcontainer_file_list` | List directory contents (non-hidden, 2 levels deep) |
143151

144-
### GitHub Codespaces (7 tools) — require `auth` handle
152+
### GitHub Codespaces (11 tools) — require `auth` handle
145153

146154
| Tool | Description |
147155
|------|-------------|
@@ -152,6 +160,10 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes**
152160
| `codespaces_delete` | Delete a codespace |
153161
| `codespaces_view` | View detailed codespace info (state, machine, config) |
154162
| `codespaces_ports` | List forwarded ports with visibility and URLs |
163+
| `codespaces_file_read` | Read file content with optional line range |
164+
| `codespaces_file_write` | Create or overwrite a file (auto-creates parent dirs) |
165+
| `codespaces_file_edit` | Surgical string replacement — old_str → new_str |
166+
| `codespaces_file_list` | List directory contents (non-hidden, 2 levels deep) |
155167

156168
## MCP Server Configuration
157169

SKILL.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ tools:
3535
- codespaces_delete
3636
- codespaces_view
3737
- codespaces_ports
38+
- devpod_file_read
39+
- devpod_file_write
40+
- devpod_file_edit
41+
- devpod_file_list
42+
- devcontainer_file_read
43+
- devcontainer_file_write
44+
- devcontainer_file_edit
45+
- devcontainer_file_list
46+
- codespaces_file_read
47+
- codespaces_file_write
48+
- codespaces_file_edit
49+
- codespaces_file_list
3850
---
3951

4052
# DevContainer MCP Skill
@@ -163,3 +175,46 @@ If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors:
163175
- ✅ DO ask the user which account/machine type to use
164176
- ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything
165177
- ✅ DO check `.devcontainer/devcontainer.json` first
178+
179+
## File Operations
180+
181+
**All backends support built-in file operations — no need to construct shell commands.**
182+
183+
These tools mirror familiar editing tools (read, write, edit, list) and handle escaping, encoding, and directory creation automatically.
184+
185+
### Reading files
186+
```
187+
devpod_file_read(workspace: "my-ws", path: "/workspaces/project/src/main.rs")
188+
devcontainer_file_read(workspace_folder: "/path/to/project", path: "/workspaces/project/src/main.rs")
189+
codespaces_file_read(auth: "github-user", codespace: "name", path: "src/main.rs")
190+
```
191+
Supports optional `start_line` and `end_line` for reading specific ranges.
192+
193+
### Writing files
194+
```
195+
devpod_file_write(workspace: "my-ws", path: "/workspaces/project/new_file.rs", content: "fn main() {}")
196+
devcontainer_file_write(workspace_folder: "/path/to/project", path: "new_file.rs", content: "fn main() {}")
197+
codespaces_file_write(auth: "github-user", codespace: "name", path: "src/new.rs", content: "...")
198+
```
199+
Creates parent directories automatically.
200+
201+
### Editing files (surgical replacement)
202+
```
203+
devpod_file_edit(workspace: "my-ws", path: "src/main.rs", old_str: "fn old()", new_str: "fn new()")
204+
devcontainer_file_edit(workspace_folder: "/path/to/project", path: "src/lib.rs", old_str: "v1", new_str: "v2")
205+
codespaces_file_edit(auth: "github-user", codespace: "name", path: "src/lib.rs", old_str: "TODO", new_str: "DONE")
206+
```
207+
`old_str` must match exactly once in the file. Include surrounding context to make it unique.
208+
209+
### Listing directories
210+
```
211+
devpod_file_list(workspace: "my-ws", path: "/workspaces/project/src")
212+
devcontainer_file_list(workspace_folder: "/path/to/project", path: "src")
213+
codespaces_file_list(auth: "github-user", codespace: "name", path: ".")
214+
```
215+
Shows non-hidden files up to 2 levels deep.
216+
217+
### When to use file tools vs exec/ssh
218+
-**Use file tools** for reading, writing, and editing source files
219+
-**Use exec/ssh** for running builds, tests, and commands
220+
-**Don't** construct `sed`, `cat`, or `echo` commands via exec for file editing

crates/devcontainer-mcp-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ bollard = { workspace = true }
1515
tracing = { workspace = true }
1616
futures-util = "0.3"
1717
async-trait = "0.1"
18+
base64 = "0.22"

crates/devcontainer-mcp-core/src/codespaces.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,67 @@ pub async fn ports(env: &HashMap<String, String>, codespace: &str) -> Result<Cli
107107
let args = vec!["ports", "-c", codespace, "--json", PORT_FIELDS];
108108
run_gh_cs(&args, true, Some(env)).await
109109
}
110+
111+
// ---------------------------------------------------------------------------
112+
// File operations
113+
// ---------------------------------------------------------------------------
114+
115+
/// Read a file from a Codespace.
116+
pub async fn file_read(
117+
env: &HashMap<String, String>,
118+
codespace: &str,
119+
path: &str,
120+
) -> Result<CliOutput> {
121+
let cmd = crate::file_ops::read_file_command(path);
122+
ssh_exec(env, codespace, &cmd).await
123+
}
124+
125+
/// Write (create or overwrite) a file in a Codespace.
126+
pub async fn file_write(
127+
env: &HashMap<String, String>,
128+
codespace: &str,
129+
path: &str,
130+
content: &str,
131+
) -> Result<CliOutput> {
132+
let cmd = crate::file_ops::write_file_command(path, content);
133+
ssh_exec(env, codespace, &cmd).await
134+
}
135+
136+
/// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`.
137+
pub async fn file_edit(
138+
env: &HashMap<String, String>,
139+
codespace: &str,
140+
path: &str,
141+
old_str: &str,
142+
new_str: &str,
143+
) -> Result<String> {
144+
let read_output = file_read(env, codespace, path).await?;
145+
if read_output.exit_code != 0 {
146+
return Err(crate::error::Error::FileRead(format!(
147+
"Failed to read {path}: {}",
148+
read_output.stderr.trim()
149+
)));
150+
}
151+
152+
let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?;
153+
154+
let write_output = file_write(env, codespace, path, &modified).await?;
155+
if write_output.exit_code != 0 {
156+
return Err(crate::error::Error::FileEdit(format!(
157+
"Failed to write {path}: {}",
158+
write_output.stderr.trim()
159+
)));
160+
}
161+
162+
Ok(format!("Edit applied to {path}"))
163+
}
164+
165+
/// List directory contents in a Codespace.
166+
pub async fn file_list(
167+
env: &HashMap<String, String>,
168+
codespace: &str,
169+
path: &str,
170+
) -> Result<CliOutput> {
171+
let cmd = crate::file_ops::list_dir_command(path);
172+
ssh_exec(env, codespace, &cmd).await
173+
}

crates/devcontainer-mcp-core/src/devcontainer.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,63 @@ pub async fn status(workspace_folder: &str) -> Result<Option<docker::ContainerIn
9494
let client = docker::connect()?;
9595
docker::find_container_by_local_folder(&client, workspace_folder).await
9696
}
97+
98+
// ---------------------------------------------------------------------------
99+
// File operations
100+
// ---------------------------------------------------------------------------
101+
102+
/// Read a file from a dev container.
103+
pub async fn file_read(
104+
workspace_folder: &str,
105+
path: &str,
106+
) -> Result<CliOutput> {
107+
let cmd = crate::file_ops::read_file_command(path);
108+
exec(workspace_folder, "sh", &["-c", &cmd]).await
109+
}
110+
111+
/// Write (create or overwrite) a file in a dev container.
112+
pub async fn file_write(
113+
workspace_folder: &str,
114+
path: &str,
115+
content: &str,
116+
) -> Result<CliOutput> {
117+
let cmd = crate::file_ops::write_file_command(path, content);
118+
exec(workspace_folder, "sh", &["-c", &cmd]).await
119+
}
120+
121+
/// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`.
122+
pub async fn file_edit(
123+
workspace_folder: &str,
124+
path: &str,
125+
old_str: &str,
126+
new_str: &str,
127+
) -> Result<String> {
128+
let read_output = file_read(workspace_folder, path).await?;
129+
if read_output.exit_code != 0 {
130+
return Err(crate::error::Error::FileRead(format!(
131+
"Failed to read {path}: {}",
132+
read_output.stderr.trim()
133+
)));
134+
}
135+
136+
let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?;
137+
138+
let write_output = file_write(workspace_folder, path, &modified).await?;
139+
if write_output.exit_code != 0 {
140+
return Err(crate::error::Error::FileEdit(format!(
141+
"Failed to write {path}: {}",
142+
write_output.stderr.trim()
143+
)));
144+
}
145+
146+
Ok(format!("Edit applied to {path}"))
147+
}
148+
149+
/// List directory contents in a dev container.
150+
pub async fn file_list(
151+
workspace_folder: &str,
152+
path: &str,
153+
) -> Result<CliOutput> {
154+
let cmd = crate::file_ops::list_dir_command(path);
155+
exec(workspace_folder, "sh", &["-c", &cmd]).await
156+
}

crates/devcontainer-mcp-core/src/devpod.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,67 @@ pub async fn import(args: &[&str]) -> Result<CliOutput> {
167167
pub async fn export(workspace: &str) -> Result<CliOutput> {
168168
run_devpod(&["export", workspace], false).await
169169
}
170+
171+
// ---------------------------------------------------------------------------
172+
// File operations
173+
// ---------------------------------------------------------------------------
174+
175+
/// Read a file from a DevPod workspace.
176+
pub async fn file_read(
177+
workspace: &str,
178+
path: &str,
179+
user: Option<&str>,
180+
) -> Result<CliOutput> {
181+
let cmd = crate::file_ops::read_file_command(path);
182+
ssh_exec(workspace, &cmd, user, None).await
183+
}
184+
185+
/// Write (create or overwrite) a file in a DevPod workspace.
186+
pub async fn file_write(
187+
workspace: &str,
188+
path: &str,
189+
content: &str,
190+
user: Option<&str>,
191+
) -> Result<CliOutput> {
192+
let cmd = crate::file_ops::write_file_command(path, content);
193+
ssh_exec(workspace, &cmd, user, None).await
194+
}
195+
196+
/// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`.
197+
pub async fn file_edit(
198+
workspace: &str,
199+
path: &str,
200+
old_str: &str,
201+
new_str: &str,
202+
user: Option<&str>,
203+
) -> Result<String> {
204+
let read_output = file_read(workspace, path, user).await?;
205+
if read_output.exit_code != 0 {
206+
return Err(Error::FileRead(format!(
207+
"Failed to read {path}: {}",
208+
read_output.stderr.trim()
209+
)));
210+
}
211+
212+
let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?;
213+
214+
let write_output = file_write(workspace, path, &modified, user).await?;
215+
if write_output.exit_code != 0 {
216+
return Err(Error::FileEdit(format!(
217+
"Failed to write {path}: {}",
218+
write_output.stderr.trim()
219+
)));
220+
}
221+
222+
Ok(format!("Edit applied to {path}"))
223+
}
224+
225+
/// List directory contents in a DevPod workspace.
226+
pub async fn file_list(
227+
workspace: &str,
228+
path: &str,
229+
user: Option<&str>,
230+
) -> Result<CliOutput> {
231+
let cmd = crate::file_ops::list_dir_command(path);
232+
ssh_exec(workspace, &cmd, user, None).await
233+
}

crates/devcontainer-mcp-core/src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ pub enum Error {
3030
#[error("DevPod command failed (exit code {exit_code}): {stderr}")]
3131
DevPodCommand { exit_code: i32, stderr: String },
3232

33+
#[error("File read error: {0}")]
34+
FileRead(String),
35+
36+
#[error("File edit error: {0}")]
37+
FileEdit(String),
38+
3339
#[error("IO error: {0}")]
3440
Io(#[from] std::io::Error),
3541

0 commit comments

Comments
 (0)