Compare commits

...
Author SHA1 Message Date
Riz Ashraf f56750f596 refactor: bulk AST replacement and fault-tolerant graph handlers 2026-10-10 19:05:03 +01:00
Riz Ashraf 3ee95f5c39 Format massive JSON arrays into Markdown natively to avoid context blowouts 2026-10-10 18:45:00 +01:00
Riz Ashraf 760ebfcd40 perf: auto-populate git context natively to avoid token waste
- Added get_git_context() to utils.rs using git2 to natively fetch branch, commit, and repo_name without CLI overhead.
- Wired native auto-population into LogCodeChangeHandler, LogErrorFixHandler, TechDebtHandler, TasksHandler, MilestonesHandler, AutoSessionCheckpointHandler, and HandoffMemosHandler.
- Eliminates the need for the LLM to waste repetitive background turns running git rev-parse HEAD and git branch --show-current via terminal shell commands just to feed the MCP logging tools.
2026-10-10 18:32:02 +01:00
Riz Ashraf 11f375017a feat: add ignore_ssl_errors to FetchUrlTool 2026-10-10 18:00:38 +01:00
Riz Ashraf 5583132cdc feat: add FetchUrlTool with proxy support and cross-OS path sanitization
- Implement native FetchUrlTool using reqwest for HTTP fetching, which respects HTTP_PROXY, HTTPS_PROXY, and NO_PROXY natively.
- Introduce sanitize_path in utils to bidirectionally handle Windows/WSL path mapping, applying it to various search/git handlers.
- Update .gitignore to exclude mcp_store.redb and tantivy_index.
2026-10-10 17:51:56 +01:00
Riz Ashraf 250b70d329 chore: strictly deny unused imports workspace-wide
- Added `unused_imports = "deny"` to workspace lints in Cargo.toml
- Opted-in all crates to inherit workspace lints
- Fixed resulting unused imports in `dump_schema.rs`
2026-10-10 14:30:49 +01:00
Riz Ashraf 9546ca4c04 test: update assertions to match markdown refactor of GetProjectHealthTool 2026-10-10 13:42:34 +01:00
Riz Ashraf b5f1da3c0e refactor: optimize LLM context window by returning curated Markdown instead of raw YAML dumps for query tools 2026-10-10 13:36:35 +01:00
Riz Ashraf de060c4db4 fix: restore AppError::Internal for AST parser error, keeping BadRequest for JSON schema validation 2026-10-10 13:10:15 +01:00
Riz Ashraf 39528411f4 fix(graph): improve LLM tool UX for GetSubgraphTool and ReadGraphTool 2026-10-10 12:50:07 +01:00
22 changed files with 842 additions and 360 deletions

No files matched your search

