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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,23 @@ 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
# Override any flag — all CLI args are supported natively via ENTRYPOINT
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 \
Expand All @@ -55,17 +56,29 @@ 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.

Pass `--help` to see all options:

```sh
docker run --rm code-mcp --help
docker run --rm ghcr.io/devfire/code-mcp:latest --help
```

<details>
<summary>Build locally instead</summary>

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
```

</details>

Flags:

- `--bind <addr:port>` — default `0.0.0.0:8080`.
Expand All @@ -77,8 +90,6 @@ Flags:
- `--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.

Multi-arch images (`linux/amd64`, `linux/arm64`) are published automatically on every release.

### From source

```bash
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions scripts/generate-memories.sh
Original file line number Diff line number Diff line change
@@ -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 <project-dir> <memory-out-dir>
#
# 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 <project-dir> <memory-out-dir>}"
OUT="${2:?usage: generate-memories.sh <project-dir> <memory-out-dir>}"
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 <<EOF || true
You are bootstrapping a persistent "memory" directory for an MCP code-intelligence
server. Future LLM clients will connect to a large codebase with only grep/find/cat
tools. Your job is to give them a MENTAL MODEL so they navigate efficiently instead
of rediscovering structure from scratch on every session.

The codebase to analyze is at: $PROJECT
Write all output files into: $OUT

Explore the codebase with your file tools before writing anything. Do NOT guess —
verify file paths and module names.

Produce a set of Markdown files in the output directory:

1. MEMORY.md — the index, loaded first by every client. Contains:
- A one-paragraph "what is this codebase" orientation.
- A FUNCTIONAL AREA MAP: the 5-15 major areas (e.g. "HTTP gateway",
"session management", "search engine", "auth"), each with: one-line
responsibility, the entry-point file(s), and a pointer to its detail file.
- A short "how the pieces talk" section: the main data/control flow across areas.
- A list of the detail files below, with cat-able relative paths.

2. One file per functional area (e.g. area_search.md, area_sessions.md). Each:
- What it does and why it exists.
- Key files and the symbols/entry points worth knowing (with paths).
- Cross-cutting concerns and non-obvious gotchas (invariants, footguns,
"looks like X but actually Y").
- Where its tests live.

Rules:
- Capture what is NON-OBVIOUS and EXPENSIVE to rediscover. Do not transcribe code
or list every file — a client can grep. Favor the map over the territory.
- Be concrete: real paths, real module/function names, verified by reading files.
- Keep each file scannable. Terse > 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
13 changes: 13 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use rmcp::ErrorData;
use rmcp::model::CallToolResult;
use serde_json::json;
use thiserror::Error;

Expand All @@ -17,6 +18,18 @@ pub fn join_error(e: tokio::task::JoinError) -> ErrorData {
/// Convenience alias for tool handler return types.
pub type ToolResult<T> = Result<T, ErrorData>;

/// 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<AppError> for ErrorData` impl below: user-facing failures
/// (bad regex, scope violations, not-found) become `invalid_params`, while
Expand Down
93 changes: 62 additions & 31 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -56,8 +56,14 @@ impl CodeMcpServer {
&self,
Parameters(args): Parameters<GrepArgs>,
) -> ToolResult<CallToolResult> {
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,
Expand All @@ -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(
Expand All @@ -89,7 +98,10 @@ impl CodeMcpServer {
&self,
Parameters(args): Parameters<FindArgs>,
) -> ToolResult<CallToolResult> {
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,
Expand All @@ -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<CatArgs>) -> ToolResult<CallToolResult> {
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(),
Expand All @@ -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(
Expand All @@ -131,29 +152,39 @@ impl CodeMcpServer {
&self,
Parameters(args): Parameters<MemoriesArgs>,
) -> ToolResult<CallToolResult> {
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 <path>"
})),
)
})?;
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 <path>",
)],
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)),
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/tools/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading