diff --git a/crates/fff-mcp/src/instructions.rs b/crates/fff-mcp/src/instructions.rs new file mode 100644 index 00000000..36936598 --- /dev/null +++ b/crates/fff-mcp/src/instructions.rs @@ -0,0 +1,158 @@ +use crate::ExposedTool; + +pub(crate) fn build_instructions(tools: &[ExposedTool]) -> String { + let has_find = tools.contains(&ExposedTool::FindFiles); + let has_grep = tools.contains(&ExposedTool::Grep); + let has_multi = tools.contains(&ExposedTool::MultiGrep); + + let mut s = String::new(); + s.push_str( + "FFF is a fast file finder with frecency-ranked results (frequent/recent files first, git-dirty files boosted).\n\n", + ); + + s.push_str("## Which Tool Should I Use?\n\n"); + if has_grep { + s.push_str("- **grep**: DEFAULT tool. Searches file CONTENTS -- definitions, usage, patterns. Use when you have a specific name or pattern.\n"); + } + if has_find { + s.push_str("- **find_files**: Explores which files/modules exist for a topic. Use when you DON'T have a specific identifier or LOOKING FOR A FILE.\n"); + } + if has_multi { + s.push_str("- **multi_grep**: OR logic across multiple patterns. Use for case variants (e.g. ['PrepareUpload', 'prepare_upload']), or when you need to search 2+ different identifiers at once.\n"); + } + + if has_grep || has_multi { + s.push_str("\n## Core Rules\n\n"); + s.push_str("### 1. Search BARE IDENTIFIERS only\n"); + s.push_str("Grep matches single lines. Search for ONE identifier per query:\n"); + s.push_str(" + 'InProgressQuote' -> finds definition + all usages\n"); + s.push_str(" + 'ActorAuth' -> finds enum, struct, all call sites\n"); + s.push_str( + " x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n", + ); + s.push_str(" x 'ctx.data::' -> code syntax, too specific, 0 results\n"); + s.push_str(" x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n"); + s.push_str(" x 'TODO.*#\\d+' -> complex regex, use simple 'TODO' then filter visually\n\n"); + + s.push_str("### 2. NEVER use regex unless you truly need alternation\n"); + s.push_str("Plain text search is faster and more reliable. Regex patterns like `.*`, `\\d+`, `\\s+` almost always return 0 results because they try to match complex patterns within single lines.\n"); + if has_multi { + s.push_str("If you need OR logic, use multi_grep with literal patterns instead of regex alternation.\n"); + } + s.push('\n'); + + s.push_str("### 3. Stop searching after 2 greps -- READ the code\n"); + s.push_str("After 2 grep calls, you have enough file paths. Read the top result to understand the code.\n"); + s.push_str("Do NOT keep grepping with variations. More greps != better understanding.\n\n"); + + if has_multi { + s.push_str("### 4. Use multi_grep for multiple identifiers\n"); + s.push_str("When you need to find different names (e.g. snake_case + PascalCase, or definition + usage patterns), use ONE multi_grep call instead of sequential greps:\n"); + s.push_str(" + multi_grep(['ActorAuth', 'PopulatedActorAuth', 'actor_auth'])\n"); + s.push_str(" x grep 'ActorAuth' -> grep 'PopulatedActorAuth' -> grep 'actor_auth' (3 calls wasted)\n\n"); + } + } + + s.push_str("## Workflow\n\n"); + if has_grep { + s.push_str("**Have a specific name?** -> grep the bare identifier.\n"); + } + if has_multi { + s.push_str( + "**Need multiple name variants?** -> multi_grep with all variants in one call.\n", + ); + } + if has_find { + s.push_str("**Exploring a topic / finding files?** -> find_files.\n"); + } + if has_grep || has_multi { + s.push_str("**Got results?** -> Read the top file. Don't grep again.\n"); + } + + if has_grep || has_multi { + s.push_str("\n## Constraint Syntax\n\n"); + if has_grep { + s.push_str("For grep: constraints go INLINE, prepended before the search text.\n"); + } + if has_multi { + s.push_str("For multi_grep: constraints go in the separate 'constraints' parameter.\n"); + } + s.push('\n'); + + s.push_str("Constraints MUST match one of these formats:\n"); + s.push_str(" Extension: '*.rs', '*.{ts,tsx}'\n"); + s.push_str(" Directory: 'src/', 'quotes/'\n"); + s.push_str(" Filename: 'schema.rs', 'src/main.rs'\n"); + s.push_str(" Exclude: '!test/', '!*.spec.ts'\n\n"); + + s.push_str("! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n"); + s.push_str(" + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n"); + s.push_str(" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n"); + s.push_str( + " x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n\n", + ); + + s.push_str("Prefer broad constraints:\n"); + s.push_str(" + '*.rs query' -> file type\n"); + s.push_str(" + 'quotes/ query' -> top-level dir\n"); + s.push_str(" x 'quotes/storage/db/ query' -> too specific, misses results\n\n"); + + s.push_str("## Output Format\n\n"); + s.push_str("grep results auto-expand definitions with body context (struct fields, function signatures).\n"); + s.push_str("This often provides enough information WITHOUT a follow-up Read call.\n"); + s.push_str( + "Lines marked with | are definition body context. [def] marks definition files.\n", + ); + s.push_str("-> Read suggestions point to the most relevant file -- follow them when you need more context.\n\n"); + + s.push_str("## Default Exclusions\n\n"); + s.push_str("If results are cluttered with irrelevant files, exclude them:\n"); + s.push_str(" !tests/ - exclude tests directory\n"); + s.push_str(" !*.spec.ts - exclude test files\n"); + s.push_str(" !generated/ - exclude generated code"); + } + + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_all_tools_mentions_all_three() { + let s = build_instructions(&ExposedTool::ALL); + assert!(s.contains("**find_files**")); + assert!(s.contains("**grep**")); + assert!(s.contains("**multi_grep**")); + assert!(s.contains("### 4. Use multi_grep")); + } + + #[test] + fn only_find_files_drops_grep_and_multi_grep_sections() { + let s = build_instructions(&[ExposedTool::FindFiles]); + assert!(s.contains("**find_files**")); + assert!(!s.contains("**grep**")); + assert!(!s.contains("**multi_grep**")); + assert!(!s.contains("Constraint Syntax")); + assert!(!s.contains("Core Rules")); + } + + #[test] + fn grep_without_multi_grep_drops_multi_grep_rule() { + let s = build_instructions(&[ExposedTool::Grep]); + assert!(s.contains("**grep**")); + assert!(!s.contains("**multi_grep**")); + assert!(!s.contains("### 4. Use multi_grep")); + assert!(!s.contains("use multi_grep with literal patterns")); + assert!(s.contains("Constraint Syntax")); + } + + #[test] + fn multi_grep_only_keeps_multi_grep_rules() { + let s = build_instructions(&[ExposedTool::MultiGrep]); + assert!(!s.contains("**grep**:")); + assert!(s.contains("**multi_grep**")); + assert!(s.contains("### 4. Use multi_grep")); + } +} diff --git a/crates/fff-mcp/src/main.rs b/crates/fff-mcp/src/main.rs index 17772820..29c02e4b 100644 --- a/crates/fff-mcp/src/main.rs +++ b/crates/fff-mcp/src/main.rs @@ -1,10 +1,11 @@ mod cursor; mod healthcheck; +mod instructions; mod output; mod server; mod update_check; -use clap::Parser; +use clap::{Parser, ValueEnum}; use fff::file_picker::FilePicker; use fff::frecency::FrecencyTracker; use fff::{FFFMode, SharedFilePicker, SharedFrecency}; @@ -16,81 +17,29 @@ use server::FffServer; #[global_allocator] static GLOBAL: MiMalloc = MiMalloc; -pub const MCP_INSTRUCTIONS: &str = concat!( - "FFF is a fast file finder with frecency-ranked results (frequent/recent files first, git-dirty files boosted).\n", - "\n", - "## Which Tool Should I Use?\n", - "\n", - "- **grep**: DEFAULT tool. Searches file CONTENTS -- definitions, usage, patterns. Use when you have a specific name or pattern.\n", - "- **find_files**: Explores which files/modules exist for a topic. Use when you DON'T have a specific identifier or LOOKING FOR A FILE.\n", - "- **multi_grep**: OR logic across multiple patterns. Use for case variants (e.g. ['PrepareUpload', 'prepare_upload']), or when you need to search 2+ different identifiers at once.\n", - "\n", - "## Core Rules\n", - "\n", - "### 1. Search BARE IDENTIFIERS only\n", - "Grep matches single lines. Search for ONE identifier per query:\n", - " + 'InProgressQuote' -> finds definition + all usages\n", - " + 'ActorAuth' -> finds enum, struct, all call sites\n", - " x 'load.*metadata.*InProgressQuote' -> regex spanning multiple tokens, 0 results\n", - " x 'ctx.data::' -> code syntax, too specific, 0 results\n", - " x 'struct ActorAuth' -> adding keywords narrows results, misses enums/traits/type aliases\n", - " x 'TODO.*#\\d+' -> complex regex, use simple 'TODO' then filter visually\n", - "\n", - "### 2. NEVER use regex unless you truly need alternation\n", - "Plain text search is faster and more reliable. Regex patterns like `.*`, `\\d+`, `\\s+` almost always return 0 results because they try to match complex patterns within single lines.\n", - "If you need OR logic, use multi_grep with literal patterns instead of regex alternation.\n", - "\n", - "### 3. Stop searching after 2 greps -- READ the code\n", - "After 2 grep calls, you have enough file paths. Read the top result to understand the code.\n", - "Do NOT keep grepping with variations. More greps != better understanding.\n", - "\n", - "### 4. Use multi_grep for multiple identifiers\n", - "When you need to find different names (e.g. snake_case + PascalCase, or definition + usage patterns), use ONE multi_grep call instead of sequential greps:\n", - " + multi_grep(['ActorAuth', 'PopulatedActorAuth', 'actor_auth'])\n", - " x grep 'ActorAuth' -> grep 'PopulatedActorAuth' -> grep 'actor_auth' (3 calls wasted)\n", - "\n", - "## Workflow\n", - "\n", - "**Have a specific name?** -> grep the bare identifier.\n", - "**Need multiple name variants?** -> multi_grep with all variants in one call.\n", - "**Exploring a topic / finding files?** -> find_files.\n", - "**Got results?** -> Read the top file. Don't grep again.\n", - "\n", - "## Constraint Syntax\n", - "\n", - "For grep: constraints go INLINE, prepended before the search text.\n", - "For multi_grep: constraints go in the separate 'constraints' parameter.\n", - "\n", - "Constraints MUST match one of these formats:\n", - " Extension: '*.rs', '*.{ts,tsx}'\n", - " Directory: 'src/', 'quotes/'\n", - " Filename: 'schema.rs', 'src/main.rs'\n", - " Exclude: '!test/', '!*.spec.ts'\n", - "\n", - "! Bare words without extensions are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n", - " + 'schema.rs TODO' -> searches for 'TODO' in files schema.rs\n", - " + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n", - " x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\n", - "\n", - "Prefer broad constraints:\n", - " + '*.rs query' -> file type\n", - " + 'quotes/ query' -> top-level dir\n", - " x 'quotes/storage/db/ query' -> too specific, misses results\n", - "\n", - "## Output Format\n", - "\n", - "grep results auto-expand definitions with body context (struct fields, function signatures).\n", - "This often provides enough information WITHOUT a follow-up Read call.\n", - "Lines marked with | are definition body context. [def] marks definition files.\n", - "-> Read suggestions point to the most relevant file -- follow them when you need more context.\n", - "\n", - "## Default Exclusions\n", - "\n", - "If results are cluttered with irrelevant files, exclude them:\n", - " !tests/ - exclude tests directory\n", - " !*.spec.ts - exclude test files\n", - " !generated/ - exclude generated code", -); +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, ValueEnum)] +#[value(rename_all = "snake_case")] +pub(crate) enum ExposedTool { + FindFiles, + Grep, + MultiGrep, +} + +impl ExposedTool { + pub(crate) fn tool_name(self) -> &'static str { + match self { + ExposedTool::FindFiles => "find_files", + ExposedTool::Grep => "grep", + ExposedTool::MultiGrep => "multi_grep", + } + } + + pub(crate) const ALL: [ExposedTool; 3] = [ + ExposedTool::FindFiles, + ExposedTool::Grep, + ExposedTool::MultiGrep, + ]; +} /// FFF MCP Server -- a high performance & accuracy file finder for AI code assistants. #[derive(Parser)] @@ -165,6 +114,10 @@ pub(crate) struct Args { default_value_t = 900 )] idle_timeout_secs: u64, + + /// Tools to expose (comma-separated). Defaults to all. + #[arg(long = "tools", value_enum, value_delimiter = ',', num_args = 1..)] + tools: Option>, } /// Resolve default paths for the log file. @@ -285,8 +238,18 @@ async fn main() -> Result<(), Box> { update_check::spawn_update_check(); } + let exposed_tools: Vec = args + .tools + .clone() + .map(|mut v| { + v.sort_by_key(|t| *t as u8); + v.dedup(); + v + }) + .unwrap_or_else(|| ExposedTool::ALL.to_vec()); + // Create and start the MCP server - let server = FffServer::new(shared_picker.clone()); + let server = FffServer::new(shared_picker.clone(), &exposed_tools); let last_activity = server.last_activity(); let idle_timeout_secs = args.idle_timeout_secs; diff --git a/crates/fff-mcp/src/server.rs b/crates/fff-mcp/src/server.rs index c4affa33..49e07d6c 100644 --- a/crates/fff-mcp/src/server.rs +++ b/crates/fff-mcp/src/server.rs @@ -1,9 +1,12 @@ use crate::cursor::CursorStore; use crate::output::{GrepFormatter, OutputMode, file_suffix}; +use crate::ExposedTool; +use crate::instructions::build_instructions; use fff::grep::{GrepMode, GrepSearchOptions, has_regex_metacharacters}; use fff::types::{FileItem, PaginationArgs}; use fff::{FuzzySearchOptions, QueryParser, SharedFilePicker}; use fff_query_parser::AiGrepConfig; +use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::*; use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router}; @@ -175,6 +178,8 @@ pub struct FffServer { update_notice_sent: Arc, last_activity: Arc, scan_ready: Arc, + tool_router: ToolRouter, + instructions: Arc, } fn now_secs() -> u64 { @@ -185,13 +190,21 @@ fn now_secs() -> u64 { } impl FffServer { - pub fn new(picker: SharedFilePicker) -> Self { + pub fn new(picker: SharedFilePicker, exposed_tools: &[ExposedTool]) -> Self { + let mut router = Self::tool_router(); + for tool in ExposedTool::ALL { + if !exposed_tools.contains(&tool) { + router.remove_route(tool.tool_name()); + } + } Self { picker, cursor_store: Arc::new(Mutex::new(CursorStore::new())), update_notice_sent: Arc::new(AtomicBool::new(false)), last_activity: Arc::new(AtomicU64::new(now_secs())), scan_ready: Arc::new(AtomicBool::new(false)), + tool_router: router, + instructions: Arc::from(build_instructions(exposed_tools)), } } @@ -692,14 +705,14 @@ impl FffServer { } } -#[tool_handler] +#[tool_handler(router = self.tool_router)] impl ServerHandler for FffServer { fn get_info(&self) -> ServerInfo { let notice = crate::update_check::get_update_notice(); let instructions = if notice.is_empty() { - crate::MCP_INSTRUCTIONS.to_string() + self.instructions.to_string() } else { - format!("{}{}", crate::MCP_INSTRUCTIONS, notice) + format!("{}{}", self.instructions, notice) }; ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) @@ -765,4 +778,40 @@ mod tests { serde_json::from_str(r#"{"pattern":"foo"}"#).expect("pattern alias"); assert_eq!(via_pattern.query, "foo"); } + + fn tool_names_for(exposed: &[ExposedTool]) -> Vec { + let server = FffServer::new(SharedFilePicker::default(), exposed); + let mut names: Vec = server + .tool_router + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + names.sort(); + names + } + + #[test] + fn router_defaults_to_all_three_tools() { + assert_eq!( + tool_names_for(&ExposedTool::ALL), + vec!["find_files", "grep", "multi_grep"] + ); + } + + #[test] + fn router_exposes_only_find_files_when_requested() { + assert_eq!( + tool_names_for(&[ExposedTool::FindFiles]), + vec!["find_files"] + ); + } + + #[test] + fn router_exposes_only_grep_pair_when_multi_grep_dropped() { + assert_eq!( + tool_names_for(&[ExposedTool::Grep, ExposedTool::MultiGrep]), + vec!["grep", "multi_grep"] + ); + } }