Compare commits

...

6 Commits

Author SHA1 Message Date
Dmitriy Kovalenko fcdf4a9172 feat(mcp): Make install script understand upgrade use case 2026-03-13 16:45:21 -07:00
Dmitriy Kovalenko 1001eb8b5e feat(mcp): Add file path fallback for grep (#281)
* feat(mcp): Add file path fallback for grep

* chore: Update docs for - feat(mcp): Add file path fallback for grep
2026-03-13 16:29:24 -07:00
Dmitriy Kovalenko f0ce2dd50d chore: fix format 2026-03-13 09:58:27 -07:00
Dmitriy Kovalenko 1c2a1c1204 fix(grep): file path single file constraint parsing (#280) 2026-03-12 17:10:28 -07:00
Salomon Popp 66bdfff454 fix: correct typo in vim.uv.fs_unlink() (#279) 2026-03-12 16:51:20 -07:00
Dmitriy Kovalenko 1e50f8df80 fix: Prevent mcp from running root large folders (#278) 2026-03-12 08:35:27 -07:00
16 changed files with 196 additions and 136 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
# - os: ubuntu-latest TODO uncomment once bun stop crashing
- os: macos-latest
- os: windows-latest
target: x86_64-pc-windows-msvc
-4
View File
@@ -31,10 +31,6 @@ use ffi_types::{
FffResult, GrepSearchOptionsJson, InitOptions, MultiGrepOptionsJson, ScanProgress,
SearchOptions,
};
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
/// Opaque fff_handle holding all per-instance state.
///
+2 -1
View File
@@ -858,7 +858,7 @@ fn scan_filesystem(
.build_parallel();
let walker_start = std::time::Instant::now();
info!("SCAN: Starting file walker");
debug!("SCAN: Starting file walker");
let files = Arc::new(std::sync::Mutex::new(Vec::new()));
walker.run(|| {
@@ -903,6 +903,7 @@ fn scan_filesystem(
let frecency = shared_frecency
.read()
.map_err(|_| Error::AcquireFrecencyLock)?;
files
.par_iter_mut()
.try_for_each(|file| -> Result<(), Error> {
+4 -1
View File
@@ -167,7 +167,10 @@ impl FrecencyTracker {
}
}
if read_errors > 0 {
tracing::warn!(read_errors, "Skipped corrupted entries during compaction read");
tracing::warn!(
read_errors,
"Skipped corrupted entries during compaction read"
);
}
entries
}
+4 -1
View File
@@ -1556,7 +1556,10 @@ pub fn grep_search<'a>(
prepare_files_to_search(files, constraints_from_query, options);
// If constraints yielded 0 files and we had a FilePath constraint,
// retry without it (the path token was likely part of the search text).
// retry without it — the filename may not exist in this repo.
// Keep the original grep_text (e.g. "ActorAuth") rather than restoring
// the raw query ("nonexistent.rs ActorAuth"), since the search term
// was correctly extracted by the parser.
if files_to_search.is_empty()
&& let Some(stripped) = strip_file_path_constraints(constraints_from_query)
{
+1
View File
@@ -327,6 +327,7 @@ pub fn match_and_score_files<'a>(
match_type: match filename_match {
Some(filename_match) if filename_match.exact => "exact_filename",
Some(_) => "fuzzy_filename",
None if path_match.exact => "exact_path",
None => "fuzzy_path",
},
};
+16 -7
View File
@@ -17,6 +17,7 @@ use clap::Parser;
use fff_core::file_picker::FilePicker;
use fff_core::frecency::FrecencyTracker;
use fff_core::{FFFMode, SharedFrecency, SharedPicker};
use git2::Repository;
use mimalloc::MiMalloc;
use rmcp::{ServiceExt, transport::stdio};
use server::FffServer;
@@ -70,13 +71,15 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
"For multi_grep: constraints go in the separate 'constraints' parameter.\n",
"\n",
"Constraints MUST match one of these formats:\n",
" Extension: '*.rs', '*.{ts,tsx}' (starts with *.)\n",
" Directory: 'src/', 'quotes/' (ends with /)\n",
" Exclude: '!test/', '!*.spec.ts' (starts with !)\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 are NOT constraints. 'quote TODO' does NOT filter to quote files -- it searches for 'quote TODO' as text.\n",
" + 'quotes/ TODO' -> searches for 'TODO' in the quotes/ directory\n",
" x 'quote TODO' -> searches for literal text 'quote TODO', finds nothing\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",
@@ -100,7 +103,7 @@ pub const MCP_INSTRUCTIONS: &str = concat!(
/// FFF MCP Server — high-performance file finder for AI code assistants.
#[derive(Parser)]
#[command(name = "fff-mcp", version = env!("CARGO_PKG_VERSION"))]
#[command(name = "fff-mcp", version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("FFF_GIT_HASH"), ")"))]
struct Args {
/// Base directory to index. Defaults to the current working directory.
#[arg(value_name = "PATH")]
@@ -205,6 +208,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.to_string_lossy()
.to_string()
});
if Repository::discover(&base_path).is_err() {
tracing::error!("MCP server must be run within a Git repository");
return Err(format!("Not a Git repository: {}", base_path).into());
}
let frecency_db_path = args.frecency_db_path.unwrap_or_default();
let shared_picker: SharedPicker = Arc::new(RwLock::new(None));
+36 -31
View File
@@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
use fff_core::file_picker::FilePicker;
use fff_core::grep::{self, GrepMode, GrepSearchOptions, has_regex_metacharacters};
use fff_core::types::{FileItem, PaginationArgs};
use fff_core::{Constraint, FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use fff_core::{FuzzySearchOptions, QueryParser, SharedFrecency, SharedPicker};
use fff_query_parser::AiGrepConfig;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
@@ -21,12 +21,12 @@ use rmcp::{ServerHandler, schemars, tool, tool_handler, tool_router};
use crate::cursor::CursorStore;
use crate::output::{GrepFormatter, OutputMode, file_suffix};
/// Strip common delimiters for fuzzy fallback queries.
fn strip_delimiters(s: &str) -> String {
/// Strip common delimiters and lowercase for fuzzy fallback queries.
fn cleanup_fuzzy_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if !matches!(c, ':' | '-' | '_') {
out.push(c);
out.extend(c.to_lowercase());
}
}
out
@@ -305,7 +305,7 @@ impl FffServer {
}
// Fuzzy fallback for typo tolerance
let fuzzy_query = strip_delimiters(&query.to_lowercase());
let fuzzy_query = cleanup_fuzzy_query(query);
let (fuzzy_options, _) = make_grep_options(output_mode, GrepMode::Fuzzy, 0, Some(0));
let fuzzy_parsed = parser.parse(&fuzzy_query);
let fuzzy_result =
@@ -331,35 +331,40 @@ impl FffServer {
)]));
}
let hint = match &parsed {
Some(q)
if q.constraints
.iter()
.any(|c| matches!(c, Constraint::FilePath(_))) =>
// File path fallback: if query looks like a path, suggest the matching file
if query.contains('/') {
let file_parser = QueryParser::default();
let file_query = file_parser.parse(query);
let file_opts = FuzzySearchOptions {
max_threads: 0,
current_file: None,
project_path: Some(picker.base_path()),
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 1,
},
};
let file_result = FilePicker::fuzzy_search(files, query, file_query, file_opts);
if let (Some(top), Some(score)) =
(file_result.items.first(), file_result.scores.first())
{
let path = q
.constraints
.iter()
.find_map(|c| match c {
Constraint::FilePath(p) => Some(*p),
_ => None,
})
.unwrap();
let ext = path.rsplit('.').next().unwrap_or("");
format!(
" Constraint '{path}' looks like a file path — use Read to search in a specific file, or '*.{ext}' for extension filter."
)
// Only suggest when the match is strong enough.
let query_len = query.len() as i32;
if score.base_score > query_len * 10 {
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 content matches. But there is a relevant file path: {}",
top.relative_path
))]));
}
}
Some(q) if !q.constraints.is_empty() && !q.grep_text().is_empty() => {
" Try to omit constraint".to_string()
}
_ => String::new(),
};
}
return Ok(CallToolResult::success(vec![Content::text(format!(
"0 matches {}",
hint
))]));
return Ok(CallToolResult::success(vec![Content::text(
"0 matches.".to_string(),
)]));
}
if result.matches.is_empty() {
+2 -2
View File
@@ -34,7 +34,7 @@ impl PathCache {
}
}
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size))]
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size), level = tracing::Level::TRACE)]
fn get(&self, path: &Path, max_size: usize) -> Option<&str> {
self.map.get(path).and_then(|entry| {
// Only return cached value if max_size matches
@@ -85,7 +85,7 @@ pub fn shorten_path_with_cache(
.read()
.map_err(|_| "Failed to acquire path cache lock".to_string())?;
if let Some(cached) = cache.get(path, max_size) {
tracing::debug!("Cache hit for path '{}'", path.display());
tracing::trace!("Cache hit for path '{}'", path.display());
return Ok(cached.to_string());
}
}
+20 -31
View File
@@ -73,6 +73,13 @@ pub trait ParserConfig {
true
}
/// Should parse location suffixes (e.g., file:12, file:12:4)
/// Disabled for grep modes where colon-number patterns like localhost:8080
/// are search text, not file locations.
fn enable_location(&self) -> bool {
true
}
/// Determine whether a token should be treated as a glob constraint.
///
/// The default implementation delegates to `zlob::has_wildcards` with
@@ -126,6 +133,10 @@ impl ParserConfig for GrepConfig {
false
}
fn enable_location(&self) -> bool {
false
}
/// Only recognise globs that are clearly directory/path oriented.
///
/// Characters like `?`, `[`, and bare `*` (without `/`) are extremely
@@ -161,10 +172,10 @@ impl ParserConfig for GrepConfig {
/// Configuration for AI-mode grep — extends `GrepConfig` behavior with
/// automatic file-path constraint detection.
///
/// When an AI agent sends `"libswscale/input.c rgba32ToY"`, the token
/// `libswscale/input.c` is detected as a `FilePath` constraint so the
/// search is scoped to that file. The caller validates the constraint
/// against the index and drops it if no files match (fallback).
/// Bare filenames with valid extensions (`schema.rs`) and path-prefixed
/// filenames (`libswscale/input.c`) are detected as `FilePath` constraints
/// so the search is scoped to matching files. The caller validates the
/// constraint against the index and drops it if no files match (fallback).
#[derive(Debug, Clone, Copy, Default)]
pub struct AiGrepConfig;
@@ -177,6 +188,10 @@ impl ParserConfig for AiGrepConfig {
false
}
fn enable_location(&self) -> bool {
false
}
fn is_glob_pattern(&self, token: &str) -> bool {
// First check GrepConfig's strict rules (path globs, brace expansion)
if GrepConfig.is_glob_pattern(token) {
@@ -202,36 +217,10 @@ impl ParserConfig for AiGrepConfig {
}
fn parse_custom<'a>(&self, token: &'a str) -> Option<Constraint<'a>> {
if is_file_path_token(token) {
if is_filename_constraint_token(token) {
Some(Constraint::FilePath(token))
} else {
None
}
}
}
#[inline]
fn is_file_path_token(token: &str) -> bool {
let bytes = token.as_bytes();
// Must contain at least one /
if !bytes.contains(&b'/') {
return false;
}
// Must NOT end with / (that's a PathSegment)
if bytes.last() == Some(&b'/') {
return false;
}
// Must NOT contain wildcards (those are globs)
if has_wildcards(token) {
return false;
}
// Last component must contain . (file extension)
match token.rsplit('/').next() {
Some(last) => last.contains('.'),
None => false,
}
}
+72 -11
View File
@@ -58,13 +58,15 @@ impl<C: ParserConfig> QueryParser<C> {
}
// Try to extract location from single token (e.g., "file:12")
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
});
if config.enable_location() {
let (query_without_loc, location) = parse_location(query);
if location.is_some() {
return Some(FFFQuery {
constraints,
fuzzy_query: FuzzyQuery::Text(query_without_loc),
location,
});
}
}
// Plain text single token - return None (caller handles as simple fuzzy match)
@@ -99,7 +101,7 @@ impl<C: ParserConfig> QueryParser<C> {
// Try to extract location from the last fuzzy token
// e.g., "search file:12" -> fuzzy="search file", location=Line(12)
let location = if !text_parts.is_empty() {
let location = if config.enable_location() && !text_parts.is_empty() {
let last_idx = text_parts.len() - 1;
let (without_loc, loc) = parse_location(text_parts[last_idx]);
if loc.is_some() {
@@ -918,13 +920,44 @@ mod tests {
}
#[test]
fn test_ai_grep_no_false_positive_no_slash() {
fn test_ai_grep_bare_filename_is_file_path() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("main.rs pattern").expect("Should parse");
// No slash → not a file path, just text
// Bare filename with valid extension → FilePath constraint
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("main.rs")),
"Expected FilePath, got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "pattern");
}
#[test]
fn test_ai_grep_bare_filename_schema_rs() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser
.parse("schema.rs part_revisions")
.expect("Should parse");
assert_eq!(result.constraints.len(), 1);
assert!(
matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
"Expected FilePath(schema.rs), got {:?}",
result.constraints[0]
);
assert_eq!(result.grep_text(), "part_revisions");
}
#[test]
fn test_ai_grep_bare_word_no_extension_not_constraint() {
use crate::AiGrepConfig;
let parser = QueryParser::new(AiGrepConfig);
let result = parser.parse("schema pattern").expect("Should parse");
// No extension → not a file path, just text
assert_eq!(result.constraints.len(), 0);
assert_eq!(result.grep_text(), "main.rs pattern");
assert_eq!(result.grep_text(), "schema pattern");
}
#[test]
@@ -979,6 +1012,34 @@ mod tests {
);
}
#[test]
fn test_grep_no_location_parsing_single_token() {
let parser = QueryParser::new(GrepConfig);
// localhost:8080 should NOT be parsed as location — it's a search pattern
let result = parser.parse("localhost:8080");
assert!(
result.is_none(),
"Single-token grep query with colon-number should return None (plain text), got {:?}",
result
);
}
#[test]
fn test_grep_no_location_parsing_multi_token() {
let q = QueryParser::new(GrepConfig)
.parse("*.rs localhost:8080")
.expect("should parse");
assert_eq!(
q.grep_text(),
"localhost:8080",
"Colon-number suffix should be preserved in grep text"
);
assert!(
q.location.is_none(),
"Grep should not parse location from colon-number"
);
}
#[test]
fn test_grep_config_star_text_star_not_glob() {
use crate::GrepConfig;
+1 -1
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 12
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 March 13
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
+24 -6
View File
@@ -126,7 +126,9 @@ download_binary() {
mv "${tmp_dir}/${filename}" "${INSTALL_DIR}/${BINARY_NAME}${ext}"
chmod +x "${INSTALL_DIR}/${BINARY_NAME}${ext}"
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
if [ "$IS_UPDATE" != true ]; then
success "Installed ${BINARY_NAME} to ${INSTALL_DIR}/${BINARY_NAME}${ext}"
fi
}
check_path() {
@@ -236,19 +238,35 @@ print_setup_instructions() {
}
main() {
info "Installing FFF MCP Server..."
echo ""
local target
target="$(detect_platform)"
local existing_binary="${INSTALL_DIR}/${BINARY_NAME}"
IS_UPDATE=false
if [ -x "$existing_binary" ]; then
IS_UPDATE=true
info "Updating FFF MCP Server..."
else
info "Installing FFF MCP Server..."
fi
echo ""
info "Detected platform: ${target}"
local tag
tag="$(get_latest_release_tag "$target")"
download_binary "$target" "$tag"
check_path
print_setup_instructions
if [ "$IS_UPDATE" = true ]; then
echo ""
success "FFF MCP Server updated to ${tag}!"
echo ""
else
check_path
print_setup_instructions
fi
}
main
+1 -1
View File
@@ -114,7 +114,7 @@ local function download_from_github(version, binary_path, opts, callback)
extra_curl_args = opts.extra_curl_args,
}, function(success, err)
if not success then
vim.uv.fanoushkas_unlink(tmp_path)
vim.uv.fs_unlink(tmp_path)
callback(false, err)
return
end
+2 -1
View File
@@ -1,6 +1,7 @@
{
"private": true,
"workspaces": ["packages/fff-bun", "packages/fff-mcp"],
"packageManager": "bun@1.3.9",
"scripts": {
"format": "biome format --write",
"format:check": "biome format",
@@ -11,4 +12,4 @@
"devDependencies": {
"@biomejs/biome": "^2.4.4"
}
}
}
+10 -37
View File
@@ -148,9 +148,7 @@ function snakeToCamel(obj: unknown): unknown {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const camelKey = key.replace(/_([a-z])/g, (_, letter) =>
letter.toUpperCase(),
);
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
result[camelKey] = snakeToCamel(value);
}
return result;
@@ -298,15 +296,9 @@ export function ffiGetScanProgress(handle: NativeHandle): Result<unknown> {
/**
* Wait for scan to complete.
*/
export function ffiWaitForScan(
handle: NativeHandle,
timeoutMs: number,
): Result<boolean> {
export function ffiWaitForScan(handle: NativeHandle, timeoutMs: number): Result<boolean> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_wait_for_scan(
handle,
BigInt(timeoutMs),
);
const resultPtr = library.symbols.fff_wait_for_scan(handle, BigInt(timeoutMs));
const result = parseResult<boolean | string>(resultPtr);
if (!result.ok) return result;
// JSON.parse("true") returns boolean true, but we also handle
@@ -317,15 +309,9 @@ export function ffiWaitForScan(
/**
* Restart index in new path.
*/
export function ffiRestartIndex(
handle: NativeHandle,
newPath: string,
): Result<void> {
export function ffiRestartIndex(handle: NativeHandle, newPath: string): Result<void> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_restart_index(
handle,
ptr(encodeString(newPath)),
);
const resultPtr = library.symbols.fff_restart_index(handle, ptr(encodeString(newPath)));
return parseResult<void>(resultPtr);
}
@@ -340,10 +326,7 @@ export function ffiRefreshGitStatus(handle: NativeHandle): Result<number> {
// JSON.parse("3") returns 3 (number), parseInt handles both
return {
ok: true,
value:
typeof result.value === "number"
? result.value
: parseInt(result.value, 10),
value: typeof result.value === "number" ? result.value : parseInt(result.value, 10),
};
}
@@ -374,14 +357,10 @@ export function ffiGetHistoricalQuery(
offset: number,
): Result<string | null> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_get_historical_query(
handle,
BigInt(offset),
);
const resultPtr = library.symbols.fff_get_historical_query(handle, BigInt(offset));
const result = parseResult<string | null>(resultPtr);
if (!result.ok) return result;
if (result.value === null || result.value === "null")
return { ok: true, value: null };
if (result.value === null || result.value === "null") return { ok: true, value: null };
return result as Result<string>;
}
@@ -431,15 +410,9 @@ export function ffiLiveGrep(
/**
* Multi-pattern grep - Aho-Corasick multi-needle search.
*/
export function ffiMultiGrep(
handle: NativeHandle,
optsJson: string,
): Result<unknown> {
export function ffiMultiGrep(handle: NativeHandle, optsJson: string): Result<unknown> {
const library = loadLibrary();
const resultPtr = library.symbols.fff_multi_grep(
handle,
ptr(encodeString(optsJson)),
);
const resultPtr = library.symbols.fff_multi_grep(handle, ptr(encodeString(optsJson)));
return parseResult<unknown>(resultPtr);
}