Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions crates/fff-mcp/src/instructions.rs
Original file line number Diff line number Diff line change
@@ -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::<ActorAuth>' -> 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"));
}
}
117 changes: 40 additions & 77 deletions crates/fff-mcp/src/main.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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::<ActorAuth>' -> 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)]
Expand Down Expand Up @@ -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<Vec<ExposedTool>>,
}

/// Resolve default paths for the log file.
Expand Down Expand Up @@ -285,8 +238,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
update_check::spawn_update_check();
}

let exposed_tools: Vec<ExposedTool> = 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;

Expand Down
Loading
Loading