+2
View File
@@ -10,3 +10,5 @@ temp.json
*.txt
summary.md
__pycache__/
server/mcp_store.redb
server/tantivy_index/
+4
View File
@@ -6,3 +6,7 @@ members = [
"mcp-stdio"
]
resolver = "2"
[workspace.lints.rust]
unused_imports = "deny"
+3
View File
@@ -8,3 +8,6 @@ tokio = { version = "1.53.1", features = ["io-util", "macros"] }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["rt", "macros"] }
[lints]
workspace = true
+3
View File
@@ -21,3 +21,6 @@ schemars = "0.8"
[build-dependencies]
chrono = "0.4.45"
[lints]
workspace = true
+4 -1
View File
@@ -14,7 +14,7 @@ clap = { version = "4.6.6", features = ["derive"] }
dirs = "7.0.0"
futures-util = "0.3.34"
redb = "4.3.0"
reqwest = { version = "0.13", default-features = false, features = ["stream", "json"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls"] }
schemars = "1.2.2"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
@@ -76,3 +76,6 @@ windows = { version = "0.62.2", features = [
"Graphics_Imaging",
"Storage_Streams",
] }
[lints]
workspace = true
+1 -1
View File
@@ -1,4 +1,4 @@
use mcp_memory_server::tools::{DecisionsTool, TechDebtTool, ManageCheckpointTool, HypothesesTool, AgentSignalsTool};
use mcp_memory_server::tools::AgentSignalsTool;
use schemars::schema_for;
fn main() {
+236 -169
View File
@@ -41,7 +41,7 @@ impl McpTool for ReadFileSkeletonHandler {
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ReadFileSkeletonTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let file_path = tool_args.file_path.clone();
validate_safe_path(&file_path)?;
@@ -202,169 +202,174 @@ impl McpTool for ReplaceAstNodeHandler {
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ReplaceAstNodeTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let file_path = tool_args.file_path.clone();
validate_safe_path(&file_path)?;
let replacements = tool_args.replacements;
if replacements.is_empty() {
return Ok("No replacements provided.".to_string());
}
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let code = std::fs::read_to_string(&file_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
})?;
let mut msgs = Vec::new();
for rep in replacements {
let file_path = rep.file_path.clone();
validate_safe_path(&file_path)?;
let mut parser = Parser::new();
let code = std::fs::read_to_string(&file_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to read file: {}", e))
})?;
let ext = std::path::Path::new(&file_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let mut parser = Parser::new();
let language = match ext {
"rs" => tree_sitter_rust::LANGUAGE,
"ts" | "tsx" | "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
"py" => tree_sitter_python::LANGUAGE,
"java" => tree_sitter_java::LANGUAGE,
"c" | "h" => tree_sitter_c::LANGUAGE,
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => tree_sitter_cpp::LANGUAGE,
"go" => tree_sitter_go::LANGUAGE,
_ => {
return Err(crate::error::AppError::Internal(format!(
"Unsupported language for AST replacement: {}",
ext
)));
let ext = std::path::Path::new(&file_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let language = match ext {
"rs" => tree_sitter_rust::LANGUAGE,
"ts" | "tsx" | "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
"py" => tree_sitter_python::LANGUAGE,
"java" => tree_sitter_java::LANGUAGE,
"c" | "h" => tree_sitter_c::LANGUAGE,
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => tree_sitter_cpp::LANGUAGE,
"go" => tree_sitter_go::LANGUAGE,
_ => {
return Err(crate::error::AppError::Internal(format!(
"Unsupported language for AST replacement: {}",
ext
)));
}
};
parser
.set_language(&language.into())
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
let tree = parser.parse(&code, None).ok_or_else(|| {
crate::error::AppError::Internal("Failed to parse code".to_string())
})?;
fn matches_node_type(actual_kind: &str, requested_type: &str) -> bool {
if actual_kind == requested_type {
return true;
}
match requested_type.to_lowercase().as_str() {
"function" | "func" | "fn" | "method" | "def" => matches!(
actual_kind,
"function_item"
| "function_declaration"
| "function_definition"
| "method_definition"
| "function"
),
"struct" => matches!(
actual_kind,
"struct_item" | "struct_declaration" | "struct_specifier"
),
"class" => matches!(
actual_kind,
"class_declaration" | "class_definition" | "class_item"
),
"enum" => matches!(actual_kind, "enum_item" | "enum_declaration"),
"trait" | "interface" => matches!(
actual_kind,
"trait_item" | "interface_declaration" | "interface_item"
),
"impl" | "impl_item" => actual_kind == "impl_item",
"type" | "type_alias" => matches!(
actual_kind,
"type_alias_declaration" | "type_item" | "type_definition"
),
_ => false,
}
}
};
parser
.set_language(&language.into())
.map_err(|e| e.to_string())?;
let tree = parser.parse(&code, None).ok_or_else(|| {
crate::error::AppError::Internal("Failed to parse code".to_string())
})?;
fn matches_node_type(actual_kind: &str, requested_type: &str) -> bool {
if actual_kind == requested_type {
return true;
}
match requested_type.to_lowercase().as_str() {
"function" | "func" | "fn" | "method" | "def" => matches!(
actual_kind,
"function_item"
| "function_declaration"
| "function_definition"
| "method_definition"
| "function"
),
"struct" => matches!(
actual_kind,
"struct_item" | "struct_declaration" | "struct_specifier"
),
"class" => matches!(
actual_kind,
"class_declaration" | "class_definition" | "class_item"
),
"enum" => matches!(actual_kind, "enum_item" | "enum_declaration"),
"trait" | "interface" => matches!(
actual_kind,
"trait_item" | "interface_declaration" | "interface_item"
),
"impl" | "impl_item" => actual_kind == "impl_item",
"type" | "type_alias" => matches!(
actual_kind,
"type_alias_declaration" | "type_item" | "type_definition"
),
_ => false,
}
}
// Search for the node
fn find_node<'a>(
node: Node<'a>,
code: &str,
target_type: &str,
target_name: &str,
) -> Option<Node<'a>> {
if matches_node_type(node.kind(), target_type) {
// Try to find the name/identifier
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
if kind == "identifier"
|| kind == "name"
|| kind == "type_identifier"
|| kind == "property_identifier"
|| kind == "field_identifier"
{
let name = child.utf8_text(code.as_bytes()).unwrap_or("");
if name == target_name {
return Some(node);
fn find_node<'a>(
node: Node<'a>,
code: &str,
target_type: &str,
target_name: &str,
) -> Option<Node<'a>> {
if matches_node_type(node.kind(), target_type) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
if kind == "identifier"
|| kind == "name"
|| kind == "type_identifier"
|| kind == "property_identifier"
|| kind == "field_identifier"
{
let name = child.utf8_text(code.as_bytes()).unwrap_or("");
if name == target_name {
return Some(node);
}
}
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_node(child, code, target_type, target_name) {
return Some(found);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_node(child, code, target_type, target_name) {
return Some(found);
}
}
}
None
}
let target_node = find_node(
tree.root_node(),
&code,
&tool_args.node_type,
&tool_args.node_name,
);
if let Some(node) = target_node {
let start_byte = node.start_byte();
let end_byte = node.end_byte();
if !code.is_char_boundary(start_byte) || !code.is_char_boundary(end_byte) {
return Err(crate::error::AppError::Internal(format!(
"Byte offsets {}..{} do not fall on UTF-8 character boundaries in {}",
start_byte, end_byte, file_path
)));
None
}
let mut new_file_content =
String::with_capacity(code.len() + tool_args.new_content.len());
new_file_content.push_str(&code[..start_byte]);
new_file_content.push_str(&tool_args.new_content);
new_file_content.push_str(&code[end_byte..]);
let target_node = find_node(
tree.root_node(),
&code,
&rep.node_type,
&rep.node_name,
);
let target_path = std::path::PathBuf::from(&file_path);
let parent_dir = target_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let temp_file_path =
parent_dir.join(format!(".tmp_ast_{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&temp_file_path, new_file_content).map_err(|e| e.to_string())?;
if let Err(e) = std::fs::rename(&temp_file_path, &target_path) {
// On Windows, std::fs::rename fails if the target file already exists.
// Fall back to copy-and-remove to ensure atomic-like overwrite behavior.
if let Err(copy_err) = std::fs::copy(&temp_file_path, &target_path) {
let _ = std::fs::remove_file(&temp_file_path);
if let Some(node) = target_node {
let start_byte = node.start_byte();
let end_byte = node.end_byte();
if !code.is_char_boundary(start_byte) || !code.is_char_boundary(end_byte) {
return Err(crate::error::AppError::Internal(format!(
"Failed to atomically overwrite {}: rename failed ({}), copy failed ({})",
file_path, e, copy_err
"Byte offsets {}..{} do not fall on UTF-8 character boundaries in {}",
start_byte, end_byte, file_path
)));
}
let _ = std::fs::remove_file(&temp_file_path);
let mut new_file_content =
String::with_capacity(code.len() + rep.new_content.len());
new_file_content.push_str(&code[..start_byte]);
new_file_content.push_str(&rep.new_content);
new_file_content.push_str(&code[end_byte..]);
let target_path = std::path::PathBuf::from(&file_path);
let parent_dir = target_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
let temp_file_path =
parent_dir.join(format!(".tmp_ast_{}.tmp", uuid::Uuid::new_v4()));
std::fs::write(&temp_file_path, new_file_content).map_err(|e| crate::error::AppError::Internal(format!("Failed to write temporary file: {}", e)))?;
if let Err(e) = std::fs::rename(&temp_file_path, &target_path) {
if let Err(copy_err) = std::fs::copy(&temp_file_path, &target_path) {
let _ = std::fs::remove_file(&temp_file_path);
return Err(crate::error::AppError::Internal(format!(
"Failed to atomically overwrite {}: rename failed ({}), copy failed ({})",
file_path, e, copy_err
)));
}
let _ = std::fs::remove_file(&temp_file_path);
}
msgs.push(format!(
"Successfully replaced node {} of type {} in {}",
rep.node_name, rep.node_type, file_path
));
} else {
return Err(crate::error::AppError::Internal(format!(
"Could not find node {} of type {} in {}",
rep.node_name, rep.node_type, file_path
)));
}
Ok(format!(
"Successfully replaced node {} of type {} in {}",
tool_args.node_name, tool_args.node_type, file_path
))
} else {
Err(crate::error::AppError::Internal(format!(
"Could not find node {} of type {}",
tool_args.node_name, tool_args.node_type
)))
}
Ok(msgs.join("\n"))
})
.await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
@@ -440,7 +445,7 @@ fn scan_workspace_for_symbol(
if is_match {
results.push(serde_json::json!({
"file_path": path.to_string_lossy(),
"file_path": crate::handlers::utils::sanitize_path(&path.to_string_lossy()),
"line": line_num + 1,
"content": line.trim(),
}));
@@ -473,7 +478,7 @@ impl McpTool for FindSymbolReferencesHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::FindSymbolReferencesTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let limit = req.limit.unwrap_or(10);
let target_sym = req.symbol.clone();
@@ -498,15 +503,37 @@ impl McpTool for FindSymbolReferencesHandler {
if matches.len() < limit {
let remaining = limit - matches.len();
let target_sym_clone = target_sym.clone();
let disk_matches = tokio::task::spawn_blocking(move || {
scan_workspace_for_symbol(&target_sym, remaining, false, custom_dir)
scan_workspace_for_symbol(&target_sym_clone, remaining, false, custom_dir)
})
.await
.unwrap_or_default();
matches.extend(disk_matches);
}
Ok(serde_yaml::to_string(&matches)?)
let mut out = String::new();
out.push_str(&format!("## Symbol References for `{}`\n\n", target_sym));
for match_item in &matches {
if let Some(source) = match_item.get("source").and_then(|s| s.as_str()) {
if source == "snippet" {
let name = match_item.get("name").and_then(|n| n.as_str()).unwrap_or("Unknown");
let code = match_item.get("code").and_then(|c| c.as_str()).unwrap_or("");
out.push_str(&format!("### Snippet: {}\n```rust\n{}\n```\n\n", name, code));
}
} else {
let file = match_item.get("file_path").and_then(|f| f.as_str()).unwrap_or("Unknown");
let line = match_item.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
let content = match_item.get("content").and_then(|c| c.as_str()).unwrap_or("");
out.push_str(&format!("- `{}:{}`: `{}`\n", file, line, content));
}
}
if matches.is_empty() {
out.push_str("No references found.\n");
}
Ok(out)
}
}
@@ -527,7 +554,7 @@ impl McpTool for GetCallersHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::GetCallersTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let limit = req.limit.unwrap_or(10);
let target_fn = req.function_name.clone();
let call_pattern = format!("{}(", target_fn);
@@ -554,15 +581,37 @@ impl McpTool for GetCallersHandler {
if callers.len() < limit {
let remaining = limit - callers.len();
let target_fn_clone = target_fn.clone();
let disk_callers = tokio::task::spawn_blocking(move || {
scan_workspace_for_symbol(&target_fn, remaining, true, custom_dir)
scan_workspace_for_symbol(&target_fn_clone, remaining, true, custom_dir)
})
.await
.unwrap_or_default();
callers.extend(disk_callers);
}
Ok(serde_yaml::to_string(&callers)?)
let mut out = String::new();
out.push_str(&format!("## Callers for `{}`\n\n", target_fn));
for caller in &callers {
if let Some(source) = caller.get("source").and_then(|s| s.as_str()) {
if source == "snippet" {
let name = caller.get("name").and_then(|n| n.as_str()).unwrap_or("Unknown");
let code = caller.get("code").and_then(|c| c.as_str()).unwrap_or("");
out.push_str(&format!("### Snippet: {}\n```rust\n{}\n```\n\n", name, code));
}
} else {
let file = caller.get("file_path").and_then(|f| f.as_str()).unwrap_or("Unknown");
let line = caller.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
let content = caller.get("content").and_then(|c| c.as_str()).unwrap_or("");
out.push_str(&format!("- `{}:{}`: `{}`\n", file, line, content));
}
}
if callers.is_empty() {
out.push_str("No callers found.\n");
}
Ok(out)
}
}
@@ -583,7 +632,7 @@ impl McpTool for AnalyzeImpactHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::AnalyzeImpactTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let sym = req.target_symbol.clone();
let mut callers = Vec::new();
@@ -637,21 +686,35 @@ impl McpTool for AnalyzeImpactHandler {
"LOW"
};
let result = serde_json::json!({
"target_symbol": sym,
"risk_level": risk_level,
"ast_callers_count": caller_count,
"ast_callers_sample": callers.into_iter().take(10).collect::<Vec<_>>(),
"graph_relations_count": graph_count,
"graph_relations": kg_connected,
"recommendation": match risk_level {
"CRITICAL" | "HIGH" => "Requires comprehensive unit test verification and backwards compatibility checks before modifying.",
"MEDIUM" => "Verify direct call sites and run affected module tests.",
_ => "Safe to modify with standard unit test verification.",
}
});
let mut out = String::new();
out.push_str(&format!("## Impact Analysis for `{}`\n\n", sym));
out.push_str(&format!("**Risk Level:** {}\n\n", risk_level));
let rec = match risk_level {
"CRITICAL" | "HIGH" => "Requires comprehensive unit test verification and backwards compatibility checks before modifying.",
"MEDIUM" => "Verify direct call sites and run affected module tests.",
_ => "Safe to modify with standard unit test verification.",
};
out.push_str(&format!("**Recommendation:** {}\n\n", rec));
out.push_str(&format!("### AST Callers ({} total, showing up to 10)\n", caller_count));
for c in callers.into_iter().take(10) {
out.push_str(&format!("- `{}`\n", c));
}
if caller_count == 0 {
out.push_str("No callers found.\n");
}
out.push_str("\n");
out.push_str(&format!("### Graph Relations ({})\n", graph_count));
for g in kg_connected {
out.push_str(&format!("- {}\n", g));
}
if graph_count == 0 {
out.push_str("No graph relations found.\n");
}
Ok(serde_yaml::to_string(&result)?)
Ok(out)
}
}
@@ -691,10 +754,14 @@ mod tests {
let handler = ReplaceAstNodeHandler;
let args = json!({
"file_path": file_path.to_str().unwrap(),
"node_type": "function_item",
"node_name": "my_func",
"new_content": "fn my_func() {\n let x = 2;\n}"
"replacements": [
{
"file_path": file_path.to_str().unwrap(),
"node_type": "function_item",
"node_name": "my_func",
"new_content": "fn my_func() {\n let x = 2;\n}"
}
]
});
let res = handler.execute(args, state.clone()).await.unwrap();
+1 -1
View File
@@ -21,7 +21,7 @@ impl McpTool for EnvironmentHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: EnvironmentTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: EnvironmentTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let ns = req
.namespace
.unwrap_or_else(crate::models::default_namespace);
+54 -18
View File
@@ -27,8 +27,8 @@ impl McpTool for GetActiveWorktreeContextHandler {
_state: Arc<MemoryState>,
) -> crate::error::Result<String> {
let result =
tokio::task::spawn_blocking(move || -> crate::error::Result<serde_json::Value> {
let cwd = env::current_dir().map_err(|e| e.to_string())?;
tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
let cwd = env::current_dir().map_err(|e| crate::error::AppError::Internal(format!("Failed to get current directory: {}", e)))?;
let repo = git2::Repository::discover(&cwd).map_err(|e| {
crate::error::AppError::Internal(format!("Not in a git repository: {}", e))
})?;
@@ -66,7 +66,8 @@ impl McpTool for GetActiveWorktreeContextHandler {
for delta in diff.deltas() {
if let Some(path) = delta.new_file().path() {
files.push(path.to_string_lossy().into_owned());
let path_str = path.to_string_lossy().into_owned();
files.push(crate::handlers::utils::sanitize_path(&path_str));
}
}
}
@@ -78,16 +79,34 @@ impl McpTool for GetActiveWorktreeContextHandler {
diff_output.push_str("\n... [Diff truncated due to size]");
}
Ok(json!({
"branch": branch_name,
"modified_files": files,
"diff": diff_output
}))
let mut out = String::new();
out.push_str("## Active Worktree Context\n\n");
out.push_str(&format!("**Branch:** `{}`\n\n", branch_name));
out.push_str("### Modified Files\n");
if files.is_empty() {
out.push_str("No modified files.\n");
} else {
for f in files {
out.push_str(&format!("- `{}`\n", f));
}
}
out.push_str("\n### Diff\n");
if diff_output.is_empty() {
out.push_str("No changes.\n");
} else {
out.push_str("```diff\n");
out.push_str(&diff_output);
out.push_str("\n```\n");
}
Ok(out)
})
.await
.map_err(|e| crate::error::AppError::Internal(format!("Task panic: {}", e)))??;
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&result)?)
Ok(result)
}
}
@@ -108,7 +127,7 @@ impl McpTool for QueryGitDiffsHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::QueryGitDiffsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let limit = req.limit.unwrap_or(5);
let q = req.query.to_lowercase();
@@ -121,7 +140,7 @@ impl McpTool for QueryGitDiffsHandler {
|| change.git_commit.as_ref().is_some_and(|c| c.contains(&q))
{
matches.push(json!({
"file_path": change.file_path,
"file_path": crate::handlers::utils::sanitize_path(&change.file_path),
"description": change.description,
"commit": change.git_commit,
"branch": change.git_branch,
@@ -169,7 +188,25 @@ impl McpTool for QueryGitDiffsHandler {
matches.extend(git_matches);
}
Ok(serde_yaml::to_string(&matches)?)
let mut out = String::new();
out.push_str(&format!("## Git Diffs for `{}`\n\n", req.query));
for m in &matches {
if let Some(file) = m.get("file_path").and_then(|f| f.as_str()) {
let desc = m.get("description").and_then(|d| d.as_str()).unwrap_or("");
let commit = m.get("commit").and_then(|c| c.as_str()).unwrap_or("");
out.push_str(&format!("- **File:** `{}` | **Commit:** `{}`\n - {}\n", file, commit, desc));
} else {
let commit = m.get("commit_id").and_then(|c| c.as_str()).unwrap_or("");
let author = m.get("author").and_then(|a| a.as_str()).unwrap_or("");
let msg = m.get("message").and_then(|m| m.as_str()).unwrap_or("");
out.push_str(&format!("- **Commit:** `{}` | **Author:** {}\n - {}\n", commit, author, msg));
}
}
if matches.is_empty() {
out.push_str("No git diffs found.\n");
}
Ok(out)
}
}
@@ -192,10 +229,10 @@ mod tests {
.map_err(|e| format!("Failed to get worktree context: {}", e))
.unwrap();
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
assert!(parsed.get("branch").is_some());
assert!(parsed.get("modified_files").is_some());
assert!(parsed.get("diff").is_some());
assert!(result.contains("## Active Worktree Context"));
assert!(result.contains("**Branch:**"));
assert!(result.contains("### Modified Files"));
assert!(result.contains("### Diff"));
}
#[tokio::test]
@@ -211,7 +248,6 @@ mod tests {
.map_err(|e| format!("Failed to get worktree context: {}", e))
.unwrap();
let parsed: serde_json::Value = serde_yaml::from_str(&result).unwrap();
assert!(parsed.get("branch").is_some() || parsed.is_object());
assert!(result.contains("## Active Worktree Context"));
}
}
+109 -74
View File
@@ -121,7 +121,7 @@ impl McpTool for QueryGraphPathHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: crate::tools::QueryGraphPathTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
tokio::task::spawn_blocking(move || {
state.read_graph(|graph| {
@@ -160,7 +160,7 @@ impl McpTool for CreateEntitiesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: CreateEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: CreateEntitiesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut inserted = Vec::new();
state.modify_graph(|g| {
for entity_input in req.entities {
@@ -209,7 +209,7 @@ impl McpTool for UpdateEntitiesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: UpdateEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: UpdateEntitiesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut updated_entities = Vec::new();
let mut deleted_names = Vec::new();
@@ -218,19 +218,25 @@ impl McpTool for UpdateEntitiesHandler {
state.modify_graph(|g| {
for update in req.updates {
if !g.entities.contains_key(&update.name) {
not_found.push(update.name.clone());
continue;
let mut target_name = update.name.clone();
if !g.entities.contains_key(&target_name) {
let lower_target = target_name.to_lowercase();
if let Some(matched_key) = g.entities.keys().find(|k| k.to_lowercase() == lower_target).cloned() {
target_name = matched_key;
} else {
not_found.push(update.name.clone());
continue;
}
}
if let Some(new_name) = &update.new_name {
if update.name != *new_name && g.entities.contains_key(new_name) {
if target_name != *new_name && g.entities.contains_key(new_name) {
conflict_names.push(new_name.clone());
continue;
}
}
if let Some(mut entity) = g.entities.remove(&update.name) {
if let Some(mut entity) = g.entities.remove(&target_name) {
let mut renamed = false;
let old_name = entity.name.clone();
@@ -269,18 +275,6 @@ impl McpTool for UpdateEntitiesHandler {
}
});
if !not_found.is_empty() {
return Err(crate::error::AppError::Internal(format!(
"Error: Entities not found: {}", not_found.join(", ")
)));
}
if !conflict_names.is_empty() {
return Err(crate::error::AppError::Internal(format!(
"Error: Cannot rename to existing entity names: {}", conflict_names.join(", ")
)));
}
let idx = state.get_search_index().await;
for old_name in deleted_names {
drop(idx.delete_document(&old_name));
@@ -291,7 +285,16 @@ impl McpTool for UpdateEntitiesHandler {
}
let names: Vec<String> = updated_entities.iter().map(|e| e.name.clone()).collect();
Ok(format!("Successfully updated {} entity/entities: {}", names.len(), names.join(", ")))
let mut msg = format!("Successfully updated {} entity/entities: {}", names.len(), names.join(", "));
if !not_found.is_empty() {
msg.push_str(&format!("\nNote: {} entities were not found and skipped: {}", not_found.len(), not_found.join(", ")));
}
if !conflict_names.is_empty() {
msg.push_str(&format!("\nNote: {} entity renames were skipped due to name conflicts: {}", conflict_names.len(), conflict_names.join(", ")));
}
Ok(msg)
}
}
@@ -414,7 +417,7 @@ impl McpTool for AddObservationsHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AddObservationsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: AddObservationsTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut missing_entities = Vec::new();
state.read_graph(|g| {
for o in &req.observations {
@@ -458,38 +461,44 @@ impl McpTool for DeleteEntitiesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: DeleteEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let to_delete: std::collections::HashSet<_> = req.entity_names.into_iter().collect();
let mut missing = Vec::new();
state.read_graph(|g| {
for name in &to_delete {
if !g.entities.contains_key(name) {
missing.push(name.clone());
}
}
});
if !missing.is_empty() {
return Err(crate::error::AppError::Internal(format!(
"Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.",
missing.join(", ")
)));
}
let req: DeleteEntitiesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut actual_deletes = Vec::new();
let mut not_found = Vec::new();
state.modify_graph(|master| {
for name in &to_delete {
for target in req.entity_names {
if master.entities.contains_key(&target) {
actual_deletes.push(target);
} else {
let lower_target = target.to_lowercase();
if let Some(matched_key) = master.entities.keys().find(|k| k.to_lowercase() == lower_target).cloned() {
actual_deletes.push(matched_key);
} else {
not_found.push(target);
}
}
}
for name in &actual_deletes {
master.entities.remove(name);
}
let delete_set: std::collections::HashSet<_> = actual_deletes.iter().cloned().collect();
master
.relations
.retain(|r| !to_delete.contains(&r.from) && !to_delete.contains(&r.to));
.retain(|r| !delete_set.contains(&r.from) && !delete_set.contains(&r.to));
});
let idx = state.get_search_index().await;
for name in to_delete {
drop(idx.delete_document(&name));
for name in &actual_deletes {
drop(idx.delete_document(name));
}
Ok("Entities deleted".to_string())
let mut msg = format!("Successfully deleted {} entities.", actual_deletes.len());
if !not_found.is_empty() {
msg.push_str(&format!(" Note: {} entities were not found and skipped: {}", not_found.len(), not_found.join(", ")));
}
Ok(msg)
}
}
@@ -510,7 +519,7 @@ impl McpTool for DeleteObservationsHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: DeleteObservationsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut missing_entities = Vec::new();
let mut missing_observations = Vec::new();
@@ -573,7 +582,7 @@ impl McpTool for DeleteRelationsHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: DeleteRelationsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: DeleteRelationsTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut deleted_count = 0;
let requested_count = req.relations.len();
@@ -581,7 +590,12 @@ impl McpTool for DeleteRelationsHandler {
let initial_len = master.relations.len();
master.relations.retain(|r| {
let should_delete = req.relations.iter().any(|target| {
target.from == r.from && target.to == r.to && target.relation_type == r.relation_type
if target.from == r.from && target.to == r.to && target.relation_type == r.relation_type {
return true;
}
target.from.eq_ignore_ascii_case(&r.from)
&& target.to.eq_ignore_ascii_case(&r.to)
&& target.relation_type.eq_ignore_ascii_case(&r.relation_type)
});
!should_delete
});
@@ -589,13 +603,11 @@ impl McpTool for DeleteRelationsHandler {
});
let missing_count = requested_count.saturating_sub(deleted_count);
let mut msg = format!("Successfully deleted {} relations.", deleted_count);
if missing_count > 0 {
return Err(crate::error::AppError::Internal(format!(
"Error: {} relation(s) not found in graph. Please verify exact relation properties (from, to, relation_type) using read_graph or get_subgraph.",
missing_count
)));
msg.push_str(&format!(" Note: {} relations were not found and skipped.", missing_count));
}
Ok("Relations deleted".to_string())
Ok(msg)
}
}
@@ -615,7 +627,7 @@ impl McpTool for ReadGraphHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let max_tokens = req.max_tokens;
let limit = req.limit;
let offset = req.offset.unwrap_or(0);
@@ -625,6 +637,7 @@ impl McpTool for ReadGraphHandler {
.entities
.iter()
.filter(|(_, v)| req.namespace.as_ref().is_none_or(|ns| &v.namespace == ns))
.filter(|(_, v)| req.entity_type.as_ref().is_none_or(|et| &v.entity_type == et))
.collect();
let total_count = filtered_entities.len();
@@ -750,7 +763,7 @@ impl McpTool for SearchNodesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SearchNodesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: SearchNodesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let limit = req.limit.unwrap_or(10);
let include_body = req.include_body.unwrap_or(false);
@@ -800,7 +813,7 @@ impl McpTool for OpenNodesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: OpenNodesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: OpenNodesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let data = state.read_graph(|full| -> crate::error::Result<String> {
let targets: HashSet<&str> = req.names.iter().map(|s| s.as_str()).collect();
let mut result = BorrowedGraph::default();
@@ -841,7 +854,7 @@ impl McpTool for VisualizeGraphHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: VisualizeGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: VisualizeGraphTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let query = req.query.unwrap_or_default();
let mut output = state.read_graph(|full| {
let mut included: HashSet<&str> = HashSet::new();
@@ -933,7 +946,7 @@ impl McpTool for CondenseEntityHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: CondenseEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: CondenseEntityTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut missing = false;
if req.summarized_observations.is_empty() {
@@ -1019,7 +1032,7 @@ impl McpTool for MergeEntitiesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: MergeEntitiesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: MergeEntitiesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut missing = false;
state.modify_graph(|master| {
if let Some(src) = master.entities.remove(&req.source_entity) {
@@ -1115,12 +1128,12 @@ impl McpTool for GetSubgraphHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: GetSubgraphTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let root = req.root_entity.ok_or_else(|| {
crate::error::AppError::Internal("root_entity is required".to_string())
})?;
let depth = req.depth.unwrap_or(2);
let format = req.format.unwrap_or(SubgraphFormat::Json);
let format = req.format.unwrap_or(SubgraphFormat::MarkdownTree);
let (matched_entities, matched_relations) = state.read_graph(|full| {
let mut adj: std::collections::HashMap<&str, Vec<&str>> =
@@ -1210,7 +1223,7 @@ impl McpTool for SweepGraphHealthHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SweepGraphHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: SweepGraphHealthTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let auto_prune = req.auto_prune_orphans.unwrap_or(false);
let auto_prune_stale = req.auto_prune_stale_files.unwrap_or(false);
@@ -1278,16 +1291,38 @@ impl McpTool for SweepGraphHealthHandler {
}
});
let report = serde_json::json!({
"orphaned_entities": orphans,
"orphans_pruned": auto_prune,
"stale_entities": stale_entities,
"stale_pruned": auto_prune_stale,
"potential_duplicates": duplicates,
"health_score": if orphans.is_empty() && duplicates.is_empty() && stale_entities.is_empty() { "100%" } else { "Needs Maintenance" }
});
let mut out = String::new();
out.push_str("## Graph Health Report\n\n");
let score = if orphans.is_empty() && duplicates.is_empty() && stale_entities.is_empty() { "100% (Healthy)" } else { "Needs Maintenance" };
out.push_str(&format!("**Health Status:** {}\n\n", score));
Ok(serde_yaml::to_string(&report)?)
out.push_str(&format!("### Orphaned Entities ({})\n", orphans.len()));
if auto_prune {
out.push_str("*These orphans were automatically pruned.*\n");
}
for o in orphans {
out.push_str(&format!("- `{}`\n", o));
}
out.push_str("\n");
out.push_str(&format!("### Stale Entities ({})\n", stale_entities.len()));
if auto_prune_stale {
out.push_str("*These stale entities were automatically pruned.*\n");
}
for s in stale_entities {
out.push_str(&format!("- `{}`\n", s));
}
out.push_str("\n");
out.push_str(&format!("### Potential Duplicates ({})\n", duplicates.len()));
for d in duplicates {
let a = d.get("entity_a").and_then(|x| x.as_str()).unwrap_or("?");
let b = d.get("entity_b").and_then(|x| x.as_str()).unwrap_or("?");
let act = d.get("suggested_action").and_then(|x| x.as_str()).unwrap_or("");
out.push_str(&format!("- `{}` and `{}`\n - Suggested: `{}`\n", a, b, act));
}
Ok(out)
}
}
@@ -1308,7 +1343,7 @@ impl McpTool for ResolveStaleSymbolsHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ResolveStaleSymbolsTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let auto_heal = req.auto_heal.unwrap_or(true);
let mut checked = 0;
@@ -1366,7 +1401,7 @@ impl McpTool for SummarizeSubgraphHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SummarizeSubgraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: SummarizeSubgraphTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let depth = req.depth.unwrap_or(2);
let (sub_entities, sub_relations) = state.read_graph(|g| {
@@ -1578,7 +1613,7 @@ mod tests {
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert_eq!(res4, "Entities deleted");
assert_eq!(res4, "Successfully deleted 1 entities.");
let res5 = read_graph
.execute(json!({"namespace": "global"}), state.clone())
@@ -1751,7 +1786,7 @@ mod tests {
)
.await
.unwrap();
assert_eq!(del_rel_res, "Relations deleted");
assert_eq!(del_rel_res, "Successfully deleted 1 relations.");
let bcast_handler = AgentSignalsHandler;
let bcast_res = bcast_handler
+143
View File
@@ -0,0 +1,143 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::FetchUrlTool;
use async_trait::async_trait;
use reqwest::Client;
use serde_json::Value;
use std::sync::Arc;
pub struct FetchUrlHandler;
#[async_trait]
impl McpTool for FetchUrlHandler {
fn name(&self) -> &'static str {
"fetch_url"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<FetchUrlTool>(
"fetch_url",
"Fetch content from a URL via an HTTP GET request natively (supports proxy config via environment variables: HTTP_PROXY, HTTPS_PROXY, NO_PROXY)",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: FetchUrlTool = serde_json::from_value(args).map_err(|e| {
crate::error::AppError::BadRequest(format!(
"Schema validation failed. Your JSON arguments do not match the expected tool schema: {}",
e
))
})?;
let mut client_builder = Client::builder();
// Add optional user agent
if let Some(ua) = req.user_agent {
client_builder = client_builder.user_agent(ua);
} else {
client_builder = client_builder.user_agent("mcp-memory-server/1.0");
}
// Allow bypassing SSL validation for self-signed certificates
if req.ignore_ssl_errors.unwrap_or(false) {
client_builder = client_builder.danger_accept_invalid_certs(true);
}
// Reqwest automatically uses HTTP_PROXY, HTTPS_PROXY, NO_PROXY
// environment variables by default, so we don't need to manually
// extract and apply them, the builder handles it.
let client = client_builder.build().map_err(|e| {
crate::error::AppError::Internal(format!("Failed to build HTTP client: {}", e))
})?;
let res = client.get(&req.url).send().await.map_err(|e| {
crate::error::AppError::Internal(format!("HTTP request to {} failed: {}", req.url, e))
})?;
let status = res.status();
let content = res.text().await.map_err(|e| {
crate::error::AppError::Internal(format!("Failed to read response body: {}", e))
})?;
if !status.is_success() {
return Ok(format!(
"HTTP Error: {} {}\n\nResponse Body:\n{}",
status.as_u16(),
status.canonical_reason().unwrap_or("Unknown"),
content
));
}
Ok(content)
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{routing::get, Router};
use tokio::net::TcpListener;
async fn spawn_test_server() -> String {
let app = Router::new()
.route("/test", get(|| async { "Hello, World!" }))
.route(
"/not_found",
get(|| async {
(
axum::http::StatusCode::NOT_FOUND,
"Not Found Error",
)
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://127.0.0.1:{}", port)
}
#[tokio::test]
async fn test_fetch_url_success() {
let base_url = spawn_test_server().await;
let handler = FetchUrlHandler;
let url = format!("{}/test", base_url);
let res = handler
.execute(
serde_json::json!({
"url": url
}),
Arc::new(MemoryState::new("")),
)
.await
.unwrap();
assert_eq!(res, "Hello, World!");
}
#[tokio::test]
async fn test_fetch_url_not_found() {
let base_url = spawn_test_server().await;
let handler = FetchUrlHandler;
let url = format!("{}/not_found", base_url);
let res = handler
.execute(
serde_json::json!({
"url": url
}),
Arc::new(MemoryState::new("")),
)
.await
.unwrap();
assert!(res.contains("HTTP Error: 404 Not Found"));
assert!(res.contains("Not Found Error"));
}
}
+4 -4
View File
@@ -23,7 +23,7 @@ impl McpTool for ProcessLogsHandler {
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ProcessLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let tool_args: ProcessLogsTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::Internal(format!("Failed to seek file: {}", e)))?;
let safe_path = crate::handlers::utils::validate_safe_path(&tool_args.file_path)?;
match tool_args.action {
@@ -43,14 +43,14 @@ impl McpTool for ProcessLogsHandler {
let mut file = File::open(&safe_path).map_err(|e| {
crate::error::AppError::Internal(format!("Failed to open file: {}", e))
})?;
let len = file.metadata().map_err(|e| e.to_string())?.len();
let len = file.metadata().map_err(|e| crate::error::AppError::Internal(format!("Failed to read file metadata: {}", e)))?.len();
let read_size = std::cmp::min(16 * 1024, len);
file.seek(SeekFrom::End(-(read_size as i64)))
.map_err(|e| e.to_string())?;
.map_err(|e| crate::error::AppError::Internal(format!("Failed to seek file: {}", e)))?;
let mut vec_buf = Vec::new();
file.read_to_end(&mut vec_buf).map_err(|e| e.to_string())?;
file.read_to_end(&mut vec_buf).map_err(|e| crate::error::AppError::Internal(format!("Failed to seek file: {}", e)))?;
let buffer = String::from_utf8_lossy(&vec_buf).to_string();
let lines: Vec<&str> = buffer.lines().collect();
+154 -63
View File
@@ -22,7 +22,7 @@ impl McpTool for LogErrorFixHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: LogErrorFixTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: LogErrorFixTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let text_to_embed = format!("Signature: {}\nSolution: {}", req.signature, req.solution);
let solution = req.solution;
@@ -34,8 +34,8 @@ impl McpTool for LogErrorFixHandler {
signature: req.signature.clone(),
solution: solution.clone(),
timestamp: crate::handlers::utils::now_secs(),
git_commit: req.git_commit,
git_branch: req.git_branch,
git_commit: req.git_commit.or_else(|| crate::handlers::utils::get_git_context().1),
git_branch: req.git_branch.or_else(|| crate::handlers::utils::get_git_context().0),
embedding,
..Default::default()
});
@@ -71,7 +71,7 @@ impl McpTool for SearchErrorFixesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SearchErrorFixesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: SearchErrorFixesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let limit = req.limit.unwrap_or(5);
let include_body = req.include_body.unwrap_or(true);
@@ -175,7 +175,7 @@ impl McpTool for LogCodeChangeHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: LogCodeChangeTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: LogCodeChangeTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let mut description = req.description;
if let Some(range) = &req.line_range {
@@ -203,17 +203,20 @@ impl McpTool for LogCodeChangeHandler {
_ => crate::models::ChangeKind::Modified,
};
let (auto_branch, auto_commit, auto_repo) = crate::handlers::utils::get_git_context();
let effective_repo = req.repo_name.clone().filter(|rn| !rn.trim().is_empty()).or(auto_repo.clone());
let namespace = req
.namespace
.filter(|ns| !ns.trim().is_empty())
.or_else(|| req.repo_name.clone().filter(|rn| !rn.trim().is_empty()))
.or_else(|| effective_repo.clone())
.unwrap_or_else(crate::models::default_namespace);
let symbols = req.symbol_references.clone().unwrap_or_default();
let line_range = req.line_range.clone();
let effective_rev = req.revision.clone().or_else(|| req.git_commit.clone());
let effective_branch = req.branch.clone().or_else(|| req.git_branch.clone());
let effective_rev = req.revision.clone().or_else(|| req.git_commit.clone()).or(auto_commit);
let effective_branch = req.branch.clone().or_else(|| req.git_branch.clone()).or(auto_branch);
let detected_vcs = if let Some(vcs) = req.vcs_type.clone() {
Some(vcs)
} else if let Some(ref rev) = effective_rev {
@@ -248,7 +251,7 @@ impl McpTool for LogCodeChangeHandler {
description: description.clone(),
git_commit: effective_rev.clone(),
git_branch: effective_branch.clone(),
repo_name: req.repo_name,
repo_name: effective_repo,
repo_url: req.repo_url,
namespace: namespace.clone(),
change_kind,
@@ -331,6 +334,10 @@ impl McpTool for QueryRecentChangesHandler {
.skip(offset)
.take(limit)
.cloned()
.map(|mut c| {
c.file_path = crate::handlers::utils::sanitize_path(&c.file_path);
c
})
.collect();
Ok::<String, crate::error::AppError>(serde_yaml::to_string(&filtered)?)
})?;
@@ -353,7 +360,7 @@ impl McpTool for DecisionsHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: DecisionsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: DecisionsTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req {
DecisionsTool::Log { title, status, context, decision, consequences, namespace, repo_name, author, affected_components, alternatives_considered, supersedes, git_commit, git_branch, task_id } => {
let ns = namespace.clone().unwrap_or_else(crate::models::default_namespace);
@@ -607,7 +614,7 @@ impl McpTool for TechDebtHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: TechDebtTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: TechDebtTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req {
TechDebtTool::Log { description, title: _, ideal_solution, git_commit, git_branch, symbol_references: _, line_range, namespace, severity, repo_name, file_path, workaround, effort_estimate } => {
let ns = namespace.clone().unwrap_or_else(crate::models::default_namespace);
@@ -625,10 +632,10 @@ impl McpTool for TechDebtHandler {
ideal_solution: ideal,
is_resolved: false,
created_at: crate::handlers::utils::now_secs(),
git_commit: git_commit,
git_branch: git_branch,
git_commit: git_commit.or_else(|| crate::handlers::utils::get_git_context().1),
git_branch: git_branch.or_else(|| crate::handlers::utils::get_git_context().0),
embedding,
repo_name: repo_name,
repo_name: repo_name.or_else(|| crate::handlers::utils::get_git_context().2),
severity: severity,
file_path: file_path,
line_range: line_range,
@@ -733,7 +740,7 @@ impl McpTool for OmniSearchHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: OmniSearchTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: OmniSearchTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let limit = req.limit.unwrap_or(5);
let include_body = req.include_body.unwrap_or(false);
@@ -1086,7 +1093,50 @@ impl McpTool for OmniSearchHandler {
}
}
}
Ok(report.to_string())
let mut out = String::new();
out.push_str(&format!("## OmniSearch Results for '{}'\n\n", req.query));
if report.get("_truncated_to_max_tokens").is_some() {
out.push_str("> [!WARNING] Results truncated to fit token limits.\n\n");
}
let sections = [
("Knowledge Graph", "knowledge_graph"),
("Tasks", "tasks"),
("Snippets", "snippets"),
("ADRs", "adrs"),
("Tech Debt", "tech_debts"),
("Handoff Memos", "handoff_memos"),
("Error Fixes", "error_fixes"),
];
for (title, key) in sections {
if let Some(arr) = report.get(key).and_then(|v| v.as_array()) {
if arr.is_empty() {
continue;
}
out.push_str(&format!("### {}\n", title));
for item in arr {
// Try to extract a title or ID
let id = item.get("id").or_else(|| item.get("name")).and_then(|v| v.as_str()).unwrap_or("Unknown");
let text = item.get("title").or_else(|| item.get("description")).or_else(|| item.get("content")).and_then(|v| v.as_str()).unwrap_or("");
let text_trunc: String = text.chars().take(200).collect();
out.push_str(&format!("- **{}**: {}\n", id, text_trunc.replace('\n', " ")));
}
out.push_str("\n");
} else if let Some(obj) = report.get(key).and_then(|v| v.as_object()) {
if obj.is_empty() {
continue;
}
out.push_str(&format!("### {}\n", title));
for (id, val) in obj {
let text = val.get("entity_type").and_then(|v| v.as_str()).unwrap_or("Entity");
out.push_str(&format!("- **{}** ({})\n", id, text));
}
out.push_str("\n");
}
}
Ok(out)
}
}
@@ -1106,7 +1156,7 @@ impl McpTool for GetProjectHealthHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: GetProjectHealthTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: GetProjectHealthTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let active_tasks = state.project.tasks.read_with(|tasks| {
tasks
.iter()
@@ -1131,13 +1181,15 @@ impl McpTool for GetProjectHealthHandler {
.filter(|m| m.namespace == req.namespace && m.status != "done")
.count()
});
let report = serde_json::json!({
"active_tasks": active_tasks,
"unresolved_tech_debt": unresolved_debt,
"unread_handoff_memos": unread_memos,
"active_milestones": active_milestones
});
Ok(report.to_string())
let out = format!(
"## Project Health (Namespace: {})\n\n\
- **Active Tasks:** {}\n\
- **Unresolved Tech Debt:** {}\n\
- **Unread Handoff Memos:** {}\n\
- **Active Milestones:** {}\n",
req.namespace, active_tasks, unresolved_debt, unread_memos, active_milestones
);
Ok(out)
}
}
@@ -1157,7 +1209,7 @@ impl McpTool for ManageCheckpointHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ManageCheckpointTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: ManageCheckpointTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req {
ManageCheckpointTool::Create { name_or_id, description, namespace } => {
let name = name_or_id.ok_or_else(|| crate::error::AppError::Internal("name_or_id is required".to_string()))?;
@@ -1297,7 +1349,7 @@ impl McpTool for QueryLineageHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: QueryLineageTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: QueryLineageTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let q = req.query.to_lowercase();
let mut timeline = Vec::new();
@@ -1373,13 +1425,15 @@ impl McpTool for QueryLineageHandler {
timeline.sort_by_key(|item| item["timestamp"].as_u64().unwrap_or(0));
let res = serde_json::json!({
"query": req.query,
"lineage_count": timeline.len(),
"timeline": timeline
});
let mut out = format!("## Lineage Query Results for '{}'\n\nFound {} items:\n\n", req.query, timeline.len());
for item in timeline {
out.push_str(&format!("- **{}** (Type: `{}`)\n", item["id"].as_str().unwrap_or(""), item["type"].as_str().unwrap_or("")));
if let Some(msg) = item["message"].as_str() {
out.push_str(&format!(" {}\n", msg.replace('\n', " ")));
}
}
Ok(serde_yaml::to_string(&res)?)
Ok(out)
}
}
@@ -1400,7 +1454,7 @@ impl McpTool for GetNextActionableTasksHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: GetNextActionableTasksTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let limit = req.limit.unwrap_or(5);
let tasks = state.project.tasks.read_with(|t| t.clone());
@@ -1434,12 +1488,21 @@ impl McpTool for GetNextActionableTasksHandler {
actionable.truncate(limit);
let res = serde_json::json!({
"actionable_count": actionable.len(),
"tasks": actionable
});
Ok(serde_yaml::to_string(&res)?)
let mut out = format!("Found {} actionable tasks:\n\n", actionable.len());
for t in actionable {
out.push_str(&format!("- [{}] **{}** (ID: `{}`)\n", t.status, t.title, t.id));
if !t.description.is_empty() {
let desc: String = t.description.chars().take(150).collect();
out.push_str(&format!(" {}\n", desc.replace('\n', " ")));
}
if !t.dependencies.is_empty() {
out.push_str(&format!(" Dependencies: `{}`\n", t.dependencies.join("`, `")));
}
if let Some(b) = &t.git_branch {
out.push_str(&format!(" Branch: `{}`\n", b));
}
}
Ok(out)
}
}
@@ -1459,7 +1522,7 @@ impl McpTool for HypothesesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: HypothesesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: HypothesesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req {
HypothesesTool::Log { hypothesis, task_id, status, evidence } => {
let hyp_text = hypothesis;
@@ -1527,7 +1590,7 @@ impl McpTool for GetPreflightContextHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: GetPreflightContextTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let tasks = state.project.tasks.read_with(|t| t.clone());
let tech_debts = state.code.tech_debts.read_with(|d| d.clone());
@@ -1573,17 +1636,40 @@ impl McpTool for GetPreflightContextHandler {
.take(5)
.collect();
let preflight = serde_json::json!({
"namespace": req.namespace,
"git_branch": req.git_branch,
"active_tasks": active_tasks,
"top_open_tech_debts": open_tech_debts,
"active_hypotheses": active_hypotheses,
"recent_terminal_commands": recent_commands,
"recent_activities": recent_activities
});
let mut out = format!("## Preflight Context (Namespace: {}, Branch: {})\n\n", req.namespace, req.git_branch.unwrap_or_default());
out.push_str(&format!("### Active Tasks ({})\n", active_tasks.len()));
for t in active_tasks {
out.push_str(&format!("- [{}] **{}** (ID: `{}`)\n", t["status"].as_str().unwrap_or(""), t["title"].as_str().unwrap_or(""), t["id"].as_str().unwrap_or("")));
}
out.push_str("\n");
Ok(serde_yaml::to_string(&preflight)?)
out.push_str(&format!("### Top Open Tech Debt ({})\n", open_tech_debts.len()));
for d in open_tech_debts {
out.push_str(&format!("- **ID: {}**\n Description: {}\n", d["id"].as_str().unwrap_or(""), d["description"].as_str().unwrap_or("")));
}
out.push_str("\n");
out.push_str(&format!("### Active Hypotheses ({})\n", active_hypotheses.len()));
for h in active_hypotheses {
out.push_str(&format!("- [{}] {}\n", h.status, h.hypothesis));
}
out.push_str("\n");
out.push_str("### Recent Terminal Commands\n");
for c in recent_commands {
out.push_str(&format!("- `{}` (Exit Code: {})\n", c.command, c.exit_code));
}
out.push_str("\n");
out.push_str("### Recent Activities\n");
for a in recent_activities {
let cat = a.get("category").and_then(|v| v.as_str()).unwrap_or("Unknown");
let sum = a.get("summary").and_then(|v| v.as_str()).unwrap_or("");
out.push_str(&format!("- {} ({})\n", cat, sum));
}
Ok(out)
}
}
@@ -1603,7 +1689,7 @@ impl McpTool for AgentSignalsHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AgentSignalsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: AgentSignalsTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req {
AgentSignalsTool::Broadcast { sender, signal_type, payload, ttl_seconds } => {
let sender = sender;
@@ -1705,7 +1791,7 @@ impl McpTool for AutoSessionCheckpointHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AutoSessionCheckpointTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1740,6 +1826,8 @@ impl McpTool for AutoSessionCheckpointHandler {
timestamp, active_tasks, unverified_hyp, recent_changes
);
let (auto_branch, auto_commit, auto_repo) = crate::handlers::utils::get_git_context();
let memo = crate::models::HandoffMemo {
id: memo_id.clone(),
author,
@@ -1747,6 +1835,9 @@ impl McpTool for AutoSessionCheckpointHandler {
expires_at: None,
namespace: req.namespace,
timestamp,
git_branch: auto_branch,
vcs_revision: auto_commit,
repo_name: auto_repo,
..Default::default()
};
@@ -1812,7 +1903,7 @@ mod tests {
.await
.map_err(|e| crate::error::AppError::Internal(e.to_string()))
.unwrap();
assert!(res.contains("unresolved_tech_debt"));
assert!(res.contains("Unresolved Tech Debt:"));
}
#[tokio::test]
@@ -2238,14 +2329,14 @@ mod tests {
.execute(serde_json::json!({}), state.clone())
.await
.unwrap();
assert!(proj_h_res.contains("active_tasks"));
assert!(proj_h_res.contains("Active Tasks:"));
let sweep_h = SweepGraphHealthHandler;
let sweep_h_res = sweep_h
.execute(serde_json::json!({}), state.clone())
.await
.unwrap();
assert!(sweep_h_res.contains("orphaned_entities"));
assert!(sweep_h_res.contains("Orphaned Entities"));
// AutoSessionCheckpoint
let chk = AutoSessionCheckpointHandler;
@@ -2268,7 +2359,7 @@ mod tests {
.execute(serde_json::json!({"query": "test_sym"}), state.clone())
.await
.unwrap();
assert!(q_lin_res.contains("timeline"));
assert!(q_lin_res.contains("Lineage Query Results for"));
// Agent Signals
let sig_handler = AgentSignalsHandler;
@@ -2326,7 +2417,7 @@ mod tests {
.execute(serde_json::json!({}), state.clone())
.await
.unwrap();
assert!(get_next_res.contains("actionable_count"));
assert!(get_next_res.contains("actionable tasks"));
// GetPreflightContext
let preflight = GetPreflightContextHandler;
@@ -2334,7 +2425,7 @@ mod tests {
.execute(serde_json::json!({}), state.clone())
.await
.unwrap();
assert!(preflight_res.contains("active_tasks"));
assert!(preflight_res.contains("Active Tasks"));
// ManageCheckpoint
let mg_chk = ManageCheckpointHandler;
@@ -2551,7 +2642,7 @@ mod tests {
)
.await
.unwrap();
assert!(preflight_res2.contains("active_tasks"));
assert!(preflight_res2.contains("Active Tasks"));
// Hypotheses query with task_id and query
let hyp_handler = HypothesesHandler;
@@ -2653,7 +2744,7 @@ mod tests {
.execute(serde_json::json!({"query": "E0425"}), state.clone())
.await
.unwrap();
assert!(q_lin_res.contains("lineage_count"));
assert!(q_lin_res.contains("Lineage Query Results for"));
// Agent signals filtering
let sig_handler = AgentSignalsHandler;
@@ -2779,7 +2870,7 @@ mod tests {
)
.await
.unwrap();
assert!(next_act_res.contains("actionable_count"));
assert!(next_act_res.contains("actionable tasks"));
// GetPreflightContext with branch
let preflight = GetPreflightContextHandler;
@@ -2790,7 +2881,7 @@ mod tests {
)
.await
.unwrap();
assert!(pre_res.contains("active_tasks"));
assert!(pre_res.contains("Active Tasks"));
}
#[tokio::test]
+1
View File
@@ -1,4 +1,5 @@
pub mod ast;
pub mod http;
pub mod env;
pub mod git;
pub mod graph;
+34 -14
View File
@@ -23,7 +23,7 @@ impl McpTool for HandoffMemosHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: HandoffMemosTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: HandoffMemosTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req.action {
HandoffMemoAction::Leave => {
let content = req.content.ok_or_else(|| {
@@ -32,6 +32,7 @@ impl McpTool for HandoffMemosHandler {
let ns = req
.namespace
.unwrap_or_else(crate::models::default_namespace);
let (auto_branch, auto_commit, auto_repo) = crate::handlers::utils::get_git_context();
state.telemetry.handoff_memos.modify(|memos| {
memos.push(crate::models::HandoffMemo {
id: uuid::Uuid::new_v4().to_string(),
@@ -40,6 +41,9 @@ impl McpTool for HandoffMemosHandler {
namespace: ns,
timestamp: crate::handlers::utils::now_secs(),
expires_at: None,
git_branch: auto_branch,
vcs_revision: auto_commit,
repo_name: auto_repo,
..Default::default()
});
if memos.len() > 200 {
@@ -95,7 +99,7 @@ impl McpTool for AddSessionSummaryHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: AddSessionSummaryTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: AddSessionSummaryTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
state.telemetry.session_summaries.modify(|summaries| {
summaries.push(crate::models::SessionSummary {
summary: req.summary,
@@ -103,9 +107,9 @@ impl McpTool for AddSessionSummaryHandler {
timestamp: crate::handlers::utils::now_secs(),
expires_at: None,
session_id: req.session_id,
repo_name: req.repo_name,
git_branch: req.git_branch,
git_commit: req.git_commit,
repo_name: req.repo_name.or_else(|| crate::handlers::utils::get_git_context().2),
git_branch: req.git_branch.or_else(|| crate::handlers::utils::get_git_context().0),
git_commit: req.git_commit.or_else(|| crate::handlers::utils::get_git_context().1),
tasks_completed: req.tasks_completed.unwrap_or_default(),
next_steps: req.next_steps.unwrap_or_default(),
});
@@ -143,7 +147,7 @@ impl McpTool for GenerateStandupReportHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: GenerateStandupReportTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let cutoff = crate::handlers::utils::now_secs().saturating_sub(req.hours_lookback * 3600);
let filtered_tasks = state.project.tasks.read_with(|items| {
@@ -168,13 +172,29 @@ impl McpTool for GenerateStandupReportHandler {
.collect::<Vec<_>>()
});
let report = serde_json::json!({
"tasks_updated": filtered_tasks,
"code_changes": filtered_changes,
"session_summaries": filtered_summaries
});
let report_str = report.to_string();
Ok(report_str)
let mut out = String::new();
out.push_str(&format!("## Standup Report (Past {} hours)\n\n", req.hours_lookback));
out.push_str(&format!("### Tasks Updated ({})\n", filtered_tasks.len()));
for t in filtered_tasks {
out.push_str(&format!("- [{}] **{}** (ID: `{}`)\n", t.status, t.title, t.id));
}
out.push_str("\n");
out.push_str(&format!("### Code Changes ({})\n", filtered_changes.len()));
for c in filtered_changes {
let branch = c.git_branch.as_deref().unwrap_or("unknown");
out.push_str(&format!("- `{}` (Branch: `{}`): {}\n", c.file_path, branch, c.description));
}
out.push_str("\n");
out.push_str(&format!("### Session Summaries ({})\n", filtered_summaries.len()));
for s in filtered_summaries {
out.push_str(&format!("- {}\n", s.summary));
}
out.push_str("\n");
Ok(out)
}
}
@@ -194,7 +214,7 @@ impl McpTool for PromoteToEntityHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: PromoteToEntityTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: PromoteToEntityTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let entity_name = req.entity_name.clone();
let obs = req.content.clone();
+5 -5
View File
@@ -22,7 +22,7 @@ impl McpTool for TasksHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: TasksTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: TasksTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req {
TasksTool::Add { title, description, parent_id, dependencies, git_branch, namespace, repo_name, priority, assigned_agent, verification_command } => {
let description = description.unwrap_or_default();
@@ -37,13 +37,13 @@ impl McpTool for TasksHandler {
description,
created_at: now,
updated_at: now,
git_branch,
git_branch: git_branch.or_else(|| crate::handlers::utils::get_git_context().0),
parent_id,
dependencies: deps,
acceptance_criteria: vec![],
expires_at: None,
namespace: namespace.unwrap_or_else(|| "default".to_string()),
repo_name,
repo_name: repo_name.or_else(|| crate::handlers::utils::get_git_context().2),
priority,
assigned_agent,
verification_command,
@@ -414,7 +414,7 @@ impl McpTool for MilestonesHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: MilestonesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: MilestonesTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req {
MilestonesTool::Add { title, namespace, target_date, description, deliverables, task_ids, repo_name } => {
let ns = namespace.unwrap_or_else(crate::models::default_namespace);
@@ -430,7 +430,7 @@ impl McpTool for MilestonesHandler {
description: description,
task_ids: task_ids.unwrap_or_default(),
deliverables: deliverables.unwrap_or_default(),
repo_name,
repo_name: repo_name.or_else(|| crate::handlers::utils::get_git_context().2),
..Default::default()
})
});
+52 -1
View File
@@ -15,7 +15,8 @@ pub fn now_secs() -> u64 {
}
pub fn validate_safe_path(path_str: &str) -> crate::error::Result<std::path::PathBuf> {
let path = std::path::Path::new(path_str);
let sanitized_str = sanitize_path(path_str);
let path = std::path::Path::new(&sanitized_str);
for component in path.components() {
if component == std::path::Component::ParentDir {
return Err(crate::error::AppError::BadRequest(format!(
@@ -27,6 +28,56 @@ pub fn validate_safe_path(path_str: &str) -> crate::error::Result<std::path::Pat
Ok(path.to_path_buf())
}
/// Sanitizes a given path based on the operating environment of the server.
/// This translates Windows paths to WSL paths if the server runs in Linux,
/// and translates WSL paths to Windows paths if the server runs in Windows.
pub fn sanitize_path(raw_path: &str) -> String {
if cfg!(unix) {
if raw_path.contains(":\\") {
if let Ok(output) = std::process::Command::new("wslpath").arg("-u").arg(raw_path).output() {
if output.status.success() {
return String::from_utf8_lossy(&output.stdout).trim().to_string();
}
}
if raw_path.len() >= 3 && &raw_path[1..3] == ":\\" {
let drive_letter = &raw_path[0..1].to_lowercase();
let remainder = raw_path[3..].replace("\\", "/");
return format!("/mnt/{}/{}", drive_letter, remainder);
}
}
} else if cfg!(windows) {
if raw_path.starts_with("/mnt/") && raw_path.len() > 6 {
let drive_letter = &raw_path[5..6].to_uppercase();
let remainder = raw_path[7..].replace("/", "\\");
return format!("{}:\\{}", drive_letter, remainder);
} else if raw_path.starts_with("/") {
return format!("\\\\wsl$\\Ubuntu{}", raw_path.replace("/", "\\"));
}
}
raw_path.to_string()
}
pub fn get_git_context() -> (Option<String>, Option<String>, Option<String>) {
let mut branch = None;
let mut commit = None;
let mut repo_name = None;
if let Ok(cwd) = std::env::current_dir() {
if let Ok(repo) = git2::Repository::discover(&cwd) {
if let Ok(head) = repo.head() {
branch = head.shorthand().map(|s| s.to_string());
commit = head.target().map(|oid| oid.to_string());
}
if let Some(path) = repo.workdir() {
if let Some(name) = path.file_name() {
repo_name = name.to_str().map(|s| s.to_string());
}
}
}
}
(branch, commit, repo_name)
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -700,7 +700,7 @@ impl McpTool for ClipboardHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ClipboardTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: ClipboardTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req.action {
ClipboardAction::Image => {
let cached_opt = {
+5 -5
View File
@@ -22,7 +22,7 @@ impl McpTool for SnippetsHandler {
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: SnippetsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let req: SnippetsTool = serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
match req.action {
SnippetAction::Store => {
let name = req.name.or(req.query).or(req.id).ok_or_else(|| {
@@ -231,7 +231,7 @@ impl McpTool for ReadDirectoryArchitectureHandler {
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: ReadDirectoryArchitectureTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let dir = tool_args.directory.clone();
let result = tokio::task::spawn_blocking(move || -> crate::error::Result<String> {
@@ -301,7 +301,7 @@ impl McpTool for ReadDirectoryArchitectureHandler {
}
summary.push_str(&format!("Architecture of {}:\n", dir));
visit_dirs(path, &mut summary, 0).map_err(|e| e.to_string())?;
visit_dirs(path, &mut summary, 0).map_err(|e| crate::error::AppError::Internal(format!("Failed to read directory: {}", e)))?;
Ok(summary)
})
@@ -330,7 +330,7 @@ impl McpTool for SemanticCodeSearchHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let tool_args: SemanticCodeSearchTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let results = state
.search()
@@ -388,7 +388,7 @@ impl McpTool for ManageSubagentNamespaceHandler {
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> crate::error::Result<String> {
let req: ManageSubagentNamespaceTool =
serde_json::from_value(args).map_err(|e| e.to_string())?;
serde_json::from_value(args).map_err(|e| crate::error::AppError::BadRequest(format!("Schema validation failed. Your JSON arguments do not match the expected tool schema: {}", e)))?;
let subagent_ns = format!("subagent-{}", req.subagent_id);
match req.action {
+1
View File
@@ -536,6 +536,7 @@ impl MemoryHandler {
register!(graph::SummarizeSubgraphHandler);
register!(meta::AgentSignalsHandler);
register!(meta::AutoSessionCheckpointHandler);
register!(http::FetchUrlHandler);
Self {
state,
+22 -3
View File
@@ -100,6 +100,8 @@ pub struct DeleteRelationsTool {
pub struct ReadGraphTool {
/// Optional namespace to restrict the read to.
pub namespace: Option<String>,
/// Optional entity_type to filter entities by (e.g. 'DatabaseTable').
pub entity_type: Option<String>,
/// Optional token budget cap. Truncates graph outputs to fit within the specified token budget.
pub max_tokens: Option<usize>,
/// Optional maximum number of entities to return (pagination).
@@ -213,7 +215,7 @@ pub struct GetSubgraphTool {
pub root_entity: Option<String>,
/// Maximum search depth (hops). Defaults to 2.
pub depth: Option<u32>,
/// Output format: 'json' (raw entities and relations) or 'markdown_tree' (compact topology tree). Defaults to 'json'.
/// Output format: 'json' (raw entities and relations) or 'markdown_tree' (compact topology tree). Defaults to 'markdown_tree'.
pub format: Option<SubgraphFormat>,
/// Optional namespace filter.
pub namespace: Option<String>,
@@ -496,8 +498,8 @@ pub struct ReadFileSkeletonTool {
pub file_path: String,
}
/// Replace a specific AST node in a file (robust structural editing). Use this instead of regex or line-based string replacement to prevent indentation bugs and matching failures.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReplaceAstNodeTool {
#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)]
pub struct AstNodeReplacement {
/// The path of the file to modify.
pub file_path: String,
/// The AST node type to replace (e.g., 'function_item', 'impl_item').
@@ -508,6 +510,12 @@ pub struct ReplaceAstNodeTool {
pub new_content: String,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ReplaceAstNodeTool {
/// Array of AST node replacements to execute in bulk.
pub replacements: Vec<AstNodeReplacement>,
}
/// Semantic code search using local vector embeddings. Use this conceptual search instead of raw regex (grep) when trying to locate abstract logic or exploring new patterns.
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct SemanticCodeSearchTool {
@@ -1031,3 +1039,14 @@ pub struct ClipboardTool {
/// Image path (for 'write').
pub image_path: Option<String>,
}
/// Fetch content from a URL via an HTTP GET request natively (supports proxy config via environment variables: HTTP_PROXY, HTTPS_PROXY, NO_PROXY).
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct FetchUrlTool {
/// URL to fetch content from.
pub url: String,
/// Optional custom user-agent string.
pub user_agent: Option<String>,
/// Bypass SSL certificate verification for self-signed or invalid certs. Use with caution.
pub ignore_ssl_errors: Option<bool>,
}
+3
View File
@@ -20,3 +20,6 @@ async-channel = "2.5.0"
[build-dependencies]
chrono = "0.4.45"
[lints]
workspace = true