scryer-mcp 0.2.1

Model Context Protocol (MCP) server for Scryer code intelligence
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use rmcp::model::Tool;
use scryer_engine::EngineService;
use scryer_engine::search::{SearchQuery, SearchScope};

use super::admin::{make_tool, read_only};
use crate::context::ProjectContextResolver;

// --- Input & Output Types ---

#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct SearchSymbolsParams {
    /// Concept or keyword query (e.g. 'retry backoff', 'token budget truncation'). Identifiers are split on camelCase and snake_case. Provide exactly one of `query` or `like_symbol`.
    pub query: Option<String>,
    /// Find symbols similar to this one (name or qualified name), excluding itself. Provide exactly one of `query` or `like_symbol`.
    pub like_symbol: Option<String>,
    /// Optional symbol kinds to keep (e.g. ["fn", "struct", "trait", "enum"]).
    pub kinds: Option<Vec<String>>,
    /// Optional case-insensitive substring filter on file paths.
    pub file_filter: Option<String>,
    /// project (default) searches the project; dependencies searches its Cargo dependencies.
    pub scope_level: Option<super::enums::SearchScopeLevel>,
    /// With scope_level 'dependencies': restrict to one crate (e.g. 'tokio').
    pub crate_name: Option<String>,
    /// Zero-based offset for paginating results.
    pub offset: Option<usize>,
    /// Maximum results to return (default: 20, max: 50).
    pub limit: Option<usize>,
    /// If true, omits signatures to minimize token usage.
    pub compact: Option<bool>,
    /// Optional project slug or ID override.
    pub project: Option<String>,
    /// Custom token budget for this response.
    pub max_tokens: Option<usize>,
    /// If true, bypasses token budget truncation.
    pub no_truncate: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SymbolSearchMatch {
    pub name: String,
    pub qualified_name: String,
    pub kind: String,
    pub file_path: String,
    pub start_line: u32,
    pub end_line: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub signature: Option<String>,
    pub score: f64,
    pub matched_terms: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub crate_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SearchSymbolsResult {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub query: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub like_symbol: Option<String>,
    pub scope_level: String,
    pub query_terms: Vec<String>,
    pub total: usize,
    pub returned: usize,
    pub offset: usize,
    pub has_more: bool,
    pub symbols: Vec<SymbolSearchMatch>,
}

// --- Tool Handlers ---

pub async fn handle_search_symbols(
    context: &ProjectContextResolver,
    engine: &EngineService,
    params: SearchSymbolsParams,
) -> anyhow::Result<SearchSymbolsResult> {
    let query = params.query.filter(|q| !q.trim().is_empty());
    let like_symbol = params.like_symbol.filter(|s| !s.trim().is_empty());
    anyhow::ensure!(
        query.is_some() != like_symbol.is_some(),
        "Provide exactly one of `query` or `like_symbol`"
    );

    use super::enums::SearchScopeLevel;
    let level = params.scope_level.unwrap_or(SearchScopeLevel::Project);
    let scope_level = level.as_str().to_string();
    let scope = match level {
        SearchScopeLevel::Project => {
            anyhow::ensure!(
                params.crate_name.is_none(),
                "`crate_name` requires scope_level 'dependencies'"
            );
            SearchScope::Project
        }
        SearchScopeLevel::Dependencies => SearchScope::Dependencies {
            crate_name: params.crate_name.clone(),
        },
    };

    let (project, _) = context
        .resolve_project(None, params.project.as_deref())
        .await?;

    let offset = params.offset.unwrap_or(0);
    let results = engine
        .search_symbols(
            project.id,
            SearchQuery {
                text: query.clone(),
                like_symbol: like_symbol.clone(),
                kinds: params.kinds.unwrap_or_default(),
                file_filter: params.file_filter,
                scope,
                offset,
                limit: params.limit.unwrap_or(20).clamp(1, 50),
            },
        )
        .await?;

    let compact = params.compact.unwrap_or(false);
    let symbols: Vec<SymbolSearchMatch> = results
        .hits
        .into_iter()
        .map(|h| SymbolSearchMatch {
            name: h.name,
            qualified_name: h.qualified_name,
            kind: h.kind,
            file_path: h.file_path,
            start_line: h.start_line,
            end_line: h.end_line,
            signature: (!compact).then_some(h.signature),
            score: (f64::from(h.score) * 100.0).round() / 100.0,
            matched_terms: h.matched_terms,
            crate_name: h.crate_name,
            version: h.version,
        })
        .collect();

    Ok(SearchSymbolsResult {
        query,
        like_symbol: results.like_symbol.or(like_symbol),
        scope_level,
        query_terms: results.query_terms,
        total: results.total,
        returned: symbols.len(),
        offset: results.offset,
        has_more: results.has_more,
        symbols,
    })
}

// --- Tool Definitions ---

pub fn tool_definitions() -> Vec<Tool> {
    vec![make_tool::<SearchSymbolsParams>(
        "search_symbols",
        "Use when you don't know a symbol's exact name, instead of grepping: ranks functions, types and traits by concept keywords (e.g. 'retry backoff') with BM25 over names, paths, signatures and docstrings (bodies, string literals and ordinary comments are not matched), finds code similar to a known symbol via `like_symbol` (provide exactly one of `query` or `like_symbol`), and with scope_level 'dependencies' (plus optional crate_name) searches third-party crates.",
        read_only(),
    )]
}