Skip to content
Merged
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
6 changes: 3 additions & 3 deletions IMPROVEMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,10 @@ deserialization) and `tools.rs` (grep/find/cat). There are no tests for:
result but the `match` is only used for its pattern — this is confusing.

**Actions:**
- [ ] Run `cargo clippy -- -W clippy::all -W clippy::pedantic` and fix findings.
- [ ] Replace the `let _ = match …` with a simple `let separator = "-"` (the
- [x] Run `cargo clippy -- -W clippy::all -W clippy::pedantic` and fix findings.
- [x] Replace the `let _ = match …` with a simple `let separator = "-"` (the
match arms all return the same value).
- [ ] Use `write!` instead of `format!` + `push_str` in hot paths to avoid
- [x] Use `write!` instead of `format!` + `push_str` in hot paths to avoid
intermediate allocations.

---
Expand Down
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,32 @@ The point: Claude Code's local MCP support is stdio-only. This server speaks str

## Tools

All tools return structured `ToolResponse` objects with metadata (truncation status, error counts, match counts) rather than plain strings. This allows clients to programmatically detect truncation and other conditions.

### `grep`
Regex search across files using parallel directory traversal (`ignore` + `grep-searcher`).

| arg | type | default | notes |
| -------------------- | --------------- | ------- | ----------------------------------------------------------- |
| `directory` | `string` | — | required |
| `pattern` | `string` | — | required; Rust `regex` flavor — no lookaround/backrefs |
| `before_context` | `int` | `0` | |
| `after_context` | `int` | `0` | |
| `max_results` | `int` | `100` | exact cap (no over-shoot) |
| `output_mode` | `string` | `files_with_matches` | `files_with_matches` (list matching files; fast for broad scans), `content` (matching lines with context), or `count` (per-file match tally) |
| `before_context` | `int` | `0` | lines of context before matches (ignored in `files_with_matches` and `count` modes) |
| `after_context` | `int` | `0` | lines of context after matches (ignored in `files_with_matches` and `count` modes) |
| `max_results` | `int` | `100` | exact cap (no over-shoot); for `files_with_matches`, caps the number of files; for `content`, caps the number of matching lines; for `count`, caps the number of files |
| `case_insensitive` | `bool` | `false` | equivalent to `(?i)` prefix in `pattern` |
| `include_hidden` | `bool` | `false` | |
| `follow_symlinks` | `bool` | `false` | |
| `respect_gitignore` | `bool` | `true` | |
| `file_extensions` | `string[]` | `[]` | e.g. `["rs", "toml"]`; empty = all |
| `max_bytes` | `int` | ~5 MiB | hard cap on response size; appends `[truncated: byte cap]` |
| `max_bytes` | `int` | ~5 MiB | hard cap on response size |

**Output modes:**
- **`files_with_matches`** (default): Returns only file paths that contain matches. Each path appears once (on first match), then the file's search stops early — efficient for broad reconnaissance queries. `max_results` caps the number of files.
- **`content`**: Returns matching lines with optional context (before/after). The classic grep output mode, useful when line-level detail is needed. `max_results` caps the number of lines.
- **`count`**: Returns per-file match tallies as `path: N` lines, sorted by path. Useful for understanding distribution of matches across files.

Walker errors and search errors are tallied and reported as a `[notice: N entry errors, M search errors; first: ...]` footer rather than silently dropped.
Walker errors and search errors are tallied and returned in the response metadata rather than silently dropped.

### `find`
Find files by regex.
Expand Down Expand Up @@ -65,10 +73,10 @@ Read file contents with pagination.
| ----------- | -------- | ------- | -------------------------------------------------------------------- |
| `file_path` | `string` | — | required |
| `offset` | `int` | `0` | 0-based line number to start from |
| `max_lines` | `int` | `2000` | appends `[truncated: line cap]` if more lines remain |
| `max_bytes` | `int` | ~5 MiB | appends `[truncated: byte cap]` if hit mid-line (UTF-8-safe cut) |
| `max_lines` | `int` | `2000` | maximum lines to return per call |
| `max_bytes` | `int` | ~5 MiB | hard cap on response size (UTF-8-safe cut at line boundary) |

Use `offset` to page: if the response ends with `[truncated: line cap]`, call again with `offset = previous_offset + max_lines`.
Use `offset` to page through large files: if the response indicates truncation, call again with `offset = previous_offset + max_lines`. The response will include metadata indicating whether the result was truncated and the reason.

## Build & run

Expand Down
1 change: 1 addition & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ fn default_output_mode() -> String {

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[allow(clippy::struct_excessive_bools)]
pub struct GrepArgs {
#[schemars(description = "Directory to search in")]
pub directory: String,
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub struct Args {
pub(crate) memory_dir: Option<PathBuf>,

/// Required project root. Every path the tools touch (grep/find
/// directory, cat file_path) is canonicalized and must be within
/// directory, cat `file_path`) is canonicalized and must be within
/// this directory; anything outside is rejected. Symlinks in input
/// paths are resolved before the check, so a symlink pointing out
/// of the project is also rejected.
Expand Down
10 changes: 6 additions & 4 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ use thiserror::Error;
/// Convert a `tokio::task::JoinError` (from `spawn_blocking`) into an
/// `rmcp::ErrorData` with `internal_error` code. Used by every tool handler
/// so the `.map_err` boilerplate is a single call.
///
/// Takes `JoinError` by value so it can be passed directly as
/// `.map_err(join_error)` — `JoinError` is not `Clone`, and the only
/// field we need is accessed via `&self`.
#[allow(clippy::needless_pass_by_value)]
pub fn join_error(e: tokio::task::JoinError) -> ErrorData {
ErrorData::internal_error(
"internal_error",
Some(json!({"error": e.to_string()})),
)
ErrorData::internal_error("internal_error", Some(json!({"error": e.to_string()})))
}

/// Convenience alias for tool handler return types.
Expand Down
2 changes: 1 addition & 1 deletion src/limiter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ impl PeerLimiter {

/// Convenience: a per-minute rate (capacity = `rate`, refill = `rate`/60s).
pub fn per_minute(rate: u32) -> Self {
let cap = rate.max(1) as f64;
let cap = f64::from(rate.max(1));
Self::new(cap, cap / 60.0, 4096)
}

Expand Down
15 changes: 6 additions & 9 deletions src/memory.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::error::AppError;
use std::fmt::Write;
use std::path::Path;

/// Load a memory file from the given directory, or return an index/listing.
Expand All @@ -18,16 +19,12 @@ pub fn load_memory(dir: &Path, name: Option<&str>) -> Result<String, AppError> {
// Reject path traversal: name must be a single, non-empty path component.
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(AppError::InvalidRequest(format!(
"memory name must be a plain filename, got: {:?}",
name
"memory name must be a plain filename, got: {name:?}"
)));
}
let path = dir.join(name);
if !path.is_file() {
return Err(AppError::NotFound(format!(
"memory not found: {}",
name
)));
return Err(AppError::NotFound(format!("memory not found: {name}")));
}
return Ok(std::fs::read_to_string(&path)?);
}
Expand All @@ -40,21 +37,21 @@ pub fn load_memory(dir: &Path, name: Option<&str>) -> Result<String, AppError> {

let mut listing = String::from("# Memory dir contents\n\n");
let mut entries: Vec<_> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter_map(std::result::Result::ok)
.filter(|e| {
e.path()
.extension()
.and_then(|s| s.to_str())
.is_some_and(|s| s == "md")
})
.collect();
entries.sort_by_key(|e| e.file_name());
entries.sort_by_key(std::fs::DirEntry::file_name);
if entries.is_empty() {
listing.push_str("(no .md files found; configure MEMORY.md or add memory files)\n");
} else {
for e in entries {
if let Some(name) = e.file_name().to_str() {
listing.push_str(&format!("- {}\n", name));
let _ = writeln!(listing, "- {name}");
}
}
listing.push_str(
Expand Down
2 changes: 1 addition & 1 deletion src/reaper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pub async fn reap_loop(
ticker.tick().await; // first tick fires immediately; skip it
loop {
tokio::select! {
_ = cancel.cancelled() => return,
() = cancel.cancelled() => return,
_ = ticker.tick() => {
sweep(&manager, &tracker, idle_timeout).await;
}
Expand Down
9 changes: 5 additions & 4 deletions src/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ impl Scope {
/// Build a scope. The root must exist and be a directory. It is
/// canonicalized at construction time so symlinks inside the
/// configured path are resolved once.
pub fn new(root: PathBuf) -> Result<Self, AppError> {
let canon = root.canonicalize().map_err(|e| {
AppError::Internal(format!("--project {}: {}", root.display(), e))
})?;
pub fn new(root: impl AsRef<Path>) -> Result<Self, AppError> {
let root = root.as_ref();
let canon = root
.canonicalize()
.map_err(|e| AppError::Internal(format!("--project {}: {}", root.display(), e)))?;
if !canon.is_dir() {
return Err(AppError::Internal(format!(
"--project must be a directory: {}",
Expand Down
6 changes: 4 additions & 2 deletions src/server.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::fmt::Write;
use std::path::PathBuf;

use rmcp::{
Expand Down Expand Up @@ -152,12 +153,13 @@ impl ServerHandler for CodeMcpServer {
let mut instructions = String::from(
"code-mcp: filesystem search and read tools.\n\n",
);
instructions.push_str(&format!(
let _ = write!(
instructions,
"All paths are scoped to the project root: {}. \
Paths outside this directory (or symlinks resolving outside it) are rejected \
with `invalid_params`.\n\n",
self.scope.root().display()
));
);
instructions.push_str(
"\
Regex flavor: Rust `regex` crate. No lookaround or backreferences. \
Expand Down
Loading