diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md index 3ad85c1..fd6c34a 100644 --- a/IMPROVEMENT_PLAN.md +++ b/IMPROVEMENT_PLAN.md @@ -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. --- diff --git a/README.md b/README.md index 89bdbc0..c24fd84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -8,6 +8,16 @@ The point: Claude Code's local MCP support is stdio-only. This server speaks str > 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. @@ -92,7 +102,7 @@ Flags: - `--memory-dir ` — optional. If set, enables the `memories` tool and reads `/instructions.md` (if present) into the `InitializeResult.instructions` payload sent to the model on connect. - `--max-sessions ` — 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 ` — 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: `. 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 ` — 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 ` — default `60`. How often the reaper sweeps for idle sessions. diff --git a/src/args.rs b/src/args.rs index dfd86bd..9014899 100644 --- a/src/args.rs +++ b/src/args.rs @@ -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), } impl StringOrVec { + /// Normalize into a `Vec` regardless of which variant was + /// deserialized. pub fn into_vec(self) -> Vec { match self { StringOrVec::One(s) => vec![s], diff --git a/src/cli.rs b/src/cli.rs index b55fd02..ede8567 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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 { diff --git a/src/error.rs b/src/error.rs index 4bdb45f..e0ad6e0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -17,6 +17,10 @@ pub fn join_error(e: tokio::task::JoinError) -> ErrorData { /// Convenience alias for tool handler return types. pub type ToolResult = Result; +/// Application-level error type. Variants map to MCP error codes via the +/// `From 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}")] diff --git a/src/gate.rs b/src/gate.rs index 468124f..de852c7 100644 --- a/src/gate.rs +++ b/src/gate.rs @@ -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, + /// 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, } diff --git a/src/limiter.rs b/src/limiter.rs index 8dbe17c..c638c50 100644 --- a/src/limiter.rs +++ b/src/limiter.rs @@ -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, @@ -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) diff --git a/src/main.rs b/src/main.rs index 1ec5643..8b4fa18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 . + mod args; mod error; mod gate; diff --git a/src/reaper.rs b/src/reaper.rs index 5ae3d91..f356ff4 100644 --- a/src/reaper.rs +++ b/src/reaper.rs @@ -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()); } diff --git a/src/server.rs b/src/server.rs index dd94dcd..2d6e628 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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, @@ -23,6 +30,9 @@ pub struct CodeMcpServer { } impl CodeMcpServer { + /// Construct a new server instance. `extra_instructions` is the contents + /// of `/instructions.md` (loaded once at startup) and is + /// appended to the `InitializeResult.instructions` payload. pub fn new( memory_dir: Option, extra_instructions: Option, diff --git a/src/tools.rs b/src/tools.rs index 3ca2c2d..968d233 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -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, + /// Hard cap on total response size in bytes. pub max_bytes: usize, + /// What to emit for each match (see [`OutputMode`]). pub output_mode: OutputMode, } @@ -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, } @@ -319,6 +336,16 @@ fn record_first(slot: &Mutex>, 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, @@ -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, @@ -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,