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 @@ -220,11 +220,11 @@ deserialization) and `tools.rs` (grep/find/cat). There are no tests for:
docs, no `///` doc comments on public API in `tools.rs`.

**Actions:**
- [ ] Add `//!` crate-level doc to `lib.rs` or `main.rs` explaining the
- [x] Add `//!` crate-level doc to `lib.rs` or `main.rs` explaining the
project's purpose and architecture.
- [ ] Add `///` doc comments to all public functions in `tools.rs`, `scope.rs`,
- [x] Add `///` doc comments to all public functions in `tools.rs`, `scope.rs`,
`gate.rs`, `limiter.rs`, `reaper.rs`.
- [ ] Update `README.md` with build/run instructions, CLI args, and tool
- [x] Update `README.md` with build/run instructions, CLI args, and tool
descriptions.

---
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
# code-mcp

A streamable-HTTP MCP server that exposes fast filesystem search and read tools (`grep`, `find`, `cat`) to LLM clients.
A streamable-HTTP MCP server that exposes fast filesystem search and read tools (`grep`, `find`, `cat`, `memories`) to LLM clients.

The point: Claude Code's local MCP support is stdio-only. This server speaks streamable HTTP so a single instance running on a dev box can be reached over the LAN by Claude Code, Cursor, Zed, or any other MCP client that supports HTTP transport.

> [!WARNING]
> Authentication & Authorization are outside the scope of this project. **Run this on a private LAN only.** Anyone who can reach the bind address can use the tools. `--project` scopes what they can read. Run this with `chroot` for extra jailing.
> NOTE: https://modelcontextprotocol.io/docs/tutorials/security/authorization is what you should be using regardless.

## Installation

### Pre-built binaries
Download the latest release from the [Releases page](https://github.com/devfire/code-mcp/releases).

### From source
```bash
cargo install --git https://github.com/devfire/code-mcp.git


## 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.
Expand Down Expand Up @@ -92,7 +102,7 @@ Flags:
- `--memory-dir <path>` — optional. If set, enables the `memories` tool and reads `<path>/instructions.md` (if present) into the `InitializeResult.instructions` payload sent to the model on connect.
- `--max-sessions <N>` — default `64`. Hard cap on concurrent stateful sessions in the rmcp `LocalSessionManager`. New initialize POSTs are rejected with `503 Service Unavailable` + `Retry-After: 5` once the cap is met. Existing-session traffic (any POST carrying `Mcp-Session-Id`) passes through untouched.
- `--initialize-rate-per-min <R>` — default `12`. Per-peer cap on **new** initialize requests, expressed as a per-minute token bucket (capacity = `R`, refilling continuously over 60 s). When exhausted, new initializes from that peer return `429 Too Many Requests` + `Retry-After: <secs>`. A misconfigured client that reconnects in a tight loop gets throttled here instead of pinning unbounded session state. Default `12`/min ≈ one fresh session every 5 s sustained — well above any healthy reconnect rate.
- `--trust-forwarded-for` — default `false`. When set, the gate uses the leftmost entry of `X-Forwarded-For` as the peer IP for rate-limiting. Only enable when the server sits behind a reverse proxy you control; the header is forgeable by any direct client.
- `--trust-forwarded-for` — default `false`. When set, the gate uses the rightmost entry of `X-Forwarded-For` as the peer IP for rate-limiting. This assumes a single trusted proxy hop (e.g. AWS ALB) that appends the real client IP; entries to the left of the last hop are client-supplied and forgeable. Only enable when the server sits behind a reverse proxy you control.
- `--session-idle-timeout-secs <N>` — default `1800` (30 min). Idle timeout for stateful sessions. A background reaper task closes any session whose last observed request is older than this, so abandoned clients (process killed, network gone, no DELETE sent) don't pin slots against `--max-sessions` indefinitely. The cap defends against bursts; the reaper handles long-lived zombies.
- `--session-sweep-interval-secs <N>` — default `60`. How often the reaper sweeps for idle sessions.

Expand Down
7 changes: 7 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,21 @@ const fn default_true() -> bool {
// StringOrVec — accepts a single string or an array of strings
// ---------------------------------------------------------------------------

/// Serde helper accepting either a single string or an array of strings.
/// Used by `GrepArgs::file_extensions` so MCP clients can pass either
/// `"sql"` or `["rs", "toml"]`.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum StringOrVec {
/// A single extension string.
One(String),
/// An array of extension strings.
Many(Vec<String>),
}

impl StringOrVec {
/// Normalize into a `Vec<String>` regardless of which variant was
/// deserialized.
pub fn into_vec(self) -> Vec<String> {
match self {
StringOrVec::One(s) => vec![s],
Expand Down
5 changes: 5 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ use std::path::PathBuf;
use clap::Parser;
use std::net::SocketAddr;

/// Command-line arguments for `code-mcp`.
///
/// Parsed via clap. All fields are `pub(crate)` because they're only consumed
/// by `main`; the struct itself is `pub` so it can be referenced from other
/// crate modules.
#[derive(Debug, Parser)]
#[command(name = "code-mcp", about = "Streamable HTTP MCP server for code search/read tools")]
pub struct Args {
Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ pub fn join_error(e: tokio::task::JoinError) -> ErrorData {
/// Convenience alias for tool handler return types.
pub type ToolResult<T> = Result<T, ErrorData>;

/// Application-level error type. Variants map to MCP error codes via the
/// `From<AppError> for ErrorData` impl below: user-facing failures
/// (bad regex, scope violations, not-found) become `invalid_params`, while
/// infrastructure failures (I/O, ignore, axum) become `internal_error`.
#[derive(Debug, Error)]
pub enum AppError {
#[error("I/O error: {0}")]
Expand Down
11 changes: 11 additions & 0 deletions src/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,22 @@ use crate::reaper::ActivityTracker;
const SESSION_ID_HEADER: &str = "mcp-session-id";

/// Shared state for the [`gate`] middleware.
///
/// Held in an `Arc` and passed to the middleware via `from_fn_with_state`.
/// Fields are `pub` because the middleware closure reads them directly; the
/// type is constructed once in `main` and never mutated thereafter.
pub struct GateCtx {
/// The rmcp session manager — used to read the live session count for the
/// `--max-sessions` cap.
pub sessions: Arc<LocalSessionManager>,
/// Hard cap on concurrent stateful sessions.
pub max_sessions: usize,
/// Per-peer token-bucket limiter for new initialize POSTs.
pub limiter: PeerLimiter,
/// Whether to trust `X-Forwarded-For` (rightmost entry) as the peer IP.
pub trust_forwarded_for: bool,
/// Per-session last-activity timestamps, bumped by the gate and read by
/// the reaper.
pub activity: Arc<ActivityTracker>,
}

Expand Down
5 changes: 5 additions & 0 deletions src/limiter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ pub struct PeerLimiter {
evict_threshold: usize,
}

/// A single peer's token-bucket state: current token count and the instant of
/// the last `try_consume` (used to compute refill on the next call).
#[derive(Clone, Copy)]
struct Bucket {
tokens: f64,
Expand Down Expand Up @@ -64,6 +66,9 @@ impl PeerLimiter {
}
}

/// The idle duration after which a peer's bucket is considered stale and
/// eligible for eviction. Set to twice the time it takes to fully refill
/// an empty bucket, so a peer that briefly goes idle isn't dropped.
fn stale_after(&self) -> Duration {
// Twice the time it takes to fully refill an empty bucket.
Duration::from_secs_f64((self.capacity / self.refill_per_sec) * 2.0)
Expand Down
40 changes: 40 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,43 @@
//! # code-mcp
//!
//! A streamable-HTTP MCP server that exposes fast filesystem search and read
//! tools (`grep`, `find`, `cat`, `memories`) to LLM clients.
//!
//! ## Why
//!
//! Claude Code's local MCP support is stdio-only. This server speaks
//! streamable HTTP so a single instance running on a dev box can be reached
//! over the LAN by Claude Code, Cursor, Zed, or any other MCP client that
//! supports HTTP transport.
//!
//! ## Architecture
//!
//! The binary is split into focused modules:
//!
//! - [`cli`] — clap arg parsing (`Args`).
//! - [`scope`] — filesystem scope enforcement. Every path the tools touch is
//! canonicalized and required to lie under `--project`; symlinks resolving
//! outside the root are rejected.
//! - [`server`] — the `CodeMcpServer` `ServerHandler` impl and `#[tool_router]`
//! wiring for `grep` / `find` / `cat` / `memories`.
//! - [`tools`] — the actual search/read implementations (parallel walkers,
//! `grep-searcher` sinks, `ToolResponse` structured output).
//! - [`args`] — serde `JsonSchema` arg structs for each tool, with
//! `#[serde(default)]`-driven defaults.
//! - [`memory`] — loads persisted memory files from `--memory-dir`.
//! - [`gate`] — axum middleware that caps concurrent sessions and rate-limits
//! new initialize POSTs per peer.
//! - [`limiter`] — per-peer token-bucket rate limiter used by [`gate`].
//! - [`reaper`] — background task that closes idle sessions so abandoned
//! clients don't pin slots against `--max-sessions`.
//! - [`error`] — `AppError` (thiserror) and conversion to `rmcp::ErrorData`.
//!
//! ## Security
//!
//! Authentication & Authorization are out of scope. Run on a private LAN only.
//! `--project` scopes what clients can read; anything outside is rejected with
//! `invalid_params`. See <https://modelcontextprotocol.io/docs/tutorials/security/authorization>.

mod args;
mod error;
mod gate;
Expand Down
3 changes: 3 additions & 0 deletions src/reaper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ impl ActivityTracker {
Self::default()
}

/// Record activity for the given session id (called by the gate middleware
/// on every request that carries a session id). Updates the last-seen
/// timestamp to `Instant::now()`.
pub async fn touch(&self, id: SessionId) {
self.inner.write().await.insert(id, Instant::now());
}
Expand Down
10 changes: 10 additions & 0 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ use crate::memory::load_memory;
use crate::scope::Scope;
use crate::tools::{self, OutputMode};

/// The MCP server handler. Owns the tool router, the optional memory dir,
/// the extra instructions loaded at startup, and the filesystem [`Scope`].
///
/// Constructed once per session by the `StreamableHttpService` closure in
/// `main` (so each session gets its own cheap `Clone` of the `Scope` and
/// config). All tool handlers run their blocking work in `spawn_blocking`
/// and delegate to [`crate::tools`].
#[derive(Clone)]
pub struct CodeMcpServer {
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
Expand All @@ -23,6 +30,9 @@ pub struct CodeMcpServer {
}

impl CodeMcpServer {
/// Construct a new server instance. `extra_instructions` is the contents
/// of `<memory-dir>/instructions.md` (loaded once at startup) and is
/// appended to the `InitializeResult.instructions` payload.
pub fn new(
memory_dir: Option<PathBuf>,
extra_instructions: Option<String>,
Expand Down
43 changes: 43 additions & 0 deletions src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,26 @@ impl OutputMode {
/// (flat) JSON contract exposed to MCP clients.
#[allow(clippy::struct_excessive_bools)]
pub struct GrepOptions {
/// Lines of context to emit before each match (`content` mode only).
pub before_context: usize,
/// Lines of context to emit after each match (`content` mode only).
pub after_context: usize,
/// Exact cap on results. For `files_with_matches`/`count` this caps the
/// number of files; for `content` it caps the number of matching lines.
pub max_results: usize,
/// Case-insensitive matching (equivalent to a `(?i)` prefix on the pattern).
pub case_insensitive: bool,
/// Include hidden files and directories in the walk.
pub include_hidden: bool,
/// Follow symbolic links during the walk.
pub follow_symlinks: bool,
/// Respect `.gitignore` / global / exclude gitignore rules.
pub respect_gitignore: bool,
/// Restrict search to files with these extensions (empty = all files).
pub file_extensions: Vec<String>,
/// Hard cap on total response size in bytes.
pub max_bytes: usize,
/// What to emit for each match (see [`OutputMode`]).
pub output_mode: OutputMode,
}

Expand All @@ -139,11 +150,17 @@ impl Default for GrepOptions {
}
}

/// Configuration for the `find` tool.
#[derive(Clone, Copy)]
pub struct FindOptions {
/// Exact cap on the number of matching paths returned.
pub max_results: usize,
/// Include hidden files and directories in the walk.
pub include_hidden: bool,
/// Respect `.gitignore` / global / exclude gitignore rules.
pub respect_gitignore: bool,
/// When `true` (default), match the regex against the file's basename;
/// when `false`, match against the full path.
pub match_basename: bool,
}

Expand Down Expand Up @@ -319,6 +336,16 @@ fn record_first(slot: &Mutex<Option<String>>, msg: String) {
// grep
// ---------------------------------------------------------------------------

/// Regex search across files using parallel directory traversal
/// (`ignore` + `grep-searcher`).
///
/// Dispatches to [`grep_content`], [`grep_files`], or [`grep_count`] based on
/// `opts.output_mode`. All modes share the same parallel walker, thread-local
/// buffer + mpsc pipeline, and exact `max_results` capping; only what gets
/// written to the buffer differs.
///
/// Walker entry errors and per-file search errors are tallied and surfaced in
/// the returned [`ToolResponse`] metadata rather than aborting the search.
#[allow(clippy::needless_pass_by_value)]
pub fn grep(
directory: &str,
Expand Down Expand Up @@ -794,6 +821,12 @@ fn grep_count(
// find
// ---------------------------------------------------------------------------

/// Find files by regex. Matches the basename by default; set
/// `opts.match_basename = false` to match against the full path.
///
/// Uses a parallel `ignore` walker (gitignore-aware) and an `AtomicUsize`
/// counter for exact `max_results` capping. Walker entry errors are tallied
/// and surfaced in the returned [`ToolResponse`] metadata.
pub fn find(
directory: &str,
pattern: &str,
Expand Down Expand Up @@ -898,6 +931,16 @@ pub fn find(
// cat
// ---------------------------------------------------------------------------

/// Read file contents with pagination.
///
/// Skips `offset` lines (0-based), then reads up to `max_lines` lines or
/// `max_bytes` bytes, whichever is hit first. Byte-cap cuts are performed on
/// UTF-8 character boundaries so the output is always valid UTF-8. Truncation
/// is reported via the returned [`ToolResponse`]'s `truncated` /
/// `truncation_reason` fields (`line_cap` or `byte_cap`).
///
/// Returns [`AppError::InvalidRequest`] if the target is missing or not a
/// regular file.
pub fn cat(
file_path: &str,
offset: usize,
Expand Down