From 4746845cf1586b02a773fe92b5bd5ae7381ecc40 Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:01:42 -0700 Subject: [PATCH 1/2] feat(mcp): --tools flag to select exposed MCP tools (#716) Add a CLI-configurable tool selector to fff-mcp. Default stays "all three" so existing configs are unchanged; picking a subset removes the unused tools from the router and drops the parts of MCP_INSTRUCTIONS that describe them, saving system-prompt tokens. - clap ValueEnum accepts find_files,grep,multi_grep (snake_case). Unknown names fail startup with the list of valid values. - FffServer::new takes the exposed set, filters the rmcp ToolRouter via remove_route, and builds instructions dynamically. - MCP_INSTRUCTIONS is now assembled by build_instructions() in a new instructions module; sections referring to disabled tools (e.g. multi_grep rule 4, grep constraint syntax) are omitted. Closes #716 --- crates/fff-mcp/src/instructions.rs | 158 +++++++++++++++++++++++++++++ crates/fff-mcp/src/main.rs | 127 +++++++++-------------- crates/fff-mcp/src/server.rs | 60 ++++++++++- 3 files changed, 264 insertions(+), 81 deletions(-) create mode 100644 crates/fff-mcp/src/instructions.rs diff --git a/crates/fff-mcp/src/instructions.rs b/crates/fff-mcp/src/instructions.rs new file mode 100644 index 00000000..fbd450c0 --- /dev/null +++ b/crates/fff-mcp/src/instructions.rs @@ -0,0 +1,158 @@ +use crate::ExposedTool; + +pub 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..6b3583e5 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,33 @@ 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", -); +pub use instructions::build_instructions; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, ValueEnum)] +#[value(rename_all = "snake_case")] +pub enum ExposedTool { + FindFiles, + Grep, + MultiGrep, +} + +impl ExposedTool { + pub fn tool_name(self) -> &'static str { + match self { + ExposedTool::FindFiles => "find_files", + ExposedTool::Grep => "grep", + ExposedTool::MultiGrep => "multi_grep", + } + } + + pub fn 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 +118,16 @@ pub(crate) struct Args { default_value_t = 900 )] idle_timeout_secs: u64, + + /// Comma-separated list of tools to expose. Defaults to all three. + /// Unknown names cause startup to fail with the list of valid values. + #[arg( + long = "tools", + value_enum, + value_delimiter = ',', + num_args = 1.., + )] + tools: Option>, } /// Resolve default paths for the log file. @@ -285,8 +248,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..7fb017ca 100644 --- a/crates/fff-mcp/src/server.rs +++ b/crates/fff-mcp/src/server.rs @@ -1,9 +1,11 @@ use crate::cursor::CursorStore; use crate::output::{GrepFormatter, OutputMode, file_suffix}; +use crate::{ExposedTool, 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 +177,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 +189,25 @@ 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::FindFiles, + ExposedTool::Grep, + ExposedTool::MultiGrep, + ] { + 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 +708,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 +781,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"] + ); + } } From 1dcdc2b493c0fc79cf856aff783715c2876ef04a Mon Sep 17 00:00:00 2001 From: gustav-fff <286169375+gustav-fff@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:59:04 -0700 Subject: [PATCH 2/2] chore(mcp): tighten visibility and trim comments on --tools - ExposedTool + tool_name are pub(crate); ALL is a const array. - Drop the re-export of build_instructions; server imports it directly. - Server reuses ExposedTool::ALL instead of a hardcoded list. - Shorter --tools clap doc (clap already prints valid values). --- crates/fff-mcp/src/instructions.rs | 4 ++-- crates/fff-mcp/src/main.rs | 30 ++++++++++-------------------- crates/fff-mcp/src/server.rs | 11 ++++------- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/crates/fff-mcp/src/instructions.rs b/crates/fff-mcp/src/instructions.rs index fbd450c0..36936598 100644 --- a/crates/fff-mcp/src/instructions.rs +++ b/crates/fff-mcp/src/instructions.rs @@ -1,6 +1,6 @@ use crate::ExposedTool; -pub fn build_instructions(tools: &[ExposedTool]) -> String { +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); @@ -121,7 +121,7 @@ mod tests { #[test] fn default_all_tools_mentions_all_three() { - let s = build_instructions(&ExposedTool::all()); + let s = build_instructions(&ExposedTool::ALL); assert!(s.contains("**find_files**")); assert!(s.contains("**grep**")); assert!(s.contains("**multi_grep**")); diff --git a/crates/fff-mcp/src/main.rs b/crates/fff-mcp/src/main.rs index 6b3583e5..29c02e4b 100644 --- a/crates/fff-mcp/src/main.rs +++ b/crates/fff-mcp/src/main.rs @@ -17,18 +17,16 @@ use server::FffServer; #[global_allocator] static GLOBAL: MiMalloc = MiMalloc; -pub use instructions::build_instructions; - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, ValueEnum)] #[value(rename_all = "snake_case")] -pub enum ExposedTool { +pub(crate) enum ExposedTool { FindFiles, Grep, MultiGrep, } impl ExposedTool { - pub fn tool_name(self) -> &'static str { + pub(crate) fn tool_name(self) -> &'static str { match self { ExposedTool::FindFiles => "find_files", ExposedTool::Grep => "grep", @@ -36,13 +34,11 @@ impl ExposedTool { } } - pub fn all() -> [ExposedTool; 3] { - [ - ExposedTool::FindFiles, - ExposedTool::Grep, - ExposedTool::MultiGrep, - ] - } + 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. @@ -119,14 +115,8 @@ pub(crate) struct Args { )] idle_timeout_secs: u64, - /// Comma-separated list of tools to expose. Defaults to all three. - /// Unknown names cause startup to fail with the list of valid values. - #[arg( - long = "tools", - value_enum, - value_delimiter = ',', - num_args = 1.., - )] + /// Tools to expose (comma-separated). Defaults to all. + #[arg(long = "tools", value_enum, value_delimiter = ',', num_args = 1..)] tools: Option>, } @@ -256,7 +246,7 @@ async fn main() -> Result<(), Box> { v.dedup(); v }) - .unwrap_or_else(|| ExposedTool::all().to_vec()); + .unwrap_or_else(|| ExposedTool::ALL.to_vec()); // Create and start the MCP server let server = FffServer::new(shared_picker.clone(), &exposed_tools); diff --git a/crates/fff-mcp/src/server.rs b/crates/fff-mcp/src/server.rs index 7fb017ca..49e07d6c 100644 --- a/crates/fff-mcp/src/server.rs +++ b/crates/fff-mcp/src/server.rs @@ -1,6 +1,7 @@ use crate::cursor::CursorStore; use crate::output::{GrepFormatter, OutputMode, file_suffix}; -use crate::{ExposedTool, build_instructions}; +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}; @@ -191,11 +192,7 @@ fn now_secs() -> u64 { impl FffServer { pub fn new(picker: SharedFilePicker, exposed_tools: &[ExposedTool]) -> Self { let mut router = Self::tool_router(); - for tool in [ - ExposedTool::FindFiles, - ExposedTool::Grep, - ExposedTool::MultiGrep, - ] { + for tool in ExposedTool::ALL { if !exposed_tools.contains(&tool) { router.remove_route(tool.tool_name()); } @@ -797,7 +794,7 @@ mod tests { #[test] fn router_defaults_to_all_three_tools() { assert_eq!( - tool_names_for(&ExposedTool::all()), + tool_names_for(&ExposedTool::ALL), vec!["find_files", "grep", "multi_grep"] ); }