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,