1use anyhow::Result;
14use async_trait::async_trait;
15use serde::Deserialize;
16use serde_json::{json, Value};
17use std::path::Path;
18use walkdir::WalkDir;
19
20use super::{envelope_err, envelope_ok};
21use crate::hanzo_api::HanzoApi;
22use crate::{MCPTool, ToolResult};
23
24const SKIP_DIRS: &[&str] = &[
25 ".git", "node_modules", "target", "dist", "build", "__pycache__", ".venv", "venv", ".next",
26];
27const MAX_INDEX_FILES: usize = 5000;
28const MAX_FILE_BYTES: u64 = 512 * 1024;
29
30fn respond(tool: &'static str, action: &'static str, result: Result<Value>) -> Result<ToolResult> {
32 Ok(match result {
33 Ok(body) => ToolResult::ok(envelope_ok(tool, action, body)),
34 Err(e) => ToolResult::ok(envelope_err(tool, action, "UPSTREAM", e.to_string())),
35 })
36}
37
38fn require_key(api: &HanzoApi, tool: &'static str, action: &'static str) -> Option<Result<ToolResult>> {
39 if api.has_key() {
40 None
41 } else {
42 Some(Ok(ToolResult::ok(envelope_err(
43 tool,
44 action,
45 "NO_API_KEY",
46 crate::hanzo_api::NO_KEY,
47 ))))
48 }
49}
50
51#[derive(Debug, Default, Deserialize)]
56struct SearchArgs {
57 #[serde(alias = "q")]
58 query: Option<String>,
59 #[serde(alias = "type")]
61 kind: Option<String>,
62 repo: Option<String>,
63 limit: Option<u32>,
64}
65
66pub struct CodeSearchTool {
67 api: HanzoApi,
68}
69
70impl CodeSearchTool {
71 pub fn new() -> Self {
72 Self { api: HanzoApi::from_env() }
73 }
74 pub fn schema() -> Value {
75 json!({
76 "name": "code_search",
77 "description": "Hybrid cross-repo code search on api.hanzo.ai (symbol|text|semantic|hybrid). Cloud counterpart to the local `code` AST tool.",
78 "inputSchema": {
79 "type": "object",
80 "properties": {
81 "query": { "type": "string", "description": "Search query" },
82 "type": { "type": "string", "enum": ["symbol", "text", "semantic", "hybrid"], "default": "hybrid" },
83 "repo": { "type": "string", "description": "Restrict to a repo" },
84 "limit": { "type": "number", "default": 20 }
85 },
86 "required": ["query"]
87 }
88 })
89 }
90}
91
92impl Default for CodeSearchTool {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98#[async_trait]
99impl MCPTool for CodeSearchTool {
100 fn name(&self) -> &str {
101 "code_search"
102 }
103 fn description(&self) -> &str {
104 "Hybrid cross-repo code search on api.hanzo.ai (symbol|text|semantic|hybrid)"
105 }
106 fn parameters(&self) -> Value {
107 Self::schema()["inputSchema"].clone()
108 }
109 async fn execute(&self, params: Value) -> Result<ToolResult> {
110 if let Some(r) = require_key(&self.api, "code_search", "search") {
111 return r;
112 }
113 let args: SearchArgs = serde_json::from_value(params).unwrap_or_default();
114 let query = match args.query.filter(|q| !q.trim().is_empty()) {
115 Some(q) => q,
116 None => return Ok(ToolResult::ok(envelope_err("code_search", "search", "INVALID_ARGS", "query required"))),
117 };
118
119 let mut q: Vec<(&str, String)> = vec![
120 ("q", query),
121 ("type", args.kind.unwrap_or_else(|| "hybrid".to_string())),
122 ("limit", args.limit.unwrap_or(20).to_string()),
123 ];
124 if let Some(repo) = args.repo.filter(|r| !r.is_empty()) {
125 q.push(("repo", repo));
126 }
127 respond("code_search", "search", self.api.get("/v1/code/search", &q).await)
128 }
129}
130
131#[derive(Debug, Default, Deserialize)]
136struct ContextArgs {
137 #[serde(alias = "q")]
138 query: Option<String>,
139 #[serde(alias = "budget_tokens", alias = "budget")]
140 budget_tokens: Option<u32>,
141 repo: Option<String>,
142}
143
144pub struct CodeContextTool {
145 api: HanzoApi,
146}
147
148impl CodeContextTool {
149 pub fn new() -> Self {
150 Self { api: HanzoApi::from_env() }
151 }
152 pub fn schema() -> Value {
153 json!({
154 "name": "code_context",
155 "description": "Token-budgeted code context bundle for a query from api.hanzo.ai.",
156 "inputSchema": {
157 "type": "object",
158 "properties": {
159 "query": { "type": "string", "description": "What to gather context for" },
160 "budgetTokens": { "type": "number", "default": 2000 },
161 "repo": { "type": "string" }
162 },
163 "required": ["query"]
164 }
165 })
166 }
167}
168
169impl Default for CodeContextTool {
170 fn default() -> Self {
171 Self::new()
172 }
173}
174
175#[async_trait]
176impl MCPTool for CodeContextTool {
177 fn name(&self) -> &str {
178 "code_context"
179 }
180 fn description(&self) -> &str {
181 "Token-budgeted code context bundle for a query from api.hanzo.ai"
182 }
183 fn parameters(&self) -> Value {
184 Self::schema()["inputSchema"].clone()
185 }
186 async fn execute(&self, params: Value) -> Result<ToolResult> {
187 if let Some(r) = require_key(&self.api, "code_context", "context") {
188 return r;
189 }
190 let args: ContextArgs = serde_json::from_value(params).unwrap_or_default();
191 let query = match args.query.filter(|q| !q.trim().is_empty()) {
192 Some(q) => q,
193 None => return Ok(ToolResult::ok(envelope_err("code_context", "context", "INVALID_ARGS", "query required"))),
194 };
195 let mut body = json!({
196 "query": query,
197 "budgetTokens": args.budget_tokens.unwrap_or(2000),
198 });
199 if let Some(repo) = args.repo.filter(|r| !r.is_empty()) {
200 body["repo"] = json!(repo);
201 }
202 respond("code_context", "context", self.api.post("/v1/code/context", body).await)
203 }
204}
205
206#[derive(Debug, Default, Deserialize)]
211struct AskArgs {
212 #[serde(alias = "q", alias = "question")]
213 query: Option<String>,
214 repo: Option<String>,
215}
216
217pub struct CodeAskTool {
218 api: HanzoApi,
219}
220
221impl CodeAskTool {
222 pub fn new() -> Self {
223 Self { api: HanzoApi::from_env() }
224 }
225 pub fn schema() -> Value {
226 json!({
227 "name": "code_ask",
228 "description": "Ask a question about indexed code; returns a cited RAG answer from api.hanzo.ai.",
229 "inputSchema": {
230 "type": "object",
231 "properties": {
232 "query": { "type": "string", "description": "Question about the code" },
233 "repo": { "type": "string" }
234 },
235 "required": ["query"]
236 }
237 })
238 }
239}
240
241impl Default for CodeAskTool {
242 fn default() -> Self {
243 Self::new()
244 }
245}
246
247#[async_trait]
248impl MCPTool for CodeAskTool {
249 fn name(&self) -> &str {
250 "code_ask"
251 }
252 fn description(&self) -> &str {
253 "Ask a question about indexed code; returns a cited RAG answer from api.hanzo.ai"
254 }
255 fn parameters(&self) -> Value {
256 Self::schema()["inputSchema"].clone()
257 }
258 async fn execute(&self, params: Value) -> Result<ToolResult> {
259 if let Some(r) = require_key(&self.api, "code_ask", "ask") {
260 return r;
261 }
262 let args: AskArgs = serde_json::from_value(params).unwrap_or_default();
263 let query = match args.query.filter(|q| !q.trim().is_empty()) {
264 Some(q) => q,
265 None => return Ok(ToolResult::ok(envelope_err("code_ask", "ask", "INVALID_ARGS", "query required"))),
266 };
267 let mut q: Vec<(&str, String)> = vec![("q", query)];
268 if let Some(repo) = args.repo.filter(|r| !r.is_empty()) {
269 q.push(("repo", repo));
270 }
271 respond("code_ask", "ask", self.api.get("/v1/code/ask", &q).await)
272 }
273}
274
275#[derive(Debug, Deserialize)]
280struct IndexFile {
281 path: String,
282 content: String,
283}
284
285#[derive(Debug, Default, Deserialize)]
286struct IndexArgs {
287 repo: Option<String>,
288 path: Option<String>,
290 files: Option<Vec<IndexFile>>,
291}
292
293pub struct CodeIndexTool {
294 api: HanzoApi,
295}
296
297impl CodeIndexTool {
298 pub fn new() -> Self {
299 Self { api: HanzoApi::from_env() }
300 }
301 pub fn schema() -> Value {
302 json!({
303 "name": "code_index",
304 "description": "Index a repo into api.hanzo.ai. Pass explicit `files`, or a local `path` to walk (text files, bounded).",
305 "inputSchema": {
306 "type": "object",
307 "properties": {
308 "repo": { "type": "string", "description": "Repo name (defaults to the directory basename)" },
309 "path": { "type": "string", "description": "Local directory to walk and index" },
310 "files": {
311 "type": "array",
312 "description": "Explicit files to index",
313 "items": {
314 "type": "object",
315 "properties": {
316 "path": { "type": "string" },
317 "content": { "type": "string" }
318 },
319 "required": ["path", "content"]
320 }
321 }
322 },
323 "required": []
324 }
325 })
326 }
327}
328
329impl Default for CodeIndexTool {
330 fn default() -> Self {
331 Self::new()
332 }
333}
334
335fn collect_files(dir: &Path) -> (Vec<Value>, usize) {
337 let mut files = Vec::new();
338 let mut skipped = 0usize;
339 for entry in WalkDir::new(dir)
340 .follow_links(false)
341 .into_iter()
342 .filter_entry(|e| {
343 !(e.file_type().is_dir()
344 && e.file_name().to_str().map_or(false, |n| SKIP_DIRS.contains(&n)))
345 })
346 .filter_map(|e| e.ok())
347 .filter(|e| e.file_type().is_file())
348 {
349 if files.len() >= MAX_INDEX_FILES {
350 break;
351 }
352 let path = entry.path();
353 match entry.metadata() {
354 Ok(m) if m.len() > MAX_FILE_BYTES => {
355 skipped += 1;
356 continue;
357 }
358 Ok(_) => {}
359 Err(_) => {
360 skipped += 1;
361 continue;
362 }
363 }
364 match std::fs::read_to_string(path) {
365 Ok(content) => {
366 let rel = path.strip_prefix(dir).unwrap_or(path).to_string_lossy().to_string();
367 files.push(json!({ "path": rel, "content": content }));
368 }
369 Err(_) => skipped += 1, }
371 }
372 (files, skipped)
373}
374
375#[async_trait]
376impl MCPTool for CodeIndexTool {
377 fn name(&self) -> &str {
378 "code_index"
379 }
380 fn description(&self) -> &str {
381 "Index a repo into api.hanzo.ai (explicit files or a local path to walk)"
382 }
383 fn parameters(&self) -> Value {
384 Self::schema()["inputSchema"].clone()
385 }
386 async fn execute(&self, params: Value) -> Result<ToolResult> {
387 if let Some(r) = require_key(&self.api, "code_index", "index") {
388 return r;
389 }
390 let args: IndexArgs = serde_json::from_value(params).unwrap_or_default();
391
392 let (files, repo, walked_skipped) = if let Some(list) = args.files {
394 let files: Vec<Value> = list
395 .into_iter()
396 .map(|f| json!({ "path": f.path, "content": f.content }))
397 .collect();
398 (files, args.repo.clone(), 0usize)
399 } else if let Some(path) = args.path.as_deref().filter(|p| !p.is_empty()) {
400 let dir = Path::new(path);
401 if !dir.is_dir() {
402 return Ok(ToolResult::ok(envelope_err("code_index", "index", "INVALID_ARGS", format!("not a directory: {}", path))));
403 }
404 let repo = args.repo.clone().or_else(|| {
405 dir.canonicalize()
406 .ok()
407 .and_then(|c| c.file_name().map(|n| n.to_string_lossy().to_string()))
408 });
409 let (files, skipped) = collect_files(dir);
410 (files, repo, skipped)
411 } else {
412 return Ok(ToolResult::ok(envelope_err("code_index", "index", "INVALID_ARGS", "provide `files` or a local `path`")));
413 };
414
415 if files.is_empty() {
416 return Ok(ToolResult::ok(envelope_err("code_index", "index", "EMPTY", "no indexable files found")));
417 }
418 let repo = match repo.filter(|r| !r.is_empty()) {
419 Some(r) => r,
420 None => return Ok(ToolResult::ok(envelope_err("code_index", "index", "INVALID_ARGS", "repo required"))),
421 };
422
423 let sent = files.len();
424 let body = json!({ "repo": repo, "files": files });
425 match self.api.post("/v1/code/index", body).await {
426 Ok(mut result) => {
427 if let Some(obj) = result.as_object_mut() {
428 obj.insert("sent".into(), json!(sent));
429 if walked_skipped > 0 {
430 obj.insert("walkedSkipped".into(), json!(walked_skipped));
431 }
432 }
433 Ok(ToolResult::ok(envelope_ok("code_index", "index", result)))
434 }
435 Err(e) => Ok(ToolResult::ok(envelope_err("code_index", "index", "UPSTREAM", e.to_string()))),
436 }
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use std::io::Write;
444
445 #[test]
446 fn search_schema_shape() {
447 let s = CodeSearchTool::schema();
448 assert_eq!(s["name"], "code_search");
449 assert_eq!(s["inputSchema"]["required"][0], "query");
450 let props = &s["inputSchema"]["properties"];
451 assert!(props.get("type").is_some());
452 assert!(props.get("repo").is_some());
453 }
454
455 #[test]
456 fn tool_names_are_stable() {
457 assert_eq!(CodeSearchTool::new().name(), "code_search");
458 assert_eq!(CodeContextTool::new().name(), "code_context");
459 assert_eq!(CodeAskTool::new().name(), "code_ask");
460 assert_eq!(CodeIndexTool::new().name(), "code_index");
461 }
462
463 #[test]
464 fn search_args_accept_q_and_type_aliases() {
465 let a: SearchArgs = serde_json::from_value(json!({ "q": "foo", "type": "symbol", "limit": 5 })).unwrap();
466 assert_eq!(a.query.as_deref(), Some("foo"));
467 assert_eq!(a.kind.as_deref(), Some("symbol"));
468 assert_eq!(a.limit, Some(5));
469 }
470
471 #[test]
472 fn collect_files_walks_and_skips_noise() {
473 let dir = tempfile::tempdir().unwrap();
474 let root = dir.path();
475 let mut f = std::fs::File::create(root.join("a.rs")).unwrap();
476 writeln!(f, "fn main() {{}}").unwrap();
477 std::fs::create_dir_all(root.join("node_modules")).unwrap();
478 std::fs::File::create(root.join("node_modules").join("junk.js")).unwrap();
479
480 let (files, _skipped) = collect_files(root);
481 assert_eq!(files.len(), 1, "node_modules must be pruned");
482 assert_eq!(files[0]["path"], "a.rs");
483 }
484
485 #[tokio::test]
488 #[ignore]
489 async fn live_index_then_search_via_registry() {
490 let registry = crate::ToolRegistry::with_defaults();
491 let repo = format!("mcp-rust-live-{}", std::process::id());
492
493 let indexed = registry
494 .execute("code_index", json!({
495 "repo": repo,
496 "files": [{ "path": "greet.rs", "content": "pub fn hanzo_greet() -> &'static str { \"hi\" }" }]
497 }))
498 .await
499 .unwrap();
500 assert_eq!(indexed.content["ok"], true, "index envelope: {}", indexed.content);
501 assert!(indexed.content["data"]["indexed"].as_u64().unwrap_or(0) >= 1);
502
503 let found = registry
504 .execute("code_search", json!({ "q": "hanzo_greet", "type": "hybrid", "repo": repo }))
505 .await
506 .unwrap();
507 assert_eq!(found.content["ok"], true, "search envelope: {}", found.content);
508 println!("code_search data: {}", found.content["data"]);
509 }
510}