feat(acp): add read tool and delegate filesystem I/O to ACP clients (#7668)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Adrian Cole
2026-03-11 21:21:00 +08:00
committed by GitHub
parent 913537d540
commit cb4d99d303
16 changed files with 1189 additions and 140 deletions
@@ -507,7 +507,6 @@ impl ExtensionManager {
}
}
#[cfg(test)]
pub fn new_without_provider(data_dir: std::path::PathBuf) -> Self {
let session_manager = Arc::new(crate::session::SessionManager::new(data_dir));
Self::new(
@@ -7,6 +7,17 @@ use serde::Deserialize;
const NO_MATCH_PREVIEW_LINES: usize = 20;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FileReadParams {
/// Absolute path to the file to read.
pub path: String,
/// Line number to start reading from (1-based).
#[schemars(range(min = 1))]
pub line: Option<u32>,
/// Maximum number of lines to read.
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FileWriteParams {
pub path: String,
@@ -27,6 +38,26 @@ impl EditTools {
Self
}
pub fn file_read_with_cwd(
&self,
params: FileReadParams,
working_dir: Option<&Path>,
) -> CallToolResult {
let path = resolve_path(&params.path, working_dir);
match fs::read_to_string(&path) {
Ok(content) => {
let content = apply_line_limit(&content, params.line, params.limit);
CallToolResult::success(vec![Content::text(content).with_priority(0.0)])
}
Err(error) => CallToolResult::error(vec![Content::text(format!(
"Failed to read {}: {}",
params.path, error
))
.with_priority(0.0)]),
}
}
pub fn file_write(&self, params: FileWriteParams) -> CallToolResult {
self.file_write_with_cwd(params, None)
}
@@ -93,62 +124,27 @@ impl EditTools {
}
};
let matches: Vec<_> = content.match_indices(&params.before).collect();
match matches.len() {
0 => {
let suggestion = find_similar_context(&content, &params.before);
let mut msg = "No match found for the specified text.".to_string();
if let Some(hint) = suggestion {
msg.push_str(&format!("\n\nDid you mean:\n```\n{}\n```", hint));
}
let preview = build_file_preview(&content, NO_MATCH_PREVIEW_LINES);
msg.push_str(&format!("\n\nFile preview:\n```\n{}\n```", preview));
CallToolResult::error(vec![Content::text(msg).with_priority(0.0)])
let new_content = match string_replace(&content, &params.before, &params.after) {
Ok(c) => c,
Err(msg) => {
return CallToolResult::error(vec![Content::text(msg).with_priority(0.0)]);
}
1 => {
let new_content = content.replacen(&params.before, &params.after, 1);
match fs::write(&path, &new_content) {
Ok(()) => {
let old_lines = params.before.lines().count();
let new_lines = params.after.lines().count();
CallToolResult::success(vec![Content::text(format!(
"Edited {} ({} lines -> {} lines)",
params.path, old_lines, new_lines
))
.with_priority(0.0)])
}
Err(error) => CallToolResult::error(vec![Content::text(format!(
"Failed to write {}: {}",
params.path, error
))
.with_priority(0.0)]),
}
}
n => {
let mut msg = format!(
"Found {} matches. Please provide more context to identify a unique match:\n",
n
);
for (i, (pos, _)) in matches.iter().enumerate().take(2) {
let line_num = count_lines_before(&content, *pos);
let context = get_line_context(&content, line_num, 1);
msg.push_str(&format!(
"\nMatch {} (line {}):\n```\n{}\n```",
i + 1,
line_num,
context
));
}
if n > 2 {
msg.push_str(&format!("\n\n...and {} more", n - 2));
}
CallToolResult::error(vec![Content::text(msg).with_priority(0.0)])
};
match fs::write(&path, &new_content) {
Ok(()) => {
let old_lines = params.before.lines().count();
let new_lines = params.after.lines().count();
CallToolResult::success(vec![Content::text(format!(
"Edited {} ({} lines -> {} lines)",
params.path, old_lines, new_lines
))
.with_priority(0.0)])
}
Err(error) => CallToolResult::error(vec![Content::text(format!(
"Failed to write {}: {}",
params.path, error
))
.with_priority(0.0)]),
}
}
}
@@ -159,7 +155,64 @@ impl Default for EditTools {
}
}
fn resolve_path(path: &str, working_dir: Option<&Path>) -> PathBuf {
pub fn string_replace(content: &str, before: &str, after: &str) -> Result<String, String> {
let matches: Vec<_> = content.match_indices(before).collect();
match matches.len() {
0 => {
let suggestion = find_similar_context(content, before);
let mut msg = "No match found for the specified text.".to_string();
if let Some(hint) = suggestion {
msg.push_str(&format!("\n\nDid you mean:\n```\n{}\n```", hint));
}
let preview = build_file_preview(content, NO_MATCH_PREVIEW_LINES);
msg.push_str(&format!("\n\nFile preview:\n```\n{}\n```", preview));
Err(msg)
}
1 => Ok(content.replacen(before, after, 1)),
n => {
let mut msg = format!(
"Found {} matches. Please provide more context to identify a unique match:\n",
n
);
for (i, (pos, _)) in matches.iter().enumerate().take(2) {
let line_num = count_lines_before(content, *pos);
let context = get_line_context(content, line_num, 1);
msg.push_str(&format!(
"\nMatch {} (line {}):\n```\n{}\n```",
i + 1,
line_num,
context
));
}
if n > 2 {
msg.push_str(&format!("\n\n...and {} more", n - 2));
}
Err(msg)
}
}
}
fn apply_line_limit(content: &str, line: Option<u32>, limit: Option<u32>) -> String {
if line.is_none() && limit.is_none() {
return content.to_string();
}
let lines: Vec<&str> = content.split_inclusive('\n').collect();
let start = line
.map(|l| (l as usize).saturating_sub(1))
.unwrap_or(0)
.min(lines.len());
let end = limit
.map(|l| start + l as usize)
.unwrap_or(lines.len())
.min(lines.len());
lines[start..end].concat()
}
pub fn resolve_path(path: &str, working_dir: Option<&Path>) -> PathBuf {
let path = PathBuf::from(path);
if path.is_absolute() {
path
@@ -231,6 +284,7 @@ mod tests {
use rmcp::model::RawContent;
use std::fs;
use tempfile::TempDir;
use test_case::test_case;
fn setup() -> TempDir {
tempfile::tempdir().unwrap()
@@ -243,6 +297,58 @@ mod tests {
}
}
#[test_case(None, None, "line1\nline2\nline3" ; "full content")]
#[test_case(Some(2), None, "line2\nline3" ; "from line 2")]
#[test_case(None, Some(2), "line1\nline2\n" ; "limit 2")]
#[test_case(Some(2), Some(1), "line2\n" ; "line 2 limit 1")]
#[test_case(Some(99), None, "" ; "beyond eof")]
fn test_apply_line_limit(line: Option<u32>, limit: Option<u32>, expected: &str) {
assert_eq!(
apply_line_limit("line1\nline2\nline3", line, limit),
expected
);
}
#[test]
fn test_file_read() {
let dir = setup();
let path = dir.path().join("read.txt");
fs::write(&path, "line1\nline2\nline3").unwrap();
let tools = EditTools::new();
let result = tools.file_read_with_cwd(
FileReadParams {
path: path.to_string_lossy().to_string(),
line: None,
limit: None,
},
None,
);
assert!(!result.is_error.unwrap_or(false));
assert_eq!(extract_text(&result), "line1\nline2\nline3");
}
#[test]
fn test_file_read_partial() {
let dir = setup();
let path = dir.path().join("read.txt");
fs::write(&path, "line1\nline2\nline3").unwrap();
let tools = EditTools::new();
let result = tools.file_read_with_cwd(
FileReadParams {
path: path.to_string_lossy().to_string(),
line: Some(2),
limit: Some(1),
},
None,
);
assert!(!result.is_error.unwrap_or(false));
assert_eq!(extract_text(&result), "line2\n");
}
#[test]
fn test_file_write_new() {
let dir = setup();
@@ -6,7 +6,7 @@ use crate::agents::extension::PlatformExtensionContext;
use crate::agents::mcp_client::{Error, McpClientTrait};
use anyhow::Result;
use async_trait::async_trait;
use edit::{EditTools, FileEditParams, FileWriteParams};
use edit::{EditTools, FileEditParams, FileReadParams, FileWriteParams};
use indoc::indoc;
use rmcp::model::{
CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult,
@@ -65,7 +65,7 @@ impl DeveloperClient {
.clone()
}
fn parse_args<T: serde::de::DeserializeOwned>(
pub fn parse_args<T: serde::de::DeserializeOwned>(
arguments: Option<JsonObject>,
) -> Result<T, String> {
let value = arguments
@@ -74,7 +74,7 @@ impl DeveloperClient {
serde_json::from_value(value).map_err(|e| format!("Failed to parse arguments: {e}"))
}
fn get_tools() -> Vec<Tool> {
pub(crate) fn get_tools() -> Vec<Tool> {
vec![
Tool::new(
"write".to_string(),
@@ -154,6 +154,13 @@ impl McpClientTrait for DeveloperClient {
) -> Result<CallToolResult, Error> {
let working_dir = working_dir.map(Path::new);
match name {
"read" => match Self::parse_args::<FileReadParams>(arguments) {
Ok(params) => Ok(self.edit_tools.file_read_with_cwd(params, working_dir)),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {error}"
))
.with_priority(0.0)])),
},
"shell" => match Self::parse_args::<ShellParams>(arguments) {
Ok(params) => Ok(self.shell_tool.shell_with_cwd(params, working_dir).await),
Err(error) => Ok(ShellTool::error_result(&format!("Error: {error}"), None)),