diff --git a/Cargo.lock b/Cargo.lock index 0fd1455..4407b9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -269,7 +269,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "code-mcp" -version = "0.1.0" +version = "0.1.1" dependencies = [ "axum", "clap", diff --git a/Cargo.toml b/Cargo.toml index 0c01f47..aa6c96a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "code-mcp" -version = "0.1.0" +version = "0.1.1" edition = "2024" repository = "https://github.com/devfire/code-mcp.git" diff --git a/README.md b/README.md index 3ebbbfc..d4246b2 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,15 @@ Download the latest release from the [Releases page](https://github.com/devfire/ The included `Dockerfile` uses a multi-stage build with `cargo-chef` for optimal layer caching. The final image is based on `debian:bookworm-slim` (~80 MB) and contains only the stripped binary and `git` (needed by the `ignore` crate for `.gitignore` traversal). +Multi-arch images (`linux/amd64`, `linux/arm64`) are published to GHCR automatically on every release. Pull the pre-built image — no local build needed: + ```sh -# Build -docker build -t code-mcp . +docker pull ghcr.io/devfire/code-mcp:latest ``` ```sh # Run with defaults (bind 0.0.0.0:8080, project /project) -docker run -p 8080:8080 -v /path/to/repo:/project:ro code-mcp +docker run -p 8080:8080 -v /path/to/repo:/project:ro ghcr.io/devfire/code-mcp:latest ``` ```sh @@ -43,7 +44,7 @@ docker run -p 8080:8080 -v /path/to/repo:/project:ro code-mcp docker run -p 9090:9090 \ -v /path/to/repo:/src:ro \ -v /path/to/memories:/memories:ro \ - code-mcp \ + ghcr.io/devfire/code-mcp:latest \ --bind 0.0.0.0:9090 \ --project /src \ --memory-dir /memories \ @@ -55,7 +56,7 @@ docker run -p 9090:9090 \ ```sh # With debug logging docker run -p 8080:8080 -e RUST_LOG=debug,rmcp=info \ - -v /path/to/repo:/project:ro code-mcp + -v /path/to/repo:/project:ro ghcr.io/devfire/code-mcp:latest ``` The `ENTRYPOINT` is the binary itself, so any arguments after the image name replace the default `CMD` and go directly to clap. @@ -63,9 +64,21 @@ The `ENTRYPOINT` is the binary itself, so any arguments after the image name rep Pass `--help` to see all options: ```sh -docker run --rm code-mcp --help +docker run --rm ghcr.io/devfire/code-mcp:latest --help +``` + +
+Build locally instead + +If you want to build from source (e.g. for an unreleased commit or a fork): + +```sh +docker build -t code-mcp . +docker run -p 8080:8080 -v /path/to/repo:/project:ro code-mcp ``` +
+ Flags: - `--bind ` — default `0.0.0.0:8080`. @@ -77,8 +90,6 @@ Flags: - `--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. -Multi-arch images (`linux/amd64`, `linux/arm64`) are published automatically on every release. - ### From source ```bash @@ -203,6 +214,22 @@ The `--memory-dir` is **not** required to be inside `--project` — it's server- The `instructions.md` file is read once at startup. The other files are read on demand by the `memories` and `cat` tools, so editing them does not require a restart. +### Bootstrapping memories + +code-mcp is a read-only, LLM-free tool server: it can *serve* a memory directory but it cannot *create* one. Generating memories needs read access to the code, write access to the memory dir, and a model — and all three only coexist **on the box where the files live**, not in a remote client connecting over the network. + +So bootstrapping is a separate, co-located step: run a local coding agent against the repo to produce the `MEMORY.md` index + per-area files, then point `--memory-dir` at the output. The included script does this: + +```sh +# Uses `claude -p` by default; the agent reads/writes the local filesystem directly. +scripts/generate-memories.sh ./my/repo ./memories + +# Any stdin-driven agent with local fs access works: +AGENT="codex exec" scripts/generate-memories.sh /srv/monorepo /srv/memories +``` + +The script feeds the agent a prompt that builds a **functional-area mental model** — the major subsystems, their entry points, how they talk, and the non-obvious gotchas — rather than transcribing code the client can already `grep`. The goal is to orient a cold client so it doesn't burn calls rediscovering structure every session. Review the generated files before serving them, then start the server with `--memory-dir ./memories`. + Logging via `RUST_LOG`: ```sh diff --git a/scripts/generate-memories.sh b/scripts/generate-memories.sh new file mode 100755 index 0000000..fa0e181 --- /dev/null +++ b/scripts/generate-memories.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# generate-memories.sh — bootstrap a memory directory for code-mcp. +# +# code-mcp itself is a read-only, LLM-free tool server: it can serve a memory +# directory but cannot create one (no write channel, no model). This script +# runs a local coding agent *on the box where the files live* to produce the +# memory layout the `memories` tool expects (MEMORY.md index + per-area files). +# +# Run it once, before (or whenever you want to refresh) starting the server, +# then point `--memory-dir` at the output. +# +# Usage: +# scripts/generate-memories.sh +# +# Env: +# AGENT the agent command to pipe the prompt into. Default: "claude -p". +# Anything that reads a prompt on stdin and has read+write access to +# the local filesystem works (e.g. AGENT="codex exec"). +# +# Examples: +# scripts/generate-memories.sh ./my/repo ./memories +# AGENT="codex exec" scripts/generate-memories.sh /srv/monorepo /srv/memories + +set -euo pipefail + +PROJECT="${1:?usage: generate-memories.sh }" +OUT="${2:?usage: generate-memories.sh }" +AGENT="${AGENT:-claude -p}" + +if [[ ! -d "$PROJECT" ]]; then + echo "error: project dir '$PROJECT' does not exist" >&2 + exit 1 +fi +mkdir -p "$OUT" + +# Resolve to absolute paths so the agent isn't confused by its own cwd. +PROJECT="$(cd "$PROJECT" && pwd)" +OUT="$(cd "$OUT" && pwd)" + +echo "Generating memories for $PROJECT -> $OUT (agent: $AGENT)" >&2 + +read -r -d '' PROMPT < exhaustive. +- State uncertainty explicitly rather than inventing structure. +- End MEMORY.md by reminding the client this map is a starting point; confirm + details with grep/cat before relying on them. +EOF + +printf '%s\n' "$PROMPT" | $AGENT + +echo "Done. Review the generated files in $OUT before serving them." >&2 diff --git a/src/error.rs b/src/error.rs index e0ad6e0..3b151ff 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,5 @@ use rmcp::ErrorData; +use rmcp::model::CallToolResult; use serde_json::json; use thiserror::Error; @@ -17,6 +18,18 @@ pub fn join_error(e: tokio::task::JoinError) -> ErrorData { /// Convenience alias for tool handler return types. pub type ToolResult = Result; +/// Convert an `AppError` into a `CallToolResult` with `is_error: true`. +/// This keeps tool failures at the tool level (the session stays alive) +/// rather than escalating to a JSON-RPC protocol error that kills the session. +pub fn tool_error(err: AppError) -> CallToolResult { + CallToolResult { + content: vec![rmcp::model::Content::text(err.to_string())], + structured_content: None, + is_error: Some(true), + meta: None, + } +} + /// 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 diff --git a/src/server.rs b/src/server.rs index 2d6e628..2aefe4d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -9,7 +9,7 @@ use rmcp::{ }; use crate::args::{CatArgs, FindArgs, GrepArgs, MemoriesArgs, StringOrVec}; -use crate::error::{ToolResult, join_error}; +use crate::error::{ToolResult, join_error, tool_error}; use crate::memory::load_memory; use crate::scope::Scope; use crate::tools::{self, OutputMode}; @@ -56,8 +56,14 @@ impl CodeMcpServer { &self, Parameters(args): Parameters, ) -> ToolResult { - let directory = self.scope.check(&args.directory)?; - let output_mode = OutputMode::from_str_lossy(&args.output_mode)?; + let directory = match self.scope.check(&args.directory) { + Ok(d) => d, + Err(e) => return Ok(tool_error(e)), + }; + let output_mode = match OutputMode::from_str_lossy(&args.output_mode) { + Ok(m) => m, + Err(e) => return Ok(tool_error(e)), + }; let res = tokio::task::spawn_blocking(move || { let opts = tools::GrepOptions { before_context: args.before_context, @@ -77,9 +83,12 @@ impl CodeMcpServer { tools::grep(&directory.to_string_lossy(), &args.pattern, opts) }) .await - .map_err(join_error)??; + .map_err(join_error)?; - Ok(res.into_call_tool_result()) + match res { + Ok(r) => Ok(r.into_call_tool_result()), + Err(e) => Ok(tool_error(e)), + } } #[tool( @@ -89,7 +98,10 @@ impl CodeMcpServer { &self, Parameters(args): Parameters, ) -> ToolResult { - let directory = self.scope.check(&args.directory)?; + let directory = match self.scope.check(&args.directory) { + Ok(d) => d, + Err(e) => return Ok(tool_error(e)), + }; let res = tokio::task::spawn_blocking(move || { let opts = tools::FindOptions { max_results: args.max_results, @@ -100,16 +112,22 @@ impl CodeMcpServer { tools::find(&directory.to_string_lossy(), &args.pattern, opts) }) .await - .map_err(join_error)??; + .map_err(join_error)?; - Ok(res.into_call_tool_result()) + match res { + Ok(r) => Ok(r.into_call_tool_result()), + Err(e) => Ok(tool_error(e)), + } } #[tool( description = "Read file contents. Use offset to paginate long files; max_lines / max_bytes cap the response size." )] async fn cat(&self, Parameters(args): Parameters) -> ToolResult { - let file_path = self.scope.check(&args.file_path)?; + let file_path = match self.scope.check(&args.file_path) { + Ok(p) => p, + Err(e) => return Ok(tool_error(e)), + }; let res = tokio::task::spawn_blocking(move || { tools::cat( &file_path.to_string_lossy(), @@ -119,9 +137,12 @@ impl CodeMcpServer { ) }) .await - .map_err(join_error)??; + .map_err(join_error)?; - Ok(res.into_call_tool_result()) + match res { + Ok(r) => Ok(r.into_call_tool_result()), + Err(e) => Ok(tool_error(e)), + } } #[tool( @@ -131,29 +152,39 @@ impl CodeMcpServer { &self, Parameters(args): Parameters, ) -> ToolResult { - let dir = self.memory_dir.clone().ok_or_else(|| { - rmcp::ErrorData::invalid_params( - "invalid_request", - Some(serde_json::json!({ - "error": "memory dir not configured; start server with --memory-dir " - })), - ) - })?; + let dir = match self.memory_dir.clone() { + Some(d) => d, + None => { + return Ok(CallToolResult { + content: vec![rmcp::model::Content::text( + "memory dir not configured; start server with --memory-dir ", + )], + structured_content: None, + is_error: Some(true), + meta: None, + }); + } + }; let res = tokio::task::spawn_blocking(move || load_memory(&dir, args.name.as_deref())) .await - .map_err(join_error)??; - - let resp = tools::ToolResponse { - content: res, - truncated: false, - truncation_reason: None, - match_count: None, - entry_error_count: None, - search_error_count: None, - first_error: None, - }; - Ok(resp.into_call_tool_result()) + .map_err(join_error)?; + + match res { + Ok(content) => { + let resp = tools::ToolResponse { + content, + truncated: false, + truncation_reason: None, + match_count: None, + entry_error_count: None, + search_error_count: None, + first_error: None, + }; + Ok(resp.into_call_tool_result()) + } + Err(e) => Ok(tool_error(e)), + } } } diff --git a/src/tools/response.rs b/src/tools/response.rs index 9386d71..701b721 100644 --- a/src/tools/response.rs +++ b/src/tools/response.rs @@ -31,8 +31,12 @@ pub struct ToolResponse { impl ToolResponse { /// Build a `CallToolResult` from this response: text content goes into /// `content`, and the structured metadata goes into `structured_content`. + /// + /// `structured_content` includes the text *plus* metadata so clients that + /// prefer structured output still get the actual content. pub fn into_call_tool_result(self) -> CallToolResult { let structured = json!({ + "content": self.content, "truncated": self.truncated, "truncation_reason": self.truncation_reason, "match_count": self.match_count,