Skip to content

Commit c2ca0f8

Browse files
authored
chore(tools, mcp): Improve error handling and output formatting (#373)
Refactor internal maintenance tools to use a consistent `Outcome` type for errors and successes. This allows for better structured output when tools fail or require user input. Improve the user experience in the CLI by updating MCP tool definitions to support inline result rendering and custom parameter formatting. Specific tools like `fs_create_file` now provide a preview of the content to be created with syntax highlighting. Adjust `cargo_test` to only use one `--cargo-quiet` flag, ensuring that compilation errors are still visible to the LLM when tests fail to run. Signed-off-by: Jean Mertz <[email protected]>
1 parent a631efe commit c2ca0f8

File tree

12 files changed

+126
-69
lines changed

12 files changed

+126
-69
lines changed

.config/jp/tools/src/cargo/test.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ pub(crate) async fn cargo_test(
2929
"nextest",
3030
"run",
3131
package,
32-
// Twice to silence Cargo completely.
33-
"--cargo-quiet",
32+
// Once to still print any compilation errors.
3433
"--cargo-quiet",
3534
// Run all tests, even if one fails.
3635
"--no-fail-fast",
@@ -92,7 +91,8 @@ pub(crate) async fn cargo_test(
9291

9392
if ran_tests == 0 {
9493
return Err(format!(
95-
"Unable to find any tests. Are the package and test name correct?\n\n{stderr}"
94+
"Unable to run any tests. This can be due to compilation issues, or incorrect package \
95+
or test name:\n\n{stderr}"
9696
))?;
9797
}
9898

.config/jp/tools/src/fs.rs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@ pub async fn run(ctx: Context, t: Tool) -> std::result::Result<Outcome, Error> {
2222
.and_then(to_xml)
2323
.map(Into::into),
2424

25-
"read_file" => fs_read_file(
26-
ctx.root,
27-
t.req("path")?,
28-
t.opt("start_line")?,
29-
t.opt("end_line")?,
30-
)
31-
.await
32-
.map(Into::into),
25+
"read_file" => {
26+
fs_read_file(
27+
ctx.root,
28+
t.req("path")?,
29+
t.opt("start_line")?,
30+
t.opt("end_line")?,
31+
)
32+
.await
33+
}
3334

3435
"grep_files" => fs_grep_files(
3536
ctx.root,
@@ -49,9 +50,7 @@ pub async fn run(ctx: Context, t: Tool) -> std::result::Result<Outcome, Error> {
4950
.await
5051
.map(Into::into),
5152

52-
"create_file" => {
53-
fs_create_file(ctx.root, &t.answers, t.req("path")?, t.opt("content")?).await
54-
}
53+
"create_file" => fs_create_file(ctx, &t.answers, t.req("path")?, t.opt("content")?).await,
5554

5655
"delete_file" => fs_delete_file(ctx.root, &t.answers, t.req("path")?).await,
5756

.config/jp/tools/src/fs/create_file.rs

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,63 @@ use std::{
77
use jp_tool::{AnswerType, Outcome, Question};
88
use serde_json::{Map, Value};
99

10-
use crate::Error;
10+
use crate::{
11+
Context,
12+
util::{ToolResult, error, fail},
13+
};
1114

1215
pub(crate) async fn fs_create_file(
13-
root: PathBuf,
16+
ctx: Context,
1417
answers: &Map<String, Value>,
1518
path: String,
1619
content: Option<String>,
17-
) -> std::result::Result<Outcome, Error> {
20+
) -> ToolResult {
21+
if ctx.format_parameters {
22+
let lang = match path.split('.').next_back().unwrap_or_default() {
23+
"rs" => "rust",
24+
"js" => "javascript",
25+
"ts" => "typescript",
26+
"c" => "c",
27+
"cpp" => "cpp",
28+
"go" => "go",
29+
"php" => "php",
30+
"py" => "python",
31+
"rb" => "ruby",
32+
lang => lang,
33+
};
34+
35+
let mut response = format!("Create file '{path}'");
36+
if let Some(content) = content {
37+
response.push_str(&format!(" with content:\n\n```{lang}\n{content}\n```"));
38+
}
39+
40+
return Ok(response.into());
41+
}
42+
1843
let p = PathBuf::from(&path);
1944

2045
if p.is_absolute() {
21-
return Err("Path must be relative.".into());
46+
return error("Path must be relative.");
2247
}
2348

2449
if p.iter().any(|c| c.len() > 30) {
25-
return Err("Individual path components must be less than 30 characters long.".into());
50+
return error("Individual path components must be less than 30 characters long.");
2651
}
2752

2853
if p.iter().count() > 20 {
29-
return Err("Path must be less than 20 components long.".into());
54+
return error("Path must be less than 20 components long.");
3055
}
3156

32-
let absolute_path = root.join(path.trim_start_matches('/'));
57+
let absolute_path = ctx.root.join(path.trim_start_matches('/'));
3358
if absolute_path.is_dir() {
34-
return Err("Path is an existing directory.".into());
59+
return error("Path is an existing directory.");
3560
}
3661

3762
if absolute_path.exists() {
3863
match answers.get("overwrite_file").and_then(Value::as_bool) {
3964
Some(true) => {}
4065
Some(false) => {
41-
return Err("Path points to existing file".into());
66+
return error("Path points to existing file");
4267
}
4368
None => {
4469
return Ok(Outcome::NeedsInput {
@@ -54,7 +79,7 @@ pub(crate) async fn fs_create_file(
5479
}
5580

5681
let Some(parent) = absolute_path.parent() else {
57-
return Err("Path has no parent".into());
82+
return fail("Path has no parent");
5883
};
5984

6085
fs::create_dir_all(parent)?;

.config/jp/tools/src/fs/modify_file.rs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ use serde_json::{Map, Value};
1818
use similar::{ChangeTag, TextDiff, udiff::UnifiedDiff};
1919

2020
use super::utils::is_file_dirty;
21-
use crate::{Context, Error};
21+
use crate::{
22+
Context, Error,
23+
util::{ToolResult, error, fail},
24+
};
2225

2326
pub struct Change {
2427
pub path: PathBuf,
@@ -112,23 +115,23 @@ pub(crate) async fn fs_modify_file(
112115
string_to_replace: String,
113116
new_string: String,
114117
replace_using_regex: bool,
115-
) -> std::result::Result<Outcome, Error> {
118+
) -> ToolResult {
116119
if string_to_replace == new_string {
117-
return Err("String to replace is the same as the new string.".into());
120+
return error("String to replace is the same as the new string.");
118121
}
119122

120123
let p = PathBuf::from(&path);
121124

122125
if p.is_absolute() {
123-
return Err("Path must be relative.".into());
126+
return error("Path must be relative.");
124127
}
125128

126129
if p.iter().any(|c| c.len() > 30) {
127-
return Err("Individual path components must be less than 30 characters long.".into());
130+
return error("Individual path components must be less than 30 characters long.");
128131
}
129132

130133
if p.iter().count() > 20 {
131-
return Err("Path must be less than 20 components long.".into());
134+
return error("Path must be less than 20 components long.");
132135
}
133136

134137
let absolute_path = ctx.root.join(path.trim_start_matches('/'));
@@ -137,15 +140,15 @@ pub(crate) async fn fs_modify_file(
137140
for entry in glob::glob(&absolute_path.to_string_lossy())? {
138141
let entry = entry?;
139142
if !entry.exists() {
140-
return Err("File does not exist.".into());
143+
return error("File does not exist.");
141144
}
142145

143146
if !entry.is_file() {
144-
return Err("Path is not a regular file.".into());
147+
return error("Path is not a regular file.");
145148
}
146149

147150
let Ok(path) = entry.strip_prefix(&ctx.root) else {
148-
return Err("Path is not within workspace root.".into());
151+
return fail("Path is not within workspace root.");
149152
};
150153

151154
let before = fs::read_to_string(&entry)?;
@@ -190,23 +193,23 @@ pub(crate) async fn fs_modify_file(
190193
}
191194

192195
if ctx.format_parameters {
193-
Ok(format_changes(changes, &ctx.root).into())
196+
Ok(format_changes(changes).into())
194197
} else {
195198
apply_changes(changes, &ctx.root, answers)
196199
}
197200
}
198201

199-
fn format_changes(changes: Vec<Change>, root: &Path) -> String {
202+
fn format_changes(changes: Vec<Change>) -> String {
200203
changes
201204
.into_iter()
202205
.map(|change| {
203-
let path = root.join(change.path.to_string_lossy().trim_start_matches('/'));
206+
let path = change.path.to_string_lossy();
204207

205208
let diff = text_diff(&change.before, &change.after);
206-
let unified = unified_diff(&diff, &path.display().to_string());
209+
let unified = unified_diff(&diff, &path);
207210
let colored = colored_diff(&diff, &unified);
208211

209-
format!("{}:\n\n```diff\n{colored}\n```", path.display())
212+
format!("{path}:\n\n```diff\n{colored}\n```")
210213
})
211214
.collect::<Vec<_>>()
212215
.join("\n\n")

.config/jp/tools/src/fs/read_file.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
use std::{ffi::OsStr, path::PathBuf};
22

3-
use crate::Error;
3+
use crate::util::{ToolResult, error};
44

55
pub(crate) async fn fs_read_file(
66
root: PathBuf,
77
path: String,
88
start_line: Option<usize>,
99
end_line: Option<usize>,
10-
) -> std::result::Result<String, Error> {
10+
) -> ToolResult {
1111
let absolute_path = root.join(path.trim_start_matches('/'));
1212
if !absolute_path.exists() {
13-
return Err("File not found.".into());
13+
return error("File not found.");
1414
} else if !absolute_path.is_file() {
15-
return Err("Path is not a file.".into());
15+
return error("Path is not a file.");
1616
}
1717

1818
let ext = absolute_path
@@ -36,5 +36,6 @@ pub(crate) async fn fs_read_file(
3636
```{ext}
3737
{contents}
3838
```
39-
"})
39+
"}
40+
.into())
4041
}

.config/jp/tools/src/main.rs

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ async fn main() {
1616
Err(error) => return println!("{error}"),
1717
};
1818

19-
let format_parameters = context.format_parameters;
2019
let name = tool.name.clone();
21-
match run(context, tool).await {
22-
Ok(Outcome::Success { content }) if format_parameters => println!("{content}"),
23-
Ok(outcome) => match serde_json::to_string(&outcome) {
24-
Ok(content) => println!("{content}"),
25-
Err(error) => handle_error(&error, &name),
26-
},
27-
Err(error) => handle_error(error.as_ref(), &name),
28-
}
20+
let result = run(context, tool)
21+
.await
22+
.unwrap_or_else(|error| error_outcome(error.as_ref(), &name));
23+
24+
let json = serde_json::to_string(&result).unwrap_or_else(|error| {
25+
format!(r#"{{"type":"error","message":"Unable to serialize result: {error}","trace":[],"transient":false}}"#)
26+
});
27+
28+
println!("{json}");
2929
}
3030

3131
fn input<T: serde::de::DeserializeOwned>(index: usize, name: &str) -> Result<T, String> {
@@ -45,19 +45,17 @@ fn input<T: serde::de::DeserializeOwned>(index: usize, name: &str) -> Result<T,
4545
.map_err(|error| format!("```json\n{error:#}\n```"))
4646
}
4747

48-
fn handle_error(error: &dyn std::error::Error, name: &str) {
49-
let mut sources = vec![];
48+
fn error_outcome(error: &dyn std::error::Error, name: &str) -> Outcome {
49+
let mut trace = vec![];
5050
let mut source = Some(error);
5151
while let Some(error) = source {
52-
sources.push(format!("{error:#}"));
52+
trace.push(format!("{error:#}"));
5353
source = error.source();
5454
}
5555

56-
println!(
57-
"```json\n{:#}\n```",
58-
json!({
59-
"error": format!("An error occurred while running the '{name}' tool."),
60-
"trace": sources,
61-
})
62-
);
56+
Outcome::Error {
57+
message: format!("An error occurred while running the '{name}' tool."),
58+
trace,
59+
transient: true,
60+
}
6361
}

.config/jp/tools/src/util.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1+
use jp_tool::Outcome;
12
use serde::{Deserialize, Serialize};
23

4+
pub type ToolResult = std::result::Result<Outcome, Box<dyn std::error::Error + Send + Sync>>;
5+
6+
#[expect(clippy::unnecessary_wraps)]
7+
pub fn error(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> ToolResult {
8+
Ok(Outcome::error(error.into().as_ref()))
9+
}
10+
11+
#[expect(clippy::unnecessary_wraps)]
12+
pub fn fail(error: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> ToolResult {
13+
Ok(Outcome::fail(error.into().as_ref()))
14+
}
15+
316
#[derive(Serialize, Deserialize)]
417
#[serde(untagged)]
518
pub enum OneOrMany<T> {

.jp/mcp/tools/cargo/check.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
[conversation.tools.cargo_check]
22
enable = false
33
run = "unattended"
4-
style.inline_results = "off"
5-
64
source = "local"
75
command = "just serve-tools {{context}} {{tool}}"
86
description = "Run `cargo check` for the given package, validating if the code compiles."
97

8+
[conversation.tools.cargo_check.style]
9+
inline_results = "full"
10+
results_file_link = "off"
11+
parameters = "function_call"
12+
1013
[conversation.tools.cargo_check.parameters.package]
1114
description = "Package to run check for, if unspecified, all workspace packages will be checked."
1215
type = "string"

.jp/mcp/tools/cargo/test.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
[conversation.tools.cargo_test]
22
enable = false
33
run = "unattended"
4-
style.inline_results = "off"
5-
64
source = "local"
75
command = "just serve-tools {{context}} {{tool}}"
86
description = "Execute all unit and integration tests and build examples of the project."
97

8+
[conversation.tools.cargo_test.style]
9+
inline_results = "full"
10+
results_file_link = "off"
11+
parameters = "function_call"
12+
1013
[conversation.tools.cargo_test.parameters.package]
1114
description = "Package to run tests for, if unspecified, all workspace packages will be tested."
1215
type = "string"

.jp/mcp/tools/fs/create_file.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ description = """
66
Create a new file in the project's local filesystem.
77
"""
88

9+
[conversation.tools.fs_create_file.style]
10+
parameters = "just serve-tools {{context}} {{tool}}"
11+
inline_results = "off"
12+
results_file_link = "off"
13+
914
[conversation.tools.fs_create_file.parameters.path]
1015
type = "string"
1116
required = true

0 commit comments

Comments
 (0)