scryer-mcp 0.3.0

Model Context Protocol (MCP) server for Scryer code intelligence
pub mod admin;
pub mod adr;
pub mod adr_rank;
pub mod budget;
pub mod dependency;
pub mod enums;
pub mod graph;
pub mod inspect;
pub mod navigation;
pub mod render;
pub mod schema;
pub mod search;
pub(crate) mod source_lines;

use rmcp::model::{MetaObject, Tool};

pub use crate::session::{AlwaysLoad, ToolProfile};

/// The tools every session lists under [`ToolProfile::Core`] and marks `alwaysLoad` for
/// Claude Code: finding, understanding and impact-checking code. The rest stay callable.
pub const CORE_TOOLS: &[&str] = &[
    "search_symbols",
    "inspect_symbol",
    "find_references",
    "get_file_outline",
    "get_enclosing_scope",
    "calculate_blast_radius",
];

/// Definitions with every accepted parameter, before [`schema::publish_schema`] hides the
/// shared ones. Argument coercion works against these.
pub(crate) fn accepted_tool_definitions() -> Vec<Tool> {
    let mut tools = Vec::with_capacity(19);
    tools.extend(admin::tool_definitions());
    tools.extend(navigation::tool_definitions());
    tools.extend(graph::tool_definitions());
    tools.extend(adr::tool_definitions());
    tools.extend(dependency::tool_definitions());
    tools.extend(search::tool_definitions());
    tools.extend(inspect::tool_definitions());
    tools
}

/// Returns definitions for all 19 tools, as published to clients.
pub fn all_tool_definitions() -> Vec<Tool> {
    let mut tools = accepted_tool_definitions();
    for tool in &mut tools {
        let mut schema = (*tool.input_schema).clone();
        schema::publish_schema(&tool.name, &mut schema);
        tool.input_schema = std::sync::Arc::new(schema);
    }
    tools
}

/// The tools a session lists. Under [`AlwaysLoad::Core`] the core tools carry
/// `_meta["anthropic/alwaysLoad"]`, so Claude Code loads their schemas up front instead of
/// deferring them behind `ToolSearch` (where they are mostly never used); other clients
/// ignore the key. Listing fewer tools never makes one uncallable: dispatch accepts every
/// tool name under every profile.
pub fn tool_definitions_for(profile: ToolProfile, always_load: AlwaysLoad) -> Vec<Tool> {
    let mut tools = all_tool_definitions();
    if profile == ToolProfile::Core {
        tools.retain(|t| CORE_TOOLS.contains(&&*t.name));
    }
    if always_load == AlwaysLoad::Core {
        for tool in tools.iter_mut().filter(|t| CORE_TOOLS.contains(&&*t.name)) {
            let mut meta = serde_json::Map::new();
            meta.insert("anthropic/alwaysLoad".into(), serde_json::Value::Bool(true));
            tool.meta = Some(MetaObject(meta));
        }
    }
    tools
}

/// Append the CLI form to a `see_also` entry that points at a tool a core-profile session
/// does not list, e.g. `trace_call_hierarchy(...)` becomes
/// `trace_call_hierarchy(...) (CLI: scryer trace-call-hierarchy)`.
pub fn cli_hint(see_also: &str) -> String {
    let name: String = see_also
        .chars()
        .take_while(|c| c.is_ascii_lowercase() || *c == '_')
        .collect();
    let is_extended = all_tool_definitions().iter().any(|t| t.name == name)
        && !CORE_TOOLS.contains(&name.as_str());
    if is_extended {
        format!("{see_also} (CLI: scryer {})", name.replace('_', "-"))
    } else {
        see_also.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cli_hints_name_the_cli_form_of_extended_tools_only() {
        assert_eq!(
            cli_hint("trace_call_hierarchy(symbol: \"x\") for all 9 callers"),
            "trace_call_hierarchy(symbol: \"x\") for all 9 callers (CLI: scryer trace-call-hierarchy)"
        );
        assert_eq!(
            cli_hint("query_adrs for the full text"),
            "query_adrs for the full text (CLI: scryer query-adrs)"
        );
        assert_eq!(
            cli_hint("find_references(symbol: \"x\")"),
            "find_references(symbol: \"x\")"
        );
        assert_eq!(
            cli_hint("rest of body: lines 3-9"),
            "rest of body: lines 3-9"
        );
    }

    #[test]
    fn the_core_tools_all_exist() {
        let names: Vec<String> = all_tool_definitions()
            .iter()
            .map(|t| t.name.to_string())
            .collect();
        for core in CORE_TOOLS {
            assert!(names.iter().any(|n| n == core), "{core} is not a tool");
        }
    }
}