scryer-mcp 0.3.0

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;

/// Hits returned when `limit` is not given: agents read the first few and the default must
/// leave room in the budget for each hit's signature and summary.
const DEFAULT_LIMIT: usize = 8;

/// Lines of code quoted for the best hit.
const SNIPPET_LINES: usize = 10;

/// The best hit leads when its score is at least this many times the second's.
const CONFIDENT_RATIO: f32 = 1.5;

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

#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct SearchSymbolsParams {
    /// Concept keywords, e.g. 'retry backoff'. Give exactly one of query or like_symbol.
    pub query: Option<String>,
    /// Find code similar to this symbol. Give exactly one of query or like_symbol.
    pub like_symbol: Option<String>,
    /// Kinds to keep, e.g. fn, struct, trait.
    pub kinds: Option<Vec<String>>,
    /// Path substring filter.
    pub file_filter: Option<String>,
    /// project (default) or dependencies (Cargo crates).
    pub scope_level: Option<super::enums::SearchScopeLevel>,
    /// With 'dependencies': one crate.
    pub crate_name: Option<String>,
    /// Pagination offset.
    pub offset: Option<usize>,
    /// Maximum results (default 8, max 50).
    pub limit: Option<usize>,
    /// Omit signatures and doc summaries.
    pub compact: Option<bool>,
    /// Optional project slug or ID override.
    pub project: Option<String>,
    /// json (default) or text: a header line, then one line per item.
    pub format: Option<crate::session::ResponseFormat>,
    /// 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>,
    /// First sentence of the doc comment.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub doc: 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>,
    /// First lines of the code, on the best hit of a `query`: the usual next step is reading it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub snippet: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SearchSymbolsResult {
    /// Set when the list was cut to fit the budget: how to get the rest.
    #[serde(flatten, default)]
    pub next: crate::tools::budget::Continuation,
    #[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,
    /// The best hit is far ahead of the second and matched every term: no need to look further.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub confident: 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(DEFAULT_LIMIT).clamp(1, 50),
            },
        )
        .await?;

    let compact = params.compact.unwrap_or(false);
    let confident = like_symbol.is_none()
        && offset == 0
        && results.hits.first().is_some_and(|top| {
            top.matched_terms.len() >= results.query_terms.len()
                && results
                    .hits
                    .get(1)
                    .is_none_or(|second| top.score >= CONFIDENT_RATIO * second.score)
        });
    let mut 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),
            doc: (!compact && !h.doc_snippet.is_empty())
                .then(|| super::inspect::first_sentence(&h.doc_snippet)),
            snippet: None,
            score: (f64::from(h.score) * 100.0).round() / 100.0,
            matched_terms: h.matched_terms,
            crate_name: h.crate_name,
            version: h.version,
        })
        .collect();

    if query.is_some()
        && offset == 0
        && !compact
        && level == SearchScopeLevel::Project
        && let Some(top) = symbols.first_mut()
    {
        top.snippet = top_hit_snippet(engine, &project, top).await;
    }

    Ok(SearchSymbolsResult {
        confident,
        next: Default::default(),
        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,
    })
}

/// The first lines of the hit's code, if its file still matches the index.
async fn top_hit_snippet(
    engine: &EngineService,
    project: &scryer_db::Project,
    hit: &SymbolSearchMatch,
) -> Option<String> {
    use scryer_db::SourceFile;
    let hash = {
        let mut guard = engine.db().lock().await;
        SourceFile::filter(
            SourceFile::fields()
                .project_id()
                .eq(project.id)
                .and(SourceFile::fields().path().eq(&hit.file_path)),
        )
        .first()
        .exec(&mut *guard)
        .await
        .ok()??
        .content_hash
    };
    let end = hit.end_line.min(hit.start_line + SNIPPET_LINES as u32 - 1);
    super::source_lines::LineCache::default()
        .range(
            std::path::Path::new(&project.root_path),
            &hit.file_path,
            &hash,
            hit.start_line,
            end,
            SNIPPET_LINES,
        )
        .map(|(text, _)| text)
}

// --- 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') over names, paths, signatures and docstrings. Hits carry signature and doc summary; a query's best hit includes its first lines, and confident means stop looking. like_symbol finds similar code; scope_level 'dependencies' searches third-party crates.",
        read_only(),
    )]
}

impl crate::tools::budget::Paged for SearchSymbolsResult {
    const TOOL: &'static str = "search_symbols";
    fn item_count(&self) -> usize {
        self.symbols.len()
    }
    fn page_offset(&self) -> usize {
        self.offset
    }
    fn keep_first(&mut self, kept: usize) {
        self.symbols.truncate(kept);
        self.returned = kept;
        self.has_more = true;
    }
    fn continuation_mut(&mut self) -> &mut crate::tools::budget::Continuation {
        &mut self.next
    }
    fn render_text(&self) -> String {
        crate::tools::render::search_symbols(self)
    }
}