use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use tracing::{debug, info};
use crate::config::TurboPropConfig;
use crate::search::search_index_with_filters;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct QueryString(String);
impl QueryString {
pub fn new(query: String) -> Self {
Self(query)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.trim().is_empty()
}
}
impl From<String> for QueryString {
fn from(query: String) -> Self {
Self::new(query)
}
}
impl AsRef<str> for QueryString {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ResultLimit(usize);
impl ResultLimit {
pub fn new(limit: usize) -> Self {
Self(limit)
}
pub fn get(&self) -> usize {
self.0
}
pub fn value(&self) -> usize {
self.0
}
}
impl From<usize> for ResultLimit {
fn from(limit: usize) -> Self {
Self::new(limit)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct ContextLines(usize);
impl ContextLines {
pub fn new(lines: usize) -> Self {
Self(lines)
}
pub fn value(&self) -> usize {
self.0
}
}
impl From<usize> for ContextLines {
fn from(lines: usize) -> Self {
Self::new(lines)
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)]
pub struct SimilarityScore(f32);
impl SimilarityScore {
pub fn new(score: f32) -> Self {
Self(score)
}
pub fn get(&self) -> f32 {
self.0
}
pub fn value(&self) -> f32 {
self.0
}
pub fn is_valid(&self) -> bool {
(0.0..=1.0).contains(&self.0)
}
}
impl From<f32> for SimilarityScore {
fn from(score: f32) -> Self {
Self::new(score)
}
}
use serde::{Deserializer, Serializer};
fn deserialize_query_string<'de, D>(deserializer: D) -> Result<QueryString, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(QueryString::from(s))
}
fn deserialize_result_limit<'de, D>(deserializer: D) -> Result<ResultLimit, D::Error>
where
D: Deserializer<'de>,
{
let n = usize::deserialize(deserializer)?;
Ok(ResultLimit::from(n))
}
fn deserialize_optional_similarity_score<'de, D>(
deserializer: D,
) -> Result<Option<SimilarityScore>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<f32>::deserialize(deserializer)?;
Ok(opt.map(SimilarityScore::from))
}
fn deserialize_optional_context_lines<'de, D>(
deserializer: D,
) -> Result<Option<ContextLines>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<usize>::deserialize(deserializer)?;
Ok(opt.map(ContextLines::from))
}
fn serialize_similarity_score<S>(score: &SimilarityScore, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_f32(score.value())
}
fn default_result_limit() -> ResultLimit {
ResultLimit::new(10)
}
#[derive(Debug, Clone, Deserialize)]
pub struct SearchToolParams {
#[serde(deserialize_with = "deserialize_query_string")]
pub query: QueryString,
#[serde(
default = "default_result_limit",
deserialize_with = "deserialize_result_limit"
)]
pub limit: ResultLimit,
#[serde(default, deserialize_with = "deserialize_optional_similarity_score")]
pub threshold: Option<SimilarityScore>,
#[serde(default)]
pub filetype: Option<String>,
#[serde(default)]
pub filter: Option<String>,
#[serde(default = "default_include_content")]
pub include_content: bool,
#[serde(default, deserialize_with = "deserialize_optional_context_lines")]
pub context_lines: Option<ContextLines>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpSearchResult {
pub file_path: String,
pub line_number: usize,
#[serde(serialize_with = "serialize_similarity_score")]
pub similarity_score: SimilarityScore,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Vec<String>>,
pub file_type: String,
pub start_line: usize,
pub end_line: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchToolResult {
pub results: Vec<McpSearchResult>,
pub total_results: usize,
pub execution_time_ms: u64,
pub query_info: SearchQueryInfo,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchQueryInfo {
pub query: String,
pub filters: SearchFilters,
pub limit: usize,
pub threshold: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchFilters {
#[serde(skip_serializing_if = "Option::is_none")]
pub filetype: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub glob_pattern: Option<String>,
}
fn default_include_content() -> bool {
true
}
use crate::mcp::error::{McpError, McpResult};
use async_trait::async_trait;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallRequest {
pub name: String,
pub arguments: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResponse {
pub success: bool,
pub content: Option<Value>,
pub error: Option<String>,
}
#[async_trait]
pub trait ToolExecutor {
async fn execute(&self, request: ToolCallRequest) -> McpResult<ToolCallResponse>;
fn definition(&self) -> ToolDefinition;
fn validate_arguments(&self, arguments: &HashMap<String, Value>) -> McpResult<()>;
}
pub struct SemanticSearchTool {
config: SearchToolConfig,
index_path: std::path::PathBuf,
repo_path: std::path::PathBuf,
turboprop_config: TurboPropConfig,
is_mock: bool,
}
#[derive(Debug, Clone)]
pub struct SearchToolConfig {
pub default_limit: usize,
pub max_limit: usize,
pub default_threshold: f32,
pub max_query_length: usize,
pub min_threshold: f32,
pub max_threshold: f32,
pub default_context_lines: usize,
pub max_context_lines: usize,
}
impl Default for SearchToolConfig {
fn default() -> Self {
Self {
default_limit: 10,
max_limit: 100,
default_threshold: 0.3,
max_query_length: 1000,
min_threshold: 0.0,
max_threshold: 1.0,
default_context_lines: 0,
max_context_lines: 10,
}
}
}
impl SemanticSearchTool {
pub fn new(
index_path: std::path::PathBuf,
repo_path: std::path::PathBuf,
turboprop_config: TurboPropConfig,
) -> Self {
Self::with_config(
SearchToolConfig::default(),
index_path,
repo_path,
turboprop_config,
)
}
pub fn with_config(
config: SearchToolConfig,
index_path: std::path::PathBuf,
repo_path: std::path::PathBuf,
turboprop_config: TurboPropConfig,
) -> Self {
let is_mock = index_path.to_string_lossy().contains("/tmp/test");
Self {
config,
index_path,
repo_path,
turboprop_config,
is_mock,
}
}
pub fn new_mock() -> Self {
use crate::config::TurboPropConfig;
use std::path::PathBuf;
Self {
config: SearchToolConfig::default(),
index_path: PathBuf::from("/tmp/test_index"),
repo_path: PathBuf::from("/tmp/test_repo"),
turboprop_config: TurboPropConfig::default(),
is_mock: true,
}
}
fn create_input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": format!("Natural language search query (max {} characters)", self.config.max_query_length)
},
"limit": {
"type": "integer",
"description": format!("Maximum number of results to return (default: {}, max: {})",
self.config.default_limit, self.config.max_limit),
"default": self.config.default_limit,
"minimum": 1,
"maximum": self.config.max_limit
},
"threshold": {
"type": "number",
"description": format!("Minimum similarity threshold ({} to {}, default: use config value)",
self.config.min_threshold, self.config.max_threshold),
"minimum": self.config.min_threshold,
"maximum": self.config.max_threshold
},
"filetype": {
"type": "string",
"description": "Filter by file extension (e.g., '.rs', '.js', '.py', '.md')",
"pattern": "^\\.[a-zA-Z0-9]+$"
},
"filter": {
"type": "string",
"description": "Glob pattern filter (e.g., '*.rs', 'src/**/*.js', 'tests/**')"
},
"include_content": {
"type": "boolean",
"description": "Include full chunk content in results (default: true)",
"default": true
},
"context_lines": {
"type": "integer",
"description": format!("Number of context lines around matches (default: {}, max: {})",
self.config.default_context_lines, self.config.max_context_lines),
"default": self.config.default_context_lines,
"minimum": 0,
"maximum": self.config.max_context_lines
}
},
"required": ["query"],
"additionalProperties": false
})
}
async fn execute_search(&self, search_params: SearchToolParams) -> anyhow::Result<Value> {
let start_time = std::time::Instant::now();
self.validate_search_params(&search_params)?;
debug!(
"Executing search: query='{}', limit={}, threshold={:?}",
search_params.query.as_str(),
search_params.limit.value(),
search_params.threshold.as_ref().map(|t| t.value())
);
let threshold = search_params
.threshold
.map(|t| t.value())
.or(Some(self.turboprop_config.search.min_similarity));
let search_results = search_index_with_filters(
&self.index_path,
search_params.query.as_str(),
Some(search_params.limit.value()),
threshold,
search_params.filetype.clone(),
search_params.filter.clone(),
)
.await
.context("Search execution failed")?;
let execution_time = start_time.elapsed();
let mcp_results = self
.convert_results(
search_results.clone(),
search_params.include_content,
search_params.context_lines.map(|c| c.value()).unwrap_or(0),
)
.await?;
let total_results = search_results.len();
let result = SearchToolResult {
results: mcp_results,
total_results,
execution_time_ms: execution_time.as_millis() as u64,
query_info: SearchQueryInfo {
query: search_params.query.as_str().to_string(),
filters: SearchFilters {
filetype: search_params.filetype,
glob_pattern: search_params.filter,
},
limit: search_params.limit.value(),
threshold: threshold.unwrap_or(self.turboprop_config.search.min_similarity),
},
};
info!(
"Search completed: query='{}', results={}/{}, time={}ms",
search_params.query.as_str(),
result.results.len(),
total_results,
result.execution_time_ms
);
Ok(serde_json::to_value(result)?)
}
fn validate_search_params(&self, params: &SearchToolParams) -> anyhow::Result<()> {
if params.query.is_empty() {
anyhow::bail!("Query cannot be empty");
}
if params.query.len() > self.config.max_query_length {
anyhow::bail!(
"Query too long (max {} characters)",
self.config.max_query_length
);
}
if params.limit.value() > self.config.max_limit {
anyhow::bail!("Limit too high (max {})", self.config.max_limit);
}
if let Some(threshold) = ¶ms.threshold {
if !(self.config.min_threshold..=self.config.max_threshold).contains(&threshold.value())
{
anyhow::bail!(
"Threshold must be between {} and {}",
self.config.min_threshold,
self.config.max_threshold
);
}
}
if let Some(filetype) = ¶ms.filetype {
if !filetype.starts_with('.') || filetype.len() < 2 {
anyhow::bail!("File type must start with '.' and have at least one character (e.g., '.rs', '.js')");
}
}
if let Some(context_lines) = ¶ms.context_lines {
if context_lines.value() > self.config.max_context_lines {
anyhow::bail!(
"Context lines too high (max {})",
self.config.max_context_lines
);
}
}
Ok(())
}
async fn convert_results(
&self,
results: Vec<crate::types::SearchResult>,
include_content: bool,
context_lines: usize,
) -> anyhow::Result<Vec<McpSearchResult>> {
let mut mcp_results = Vec::new();
for result in results {
let relative_path = result
.chunk
.chunk
.source_location
.file_path
.strip_prefix(&self.repo_path)
.unwrap_or(&result.chunk.chunk.source_location.file_path)
.to_string_lossy()
.to_string();
let file_type = result
.chunk
.chunk
.source_location
.file_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("unknown")
.to_string();
let content = if include_content {
result.chunk.chunk.content.clone()
} else {
let preview_len = 200;
if result.chunk.chunk.content.len() > preview_len {
format!("{}...", &result.chunk.chunk.content[..preview_len])
} else {
result.chunk.chunk.content.clone()
}
};
let context = if context_lines > 0 {
None
} else {
None
};
let mcp_result = McpSearchResult {
file_path: relative_path,
line_number: result.chunk.chunk.source_location.start_line,
similarity_score: SimilarityScore::from(result.similarity),
content,
context,
file_type,
start_line: result.chunk.chunk.source_location.start_line,
end_line: result.chunk.chunk.source_location.end_line,
};
mcp_results.push(mcp_result);
}
Ok(mcp_results)
}
}
#[async_trait]
impl ToolExecutor for SemanticSearchTool {
async fn execute(&self, request: ToolCallRequest) -> McpResult<ToolCallResponse> {
debug!(
"Executing semantic search tool with arguments: {:?}",
request.arguments
);
self.validate_arguments(&request.arguments)?;
if self.is_mock {
let mock_result = json!({
"results": [
{
"file_path": "src/main.rs",
"line_number": 10,
"similarity_score": 0.85,
"content": "fn main() { println!(\"Hello, world!\"); }",
"file_type": "rs",
"start_line": 10,
"end_line": 10
}
],
"total_results": 1,
"execution_time_ms": 50,
"query_info": {
"query": request.arguments.get("query").unwrap_or(&json!("test")).as_str().unwrap_or("test"),
"filters": {},
"limit": 10,
"threshold": 0.3
}
});
return Ok(ToolCallResponse {
success: true,
content: Some(mock_result),
error: None,
});
}
let search_params: SearchToolParams =
serde_json::from_value(serde_json::to_value(&request.arguments).map_err(|e| {
McpError::tool_execution(
&request.name,
format!("Failed to serialize arguments: {}", e),
)
})?)
.map_err(|e| {
McpError::tool_execution(&request.name, format!("Failed to parse arguments: {}", e))
})?;
match self.execute_search(search_params).await {
Ok(result) => Ok(ToolCallResponse {
success: true,
content: Some(result),
error: None,
}),
Err(e) => Ok(ToolCallResponse {
success: false,
content: None,
error: Some(e.to_string()),
}),
}
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "semantic_search".to_string(),
description: "Semantic search across the indexed codebase using natural language queries. Supports filtering by file type, glob patterns, and similarity thresholds.".to_string(),
input_schema: self.create_input_schema(),
}
}
fn validate_arguments(&self, arguments: &HashMap<String, Value>) -> McpResult<()> {
if !arguments.contains_key("query") {
return Err(McpError::tool_execution(
"semantic_search",
"Missing required 'query' parameter",
));
}
if !arguments.get("query").unwrap().is_string() {
return Err(McpError::tool_execution(
"semantic_search",
"'query' parameter must be a string",
));
}
if let Some(limit_value) = arguments.get("limit") {
if let Some(limit) = limit_value.as_u64() {
if limit == 0 || limit > self.config.max_limit as u64 {
return Err(McpError::tool_execution(
"semantic_search",
format!("'limit' must be between 1 and {}", self.config.max_limit),
));
}
} else {
return Err(McpError::tool_execution(
"semantic_search",
"'limit' parameter must be an integer",
));
}
}
if let Some(threshold_value) = arguments.get("threshold") {
if let Some(threshold) = threshold_value.as_f64() {
if !(self.config.min_threshold as f64..=self.config.max_threshold as f64)
.contains(&threshold)
{
return Err(McpError::tool_execution(
"semantic_search",
format!(
"'threshold' must be between {} and {}",
self.config.min_threshold, self.config.max_threshold
),
));
}
} else {
return Err(McpError::tool_execution(
"semantic_search",
"'threshold' parameter must be a number",
));
}
}
Ok(())
}
}
pub struct Tools {
tools: HashMap<String, Box<dyn ToolExecutor + Send + Sync>>,
}
impl Tools {
pub fn new() -> Self {
let mut tools: HashMap<String, Box<dyn ToolExecutor + Send + Sync>> = HashMap::new();
if cfg!(test) {
let search_tool = SemanticSearchTool::new_mock();
tools.insert("semantic_search".to_string(), Box::new(search_tool));
}
Self { tools }
}
pub fn new_for_integration_tests() -> Self {
let mut tools: HashMap<String, Box<dyn ToolExecutor + Send + Sync>> = HashMap::new();
let search_tool = SemanticSearchTool::new_mock();
tools.insert("semantic_search".to_string(), Box::new(search_tool));
Self { tools }
}
pub fn with_search_tool(
index_path: std::path::PathBuf,
repo_path: std::path::PathBuf,
turboprop_config: TurboPropConfig,
) -> Self {
let mut tools: HashMap<String, Box<dyn ToolExecutor + Send + Sync>> = HashMap::new();
let search_tool = SemanticSearchTool::new(index_path, repo_path, turboprop_config);
tools.insert("semantic_search".to_string(), Box::new(search_tool));
Self { tools }
}
pub fn register_tool(&mut self, tool: Box<dyn ToolExecutor + Send + Sync>) {
let definition = tool.definition();
self.tools.insert(definition.name.clone(), tool);
}
pub fn list_tools(&self) -> Vec<ToolDefinition> {
self.tools.values().map(|tool| tool.definition()).collect()
}
pub async fn execute_tool(&self, request: ToolCallRequest) -> Result<ToolCallResponse> {
let tool = self
.tools
.get(&request.name)
.ok_or_else(|| anyhow::anyhow!("Tool not found: {}", request.name))?;
tool.execute(request)
.await
.map_err(|e| anyhow::anyhow!("Tool execution error: {}", e))
}
pub fn has_tool(&self, name: &str) -> bool {
self.tools.contains_key(name)
}
}
impl Default for Tools {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_search_tool_creation() {
let tool = SemanticSearchTool::new_mock();
let definition = tool.definition();
assert_eq!(definition.name, "semantic_search");
assert!(!definition.description.is_empty());
assert!(definition.input_schema.is_object());
}
#[test]
fn test_search_params_deserialization() {
let params = json!({
"query": "test function",
"limit": 5,
"threshold": 0.7,
"filetype": ".rs"
});
let search_params: SearchToolParams = serde_json::from_value(params).unwrap();
assert_eq!(search_params.query.as_str(), "test function");
assert_eq!(search_params.limit.value(), 5);
assert_eq!(
search_params.threshold.as_ref().map(|t| t.value()),
Some(0.7)
);
assert_eq!(search_params.filetype, Some(".rs".to_string()));
}
#[test]
fn test_search_params_defaults() {
let params = json!({
"query": "test function"
});
let search_params: SearchToolParams = serde_json::from_value(params).unwrap();
assert_eq!(search_params.query.as_str(), "test function");
assert_eq!(search_params.limit.value(), 10); assert_eq!(search_params.threshold, None);
assert_eq!(search_params.filetype, None);
assert!(search_params.include_content); }
#[test]
fn test_parameter_validation() {
let tool = SemanticSearchTool::new_mock();
let valid_params = SearchToolParams {
query: QueryString::from("test".to_string()),
limit: ResultLimit::from(10),
threshold: Some(SimilarityScore::from(0.5)),
filetype: Some(".rs".to_string()),
filter: None,
include_content: true,
context_lines: Some(ContextLines::from(2)),
};
assert!(tool.validate_search_params(&valid_params).is_ok());
let empty_query = SearchToolParams {
query: QueryString::from("".to_string()),
limit: ResultLimit::from(10),
threshold: None,
filetype: None,
filter: None,
include_content: true,
context_lines: None,
};
assert!(tool.validate_search_params(&empty_query).is_err());
let invalid_threshold = SearchToolParams {
query: QueryString::from("test".to_string()),
limit: ResultLimit::from(10),
threshold: Some(SimilarityScore::from(1.5)),
filetype: None,
filter: None,
include_content: true,
context_lines: None,
};
assert!(tool.validate_search_params(&invalid_threshold).is_err());
let invalid_filetype = SearchToolParams {
query: QueryString::from("test".to_string()),
limit: ResultLimit::from(10),
threshold: None,
filetype: Some("rs".to_string()), filter: None,
include_content: true,
context_lines: None,
};
assert!(tool.validate_search_params(&invalid_filetype).is_err());
}
#[test]
fn test_input_schema_structure() {
let tool = SemanticSearchTool::new_mock();
let definition = tool.definition();
let schema = &definition.input_schema;
assert!(schema["properties"]["query"].is_object());
assert!(schema["required"]
.as_array()
.unwrap()
.contains(&json!("query")));
assert!(schema["properties"]["limit"].is_object());
assert!(schema["properties"]["threshold"].is_object());
assert!(schema["properties"]["filetype"].is_object());
assert!(schema["properties"]["filter"].is_object());
}
#[test]
fn test_tools_registry() {
let tools = Tools::new();
assert!(tools.has_tool("semantic_search"));
let tool_list = tools.list_tools();
assert!(!tool_list.is_empty());
}
}