Introduction
This manual covers codescout: an MCP server that gives AI coding agents IDE-grade code navigation, optimized for token efficiency.
The Problem
When an AI coding agent tries to understand a codebase with conventional file tools, it faces a mismatch between what the tools produce and what the task actually requires.
Consider a routine task: “find all callers of authenticate_user and check
whether they handle the error case.” With standard tools, the agent has a few
options:
- grep — returns every line containing the string, including comments, string literals, documentation, and test fixtures. Disambiguation is the agent’s problem.
- cat — dumps the entire file when the agent needs one function. A 1,000- line module floods the context for a 30-line function.
- find — locates files by name, but has no awareness of what is inside them.
None of these tools understand code structure. They operate on bytes and lines, not symbols, definitions, or references. The result is that agents burn most of their context window on navigation overhead: reading full files to find one function, re-reading the same module multiple times from different entry points, asking questions they already answered two tool calls ago.
The downstream effects compound:
- Shallow understanding. When an agent can only see fragments at a time, it builds an incomplete picture and fills gaps with plausible-sounding guesses.
- Hallucinated edits. Functions that do not exist, arguments in the wrong order, return types copied from the wrong overload.
- Constant course-correction. The human has to re-read the agent’s output, identify what it got wrong, and re-explain the structure it missed.
The tools are structurally blind. Every coding agent using only file primitives runs into this wall, regardless of model capability.
The Solution
codescout exposes the same information an IDE uses — symbol definitions, references, call hierarchies, type information, git history — through a standard MCP interface that any agent can call.
It is a Rust binary that runs alongside your coding agent. The agent sends MCP tool calls; codescout delegates to the right backend (LSP server, tree-sitter, git, embedding index) and returns structured, compact results.
Four pillars:
LSP Navigation (5 tools, 9 languages)
symbols— the outline of a file or directory: classes, functions, structs, in tree form (also performs name-based search)references— all callers/usages of a given symbolsymbol_at— inspect a symbol at a position via LSP: definition location and/or hover (type signature + docs)call_graph— transitive caller/callee traversal for impact analysisedit_code— mutate code by symbol name withaction: replace | insert | remove | rename(consolidates the olderreplace_symbol,insert_code,remove_symbol,rename_symbolinto one tool)
Supported languages: Rust, Python, TypeScript/JavaScript, Go, Java, Kotlin, C/C++, C#, Ruby.
Semantic Search (2 tools)
Sometimes you know the concept but not the name. Semantic search finds code by meaning using embeddings, not keywords.
semantic_search— “authentication middleware”, “retry with exponential backoff”, “how errors are serialized” — returns ranked code chunks. The optionalscopeparameter restricts search to project code, a specific library, or all sources.index(action: build)— build or incrementally update the embedding index (smart change detection via git diff → mtime → SHA-256 fallback)index(action: status)— show index stats: file count, chunk count, embedding model, last update time, and optional per-file drift scores
The embedding backend is configurable: OpenAI, Ollama, or any compatible endpoint.
For git history and diffs, use run_command with shell git commands (e.g. run_command("git log src/auth.rs") or run_command("git diff HEAD")).
Persistent Memory (1 tool)
Agents are stateless across sessions by default. codescout provides a
lightweight key-value store backed by markdown files in .codescout/memories/.
memory— unified dispatch tool:action: "read"/"write"/"list"/"delete"for the file store;"remember"/"recall"/"forget"for natural-language semantic memory
Use this to record decisions, gotchas, and conventions so the agent picks them up on the next session without re-discovery.
Library Navigation (1 tool)
Navigate third-party dependency source code without leaving your agent workflow.
Libraries auto-register when LSP symbol_at returns paths outside the
project root.
library(action: list)— see all registered libraries and their status (useindex(action: build)with a library scope to build a semantic index for it)
The Rest
Beyond these pillars: 7 file/markdown operation tools (directory listing,
file reading, pattern search, file creation, find-and-replace editing,
markdown-aware reading and editing), 3 workflow tools (project onboarding,
shell commands, write-root approval), and 1 config tool (workspace). With
librarian enabled (default), 5 additional artifact tools appear at session
start. ~20 tools in the base server, ~25 with librarian.
Token Efficiency by Design
Every tool defaults to the most compact representation that is still useful.
Full bodies are available via detail_level: "full". Paginated results use
offset and limit. Tools never dump unbounded output.
The design follows two modes:
- Exploring (default) — names and locations, capped at 200 items. Low token cost. Right for orientation.
- Focused — full detail, paginated. Use once you know what you are looking at.
When results overflow the cap, the tool tells you how to narrow the query rather than silently truncating. You get guidance, not garbage.
Who This Manual Is For
This manual is written for three audiences.
Operators
You are setting up codescout for a team or configuring it to work with Claude Code, Cursor, or another MCP-capable agent. You need to understand installation, the MCP configuration format, embedding backend options, and which LSP servers to install for your languages.
Start here: Installation, then Project Configuration.
End-User Developers
You are a developer using Claude Code (or another agent) with codescout already set up. You want to understand what the tools do and when to reach for each one, so you can ask the agent better questions and interpret its reasoning.
Start here: Progressive Disclosure and Tool Selection, then browse the Tool Reference for the categories you use most.
Contributors
You want to add a language, write a new tool, or swap in a different embedding
backend. You need to understand the internal architecture: the Tool trait,
the LSP client, the embedding pipeline, the output guard system.
Start here: Architecture, then Adding Languages and Writing Tools.
How to Read This Manual
The manual is organized into three sections:
User Guide — everything you need to install, configure, and use codescout. Reads linearly for first-time setup; use it as a reference once you are familiar.
Tool Reference — one page per tool category. Each page covers what the tools do, their parameters, output format, and when to prefer them over alternatives. You do not need to read this cover to cover; look up the category you need.
Development — architecture internals, extension guides, and troubleshooting. Oriented toward contributors and operators debugging unexpected behavior.
Get Started
- Installation — build the binary, register the MCP server, install LSP servers
- Your First Project — onboarding, indexing, and your first tool calls
- Routing Plugin — the plugin that ensures Claude always reaches for codescout tools
A Quick Example
Here is what a concrete agent interaction looks like with codescout versus without it.
Without codescout — the agent uses read_file on auth.rs (850
lines), scans for authenticate_user, reads the function, then uses grep for
callers, gets 23 hits including test fixtures, reads three more files to
disambiguate, and still misses that the error type changed in a recent refactor.
With codescout:
symbols("src/auth.rs")
→ authenticate_user [fn, line 142], SessionStore [struct, line 12], ...
references("authenticate_user", "src/auth.rs")
→ middleware/auth_guard.rs:88, handlers/login.rs:34, handlers/api.rs:201
run_command("git log src/auth.rs")
→ 3 days ago: "refactor: change AuthError to return structured payload"
symbols("AuthError", include_body=true)
→ enum AuthError { ... } with full definition
Four targeted calls. The agent sees the symbol tree, the exact call sites, the relevant git history, and the type definition — without reading a single full file. That is the difference codescout makes.
From code-explorer to codescout
TL;DR
- The project was renamed. The binary is now
codescout, notcode-explorer.- Update your MCP config: change the server key from
code-explorertocodescout.- Update any scripts or aliases that call the old binary name.
- 9 tools were renamed and 3 consolidated — see the CHANGELOG for the full mapping.
The name
The original name was code-explorer. It made sense at the time — the tool helped an AI navigate
a codebase the way a developer would explore it in an IDE.
Two things changed.
First, the practical one: code-explorer was already taken on crates.io. A
Rust binary needs a crate name, and that one wasn’t available.
Second, the honest one: the name had stopped fitting. By the time the rename happened, the tool had grown persistent memory that survives across sessions, semantic search over embeddings, a shell integration with output buffering, a project dashboard, and LSP-backed navigation across 9 languages. It wasn’t just exploring files anymore. It was orienting an AI inside a codebase — tracking context, surfacing what matters, remembering what was learned.
Scout felt closer to that. A scout doesn’t just wander. It goes ahead, maps the terrain, and comes back with something useful.
What it grew into
The project started as file navigation. You could list symbols, search for patterns, read a function body without dumping the whole file into context.
Then it got LSP: real go-to-definition, hover types, find-all-references — the same signals a developer gets from their IDE, available to the AI.
Then semantic search: find code by concept, not just by text match. Then persistent memory: notes the AI can read back next session, carrying context forward. Then shell integration with output buffers, so large command output doesn’t blow the context window. Then a dashboard for project health.
Each addition was driven by a recurring friction — the AI doing something clumsy that a better tool could prevent. The scope kept expanding because the problem kept expanding.
Migrating from code-explorer
If you were running code-explorer before, here’s everything that changed at the API surface:
| What | Before | After |
|---|---|---|
| Binary name | code-explorer | codescout |
MCP server key (.mcp.json) | "code-explorer" | "codescout" |
| Claude Code settings key | "code-explorer" | "codescout" |
| Cargo crate | code-explorer | codescout |
Update your .mcp.json (or Claude Code’s ~/.claude/settings.json) to use "codescout" as the
server key. The core behavior is unchanged — it’s a rename, not a rewrite. Tool names were also
tidied up alongside the rename; see What else changed below.
What else changed
Alongside the rename, the tool API was tidied up:
- 9 tools renamed for consistency — plural
list_*for enumeration,find_*for search,search_*for text/semantic. Full mapping in the CHANGELOG. - 3 tools consolidated —
insert_before_symbolandinsert_after_symbolmerged intoinsert_code(position: "before"|"after").is_onboardedfolded intoonboarding(force: true).
Why codescout?
AI coding agents using only raw file tools — cat, grep, find — burn most of their
context window on navigation overhead: reading full files to find one function,
re-reading the same module from different entry points, asking questions they already
answered two tool calls ago.
The result is shallow understanding, hallucinated edits, and constant course-correction. See the comparison table in the project README for a side-by-side view.
Design choices
codescout exposes the same information an IDE uses — symbol definitions, references, type info, git history — through a standard MCP interface. Three choices drive the design:
- Single-session over agent chains — skills run in the same context window as the main session, avoiding the compound error that accumulates at every inter-agent handoff
- LSP navigation over file reads — symbol-level queries are 10–50x more token-efficient than reading files, and return structured results rather than noise
- Compact by default — every tool defaults to the most useful minimal representation;
full bodies available on demand via
detail_level: "full"
Research
These choices are informed by research on compound error in multi-agent systems — research and empirical evidence confirm failure rates of 41–87% in production multi-agent pipelines.
Installation
Platform support: codescout has been tested on Linux. macOS and Windows may work but have not been verified. Contributions welcome.
This is a Claude Code tool. codescout is built for Claude Code and currently requires it as the host agent.
The Easy Way
Clone the repo and let Claude handle the installation. It has access to the full documentation, your system, and the install scripts — it will build the binary, register the MCP server, install LSP servers for your languages, and set up the routing plugin.
git clone https://github.com/mareurs/codescout.git
cd codescout
claude
# Then ask: "Help me install and set up codescout"
Manual Installation
If you prefer to install manually, follow the steps below.
Prerequisites
You need a working Rust toolchain. If you do not have one, install it via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Verify you have Rust 1.75 or later:
rustc --version
You also need Claude Code installed and available as claude on your PATH.
Installing the Binary
Install codescout from crates.io:
cargo install codescout
This builds with the remote-embed feature enabled by default, which adds HTTP client support for
talking to an external embedding API (OpenAI-compatible, Ollama, etc.). If you want local CPU-based
embeddings without any external service, see Feature Flags below.
Registering as an MCP Server
Claude Code discovers MCP servers through its configuration. You can register codescout either globally (applies to every Claude Code session on your machine) or per-project (applies only when working in a specific directory tree).
Global Registration
Run this once to make codescout available in every Claude Code session:
claude mcp add --global codescout -- codescout start --project .
With global registration, codescout starts automatically whenever Claude Code opens. The
--project . argument tells it to activate the project you opened Claude Code in.
Per-Project Registration via .mcp.json
For tighter control — useful when different projects need different embedding backends or when you
are sharing configuration with a team — create or edit .mcp.json in the project root:
claude mcp add codescout -- codescout start --project /path/to/your/project
This writes an entry to .mcp.json. Commit the file so that everyone working on the project gets
the same codescout setup automatically.
A resulting .mcp.json entry looks like this:
{
"mcpServers": {
"codescout": {
"command": "codescout",
"args": ["start", "--project", "/path/to/your/project"]
}
}
}
Replace /path/to/your/project with the absolute path to your repository root.
Verification
After registering, confirm Claude Code sees the server and all its tools:
claude mcp list
You should see codescout listed with 29 tools. If it does not appear, make sure the
codescout binary is on your PATH:
which codescout
codescout --version
Running Onboarding
This is the most important step. After registering the MCP server, you must run onboarding once for each project you want to use codescout with. Until you do, codescout has no project context — LSP servers haven’t started, no system prompt has been generated, and the agent won’t know which tools to use or when.
In a new session in your project directory, ask your agent:
Run codescout onboarding
The onboarding tool will:
- Detect your project — languages, entry points, key files
- Start LSP servers — one per detected language (Rust analyzer, Pyright, ts-server, etc.)
- Generate a system prompt — a project-specific guidance block injected into every future session, covering tool selection rules, entry points, and navigation tips
- Write
.codescout/project.toml— your project config file, which you can edit to customize embedding backends, ignored paths, and security settings
Onboarding takes 10–30 seconds on first run (LSP server startup). Subsequent sessions reuse the running servers and load instantly.
When to re-run onboarding
- After adding a new language to a project (new LSP server needed)
- After significantly restructuring your codebase (entry points change)
- After editing
.codescout/project.tomlmanually - If codescout’s tool guidance feels stale or wrong
Re-run with force: true to rebuild from scratch: ask your agent "Run codescout onboarding with force: true".
Note: If you skip onboarding, tools like
symbols,symbols, andsymbol_atwill return errors — they depend on LSP servers that onboarding starts.
Feature Flags
As of v0.12 the default substrate is the Retrieval Stack (Qdrant + dense embedder + sparse SPLADE + cross-encoder reranker, all running as docker containers). The
local-embedCargo feature still exists for air-gapped use but is no longer the default and is no longer the path the team benchmarks against —local-embedskips sparse fusion and rerank, which the benchmark shows costs ~9 points out of 75 (28 vs 37 on the champion config). See Migration from local-embed if you are upgrading from <0.12.
The Cargo features below control the codescout binary’s compile-time
capabilities. They are independent of whether you run the retrieval stack —
even without local-embed, the binary always speaks HTTP to the stack via
remote-embed.
| Feature | What it does | When to use it |
|---|---|---|
remote-embed (default) | HTTP client for the dense embedder service (TEI or OpenAI protocol) | Always — required for the retrieval stack and for Ollama / OpenAI / llama.cpp endpoints |
local-embed | In-process CPU embeddings via fastembed-rs + ONNX Runtime | Air-gapped machines; requires building from source and skips the network rerank/sparse pipeline |
http (default) | HTTP/SSE MCP transport (vs stdio-only) | Always — used by codescout dashboard and remote MCP clients |
librarian (default) | Embeds the librarian-mcp doc/spec/plan indexer | Always — runtime-enabled by default; opt out via LIBRARIAN_ENABLED=0 |
Want free, local embeddings without running docker? Two options:
- Ollama as the dense embedder. Install Ollama, pull a model (
ollama pull nomic-embed-text), and setCODESCOUT_EMBEDDER_URL=http://127.0.0.1:11434,CODESCOUT_EMBEDDER_PROTOCOL=openai. You still need Qdrant + the reranker + the sparse service running from the docker stack — Ollama only replaces the dense leg. See Retrieval Stack > Using Ollama / llama.cpp / OpenAI.- Build with
local-embedfor the pure in-process path. Accept the benchmark penalty (no rerank, no sparse fusion) in exchange for zero network dependencies.
Local Embeddings via fastembed (local-embed)
The local-embed feature depends on ONNX Runtime as a native system library. Because of
this native dependency, it is not available via cargo install codescout from crates.io.
To use it you must build from source:
git clone https://github.com/mareurs/codescout.git
cd codescout
cargo install --path . --no-default-features --features local-embed,http,librarian
The first time you build a semantic search index, the local backend model downloads
automatically to ~/.cache/huggingface/hub/. Subsequent uses are fully offline.
In this mode semantic_search falls back to pure dense vector scoring — no
SPLADE sparse leg, no cross-encoder rerank. The retrieval-stack configuration
in this manual does not apply to local-embed mode.
Minimal Install (No Embeddings)
If you only want LSP-backed symbol navigation and git tools and do not need semantic search, you can build without any embedding feature:
cargo install codescout --no-default-features --features http,librarian
Semantic search tools (semantic_search, index) will return a clear error if called
without an embedding backend compiled in.
Next Steps
- Your First Project — open a project, run onboarding, and try the basic tools
- Routing Plugin — install the plugin that steers Claude toward codescout tools automatically
Your First Project
This page walks you through opening a project for the first time and making sure codescout is working correctly before you start a real task.
Start a Claude Code Session
Open Claude Code in your project directory:
cd /path/to/your/project
claude
When codescout is registered (either globally or via .mcp.json), it starts automatically
alongside Claude Code. You do not need to do anything to launch the MCP server.
What Happens on First Open
The first time codescout activates in a project it:
- Creates a configuration file at
.codescout/project.tomlwith sensible defaults. - Detects the languages present in the repository (based on file extensions and tree-sitter grammar support).
- Starts LSP servers for the detected languages, ready to answer symbol queries.
You can check the generated configuration at any time with the workspace tool:
{ "name": "workspace", "arguments": { "action": "status" } }
Running Onboarding
For a project you have not explored before, run onboarding first. It performs a structured
discovery pass: reads directory structure, detects languages and frameworks, and writes a set of
memory entries so future sessions start with context already in place.
{ "name": "onboarding", "arguments": {} }
Onboarding takes 10–30 seconds depending on project size. During the run it probes your hardware
(Ollama availability, GPU, RAM) and presents a ranked list of embedding model options. Accept the
recommendation or pick an alternative — the chosen model is written into .codescout/project.toml
before indexing begins.
It produces a summary of what it found and tells you how many memory entries it wrote. Subsequent
sessions skip the heavy discovery work because the memories are already there — call onboarding
again (with default arguments) to retrieve existing memories without re-running discovery:
{ "name": "onboarding", "arguments": {} }
Building the Embedding Index
Semantic search requires an embedding index. Build it once, then keep it up to date as the codebase changes:
{ "name": "index", "arguments": { "action": "build" } }
Indexing chunks every source file, embeds each chunk, and stores the vectors in
.codescout/embeddings.db. For a project with ~100k lines of code this typically takes
1–3 minutes. The index is incremental — only changed files are re-embedded on subsequent runs.
Verify the index was built successfully:
{ "name": "workspace", "arguments": { "action": "status" } }
Sample output:
Embedding index status
Files indexed : 312
Chunks : 4 847
Model : nomic-embed-text
Last updated : 2026-02-26 14:32 UTC
Trying the Basic Tools
Once onboarding and indexing are done, try these tools to get a feel for what is available.
List Directory Structure
See the top-level layout of the project:
{ "name": "tree", "arguments": { "path": "." } }
Drill into a subdirectory:
{ "name": "tree", "arguments": { "path": "src", "recursive": true } }
List Symbols
See the classes, functions, and types defined in a directory — one compact line per symbol, no bodies:
{ "name": "symbols", "arguments": { "path": "src/" } }
Sample output (Rust project):
src/main.rs
fn main src/main.rs:12
fn parse_args src/main.rs:28
src/server.rs
struct Server src/server.rs:14
impl Server
fn new src/server.rs:31
fn run src/server.rs:58
fn shutdown src/server.rs:102
Find a Symbol by Name
Locate every definition of a symbol across the entire project:
{ "name": "symbols", "arguments": { "pattern": "main" } }
To see the full function body alongside the location, add include_body:
{
"name": "symbols",
"arguments": { "pattern": "main", "include_body": true }
}
Semantic Search
Find code by concept rather than by name — useful when you do not know what the relevant symbol is called:
{
"name": "semantic_search",
"arguments": { "query": "error handling" }
}
Sample output:
src/server.rs lines 88-112 score 0.91
fn handle_request(...) -> Result<Response, AppError> {
...
src/errors.rs lines 1-45 score 0.87
pub enum AppError { ... }
Each result includes the file path, line range, similarity score, and a preview of the matched chunk. Use the score as a rough relevance signal — results above 0.8 are usually directly relevant; results below 0.5 are often tangential.
Typical First-Session Workflow
A practical sequence for exploring an unfamiliar codebase:
onboarding— discover and remember the project structure.index(action: build)— build the semantic search index.treeon the root and key subdirectories — build a mental map.symbols("src/")— see what is defined at the top level.semantic_search("entry point")orsymbols("main")— find where execution starts.- From there, use
referencesto trace callers andsymbolsto navigate deeper into subsystems.
After the first session, onboarding memories persist in .codescout/memories/ and the
embedding index stays in .codescout/embeddings.db. Both are checked into .gitignore
by default so team members build their own local copies.
Next Steps
- Routing Plugin — install the plugin that ensures subagents also use codescout
- Tool Selection — when to use symbol tools vs semantic search vs text search
- Progressive Disclosure — how tools manage output size automatically
codescout-companion Plugin
Why the Plugin Exists
Claude Code has access to codescout’s 28 tools, but it also has built-in tools like grep,
cat, and Read. Without guidance, Claude tends to reach for the familiar built-ins — especially
in subagents, which start each task with a blank slate and have no memory of earlier instructions.
The codescout-companion plugin solves this with three hooks that run automatically:
- SessionStart — injects a tool selection guide into every new session, explaining when to prefer codescout tools over built-ins.
- SubagentStart — propagates the same guide to every subagent that Claude Code spawns, so subagents also know to use codescout from the start.
- PreToolUse — actively intercepts calls to
grep,cat,Read,find, andlsand redirects them to the appropriate codescout equivalents before they execute.
The difference in practice:
- Without the plugin: Claude has access to codescout but may use
grepfor pattern search andcatfor reading files out of habit, missing LSP-backed navigation, token-efficient output, and progressive disclosure. - With the plugin: every session and subagent starts with a clear preference order, and old habits are caught and redirected automatically.
Architecture
┌─────────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ codescout-companion plugin (hooks) │ │
│ │ │ │
│ │ SessionStart → inject tool selection guide │ │
│ │ SubagentStart → propagate to all subagents │ │
│ │ PreToolUse → redirect grep/cat/read to │ │
│ │ codescout equivalents │ │
│ └──────────────────────┬──────────────────────┘ │
│ │ routes to │
│ ┌──────────────────────▼──────────────────────┐ │
│ │ codescout MCP server (28 tools) │ │
│ │ │ │
│ │ LSP · Semantic · Git · AST · Memory · ... │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
The plugin is a lightweight shim — it holds no state and adds no latency to tool calls. Its only job is to steer Claude toward the right tools at the right moment.
Installation
Option 1: Claude Code Plugin Command
/plugin marketplace add mareurs/sdd-misc-plugins
/plugin install codescout-companion@sdd-misc-plugins
This downloads and enables the plugin immediately. It persists across sessions.
Option 2: User Settings
Add the plugin to your Claude Code user settings at ~/.claude/settings.json:
{
"enabledPlugins": {
"codescout-companion@sdd-misc-plugins": true
}
}
If the file does not exist yet, create it with that content. Settings take effect the next time you start a Claude Code session.
Verification
After installation, start a new Claude Code session and ask Claude which tools it will use for
code search. You should see it cite grep, symbols, and semantic_search
rather than grep. You can also check installed plugins:
claude /plugin list
codescout-companion@sdd-misc-plugins should appear in the output.
What Each Hook Does
SessionStart
Injects the following guidance at the start of every session:
- Prefer
grepovergrepfor regex search across files. - Prefer
symbolsandsymbolsovercat/Readwhen exploring code structure. - Prefer
treeoverlsandtree(with glob) overfind. - Use
semantic_searchwhen looking for code by concept rather than by name. - Reserve built-in file tools for writing new content and reading files that codescout does not index (binary files, generated artifacts, etc.).
This guidance is injected as a system-level note, not as a user message, so it does not clutter the conversation.
SubagentStart
Claude Code spawns subagents for parallel or delegated tasks. Each subagent is a fresh context with no knowledge of earlier instructions. The SubagentStart hook fires when each subagent initialises and injects the same tool selection guide, ensuring consistent behaviour across the full agent tree.
Without this hook, subagents reliably fall back to grep and cat because they have no other
frame of reference.
PreToolUse
The interception hook fires before any of these built-in tools execute:
| Built-in called | Redirected to |
|---|---|
grep | grep |
Read | symbols or symbols (for source files) |
cat | symbols or symbols (for source files) |
find | tree (with glob) |
ls | tree |
The hook does not blindly block all uses of these tools. It applies heuristics to distinguish between reading source code (redirect) and reading configuration, logs, or other non-code files (allow through). You can inspect the redirection logic in the plugin source if you need to adjust the heuristics for your workflow.
Disabling the Plugin
To turn off the plugin without uninstalling it, set its value to false in settings:
{
"enabledPlugins": {
"codescout-companion@sdd-misc-plugins": false
}
}
Or uninstall it entirely:
claude /plugin uninstall codescout-companion@sdd-misc-plugins
Further Reading
- Routing Plugin (concepts) — how the plugin works, why hard blocks beat soft warnings, the subagent coverage problem
Agent Integrations
codescout works with any MCP-capable coding agent. Once registered as an MCP server, codescout’s system prompt injects automatically into every session, giving the agent tool selection rules and iron laws for code navigation.
Feature comparison
| Feature | Claude Code | GitHub Copilot | Cursor |
|---|---|---|---|
| MCP protocol | stdio | stdio | stdio |
| System prompt injection | Automatic | Automatic | Automatic |
| Tool enforcement (routing plugin) | Plugin with hooks | Copilot Skill guidance | Cursor Rules guidance |
| Workspace support | Full | Full | Full |
| Onboarding | Automatic | Automatic | Automatic |
Guides
| Agent | Guide |
|---|---|
| Claude Code | Claude Code — primary integration with routing plugin enforcement |
| GitHub Copilot | GitHub Copilot — VS Code extension with Skills-based guidance |
| Cursor | Cursor — Cursor Rules-based guidance |
Claude Code
One-Time Setup
Prerequisites: Rust toolchain.
1. Install the binary
Option A — from crates.io (recommended):
cargo install codescout
The binary lands at ~/.cargo/bin/codescout.
Option B — build from source:
git clone https://github.com/mareurs/codescout
cd codescout
cargo build --release
# binary is at target/release/codescout
# optionally copy it somewhere on your PATH:
cp target/release/codescout ~/.local/bin/
2. Register as an MCP server
User-level (available in all projects) — recommended:
claude mcp add --scope user --transport stdio codescout -- codescout start
This writes the entry to ~/.claude.json. You can also edit that file directly if you prefer.
Project-level — place a .mcp.json file at the project root:
{
"mcpServers": {
"codescout": {
"command": "codescout",
"args": ["start"],
"type": "stdio"
}
}
}
3. Onboard your project
Once the MCP server is registered, open Claude Code in your project directory and ask it to run onboarding. Onboarding performs a structured discovery pass — reads directory structure, detects languages and frameworks, probes available embedding backends, and writes memory entries so future sessions start with context already in place. It takes 10–30 seconds depending on project size.
After onboarding, build the semantic search index:
Run
index_project.
See Your First Project for the full walkthrough.
Workflow Skills
Claude Code handles workflow skills differently from Copilot/Cursor — skills are loaded via the Superpowers plugin system, not manually installed files. No manual skill file installation is needed; skills activate automatically once the companion plugin is set up. See Superpowers workflow for details.
Routing Plugin (codescout-companion)
The routing plugin is a Claude Code plugin that enforces codescout tool use via
PreToolUse hooks. Without it, the agent may fall back to native Read, Grep, and
Glob tools — which work but bypass codescout’s token-efficient symbol navigation.
What it blocks:
Readon source files (.rs,.ts,.py, etc.) → redirects tolist_symbols/find_symbolGrep/Globon source files → redirects tosearch_pattern/find_fileBashfor shell commands → redirects torun_command
What it allows:
Readon non-source files (markdown, TOML, JSON, config)- All codescout MCP tools pass through unrestricted
Install via:
claude plugin install codescout-companion
Or follow the Routing Plugin guide for manual setup.
Debugging: If the plugin blocks a legitimate operation, create
.claude/codescout-companion.json with {"block_reads": false} to temporarily
disable blocking.
Verify
Restart Claude Code, then run /mcp — confirm codescout appears as connected. Then ask: “What symbols are in src/main.rs?” — Claude should call mcp__codescout__list_symbols, not read the file.
Multi-Project Workspaces
codescout supports multi-project workspaces. Register projects in
.codescout/workspace.toml:
[[project]]
id = "backend"
root = "services/backend"
[[project]]
id = "frontend"
root = "apps/frontend"
After onboarding, use the project parameter to scope tool calls:
find_symbol("UserService", project: "backend")
memory(action: "read", project: "frontend", topic: "architecture")
Day-to-Day Workflow
codescout injects tool guidance automatically into every session via the MCP system prompt. For the full disciplined development workflow, see:
Tips
Buffer refs — When read_file or run_command returns a @file_* or @cmd_*
handle, the content is stored server-side. Query it with
run_command("grep pattern @cmd_xxxx") or read sub-ranges with
read_file("@file_xxxx", start_line=1, end_line=100).
Semantic search for exploration — When entering an unfamiliar part of the codebase,
start with semantic_search("how does X work") rather than reading files. It returns
ranked code chunks by relevance.
Memory for cross-session context — Use memory(action: "remember", content: "...")
to store decisions, patterns, or gotchas. Use memory(action: "recall", query: "...")
to retrieve them by meaning in future sessions.
Library navigation — When goto_definition resolves to a dependency, codescout
auto-registers the library. Use semantic_search(scope: "lib:tokio") to search
within it.
GitHub Copilot
codescout works with GitHub Copilot Chat in VS Code via the MCP protocol. Once registered, Copilot uses codescout’s symbol and semantic tools for all code navigation instead of reading source files directly.
One-Time Setup
Prerequisites: VS Code (latest), GitHub Copilot subscription (Individual, Business, or Enterprise), Rust toolchain.
Install codescout:
cargo install codescout
The binary lands at ~/.cargo/bin/codescout. Make sure ~/.cargo/bin is in your PATH.
Next, clone the codescout-companion repository — this provides the workflow skills, enforcement hook, and code-reviewer agent used in the steps below. The commands use path/to/copilot-codescout as a placeholder for wherever you cloned it.
Register codescout as an MCP server
Recommended: user-level registration — codescout becomes available in all your projects
without per-project config. Edit ~/.config/Code/User/mcp.json (create if it doesn’t exist):
{
"servers": {
"codescout": {
"type": "stdio",
"command": "codescout",
"args": ["start"]
}
},
"inputs": []
}
Note: VS Code uses
"servers"not"mcpServers". Ifcargois not in PATH, use the full path as thecommandvalue (e.g.~/.cargo/bin/codescout).
Alternative: per-project registration — create .vscode/mcp.json in your project root
with the same servers block. Use this if you want codescout only for specific projects.
VS Code schema validation note: VS Code validates MCP tool schemas more strictly than Claude Code. If you see a warning like “Failed to validate tool … array type must have items”, it is a schema bug in the MCP server — other tools still load and work normally. Report it to the codescout maintainer.
Once registered, codescout’s system prompt injects automatically into every Copilot Chat session, giving Copilot iron laws for code navigation, a tool selection table, anti-patterns to avoid, and the output buffer system for large results.
Enable Agent Skills
- Open Settings (
Ctrl+,/Cmd+,) - Search for
chat.useAgentSkills - Enable it
Copilot will now auto-detect and load skill files when relevant to your request.
Add workflow skills
VS Code Copilot discovers skills in .github/skills/.
# Option A: Symlink (recommended — stays in sync with updates)
# NOTE: do NOT mkdir .github/skills first — ln creates the symlink as the target name
mkdir -p .github
ln -s path/to/copilot-codescout/Skills .github/skills
# Option B: Copy (standalone, no sync)
mkdir -p .github/skills
cp -r path/to/copilot-codescout/Skills/* .github/skills/
Skills are discovered automatically — no further configuration needed.
Add the code-reviewer agent
VS Code Copilot discovers agents in .github/agents/.
mkdir -p .github/agents
cp path/to/copilot-codescout/Agents/code-reviewer.agent.md .github/agents/
Enforcement Hook
The enforcement hook blocks Copilot from reading source files directly and redirects it to codescout tools. Requires Python 3.
mkdir -p .github/hooks
cp path/to/copilot-codescout/Hooks/enforce-codescout.py .github/hooks/
cp path/to/copilot-codescout/Hooks/enforce-codescout.json .github/hooks/
This installs a PreToolUse hook that:
- Blocks
read/readFileon source files (.ts,.js,.py,.rs, etc.) and redirects to the appropriate codescout tool - Blocks
search/codebaseand redirects tomcp__codescout__semantic_search - Allows reading config files, markdown, JSON, YAML, and lock files
Multi-Project Workspaces
codescout supports multi-project workspaces via .codescout/workspace.toml.
After onboarding, pass project to scope tool calls to a specific project:
{ "tool": "find_symbol", "arguments": { "pattern": "UserService", "project": "backend" } }
Always-On Instructions
cp path/to/copilot-codescout/copilot-instructions.md .github/copilot-instructions.md
VS Code Copilot injects .github/copilot-instructions.md into every chat session
automatically, giving Copilot the codescout iron laws and tool selection table before any
request.
If
.github/copilot-instructions.mdalready exists, append the codescout section rather than overwriting.
Verify
Start a new Copilot Chat session and ask:
“What symbols are in src/main.ts?”
Copilot should call mcp__codescout__list_symbols rather than reading the file directly.
Day-to-Day Workflow
Skills activate automatically when their description matches the request — you never type a slash command. The standard flow is:
brainstorming → using-git-worktrees → writing-plans → subagent-driven-development → finishing-a-development-branch
Skill trigger table
| What you say | Skill that activates |
|---|---|
| “Let’s build X” / “I want to add X” | brainstorming |
| “Create a worktree” / “New branch” | using-git-worktrees |
| “Write the plan” / “Break into tasks” | writing-plans |
| “Execute” / “Implement” | subagent-driven-development |
| “Review this” / “Before I merge” | requesting-code-review |
| “I’m done” / “All tasks complete” | finishing-a-development-branch |
Step-by-step
1. Brainstorming — Describe what you want to build. Copilot activates the brainstorming
skill, explores the codebase via codescout (semantic search, symbol lookup), asks clarifying
questions one at a time, and proposes 2–3 approaches with trade-offs. No code is written until
you approve the design.
2. Set up an isolated workspace — Say “Create a worktree” or “Let’s work on a branch.”
The using-git-worktrees skill creates an isolated git worktree on a new branch, runs project
setup, and verifies the test baseline is clean before any code is written.
3. Write the implementation plan — Say “Write the plan” or “Break this into tasks.” The
writing-plans skill produces a detailed docs/plans/YYYY-MM-DD-feature.md with every task
broken into 2–5 minute TDD steps: write the failing test, watch it fail, write minimal code,
watch it pass, commit.
4. Execute the plan — Say “Execute the plan” or “Implement it.” The
subagent-driven-development skill dispatches a fresh sub-agent per task (clean context, no
drift). A spec compliance reviewer and a code quality reviewer both check each implementation
before the next task begins.
5. Code review — Happens automatically after each task in subagent-driven-development.
For ad-hoc review, say “Review this implementation.” The requesting-code-review skill
dispatches the code-reviewer agent, which uses references and semantic_search to
check impact beyond the changed files.
6. Finish the branch — Say “I’m done” or “All tasks complete.” The
finishing-a-development-branch skill verifies all tests pass, presents merge/PR/discard
options, and cleans up the worktree.
Tips
- Don’t rush brainstorming. The questions feel slow but they prevent far more work later.
- Trust the spec reviewer. If it says something is missing, it read the actual code — not the implementer’s report.
- Let codescout navigate. If Copilot starts reading whole files, remind it: “use codescout to explore”.
- One task at a time. The subagent-driven workflow is sequential by design — parallel tasks introduce conflicts.
Updating Skills
cd path/to/copilot-codescout
git pull
# Symlink: already up to date.
# Copy: re-run the cp command.
Cursor
codescout works with Cursor Agent chat via the MCP protocol. Once registered, Cursor uses codescout’s symbol and semantic tools for all code navigation instead of reading source files directly.
One-Time Setup
Prerequisites: Cursor (latest), Rust toolchain.
Install codescout:
cargo install codescout
The binary lands at ~/.cargo/bin/codescout. Make sure ~/.cargo/bin is in your PATH.
Next, clone the codescout-companion repository — this provides the workflow skills, enforcement hook, and code-reviewer agent used in the steps below. The commands use path/to/copilot-codescout as a placeholder for wherever you cloned it.
Register codescout as an MCP server
Recommended: project-level registration — checked into git, shared with your team. Create
.cursor/mcp.json in your project root:
{
"mcpServers": {
"codescout": {
"command": "codescout",
"args": ["start"]
}
}
}
Note: Cursor uses
"mcpServers"(withs), unlike VS Code’s"servers". Ifcargois not in PATH, use the full path as thecommandvalue (e.g.~/.cargo/bin/codescout).
Alternative: global registration — available in all projects. Open Cursor → Settings → Cursor Settings → MCP → Add new server → paste the same config block.
Once registered, codescout’s system prompt injects automatically into every Agent session.
Add workflow skills
Cursor discovers rules in .cursor/rules/. Two options:
Option A — Convert each skill to .mdc format (recommended):
For each skill in path/to/copilot-codescout/Skills/, create a corresponding
.cursor/rules/<name>.mdc file:
mkdir -p .cursor/rules
Each .mdc file follows this format:
---
description: [paste the description from skill frontmatter]
globs:
alwaysApply: false
---
[paste the full skill body here]
Set alwaysApply: false — Cursor auto-applies rules based on description matching, the same
model-invoked behavior as Copilot Skills.
Option B — Entry-point rule with alwaysApply: true:
Add the using-superpowers skill content to .cursor/rules/using-superpowers.mdc with
alwaysApply: true. This tells the agent to check for relevant rules before any task,
mirroring the Claude Code hook behavior.
Add the code-reviewer agent
mkdir -p .cursor/agents
cp path/to/copilot-codescout/Agents/code-reviewer.agent.md .cursor/agents/
Enforcement Hook
The enforcement hook blocks Cursor from reading source files directly and redirects it to codescout tools. Requires Python 3.
mkdir -p .cursor/hooks
cp path/to/copilot-codescout/Hooks/enforce-codescout.py .cursor/hooks/
cp path/to/copilot-codescout/Hooks/enforce-codescout.json .cursor/hooks/
This installs a PreToolUse hook that:
- Blocks
read/readFileon source files (.ts,.js,.py,.rs, etc.) and redirects to the appropriate codescout tool - Blocks
search/codebaseand redirects tomcp__codescout__semantic_search - Allows reading config files, markdown, JSON, YAML, and lock files
Verify
Start a new Agent chat and ask:
“What symbols are in src/main.ts?”
The agent should call mcp__codescout__list_symbols rather than reading the file directly.
Day-to-Day Workflow
Skills activate automatically when their description matches the request — you never type a slash command. The standard flow is:
brainstorming → using-git-worktrees → writing-plans → subagent-driven-development → finishing-a-development-branch
Rule trigger table
| What you say | Rule that activates |
|---|---|
| “Let’s build X” / “I want to add X” | brainstorming |
| “Create a worktree” / “New branch” | using-git-worktrees |
| “Write the plan” / “Break into tasks” | writing-plans |
| “Execute” / “Implement” | subagent-driven-development |
| “Review this” / “Before I merge” | requesting-code-review |
| “I’m done” / “All tasks complete” | finishing-a-development-branch |
Step-by-step
1. Brainstorming — Describe what you want to build. The agent activates the brainstorming
rule, explores the codebase via codescout (semantic search, symbol lookup), asks clarifying
questions one at a time, and proposes 2–3 approaches with trade-offs. No code is written until
you approve the design.
2. Set up an isolated workspace — Say “Create a worktree” or “Let’s work on a branch.”
The using-git-worktrees rule creates an isolated git worktree on a new branch, runs project
setup, and verifies the test baseline is clean before any code is written.
3. Write the implementation plan — Say “Write the plan” or “Break this into tasks.” The
writing-plans rule produces a detailed docs/plans/YYYY-MM-DD-feature.md with every task
broken into 2–5 minute TDD steps: write the failing test, watch it fail, write minimal code,
watch it pass, commit.
4. Execute the plan — Say “Execute the plan” or “Implement it.” The
subagent-driven-development rule dispatches a fresh sub-agent per task (clean context, no
drift). A spec compliance reviewer and a code quality reviewer both check each implementation
before the next task begins.
5. Code review — Happens automatically after each task in subagent-driven-development.
For ad-hoc review, say “Review this implementation.” The requesting-code-review rule
dispatches the code-reviewer agent, which uses references and semantic_search to
check impact beyond the changed files.
6. Finish the branch — Say “I’m done” or “All tasks complete.” The
finishing-a-development-branch rule verifies all tests pass, presents merge/PR/discard
options, and cleans up the worktree.
Tips
.cursor/rules/is your friend. If a rule isn’t triggering, check that the.mdcfile exists and thedescriptionfield is specific enough.- Context window. Cursor Agent can lose context on very long sessions. The subagent-driven approach (fresh agent per task) is specifically designed to prevent this.
- Let codescout navigate. If the agent starts reading whole files instead of using symbol tools, say: “use codescout to explore this”.
- Compose with existing Cursor Rules. Your team’s
.cursor/rules/files for style, architecture, or tooling sit alongside the codescout skill rules — they don’t conflict. - Check MCP is active. Open Cursor Settings → MCP and verify the server shows a green status indicator.
Multi-Project Workspaces
codescout supports multi-project workspaces via .codescout/workspace.toml.
After onboarding, pass project to scope tool calls to a specific project:
{ "tool": "find_symbol", "arguments": { "pattern": "UserService", "project": "backend" } }
Cursor-Specific Notes
Rules vs Skills
Cursor calls them “Rules” (.cursor/rules/*.mdc). They are functionally identical to Copilot
Skills — model-invoked based on description matching. Same content, different filename
extension and location.
Agent Chat vs Background Agent
- Agent Chat (Cmd+L → Agent): Interactive, sees your conversation. Use this for the full workflow.
- Background Agent: Headless task runner. Good for executing a specific isolated task from the plan, but lacks the interactive brainstorming/review loop.
Use Agent Chat for the full codescout workflow.
Plan Mode
Cursor has a built-in “Plan Mode” (the thinking icon in Agent chat). The brainstorming rule
replaces this for structured design work — it’s more thorough and saves a design doc. For
quick one-off tasks, Plan Mode is fine.
Feature comparison
| Feature | GitHub Copilot | Cursor |
|---|---|---|
| Skills location | .github/skills/<name>/SKILL.md | .cursor/rules/<name>.mdc |
| Skills activation | chat.useAgentSkills: true | alwaysApply: false per rule |
| MCP config key | "servers" | "mcpServers" |
| Per-project config | .vscode/mcp.json | .cursor/mcp.json |
| Agent config location | .github/agents/ | .cursor/agents/ |
| Always-on instructions | .github/copilot-instructions.md | .cursor/rules/*.mdc with alwaysApply: true |
Progressive Disclosure
LLM context windows are finite. Every token spent on output you didn’t need is a token that could have held something useful. codescout is designed around a single principle to address this: show the minimum that is actionable, and reveal detail only when asked.
The Problem
Without guardrails, code intelligence tools can produce enormous output:
| Tool | Worst case |
|---|---|
symbols(dir) | Walks the entire project, dumps every symbol in every file |
symbols(pattern) | Project-wide search with thousands of matches |
references | Popular symbols referenced in hundreds of files |
tree(recursive=true) | Full directory tree of a large monorepo |
run_command("git blame file") (no line range) | Every line in a long file |
Filling the context window with irrelevant symbols, boilerplate bodies, and off-target files makes it harder to reason about the code you actually care about. It also wastes time: you pay the cost of processing all that output before you can identify what you need.
The Solution: Two Modes
codescout tools operate in one of two modes, controlled by the
detail_level parameter.
Exploring mode (the default) produces compact summaries: names, kinds, file paths, and line numbers. Results are capped at 200 items. No function bodies, no full diffs, no deep trees. The goal is to give you a map of the territory so you can identify your target.
Focused mode (detail_level: "full") produces complete detail: function
bodies, full symbol trees, entire diffs. Results are paginated via offset and
limit (default page size: 50). Use this only after you know what you are
looking for.
How OutputGuard Enforces This
Every tool that can produce unbounded output delegates its output control to a
shared OutputGuard struct (in src/tools/output.rs). Tools do not implement
their own truncation logic; the guard enforces consistent behavior
project-wide.
OutputGuard::from_input() reads three optional fields from a tool’s JSON
input:
detail_level:"full"activates Focused mode; anything else (or absent) gives Exploring mode.offset: where to start in paginated output (default: 0).limit: page size in Focused mode (default: 50). If you pass an explicitlimitin Exploring mode, the guard honours it as a cap.
should_include_body() returns true only in Focused mode. Tools use this to
decide whether to fetch and include function bodies from the language server.
cap_items() and cap_files() enforce the limits:
- In Exploring mode: keep the first
max_results(ormax_files) items, discard the rest, attach anoverflowobject describing what was omitted. - In Focused mode: apply
offset/limitpagination, attach anoverflowobject that includesnext_offsetwhen more pages remain.
When results exceed the cap, the overflow object tells you what to do next:
{
"overflow": {
"shown": 47,
"total": 312,
"hint": "Narrow with a file path or glob pattern"
}
}
In Focused mode, the overflow includes next_offset for sequential pagination:
{
"overflow": {
"shown": 50,
"total": 312,
"hint": "Use offset/limit to page through results",
"next_offset": 50
}
}
The Pattern: Explore, Identify, Focus
The intended workflow has three steps:
- Explore broadly. Use tools in their default Exploring mode to get a compact map of the area you care about.
- Identify your target. Read the compact output to find the file, symbol, or range that contains what you need.
- Focus narrowly. Switch to Focused mode on exactly that target to get full detail.
Example
You want to understand the authentication logic in a service layer.
Step 1 — get the map:
{ "tool": "symbols", "arguments": { "path": "src/services/" } }
Response (compact, exploring mode):
{
"files": [
{
"file": "src/services/auth.rs",
"symbols": [
{ "name": "AuthService", "kind": "Struct", "start_line": 12 },
{ "name": "handle_login", "kind": "Function", "start_line": 34 },
{ "name": "verify_token", "kind": "Function", "start_line": 61 },
{ "name": "refresh_session", "kind": "Function", "start_line": 89 }
]
},
{
"file": "src/services/user.rs",
"symbols": [
{ "name": "UserService", "kind": "Struct", "start_line": 8 },
{ "name": "find_by_email", "kind": "Function", "start_line": 22 }
]
}
]
}
Four symbols in auth.rs, verify_token looks relevant. Total context
consumed: a few dozen tokens.
Step 2 — identify the target: verify_token in src/services/auth.rs.
Step 3 — focus on it:
{
"tool": "symbols",
"arguments": {
"pattern": "verify_token",
"relative_path": "src/services/auth.rs",
"include_body": true,
"detail_level": "full"
}
}
Now you get the full function body — but only for the one function you identified, not for every symbol in the file.
This workflow minimizes context usage at every step. The broad exploration is cheap; the focused read is exact.
Further Reading
- Output Modes — the
detail_level,offset, andlimitparameters in full detail, with examples for every tool - Tool Selection — matching your level of knowledge to the right tool, including the anti-patterns that cause context bloat
Output Modes
codescout tools produce different amounts of detail depending on which mode they operate in. Understanding the two modes — and when to switch between them — is the key to using the tools efficiently.
Exploring Mode (Default)
Exploring mode is the default for every tool that supports it. You do not need to pass any parameter to get it.
In Exploring mode, tools return compact summaries:
- Symbols: name, kind, file path, start and end line. No function bodies.
- Files: paths only, no content.
- Diffs: truncated at a reasonable size with a count of omitted files.
- Blame: first 200 lines, with an overflow message if the file is longer.
Results are capped at 200 items (or 200 files for directory-spanning tools).
If more results exist, the response includes an overflow object explaining
what was omitted and how to narrow the query.
Example — exploring mode response for symbols:
{
"file": "src/services/auth.rs",
"symbols": [
{ "name": "AuthService", "kind": "Struct", "start_line": 12, "end_line": 30 },
{ "name": "handle_login", "kind": "Function", "start_line": 34, "end_line": 60 },
{ "name": "verify_token", "kind": "Function", "start_line": 61, "end_line": 88 }
]
}
No bodies, no children — just the shape of the file.
Focused Mode (detail_level: "full")
Focused mode returns full detail and paginates results. Activate it by passing
detail_level: "full" to any tool that supports it.
In Focused mode, tools return:
- Symbols: full bodies, all children, complete detail.
- Files: full content of matching entries.
- Diffs: complete diff output, paginated by file if needed.
- Blame: paginated line-by-line blame for the full file.
Results are paginated via offset and limit (default page size: 50).
The first page starts at offset: 0. Subsequent pages use the next_offset
value from the overflow object.
Example — focused mode response for symbols:
{
"symbols": [
{
"name": "verify_token",
"kind": "Function",
"file": "src/services/auth.rs",
"start_line": 61,
"end_line": 88,
"body": "pub fn verify_token(token: &str, secret: &[u8]) -> Result<Claims> {\n let key = DecodingKey::from_secret(secret);\n let validation = Validation::new(Algorithm::HS256);\n let data = decode::<Claims>(token, &key, &validation)\n .map_err(|e| AuthError::InvalidToken(e.to_string()))?;\n if data.claims.exp < Utc::now().timestamp() as usize {\n return Err(AuthError::TokenExpired);\n }\n Ok(data.claims)\n}"
}
]
}
Switching Between Modes
Pass detail_level: "full" to any tool that supports it:
{
"tool": "symbols",
"arguments": {
"pattern": "verify_token",
"relative_path": "src/services/auth.rs",
"include_body": true,
"detail_level": "full"
}
}
{
"tool": "symbols",
"arguments": {
"path": "src/services/",
"detail_level": "full",
"limit": 10
}
}
## Overflow Messages
When the number of results exceeds the cap, the response includes an `overflow`
object at the top level:
```json
{
"results": [ "..." ],
"overflow": {
"shown": 47,
"total": 312,
"hint": "Narrow with a file path or glob pattern"
}
}
The hint tells you how to reduce the result set. Common hints suggest
narrowing the path, providing a more specific pattern, or adding a glob filter.
In Focused mode, the overflow object also includes next_offset when more
pages exist:
{
"results": [ "..." ],
"overflow": {
"shown": 50,
"total": 312,
"hint": "Use offset and limit to page through results",
"next_offset": 50
}
}
When next_offset is absent (or null), you are on the last page.
Paginating through results:
{ "tool": "symbols", "arguments": { "pattern": "Error", "detail_level": "full", "offset": 0, "limit": 50 } }
{ "tool": "symbols", "arguments": { "pattern": "Error", "detail_level": "full", "offset": 50, "limit": 50 } }
{ "tool": "symbols", "arguments": { "pattern": "Error", "detail_level": "full", "offset": 100, "limit": 50 } }
Tools That Support Both Modes
These tools respect detail_level, offset, and limit:
| Tool | Exploring output | Focused output |
|---|---|---|
symbols | Names, kinds, files, lines | Full symbol trees with bodies |
symbols | Names, kinds, locations | + bodies (when include_body=true) |
references | Reference locations | Paginated reference list |
tree | File paths | Paginated entries |
grep | Top matches | Paginated full matches |
semantic_search | Top matches with scores | Paginated full chunks |
Tools With Fixed Output
Some tools always cap their output at a fixed limit and do not support mode switching:
| Tool | Behaviour |
|---|---|
grep | Always returns up to max_results matches (default: 50) |
tree (with glob) | Always returns up to max_results paths (default: 100) |
symbols | Returns all symbols in a file (bounded by nature) |
For these tools, use their own limit or max_results parameter to control
output size. They do not use the detail_level / offset / limit pattern.
Practical Guidance
Use Exploring mode to find what you are looking for. Use Focused mode only once you have a specific target. Switching early costs you context; switching at the right time costs almost nothing.
If you see an overflow message in Exploring mode, prefer narrowing the query (a more specific path, glob, or pattern) over switching to Focused mode. A narrower Exploring query is usually more useful than a paginated Focused query over 300 results.
If you see next_offset in Focused mode and you need all the results, page
through sequentially. Do not try to read all pages in a single pass unless you
need the full picture; often the first one or two pages contain the answer.
Further Reading
- Progressive Disclosure — the design principle
behind the two-mode system and how
OutputGuardenforces it - Symbol Navigation Tools — the tools where
detail_levelhas the most impact
Tool Selection
Choosing the right tool for a task depends on what you already know about the code. codescout organizes its tools around three knowledge levels. Matching your level of knowledge to the right tool category avoids wasted queries and keeps context usage low.
You Know the Name
You have a file path, function name, class name, or method name. Use the structure-aware tools that navigate by name directly.
symbols(pattern) — locate a symbol by name substring across the
project or within a specific file. Fast because it goes through the language
server index.
{
"tool": "symbols",
"arguments": { "pattern": "AuthService", "relative_path": "src/services/auth.rs" }
}
symbols(path) — list all symbols in a file, directory, or
glob pattern. Use this when you have a file and want to see what is in it
before deciding which symbol to read.
{ "tool": "symbols", "arguments": { "path": "src/services/auth.rs" } }
**`references(name_path, path)`** — find every location
that references a specific symbol. Use this when you know the symbol and want
to trace all its callers or usages.
```json
{
"tool": "references",
"arguments": { "name_path": "AuthService/verify_token", "path": "src/services/auth.rs" }
}
Once you have located the symbol you want, switch to Focused mode to read its
body:
```json
{
"tool": "symbols",
"arguments": {
"pattern": "verify_token",
"relative_path": "src/services/auth.rs",
"include_body": true,
"detail_level": "full"
}
}
You Know the Concept
You have domain knowledge but not a specific name. You know that “there is error handling somewhere” or “authentication goes through a token check” but you do not know the file or function name.
Start with semantic search, then drill down.
semantic_search(query) — finds code relevant to a natural language
description using embedding similarity. It crosses file boundaries and finds
conceptually related code even when the literal words do not match.
{ "tool": "semantic_search", "arguments": { "query": "JWT token verification and expiry" } }
The response gives you scored chunks with file paths and line ranges. From those results, you have names and locations — move to the name-based tools to read the full symbols.
Typical concept-first workflow:
semantic_search("how are database errors handled")— get a list of relevant files and line ranges.symbols(found_file)— see the symbol structure around those lines.symbols(name, include_body=true, detail_level="full")— read the specific function body.
You Know Nothing
You are exploring an unfamiliar codebase or an area you have not touched before. Start with structure and orient yourself before looking at any code.
Step 1 — see the directory structure:
{ "tool": "tree", "arguments": { "path": "src" } }
This gives you the top-level layout: which directories exist, rough file
counts. Do not use recursive: true yet — the compact view of the top level
is usually enough to identify where to look next.
Step 2 — scan a promising file or directory:
{ "tool": "symbols", "arguments": { "path": "src/services/" } }
This shows all symbols across the directory in compact form. You get names, kinds, and line numbers for every symbol in every file, without loading any bodies.
Step 3 — get a high-level picture with semantic search:
{ "tool": "semantic_search", "arguments": { "query": "request lifecycle and routing" } }
Semantic search fills in context that structure cannot: which files handle which concerns, what patterns are used, where the main logic lives.
Step 4 — drill into specifics:
Once you have a target, use symbols in Focused mode to read actual code.
Common Anti-Patterns
Reading entire files with read_file instead of using symbols.
read_file without explicit line ranges dumps the full file content into
context. If you know the function name, use symbols(include_body=true)
instead — you get the function body without the surrounding boilerplate.
Using grep (grep) when semantic_search would serve better.
grep matches literal text. It works well for finding exact
strings, imports, or call sites where you know the exact text. When you want
code that implements a concept (“retry logic”, “cache invalidation”), semantic
search finds related code even when the words you think of do not appear in the
source.
Switching to Focused mode before knowing what you want.
Calling symbols with detail_level: "full" on a large directory
floods the context with every function body in every file. Use Exploring mode
to identify the target, then use Focused mode on that specific target.
Using references without a specific symbol.
This tool requires a fully-qualified symbol path (TypeName/method_name in the
file that defines it). It is not a search tool — it is a precision tool for
tracing usages of a known symbol.
Quick Reference
| You know… | Start with |
|---|---|
| File path | symbols(file) |
| Function/class name | symbols(pattern) |
| Who calls a function | references(name_path, file) |
| A concept or behaviour | semantic_search(query) |
| Nothing (unfamiliar area) | tree → symbols → semantic_search |
| Exact string or import | grep(regex) |
Further Reading
- Progressive Disclosure — how output volume is controlled once you’ve selected the right tool
- Semantic Search — deeper explanation of when and how semantic search finds code you can’t name
Shell Integration
Overview
run_command executes any shell command from the project root with stderr
capture, Output Buffer support, and a thin safety layer. It is the primary way
the AI interacts with the build system, test runner, version control, and any
other CLI tooling.
run_command("cargo build")
run_command("git log --oneline -20")
run_command("grep FAILED @cmd_a1b2c3") # query a previous buffer
run_command("diff @cmd_abc @file_def") # compose refs freely
Stderr is captured automatically alongside stdout — no 2>&1 needed. Use the
cwd parameter to run in a subdirectory (path traversal outside the project
root is rejected).
Safety Layer
Dangerous Command Detection
Commands with destructive potential are detected before execution and blocked
until the AI explicitly passes acknowledge_risk: true. The detection covers:
- Filesystem destruction:
rm -rf,rmdir,shred - Git rewrites:
reset --hard,push --force,clean -f,rebase - Database mutations:
DROP TABLE,DELETE FROM,TRUNCATE - Process termination:
kill -9,pkill
The intent is a deliberate pause, not an impenetrable wall. The AI can always
pass acknowledge_risk: true to proceed — it just has to do so explicitly
rather than accidentally.
In practice: Over 6+ months of daily use, Claude has never triggered this unprompted on a command that would actually cause damage. The main effect is an extra confirmation click for legitimate destructive operations (e.g. cleaning build artifacts). It’s still worth keeping — MCP tools run with your full user permissions, and the occasional pause costs almost nothing compared to what it could prevent.
Source File Access Blocking
cat, grep, head, tail, sed, and awk used directly on source files
(.rs, .py, .ts, .go, .java, .kt, etc.) are blocked at the tool
level. The error message suggests the appropriate codescout equivalent:
| Blocked pattern | Redirect to |
|---|---|
cat src/foo.rs | read_file("src/foo.rs") or symbols("Foo") |
grep 'fn parse' src/ | grep("fn parse", path="src/") |
head -20 main.py | read_file("main.py", start_line=1, end_line=20) |
This enforces token-efficient navigation. Reading an entire file to find one function is the antipattern codescout is designed to eliminate.
Pass acknowledge_risk: true to bypass when you genuinely need raw access
(e.g. checking file encoding, binary content, or files codescout can’t
parse).
Path Traversal Protection
The cwd parameter is validated before the command runs. Any path that
attempts to escape the project root — via ../, symlink chains, or absolute
paths outside the tree — is rejected with an error naming the violation.
Output Buffer refs (@cmd_id, @file_id) are resolved within the session
and are read-only when materialised as temporary files for Unix tool access.
They never expose raw filesystem paths outside the buffer system.
Further Reading
- Output Buffers — how large command output is stored and
queried with
@cmd_idrefs rather than dumped into context - Workflow & Config Tools — full
run_commandreference including all parameters
Output Buffers
The Problem
MCP tools return their results directly into the AI’s context window. For large
command output — a full cargo test run, a broad grep, a 2000-line file — that
means the entire output lands in context whether the AI needs all of it or not.
The result is a bloated context, wasted tokens, and an AI that has to skim walls
of text to find what it actually needs.
How It Works
When run_command or read_file produces output above a size threshold,
codescout stores the full content in an in-memory buffer and returns a compact
summary + an @id handle instead:
run_command("cargo test")
→ {
"summary": "47 passed, 2 failed — FAILED: test_parse, test_render",
"output_id": "@cmd_a1b2c3",
"exit_code": 1
}
The full output is held in memory, keyed by the @id. The AI can then query it
with targeted follow-up run_command calls using standard Unix tools:
run_command("grep FAILED @cmd_a1b2c3")
run_command("sed -n '42,80p' @cmd_a1b2c3")
run_command("grep -A5 'thread.*panicked' @cmd_a1b2c3")
File reads work the same way — large files become @file_id references:
read_file("src/main.rs")
→ { "summary": "...", "file_id": "@file_abc456" }
run_command("grep 'fn.*async' @file_abc456")
Refs compose freely. You can diff two buffers, pipe one through awk, or pass
a @file_id to grep alongside a pattern from a @cmd_id:
run_command("diff @cmd_a1b2c3 @cmd_d4e5f6")
run_command("grep -F -f @file_abc456 @cmd_a1b2c3")
Why It Matters
Short output is always returned inline. Only responses above the threshold
get buffered. The AI never has to think about whether to use @refs — the
tool handles the routing automatically.
Each buffer query shows up as a distinct tool call in Claude Code’s UI.
Instead of one undifferentiated wall of text, the user sees the AI making
targeted, reviewable queries — grep FAILED, then sed -n '42,80p', then
grep -A5 'panicked'. The exploration is transparent and auditable.
When a buffer query still returns too much, you get 100 lines inline.
If grep @ref or jq @tool_ref produces more than 100 lines, codescout
returns the first 100 lines inline with truncation metadata rather than
creating another @ref handle (which would cause an infinite loop).
The response includes truncated: true, stdout_shown/stdout_total
(and stderr_shown/stderr_total when stderr is non-empty) so the AI
can decide whether to refine further. Stderr is prioritised —
up to 20 stderr lines are shown, with the remaining budget going to stdout.
The context window stays lean. The AI holds a reference to large output without paying the token cost of the full content. It pays only for what it actually reads.
Buffers survive across multiple turns. A @cmd_id from a cargo test run
can be queried again later in the same session — no need to re-run the command
to look at a different part of the output.
Buffer Lifecycle
Buffers are held in memory for the lifetime of the MCP server process. They use an LRU eviction policy: when the buffer store fills up (default: 50 entries), the least-recently-accessed entry is dropped. Accessing a buffer (even to query it) refreshes its position in the eviction order.
Buffers are not persisted to disk. Restarting the server clears them.
Further Reading
- Shell Integration —
run_commandin full detail: safety layer, dangerous command detection, and source file access blocking - Workflow & Config Tools — full reference
for
run_commandincluding thecwd,acknowledge_risk, andtimeout_secsparameters
Elicitation-Driven Interactive Sessions
run_command now supports an interactive: true parameter that spawns the
process with piped stdin/stdout/stderr and drives it via MCP elicitation in a
loop. Instead of a session_send/session_cancel multi-tool protocol, the
entire session fits in one tool call.
Usage
{
"command": "python3 -c \"name=input('Your name? '); print(f'Hello, {name}!')\"",
"interactive": true
}
Each loop iteration:
- Reads available output (with a 150 ms settle window to batch bursts).
- Shows accumulated output in an elicitation dialog.
- Waits for the user to type input (or leave empty to cancel).
- Sends the input to the process stdin.
- Repeats until the process exits naturally or the user cancels.
Parameters
| Parameter | Type | Notes |
|---|---|---|
command | string | Shell command to run (required). |
interactive | boolean | Set true to enable interactive mode. |
cwd | string | Subdirectory relative to project root. |
timeout_secs | integer | Ignored in interactive mode (reserved for future use). |
Cancellation
Leave the input field empty in the elicitation dialog to cancel. The process is
killed with SIGKILL and accumulated output is returned.
A safety cap of 50 elicitation rounds is enforced. If reached, the process is
killed and a [interactive: max rounds reached, process killed] note is
appended to the output.
Return value
{
"exit_code": 0,
"stdout": "<accumulated stdout + stderr>",
"interactive_rounds": 3
}
When the process is killed (user cancel, max rounds, or write error):
{
"exit_code": -1,
"stdout": "<accumulated output>\n[interactive: cancelled by user]",
"interactive_rounds": 2,
"note": "process killed or loop exited before natural termination"
}
Limitations
- Latency: each round-trip through MCP elicitation adds ~1–3 s. Suitable for setup wizards and slow-paced REPLs; not suitable for ncurses TUIs, editors, or programs expecting sub-second responses.
- Settle heuristic: the 150 ms silence window may split a logical prompt across two rounds if the program emits output in bursts with longer pauses.
- Dangerous commands:
interactive: trueblocks dangerous commands outright (no elicitation confirmation). Use the standard non-interactive path withacknowledge_risk: trueif needed. - No elicitation fallback: if the MCP client does not support elicitation,
a
RecoverableErroris returned immediately. There is no non-interactive fallback — useinteractive: false(the default) in that case. - No test coverage: integration testing requires a live MCP peer with elicitation support.
Semantic Search
Semantic search finds code by meaning rather than by name or text pattern. It answers queries like “authentication middleware”, “retry with exponential backoff”, or “parse JSON from HTTP response” — without knowing what the relevant functions are called.
It complements symbol tools: use symbol tools when you know the name, semantic search when you know the concept.
How It Works
Three steps happen when you call semantic_search:
1. Chunking — The first time index(action: build) runs, every source file is split
into chunks whose size is derived from the configured model’s context window
(roughly max_tokens × 3 chars/token at 85 % utilisation). Splits follow
language structure: each top-level function, method, or class becomes its own
chunk. When a container (an impl block, a class) exceeds the budget, it is
recursively split into one chunk per inner method, plus a header chunk for the
container signature. The plain-text fallback path handles languages without
tree-sitter support. Each chunk records its 1-indexed start and end line so
results link back to exact source locations.
2. Embedding — Each chunk is converted to a vector (a list of floating-point
numbers) by the configured embedding model. Semantically similar text produces
vectors that point in similar directions in high-dimensional space.
The model is selected by onboarding based on your hardware (Ollama availability, GPU, RAM);
ollama:nomic-embed-text is the default when Ollama is running, and
local:JinaEmbeddingsV2BaseCode is used on CPU-only machines. See
Embedding Backends to change it manually.
The vectors are stored in .codescout/embeddings.db.
3. Search — Your query is embedded with the same model and compared to every stored chunk using cosine similarity. The closest chunks are returned, ranked by score.
The index is incremental. On subsequent index(action: build) calls, only files that
changed since the last run are re-embedded — detected via git diff, then file
mtime, then SHA-256 as a fallback chain.
Similarity Scores
Results include a score between 0 and 1:
| Score | Meaning |
|---|---|
| > 0.85 | Almost certainly what you’re looking for |
| 0.70 – 0.85 | Likely relevant — worth inspecting |
| 0.50 – 0.70 | Tangentially related |
| < 0.50 | Probably noise |
Code embeddings score lower than prose embeddings for the same conceptual similarity — a score of 0.75 in a code search is strong. Do not compare scores across different embedding models; they are not on the same scale.
When to Use Semantic Search
| You know… | Use |
|---|---|
| The exact name | symbols(pattern) |
| The file it’s in | symbols(path) |
| A text fragment | grep(regex) |
| The concept, not the name | semantic_search(query) |
| The concept, inside a library | semantic_search(query, scope: "lib:<name>") |
Semantic search is slowest of these options (it embeds your query at call time and scans all stored vectors). Prefer symbol tools when you know the name.
Index Lifecycle
Build the index once before first use:
{ "tool": "index(action: build)", "arguments": {} }
Check its health:
{ "tool": "workspace(action: status)", "arguments": {} }
The index is stored in .codescout/embeddings.db and excluded from version
control by default. Each team member builds their own local copy.
Drift detection: workspace(action: status) can report per-file drift scores — a measure
of how much file content has changed since it was last indexed. Pass threshold
to surface files with high drift:
{ "tool": "workspace(action: status)", "arguments": { "threshold": 0.3 } }
Switching embedding models invalidates the entire index — all chunks must be re-embedded. See Embedding Backends for model selection guidance.
Further Reading
- Semantic Search Setup Guide — step-by-step: choose a backend, configure, build the index, write effective queries
- Embedding Backends — all supported backends and model selection guidance
- Semantic Search Tools — full reference for
semantic_search,index(action: build), andworkspace(action: status)
The Retrieval Stack
As of v0.12 codescout’s default retrieval substrate is a network-attached stack (Qdrant + three embedding services), not the in-process local-embed path. The
local-embedCargo feature still exists for air-gapped use, but it is no longer the default and is no longer the path the team benchmarks against. If you upgrade from <0.12 and want to keep working, you must either bring up the stack or rebuild with--features local-embedand accept the older sqlite-vec code path. See Migration from local-embed below.
What runs where
| Service | Default port | Image / binary | Role |
|---|---|---|---|
| Qdrant | 6334 (gRPC), 6333 (HTTP) | qdrant/qdrant:v1.17.0 | Vector storage. Two collections: code_chunks, memories. |
| Dense embedder | 48081 (HTTP) | llama.cpp:server running CodeRankEmbed-Q4_K_M.gguf (default) | Text → 768-dim dense vector. Speaks TEI protocol; switchable to OpenAI protocol for Ollama / OpenAI / Anthropic-compatible endpoints. |
| Sparse SPLADE | 48084 (HTTP) | text-embeddings-inference running prithivida/Splade_PP_en_v1 | Text → sparse vector for lexical complement. |
| Reranker | 48083 (HTTP) | text-embeddings-inference running BAAI/bge-reranker-base (CPU) or bge-reranker-v2-m3 (GPU) | Cross-encoder pairwise re-rank of fused candidates. |
codescout connects to these services on 127.0.0.1. There is no per-project
substrate — the stack is shared across all projects on a machine.
Bring up the stack
# CPU profile (default — works on any Linux/macOS machine, ~3 GB RAM idle):
docker compose --profile cpu up -d
# GPU profile (CUDA — uses NVIDIA runtime, ~2.5 GB VRAM idle):
docker compose --profile gpu up -d
The dense embedder needs a GGUF model file. First-run setup:
mkdir -p models
cd models
huggingface-cli download nomic-ai/CodeRankEmbed-GGUF \
CodeRankEmbed-Q4_K_M.gguf --local-dir .
# Or: wget https://huggingface.co/nomic-ai/CodeRankEmbed-GGUF/resolve/main/CodeRankEmbed-Q4_K_M.gguf
If your models/ directory is somewhere else, set CODESCOUT_MODEL_DIR before
docker compose up.
Verify everything is healthy:
docker compose ps # all services "healthy"
curl -fsS http://127.0.0.1:48081/health # dense
curl -fsS http://127.0.0.1:48083/health # reranker
curl -fsS http://127.0.0.1:48084/health # sparse
curl -fsS http://127.0.0.1:6333/healthz # qdrant
AMD ROCm profile (docker compose --profile amd)
The amd profile in docker-compose.yml runs every leg of the retrieval
stack on the GPU: dense embedder, cross-encoder reranker, and sparse SPLADE
all share the AMD device. Qdrant runs alongside on CPU as usual. This is the
recommended path on any workstation with an AMD GPU and ROCm 7.x installed
on the host.
Bring up:
docker compose --profile amd up -d
Topology when using the amd profile:
| Service | Port | Image | Notes |
|---|---|---|---|
| Qdrant | 6333 HTTP / 6334 gRPC | qdrant/qdrant:v1.17.0 | Shared across all profiles. |
Dense (dense-amd) | 48081 | rocm/llama.cpp:llama.cpp-b6652.amd0_rocm7.0.0_ubuntu24.04_server | llama-server --embedding --pooling mean, CodeRankEmbed-Q4_K_M.gguf. |
Reranker (reranker-amd) | 48083 | same image | llama-server --reranking --pooling rank, bge-reranker-v2-m3-Q4_K_M.gguf. |
Sparse (sparse-amd) | 48084 | codescout/sparse-amd:tei-1588129f93 (built locally) | TEI-on-ROCm running SPLADE-PP_en_v1. See SPLADE on ROCm. |
Required model files in ${CODESCOUT_MODEL_DIR:-./models}:
huggingface-cli download nomic-ai/CodeRankEmbed-GGUF \
CodeRankEmbed-Q4_K_M.gguf --local-dir ./models # ~90 MB
huggingface-cli download gpustack/bge-reranker-v2-m3-GGUF \
bge-reranker-v2-m3-Q4_K_M.gguf --local-dir ./models # ~419 MB
The SPLADE model is pulled by the sparse-amd container at first launch
into the huggingface-cache volume; no manual download needed.
Host requirements:
- AMD GPU (RX 7xxx / MI series), gfx1100+ recommended
- ROCm 7.x installed on host (kernel driver +
/dev/kfd,/dev/dri) - User in
videoandrendergroups
The compose service declares devices: [/dev/kfd, /dev/dri] and
group_add: ["44", "992"] (numeric video/render GIDs — the rocm/pytorch
sparse image lacks a render group entry, so group names don’t resolve).
No NVIDIA-style runtime extension needed; AMD exposes the GPU via standard
Linux character devices.
Wire codescout: copy .env.amd (in the repo root) to .env. It sets the
ports above plus the protocol selectors required to talk to llama-server’s
/v1/embeddings and /v1/rerank:
CODESCOUT_EMBEDDER_PROTOCOL=llama-server # /v1/embeddings, not TEI's /embed
CODESCOUT_RERANKER_PROTOCOL=llama-server # Cohere-shape /rerank
Why dense + reranker use llama.cpp instead of TEI:
- TEI’s ROCm path is fragile and lags upstream;
rocm/llama.cppis AMD-built. - Same binary serves the dense embedder and the cross-encoder reranker
(
--rerankingmode), so one image covers two services.
Why sparse uses TEI: SPLADE is an MLM-style model with no llama.cpp implementation, and CPU latency saturates 32 cores on a full reindex of a 21k-chunk project. Building TEI from source against ROCm 7.1 + PyTorch 2.8 (see SPLADE on ROCm) puts SPLADE on the GPU and drops a full reindex from “minutes of CPU melting” to ~6 m 36 s at 121 % CPU.
How codescout finds the stack
codescout reads endpoints from environment variables and falls back to the defaults above:
| Env | Default | Effect |
|---|---|---|
CODESCOUT_QDRANT_URL | http://127.0.0.1:6334 | Qdrant gRPC URL |
CODESCOUT_EMBEDDER_URL | http://127.0.0.1:48081 | Dense embedder base URL |
CODESCOUT_RERANKER_URL | http://127.0.0.1:48083 | Reranker base URL |
CODESCOUT_SPARSE_URL | http://127.0.0.1:48084 | Sparse SPLADE base URL |
CODESCOUT_EMBEDDER_PROTOCOL | tei | tei (TEI/llama-server native) or openai/llama-server (Ollama, OpenAI, Anthropic-compatible) |
CODESCOUT_EMBEDDER_MODEL_NAME | (empty) | Model id sent in OpenAI-protocol JSON payloads |
CODESCOUT_QUERY_PREFIX | (empty) | Prepended to query text only. Required by some asymmetric models (e.g. Nomic, BGE-large). |
CODESCOUT_RERANKER_PROTOCOL | tei | tei (HuggingFace TEI) or llama-server/infinity/cohere (Cohere-shape /rerank, used by llama-server --reranking) |
CODESCOUT_RERANKER_MODEL | (unset) | Override the reranker model id (Infinity-protocol only) |
Using Ollama / llama.cpp / OpenAI as the dense embedder
The shipped stack uses llama.cpp:server for the dense leg, but the dense
service is just an HTTP endpoint behind CODESCOUT_EMBEDDER_URL. Any
TEI-compatible or OpenAI-compatible server will work.
Ollama
Ollama exposes an OpenAI-compatible embeddings endpoint at
http://localhost:11434/v1. Pull a model and point codescout at it:
ollama pull nomic-embed-text # or any model with /api/embeddings
export CODESCOUT_EMBEDDER_URL=http://127.0.0.1:11434
export CODESCOUT_EMBEDDER_PROTOCOL=openai
export CODESCOUT_EMBEDDER_MODEL_NAME=nomic-embed-text
# Optional — Nomic needs a query prefix for asymmetric search:
export CODESCOUT_QUERY_PREFIX="search_query: "
You still need Qdrant + the reranker + the sparse service running from the
docker-compose stack — Ollama only replaces the dense leg. Stop the compose
dense-cpu or dense-gpu container so the port is free:
docker compose --profile cpu stop dense-cpu
llama.cpp (standalone)
If you already run llama-server outside docker, the same approach applies:
llama-server -m ~/models/CodeRankEmbed-Q4_K_M.gguf \
--port 48081 --embedding --pooling mean --ctx-size 8192
…then leave CODESCOUT_EMBEDDER_URL and CODESCOUT_EMBEDDER_PROTOCOL at
their defaults. The compose dense-* service is just a packaged version of
this command — see docker-compose.yml for the full flag list.
OpenAI / Anthropic-compatible APIs
export CODESCOUT_EMBEDDER_URL=https://api.openai.com/v1
export CODESCOUT_EMBEDDER_PROTOCOL=openai
export CODESCOUT_EMBEDDER_MODEL_NAME=text-embedding-3-small
# (codescout reads OPENAI_API_KEY from the environment automatically)
Cost: a full index of a ~10k-file Rust project is roughly 8 M tokens at ~768-dim. Budget accordingly.
How we chose the components — benchmark summary
A 75-query retrieval benchmark was run across ~15 candidate stacks on a
pinned worktree of this repo. The full history lives in
docs/trackers/retrieval-benchmark.md.
Headline results below — all measured on the same query set at
bm25_boost=5.0, mode=code, with cross-encoder rerank enabled unless
noted.
Dense embedder
| Model | Quantization | Query prefix | Score (out of 75) | Notes |
|---|---|---|---|---|
| CodeRankEmbed | Q4_K_M (90 MB) | none | 37 | Champion. Best on env-var / identifier-bag queries. Q4 loses asymmetric subspace if a prefix is forced. |
| CodeRankEmbed | f16 (~550 MB) | required | 34 | f16 with prefix peaked one point below Q4 no-prefix. |
| jina-embeddings-v2-base-code | (native) | none | 36 | Strong general-code model; +2 vs jina without sparse fusion. |
| Nomic Embed Code 7B | Q4 | required | 24 | “Claimed CoIR SOTA” failed on real-world queries — bigger is not better. |
| Tavily-stack baseline (CodeRank, no rerank, sqlite-vec + tantivy) | Q4_K_M | none | 28 | Reference point for the legacy substrate we replaced. |
Why Q4 over f16: Q4_K_M scores higher than f16 in our query set when no prefix is set, and runs in ≤1 GB RAM. The f16 advantage only appears when the model’s asymmetric query prefix is enabled, and even then it caps one point below Q4 no-prefix. We default to Q4 no-prefix.
Sparse leg
We initially shipped a local Tantivy BM25 leg. It scored similarly to
SPLADE on lexical queries but was a maintenance burden (tantivy compile
time, on-disk index drift, separate rebuild step) and could not run as a
service. We migrated to SPLADE-PP_en_v1 via TEI — same conceptual role,
runs as a container, no per-project index. The benchmark showed sparse
fusion gives +2 points over dense-only at bm25_boost=5.0.
Reranker
| Model | Protocol | T5 (real-usage tier, /15) | Full /75 | Latency (p95) |
|---|---|---|---|---|
| bge-reranker-v2-m3 | TEI | 10 | 37 | ~80 ms (GPU) |
| bge-reranker-base | TEI | 9 | 35 | ~250 ms (CPU) |
| jina-rerank-v2 | Infinity | 11 | 38 (jina-v2 dense), 36 (CodeRank Q4 dense) | ~120 ms |
bge-v2-m3 wins on the full suite and is the default. jina-rerank-v2 lifts
the T5 (real-usage) tier by +1 every time but loses on long natural-language
queries. The protocol toggle (CODESCOUT_RERANKER_PROTOCOL=infinity) lets
you swap with a single env var — no rebuild needed.
Stack-wide latency (champion config)
| Stage | CPU profile | GPU profile |
|---|---|---|
| Dense embed (single query) | ~30 ms | ~5 ms |
| Sparse embed (single query) | ~80 ms | ~30 ms |
| Qdrant hybrid search (RRF) | ~10 ms | ~10 ms |
| Cross-encoder rerank (top-20) | ~250 ms | ~80 ms |
End-to-end semantic_search | ~370 ms | ~125 ms |
Indexing throughput on the codescout repo itself (~3.5 k chunks):
| Profile | Wall time | Throughput |
|---|---|---|
| CPU | ~45 s | ~80 chunks/s |
| GPU | ~12 s | ~290 chunks/s |
Migration from local-embed
If you have a .codescout/embeddings/project.db from a pre-v0.12 install:
# 1. Stand up the stack (see above)
# 2. Re-embed legacy memories into Qdrant:
codescout migrate-memories --dry-run # preview
codescout migrate-memories # execute
# 3. Re-index your project:
codescout index
The legacy sqlite-vec file is no longer read after migration. You can delete
it once you’ve verified memory recall works against the new substrate.
If you cannot run the stack (air-gapped, embedded environment), build with
local-embed:
cargo install codescout --no-default-features --features local-embed,http,librarian
This restores the in-process ONNX + fastembed path. Note: the network
retrieval pipeline (sparse fusion, cross-encoder rerank) is not available in
this mode — semantic_search falls back to pure dense vector scoring.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
semantic_search returns “stack unreachable” | dense/sparse/rerank/qdrant container not running | docker compose ps then start the missing profile |
| Empty results despite indexed data | wrong project_id namespace | workspace status to confirm the active project_id; codescout index --force to rebuild |
| Slow first query (10+ s) | model warmup on cold container | normal — subsequent queries hit the loaded model |
migrate-memories reports “db not found” | legacy file at unexpected path | pass --db-path /path/to/embeddings.db explicitly |
Semantic Search Guide
Semantic search lets you find code by describing what it does rather than knowing what it is called. This page walks you through the full setup from choosing a backend to writing effective queries. For a reference of the individual tools, see Semantic Search Tools.
For an explanation of how semantic search works under the hood — chunking, scoring, and when to use it vs symbol tools — see Semantic Search Concepts.
Choosing an Embedding Backend
codescout supports four embedding backends. The model string prefix in
project.toml selects which one is used:
| Prefix | Example | When to use |
|---|---|---|
ollama: | ollama:mxbai-embed-large | Local development — free, private, no API key |
openai: | openai:text-embedding-3-small | Best retrieval quality, cloud cost |
custom: | custom:my-model@http://host:8080 | Any OpenAI-compatible endpoint |
local: | local:AllMiniLML6V2Q | Offline / air-gapped, no daemon required |
Recommended starting point: The bundled local:AllMiniLML6V2Q model — no setup
required, works offline, and downloads only ~22 MB on first use. For higher search
quality or multi-project setups, see Embedding Backends.
Setting Up Ollama
Install Ollama, pull the default model, and verify it responds correctly before
touching project.toml.
# Install Ollama (Linux/macOS)
curl -fsSL https://ollama.com/install.sh | sh
# Pull the default model
ollama pull mxbai-embed-large
# Verify the embedding endpoint is responding
curl http://localhost:11434/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"model": "mxbai-embed-large", "input": "test"}'
A successful response looks like:
{
"object": "list",
"data": [{ "object": "embedding", "index": 0, "embedding": [0.012, -0.034, ...] }],
"model": "mxbai-embed-large"
}
If curl returns a connection error, Ollama is not running. Start it with
ollama serve in a separate terminal and retry.
If you run Ollama on a non-default host or port, set the OLLAMA_HOST
environment variable before starting Claude Code:
export OLLAMA_HOST=http://192.168.1.10:11434
Configuring codescout
The [embeddings] section of .codescout/project.toml controls which
model is used and how files are chunked. The defaults work well for most
projects:
[embeddings]
model = "local:AllMiniLML6V2Q"
model is the only setting you need to change. Chunk size is derived
automatically from the model’s context window — no manual tuning required.
To use OpenAI instead, set the model and export your API key:
[embeddings]
model = "openai:text-embedding-3-small"
export OPENAI_API_KEY=sk-...
Building the Index
Once project.toml is configured (or using the default), build the index:
{ "name": "index", "arguments": { "action": "build" } }
What happens internally:
- codescout walks the project tree, skipping directories listed in
ignored_paths(by default:.git,node_modules,target,__pycache__,.venv,dist,build,.codescout). - Each source file is split into chunks using an AST-aware chunker. Each top-level function, method, or class becomes its own chunk. Oversized containers (impl blocks, classes) are recursively split into one chunk per inner method plus a header chunk for the container signature. Chunk size is derived from the model’s context window — no configuration needed.
- Each chunk is sent to the configured embedding backend, which returns a dense vector.
- The vectors and chunk metadata are stored in
.codescout/embeddings.db(SQLite).
How long it takes: With Ollama on a modern laptop, expect roughly 80–120 files per minute. OpenAI’s API is faster in wall-clock time because requests are batched and network latency is low — typically 3–5x faster for large projects. A 10,000-line project usually indexes in under two minutes with either backend.
Incremental updates: Running index(action: build) again after editing a few
files is cheap. codescout hashes each file’s content and only re-embeds
files whose hash has changed since the last run. Unchanged files are skipped
at negligible cost.
Force reindex: Use force: true when you change the model in
project.toml. Vectors from different models are not comparable, so the entire
index must be rebuilt:
{ "name": "index", "arguments": { "action": "build", "force": true } }
You can check index health at any time:
{ "name": "workspace", "arguments": { "action": "status" } }
The output shows config.embeddings.model (from project.toml) and
the index.model (what was used to build the current index). If they
differ, a force reindex is needed.
Searching Effectively
Natural Language Queries
Describe what the code does in plain language. You do not need to know the function name or file location:
{ "name": "semantic_search", "arguments": { "query": "how errors are handled" } }
{ "name": "semantic_search", "arguments": { "query": "database connection setup" } }
{ "name": "semantic_search", "arguments": { "query": "authentication token validation" } }
Concrete, specific queries outperform vague ones. Prefer “retry logic with exponential backoff” over “error handling”. Prefer “connection pool initialization” over “database”.
Code Snippet Queries
Paste a function signature or a short snippet as the query to find similar code elsewhere in the project. This is useful for spotting duplication or locating the canonical version of a pattern:
{
"name": "semantic_search",
"arguments": {
"query": "fn connect(host: &str, port: u16) -> Result<Connection>"
}
}
Interpreting Scores
Each result includes a score between 0 and 1 (cosine similarity):
| Score range | Interpretation |
|---|---|
| > 0.85 | Strong match — the chunk directly addresses your query |
| 0.6 – 0.85 | Related — the concept is present but may not be the primary focus |
| < 0.6 | Tangential — treat as background context at best |
The top result is not always the most useful one. Scan the top five results before drilling into any single chunk.
Recommended Workflow
Semantic search is the entry point for concept-first exploration. After finding relevant chunks, use the symbol tools to navigate the surrounding code:
semantic_search— find the files and line ranges where a concept lives.symbolson those files — see the surrounding structure.symbolswithinclude_body: true— read the exact implementation.references— trace callers if needed.
Tuning
Chunk Size
Chunk size is not configurable — it is derived automatically from the model’s published context window using the formula:
chunk_size = max_tokens × 0.85 × 3 chars/token
The 0.85 factor leaves headroom for tokenisation variance; 3 chars/token is a conservative lower bound for mixed code and prose. Representative values:
| Model | Context | Chunk budget |
|---|---|---|
ollama:mxbai-embed-large | 512 tokens | ~1 300 chars |
ollama:nomic-embed-text | 8 192 tokens | ~20 900 chars |
openai:text-embedding-3-small | 8 191 tokens | ~20 900 chars |
local:JinaEmbeddingsV2BaseCode | 8 192 tokens | ~20 900 chars |
local:AllMiniLML6V2Q | 512 tokens | ~1 300 chars |
local:AllMiniLML6V2Q | 256 tokens | ~650 chars |
Because AST chunking splits at function/method boundaries rather than at character counts, most chunks are well within the budget regardless of model. The budget mainly controls when a single oversized node is recursively split into inner methods.
Model Choice
The embedding model has the largest effect on search quality. General-purpose
text models (nomic-embed-text, text-embedding-3-small) work well for
documentation and comments. Code-specific models
(local:JinaEmbeddingsV2BaseCode) tend to perform
better on function signatures and code identifiers.
After changing the model, always run index(action: build) with force: true.
Troubleshooting
“No results” or empty results list
The index may not be built yet. Run workspace(action: status) to check. If
index.indexed is false, run index(action: build). If the index exists but results are
empty, the query may be too generic — try a more specific description.
“Connection refused” when indexing
An external embedding server (Ollama, llama.cpp, etc.) is not running. Start
it, or switch to the bundled model by setting model = "local:AllMiniLML6V2Q"
in .codescout/project.toml.
“Model not found” error
The model has not been pulled. For Ollama, run ollama pull <model-name> (or
whatever model is configured) and retry.
Stale results after editing many files
Run index(action: build) without extra arguments. The incremental update will re-embed
only the files that changed.
Results seem wrong after changing the model
The index was built with a different model and the vectors are no longer
compatible. Run index(action: build) with force: true. You can confirm the
mismatch by checking workspace(action: status): if the config model and the index model
differ, a force reindex is required.
Indexing is very slow
If using an external server (Ollama, llama.cpp), check it is running locally
and not routing over a slow network connection. The bundled local:AllMiniLML6V2Q
model runs in-process and avoids network overhead. For the fastest throughput on
large projects, openai:text-embedding-3-small batches requests via API.
Asymmetric Query Prefix for Embedding Models
Some embedding models are trained asymmetrically: documents and queries use different input conventions. The canonical example is CodeRankEmbed, which expects every query to be prefixed with:
Represent this query for searching relevant code:
Without the prefix, query vectors land in a different part of the embedding
space than the indexed document vectors, and semantic_search recall drops
sharply (typical effect: 30–50% worse top-5 recall).
What this does
codescout now distinguishes document embedding from query embedding at
the Embedder trait level. RemoteEmbedder automatically prepends the
model-specific query prefix when semantic_search runs, while index-time
embedding (through embed) stays unprefixed.
The prefix is selected from the model name:
| Model name contains | Query prefix |
|---|---|
coderank (any case) | Represent this query for searching relevant code: |
| any other model | none (symmetric) |
Detection is purely name-based — no external config and no registry lookup.
Configuration
No user-facing configuration. Set your model via project.toml as usual:
[embeddings]
model = "ollama:coderank-embed"
# or any remote endpoint serving a CodeRankEmbed variant
url = "http://127.0.0.1:43300/v1/embeddings"
codescout detects the coderank substring in the model name and applies the
prefix to every query automatically. Re-index after switching models, since
document vectors are model-specific.
Trait surface
For library consumers, two points on the Embedder trait:
embed(&[&str])— unchanged, document-side batch embedding.embed_query(&str)— new, single-query embedding with prefix applied. Default impl delegates toembedwith no prefix;RemoteEmbedderoverrides to apply the model-specific prefix.
The free function embed_one(embedder, text) now routes through
embed_query, so all code paths that embed a single query string benefit
from the prefix automatically.
Adding a new asymmetric model
Extend RemoteEmbedder::query_prefix_for in src/embed/remote.rs:
#![allow(unused)]
fn main() {
fn query_prefix_for(model: &str) -> Option<String> {
let l = model.to_lowercase();
if l.contains("coderank") {
Some("Represent this query for searching relevant code: ".into())
} else if l.contains("your-model") {
Some("Your model's query prefix: ".into())
} else {
None
}
}
}
No other call site needs to change — the trait default + override pattern keeps the per-model knowledge localized.
Limitations
- Local (fastembed) models do not currently apply prefixes. Add an override on
LocalEmbedder::embed_queryif you run an asymmetric model locally. - The match is a simple substring check; if a non-asymmetric model happens to
contain
coderankin its name you’ll get an incorrect prefix. Rename the model or extend the match if this bites you.
Why this matters
Embedding models are often evaluated on symmetric tasks (document-to-document similarity). Asymmetric models can score higher on code search benchmarks but silently underperform when a pipeline treats query and document embedding as the same operation. Making the distinction explicit at the trait level means the right thing happens automatically once the model is selected.
Metadata-Enriched Chunks
Every code chunk stored in the semantic index now carries a short searchable header prepended to its embedding input. Headers encode file path, container context, and symbol name:
src/embed/index.rs :: impl IndexStore :: fn build_index(force: bool)
This information was previously invisible to the embedding model — chunks were embedded as raw code bodies with no location context. Multi-concept keyword queries (the dominant query shape in real usage) now match on file path, container, and symbol name in addition to body content, giving them more surface area to match on.
What changes
- Chunks have a new
metadatacolumn populated during indexing. - Embedding input is
metadata + "\n" + contentwhen metadata is present. - Search results are unchanged — users still see raw code content. The header is an embedding-only signal.
- Unknown languages and markdown files have
metadata = NULLand embed only the body (no behavior change there).
When it helps
- Queries that mention a file path or module name (
"embed index build") - Queries that mention a struct/class name alongside a concept (
"IndexStore force rebuild") - 3–10 word keyword queries — the dominant shape in production traffic
When it won’t help
- Queries that don’t map to any code structure (cross-file architectural questions)
- Bare symbol lookups — use
symbolsinstead, it’s exact
Schema migration
On first index after upgrading, the existing chunks and chunk_embeddings tables are dropped and rebuilt. Expect one reindex delay; thereafter indexing is incremental as usual.
Index Scope Guard
Before index(action: build) commits to walking and embedding a directory, codescout
checks whether the scope looks broad enough to be accidental, and requires
explicit human confirmation via an MCP elicitation dialog before proceeding.
Triggers
Confirmation is required if either:
- The project root is a known-broad directory, such as:
- Your home directory (
~) - The parent of home (e.g.
/home) - A system root:
/,/usr,/etc,/var,/tmp,/root,/opt,/proc,/sys
- Your home directory (
- The approximate raw source size exceeds the threshold (default 500 MB of
eligible content, respecting
.gitignoreand hidden-file rules — same filterindex(action: build)itself uses).
When either trigger fires, the MCP client shows a dialog like:
⚠ Broad index scope detected
Root: /home/alice (home directory)
Eligible files: ~3,200
Approx source content: ~2.4 GB
Estimated chunks: ~600,000
This will use significant RAM and CPU time.
Confirm indexing this directory?
You can accept to proceed or decline to abort. The check runs on every call — it is not persisted. If your MCP client does not support elicitation, the call is refused with a clear error rather than silently proceeding.
Configuration
Adjust the size threshold in .codescout/project.toml:
[security]
max_index_bytes = 1073741824 # 1 GB
The default is 524288000 (500 MB). Set it higher to allow larger projects
without a prompt; lower to trigger the guard more aggressively.
Currently, the suspicious-path list is fixed — it is not configurable.
Rationale
An agent that calls workspace(action: activate, path: "~") followed by index(action: build) would
otherwise walk the entire home directory, ingest every file, and cause severe
RAM spikes or OOM (see docs/issues/memory-leak-x-session-freeze.md). The
scope guard makes that path impossible without a human in the loop.
Auto-Reindex on Edit
Semantic search results stay current as files are edited, without requiring an explicit index(action='build') call.
How it works
When a write tool (edit_file, edit_code, create_file) modifies a file, codescout checks whether the file’s hash has changed since it was last indexed. If it has, the file is added to an in-memory dirty set.
The next call to semantic_search drains the dirty set and re-embeds all changed files before running the KNN query. The write tool still returns immediately — re-embedding is deferred until search time.
edit_code("src/foo.rs", ...) → dirty_set: {"src/foo.rs"}
semantic_search("find all traits") → reindex src/foo.rs → knn on fresh index
Properties
- Zero write latency — dirty set insertion is synchronous and sub-millisecond (one SHA-256 hash check).
- Idempotent — multiple writes to the same file before a search collapse to a single re-embed.
- Non-blocking on failure — if re-embedding fails (e.g. embedder unavailable), a warning is logged and the search continues with stale data.
- Scope — only files written through codescout tools. External editor edits are not tracked (filesystem watcher is a separate future feature).
Hybrid Dense + Sparse Retrieval
semantic_search uses a hybrid retrieval pipeline combining dense vector
search with sparse SPLADE keyword search, fused via Reciprocal Rank Fusion
(RRF) inside Qdrant.
History: Prior to v0.12 the sparse leg was a local Tantivy BM25 index. Since v0.12 it is a SPLADE service in the retrieval stack — same conceptual shape (lexical complement to dense), different implementation. The Tantivy code and
bm25.rsmodule have been deleted.
How it works
- Dense leg — query embedded by the dense embedder service (default
localhost:48081, TEI or OpenAI protocol) and matched against thecode_chunkscollection’s dense vector field. - Sparse leg — query embedded by the SPLADE service (default
localhost:48084, TEI protocol) and matched against the same collection’s sparse vector field. - RRF fusion — Qdrant fuses the two ranked lists server-side using
1/(1+rank)(note: rank-1 = 0.500, rank-9 ≈ 0.100 — this is Qdrant’s constant, not the academick=60formula). - Cross-encoder rerank — top fused candidates are POSTed to the
reranker service (default
localhost:48083,bge-reranker-v2-m3) for pairwise scoring. Final results are sorted by rerank score.
Behavior
- Hybrid search is always on for project-scope queries — both legs run unconditionally when the retrieval stack is reachable.
- Library scope (
scope: "libraries") follows the same pipeline against thelib:NAMEproject_id namespace. - If the retrieval stack is unreachable,
semantic_searchreturns a structured error with stack-inspection hints (seesrc/tools/semantic/search.rserror classification).
Configuration
| Env | Default | Effect |
|---|---|---|
CODESCOUT_EMBEDDER_PROTOCOL | tei | tei or openai (e.g. for Ollama) |
CODESCOUT_EMBEDDER_MODEL_NAME | (empty) | Model id sent in OpenAI-protocol payloads |
CODESCOUT_QUERY_PREFIX | (empty) | Prepended to query text only — for asymmetric models like Nomic |
CODESCOUT_RERANKER_PROTOCOL | tei | tei or infinity |
CODESCOUT_RERANKER_MODEL | (unset) | Override the reranker model id |
Rebuilding the indexes
The hybrid indexes are rebuilt automatically at the end of every
index(action="build") call — no separate “build the sparse index” step.
Both legs share the same chunk set and are upserted into Qdrant atomically.
SPLADE on ROCm (sparse-amd)
Status: stable since v0.12.0. The image is built from a not-yet-merged upstream PR and verified on gfx1100. Other RDNA3 / CDNA arches will probably work but have not been tested by us. If upstream PR #860 ships, this page will simplify to a one-line pointer at the official ROCm image.
The default amd profile keeps SPLADE on CPU because upstream
text-embeddings-inference (TEI) does not ship a ROCm release. The
sparse-amd service brings sparse encoding onto the GPU by building TEI from
source against ROCm 7.1 + PyTorch 2.8.
On a 21k-chunk codescout reindex this drops sparse CPU usage from ~3200 % (saturating 32 cores) to ~121 % (the Rust router thread plus light Python overhead) and finishes the full re-embed in 6 m 36 s.
Why this is experimental
- Upstream PR not merged. The AMD path lives on PR
#860 (
fa-varlenbranch). We pin commit1588129f93…becauserequirements-amd.txtandDockerfile-amdlanded there post-v1.9.3. If PR #860 changes, you may need to rebuild. - gfx1100 has no upstream flash-attention. Upstream PR #860 builds ROCm/flash-attention pinned to gfx942 (MI300). RDNA3 is not supported by that fork. Our Dockerfile skips the flash-attn build and relies on PyTorch SDPA fallback (wired by upstream PR #853). Functionally correct, slower than MI300 would be.
- Heavy image. ~12 GB because the runtime stage keeps the full
rocm/pytorchbase. A leaner runtime stage is on the TODO list.
Bring up
The service is part of the amd profile in docker-compose.yml:
docker compose --profile amd up -d --build sparse-amd
First build is ~25 minutes (Rust router compile + Python deps + ROCm PyTorch). Subsequent runs reuse the image.
Verify:
curl 127.0.0.1:48084/health # {"status":"Ok"}
curl -X POST 127.0.0.1:48084/embed_sparse \
-H 'Content-Type: application/json' \
-d '{"inputs":"async fn cancel()"}'
# → [[{"index":..., "value":...}, ...]] sparse activations
The container logs Python backend ready in 5.157s and
ROCm / HIP version: 7.1.25424 on startup. If you see
torch.cuda.is_available=False, the GPU passthrough is misconfigured —
check /dev/kfd and /dev/dri permissions on the host.
Compose service
sparse-amd:
profiles: [amd]
build:
context: ./docker/sparse-amd
dockerfile: Dockerfile
args:
TEI_REF: 1588129f932125a780ab97ccb300e7774b02d230
PYTORCH_ROCM_ARCH: gfx1100
image: codescout/sparse-amd:tei-1588129f93
container_name: codescout-sparse-amd
ports: ["127.0.0.1:48084:80"]
devices: [/dev/kfd, /dev/dri]
group_add: ["44", "992"] # video, render — numeric: rocm/pytorch image lacks 'render' group
shm_size: 8g
The numeric group_add is intentional. Docker resolves group names against
the image’s /etc/group, not the host’s. rocm/pytorch does not declare
a render group, so passing the name fails with
Unable to find group render. GIDs 44 (video) and 992 (render) match the
defaults on Debian/Ubuntu hosts — adjust if your host differs.
Deviations from upstream PR #860
We follow upstream where possible. Three intentional differences:
- Skip the flash-attention build. Upstream pins ROCm/flash-attention to gfx942. We delete that build step; PyTorch SDPA covers the gap.
- Force-reinstall numpy / scipy / scikit-learn after
make install.requirements-amd.txtpinsnumpy==1.26.4and an oldacceleratethat wantsnumpy<2. Therocm/pytorchbase image ships numpy 2.x and scipy 1.15 already, so the downgrade leavesscipy._fitpack_impllinked against the wrong numpy ABI and import fails with aTypeError. We restore the base versions instead. - Add three missing deps.
more_itertools,psutil, andbackports.tarfileare transitive requirements oftransformersthat the rocm/pytorch slim env doesn’t ship. Without them the Python backend crashes on import.
If you hit different ABI breakage, the upstream Makefile workflow
(cd backends/python/server && make install) is the reproducible
starting point.
Wiring
The default .env.amd already points sparse at 127.0.0.1:48084. No env
change is needed when you swap sparse-cpu → sparse-amd; the codescout
client only cares about the URL and the protocol (TEI’s /embed_sparse),
both of which match.
CODESCOUT_SPARSE_EMBEDDER_URL=http://127.0.0.1:48084
Known issues
- Image size ~12 GB. Runtime stage carries the full
rocm/pytorchbase. A multi-stage trim that copies only/opt/venv+ ROCm runtime libraries onto a smaller base is feasible but not yet attempted. - Cold start 5 s. The Python backend imports torch + transformers at startup. Live latency after warm is in the same ballpark as TEI-on-CUDA.
- gfx1100 only verified. gfx1030, gfx1101, MI series should work (PyTorch SDPA is arch-agnostic) but have not been tested.
Library Navigation
Library navigation lets you explore third-party dependency source code using the
same symbol tools you use for your own project — symbols, symbols,
symbol_at, semantic_search — without switching contexts or manually
locating package directories.
Auto-Discovery
Libraries are discovered automatically. When you call symbol_at on a
symbol and the LSP resolves it to a path outside the project root (typically
inside a language package cache), codescout registers that path as a library
and names it by the package name inferred from the manifest it finds there.
The next time you call list_libraries, the dependency appears in the list.
No manual registration is required for the common case.
The Scope Parameter
Once a library is registered, pass scope to any navigation or search tool to
target it:
| Value | Searches |
|---|---|
"project" (default) | Only your project’s source code |
"lib:<name>" | A specific registered library (e.g. "lib:tokio") |
"libraries" | All registered libraries combined |
"all" | Your project + all registered libraries |
{
"tool": "semantic_search",
"arguments": { "query": "retry with backoff", "scope": "lib:reqwest" }
}
Results include a "source" field so you can tell project code from library
code at a glance.
Building a Library Index
Semantic search over library code requires an embedding index, just like project
code. Build one with index(action: build) pointed at the library’s root path:
{ "tool": "index(action: build)", "arguments": { "path": "/path/to/tokio-1.35.1/" } }
This is a one-time cost per library. The index persists in
.codescout/embeddings/lib/<name>.db — see
Per-Library Embedding Databases below.
When to Use Library Navigation
- You’re debugging an unfamiliar error from a dependency and want to read its source without leaving your session
- You want to understand how a library’s internal types relate before writing integration code
- You’re doing a security audit and want to trace a call chain into a dependency
- You want to find usage examples by searching the library’s own tests with
semantic_search(scope: "lib:<name>")
Further Reading
- Library Navigation Tools — full reference for
list_librariesand library indexing - Symbol Navigation Tools — the tools that accept
the
scopeparameter - Semantic Search Tools — semantic search within library scope
Per-Library Embedding Databases
Earlier versions stored all embeddings in a single .codescout/embeddings.db.
The current layout splits storage into separate databases:
.codescout/
embeddings/
project.db ← your project's code
lib/
tokio.db ← one file per registered library
serde.db
reqwest.db
The filename for each library is derived from its registered name: / and \
are replaced with -- and the result is lowercased (e.g. @org/pkg →
org--pkg.db).
Migration is automatic. If an old embeddings.db is found, codescout moves
its contents into the new structure the first time the project is opened. No
manual steps required.
To build or rebuild a library’s index:
{ "tool": "index(action: build)", "arguments": { "scope": "lib:tokio" } }
Version Tracking and Staleness Hints
When index(action: build, scope="lib:<name>") runs, codescout reads the project’s
lockfile (Cargo.lock, package-lock.json, etc.) to record the library version
that was indexed.
After a dependency upgrade, semantic_search includes a stale_libraries field:
{
"stale_libraries": [
{
"name": "tokio",
"indexed": "1.37.0",
"current": "1.38.0",
"hint": "tokio was updated — run index(action: build, scope='lib:tokio') to re-index"
}
]
}
Staleness is detected by comparing indexed vs current versions from the lockfile. If the lockfile ecosystem is not recognised, version tracking is skipped.
Multi-Ecosystem Library Auto-Registration
Overview
When workspace(action: activate) runs, codescout now automatically detects and registers dependencies
from five ecosystems:
| Ecosystem | Manifest | Source Location |
|---|---|---|
| Rust | Cargo.toml | ~/.cargo/registry/src/ |
| Node/TypeScript | package.json | node_modules/ |
| Python | pyproject.toml / requirements.txt | .venv/lib/pythonX.Y/site-packages/ |
| Go | go.mod | $GOMODCACHE (via go env) |
| Java/Kotlin | build.gradle.kts / build.gradle / pom.xml | (no local source) |
How It Works
-
Discovery — each ecosystem’s manifest file is parsed to extract dependency names. Only production dependencies are included (dev/test dependencies are skipped).
-
Source location — for each dependency, codescout checks whether local source code exists (e.g., in
node_modules/, the Cargo registry, or a Python venv). -
Registration — dependencies are batch-registered with
DiscoveryMethod::ManifestScan. Thesource_availableflag indicates whether the source was found locally. -
Precedence —
ManifestScannever overwritesManualorLspFollowThroughregistrations. If you’ve manually registered a library, auto-scan won’t touch it.
Source Availability
Libraries without local source (common for JVM dependencies) are registered with
source_available: false. When an agent tries to use symbol tools or semantic search on
such a library, they receive a RecoverableError with a hint:
Library source code is not available locally for: jackson-databind
Hint: To browse library source, download it using the project's build tool
(e.g. ./gradlew dependencies, mvn dependency:sources), then call
register_library(name, "/path/to/source", language) and retry.
Output
The workspace(action: activate) response includes auto-registered libraries:
{
"auto_registered_libs": [
{"name": "express", "language": "javascript", "source_available": true},
{"name": "guava", "language": "java", "source_available": false}
]
}
The compact output shows: activated · myproject · auto-registered 5 libs (2 without source)
list_libraries now includes source_available for each entry.
Python Name Normalization
Python package names are normalized per PEP 503: lowercased, with runs of -, _, .
collapsed to a single _. This matches how pip stores packages in site-packages/.
Go Module Cache Encoding
Go module paths with uppercase letters are encoded per Go conventions: Azure → !azure.
This ensures correct lookups in $GOMODCACHE.
Multi-Project Workspace Support
codescout can manage multiple related projects from a single server instance. This is useful for monorepos or closely related repositories where you want cross-project navigation without running separate MCP servers.
Registering projects
Projects are registered in .codescout/workspace.toml under a [[project]] table:
[[project]]
id = "backend"
root = "services/backend"
[[project]]
id = "frontend"
root = "apps/frontend"
Each entry requires id (unique name) and root (path relative to the workspace root).
The languages and depends_on fields are optional: languages restricts which LSP
servers are started for the project; depends_on lists project IDs whose symbols are
visible during cross-project navigation.
Each project gets its own LSP servers, memory store, and semantic index.
Using project scope
Most tools accept a project parameter to scope the operation:
symbols("MyStruct", project: "backend")
semantic_search("authentication flow", project: "frontend")
memory(action: "read", project: "backend", topic: "architecture")
Omitting project uses the workspace-level context.
Onboarding
Run onboarding once after registering projects:
Run codescout onboarding
codescout generates a per-project Navigation Strategy section in the system prompt so the agent knows which files and entry points belong to each project. It also generates cross-project semantic search scope guidance.
Cross-project semantic search
The system prompt includes guidance on which scope= values to use for semantic search
across projects, so the agent does not need to guess project boundaries.
workspace(action: activate) Output Optimization
workspace(action: activate) now returns a slim orientation card instead of the full raw config dump.
New response shape
{
"status": "ok",
"project": "my-project",
"project_root": "/home/user/my-project",
"read_only": false,
"languages": ["rust"],
"index": { "status": "not_indexed", "hint": "Run index(action: build) to enable semantic_search." },
"memories": ["architecture", "conventions"],
"hint": "CWD: /home/user/my-project. Run workspace(action: status) for health checks and memory staleness.",
// RW only:
"security_profile": "default",
"shell_enabled": true,
// Multi-project workspaces only:
"workspace": [
{ "id": "root", "root": ".", "languages": ["rust"], "depends_on": [] },
{ "id": "web", "root": "packages/web", "languages": ["typescript"], "depends_on": ["root"] }
],
// When dependencies were auto-registered:
"auto_registered_libs": { "count": 12, "without_source": 3 }
}
What changed
| Before | After |
|---|---|
Full config object (all TOML fields) | Slim orientation card |
| Security fields always present | Security fields only in RW mode |
| No workspace on activation | workspace array included when multi-project |
| No memory list | memories array (topic names) |
| No index status | index.status field |
Focus-switch returned minimal {activated: {project_root}} | Focus-switch returns the same full card |
auto_registered_libs was an array of objects | Now a summary {count, without_source} |
Hint scenarios
| Scenario | Hint |
|---|---|
| First activation (home project) | "CWD: …. Run workspace(action: status) for health checks…" |
| Returning to home | "Returned to home project. CWD: …. Run workspace(action: status)…" |
| Switching away (RO) | "Browsing {name} (read-only). CWD: … — remember to workspace(action: activate, path: ...)" |
| Switching away (RW) | "Switched project (read-write). CWD: … — remember to workspace(action: activate, path: ...)" |
workspace(action: status) for full details
The orientation card is intentionally compact. For detailed health checks, memory staleness
scores, and drift detection, call workspace(action: status) after activation.
Project Hints
workspace(action: activate) now returns a project_hints field with manifest-derived
context so agents have useful information even when onboarding has never been
run.
Why
Previously, an agent hitting a codescout project in a client that never calls
onboarding saw only the languages list. No build commands, no entry points,
no manifest info. The agent had to probe the filesystem itself — or guess.
project_hints fills that gap with a cheap manifest probe that runs on every
workspace(action: activate) call.
What you get
{
"status": "ok",
"project": "codescout",
"project_root": "/home/you/work/codescout",
"languages": ["rust"],
"project_hints": {
"primary_language": "rust",
"manifest": "Cargo.toml",
"entry_points": ["src/main.rs", "src/lib.rs"],
"build_commands": ["cargo build", "cargo test", "cargo run"],
"onboarded": false
},
"memories": [],
"hint": "CWD: /home/you/work/codescout. Run workspace(action: status) for health checks and memory staleness."
}
Fields
| Field | Meaning |
|---|---|
primary_language | Language inferred from the detected manifest. null when no manifest recognised. |
manifest | Filename that drove detection (Cargo.toml, package.json, etc.). |
entry_points | Canonical entry-point files that exist on disk. Capped at 3. |
build_commands | Build / test / run commands for the detected manifest. |
onboarded | true when an onboarding memory exists (indicates a full onboarding has been performed at some point). |
Supported manifests
| Manifest | Language | Notes |
|---|---|---|
package.json | typescript if tsconfig.json also exists, else javascript | Checked first — Node projects sometimes ship a pyproject.toml for tooling. |
Cargo.toml | rust | |
pyproject.toml / setup.py | python | |
go.mod | go | |
pom.xml | java | |
build.gradle.kts / build.gradle | kotlin |
Relationship to onboarding
project_hints is not a replacement for the onboarding tool. It’s a
cheap fallback for when onboarding has never been called:
| Signal | Source |
|---|---|
| Primary language, entry points, build commands | project_hints — always populated when a manifest exists |
| README summary, architecture notes, memory writes | onboarding — only after full scan |
| System prompt draft | onboarding — only after full scan |
When onboarded: true, the real memories (project-overview, architecture,
language-patterns) are the authoritative source. project_hints stays
populated for consistency but agents should prefer memory content when
available.
Behaviour
- No probe runs if the project root has no recognised manifest — fields are
null/ empty arrays. - All probes are read-only file-existence checks. No file parsing beyond
checking
tsconfig.jsonpresence for TS vs JS disambiguation. - First manifest match wins.
package.jsontakes priority; otherwise the order is: Cargo.toml → pyproject.toml → setup.py → go.mod → pom.xml → build.gradle.kts → build.gradle.
MCP resources, tool diet, and progress notifications
Codescout now exposes three mechanisms to reduce per-turn token overhead and surface activity from long-running operations. They ship together because they share one thesis: pay tokens only when the model asks.
Resources
Codescout implements MCP’s resources/list and resources/read so Claude Code
(and any MCP client that supports resources) can fetch static and dynamic
context on demand:
| URI | Contents |
|---|---|
doc://progressive-disclosure | docs/PROGRESSIVE_DISCOVERABILITY.md — output sizing, overflow hints |
doc://tool-misbehaviors | docs/TODO-tool-misbehaviors.md — living log of observed tool bugs |
doc://codescout-tool-guide | Generated per-tool long-form usage notes |
memory://<name> | One resource per file in the active project’s memory directory |
project://summary | JSON snapshot — active project, index status, language, LSP ready |
In Claude Code, @-mention the URIs to include them in the prompt.
Tool-description diet + conditional exposure
Tool descriptions sent to the model every turn are capped at 300 characters.
Longer usage notes (examples, tradeoffs, gotchas) live in
doc://codescout-tool-guide and are fetched only when the agent needs them.
Tools are also hidden from list_tools when their required capability is
missing:
- LSP tools (
symbol_at,references,edit_code(action="rename")) — hidden when no LSP provider is wired for the project’s language. - Embedding tools (
semantic_search,index) — hidden when embeddings are disabled at build time. - Library tools (
library) — hidden when no supported language is detected in the registry.
workspace(action: activate) emits notifications/tools/list_changed when the set
shifts, so Claude Code refreshes its tool palette automatically.
Progress notifications
Long-running operations emit notifications/progress (throttled to 2 Hz):
index(action: build)— per-batch progress + start / complete textsemantic_search— “loading embedding model” → “searching”run_command— elapsed-time heartbeat every 3s during long commands
LSP cold-start progress is not yet wired (would require a trait-wide change to the LSP provider interface — tracked for a future release).
Why this matters
Claude Code re-sends every MCP tool description and server-instruction block every turn with no delta caching on the client side. Codescout ships 22 tools. Shrinking descriptions and moving reference material into on-demand resources compounds into a significant per-turn token saving on long sessions, without losing any information the agent might need.
Tool Description Diet & Tool Guide Resource
Keeps every tool’s MCP description at ≤ 300 characters and exposes long-form
usage notes on demand via the doc://codescout-tool-guide resource.
Motivation
The MCP tool list is sent to the LLM on every turn. Bloated descriptions waste context tokens with prose that is only occasionally useful. This feature separates what the tool does (short, always paid) from how to use it well (long, paid only when the agent asks).
Tool::long_docs()
A new optional method on the Tool trait:
#![allow(unused)]
fn main() {
fn long_docs(&self) -> Option<&str> { None }
}
Tools with long documentation override it. Currently populated for the five most
complex tools: symbols, symbols, semantic_search, run_command,
memory.
doc://codescout-tool-guide
A generated MCP resource that renders each registered tool’s long_docs() (or
falls back to its short description()) into a single Markdown page.
Fetch it with:
resources/read doc://codescout-tool-guide
The guide is re-generated on every workspace(action: activate) call so it always reflects
the current tool set.
Guard test
tool_descriptions_stay_under_budget in src/server.rs asserts that every
registered tool’s description().len() <= 300. The test fails at compile time if
a future tool exceeds the budget.
Tool Usage Doctor
The doctor://tool-usage MCP resource surfaces per-tool call statistics and
prune candidates, so codescout maintainers can quantify what the current
token-diet actually bought us and identify rarely-used tools for the next
prompt-surface review.
Background
From docs/trackers/mcp-integration-ideas-2026-04.md idea #7: every tool
description is re-sent to the LLM on every turn. Tools that almost never get
called are pure context-window tax. Before pruning a tool we want to verify —
with data — that it’s actually unused across sessions.
Reading the resource
Any MCP client can read the resource. Example over HTTP:
curl -s -X POST http://127.0.0.1:PORT/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "mcp-session-id: $SESSION" \
-d '{"jsonrpc":"2.0","id":1,"method":"resources/read",
"params":{"uri":"doctor://tool-usage"}}'
Response shape
{
"window": "30d",
"low_call_threshold": 5,
"total_calls": 4217,
"tools": [
{
"name": "symbols",
"calls": 1830,
"errors": 4,
"overflows": 12,
"error_rate_pct": 0.22,
"overflow_rate_pct": 0.66,
"p50_ms": 18,
"p99_ms": 210
}
],
"prune_candidates": ["symbol_at", "git_blame"],
"unused_tools": ["register_library"]
}
Fields
| Field | Meaning |
|---|---|
window | Time window analysed (default 30d). |
low_call_threshold | Tools called fewer than this many times are flagged as prune_candidates. |
total_calls | Sum of calls across all tools in the window. |
tools | Per-tool stats from usage.db, ordered by call count descending. |
prune_candidates | Known tools with 1 ≤ calls < low_call_threshold. |
unused_tools | Currently-registered tools that were never called in the window. |
Behaviour
- If
usage.dbdoesn’t exist for the active project (fresh install), all counts are zero and every registered tool appears inunused_tools. - The window is currently fixed at
30d. Future work: accept a query parameter to change it. prune_candidatesuses a strict<comparison, so a tool called exactlylow_call_thresholdtimes is not flagged.- Sorting:
toolsmirrors usage-DB ordering (descending calls).unused_toolsis sorted alphabetically for deterministic output.
Typical workflow
- Review the report monthly.
- For every name in
unused_tools: check if any known MCP client would ever plausibly use it. If not, consider unregistering. - For every name in
prune_candidates: look at thep99_msanderror_rate_pct. Expensive + rarely-useful tools are the highest-value pruning targets. - When pruning a tool, update all three prompt surfaces (see
CLAUDE.md § Prompt Surface Consistency) in the same commit.
librarian-mcp — workspace artifact registry
Codescout’s sister MCP server. Indexes markdown artifacts (specs, plans, runbooks, ADRs, memories, audits, handoffs, roadmaps, user docs) across every repo in a workspace, stores metadata + link graph in SQLite, and exposes MCP tools for finding, reading, linking, and packaging those artifacts as context.
Where codescout is project-scoped and code-shaped, librarian-mcp is workspace-scoped and artifact-shaped. Runs as a separate stdio MCP server — both can be wired into the same agent.
Installation
Ships as a sibling binary in the same Cargo workspace. Build with:
cargo build --release -p librarian-mcp
The binary lands at target/release/librarian-mcp.
One-time setup
-
Seed project registry. librarian-mcp reads a user-maintained TOML listing every repo to index. By default:
~/.codescout-registry.toml(or override viaCODESCOUT_REGISTRYenv). Format:[[projects]] name = "codescout" path = "/home/you/work/codescout" [[projects]] name = "backend" path = "/home/you/work/backend" -
Generate workspace config. Run once:
./target/release/librarian-mcp import-codescoutWrites
~/.config/librarian/workspace.tomlwith the seeded projects plus 9 default classification rules (spec / plan / memory / roadmap / adr / audit / handoff / runbook / doc). -
Index. Populate the catalog:
./target/release/librarian-mcp reindex # incremental ./target/release/librarian-mcp reindex --force # wipe + rebuild -
Wire into Claude Code:
claude mcp add librarian-mcp /absolute/path/to/target/release/librarian-mcpFull Claude Code restart to surface the tools.
Tools
| Tool | Purpose |
|---|---|
artifact_find | Search by filter AST (kind/status/tags/updated_at) with optional semantic query |
artifact_get | Fetch one artifact, optionally with observations + link neighbourhood |
artifact_list_by_kind | Thin wrapper — {kind, status?} |
artifact_links | Outgoing / incoming edges, optionally filtered by relation |
artifact_graph | BFS neighbourhood, depth 1–3 |
artifact_create | Write a new markdown file with frontmatter + index it |
artifact_update | Patch frontmatter or body; round-trips through the file |
artifact_link | Add a relation edge; supersedes transitions dst.status = “superseded” |
artifact_observe | Append a note to an artifact’s observation log |
librarian_reindex | Manual re-scan. {repo?, force?} |
librarian_context | Pack a topic or anchor artifact into a markdown context bundle, token-budgeted |
Filter AST
JSON tree ported from Redis agent-memory-server. Composition and / or /
not. Leaf ops eq / ne / in / nin / gt / lt / gte / lte /
contains. Example:
{"and": [
{"kind": {"eq": "spec"}},
{"status": {"in": ["active", "blocked"]}},
{"tags": {"contains": "embedding"}}
]}
tags and owners are JSON-array columns — contains compiles to a
json_each membership test, not LIKE.
Semantic search
Optional. Set LIBRARIAN_EMBED_MODEL to any model codescout supports (local
via fastembed, remote via Ollama / OpenAI-compatible endpoint). On reindex,
each artifact’s first chunk is embedded into sqlite-vec’s vec0 virtual table.
artifact_find and librarian_context accept semantic: "<natural language>"
and fall back to SQL LIKE when no embedder is configured.
Classification
Every indexed file passes through two sources of truth:
- Frontmatter (authoritative if present) —
kind,status,title,owners,tags,topic,time_scope. - Rule match on relative path — compiled glob patterns from
workspace.tomlunder[[rule]].
When neither identifies a kind, the row lands as kind = "unknown" with
confidence = 0.5. librarian_reindex reports the unknown ids so you can
triage by adding rules or frontmatter.
Architecture
codescout-embed— shared crate extracted from codescout. Provides theEmbeddertrait + local / remote clients + markdown chunker. Both codescout and librarian-mcp depend on it.crates/librarian-mcp— SQLite catalog (artifact / artifact_link / artifact_observation / artifact_vec), indexer, filter AST compiler, 11 MCP tools, stdio transport viarmcp.~/.config/librarian/workspace.toml— roots + ignore globs + classification rules.~/.local/share/librarian/catalog.db— SQLite database (override withLIBRARIAN_DB).
Known limits
- Indexing is on-demand via
librarian_reindexor CLI. No file watcher. - One vector per artifact (first chunk only). Chunk-level semantic search is not v1.
artifact_update’s body patch replaces the entire body — no diff semantics.- No central codescout project registry yet;
import-codescoutreads a user-maintained TOML. - Title derivation falls back to the first
# H1when frontmatter has notitle— files with neither land withtitle: null.
Related
- Spec:
docs/superpowers/specs/2026-04-19-librarian-mcp-design.md - Plan:
docs/superpowers/plans/2026-04-19-librarian-mcp.md - Credits:
crates/librarian-mcp/CREDITS.md
Librarian (embedded in codescout)
librarian-mcp is no longer a standalone MCP server. It is embedded inside
the codescout binary as an opt-in subsystem behind the librarian cargo
feature, which is on by default in dev builds and off in --no-default-features
production builds.
When active, the 15 librarian tools (artifact_*, librarian_*,
workspace_state_at) are advertised alongside codescout’s core toolset and
the librarian server instructions block is appended to codescout’s MCP
instructions field.
Build-time control
# Cargo.toml
[features]
default = ["remote-embed", "http", "librarian"]
librarian = ["dep:librarian-mcp"]
# Dev build — librarian on
cargo build --release
# Production build — librarian compiled out, zero runtime cost
cargo build --release --no-default-features \
--features remote-embed,http
Runtime override
Even with the feature compiled in, librarian registration is enabled by
default. Opt out per session via env var, or per project via project.toml.
| Knob | Value | Effect |
|---|---|---|
LIBRARIAN_ENABLED env | 0 / false / off / no | Disable for this codescout process |
LIBRARIAN_ENABLED env | 1 / true / on / yes | Force enable (overrides project.toml) |
[librarian] enabled = false in <project>/.codescout/project.toml | bool | Per-project disable when env unset |
| (default) | — | Enabled |
The env var wins; project.toml is consulted only when the env var is unset.
To opt out globally, set LIBRARIAN_ENABLED=0 in the codescout MCP server
launch env (e.g. the env block of .mcp.json or your shell rc).
What you lose with librarian off
- The 15 librarian tools disappear from
tools/list. - The librarian instructions block is omitted from the MCP
instructionsfield, so the LLM gets no hint that artifact tooling exists. - The on-disk catalog (SQLite at
$XDG_DATA_HOME/librarian/catalog.db) and workspace.toml are untouched — flipping the feature back on resumes where the previous session left off.
Opting out in production
Production users of codescout-as-MCP without a workspace.toml or a
configured catalog can opt out via LIBRARIAN_ENABLED=0 to avoid the
token overhead of the librarian tool descriptions. The cargo feature can
also be compiled out entirely (--no-default-features) for a leaner
production binary.
Default scope: project (not workspace)
All listing tools default to scope="project", returning only artifacts
under the agent’s current sub-project. The current project resolves from
cwd → nearest .git ancestor → workspace root from ~/.config/librarian/workspace.toml.
librarian_reindex follows the same default. A force-wipe under
scope="project" only deletes rows whose rel_path starts with the
current sub-project’s subdir — sibling projects under the same workspace
root are preserved.
| Scope | Coverage |
|---|---|
project (default) | Current sub-project only |
repo | Whole workspace root (all sub-projects under it) |
umbrella | All members of the declared umbrella for the current project |
all | Workspace-wide |
Read tools surface a scope block + hints (more_in_repo,
more_in_workspace) so the LLM can widen on demand. Reindex echoes its
scope and resolved targets in the response.
Per-project classifier overrides
Drop a <project>/.codescout/librarian.toml to declare classification
rules for that project’s paths without touching the global
~/.config/librarian/workspace.toml. Schema matches the global file’s
[[rule]] blocks. Rule precedence is project > workspace > built-in
defaults, first-match-wins.
# <project>/.codescout/librarian.toml
[[rule]]
glob = "codescout/docs/reviews/**/*.md"
kind = "memory"
time_scope = "dated_snapshot"
[[rule]]
glob = "codescout/docs/agents/*.md"
kind = "doc"
Built-in defaults already cover common patterns: CHANGELOG.md,
CONTRIBUTING.md, docs/ARCHITECTURE.md, docs/QUICK-START.md,
docs/concepts/**, docs/configuration/**, docs/experimental/**,
docs/issues/** (tracker), docs/TODO-*.md (tracker), docs/review-*.md
(memory, dated), **/prompts/*.md, src/**/prompts/*.md,
crates/**/prompts/*.md. The override file is for project-specific
patterns the defaults can’t reasonably guess.
Migration from standalone librarian-mcp
Earlier sessions ran librarian-mcp as a separate stdio MCP server. That
binary no longer exists — crates/librarian-mcp is now lib-only. The
codescout-companion plugin’s session-start hook no longer injects a
separate librarian companion-hint block; the librarian instructions are
served through codescout’s own instructions field.
~/.claude/.claude.json should have only one MCP server entry (codescout)
with optional LIBRARIAN_EMBED_* envs:
{
"mcpServers": {
"codescout": {
"type": "stdio",
"command": "/abs/path/to/codescout",
"args": ["start", "--debug"],
"env": {
"LIBRARIAN_EMBED_MODEL": "CodeRankEmbed",
"LIBRARIAN_EMBED_URL": "http://localhost:43300/v1"
}
}
}
}
Librarian Tools Collapse (16 → 5)
The 16 individual librarian tools have been collapsed into 5 action-dispatched tools, reducing MCP surface area and improving discoverability.
New tools
| Tool | Actions | Replaces |
|---|---|---|
artifact | find, get, create, update, link, graph, state_at | artifact_find, artifact_get, artifact_create, artifact_update, artifact_link, artifact_graph, artifact_state_at |
artifact_event | create, list | artifact_event_create, artifact_timeline |
artifact_augment | (unchanged) | artifact_augment |
artifact_refresh | gather, list_stale | artifact_refresh, artifact_refresh_stale |
librarian | context, reindex, tracker_design, workspace_state_at | librarian_context, librarian_reindex, tracker_design, workspace_state_at |
Usage
Every tool takes a required action parameter:
// Find active trackers
{"action": "find", "kind": "tracker", "status": "active"}
// Get one artifact with links
{"action": "get", "id": "abc123", "include_links": true}
// Append a note event
{"action": "create", "artifact_id": "abc123", "kind": "note", "payload": {"text": "..."}}
// Gather refresh context
{"action": "gather", "id": "abc123"}
// Pack context bundle
{"action": "context", "topic": "authentication"}
Migration
Old tool names are no longer registered. If you have saved prompts or scripts using the old names, update them:
artifact_find {...}→artifact {action: "find", ...}artifact_get {...}→artifact {action: "get", ...}artifact_create {...}→artifact {action: "create", ...}artifact_update {...}→artifact {action: "update", ...}artifact_link {...}→artifact {action: "link", ...}artifact_graph {...}→artifact {action: "graph", ...}artifact_state_at {...}→artifact {action: "state_at", ...}artifact_event_create {...}→artifact_event {action: "create", ...}artifact_timeline {...}→artifact_event {action: "list", ...}artifact_refresh {...}→artifact_refresh {action: "gather", ...}artifact_refresh_stale {...}→artifact_refresh {action: "list_stale", ...}librarian_context {...}→librarian {action: "context", ...}librarian_reindex {...}→librarian {action: "reindex", ...}tracker_design {...}→librarian {action: "tracker_design", ...}workspace_state_at {...}→librarian {action: "workspace_state_at", ...}
doc://librarian-guide MCP Resource
A dense reference document for the librarian subsystem, surfaced as an MCP resource so agents can pull it on demand without consuming system-prompt tokens.
Usage
resources/read doc://librarian-guide
Returns a self-contained markdown guide covering:
- Artifact model — frontmatter fields,
id,status,kind,tags,owners - Filter syntax — leaf format
{"field": {"op": value}}with composition (and/or/not) - Tracker workflow — design → create → augment → refresh lifecycle
- Augmentation lifecycle —
gather/commit_refresh/append_mode/history_cap - Archiving / Moving —
artifact(action="update", patch={status:"archived"})andartifact(action="move") - Common mistakes — filter format inversion, forgetting
repoon create, direct file edits
Why pull it?
The system prompt references librarian tools but cannot include the full filter reference inline (token cost). When you encounter a complex artifact query, call resources/read doc://librarian-guide once to load the complete reference into context.
Source
src/prompts/librarian-guide.md — served directly from disk, always current.
artifact_refresh_stale
Discovery tool: surfaces augmented artifacts whose last refresh is older than a
threshold. Returns them oldest-first (never-refreshed first) so the agent knows
what to call artifact_refresh on next.
Schema
{
"threshold_hours": 24,
"limit": 10,
"scope": "project"
}
All fields are optional.
| Field | Default | Notes |
|---|---|---|
threshold_hours | 24 | Hours since last refresh to consider stale |
limit | 10 | Max results (capped at 50) |
scope | "project" | project | repo | all |
Output
{
"count": 2,
"threshold_hours": 24,
"items": [
{
"id": "abc123",
"kind": "tracker",
"title": "My Tracker",
"rel_path": "codescout/docs/trackers/my-tracker.md",
"last_refreshed_at": null,
"refresh_count": 0,
"age_hours": null
}
],
"next_step": "Call artifact_refresh(id) on each item..."
}
age_hours: null means never refreshed. Items ordered: never-refreshed first,
then oldest last_refreshed_at ascending.
Typical workflow
artifact_refresh_stale(scope="repo")
→ pick item from list
artifact_refresh(id)
→ synthesize new content
artifact_update(id, { body: "..." })
artifact_refresh_commit(id)
Known limitations
scope=umbrellais not supported (returns a recoverable error).- Threshold is wall-clock time only — no per-artifact config yet.
- No priority weighting; ordering is strictly oldest-first.
artifact(action="move") — Atomic File Rename
Atomically renames a librarian-managed artifact file and updates the catalog’s rel_path in a single operation. Replaces the previous git mv + artifact(update, patch={rel_path:...}) + librarian(action="reindex") three-step sequence.
Usage
artifact(action="move", id="<16-hex>", new_rel_path="docs/archive/my-tracker.md")
Parameters
| Parameter | Required | Description |
|---|---|---|
id | yes | 16-hex artifact ID |
new_rel_path | yes | Destination path relative to the repo root |
Response
{
"id": "abc123def456abcd",
"old_rel_path": "docs/trackers/my-tracker.md",
"new_rel_path": "docs/archive/my-tracker.md",
"moved": true
}
What it does
- Resolves the artifact’s current file path from the catalog
- Calls
std::fs::rename(atomic on same filesystem) - Creates any missing parent directories at the destination
- Updates
rel_path,updated_at,file_mtime, andfile_sha256in the catalog
Git sees the rename automatically in git status — no extra git add needed.
Error cases
- Destination already exists →
RecoverableError(no filesystem change) - Unknown
id→RecoverableError - Destination on a different filesystem → OS-level rename error propagated
When to use
Use artifact(action="move") whenever you need to reorganize a tracker or archive it to a different directory. Do not use git mv or fs::rename directly — those leave the catalog stale until the next reindex.
tracker_design
Teaching tool that guides tracker creation. Call it before tracker_create
whenever the user asks to create a tracker.
What it returns
{
"design_version": "1",
"system_prompt": "...", // 7-step design guide
"archetypes": [...], // 6 archetype definitions
"existing_trackers": [...],// current trackers in catalog (cap 30)
"next_step": "..."
}
The 7 steps
- Pick an archetype — match intent to one of 6 archetypes.
- Write the augmentation prompt — imperative, names sources, states conflict resolution.
- Design params — live state only, flat, stable keys.
- Decide schema discipline — loose early, lock when mature.
- Compose
render_template— MiniJinja projecting params to markdown. - Sketch body skeleton — prose sections + History block.
- Check for collisions —
existing_trackersprevents duplicate concerns.
Archetypes
| Name | When to use |
|---|---|
deployment_state | Feature flag / env rollout state per environment |
failure_table | Numbered F-N list from a test/eval suite |
metric_baseline | Living benchmark log with baseline + session deltas |
audit_issues | Numbered audit output with severity + status |
task_list | Phase-based task list with done/open/blocked status |
reflective | Design brainstorm or decision log — prose-driven |
Each archetype ships with params_shape_example, params_schema_example,
render_template_example, body_skeleton, and prompt_template.
Usage
tracker_design() // load guide + landscape
→ pick archetype, compose spec
tracker_create(repo, rel_path, title, prompt, params, ...)
Pass intent to tracker_design for future tailoring (reserved, currently
echoed back in the response).
Known limitations
existing_trackersis capped at 30 entries (scope=repo).intentfield is reserved; no tailoring is applied yet.
workspace_state_at — Time-Travel Workspace Snapshot
The workspace_state_at tool returns a snapshot of every artifact in scope
as it stood at a given commit or timestamp, with a per-artifact comparison of
freshness_at_as_of (freshness replayed up to the cutoff) vs freshness_now
(freshness from current state).
Use cases
- “What was stale at the time of release v2.3?”
- “Which specs were unreviewed when we cut the RC?”
- “Show me everything that has become stale since the last review round.”
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
commit | string | — | Commit hash as cutoff. Exactly one of commit / timestamp required. |
timestamp | integer | — | Unix epoch ms as cutoff. Exactly one of commit / timestamp required. |
scope | string | project | project | repo | umbrella | all |
kinds | string[] | all | Filter by artifact kind (e.g. ["spec", "adr"]). |
include_archived | boolean | false | Include archived / superseded artifacts. |
freshness_filter | string[] | all | Only return artifacts whose freshness_at_as_of is one of fresh, stale, unknown, superseded. |
Response shape
{
"as_of": 1714300000000,
"scope": { "scope": "all", ... },
"artifacts": [
{
"id": "specs/auth-redesign",
"kind": "spec",
"status_at_as_of": "active",
"freshness_at_as_of": "stale",
"freshness_now": "fresh",
"freshness_changed": true,
"latest_event_at_as_of": { "id": "...", "kind": "reviewed", "created_at": 1714200000000 },
"supersession_chain": [],
"rel_path": "specs/auth-redesign.md",
"repo": "my-repo"
}
],
"hints": {
"scope_fallback": false,
"more_in_scope": 42,
"hint": "Result capped at 200. Narrow with `kinds`, `freshness_filter`, or a tighter scope."
}
}
Cap behaviour
Results are capped at 200 artifacts. When more candidates exist,
hints.more_in_scope reports the total excess and hints.hint suggests
how to narrow the query.
Freshness semantics
freshness_at_as_of— computed by replaying only events withcreated_at ≤ cutoff. This is the true historical freshness.freshness_now— computed from all events without a cutoff (current state). Usesfile_mtimefrom the current artifact row in both cases.freshness_changed = truewhen the two values differ — the most interesting signal for “what has drifted since commit X?”
Augmentation: render_template + params_schema
Two optional fields on augmented artifacts that decouple live state (params) from narrative (artifact body).
render_template
A MiniJinja template projecting params into a
markdown snippet injected under the [LIVE] header in librarian_context
output. Set it when you want a status table, flag grid, or F-N row list that
the agent can read without parsing raw JSON.
Common patterns
{# Table from an array #}
| id | status | owner |
|----|--------|-------|
{% for f in failures %}| {{ f.id }} | {{ f.status }} | {{ f.owner or "—" }} |
{% endfor %}
{# Dict iteration #}
{% for env, s in envs|items %}{{ env }}: {{ "✅" if s.enabled else "❌" }}
{% endfor %}
{# Filtered count #}
{{ items|selectattr("status","equalto","fail")|list|length }} failing
Schema
Pass render_template to artifact_augment or artifact_update_params:
{
"render_template": "**Flag:** `{{ flag_name }}`\n..."
}
Behaviour
- Evaluated at
librarian_contextread time against currentparams. - Errors in template evaluation are surfaced inline (not fatal).
- Omit the field for
reflectivetrackers — prose-only body needs no template.
params_schema
A JSON Schema (draft-07+) validating params on every write:
artifact_augment (initial seed) and artifact_update_params merges.
Violations return a recoverable error — params are not written.
When to use
- Early life: omit or use
additionalProperties: true. Let the shape settle over 2-3 refreshes. - Mature: add
required,enum,patternconstraints to lock drift out.
Example
{
"params_schema": {
"type": "object",
"required": ["failures"],
"properties": {
"failures": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "status"],
"properties": {
"id": { "type": "string", "pattern": "^F-\\d+$" },
"status": { "type": "string", "enum": ["fail","pass","flaky","wontfix"] }
}
}
}
}
}
}
Known limitations
- Template is re-evaluated on every
librarian_contextcall — no caching. - Schema validation uses draft-07 semantics; newer keywords are ignored.
LSP Idle TTL Eviction
codescout starts LSP servers on demand and keeps them running for fast symbol lookups. A server that has been idle beyond its timeout is shut down automatically to reclaim memory.
Default timeouts
| Language | Idle TTL |
|---|---|
| Kotlin | 2 hours |
| All others | 30 minutes |
Kotlin gets a longer TTL because its LSP server has a long startup time — evicting it aggressively would cause noticeable latency on the next query.
Behaviour
When an LSP server’s idle TTL expires:
- codescout sends a
shutdownrequest andexitnotification to the server. - The server process is removed from the pool.
- On the next symbol request for that language, a new server is started automatically.
There is no user-visible interruption — the eviction and restart are transparent.
Configuration
TTL eviction is not yet configurable via project.toml. This is planned for a future release.
Memory
Memory gives codescout persistent, project-scoped storage that outlives any single conversation. Notes written in one session are available in every future session — the agent accumulates knowledge about a codebase over time rather than rediscovering the same things repeatedly.
The Problem It Solves
Without persistent memory, every new session starts from scratch. The agent has to re-read CLAUDE.md, re-run onboarding, and re-discover facts it already knew: which module handles authentication, where the main entry point is, what convention the project uses for error types. This re-discovery burns time and context window on every session.
With memory, the agent writes a note the first time it discovers something non-obvious. Every subsequent session reads that note immediately and skips the rediscovery entirely.
Storage Layout
Memories are plain Markdown files in .codescout/memories/:
.codescout/memories/
architecture.md
conventions/
error-handling.md
naming.md
debugging/
lsp-timeouts.md
Topics with forward slashes map to subdirectories. You can version-control memory files alongside code, or keep them local.
Typical Workflow
At the start of a session:
- Call
onboarding— it lists existing memories and skips heavy discovery if memories are already written - Call
memory(action: "read", topic: ...)for topics relevant to the current task
During a session:
3. Call memory(action: "write", topic: ..., content: ...) when you discover something worth remembering — a
naming convention, an architectural decision, a gotcha
At the end of a session:
4. Call memory(action: "write", ...) to update entries if your understanding changed
What Makes a Good Memory Entry
Good candidates:
- Architectural decisions — why a module is structured a certain way
- Naming conventions — patterns used throughout the codebase that aren’t obvious from reading one file
- Debugging insights — root causes of tricky issues, non-obvious interactions
- Entry points — which file/function to start from for a given concern
- Gotchas — behaviours that surprised you and would surprise the next session
Avoid:
- Things obvious from reading the code
- Things that change so frequently the memory goes stale immediately
- Duplicating information already in CLAUDE.md
Onboarding Integration
The onboarding tool automatically writes a summary entry under the topic
"onboarding". This entry contains language detection results, detected entry
points, and a system prompt draft for the routing plugin. You do not need to
write it manually.
Further Reading
- Memory Tools — full reference for
memory(action: "read/write/list/delete/remember/recall/forget/refresh_anchors") - Dashboard — the Memories page lets you browse and edit topics in a browser UI
- Workflow & Config Tools —
onboardingintegrates with memory at session start
After Onboarding: Slimming Your Config File
Running onboarding for the first time writes memories for architecture, entry points,
conventions, and gotchas. Many projects also have a hand-written config file —
CLAUDE.md, AGENTS.md, .cursorrules, or .github/copilot-instructions.md — that
predates codescout and repeats a lot of the same information.
Keeping both is redundant. Every session the agent reads the config file eagerly (full cost, always) and then reads memories on demand. When the same fact lives in both places, you pay for it twice and get two sources of truth that can drift apart.
This page walks through how to trim your config file after onboarding so it carries only what it should.
What belongs where
Config file (CLAUDE.md etc.) | codescout memories |
|---|---|
| Workflow preferences (“always run tests before committing”) | Entry points and module responsibilities |
Tool-specific instructions (“use cargo clippy -- -D warnings”) | Naming conventions and code patterns |
| Team conventions that govern agent behavior | Architectural decisions and their rationale |
| Security or access rules | Gotchas and non-obvious interactions |
| Things that must fire before the agent does anything | Debugging insights from past sessions |
The rule of thumb: if a fact describes how the codebase is built, it belongs in memory. If it describes how you want the agent to behave, it belongs in the config file.
The audit workflow
After onboarding completes, ask the agent to do a one-time audit:
Now that codescout memories are written, please audit CLAUDE.md:
1. List all memory topics with `memory(action: "list")`
2. Read each topic with `memory(action: "read", topic: "...")`
3. For each block in CLAUDE.md that is fully covered by a memory, either delete it
or replace it with a one-line reference: "See codescout memory '<topic>'"
4. Keep anything that is a workflow rule or behavior instruction
The agent will compare each section against the memory store and propose edits. Review and apply.
Example: before and after
Before (verbose, duplicated in memory):
## Project Structure
The main entry point is `src/main.rs`. The server is wired in `src/server.rs` via
`CodeScoutServer::from_parts`, which registers all 29 tools. Tools are implemented in
`src/tools/` grouped by category. The `Agent` struct in `src/agent.rs` holds project
state and is accessible to all tools via `ctx.agent`. Error routing goes through
`route_tool_error` in `src/server.rs`: `RecoverableError` maps to `isError: false`,
all other errors to `isError: true`.
After (slim, memory carries the detail):
## Architecture
See codescout memory "architecture".
Or if the memory coverage is complete, delete the section entirely and trust the agent to read the memory at session start.
Per-agent config file names
| Agent | Config file |
|---|---|
| Claude Code | CLAUDE.md |
| GitHub Copilot | .github/copilot-instructions.md |
| Cursor | .cursorrules or AGENTS.md |
| Codex / OpenAI agents | AGENTS.md |
| Generic | AGENTS.md |
The audit workflow is the same regardless of which file your agent uses.
When to repeat the audit
- After adding a significant new module or subsystem (onboarding will have written new memories — check if the config file still duplicates them)
- After onboarding a multi-project workspace (each project gets its own memories; workspace-level config can often shed per-project sections entirely)
- When context window pressure becomes noticeable — a bloated config file is often the first place to reclaim tokens
Further reading
- Memory — what makes a good memory entry and the full
memorytool reference - Onboarding — what
onboardingwrites and when to re-run it - Multi-Project Workspace — per-project memory scoping in workspace setups
Memory Sections Filter
memory(action="read") now accepts a sections parameter that returns only the
### Heading blocks you need, instead of the full memory file.
Usage
{
"action": "read",
"topic": "language-patterns",
"sections": ["Rust", "TypeScript"]
}
The response contains the file preamble (text before the first ### heading) plus
each matched section in file order. Heading matching is case-insensitive.
Why this matters
Large memory files like language-patterns can hold hundreds of lines across
many languages. Passing sections slashes context cost when you only need one
language’s patterns — the server does the filtering before the result reaches
the model.
Parameters
| Parameter | Type | Notes |
|---|---|---|
topic | string | Memory topic to read (required). |
sections | string[] | One or more ### Heading names to return. Omit to return the full file. |
private | boolean | true to read from the gitignored private store. |
Return value
When sections is supplied and at least one heading matches, the response
contains the filtered content. When a heading is requested but not found, a
RecoverableError is returned that lists:
missing— requested section names that had no match.available— all section headings present in the file, so you can correct the call.
{
"error": "sections not found: [\"Go\"]",
"missing": ["Go"],
"available": ["Rust", "TypeScript", "Python"]
}
Limitations
- Only
###(H3) headings are treated as section boundaries.##and#headings are part of the preamble or carried inside a section body. - Sections are returned in file order, not request order.
- The filter applies to topic-based reads only (
action="read"). Semantic recall (action="recall") is unaffected.
Dashboard
The dashboard is a local web UI that gives you a live view of your project’s
health, tool usage, and memories. It runs as a separate process — no MCP server,
no LSP, no tool machinery — just the data already on disk in .codescout/.
codescout dashboard --project .
# opens http://127.0.0.1:8099
Pages
Overview
Project health at a glance:
- Project — root path, detected languages, entry points
- Configuration — active settings from
.codescout/project.toml - Semantic Index — chunk count, last-indexed commit, staleness relative to HEAD
- Drift — files with high semantic drift since last index (files where meaning changed significantly, not just bytes)
- Libraries — registered third-party libraries and their index status
Tool Stats
Usage telemetry for every tool call the MCP server has handled:
- Summary — total calls, error rate, overflow rate for the selected window
- Calls by Tool — bar chart ranked by call volume
- Per-Tool Breakdown — table with calls, errors, Err%, overflows, Ovf%, p50 and p99 latency
- Recent Errors — last N errors with full input/output, searchable and collapsible by duplicate group
The time window selector covers 1h / 24h / 7d / 30d and updates all panels simultaneously.
Memories
Read and edit the project’s persistent memory store directly in the browser:
- Browse topics in the sidebar
- View raw markdown content
- Create, update, or delete topics without touching the filesystem manually
Options
| Flag | Default | Description |
|---|---|---|
--host | 127.0.0.1 | Bind address |
--port | 8099 | Port |
--no-open | off | Disable auto-opening the browser |
codescout dashboard --project . --port 9000
Notes
- The dashboard reads
.codescout/directly; the MCP server does not need to be running - Static assets (HTML, CSS, JS) are embedded in the binary — no separate serving step
- Theme toggle (light/dark) persists across page loads via
localStorage
Further Reading
- Memory Tools — the
memorytool that backs the Memories browser - Semantic Search Tools —
workspace(action: status)is the data source for the index health and drift panels on the Overview page - Workflow & Config Tools — usage data from
.codescout/usage.dbbacks the Tool Stats page - Project Configuration — the Overview page shows your active configuration from
.codescout/project.toml
LSP Startup Statistics
codescout records LSP cold-start timing to .codescout/usage.db and surfaces it
in the project dashboard under “LSP Startup”.
What is recorded
Each cold start records:
- Language — which LSP server was started
- Reason —
new_session,idle_evicted,lru_evicted, orcrashed - Handshake duration — time for the LSP
initializeround trip - First response duration — time for the first real tool request (symbols, hover, etc.)
Viewing the data
Open the dashboard (codescout dashboard --project .) and look for the
“LSP Startup” section. It shows per-language averages/p95 and a recent event list.
Limitations
first_response_msmay benullif no tool call followed the cold start in the same server process.- Events are only recorded when the project root is known at startup time.
Git Worktree Support
The Core Problem
Claude Code’s EnterWorktree creates an isolated git worktree for feature work,
and the shell’s working directory moves into it. The MCP server does not follow.
codescout’s project root is set when the server starts (or when
workspace(action: activate) is called). It has no visibility into where the shell is
currently pointed. So after EnterWorktree, write tools — edit_file,
create_file, edit_code — are still
targeting the main repo. The AI writes to the wrong tree, silently, with no
error, because the path is valid in both contexts.
The Fix: workspace(action: activate)
After EnterWorktree, always call workspace(action: activate) with the absolute worktree
path before doing any writes:
workspace(action: activate, path: "/abs/path/to/.claude/worktrees/my-feature")
All subsequent reads, writes, symbol navigation, and shell commands then target that tree. Switch back to the main repo when done:
workspace(action: activate, path: "/abs/path/to/main-repo")
Layer 1 — Write Guard (Hard Block)
If the AI enters a worktree but hasn’t called workspace(action: activate), write tools
detect the mismatch and raise a hard error rather than silently writing to the
wrong place. The error message lists the detected worktree paths and the exact
workspace(action: activate) call needed to unblock.
Layer 2 — Navigation Exclusions
Worktree directories (.claude/worktrees/, .worktrees/) are excluded from
tree (with glob) and tree results. Without this, file searches and directory
listings in the main project would surface duplicate copies of every source
file from every active worktree — polluting navigation and confusing symbol
lookups.
Cleanup Gotcha
git worktree remove <path> requires the directory to still exist. If the
worktree directory was already deleted (e.g. by the agent or a cleanup script),
worktree remove will fail. The correct command for an already-gone directory
is:
git worktree prune
Run this from the main repo root, not from inside the (now-deleted) worktree.
Plan Execution Gotcha: Start a New Session in the Worktree
When using a workflow like Superpowers writing-plans and
choosing the Parallel Session option, don’t try to launch executing-plans
from the same session that created the worktree. The EnterWorktree +
workspace(action: activate) dance is easy to miss, and subagents spawned from the current
session won’t automatically inherit the right project root.
The cleanest approach — one that sidesteps all of this — is:
cd /path/to/.worktrees/<feature-branch>
claude
Open a new terminal, cd into the worktree, and start Claude there. The session
is rooted in the worktree from the first message. No workspace(action: activate) call
needed, no stale context from the planning session, no risk of writes going to
the main repo.
Other approaches can work, but this one always does.
Further Reading
- Superpowers Workflow — how the Superpowers plugin integrates worktrees into a full TDD + parallel-agent development workflow
- Workflow & Config Tools —
workspace(action: activate)reference: the required call after entering a worktree
Security & Permissions
codescout is designed to be safe to run autonomously: an agent can explore any codebase it needs to understand, but it cannot write outside its current project without explicit opt-in. This page explains the model, the defaults, and the configuration knobs.
The Core Model
The permission model is asymmetric by design:
| Operation | Default | Restriction |
|---|---|---|
| Read | Permissive — anywhere on disk | Deny-list of sensitive locations |
| Write | Restricted — project root only | Hard boundary; opt-in escapes via config |
| Shell | Disabled by default | Two-field opt-in; cwd sandboxed to project root |
| Git | Enabled | Can disable per-project |
This asymmetry is intentional. An agent doing code intelligence work legitimately needs to read widely — library source, system headers, adjacent repositories. But writes touching unrelated projects or system files would be a serious mistake. The boundary keeps agents capable and safe simultaneously.
Why Write Restriction Matters for Agents
When an agent runs autonomously with multiple parallel tool calls in flight, a
write-boundary violation produces a RecoverableError — not a fatal crash. This
means:
- The agent receives a clear error message and a corrective hint
- Sibling parallel tool calls are not aborted — the rest of the work continues uninterrupted
- The user is never asked to intervene mid-task for a permissions issue
Writes outside the project root are blocked, not just warned about. This is intentional: the boundary needs to be hard for the safety guarantee to hold.
Read Policy
read_file, grep, tree (with glob), and all symbol tools can read from
any path on the filesystem, subject to one restriction: the built-in deny
list.
Built-in Read Deny List
These locations are always blocked, regardless of configuration:
~/.ssh
~/.aws
~/.gnupg
~/.config/gcloud
~/.config/gh
~/.docker/config.json
~/.netrc
~/.npmrc
~/.kube/config
On Linux, /etc/shadow and /etc/gshadow are also blocked. On macOS,
/etc/master.passwd is blocked.
This list cannot be overridden. It exists to prevent an agent from accidentally leaking credentials even if pointed at a project that tries to read them.
Extending the Deny List
To block additional paths specific to your environment, add them to
project.toml:
[security]
denied_read_patterns = [
"~/.config/my-app/credentials",
"/etc/internal",
]
Entries are prefix-matched, so "~/.config/my-app" blocks everything under
that directory.
Write Policy
create_file, edit_file, and edit_code (all actions — replace,
insert, remove, rename) enforce a project root
boundary. The check happens before any I/O:
- Deny list first — the target path is checked against the built-in deny
list and
denied_read_patterns. Evenextra_write_rootscannot bypass this. - Boundary check — the canonicalized path must fall under the project root
or an explicitly configured
extra_write_rootsentry. - Symlink escape prevention — the parent directory is canonicalized (not the target file, which may not exist yet), so symlinks pointing outside the root are caught.
Allowing Writes Outside the Project Root
For multi-repo setups where the agent legitimately needs to write across
repositories, add the target directory to extra_write_roots:
[security]
extra_write_roots = [
"/home/user/other-project",
]
The deny list still applies first. extra_write_roots only extends where writes
land — it cannot unlock credential paths.
Disabling Writes Entirely
For read-only sessions:
[security]
file_write_enabled = false
Shell Policy (run_command)
Shell execution is disabled by default and requires explicit opt-in:
[security]
shell_enabled = true
shell_command_mode = "warn" # or "unrestricted"
Both fields must be set. The two-field design lets you grant shell access while
keeping a reminder in every response ("warn"), which is recommended for shared
or CI environments.
shell_command_mode | Behaviour |
|---|---|
"disabled" | All calls return an error. |
"warn" | Commands run; output includes a permissions reminder. |
"unrestricted" | Commands run; no warning added. |
Shell Sandbox
Even with shell enabled, the cwd parameter is restricted to subdirectories
within the project root — path traversal (../) is rejected. The shell command
itself is unrestricted (it can reference any absolute path), but the working
directory anchor is always the project.
Dangerous commands (rm -rf, dd, mkfs, etc.) require acknowledge_risk: true to run. See Workflow & Config for the
full list.
Per-Tool Switches
Individual feature categories can be toggled independently:
[security]
file_write_enabled = true # create_file, edit_file, symbol writes
shell_enabled = false # run_command
indexing_enabled = true # index(action: build), workspace(action: status)
Disabling a category returns a RecoverableError with a hint explaining which
config field to set — the agent understands why it was blocked without user
intervention.
Summary
- Reads: anywhere except the built-in credential deny list
- Writes: project root only, by default — hard boundary, not a warning
- Shell: off by default; two-field opt-in; cwd sandboxed to project
- Violations:
RecoverableError→ agent gets a hint, no user interruption, no sibling call cancellation
→ Configuration reference: [security]
→ Troubleshooting access errors
Security Profiles
codescout now supports a profile field in the [security] section of
.codescout/project.toml. The profile controls how strictly path validation
and shell command safety checks are enforced.
Profiles
default (standard sandbox)
This is the profile used when no profile key is present.
- Read deny-list active — system paths (
.ssh/,/etc/passwd, etc.) are blocked regardless of the calling tool. - Writes restricted to the project root and the system temp directory.
Additional directories can be added via
extra_write_roots. - Dangerous shell commands (e.g.
rm -rf,dd,mkfs) requireacknowledge_risk: trueinrun_command.
root (unrestricted)
For system-administration projects that legitimately need full filesystem access.
- No read deny-list — any path readable by the OS user can be read.
- Writes allowed anywhere the OS user has permission.
- Dangerous command check bypassed —
run_commandexecutes without a speed bump.
Source-file shell access guidance (prefer read_file/symbols over cat)
remains active in both profiles. It improves tool output quality, not security.
Configuration
Add a [security] section to .codescout/project.toml:
[security]
profile = "root"
The default value is "default" — omitting the field is equivalent to
profile = "default".
When to use root mode
Only switch to root if your project genuinely needs it:
- System administration scripts that read
/etc,/var, or write outside the project tree. - Dotfile managers, backup tools, or package managers where restricting paths would prevent the tool from functioning.
For regular application development, keep profile = "default". The sandbox
prevents accidental reads of SSH keys or credential files and catches
destructive shell commands before they run.
Limitations
- The profile is per-project, not per-tool. There is no way to lift the sandbox for a single tool call.
profile = "root"does not bypass OS-level permissions — codescout can still only access paths the running user is allowed to read or write.
Compact Tool Schemas & workspace(action: activate) Safety
Two related improvements land together: tool schema descriptions were trimmed by ~24% (~1,763
tokens), and a new Iron Law + server guidance was added for safe cross-project navigation with
workspace(action: activate).
Compact tool schemas
Parameter descriptions in all tool schemas previously repeated usage guidance that already
lives in server_instructions.md. They now answer only “what is this parameter?” — not “how
should you use the tool?”. The system prompt is the right place for workflow guidance; schemas
are for type/shape information.
Net effect: ~1,763 fewer tokens injected on every MCP request, partially offset by ~448
tokens added for the new Iron Law. Net saving across a typical session is significant since
server_instructions.md is injected on every tool call.
workspace(action: activate) safety — Iron Law #4
A new Iron Law was added to server_instructions.md:
ALWAYS RESTORE THE ACTIVE PROJECT. After
workspace(action: activate)to a different project, you MUSTworkspace(action: activate)back to the original before finishing your task. The MCP server is shared state — forgetting to return silently breaks all subsequent tool calls for the parent conversation.
Cross-project navigation patterns
Two patterns are now documented and enforced via anti-pattern guidance:
| Need | Pattern |
|---|---|
| Quick lookup (1–3 calls) | Pass project: "<id>" on the tool call — no state change, no risk |
| Sustained exploration | workspace(action: activate, path: "<other>") → work → workspace(action: activate) back |
Subagents are especially risky — they share the MCP server instance with their parent
conversation. A subagent that calls workspace(action: activate) and exits without restoring leaves the
parent’s subsequent tool calls operating against the wrong project root, with no error.
workspace(action: activate) response hint
When switching away from the home project, the workspace(action: activate) response now includes a
reminder to restore:
Active project: other-project (/path/to/other)
⚠ You switched away from home-project. Remember to workspace(action: activate) back when done.
Workspace system prompt
build_system_prompt_draft (the generated per-project system prompt) now includes a
cross-project navigation section when the workspace has more than one project registered.
This ensures the guidance is present in project-specific contexts, not just the global
server instructions.
PostCompact Hook — LSP Cache Flush
After Claude Code compacts the context window, cached LSP symbol positions can become stale: the LSP server still holds the old line numbers from before compaction, and the next navigation call may resolve to the wrong location.
This feature adds a two-part fix:
Server side: workspace(action: status, post_compact: true)
workspace(action: status) accepts a new boolean parameter post_compact. When true,
all active LSP clients are shut down immediately and the call returns early:
{ "flushed": true, "hint": "LSP position caches cleared. Clients restart automatically on the next navigation call..." }
LSP clients restart lazily on the next symbol_at or references call — there is no manual restart step and no disruption to
the session. The normal status fields (project_root, languages, etc.) are
not included in the flush response.
Plugin side: PostCompact hook
The companion plugin (codescout-companion) adds a PostCompact hook that
fires automatically after every context compaction. Because hooks run outside
the MCP transport and cannot call tools directly, the hook injects an
additionalContext directive:
codescout PostCompact: context was compacted.
→ Call workspace(action: status, post_compact: true) as your FIRST action to flush stale LSP position caches.
LSP clients restart lazily — no disruption to the session.
The agent sees this as the first message of the new turn and calls
workspace(action: status, post_compact: true) before any navigation work.
When is this useful?
Long coding sessions where the context is compacted mid-task — especially when
there are open LSP-backed files (Rust, TypeScript, Kotlin) and the agent
immediately needs accurate symbol_at results after compaction.
Upgrade path
Requires:
- codescout ≥ 0.4.1 (server-side
post_compactparameter) - codescout-companion plugin with the
PostCompacthook registered inhooks/hooks.json
Cross-Process Write Serialization
When multiple codescout MCP server instances run against the same project directory simultaneously (e.g. two IDE windows, two Claude Code sessions, or a background indexer alongside an active session), write-tool calls are now serialized so they cannot corrupt each other.
How It Works
Two lock layers protect each write:
- In-process mutex —
tokio::sync::Mutex<()>perActiveProject. Acquired first. Serializes concurrent write-tool calls within a single codescout process. - Cross-process flock —
fs4advisory lock on.codescout/write.lock. Acquired second. Serializes writes across all codescout instances on the same project.
The lock is held for the full duration of the tool call and released on drop (WriteGuard RAII).
Behavior on Contention
If a write-tool call cannot acquire the cross-process lock within the timeout, it returns a RecoverableError (MCP isError: false). The agent receives a message:
another codescout instance is writing to this project
Retry in a moment — the holder should release shortly.
Because isError is false, sibling tool calls in the same MCP batch continue normally.
Configuration
Set write_lock_timeout_secs in .codescout/config.toml under [security]:
[security]
write_lock_timeout_secs = 10 # default: 5
Covered Write Tools
The following tools acquire the write lock: create_file, edit_file, edit_markdown, edit_code, and memory write actions (write, remember, forget, delete, refresh_anchors).
Read tools (read_file, symbols, symbols, etc.) skip the lock entirely — no overhead on reads.
Lock File
The lock file lives at .codescout/write.lock inside the project root. It is created automatically on workspace(action: activate) and is gitignored.
Routing (codescout-companion Plugin)
Why It Exists
The MCP server delivers 28 tools and a detailed server_instructions block that
tells the AI when to use each one. In a single-agent session this works well —
the main agent receives the instructions and generally follows them.
The problem is subagents.
Every time Claude Code spawns a subagent (a code reviewer, a parallel worker, a
debugging agent), that subagent starts with a blank slate. It has the MCP tools
available, but it has never seen the server_instructions. Its default instinct
is to reach for the native tools it was trained on: Read, Grep, Glob,
Bash cat. Without intervention it will happily read whole files, grep walls of
text, and never touch symbols or semantic_search.
The codescout-companion plugin exists to close that gap. It injects guidance into every
agent and every subagent, and hard-blocks the native tool patterns that
codescout is designed to replace.
How It Integrates with the MCP Server
The plugin is intentionally tightly coupled to codescout:
- It reads codescout’s SQLite embeddings DB to check index staleness and surface drift warnings at session start
- It calls the codescout CLI binary to trigger background reindexing when the index is behind HEAD
- It injects the
.codescout/system-prompt.mdfile — generated by theonboarding()tool — verbatim into every agent’s context. This file contains project-specific navigation hints, entry points, and memory pointers thatonboarding()tailored to the codebase.
The plugin should be updated whenever codescout adds features that affect exploration workflows.
Hooks
| Hook | Event | What it does |
|---|---|---|
session-start.sh | SessionStart | Injects tool guide, memory hints, drift warnings, and onboarding nudge into the main agent |
subagent-guidance.sh | SubagentStart | Injects compact tool-use directive + system-prompt.md into every subagent |
pre-tool-guard.sh | PreToolUse on Read/Grep/Glob/Bash | Hard-blocks Read/Grep/Glob and Bash cat/grep/head/tail/sed -i on source files; redirects to codescout equivalents |
worktree-activate.sh | PostToolUse on EnterWorktree | Symlinks .codescout/ into the new worktree and injects workspace(action: activate) guidance |
Why Hard Blocks, Not Soft Warnings
The plugin went through a soft-warning phase (v1.1–v1.4): a PostToolUse hook
would fire after a Read or Grep call and append a message suggesting
codescout alternatives. This did not work.
By the time the PostToolUse hook fires, the tool output is already in context.
The AI has the file contents or grep results it was looking for — the warning is
noise it ignores, and the damage (token waste, context pollution) is already
done.
Switching to PreToolUse hard blocks (v1.5) fixed this. The block fires
before the tool executes. No output lands in context; the AI is forced to
re-plan with a codescout tool instead. This is the approach that actually
changes behaviour.
The Subagent Coverage Problem
SubagentStart guidance (v1.5.3) always fires, even when
.codescout/system-prompt.md doesn’t exist. Before this fix, the hook
silently exited when the file was absent — leaving subagents (code reviewers,
design agents, parallel workers) with no guidance at all. The result was that
subagent behaviour was completely inconsistent: the main agent used codescout
correctly, subagents fell back to native tools immediately.
The fix was simple: always inject a minimal tool-use directive into subagents, regardless of whether a project-specific system prompt exists.
Auto-Reindex and Drift Warnings
At SessionStart, the plugin checks whether the semantic index is behind HEAD:
- Queries the embeddings DB for the last-indexed commit
- Compares against
git rev-parse HEAD - If stale, triggers
codescout index --project .in the background (non-blocking) - If high-drift files are detected (files where meaning changed significantly since last index), surfaces a warning in the session context
This means the index is usually up to date by the time the AI needs it, without
requiring a manual index(action: build) call.
Installation
/plugin marketplace add mareurs/sdd-misc-plugins
/plugin install codescout-companion@sdd-misc-plugins
See the Routing Plugin setup guide for configuration options.
Further Reading
- Routing Plugin Setup Guide — installation steps, configuration options, and verification
- Superpowers Workflow — the Superpowers plugin that pairs
with
codescout-companionfor full lifecycle development
Superpowers Workflow
What Is Superpowers
Superpowers is a Claude Code plugin that wraps the full development lifecycle in composable skills that trigger automatically. Rather than jumping straight into code, Claude steps back, clarifies requirements, writes a spec, produces a detailed implementation plan, then executes it task by task via subagents — each with two-stage review (spec compliance, then code quality).
The core skill chain:
- brainstorming — refines rough ideas, explores alternatives, validates design
- using-git-worktrees — creates an isolated workspace on a new branch
- writing-plans — breaks the design into 2–5 minute tasks with exact file paths and verification steps
- subagent-driven-development / executing-plans — dispatches fresh subagents per task, or works in human-checkpoint batches
- finishing-a-development-branch — merges, creates a PR, or discards; cleans up the worktree
The Manual Worktree Workflow
For large implementation plans — or when working on two or three separate issues
on the same repo simultaneously — the safest approach is to skip EnterWorktree
entirely and do it manually:
# create the worktree on a new branch
git worktree add .worktrees/my-feature my-feature
# cd into it and launch Claude from there
cd .worktrees/my-feature
claude
Claude starts with its CWD already set to the worktree. The MCP server needs one
workspace(action: activate) call to follow:
workspace(action: activate, path: "/abs/path/to/.worktrees/my-feature")
After that, every read, write, and shell command targets the worktree
automatically — no EnterWorktree inside the session, no risk of writing to the
main repo, no mid-session project-switch to forget.
Why this beats EnterWorktree inside a running session:
- The session CWD matches the project root from the start — there’s no window where writes could land in the wrong tree
- You can run multiple independent Claude sessions in parallel, each in its own worktree, each working a different branch of the same repo
- The terminal tab itself is the isolation boundary — clear, auditable, easy to kill
Running Parallel Sessions
When a plan is large and there are also 2–3 smaller issues to knock out at the same time, parallel worktrees let you do all of it concurrently:
# terminal 1 — big feature
git worktree add .worktrees/big-feature big-feature
cd .worktrees/big-feature && claude
# terminal 2 — hotfix
git worktree add .worktrees/hotfix-auth hotfix-auth
cd .worktrees/hotfix-auth && claude
# terminal 3 — docs update
git worktree add .worktrees/docs-update docs-update
cd .worktrees/docs-update && claude
Each session is fully isolated. They share the object store (fast) and can see each other’s commits once pushed, but their working trees never interfere.
The Prune Bug in finishing-a-development-branch
When the finishing-a-development-branch skill cleans up, it runs:
git worktree remove <worktree-path>
This works when Claude was launched from the main repo and entered the worktree
via EnterWorktree. It fails when you launched Claude from inside the
worktree — because git worktree remove refuses to remove a worktree whose path
is the current working directory of any running process.
The symptom: the skill completes the merge or PR, then hangs or errors on
cleanup, leaving a stale worktree entry in .git/worktrees/.
The correct cleanup command when CWD is inside the worktree:
# from the main repo — NOT from inside the worktree
git -C /abs/path/to/main-repo worktree prune
git worktree prune doesn’t require the directory to exist or to be reachable
as CWD — it just reconciles .git/worktrees/ against what’s actually on disk.
If the worktree directory was already deleted (by the skill or manually), prune
clears the stale entry cleanly. git worktree remove cannot do this.
Practical fix: After a session that was launched from inside a worktree,
if cleanup fails, exit back to the main repo and run git worktree prune
manually. The branch itself may need a separate git branch -d my-feature if
you want it gone entirely.
Further Reading
- Git Worktrees — two-layer protection (write guard + navigation exclusions) that prevents silent cross-worktree edits
- Routing Plugin — how the plugin’s
worktree-activate.shhook auto-callsworkspace(action: activate)whenEnterWorktreefires
Project Configuration
Every project managed by codescout has an optional configuration file at
.codescout/project.toml. The file uses TOML syntax.
File Location and Auto-Creation
The file lives at:
<project-root>/.codescout/project.toml
If the file does not exist when a project is first activated, codescout creates it with sensible defaults derived from the directory name. You can also create it manually before activating a project.
The configuration is loaded by ProjectConfig::load_or_default: if the file is present it is
parsed, otherwise defaults are applied. All sections except [project] are fully optional —
omit any section and its defaults take effect silently.
[project] — General Settings
[project]
name = "my-service"
languages = ["rust", "toml", "markdown"]
encoding = "utf-8"
tool_timeout_secs = 60
| Field | Type | Default | Description |
|---|---|---|---|
name | string | directory basename | Human-readable project name shown in tool output. |
languages | array of strings | [] | Languages detected in the project. Populated automatically by onboarding. You can set this manually if auto-detection is wrong. |
encoding | string | "utf-8" | Character encoding used when reading source files. |
tool_timeout_secs | integer | 60 | Maximum seconds any single tool call may run before it is cancelled. Increase this for very large projects where LSP startup or indexing takes longer. |
Note: languages is populated by the onboarding tool and written back to the config file.
You rarely need to set it by hand.
[embeddings] — Semantic Search Settings
Controls which embedding model is used and how source files are chunked before embedding.
[embeddings]
model = "ollama:mxbai-embed-large"
drift_detection_enabled = true
| Field | Type | Default | Description |
|---|---|---|---|
model | string | "ollama:mxbai-embed-large" | Embedding model. The prefix selects the backend. See Embedding Backends for the full list of supported prefixes and models. |
drift_detection_enabled | bool | true | Enable semantic drift detection during index builds. index(action: build) compares old and new chunk embeddings to score how much each changed file’s meaning shifted. Results queryable via workspace(action: status, threshold=...). Set to false to opt out. Experimental — adds memory overhead proportional to changed-file count. |
Note — chunk size is automatic. codescout derives the chunk budget directly from the model’s published context window using a conservative
max_tokens × 3 chars/tokenformula at 85 % utilisation. There is nochunk_sizeorchunk_overlapsetting — they were removed because manual tuning was error-prone and the model string already encodes everything needed. Existingproject.tomlfiles containing these keys are silently ignored.
Changing the model after indexing: If you change model, you must rebuild the index
(index(action: build) with force: true). codescout detects model mismatches and will warn
rather than return wrong results.
[ignored_paths] — Indexing Exclusions
Glob patterns for directories and files that should be excluded from semantic search indexing,
tree, and file traversal.
[ignored_paths]
patterns = [
".git",
"node_modules",
"target",
"__pycache__",
".venv",
"dist",
"build",
".codescout",
]
| Field | Type | Default | Description |
|---|---|---|---|
patterns | array of strings | (list above) | Path components or glob patterns to skip during traversal. Each pattern is matched against every path segment, not just the full path. |
The default list covers common build artifact and dependency directories. To add your own
exclusions, replace the entire patterns array — there is no “append” mode:
[ignored_paths]
patterns = [
".git",
"node_modules",
"target",
"__pycache__",
".venv",
"dist",
"build",
".codescout",
"vendor",
"generated",
"*.pb.go",
]
[security] — Access Controls
See Security & Permissions for the rationale behind these settings and how the permission model works end-to-end.
Controls what operations the AI agent is permitted to perform. These settings are intentionally conservative by default: shell execution is off, file writes are on, git reads are on.
[security]
denied_read_patterns = []
extra_write_roots = []
shell_command_mode = "warn"
shell_output_limit_bytes = 102400
shell_enabled = false
file_write_enabled = true
indexing_enabled = true
| Field | Type | Default | Description |
|---|---|---|---|
denied_read_patterns | array of strings | [] | Additional path prefixes to block from read_file and other read tools, beyond the built-in deny-list (see below). |
extra_write_roots | array of strings | [] | Additional directories where file write tools are allowed. By default writes are restricted to the project root. |
shell_command_mode | string | "warn" | Controls run_command behaviour. One of "unrestricted", "warn", or "disabled". |
shell_output_limit_bytes | integer | 102400 | Maximum bytes captured from shell command stdout or stderr. Output beyond this limit is truncated and flagged in the response. |
shell_enabled | bool | false | Master switch for shell execution. Must be true for run_command to run any command regardless of shell_command_mode. |
file_write_enabled | bool | true | Enables file write tools: create_file and the symbol write tools. Set to false for a read-only session. |
indexing_enabled | bool | true | Enables index(action: build) and workspace(action: status). Set to false to prevent the agent from kicking off potentially long-running indexing. |
Built-in Read Deny-List
Regardless of denied_read_patterns, codescout always blocks reads from these locations:
~/.ssh
~/.aws
~/.gnupg
~/.config/gcloud
~/.config/gh
~/.docker/config.json
~/.netrc
~/.npmrc
~/.kube/config
On Linux, /etc/shadow and /etc/gshadow are also blocked. On macOS, /etc/master.passwd
is blocked.
Use denied_read_patterns to extend this list with additional secrets or sensitive files
specific to your environment:
[security]
denied_read_patterns = [
"~/.config/my-app/credentials",
"/etc/private",
]
Shell Command Mode
The shell_command_mode field fine-tunes what happens when run_command is called
(assuming shell_enabled = true):
| Value | Behaviour |
|---|---|
"disabled" | All shell calls return an error immediately. |
"warn" | Commands execute; a reminder about full user permissions is appended to the response. This is the default. |
"unrestricted" | Commands execute with no warning added to the output. |
Enabling Shell Execution
Shell execution requires two settings to both be enabled:
[security]
shell_enabled = true
shell_command_mode = "warn" # or "unrestricted"
The two-field design means you can grant shell access (shell_enabled = true) while still
keeping the warning visible (shell_command_mode = "warn"), which is recommended for shared
or CI environments.
Complete Example
A complete project.toml for a Rust service that uses local CPU embeddings and has shell
execution enabled for running tests:
[project]
name = "payment-service"
languages = ["rust", "toml", "sql", "markdown"]
encoding = "utf-8"
tool_timeout_secs = 120
[embeddings]
model = "local:AllMiniLML6V2Q"
drift_detection_enabled = true # set to false to opt out of semantic drift scoring
[ignored_paths]
patterns = [
".git",
"node_modules",
"target",
"__pycache__",
".venv",
"dist",
"build",
".codescout",
"vendor",
"migrations/archive",
]
[security]
denied_read_patterns = ["~/.config/stripe"]
shell_command_mode = "warn"
shell_output_limit_bytes = 204800
shell_enabled = true
file_write_enabled = true
indexing_enabled = true
How Configuration Is Loaded
At startup and whenever workspace(action: activate) is called, codescout:
- Looks for
.codescout/project.tomlin the project root. - If found, parses it. Any section that is missing falls back to its defaults.
- If not found, constructs a default config using the directory name as the project name.
The effective configuration is always visible via the workspace(action: status) tool:
{ "name": "workspace(action: status)", "arguments": {} }
Changes to project.toml take effect the next time the project is activated — either by
restarting the MCP server or by calling workspace(action: activate) again with the same path.
Workspace Configuration
For multi-project repos, create .codescout/workspace.toml alongside
project.toml:
[[project]]
id = "backend"
root = "services/backend"
[[project]]
id = "frontend"
root = "apps/frontend"
depends_on = ["backend"]
Fields
| Field | Required | Description |
|---|---|---|
id | Yes | Unique project identifier, used in project parameter across tools |
root | Yes | Path relative to workspace root |
languages | No | Restrict LSP servers to listed languages |
depends_on | No | Project IDs whose symbols are visible during cross-project navigation |
Each project gets its own LSP servers, memory store, and semantic index. See Multi-Project Workspaces for usage details.
Global Config
A two-layer configuration system that merges a user-level global config with
the per-project .codescout/config.toml, so workspace-wide defaults can be
set once without touching every project.
File locations
| Layer | Path |
|---|---|
| Global | $XDG_CONFIG_HOME/codescout/config.toml (defaults to ~/.config/codescout/config.toml) |
| Project | .codescout/config.toml in the project root |
Project-level values always win. Any key present in the project config overrides the global value for that project.
Supported fields
All fields from the project config are supported in the global config. Common use cases:
# ~/.config/codescout/config.toml
[embeddings]
model = "jinaai/jina-embeddings-v2-base-code"
chunk_size = 1024
[security]
max_index_bytes = 524288000 # 500 MB
write_lock_timeout_secs = 30
Load behaviour
- Missing global config file is silently ignored (not an error).
- Malformed TOML propagates as an error (fail-fast rather than silent misconfiguration).
- File-size guard rejects configs over 64 KB.
HOMEfallback used whenXDG_CONFIG_HOMEis not set.
Merge semantics
Tables are merged key-by-key; scalar values are overridden wholesale. There is
no deep-merge within nested tables — if a project config sets [embeddings],
the entire [embeddings] table from the global config is replaced, not merged
field-by-field.
Embedding Backends
Semantic search requires converting source code into vector embeddings. codescout supports
four backends, selected at runtime by the model field in [embeddings] inside
.codescout/project.toml. The prefix before the colon determines which backend is used.
[embeddings]
model = "ollama:nomic-embed-text" # recommended when Ollama is available
The onboarding tool detects your hardware at setup time and writes the best model for
your machine into this field automatically. You rarely need to set it manually — see
Choosing a Backend if you want to override it.
Backend Comparison
| Backend | Speed | Quality | Cost | Privacy | Setup |
|---|---|---|---|---|---|
| Local (fastembed, default) | Fast (CPU) | Good–Excellent | Free | Fully local | Bundled — no setup needed |
| Ollama | Medium | Good | Free | Local | Install Ollama + pull model |
| OpenAI | Fast (network) | Excellent | Pay-per-token | Data sent to OpenAI | Set OPENAI_API_KEY |
| Custom endpoint | Varies | Varies | Varies | Depends on host | Point at any compatible server |
Recommended Models
onboarding picks the best model for your machine automatically. This table is a reference
for manual overrides or when comparing options.
| Model string | Backend | Dims | Context | Code quality | Notes |
|---|---|---|---|---|---|
local:AllMiniLML6V2Q | fastembed | 384 | 256 tok | Good | Default. 22 MB, zero-config, bundled. |
local:JinaEmbeddingsV2BaseCode | fastembed | 768 | 8192 tok | Excellent | Recommended (CPU-only). Code-specific. |
ollama:nomic-embed-text | Ollama | 768 | 8192 tok | Good | Recommended if Ollama is already running. |
ollama:bge-m3 | Ollama | 1024 | 8192 tok | Excellent | Best Ollama quality; slower, ~1.2 GB. |
openai:text-embedding-3-small | OpenAI | 1536 | — | Excellent | Best quality/cost if cloud is acceptable. |
openai:text-embedding-3-large | OpenAI | 3072 | — | Best | Overkill for most codebases. |
ollama:mxbai-embed-large | Ollama | 1024 | 512 tok | Good | Legacy. Short context truncates most code. |
Switching models requires a full reindex — see Rebuilding After a Model Change below. Scores are not comparable across models; a score of 0.75 means different things with different models.
Ollama
Uses a locally running Ollama daemon. No API key is required.
Model string format: "ollama:<model-name>"
Endpoint: $OLLAMA_HOST/v1/embeddings (default: http://localhost:11434/v1/embeddings)
Setup
-
Install Ollama from ollama.com.
-
Pull the embedding model:
ollama pull mxbai-embed-large -
Make sure the daemon is running:
ollama serve
Configuration
[embeddings]
model = "ollama:mxbai-embed-large"
To use a different Ollama host (e.g. a remote machine or a custom port), set the
OLLAMA_HOST environment variable before starting the MCP server:
export OLLAMA_HOST=http://192.168.1.50:11434
Automatic CPU Fallback
If Ollama is not running when codescout tries to connect, you’ll see a clear error:
Ollama is not reachable at http://localhost:11434
Options:
- Start Ollama:
ollama serve - Switch to bundled ONNX: set
model = "local:AllMiniLML6V2Q"in[embeddings] - Use a different server: set
url = "http://your-server:port/v1"in[embeddings]
Recommended Ollama Models
| Model | Dimensions | Context | Notes |
|---|---|---|---|
nomic-embed-text | 768 | 8192 tok | Recommended default. Fast indexing, 137 MB. |
bge-m3 | 1024 | 8192 tok | Best retrieval quality; ~1.2 GB download. |
mxbai-embed-large | 1024 | 512 tok | Legacy; short context truncates most functions. |
OpenAI
Calls the OpenAI embeddings API. Requires an active OpenAI account and an API key.
Model string format: "openai:<model-name>"
Endpoint: https://api.openai.com/v1/embeddings
Authentication: $OPENAI_API_KEY environment variable (required)
Setup
Set your API key in the environment before starting the MCP server:
export OPENAI_API_KEY=sk-...
Configuration
[embeddings]
model = "openai:text-embedding-3-small"
Recommended OpenAI Models
| Model | Dimensions | Notes |
|---|---|---|
text-embedding-3-small | 1536 | Low cost, good quality, recommended |
text-embedding-3-large | 3072 | Highest quality, higher cost |
text-embedding-ada-002 | 1536 | Legacy model, still widely used |
Custom Endpoint
Points at any OpenAI-compatible embeddings API — useful for self-hosted models, Azure OpenAI, Together AI, or other third-party providers.
Model string format: "custom:<model-name>@<base-url>"
codescout appends /v1/embeddings to <base-url>, so a base URL of
http://localhost:1234 becomes http://localhost:1234/v1/embeddings.
Authentication: $EMBED_API_KEY environment variable (optional — set it if the server
requires a bearer token)
Setup
Start your compatible server, then set the API key if needed:
export EMBED_API_KEY=your-token-here
Configuration
[embeddings]
model = "custom:mxbai-embed-large@http://localhost:1234"
Examples for common providers:
# Azure OpenAI
model = "custom:text-embedding-3-small@https://my-resource.openai.azure.com/openai/deployments/my-deployment"
# Together AI
model = "custom:togethercomputer/m2-bert-80M-8k-retrieval@https://api.together.xyz"
# Hugging Face Text Embeddings Inference (TEI)
model = "custom:BAAI/bge-large-en-v1.5@http://localhost:8080"
Local (fastembed)
Runs entirely on-device using fastembed-rs and ONNX Runtime. No external daemon, no API key, and no network traffic after the initial model download.
Model string format: "local:<EmbeddingModel-variant>"
Requires: building from source with the local-embed Cargo feature. This backend is
not available in the published cargo install codescout binary because ONNX Runtime is
a native system library that cannot be bundled through crates.io.
Looking for free local embeddings without building from source? Use Ollama instead — it is the recommended path for most users.
Model cache: ~/.cache/huggingface/hub/ — downloaded on first use, then fully offline
Build
Clone the repository and build with local embedding support:
git clone https://github.com/mareurs/codescout.git
cd codescout
cargo install --path . --features local-embed
Or, to have both local and remote backends available simultaneously:
cargo install --path . --features remote-embed,local-embed
Configuration
[embeddings]
model = "local:AllMiniLML6V2Q"
Supported Local Models
| Model string | Dimensions | Download size | Notes |
|---|---|---|---|
local:JinaEmbeddingsV2BaseCode | 768 | ~300 MB | Code-specific, highest quality for source code |
local:AllMiniLML6V2Q | 384 | ~22 MB | INT8-quantized, CPU-safe, recommended for most users |
local:BGESmallENV15Q | 384 | ~20 MB | GPU-optimized export; may fail on CPU-only machines |
local:BGESmallENV15 | 384 | ~65 MB | Full f32 precision variant of BGESmallENV15Q |
local:AllMiniLML6V2 | 384 | ~90 MB | Full f32 precision variant of AllMiniLML6V2Q |
For most local setups, AllMiniLML6V2Q gives the best tradeoff: small download, CPU-safe
inference, and solid retrieval quality. Use JinaEmbeddingsV2BaseCode when search quality
on code is the priority and the larger download is acceptable.
Error When Feature Is Missing
If you try to use a local: model without the local-embed feature compiled in, you will
see an error like:
Local embedding requires the 'local-embed' feature.
Rebuild with: cargo build --features local-embed
Batching
All backends send texts in batches of 8. This avoids HTTP 400 errors from servers that have payload size limits (Ollama is particularly strict about this) and keeps per-request latency manageable. The batch size is fixed and not configurable.
Rebuilding After a Model Change
The embedding index stores which model was used to build it. If you change the model field
in project.toml, you must rebuild the index with the force flag to avoid mixing vectors
from different models:
{ "name": "index(action: build)", "arguments": { "force": true } }
codescout will warn if it detects a mismatch between the configured model and the model recorded in the existing index.
Choosing a Backend
In most cases you do not need to choose — onboarding probes your hardware and writes
the recommended model into .codescout/project.toml automatically. The decision tree below
is for manual overrides.
- Default / getting started →
local:AllMiniLML6V2Q— bundled, 22 MB, no setup. Already active out of the box; no config change needed. - Better code search, can build from source →
local:JinaEmbeddingsV2BaseCode(code-specific, 8192-token context, ~300 MB). Build with--features local-embedfrom the repository. Outperforms general-purpose models on code. - Ollama is already running →
ollama:nomic-embed-text(fast, 8192-token context, 137 MB). Upgrade toollama:bge-m3for higher retrieval quality at the cost of a 1.2 GB download. - Best search quality, cloud acceptable →
openai:text-embedding-3-small. - Air-gapped or full data privacy required →
local:JinaEmbeddingsV2BaseCodeorlocal:AllMiniLML6V2Q(already the default — no external calls are made). - Self-hosted TEI, vLLM, or similar → set
url = "http://your-server:port/v1"in[embeddings].
Embeddings
codescout uses embeddings for semantic search — finding code by meaning rather than exact text matches. This guide covers how to configure the embedding backend.
⚠ This page describes the pre-v0.12 single-service embedding model and is being phased out. As of v0.12 the default substrate is the Retrieval Stack (Qdrant + dense embedder + sparse SPLADE + cross-encoder reranker, configured via
CODESCOUT_*environment variables, not[embeddings]inproject.toml). The[embeddings]config block still loads but only themodel = "local:..."path is honoured — and only when the binary was built with thelocal-embedCargo feature.If you are setting up a fresh install: read Retrieval Stack instead. It covers the docker-compose stack, Ollama / llama.cpp / OpenAI integration, and the benchmark we used to pick defaults.
If you are upgrading from <v0.12: the
model/url/api_keyfields inproject.tomlno longer drive search. Runcodescout migrate-memoriesto move legacy memory data into Qdrant, then bring up the stack.The remainder of this page is kept as a reference for the legacy code path; treat it as historical.
Quick Start
codescout works out of the box with a bundled embedding model. No setup needed.
On first index(action: build), it downloads all-MiniLM-L6-v2 (~22 MB, quantized)
to ~/.cache/huggingface/hub/ and runs it locally via ONNX. This is a one-time download.
# .codescout/project.toml (default — no changes needed)
[embeddings]
model = "local:AllMiniLML6V2Q"
This is fine for single-project use or getting started. For better performance with multiple projects, see the next section.
Recommended: External Embedding Server
The bundled model loads into memory per codescout instance. With multiple projects open, this duplicates memory (~22 MB each for the default model). A dedicated embedding server avoids this:
- One process serves all codescout instances
- No memory duplication — the model loads once
- Faster queries — the model stays warm
- Model freedom — use any model and quantization
Configuration
Point codescout at your server with two fields:
[embeddings]
model = "nomic-embed-text-v1.5" # model name (sent in API request)
url = "http://127.0.0.1:43300/v1" # your server's base URL
# api_key = "optional-key" # or set EMBED_API_KEY env var
The url field works with any server implementing the OpenAI /v1/embeddings API.
codescout normalizes the URL automatically — all of these are equivalent:
http://127.0.0.1:43300http://127.0.0.1:43300/v1http://127.0.0.1:43300/v1/embeddings
Setup Examples
llama.cpp
Download a GGUF model and start the server:
# Download (example: nomic-embed-text quantized)
wget https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q8_0.gguf
# Start server
llama-server -m nomic-embed-text-v1.5.Q8_0.gguf --embeddings --port 43300
[embeddings]
model = "nomic-embed-text-v1.5"
url = "http://127.0.0.1:43300/v1"
Ollama
ollama pull nomic-embed-text
ollama serve # if not already running
[embeddings]
model = "nomic-embed-text"
url = "http://127.0.0.1:11434/v1"
vLLM
vllm serve nomic-ai/nomic-embed-text-v1.5 --task embed --port 43300
[embeddings]
model = "nomic-embed-text-v1.5"
url = "http://127.0.0.1:43300/v1"
TEI (HuggingFace Text Embeddings Inference)
docker run -p 43300:80 ghcr.io/huggingface/text-embeddings-inference \
--model-id nomic-ai/nomic-embed-text-v1.5
[embeddings]
model = "nomic-embed-text-v1.5"
url = "http://127.0.0.1:43300/v1"
OpenAI
[embeddings]
model = "text-embedding-3-small"
url = "https://api.openai.com/v1"
api_key = "sk-..." # or set EMBED_API_KEY env var
Configuration Reference
[embeddings] fields
| Field | Type | Default | Description |
|---|---|---|---|
model | string | "local:AllMiniLML6V2Q" | Model name. With url: sent in API body. Without url: prefix determines backend. |
url | string | (none) | Base URL for any OpenAI-compatible /v1/embeddings endpoint. |
api_key | string | (none) | API key sent as Bearer token. Also available via EMBED_API_KEY env var. |
drift_detection_enabled | bool | true | Track how much code meaning changes between index builds. |
Resolution Order
When codescout needs to embed text, it resolves the backend in this order:
urlis set → use it as an OpenAI-compatible endpointmodelstarts withlocal:→ bundled ONNX model via fastembedmodelstarts withollama:→ Ollama API (deprecated — useurlinstead)modelstarts withopenai:→ OpenAI API withOPENAI_API_KEY- No
url, no prefix → try as a local model name, then error with suggestions
Environment Variables
| Variable | Description |
|---|---|
EMBED_API_KEY | API key for the embedding endpoint (alternative to config field) |
OPENAI_API_KEY | OpenAI API key (used with openai: prefix) |
OLLAMA_HOST | Ollama daemon URL (deprecated — use url field) |
Model Recommendations
Minimum recommended: 768 dimensions for good code search quality.
| Model | Dims | Download | Context | Best For |
|---|---|---|---|---|
| nomic-embed-text-v1.5 | 768 | ~158 MB (Q) / ~547 MB | 8192 | General purpose, good quality |
| jina-embeddings-v2-base-en | 768 | ~300 MB | 8192 | Code-specialized |
| bge-m3 | 1024 | ~1.2 GB | 8192 | Best quality, needs external server |
| CodeSage-small-v2 | 1024 | ~500 MB | — | Purpose-built for code retrieval |
| text-embedding-3-small | 1536 | API only | 8191 | OpenAI hosted, no self-hosting |
Bundled Local Models
These work with the local: prefix (no server needed):
| Model ID | Dims | Size | Context | Notes |
|---|---|---|---|---|
NomicEmbedTextV15Q | 768 | ~158 MB | 8192 | General purpose, good quality |
NomicEmbedTextV15 | 768 | ~547 MB | 8192 | Full precision variant |
JinaEmbeddingsV2BaseCode | 768 | ~300 MB | 8192 | Code-specialized |
AllMiniLML6V2Q | 384 | ~22 MB | 256 | Default — bundled, zero-config |
AllMiniLML6V2 | 384 | ~90 MB | 256 | Full precision lightweight |
How It Works
-
AST-aware chunking — tree-sitter extracts top-level definitions (functions, classes, structs). Each chunk is a complete semantic unit, not an arbitrary text window.
-
Chunk size auto-derived — codescout calculates chunk size from the model’s context window. No manual tuning needed.
-
Vector storage — embeddings are upserted into Qdrant’s
code_chunkscollection over gRPC (defaultlocalhost:6334). Both a dense and a sparse vector are stored per chunk; query-time hybrid search fuses them via RRF inside Qdrant. See Hybrid Dense + Sparse Retrieval for the topology. -
Bundled model lifecycle — when using the
local:prefix (compile-timelocal-embedfeature), the ONNX model is loaded lazily on firstsemantic_searchorindex(action="build"), cached for 5 minutes, then unloaded to free memory. The default substrate is the HTTP dense embedder service, not the bundled ONNX path.
Choosing a Model
Not sure which model to use? See the Embedding Model Comparison for benchmark results across three models, real-world usage data, and recommendations.
TL;DR: The default (local:AllMiniLML6V2Q) is within 2 points of the best model on a
60-point benchmark, indexes 21x faster, and requires zero setup. Keep it unless you have
a specific reason to change.
Troubleshooting
Model mismatch after changing config
If you change the model or url after indexing, the stored vectors are incompatible.
Rebuild the index:
index(action: build, force: true)
Endpoint unreachable
Check that the server is running and the URL is correct:
curl http://127.0.0.1:43300/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"model":"nomic-embed-text","input":["test"]}'
Corporate proxy blocking downloads
The bundled model downloads from HuggingFace. If your proxy blocks this:
- Download the model on an unrestricted machine
- Copy to
~/.cache/huggingface/hub/models--nomic-ai--nomic-embed-text-v1.5/ - Or use an external server instead (set
url)
Migration from Prefix Syntax
The ollama: prefix is deprecated and will be removed in a future version.
Migrate to the url field:
# Before (deprecated)
[embeddings]
model = "ollama:nomic-embed-text"
# After
[embeddings]
model = "nomic-embed-text"
url = "http://localhost:11434/v1"
The custom: prefix has been removed. Migrate to the url field:
# Before (removed)
[embeddings]
model = "custom:my-model@http://my-server:8080"
# After
[embeddings]
model = "my-model"
url = "http://my-server:8080/v1"
Embedding Model Comparison
Which embedding model should you use with codescout? This page summarizes benchmark results and real-world usage data to help you choose.
This comparison is based on the codescout codebase (417 files, ~32K chunks) as of 2026-04-03. Results may vary with different codebases. We will update this page as we collect more real-world data.
Models Tested
| Model | Dims | Context | Size | Backend | Setup |
|---|---|---|---|---|---|
local:AllMiniLML6V2Q | 384 | 256 tok | 22 MB | Bundled ONNX (CPU) | None — works out of the box |
nomic-embed-text | 768 | 8,192 tok | 274 MB | Ollama | ollama pull nomic-embed-text |
nomic-embed-code (Q4_K_M) | 3584 | 32,768 tok | 4.1 GB | llama.cpp (GPU) | Download GGUF + start server |
Benchmark Results
We tested 20 queries across 4 complexity tiers, scoring each 0-3 based on whether the expected source files appeared in the top 10 results.
Overall Scores (max 60)
nomic-embed-code ████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 36/60
AllMiniLML6V2Q ██████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 34/60
nomic-embed-text ████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 32/60
By Complexity Tier
| Tier | What it tests | Best model | Score |
|---|---|---|---|
| 1. Direct Concept (5 queries) | Single named type, module, or feature | nomic-embed-text | 12/15 |
| 2. Two-Concept (7 queries) | Relationship between two concepts | nomic-embed-code | 17/21 |
| 3. Cross-Cutting (5 queries) | Three+ concepts, architectural flows | nomic-embed-code | 7/15 |
| 4. Architectural (3 queries) | Design invariants, consistency patterns | AllMiniLML6V2Q | 5/9 |
No single model dominates all tiers.
Practical Metrics
| Metric | AllMiniLML6V2Q | nomic-embed-text | nomic-embed-code |
|---|---|---|---|
| Index time (417 files) | 70 seconds | 60 seconds | 25 minutes |
| DB size | 71 MB | 55 MB | 372 MB |
| Chunk count | 32,098 | 11,887 | 11,868 |
| Requires | Nothing | Ollama running | GPU + llama.cpp server |
How Agents Actually Use Semantic Search
Analysis of 31,674 tool calls across 70+ real projects:
symbols— 17.8% of all calls (the workhorse)grep— 2.3%semantic_search— 1.1% (349 calls total)
Agents use semantic search as a last resort — when they don’t know the exact name of what they’re looking for. The typical query is a short 3-6 word concept phrase:
"error handling and recovery from tool failures"
"embedding index build and incremental update"
"security path validation and write access control"
"ollama embedding configuration"
"intent classifier ONNX model prediction"
These are mostly Tier 1-2 queries (direct concept or two-concept composition). Tier 3-4 queries (complex architectural questions) are rare in organic usage.
Recommendation
Use the default: local:AllMiniLML6V2Q.
| Factor | Why the default wins |
|---|---|
| Score | 34/60 — within 2 points of the best model (36/60) |
| Speed | 70 seconds vs 25 minutes — 21x faster indexing |
| Setup | Zero. No Ollama, no GPU, no server to manage |
| Storage | 71 MB — reasonable for any machine |
| Precision | Best at Tier 4 (finding specific functions and patterns) — matches how agents actually query |
The 7B code-specialized model’s 2-point advantage doesn’t justify 21x slower indexing, 5x more storage, and a GPU requirement. For the 1.1% of calls that reach semantic search, the bundled model is good enough.
When to consider alternatives
-
nomic-embed-text via Ollama — if Ollama is already running for other tasks, add
url = "http://localhost:11434/v1"andmodel = "nomic-embed-text"for slightly better Tier 1 results at the same speed. Smallest storage footprint (55 MB). -
nomic-embed-code via llama.cpp — if you have a GPU and primarily use semantic search for concept-level exploration (architecture questions, onboarding to a new codebase). Best at Tier 2-3 queries.
Methodology
Full benchmark details, per-query scores, and test case definitions are in
docs/research/2026-04-03-embedding-model-benchmark.md.
The benchmark will be updated as we collect more real-world query data via the --debug
flag’s usage traceability feature (see Debug Mode).
Language Support
codescout provides three tiers of support depending on which backends are available for a given language.
- Full support — LSP server + tree-sitter grammar. All symbol tools work, and richer AST extraction is available internally. Semantic search is also available after indexing.
- LSP only — LSP server configured, no tree-sitter grammar. Symbol navigation, references, and rename work.
- Detection only — Language is recognized for chunking and file detection. No LSP server and no tree-sitter grammar. Only file operations and semantic search (after indexing) are available.
Supported Languages
| Language | Extensions | LSP Server | Support Level |
|---|---|---|---|
| Bash | .sh, .bash | bash-language-server | Full |
| Go | .go | gopls | Full |
| Java | .java | jdtls | Full |
| Kotlin | .kt, .kts | kotlin-lsp (JetBrains) | Full |
| Python | .py | pyright-langserver | Full |
| Rust | .rs | rust-analyzer | Full |
| TypeScript | .ts | typescript-language-server | Full |
| TSX | .tsx | typescript-language-server | Full |
| JavaScript | .js | typescript-language-server | LSP only |
| JSX | .jsx | typescript-language-server | LSP only |
| C | .c | clangd | LSP only |
| C++ | .cpp, .cc, .cxx | clangd | LSP only |
| C# | .cs | OmniSharp | LSP only |
| Ruby | .rb | solargraph | LSP only |
| HTML | .html, .htm | vscode-html-language-server | LSP only |
| CSS | .css | vscode-css-language-server | LSP only |
| SCSS | .scss | vscode-css-language-server | LSP only |
| Less | .less | vscode-css-language-server | LSP only |
Detection-Only Languages
These languages are recognized for chunking and file detection. No LSP server is configured and no tree-sitter grammar is bundled.
| Language | Extensions |
|---|---|
| PHP | .php |
| Swift | .swift |
| Scala | .scala |
| Elixir | .ex, .exs |
| Haskell | .hs |
| Lua | .lua |
| Markdown | .md |
Feature Matrix
| Feature | Full support | LSP only | Detection only |
|---|---|---|---|
symbols | Yes | Yes | No |
references | Yes | Yes | No |
symbol_at | Yes | Yes | No |
call_graph | Yes | Yes | No |
edit_code | Yes | Yes | No |
semantic_search | Yes | Yes | Yes |
| File tools | Yes | Yes | Yes |
Installing LSP Servers
codescout looks for each LSP server binary on PATH. The quickest way to
get started is the bundled install script:
# See what's installed and what's missing
./scripts/install-lsp.sh --check
# Install all supported LSP servers
./scripts/install-lsp.sh --all
# Install specific languages only
./scripts/install-lsp.sh rust python typescript go
The script supports Linux and macOS, detects your package managers, and skips servers that are already installed. For manual installation, see the per-language instructions below.
Rust
rustup component add rust-analyzer
Binary: rust-analyzer
Python
npm install -g pyright
Binary: pyright-langserver, invoked with --stdio.
TypeScript, JavaScript, TSX, JSX
npm install -g typescript-language-server typescript
Binary: typescript-language-server, invoked with --stdio. One installation
covers TypeScript, JavaScript, TSX, and JSX.
Go
go install golang.org/x/tools/gopls@latest
Binary: gopls. Ensure $(go env GOPATH)/bin is on PATH.
Java
jdtls (Eclipse JDT Language Server) is distributed as a standalone archive
from the Eclipse downloads page. Unpack
and place the launcher script on PATH.
Binary: jdtls
Kotlin
kotlin-lsp (JetBrains) is distributed as a release archive on the
GitHub releases page.
Unpack and place the kotlin-lsp script on PATH.
Binary: kotlin-lsp, invoked with --stdio. Each codescout instance
automatically passes --system-path to isolate its workspace cache.
C and C++
clangd is shipped with LLVM/Clang. Install via your system package manager:
# Debian/Ubuntu
sudo apt install clangd
# macOS
brew install llvm # or: xcode-select --install
# Fedora/RHEL
sudo dnf install clang-tools-extra
Binary: clangd. One installation covers both C and C++.
C#
OmniSharp is bundled with the .NET SDK or available as a standalone binary.
The standalone build can be downloaded from the
OmniSharp releases page.
Place the binary on PATH.
Binary: OmniSharp (note the capital O), invoked with -lsp.
Ruby
gem install solargraph
Binary: solargraph, invoked with stdio (no leading --).
HTML and CSS
npm install -g vscode-langservers-extracted
One package installs both servers. Binaries: vscode-html-language-server (HTML)
and vscode-css-language-server (CSS, SCSS, Less), each invoked with --stdio.
Bash
npm install -g bash-language-server
Binary: bash-language-server, invoked with start (positional argument — not --stdio).
Known Quirks
jdtls requires a data/workspace directory for project indexes. Some wrapper
scripts accept --data to specify this path. If symbol tools return empty
results, check whether jdtls started correctly by examining the server log.
The JAVA_HOME environment variable should point to a JDK 17+ installation.
OmniSharp binary name starts with a capital O (OmniSharp, not
omnisharp). On case-sensitive filesystems the name must match exactly. Some
distributions ship a lowercase alias; check with which OmniSharp before
assuming the server is unavailable.
solargraph takes a positional argument (stdio) rather than a flag
(--stdio). This differs from most other servers. The invocation is
solargraph stdio, not solargraph --stdio.
kotlin-lsp (JetBrains) has a single workspace session limitation: only one kotlin-lsp process can serve a given project directory at a time. If another codescout instance or editor is already running kotlin-lsp for the same project, new instances fail with “Multiple editing sessions for one workspace are not supported yet”. codescout detects this and fails fast with a clear error. Workaround: close the other session first, or use a single codescout instance for Kotlin projects. JetBrains plans to lift this restriction in a future release.
kotlin-lsp also builds a project index on first startup (JVM bootstrap + Gradle import), which takes 8–15 seconds. codescout retries the LSP handshake during this window automatically.
typescript-language-server handles JavaScript and JSX in addition to
TypeScript and TSX. The LSP languageId sent for TSX files is
typescriptreact and for JSX files is javascriptreact — this is handled
internally and requires no configuration.
Bash language support
What it does
Bash and shell scripts (.sh, .bash) now receive full language support:
symbol extraction via tree-sitter and LSP navigation via bash-language-server.
Previously Bash was detection-only — file contents were indexed for semantic search but no symbols were extracted and no LSP server was started.
Capabilities
| Capability | Available |
|---|---|
| Semantic search indexing | ✅ |
symbols / symbols | ✅ (function definitions) |
symbol_at / references | ✅ (requires LSP) |
Setup
Install bash-language-server:
npm install -g bash-language-server
No project configuration needed — codescout detects .sh / .bash files and
starts the server automatically.
Known limits
bash-language-serveris invoked withstart(positional argument), not--stdio— the invocation differs from most other LSP servers.- Symbol extraction covers
function_definitionnodes only. Variable declarations and sourced scripts are not indexed as symbols. - Large generated shell scripts may produce many small symbol chunks in semantic search results.
Kotlin LSP Multiplexer
Problem
Multiple codescout instances targeting the same Kotlin project cause severe degradation. JetBrains’ kotlin-lsp allows only one LSP process per workspace, and two instances compete for Gradle daemon locks, consuming 3-4GB RAM with duplicate project models and causing 120s+ timeouts.
Solution
codescout now runs a detached multiplexer process (codescout mux) that
manages a single kotlin-lsp instance and allows multiple codescout sessions to
share it via a Unix socket.
┌─────────────┐ ┌─────────────┐
│ codescout-A │ │ codescout-B │
└──────┬───────┘ └──────┬───────┘
│ Unix socket │
└────────────┬───────┘
│
┌─────────▼──────────┐
│ codescout mux │
└─────────┬──────────┘
│ stdio
┌─────────▼──────────┐
│ kotlin-lsp │
│ (single JVM) │
└────────────────────┘
Activation
The multiplexer is automatic — no configuration required. When codescout
detects that a project uses Kotlin, it starts or connects to a mux process
transparently. No flags, no project.toml changes.
The codescout mux sub-command runs the mux process directly (for debugging),
but in normal use it is spawned by codescout itself.
JVM Pre-warming
When a project declares java or kotlin in its language list, codescout spawns background LSP get_or_start tasks immediately on server startup and on every workspace(action: activate) call.
# .codescout/project.toml
[project]
languages = ["kotlin"] # also triggers for "java"
Pre-warming eliminates the 8–15 s cold-start penalty that would otherwise occur on the first symbol query after startup. The warm-up runs in the background — server startup and workspace(action: activate) return immediately without waiting for the LSP to be ready.
Concurrency safety: LspManager’s watch-channel serialises parallel starters. Calling workspace(action: activate) from concurrent sessions cannot trigger duplicate LSP processes.
The multiplexer handles the rest of the connection lifecycle — see How It Works below.
How It Works
- First codescout instance needing Kotlin LSP acquires an exclusive file
lock (
flock), spawnscodescout mux, and connects as a client. - Subsequent instances find the lock held, skip spawning, and connect directly to the existing mux socket.
- The mux handles ID remapping (so two clients can both send request ID 1 without collision), document state dedup (didOpen/didClose tracked per client), and version rewriting (monotonic per-URI versions for didChange).
- When all clients disconnect, the mux stays alive for 5 minutes (idle timeout), then shuts down kotlin-lsp and exits.
Ownership & Crash Recovery
The mux process holds an exclusive flock on a lock file for its entire
lifetime. If it dies — even via SIGKILL or OOM — the OS releases the lock.
The next codescout instance detects the stale lock, cleans up, and spawns a
fresh mux. No PID files, no heartbeats, no race conditions.
Gradle Isolation
Independently of the mux, kotlin-lsp now runs with an isolated
GRADLE_USER_HOME to prevent Gradle daemon cache lock contention between
instances.
Benefits
| Metric | Before (2 instances) | After (2 instances) |
|---|---|---|
| kotlin-lsp JVMs | 2 (~3-4GB total) | 1 (~2GB) |
| Gradle daemons | 2 (competing) | 1 (shared) |
| Cold start on 2nd session | 8-15s | 0s (mux already warm) |
| Typical LSP response | 120s+ timeout | 30-270ms |
Limitations
- Unix only — uses Unix domain sockets. Windows support (named pipes) is planned but not yet implemented.
- Kotlin only — other languages use direct LSP connections. The
muxflag inLspServerConfigmakes it easy to opt in additional languages (e.g., jdtls for Java) in the future. - Concurrent file edits — if two clients edit the same file simultaneously, the document state in kotlin-lsp may desync. This is inherent to LSP’s single-client design and is acceptable since two agents editing the same file is already a bug.
- Rename serialization —
workspace/applyEdit(used by rename) is routed to the client that initiated the rename. Concurrent renames from different clients are serialized through an edit lock.
Diagnostics
The mux spawn/connect is visible in codescout diagnostic logs:
INFO codescout::lsp::manager: mux process ready for kotlin at "/tmp/codescout-kotlin-mux-<hash>.sock"
INFO codescout::lsp::manager: mux already running for kotlin, connecting to "/tmp/codescout-kotlin-mux-<hash>.sock"
The mux process itself logs to .codescout/mux-kotlin-<hash>.log (or /tmp/
if .codescout/ does not exist in the workspace).
Design
Full design spec: docs/superpowers/specs/2026-03-24-kotlin-lsp-multiplexer-design.md
Rust LSP multiplexer
What it does
When two codescout instances open the same Rust project, they now share a
single rust-analyzer process via the existing LSP multiplexer (first used
for kotlin-lsp). This eliminates the stale-hover / stale-goto bug that
appeared after a write in instance A was not reflected in instance B.
Footprint
- One
rust-analyzerper(project-root)across allcodescoutinstances on the machine. - Idle-shutdown after 180 seconds with no connected clients.
- Memory saved: one full
rust-analyzer(2–4 GB on a medium Cargo workspace) per extracodescoutinstance.
Opt out
Add to .codescout/project.toml:
[lsp.rust]
mux = false
Then /mcp restart. Codescout will fall back to spawning a dedicated
rust-analyzer per instance, as before.
Known limits
- Unix only (the mux is
#[cfg(unix)]). rust-analyzermust be onPATH.- If two clients connect before
rust-analyzercompletes initialization, the second client waits on a 5-retry / 1-second backoff. No-op behaviourally; you may see a brief startup delay under heavy concurrency.
Tools Overview
codescout exposes 20 tools organized into seven categories. This page is a quick map. Each category has a dedicated reference page linked from the headings below.
Symbol Navigation
LSP-backed tools for locating and editing code by name rather than by line number. These tools require an LSP server to be running for the target language.
The navigation tools (symbols, references) accept an optional scope parameter to search library code as well as project code — see Library Navigation below.
| Tool | Description |
|---|---|
symbols | Find symbols by name pattern across the project or within a file; also provides a symbol tree for a file, directory, or glob |
symbol_at | Inspect a symbol at a position via LSP — definition location and/or hover (type + docs); auto-discovers libraries |
references | All callers and usages of a given symbol |
call_graph | Transitive call graph for a symbol — callers, callees, or both |
edit_code | Mutate a symbol: action="replace" rewrites a body, action="insert" injects adjacent code, action="remove" deletes a symbol, action="rename" renames across the codebase via LSP |
File Operations
Read, list, and search files. These tools work on any file regardless of language support.
| Tool | Description |
|---|---|
read_file | Read lines from a file, with optional range and pagination |
read_markdown | Read a Markdown file with heading-based navigation |
tree | List files and directories, optionally recursive; also finds files by glob pattern, respecting .gitignore |
grep | Search file contents with a regex pattern |
create_file | Create or overwrite a file with given content |
edit_file | Find-and-replace editing within a file |
edit_markdown | Edit a Markdown document by heading |
Semantic Search
Find code by meaning rather than by name or pattern. Requires an embedding
index built with index(action: build) — see the Setup Guide. Use the optional scope parameter to search
within a specific library (see Library Navigation).
| Tool | Description |
|---|---|
semantic_search | Search code by natural language description or code snippet |
index | Build or incrementally update the embedding index (action: build) or show index stats (action: status) |
Library Navigation
Navigate third-party dependency source code (read-only). Libraries are
auto-registered when LSP symbol_at returns a path outside the project
root; you can also register them manually.
| Tool | Description |
|---|---|
library | Show all registered libraries and their index status (action: list), or register a new library (action: register) |
Scope parameter — once a library is registered, pass scope to any
navigation or search tool to target it:
| Value | What it searches |
|---|---|
"project" (default) | Only project source code |
"lib:<name>" | A specific registered library |
"libraries" | All registered libraries |
"all" | Project + all libraries |
All results include a "source" field ("project" or "lib:<name>") so you
can tell where each result came from.
Memory
Persistent key-value store backed by markdown files in
.codescout/memories/. Survives across sessions.
| Tool | Description |
|---|---|
memory | Read, write, list, or delete memory entries via the action param |
Workflow & Config
Project setup, shell execution, and server configuration.
| Tool | Description |
|---|---|
onboarding | Initial project discovery: detect languages, read key files, write startup memory |
run_command | Run a shell command in the project root and return stdout/stderr |
approve_write | Grant write access to a directory outside the project root for this session |
workspace | Switch the active project (action: activate), display project state (action: status), or list all projects (action: list_projects) |
Which Tool Do I Use?
Use this table when you know what you want but are not sure which tool to reach for.
| You want to… | Use this |
|---|---|
| See what functions/classes a file contains | symbols |
| Find where a function is defined | symbols |
| Jump to a symbol’s definition | symbol_at with fields: ["def"] |
| Get type info or docs for a symbol | symbol_at with fields: ["hover"] |
| Find all callers of a function | references |
| Rewrite a function body | edit_code(action="replace") |
| Add a new function next to an existing one | edit_code(action="insert") |
| Delete a symbol entirely | edit_code(action="remove") |
| Rename a function everywhere | edit_code(action="rename") |
| Find code that does something (concept, not name) | semantic_search |
| Find code by concept inside a library | semantic_search with scope: "lib:<name>" (after index(action: build) on the library) |
| See what third-party libraries are registered | library(action: list) |
| Check index health, file count, drift scores | index(action: status) |
| Check project config and usage stats | workspace(action: status) |
| Search for a string or regex across files | grep |
| Find files matching a name pattern | tree (with glob) |
| Read a specific part of a file | read_file (with start_line/end_line) |
| Remember a decision for the next session | memory with action: "write" |
| Run a build or test command | run_command |
| Orient yourself in a new project | onboarding |
Choosing Between Symbol Navigation and Semantic Search
Use symbol navigation (symbols, symbols) when you know
the name of what you are looking for. LSP tools are precise and fast.
Use semantic search when you know the concept but not the name: “retry logic”, “token validation”, “connection pool initialization”. Semantic search finds code that means what you describe, regardless of what it is called.
Choosing Between symbols and symbols
symbols answers “what is in this file or directory?” — it gives
you the map. symbols answers “where is this specific thing?” — it finds
a target by name, optionally across the whole project. Start with
symbols to orient, then use symbols to drill in.
Choosing Between LSP Editing and Direct Editing
edit_code operates on named symbols via its action parameter (replace, insert, remove, rename).
It does not care about line numbers and is robust to changes above the target.
Use it when you know the symbol name.
edit_file operates on text via exact string matching. Use it for changes that are not naturally
symbol-scoped: adding an import, changing a constant value, patching a
configuration block.
Tool API Redesign: Naming & Query-Shape Detection
Two improvements to the core tool API: consistent parameter naming across all
symbol and search tools, and automatic detection of regex intent in
symbols to prevent confusing errors.
API Naming Changes
Parameter names have been standardized across symbol and search tools. Old names are removed — update any saved prompts or scripts.
Parameter renames
| Tool | Old param | New param |
|---|---|---|
symbols | name_path | symbol |
symbols | pattern | query |
references | name_path | symbol |
symbol_at | name_path | symbol |
replace_symbol | name_path | symbol |
remove_symbol | name_path | symbol |
rename_symbol | name_path | symbol |
semantic_search | project | project_id |
memory | project | project_id |
Tool renames
| Old name | New name |
|---|---|
search_pattern | grep |
find_file | glob |
The renamed tools (grep, glob) are shorter and match the mental model
agents already have for these operations.
Query-Shape Detection in symbols
symbols now detects when a query looks like a regex pattern and
returns a corrective hint instead of silently returning wrong results.
Problem it solves
Agents occasionally pass regex patterns to symbols expecting it to
match multiple symbols — but symbols does substring matching on symbol
names, not regex. A query like handle_.*_event matches nothing (or
coincidentally matches a symbol with .* in its name), giving a misleading
empty result.
Behavior
If the query contains regex metacharacters (.*, .+, ^, $, \w,
\d, |, (...)) it is flagged as regex-like and a RecoverableError
is returned:
{
"error": "query looks like a regex pattern — use grep(pattern=...) for regex search",
"hint": "symbols matches by substring; grep searches file content by pattern"
}
When it fires
| Query | Detected as | Action |
|---|---|---|
handle_event | plain substring | normal symbol search |
handle_.*_event | regex-like | RecoverableError → redirect to grep |
^MyStruct$ | regex-like | RecoverableError → redirect to grep |
foo|bar | regex-like | RecoverableError → redirect to grep |
Correct tool for each intent
// Find a symbol by name substring
symbols(query="handle_event")
// Find code matching a pattern across files
grep(pattern="handle_.*_event")
Symbol Navigation
These eight tools give you IDE-grade code navigation backed by the Language Server Protocol. They understand code structure — symbols, definitions, references, types — not just bytes and lines.
Supported languages: Rust, Python, TypeScript, JavaScript, TSX, JSX, Go, Java, Kotlin, C, C++, C#, Ruby.
All symbol navigation tools require an LSP server for the target language. If the LSP server is not running or is still indexing, some tools fall back to tree-sitter for basic results.
Scope parameter: symbols, symbols, and references accept an optional scope string to search library code as well as project code. See Library Navigation for the full scope reference.
Workspace project scoping
In a multi-project workspace, pass
project to scope operations to a specific project:
{ "tool": "symbols", "arguments": { "pattern": "UserService", "project": "backend" } }
scope and project are independent axes: scope selects project vs library
code, project selects which project in the workspace. Omitting project
uses the workspace-level context.
See also: Tool Selection — when to reach for symbol tools vs semantic search vs text search. Progressive Disclosure — how
detail_levelcontrols output volume for these tools.
symbols
Purpose: Return the symbol tree (functions, classes, methods, structs, etc.) for a file, directory, or glob pattern. Use this to orient yourself before reading or editing.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
relative_path | string | no | project root | File, directory, or glob pattern (e.g. src/**/*.rs) |
depth | integer | no | 1 | Depth of children to include (0 = top-level names only, 1 = direct children) |
detail_level | string | no | exploring | "full" activates focused mode with symbol bodies |
offset | integer | no | 0 | Skip this many files (focused mode pagination) |
limit | integer | no | 50 | Max files per page |
Example — outline a single file:
{
"tool": "symbols",
"arguments": {
"relative_path": "src/auth/middleware.rs"
}
}
Output (exploring mode):
{
"file": "src/auth/middleware.rs",
"symbols": [
{ "name": "AuthMiddleware", "kind": "Struct", "start_line": 12, "end_line": 18 },
{ "name": "new", "kind": "Function", "start_line": 21, "end_line": 28 },
{ "name": "handle", "kind": "Function", "start_line": 31, "end_line": 74 },
{ "name": "verify_token", "kind": "Function", "start_line": 77, "end_line": 102 }
]
}
Output (focused mode — detail_level: "full"):
Same structure, but each symbol includes a "body" field with the source lines
from start_line to end_line.
Example — overview of a directory:
{
"tool": "symbols",
"arguments": {
"relative_path": "src/handlers/"
}
}
Returns one entry per file in the directory, each with its symbol list. At the
project root (.), walks the entire source tree recursively.
Example — glob across multiple files:
{
"tool": "symbols",
"arguments": {
"relative_path": "src/**/*.py",
"depth": 0
}
}
With depth: 0, returns only top-level symbol names, which is useful for a
very high-level map without the per-method detail.
Tips:
- Use this before reading or editing. Two tokens spent on the map saves dozens spent re-reading the wrong file.
- When the result overflows, the response includes an
overflowobject with a hint. Narrow with a more specific path or glob. - For deep class hierarchies, increase
depthto 2 or 3. - Use
detail_level: "full"only after you have identified the specific file you need — fetching bodies for an entire directory is expensive.
symbols
Purpose: Find symbols by name pattern across the project or within a specific file. Returns matching symbols with their location and, optionally, their source body.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
pattern | string | yes | — | Symbol name or substring (case-insensitive) |
relative_path | string | no | — | Restrict to this file or glob pattern |
include_body | boolean | no | false | Include source body in results |
depth | integer | no | 0 | Depth of children to include |
detail_level | string | no | exploring | "full" for bodies and pagination |
offset | integer | no | 0 | Skip this many results |
limit | integer | no | 50 | Max results per page |
Example — find a function anywhere in the project:
{
"tool": "symbols",
"arguments": {
"pattern": "authenticate_user"
}
}
Output (exploring mode):
{
"symbols": [
{
"name": "authenticate_user",
"name_path": "AuthService/authenticate_user",
"kind": "Function",
"file": "src/auth/service.rs",
"start_line": 44,
"end_line": 89
}
],
"total": 1
}
Example — find with body, restricted to one file:
{
"tool": "symbols",
"arguments": {
"pattern": "authenticate_user",
"relative_path": "src/auth/service.rs",
"include_body": true,
"detail_level": "full"
}
}
Output (focused mode with body):
{
"symbols": [
{
"name": "authenticate_user",
"name_path": "AuthService/authenticate_user",
"kind": "Function",
"file": "src/auth/service.rs",
"start_line": 44,
"end_line": 89,
"body": "pub fn authenticate_user(&self, credentials: Credentials) -> Result<Session> {\n let user = self.user_store.find_by_email(&credentials.email)?;\n ..."
}
],
"total": 1
}
Example — find all test functions in test files:
{
"tool": "symbols",
"arguments": {
"pattern": "test_",
"relative_path": "tests/**/*.rs"
}
}
Tips:
- Pattern matching is case-insensitive substring matching.
"auth"matchesAuthService,authenticate_user, andreauth_token. - Without
relative_path, usesworkspace/symbol(one LSP request per language), which is fast. Withrelative_path, uses per-file document symbols, which is slower but scoped. name_pathin the result uses/as a separator for nested symbols, e.g.AuthService/authenticate_user. You need this value forreferences,edit_code, and related editing tools.- Use
include_body: truein the same call to avoid a separate read step when you already know the symbol name.
references
Purpose: Find all locations in the codebase that reference (call, use, import) a given symbol. This is the “find all usages” feature from your IDE.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
name_path | string | yes | — | Symbol identifier, e.g. "MyStruct/my_method" |
relative_path | string | yes | — | File that contains the symbol definition |
detail_level | string | no | exploring | "full" for pagination |
offset | integer | no | 0 | Skip this many results |
limit | integer | no | 50 | Max results per page |
Example — find all callers of a method:
{
"tool": "references",
"arguments": {
"name_path": "AuthService/authenticate_user",
"relative_path": "src/auth/service.rs"
}
}
Output (exploring mode):
{
"references": [
{
"file": "src/handlers/login.rs",
"line": 38,
"column": 18,
"context": " let session = self.auth.authenticate_user(credentials)?;"
},
{
"file": "src/handlers/api.rs",
"line": 204,
"column": 22,
"context": " auth_service.authenticate_user(req.credentials.clone())?;"
},
{
"file": "tests/auth_integration.rs",
"line": 91,
"column": 12,
"context": " let result = service.authenticate_user(bad_creds);"
}
],
"total": 3
}
Example — paginate a high-reference symbol:
{
"tool": "references",
"arguments": {
"name_path": "Logger/log",
"relative_path": "src/logging.rs",
"detail_level": "full",
"offset": 0,
"limit": 25
}
}
Tips:
- Both
name_pathandrelative_pathare required. The LSP needs to locate the symbol’s definition position before it can find references. name_pathmust match thename_pathvalue fromsymbolsorsymbolsoutput, not just the bare name. For a top-level function, the name_path is just the function name (e.g."validate_token"). For a method, it is"StructName/method_name".- Each reference includes a
contextline showing the source at that location, so you can often determine the call pattern without reading the file. - For symbols with many references (e.g. utility functions, common types),
use
detail_level: "full"withoffset/limitpagination.
replace_symbol
Renamed in v0.11. The standalone
replace_symboltool was consolidated into the unifiededit_codetool. Useedit_code(action="replace", symbol, path, body)instead — see edit_code for parameters, examples, and the full action set (replace/insert/remove/rename).
insert_code
Renamed in v0.11. Consolidated into
edit_code. Useedit_code(action="insert", symbol, path, body, position="before"|"after")instead — see edit_code.
rename_symbol
Renamed in v0.11. Consolidated into
edit_code. Useedit_code(action="rename", symbol, path, new_name)instead — see edit_code. The implementation still goes through LSPworkspace/renameand sweeps textual occurrences in comments and strings.
remove_symbol
Renamed in v0.11. Consolidated into
edit_code. Useedit_code(action="remove", symbol, path)instead — see edit_code.
symbol_at
Purpose: Inspect a symbol at a given position via LSP. Returns the symbol’s
definition location(s) (def) and/or type information + doc comments (hover).
Pass the fields parameter to choose which queries to run; the default runs
both. When a definition lives outside the project root, the library is
auto-discovered and registered in library(action: list).
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | File path (relative or absolute) |
line | integer | yes | — | 1-indexed line number |
col | integer | no | — | 1-indexed column. Preferred when known — LSP-native, no identifier-mismatch risk |
identifier | string | no | — | Optional identifier on the line to target (fallback when col not known) |
fields | array of strings | no | ["def", "hover"] | Which LSP queries to run. Allowed values: "def", "hover" |
Example — get both definition and hover info for a symbol on line 42:
{
"tool": "symbol_at",
"arguments": {
"path": "src/tools/symbol.rs",
"line": 42
}
}
Output:
{
"def": {
"definitions": [
{
"file": "src/lsp/symbols.rs",
"line": 12,
"end_line": 28,
"context": "pub struct SymbolInfo {"
}
],
"from": "symbol.rs:42"
},
"hover": {
"content": "pub struct SymbolInfo\n\nMetadata about a symbol returned by the LSP.",
"location": "symbol.rs:42"
}
}
When the definition is in a library (outside the project root), each entry in
def.definitions carries a source tag (e.g. "lib:serde") and the library
is added to library(action: list).
Example — hover only:
{
"tool": "symbol_at",
"arguments": {
"path": "src/tools/symbol.rs",
"line": 55,
"fields": ["hover"]
}
}
Tips:
- Use
symbol_atto quickly locate where a type, trait, or function is defined and to see its type signature in one round-trip. - Pass
fields: ["def"]when you only need the location, orfields: ["hover"]when you only need the type signature — saves an LSP round-trip. - Supply
col(1-indexed) when known for LSP-native targeting. Fall back toidentifierto locate by name on the line; prefercolto avoid identifier-mismatch errors. - When the definition is in an external library, run
index(action: build, scope: "lib:<name>")to enablesemantic_searchacross it. - For hover, if
contentis null, the language server has no information at that position — try adjustingline/color supplyingidentifier.
symbols progressive directory overview
What it does
When symbols is called on a directory (rather than a single file), it
now selects one of three output modes based on how many files are in the tree,
rather than always attempting a full symbol dump that overflows for large
directories.
The three modes
| Mode | Triggered when | Shows |
|---|---|---|
full_tree | ≤ 15 files | All symbols in all files — same as before |
class_overview | 16–80 files | Class / struct / type names only, one line per file |
directory_map | > 80 files | Subdirectory listing with file counts |
The response includes a mode field so agents know which level of detail they
received, and a hint with the recommended next step (e.g. drill down with
symbols('<subdir>')).
Forcing a mode
Use force_mode to override the adaptive selection:
{ "path": "src/", "force_mode": "class_overview" }
Accepted values: "full_tree", "class_overview", "directory_map".
Why this matters
Previously, calling symbols("src/") on a large project returned a
truncated dump with no structure. The new modes give agents a useful
coarse-to-fine navigation path: start at directory_map, pick a subdirectory,
drill down to class_overview, then open specific files with symbols.
Known limits
class_overviewrequires tree-sitter support for the language; files in detection-only languages are listed by path only.- Thresholds (15 / 80) are fixed constants — no per-project override yet.
call_graph
Transitive call graph for a symbol. Two directions:
callers(default): who calls this symbol, transitively. Use for blast-radius before refactoring.callees: what does this symbol call, transitively. Use to trace flow.both: both, returned in separate keys.
Schema
{ "symbol": "Agent::new", "direction": "callers", "max_depth": 3 }
Output
By default returns a compact summary: counts + by_file + by_depth. When the
total result has ≤ 30 edges, auto-promotes to full edge list. Use
detail_level: "full" to force full output on large graphs.
Each edge is tagged source: "lsp" (from callHierarchy, semantically
authoritative) or "ts" (from the tree-sitter classifier fallback —
best-effort, may include false positives on macros, shadowed names, or dynamic
dispatch).
Caching
Edges are cached in the project sqlite DB (call_edges table). Caches are
invalidated per file on did_change notifications (triggered by write tools),
so the cache stays correct across multi-session edits.
Known limitations
- Kotlin LSP coverage of
callHierarchyis partial; expectsource: "ts"edges for most Kotlin queries. - Cross-project edges are not supported in v1.
direction="callees"via tree-sitter fallback is not supported; aRecoverableErroris returned for languages where LSPcallHierarchyis unavailable. Seedocs/TODO-tool-misbehaviors.md(LIMIT-001).- Arrow function names in TypeScript/JavaScript show as
<anonymous>in tree-sitter fallback mode.
File Operations
These tools cover reading, listing, searching, and locating files. They are read-only — no file is modified. For writing and editing, see Editing.
All paths are relative to the active project root unless you supply an absolute path. Reads are subject to the project’s security deny-list (e.g. SSH keys and credential files are blocked by default).
See also: Output Buffers — how large file reads are stored as
@file_idrefs rather than dumped into context.
read_file
Purpose: Read the contents of a file, optionally restricted to a line range.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | File path relative to project root |
start_line | integer | no | — | First line to return (1-indexed) |
end_line | integer | no | — | Last line to return (1-indexed, inclusive) |
Example — read an entire file:
{
"path": "src/main.rs"
}
Output:
{
"content": "fn main() {\n println!(\"Hello\");\n}\n",
"total_lines": 3
}
Example — read a specific range:
{
"path": "src/main.rs",
"start_line": 10,
"end_line": 25
}
When you supply both start_line and end_line, the tool returns exactly those lines with no overflow cap applied. When neither is supplied and the file exceeds 200 lines, only the first 200 lines are returned and an overflow field tells you how to retrieve the rest:
{
"content": "... first 200 lines ...",
"total_lines": 850,
"overflow": {
"shown": 200,
"total": 850,
"hint": "File has 850 lines. Use start_line/end_line to read specific ranges"
}
}
Tips:
- Use
symbolsorsymbolsto locate the line range of a function before callingread_file— this lets you fetch exactly what you need without reading the whole file. - For large files, prefer reading in chunks with explicit
start_line/end_lineover reading the whole file. - If you want to search for a pattern rather than read, use
grepinstead.
Source-range gate
When start_line / end_line is supplied on a source file (.rs, .ts, .py, and other languages codescout recognises), the tool checks whether the requested range overlaps a named symbol body. If it does, the request is blocked and the error names the overlapping symbol:
Error: range 45–72 overlaps symbol `MyStruct/validate` — use symbols(name='validate', include_body=true) instead
This steers you toward symbols(include_body=true), which is robust to line-number shifts caused by later edits.
Bypass: Add "force": true to skip the gate and return the raw content regardless:
{ "path": "src/model.rs", "start_line": 45, "end_line": 72, "force": true }
Scope: Only recognised source files are gated. Config files, Markdown, TOML, JSON, and other non-code formats are never affected.
tree
Purpose: List files and directories under a path. Pass recursive=true for a full tree.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | Directory path relative to project root |
recursive | boolean | no | false | Descend into subdirectories |
detail_level | string | no | compact | "full" to show all entries without the exploring-mode cap |
offset | integer | no | 0 | Skip this many entries (focused-mode pagination) |
limit | integer | no | 50 | Max entries per page in focused mode |
Example — shallow listing:
{
"path": "src"
}
Output:
{
"entries": [
"/home/user/project/src/main.rs",
"/home/user/project/src/lib.rs",
"/home/user/project/src/tools/"
]
}
Directories are suffixed with /. In exploring mode the output is capped at 200 entries; if the directory has more, an overflow field appears with guidance.
Example — full recursive tree:
{
"path": "src",
"recursive": true
}
Hidden files and paths matched by .gitignore are excluded automatically.
Tips:
- Start with a shallow listing to understand the top-level structure, then drill into subdirectories of interest.
- Use
recursive=trueonly when you need the full tree. On large repositories a recursive walk can produce many entries; narrow it with a more specificpathif you hit the overflow cap. - To find files by name pattern use
tree(with glob) instead — it supports glob patterns and is faster for targeted searches.
grep
Purpose: Search the codebase for a regex pattern. Returns matching lines with file path and line number.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
pattern | string | yes | — | Regular expression to search for |
path | string | no | project root | Directory to restrict the search to |
max_results | integer | no | 50 | Maximum number of matching lines to return |
Example:
{
"pattern": "fn\\s+validate_\\w+",
"path": "src",
"max_results": 20
}
Output:
{
"matches": [
{
"file": "/home/user/project/src/util/path_security.rs",
"line": 14,
"content": "pub fn validate_read_path("
},
{
"file": "/home/user/project/src/util/path_security.rs",
"line": 38,
"content": "pub fn validate_write_path("
}
],
"total": 2
}
The search walks the directory tree using the same .gitignore-aware walker as tree. Binary files that cannot be decoded as UTF-8 are silently skipped. The regex engine enforces size limits to prevent pathological patterns from hanging.
Tips:
- Use
pathto narrow the search when you already know which part of the codebase is relevant — this is significantly faster on large repos. - Increase
max_resultsif you expect many matches and need to see them all. - When you know a symbol name,
symbolsis more precise than a regex search because it uses the LSP index. Usegrepwhen you are looking for text patterns, string literals, comments, or constructs that the LSP does not model as symbols. - To find files by name (not content), use
tree(with glob).
tree (with glob)
Purpose: Find files matching a glob pattern. Respects .gitignore.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
pattern | string | yes | — | Glob pattern (e.g. **/*.rs, src/**/mod.rs) |
path | string | no | project root | Directory to search within |
max_results | integer | no | 100 | Maximum number of file paths to return |
Example:
{
"pattern": "**/*.toml",
"path": "."
}
Output:
{
"files": [
"/home/user/project/Cargo.toml",
"/home/user/project/.codescout/project.toml"
],
"total": 2
}
Example — find all test files in a subdirectory:
{
"pattern": "**/test_*.py",
"path": "tests"
}
The glob is matched against the path relative to the search directory, so **/*.rs will match files at any depth. The walker respects .gitignore, so build artifacts, vendored dependencies, and other ignored paths are excluded.
Tips:
- Prefer
tree(with glob) overtreewhen you are looking for files by name — the glob match is more expressive than scanning a directory tree manually. - Use
grepwhen you need to find files by their contents rather than their names. - The
**wildcard matches across directory boundaries. Use it for language-wide searches like**/*.rsor to locate files with a specific name anywhere in the tree:**/Makefile.
create_file
Purpose: Create a new file or overwrite an existing file with given content.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | File path relative to project root |
content | string | yes | — | Full file content to write |
Example:
{
"tool": "create_file",
"arguments": {
"path": "src/utils/helpers.rs",
"content": "pub fn clamp(v: f64, min: f64, max: f64) -> f64 {\n v.max(min).min(max)\n}\n"
}
}
Output: "ok"
Tips:
- Creates parent directories if they don’t exist.
- Overwrites without warning — check that the path is correct before writing.
- For editing existing files, use
edit_fileinstead.
See Editing for more usage guidance.
edit_file
Purpose: Find-and-replace editing within an existing file. Matches an exact string and replaces it — whitespace-sensitive.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | File path relative to project root |
old_string | string | yes | — | Exact text to find (must match including whitespace) |
new_string | string | yes | — | Replacement text |
replace_all | boolean | no | false | Replace every occurrence instead of just the first |
insert | string | no | — | "prepend" or "append" — add text at the start/end of the file |
Example — change an import:
{
"tool": "edit_file",
"arguments": {
"path": "src/main.rs",
"old_string": "use crate::utils::old_helper;",
"new_string": "use crate::utils::new_helper;"
}
}
Output: "ok"
Tips:
old_stringmust match exactly — including indentation and line endings.- Use for imports, constants, config values, and small literal changes.
- For changes to a function or struct body, prefer
edit_code(action="replace")— it’s robust to line number shifts.
See Editing for full parameter details and more examples.
grep: Literal Fallback
When grep receives a pattern that fails regex compilation,
it now checks whether the input looks like intended regex syntax before
returning an error.
How It Works
If the pattern fails to compile and does not look like intentional
regex (no alternation |, wildcards .*/.+, anchors ^/$,
escape sequences \w/\d/\b, or balanced grouping (...)), the
tool falls back to a literal text search by escaping all
metacharacters automatically.
// User types this — unescaped `(` makes it invalid regex,
// but there is no closing `)` so it looks like plain text:
pattern: "if (x > 0"
// codescout escapes it to: \Qif \(x > 0\E (effectively)
// and searches for the literal string
The response includes two extra fields to signal the fallback:
{
"matches": [...],
"total": 2,
"mode": "literal_fallback",
"reason": "pattern was not valid regex — searched as literal text"
}
The compact format output is prefixed with [literal fallback] so the
mode is visible at a glance.
When the Error Is Preserved
If the broken pattern contains regex-like syntax — alternation (foo|bar),
quantified wildcards (fn.*call), escape sequences (\w+), etc. — the
original RecoverableError is returned. This avoids silently
misinterpreting a malformed regex as a literal string.
// "(foo|bar" — has alternation, so is_regex_like = true
// → RecoverableError: "invalid regex: unclosed group"
pattern: "(foo|bar"
Compact Output
[literal fallback] 3 matches
src/lib.rs:42 if (x > 0) {
src/lib.rs:87 if (x > 0) {
src/lib.rs:103 if (x > 0) {
Editing
codescout provides two categories of editing tools:
- Text-level editing —
edit_filefinds and replaces an exact string in a file. - Symbol-level editing —
edit_codemutates named code symbols located via the LSP (action:replace,insert,remove,rename). This is the preferred tool for editing source code.
All write operations are restricted to the active project root. Attempts to write outside the project root, or to paths on the security deny-list, are rejected with an error.
See also: Git Worktrees — the worktree write guard that protects against silent edits to the wrong repository tree.
When to use which editing tool
| Situation | Recommended tool |
|---|---|
| Rewrite a function or method body | edit_code(action="replace") |
| Add a new function next to an existing one | edit_code(action="insert") |
| Delete a function, struct, or method | edit_code(action="remove") |
| Rename a symbol everywhere it is used | edit_code(action="rename") |
| Change a string, constant, or small code fragment | edit_file |
| Edit a config file, Markdown, or other non-code file | edit_file |
| Create a new file | create_file |
For source code, prefer edit_code. It addresses code by name rather than by
position, which means your edit remains correct even if the file was modified
since you last read it. Fall back to edit_file when you need to change
something small that is not naturally symbol-scoped.
create_file
Purpose: Create a new file, or completely overwrite an existing one, with the supplied content. Parent directories are created automatically.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | Destination path, relative to project root |
content | string | yes | — | Full content to write |
Example:
{
"path": "src/util/helpers.rs",
"content": "pub fn clamp(v: i32, lo: i32, hi: i32) -> i32 {\n v.max(lo).min(hi)\n}\n"
}
Output:
{
"status": "ok",
"path": "/home/user/project/src/util/helpers.rs",
"bytes": 58
}
Tips:
- Use this tool when you are generating a file from scratch. If the file already exists and you only need to change part of it, use
edit_fileor a symbol tool instead — those tools are less likely to accidentally discard content you did not intend to touch. - The path is resolved relative to the active project root, so
src/util/helpers.rsand./src/util/helpers.rsboth work. Absolute paths that fall inside the project root are also accepted.
edit_file
Purpose: Find-and-replace editing. Locates old_string in the file and replaces it with new_string. Works on any file type. Alternatively, prepend or append text without a match string.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | yes | — | File to edit, relative to project root |
old_string | string | yes* | — | Exact text to find, including whitespace and indentation. *Not required when using insert. |
new_string | string | yes | — | Replacement text. Set to "" to delete the match. |
replace_all | boolean | no | false | Replace every occurrence instead of requiring a unique match. |
insert | string | no | — | "prepend" or "append" — add text at the start or end of the file without a match string. |
Example — change a constant value:
{
"path": "src/config.rs",
"old_string": " pub const MAX_RETRIES: u32 = 3;",
"new_string": " pub const MAX_RETRIES: u32 = 5;"
}
Example — add an import at the top of a file:
{
"path": "src/lib.rs",
"insert": "prepend",
"new_string": "use std::collections::HashMap;\n"
}
Example — delete a block of lines:
{
"path": "src/config.rs",
"old_string": " // TODO: remove this\n legacy_init();\n",
"new_string": ""
}
Example — replace all occurrences of a deprecated API:
{
"path": "src/util.rs",
"old_string": "old_function()",
"new_string": "new_function()",
"replace_all": true
}
Output:
"ok"
If old_string is not found, or appears multiple times without replace_all: true, the tool returns a recoverable error with the line numbers of all matches.
Multi-line edits on source files: When old_string spans multiple lines and contains a
definition keyword (fn, class, def, etc.) in an LSP-supported language, the tool
blocks the edit and returns a RecoverableError suggesting the correct symbol tool.
There is no bypass — use edit_code (with the appropriate action) instead.
See Structural Edit Gate for the full keyword table and
gate logic.
Tips:
old_stringmust match exactly — include any leading whitespace and indentation.- Use
grepfirst to verify the exact text if you are unsure what to match. - If you get a multiple-matches error, expand
old_stringto include enough surrounding context to make it unique, or usereplace_all: true. - Use
insert: "prepend"orinsert: "append"to add content at the start or end of a file when there is no anchor string to match. - For adding a completely new top-level definition adjacent to an existing one,
edit_code(action="insert")is more convenient — it addresses the location by symbol name rather than requiring an exact text match.
Symbol editing — edit_code
The standalone replace_symbol, insert_code, rename_symbol, and
remove_symbol tools were consolidated in v0.11 into the single
action-dispatched edit_code tool. It addresses code by symbol name
via the LSP and works with any language that has an LSP server configured.
See Symbol Navigation for background on how
symbols are identified.
For full parameter reference, examples for each action, and the
edit_code vs edit_file decision table, see the canonical page:
Quick mapping of the four actions:
| Action | Purpose |
|---|---|
replace | Overwrite the body of a named symbol |
insert | Inject code before or after a named symbol (position: "before"|"after") |
remove | Delete a named symbol and its full body |
rename | Rename a symbol across the codebase via LSP workspace/rename |
All four require symbol (e.g. "MyStruct/my_method") and path (file
relative to project root).
edit_code
Mutate a symbol in the codebase. Four actions: replace, insert, remove, rename.
Consolidates the older individual tools (replace_symbol, insert_code, rename_symbol, remove_symbol) into a single action-dispatched tool.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
symbol | string | yes | Symbol identifier — plain name (my_fn) or hierarchical (MyStruct/my_method) |
path | string | yes | File containing the symbol |
action | string | yes | One of replace, insert, remove, rename |
body | string | action-dependent | replace: new full body; insert: code to inject |
position | string | no | insert only — "before" or "after" the symbol (default "after") |
new_name | string | rename only | New identifier for the symbol |
Actions
replace
Overwrites the symbol’s body with new content. The declaration line is preserved — only the body between the braces changes.
{
"symbol": "MyStruct/validate",
"path": "src/model.rs",
"action": "replace",
"body": " fn validate(&self) -> bool {\n !self.name.is_empty()\n }"
}
On success, returns a hint listing callers if any were found — use it to verify that the new body is compatible.
insert
Injects code adjacent to a symbol. "after" (default) places the new code immediately below the symbol; "before" places it above. Use this to add a sibling method or a helper next to an existing definition.
{
"symbol": "MyStruct/validate",
"path": "src/model.rs",
"action": "insert",
"body": " fn is_empty(&self) -> bool {\n self.name.is_empty()\n }",
"position": "after"
}
remove
Deletes the symbol and its full body from the file.
{
"symbol": "MyStruct/deprecated_helper",
"path": "src/model.rs",
"action": "remove"
}
rename
Renames the symbol across the entire codebase via LSP workspace/rename. Follows references through type aliases, trait implementations, and macro invocations. Also sweeps textual occurrences in comments and string literals.
{
"symbol": "process_payload",
"path": "src/handler.rs",
"action": "rename",
"new_name": "handle_payload"
}
On success, reports how many files were changed and hints at verifying call sites.
When to use edit_code vs edit_file
| Scenario | Tool |
|---|---|
| Change a function or method body | edit_code(action="replace") |
| Add a sibling method or definition | edit_code(action="insert") |
| Delete a function, struct, or method | edit_code(action="remove") |
| Rename a symbol project-wide | edit_code(action="rename") |
Change an import or use line | edit_file |
| Change a constant value | edit_file |
| Edit a config or data file | edit_file |
edit_code uses LSP for symbol resolution and is robust to line number shifts. edit_file is a plain text find-and-replace — use it for lines that are not part of a symbol body.
Hard Gate for Structural Edits in edit_file
edit_file now refuses multi-line edits that contain definition keywords on
LSP-supported languages. Previously this was a soft warning that could be bypassed with
acknowledge_risk: true; the bypass has been removed.
What triggers the gate
All four conditions must be true for the block to fire:
- The edit spans multiple lines (single-line edits always pass through)
- The target file is a source file (not markdown, TOML, JSON, etc.)
- The language has LSP support in codescout (Rust, Python, Go, TypeScript/JS, Java, Kotlin, C/C++, C#, Ruby)
- The
old_stringcontains a language-specific definition keyword
The keywords are per-language — fn only blocks Rust edits, def only Python, func only
Go, and so on. This prevents false positives from comments and string literals that happen to
contain a keyword from another language.
Language keyword table
| Language | Blocked keywords |
|---|---|
| Rust | fn, async fn, struct, impl, trait, enum |
| Python | def, async def, class |
| Go | func, struct, interface |
| TypeScript / JS | function, async function, class, interface, enum |
| Java | class, interface, enum |
| Kotlin | fun, class, interface, enum |
| C / C++ | struct, class, enum |
| C# | class, struct, interface, enum |
| Ruby | def, class |
Non-LSP languages (Lua, Bash, PHP, etc.) and all single-line edits pass through freely regardless of content.
What the error looks like
When the gate fires, edit_file returns a RecoverableError (not a fatal tool error) with
a message like:
edit_file blocked: old_string contains a Rust definition keyword ("fn ").
Structural edits must use symbol tools — the LSP knows the exact range.
Use: edit_code(symbol, path, action="replace", body=...) — replaces the symbol body via LSP
The hint is inferred from the edit shape:
| Edit shape | Suggested tool |
|---|---|
new_string is empty | edit_code(action="remove") |
new_string is longer than old_string | edit_code(action="insert") |
| Replacing a function/struct body | edit_code(action="replace") |
Why no bypass?
The previous pending_ack + acknowledge_risk mechanism was removed entirely. The rationale:
edit_file on a function body is always wrong for LSP-supported languages — the symbol tools
are LSP-range-aware and will never corrupt the file with off-by-one line numbers or
whitespace drift. There is no valid use case for bypassing this gate; if you find one, it
points to a missing symbol tool, not a reason to use string matching on source.
Document Section Editing
Structured markdown operations: read, edit, and manage document sections by heading instead of line numbers or string matching.
Overview
Seven features built on a shared heading-parsing foundation:
| Feature | Tool | Purpose |
|---|---|---|
edit_section | New tool | Replace, insert, or remove entire sections by heading |
headings=[] | read_file param | Read multiple sections in one call |
heading= | edit_file param | Scope string matching to a section |
edits=[] | edit_file param | Atomic batch edits, optionally heading-scoped |
mode="complete" | read_file param | Full plan file inline with delivery receipt |
| Fuzzy heading matching | All heading params | Strips formatting, prefix/substring fallback |
| Section coverage | Automatic | Tracks which sections you’ve read, hints on writes |
Recommended Workflow
| Step | Tool | Purpose |
|---|---|---|
| 1 | read_file(path) | Get heading map — see all sections |
| 2 | read_file(path, headings=[...]) | Read target sections (one call) |
| 3a | edit_section(path, heading, action, content) | Whole-section: replace, insert, remove |
| 3b | edit_file(path, heading=, old_string, new_string) | Surgical: scoped string replacement |
| 3c | edit_file(path, edits=[...]) | Batch: multiple edits, atomic |
edit_section
Purpose: Whole-section operations on markdown files — replace content, insert new sections, or remove existing ones. Addresses sections by heading, not line numbers.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | File path relative to project root |
heading | string | yes | Section heading to target (e.g. ## Auth) |
action | string | yes | replace, insert_before, insert_after, or remove |
content | string | for replace/insert | New content |
Actions:
replace— Replaces the section body. Pass body only — the heading is preserved automatically. If your content includes a heading line, smart detection kicks in and replaces the heading too.insert_before— Inserts content as a new section before the target.insert_after— Inserts content as a new section after the target.remove— Deletes the entire section (heading + body).
Example — replace a section’s body:
{
"path": "docs/ROADMAP.md",
"heading": "## What's Next",
"action": "replace",
"content": "- Feature A\n- Feature B\n- Feature C\n"
}
Example — insert a new section after an existing one:
{
"path": "docs/ROADMAP.md",
"heading": "## What's Built",
"action": "insert_after",
"content": "## What's In Progress\n\n- Working on X\n- Prototyping Y\n"
}
Example — remove a section:
{
"path": "docs/ROADMAP.md",
"heading": "## Deprecated",
"action": "remove"
}
read_file — Heading Navigation
Single heading: heading=
Read one section by heading. Returns the section content with line range and breadcrumb (parent headings).
{
"path": "docs/ROADMAP.md",
"heading": "## What's Next"
}
Multiple headings: headings=[]
Read multiple sections in a single call. More efficient than separate calls.
{
"path": "docs/ROADMAP.md",
"headings": ["## What's Built", "## What's Next"]
}
Complete mode: mode="complete"
Returns the entire file inline (bypasses the output buffer) with a delivery
receipt showing section count and checkbox progress. Scoped to files in plans/
directories only.
{
"path": "plans/implementation-plan.md",
"mode": "complete"
}
When to use: Only when you truly need the full plan. For targeted reads,
prefer the heading map (read_file(path)) followed by headings=[].
edit_file — Heading-Scoped Editing
Scoped matching: heading=
Restricts old_string matching to the lines within a specific section. Prevents
accidental matches in other parts of the file.
{
"path": "docs/ROADMAP.md",
"heading": "## What's Next",
"old_string": "Feature A",
"new_string": "Feature A ✅"
}
Batch mode: edits=[]
Multiple edits applied atomically in a single write. Each edit can optionally
have its own heading scope.
{
"path": "docs/plan.md",
"edits": [
{
"old_string": "- [ ] Step 1",
"new_string": "- [x] Step 1",
"heading": "## Task A"
},
{
"old_string": "- [ ] Step 2",
"new_string": "- [x] Step 2",
"heading": "## Task B"
}
]
}
Fuzzy Heading Matching
All heading parameters (heading= on read_file, edit_file, edit_section)
use a 4-tier matching strategy:
- Exact match —
## Authmatches## Auth - Format-stripped —
## \Auth`matches## Auth` (backticks, bold, italic stripped) - Prefix match —
## Authmatches## Authentication & Authorization - Substring match —
Authmatches## Authentication & Authorization
Headings inside fenced code blocks are ignored.
Section Coverage Tracking
The server tracks which markdown sections you’ve read during a session. This powers two hints:
- On reads: When you read part of a file, the response includes an
unreadlist showing sections you haven’t seen yet. - On writes: When you edit a file with unread sections, a warning appears so you can verify your edit doesn’t conflict with unseen content.
Coverage resets when the file is modified on disk (mtime-based invalidation).
Choosing the Right Tool
| You want to… | Use |
|---|---|
| Replace an entire section’s content | edit_section(action="replace") |
| Add a new section | edit_section(action="insert_before/after") |
| Delete a section | edit_section(action="remove") |
| Fix a typo in a section | edit_file(heading=, old_string, new_string) |
| Toggle multiple checkboxes | edit_file(edits=[...]) with per-edit heading |
| Read specific sections | read_file(headings=[...]) |
| Read a full plan file | read_file(mode="complete") |
| See what sections exist | read_file(path) — returns heading map |
Markdown Tools: read_markdown & edit_markdown
Two dedicated tools for navigating and editing Markdown files using heading-based addressing. They replace the need to read raw line ranges or construct fragile string replacements against unstructured text.
read_markdown
Navigate a Markdown file by heading. Without heading/headings params,
returns a heading map — the document outline with line numbers.
Parameters
| Param | Type | Description |
|---|---|---|
path | string | Markdown file path (relative to project root) |
heading | string | Single section to read (fuzzy matched) |
headings | string[] | Multiple sections in one call (mutually exclusive with heading) |
start_line / end_line | int | Raw line range fallback (1-indexed, inclusive) |
Usage
// Step 1: get the heading map
read_markdown("docs/guide.md")
→ heading map with line numbers
// Step 2: read specific sections
read_markdown("docs/guide.md", headings=["## Auth", "## Config"])
→ both sections in one response
The heading map is the starting point for any markdown edit workflow — always read it first so you know which headings exist before targeting one.
edit_markdown
Edit a Markdown document section by heading. Heading matching is fuzzy —
## Auth matches ## Authentication — so you don’t need to quote headings
exactly.
Actions
| Action | Description |
|---|---|
replace | Replace section body (heading line is preserved) |
insert_before | Insert content before the heading |
insert_after | Insert content after the section (before next heading) |
remove | Delete the section and its body |
edit | Surgical string replacement within a section (old_string → new_string) |
Parameters
| Param | Type | Description |
|---|---|---|
path | string | Markdown file path |
heading | string | Target section heading (fuzzy matched) |
action | string | One of the actions above |
content | string | New body for replace/insert_* (heading not included) |
old_string | string | For edit: exact text to find |
new_string | string | For edit: replacement text |
replace_all | bool | For edit: replace all occurrences (default: false) |
edits | array | Batch mode — multiple operations applied atomically |
Examples
// Replace a section body
edit_markdown("docs/guide.md",
heading="## Configuration",
action="replace",
content="See project.toml for all options.\n")
// Surgical fix inside a section
edit_markdown("docs/guide.md",
heading="## Auth",
action="edit",
old_string="secret_key = \"\"",
new_string="secret_key = \"<your-key>\"")
// Batch: two edits in one atomic call
edit_markdown("docs/guide.md",
edits=[
{ heading: "## Usage", action: "replace", content: "..." },
{ heading: "## License", action: "remove" }
])
Batch Mode
Pass an edits array instead of heading/action to apply multiple operations
atomically. All edits are validated before any are applied — if one heading is
missing, nothing changes.
Why Not edit_file?
edit_file works on raw strings and requires exact whitespace/newline matching.
For Markdown, heading-scoped edits are both safer and more resilient:
| Scenario | edit_file | edit_markdown |
|---|---|---|
| Replace a section body | Error-prone: must match surrounding blank lines exactly | action=replace — heading preserved automatically |
| Edit text inside a section | Works, but edits anywhere in the file | action=edit scoped to one section |
| Remove a section | Must know exact start/end lines | action=remove — no line numbers needed |
| Multiple edits | Multiple calls, each can conflict | edits=[] batch — atomic |
read_markdown improvements
What changed
Three related improvements landed together:
1. Adaptive output tiers
read_markdown now selects output detail based on file size:
| File size | Output |
|---|---|
| Small (< 100 lines) | Full content |
| Medium (100–400 lines) | Heading map + full content |
| Large (> 400 lines) | Heading map only + @file_* buffer ref |
For large files the response includes a must_follow instruction directing
the agent to use the buffer ref for subsequent reads instead of the original
path. This prevents repeated full-file reads for every heading navigation call.
2. @file_* buffer ref support
read_markdown now accepts @file_* buffer refs as the path argument for
all subsequent calls (heading navigation, line-range slicing). Once a large
file is loaded into a buffer ref, every subsequent access hits the in-process
cache — no disk re-read.
{ "path": "@file_abc123", "heading": "## Configuration" }
{ "path": "@file_abc123", "start_line": 45, "end_line": 90 }
3. Heading navigation
Pass heading to jump directly to a section without reading the full file:
{ "path": "docs/guide.md", "heading": "## Installation" }
The response includes the matched section’s content, sibling headings (for orientation), and breadcrumb path.
Why this matters
Large markdown files (tracker docs, changelogs, architecture docs) previously required reading the full file to find a section. With adaptive tiers + heading nav, agents can navigate to the relevant section in one call and cache the buffer ref for the rest of the session.
Known limits
@file_*refs expire when the MCP session ends or the output buffer is evicted (LRU, capacity 50). Re-read the original path to get a fresh ref.- Heading match is case-insensitive prefix search — if two headings share a prefix, the first match wins.
Semantic Search Tools
Semantic search lets you find code by meaning rather than by exact name or keyword. Instead of knowing what a function is called, you describe what it does — “retry with exponential backoff”, “authentication middleware”, “how errors are serialized to JSON” — and the tool finds the most relevant code chunks in the project.
The backend stores vector embeddings of your source code in a SQLite database
at .codescout/embeddings.db. The embedding model is configurable (see
Project Configuration); the default works
with any OpenAI-compatible endpoint or a local Ollama server.
You must build the index before searching. Use index(action: build) once, then
semantic_search as many times as you like. Incremental re-indexing is cheap:
only files that changed since the last run are re-embedded.
See also: Semantic Search Concepts — how chunking, embedding, and scoring work; when to use semantic search vs symbol tools. Setup Guide — step-by-step configuration and indexing walkthrough.
semantic_search
Purpose: Find code by natural language description or code snippet. Returns ranked chunks with file path, line range, and similarity score.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | yes | — | Natural language description or code snippet to search for |
limit | integer | no | 10 | Maximum number of results to return |
detail_level | string | no | compact | "full" returns the complete chunk content instead of a 150-character preview |
offset | integer | no | 0 | Skip this many results (for pagination) |
scope | string | no | "project" | Search scope: "project" (default), "lib:<name>" for a specific library, "libraries" for all libraries, "all" for everything |
include_memories | boolean | no | false | If true, also search semantic memories and include them in results tagged with "source": "memory" |
Example:
{
"query": "retry with exponential backoff",
"limit": 5
}
Output (compact, default):
{
"results": [
{
"file_path": "src/embed/remote.rs",
"language": "rust",
"content": "async fn with_retry<F, Fut, T>(mut f: F, max_attempts: u8) -> anyhow::Result<T>\nwhere\n F: FnMut() -> Fut,...",
"start_line": 42,
"end_line": 68,
"score": 0.91,
"source": "project"
},
{
"file_path": "src/util/http.rs",
"language": "rust",
"content": "/// Exponential back-off starting at 200ms, doubling each attempt up to...",
"start_line": 12,
"end_line": 30,
"score": 0.84,
"source": "project"
}
],
"total": 2
}
In compact mode, content is truncated to 150 characters followed by "...".
Use detail_level: "full" to get complete chunk bodies.
Output (full detail):
{
"query": "retry with exponential backoff",
"limit": 5,
"detail_level": "full"
}
The content field contains the full source text of each chunk. Combine with
offset to page through results:
{
"query": "retry with exponential backoff",
"limit": 5,
"detail_level": "full",
"offset": 5
}
Tips:
- Use
semantic_searchwhen you know the concept but not the exact function name. For example: “where is the JWT decoded”, “rate limiting logic”, “database connection pool initialization”. - Paste a code snippet as the
queryto find similar code elsewhere in the project. This is useful for spotting duplication or finding the canonical version of a pattern. - Scores above 0.85 are typically a strong match. Scores below 0.6 usually indicate the concept is not well represented in the index.
- If results are poor, check
workspace(action: status)to confirm the index is up to date, andindex(action: build)to rebuild if files have changed. - For finding a symbol by name,
symbolsis faster and more precise. Semantic search is for concepts, not identifiers.
Workspace project scoping
{ "tool": "semantic_search", "arguments": { "query": "auth flow", "project": "frontend" } }
Omit project to search across the workspace-level context. See
Multi-Project Workspaces for setup.
index(action: build)
Purpose: Build or incrementally update the semantic search index for the
active project. Only re-embeds files whose content has changed since the last
run unless force is set. Use index(action: build).
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
action | string | yes | — | "build" |
force | boolean | no | false | Force full reindex, ignoring cached file hashes |
scope | string | no | "project" | What to index: "project" (default) for the active project, or "lib:<name>" to index a registered library. |
Example (incremental update):
{ "action": "build" }
Example (full reindex):
{
"action": "build",
"force": true
}
Output:
{
"status": "ok",
"files_indexed": 3,
"files_deleted": 0,
"detail": "3 deleted",
"total_files": 47,
"total_chunks": 312
}
When drift detection is enabled (on by default) and files had
meaningful semantic changes, a drift_summary field is included with the
top-5 most-drifted files:
{
"status": "ok",
"files_indexed": 3,
"total_files": 47,
"total_chunks": 312,
"drift_summary": [
{ "file": "src/auth/service.rs", "avg_drift": "0.72", "max_drift": "0.91", "added": 2, "removed": 1 }
]
}
Staleness warning — if semantic_search is called when the index is behind
the current HEAD commit, results include:
{ "stale": true, "behind_commits": 3, "hint": "Index is behind HEAD. Run index(action: build) to update." }
Tips:
- Run
index(action: build)once when you first activate a project, then again after large refactors or when many files have changed. - The incremental mode (default) uses a git diff → mtime → SHA-256 fallback chain. It is safe to run frequently — unchanged files are skipped at negligible cost.
- Use
force: trueif you have changed the embedding model inproject.toml. Changing the model produces incompatible vectors, so a full reindex is required. - Indexing runs synchronously. For large projects (thousands of files), this may take a few minutes the first time.
index(action: status)
Purpose: Show the health of the semantic index — file count, chunk count,
embedding model, last update time, and optional per-file drift scores.
Use index(action: status).
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
action | string | yes | — | "status" |
threshold | float | no | — | When set, include drift scores for files whose avg_drift exceeds this value (0.0–1.0). Higher = more changed. |
path | string | no | — | Limit drift reporting to a specific file or directory. |
Example (basic stats):
{ "action": "status" }
Example (drift scores for significantly changed files):
{ "action": "status", "threshold": 0.3 }
Output:
{
"indexed_files": 47,
"total_chunks": 312,
"model": "ollama:nomic-embed-text",
"last_updated": "2026-03-12T10:14:00Z",
"stale": false,
"drift": [
{ "file": "src/auth/service.rs", "avg_drift": "0.72", "max_drift": "0.91" }
]
}
Opt out of drift detection with drift_detection_enabled = false in .codescout/project.toml.
See also: Dashboard — the Overview page surfaces index staleness and per-file drift scores visually, without a tool call.
File-Diversity Re-Rank for semantic_search
semantic_search applies a per-file cap to its results before returning them,
so a single highly-relevant file cannot saturate the top-K and crowd out
sibling files that the query is also about.
Why
Embedding retrieval is biased toward chunk-level relevance, not file-level
coverage. When one file contains many similar chunks (a long impl block with
ten methods, a spec with ten matching sections), the nearest-neighbour search
happily fills every slot from that file. The caller — often an agent looking
for “which files are relevant to X” — then loses visibility of related files
that would have ranked just below.
The metadata-enriched chunks benchmark (2026-04-20) observed this explicitly: three test cases (TC-03, TC-13, TC-19) regressed when container decomposition multiplied per-file chunks, because one file’s methods filled all ten slots.
How it works
semantic_searchrequestslimit * MAX_CHUNKS_PER_FILEcandidates from the vector index (default:limit * 3).- Results come back sorted by cosine similarity, high to low.
- A post-filter iterates in score order and drops any chunk whose
file_pathalready appearsMAX_CHUNKS_PER_FILEtimes in the kept set. - The filtered list is truncated to the user’s requested
limit.
Score ordering is preserved — the cap is a filter, not a re-rank. A file
that legitimately owns multiple top-K-worthy chunks still gets up to
MAX_CHUNKS_PER_FILE of them.
Default
MAX_CHUNKS_PER_FILE = 3, hard-coded in src/tools/semantic.rs. Chosen to
preserve multi-hit files (TC-18 keeps both markdown.rs hits) while
preventing single-file saturation (TC-13’s manager.rs is limited to 3 of
10 slots).
Set to 0 in the constant to disable (useful for A/B comparison against the
pre-cap behaviour).
When it helps
- Multi-concept queries that should surface more than one file.
- Codebases with long impl blocks, long spec documents, or generated code where near-duplicate chunks cluster in a single file.
When it may hurt
- Queries where the correct answer really is “this one file”. The cap still
surfaces that file’s top 3 chunks, but slots 4–10 go to less-relevant
neighbours instead of more chunks from the winning file. Use
detail_level: "full"with a narrowed query if the agent needs deeper context from one file.
Related
docs/research/2026-04-03-embedding-model-benchmark.md— benchmark rubric this feature was tuned against.crates/codescout-embed/— upstream retrieval pipeline (no changes; cap runs entirely insrc/tools/semantic.rs).
Library Navigation
These two tools let you register, inspect, and semantically search third-party library source code — directly from within your agent workflow, without leaving the project.
All library access is read-only. Editing tools operate only on project code.
See also: Library Navigation — how auto-discovery works, the scope parameter, and when to navigate library source.
Auto-discovery
The most common way libraries enter the registry is automatically: when an LSP
symbol_at request returns a path outside the project root (e.g. a Rust
crate in ~/.cargo/registry/, a Python package in .venv/), codescout
walks the parent directories looking for a package manifest (Cargo.toml,
package.json, pyproject.toml, go.mod) and registers the library.
After auto-discovery, symbol navigation tools can follow references into library
code without any manual setup. Use the scope parameter to explicitly target
libraries in searches (see below).
scope parameter
Once libraries are registered, pass the optional scope string to any symbol
or search tool:
| Value | What it searches |
|---|---|
"project" (default) | Only project source code |
"lib:<name>" | A specific registered library, e.g. "lib:serde" |
"libraries" | All registered libraries |
"all" | Project source + all libraries |
Tools that accept scope:
symbols, symbols, references, semantic_search
All results include a "source" field ("project" or "lib:<name>") to
distinguish origin.
library(action: list / register)
Purpose: Show all registered libraries, their root paths, and whether a
semantic index has been built for each. Use library(action: list).
You can also register a new library manually with library(action: register).
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
action | string | yes | — | "list" or "register" |
path | string | for register | — | Root path of the library to register |
name | string | no | — | Friendly name for the library (inferred from manifest if omitted) |
Example (list):
{ "action": "list" }
Output:
{
"libraries": [
{
"name": "serde",
"root": "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/serde-1.0.195/",
"indexed": false
},
{
"name": "tokio",
"root": "/home/user/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.35.1/",
"indexed": true
}
],
"total": 2
}
Tips:
- Libraries with
"indexed": falsesupport symbol navigation (LSP + tree-sitter) but notsemantic_search. Runindex(action: build)with the library’s root path to add semantic search. - The registry is stored in
.codescout/libraries.json. You can inspect it directly if you need to edit or remove an entry.
Indexing a Library for Semantic Search
Once a library is registered (via library(action: list) or auto-discovery), build its
semantic index by pointing index(action: build) at its root:
{
"tool": "index",
"arguments": { "action": "build", "scope": "lib:serde" }
}
After indexing, semantic_search with scope: "lib:<name>" searches within that library:
{
"tool": "semantic_search",
"arguments": { "query": "channel with backpressure", "scope": "lib:tokio" }
}
Tips:
- Only index libraries you actively need to search semantically. LSP symbol
navigation (
symbols,symbols) works without indexing. - Indexing a large library (e.g.
tokio) may take a few minutes on the first run. The library path is shown inlibrary(action: list)output.
Git
Note: The
git_blametool was removed in the v1 tool restructure. Git history is still fully accessible viarun_command.
What to Use Instead
Use run_command with standard git commands for all git operations:
{ "tool": "run_command", "arguments": { "command": "git blame src/auth.rs" } }
{ "tool": "run_command", "arguments": { "command": "git log --oneline -20 src/auth.rs" } }
{ "tool": "run_command", "arguments": { "command": "git diff HEAD~1 src/auth.rs" } }
Why It Was Removed
git_blame returned structured JSON but the information was equally accessible through run_command("git blame ..."). Keeping a purpose-built tool for one specific git operation — while all other git operations already used run_command — was inconsistent. Consolidating to run_command gives a single mental model: git history queries go through the shell.
See Workflow & Config for the run_command reference.
AST Analysis
Note: The
list_functionsandlist_docstools were removed in the v1 tool restructure. The tree-sitter layer still exists internally and powers richer symbol extraction for languages with grammar support (Rust, Python, TypeScript, Go) — but it is no longer exposed as a standalone MCP tool.
What to Use Instead
| Old tool | Replacement | Notes |
|---|---|---|
list_functions | symbols | Returns symbol tree with line ranges; requires LSP server |
list_docs | symbols + symbols(include_body=true) | Read the symbol body to inspect doc comments |
symbols covers all 9 LSP-supported languages (not just the 4 with tree-sitter grammars) and returns richer output including types, nesting, and references. For languages where the LSP server hasn’t started yet, grep can locate doc comment blocks (///, /**) using a regex.
Why They Were Removed
The offline advantage (no LSP startup) was outweighed by the maintenance cost of a parallel navigation path. symbols starts the language server on the first call and keeps it running — subsequent calls are instant. For the initial cold start, the latency difference is negligible for interactive use.
See Symbol Navigation for the full symbols reference.
Memory
The memory tool gives the agent persistent, project-scoped storage. Notes written in one session are available in every future session, letting the agent build up knowledge about a codebase over time rather than rediscovering the same things repeatedly.
memory
Purpose: Read, write, list, or delete persistent memory entries via a single unified tool.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
action | string | yes | — | One of: "read", "write", "list", "delete" |
topic | string | required for read/write/delete | — | Path-like key, e.g. "architecture" or "debugging/async-patterns" |
content | string | required for write | — | Markdown text to persist |
private | boolean | no | false | If true, use the gitignored private store (personal notes not shared with teammates) |
include_private | boolean | no | false | For list: also return private topics — returns { shared, private } instead of { topics } |
action: "write"
Persist a piece of knowledge under a named topic.
Example:
{
"action": "write",
"topic": "conventions/error-handling",
"content": "All public functions return `anyhow::Result`. Errors are propagated with `?`. Only `main` and tool `call` methods convert to user-facing messages."
}
Output: "ok"
Tips: Write a memory whenever you learn something non-obvious — a naming convention, an architectural decision, a gotcha you had to debug. Topics with a slash create a sub-directory, which keeps related entries grouped. Calling write with an existing topic overwrites it.
action: "read"
Retrieve a previously stored memory entry by its topic.
Example:
{ "action": "read", "topic": "conventions/error-handling" }
Output (found):
{
"content": "All public functions return `anyhow::Result`. Errors are propagated with `?`. Only `main` and tool `call` methods convert to user-facing messages."
}
Output (not found): Returns a RecoverableError with a hint to call list first.
Tips: Read memories that are relevant to your current task. Use list first to see what topics exist, then read only the ones that apply.
action: "list"
List all stored memory topics for the active project.
Example:
{ "action": "list" }
Output:
{
"topics": [
"architecture",
"conventions/error-handling",
"conventions/naming",
"debugging/lsp-timeouts",
"onboarding"
]
}
With private topics:
{ "action": "list", "include_private": true }
Output:
{
"shared": ["architecture", "conventions/error-handling"],
"private": ["personal/wip-notes"]
}
Tips: Call this at the start of a session to get an overview of what the agent already knows. Topics with slashes indicate sub-categories — scan the list for entries relevant to your current task.
action: "delete"
Remove a memory entry that is no longer accurate or needed.
Example:
{ "action": "delete", "topic": "debugging/lsp-timeouts" }
Output: "ok"
Tips: Delete memories when a refactor changes the architecture they describe, or when a bug they document has been fixed. Stale memories are worse than no memories. Deleting a topic that does not exist is a no-op.
Per-project memory
In workspaces, scope memory to a specific project:
{ "tool": "memory", "arguments": { "action": "read", "project": "backend", "topic": "architecture" } }
Omitting project reads/writes workspace-level memory.
Private Store
Pass private: true to any action to target the gitignored private store at .codescout/private-memories/. Private memories are never surfaced in system instructions and are not shared with teammates:
{ "action": "write", "topic": "wip-notes", "content": "...", "private": true }
Semantic Memory Actions
In addition to the file-backed key/value actions above, memory supports four semantic actions that store and retrieve memories as vector embeddings. Semantic memories are searchable by meaning rather than by exact topic name.
Requires a configured embedding model. Semantic actions fail gracefully if no embedding model is available. The file-backed actions (
read/write/list/delete) always work regardless.
action: "remember"
Store a piece of knowledge in the semantic memory store.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
content | string | yes | The text to embed and store |
title | string | no | Short label. Auto-extracted from the first sentence of content if omitted |
bucket | string | no | Category: "code", "system", "preferences", or "unstructured" (default). Always specify — it improves recall precision |
Bucket guide:
| Bucket | Use for |
|---|---|
"code" | Functions, patterns, APIs, naming conventions, type/module knowledge |
"system" | Build/deploy/config, CI, infra, environment, credentials, migrations |
"preferences" | Style habits, things to always/never do |
"unstructured" | Decisions, context, notes (default) |
Example:
{
"action": "remember",
"content": "RecoverableError is used for expected, input-driven failures (path not found, unsupported file type). Use anyhow::bail! for genuine tool failures (LSP crash, programming error).",
"bucket": "code"
}
Output: "ok"
action: "recall"
Search semantic memories by meaning.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | yes | — | Natural language query |
limit | integer | no | 5 | Max results |
bucket | string | no | — | Filter to a specific bucket |
detail_level | string | no | compact | Pass "full" to include complete memory content instead of a truncated preview |
Example:
{ "action": "recall", "query": "how errors are handled in tools", "bucket": "code" }
Output:
{
"results": [
{
"id": 42,
"bucket": "code",
"title": "RecoverableError vs anyhow::bail",
"content": "RecoverableError is used for expected...",
"similarity": "0.91",
"created_at": "2026-03-08T10:15:00Z"
}
]
}
In compact mode (default), content is truncated to the first line (~50 chars). Use detail_level: "full" to get the complete text.
Tips: Use recall at the start of a session to find relevant past decisions before starting work. The returned id field is a UUID string — pass it to forget to delete the entry.
action: "forget"
Delete a semantic memory by its ID.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
id | string (UUID) | yes | The memory ID from a recall result |
Example:
{ "action": "forget", "id": "aa83d87b-8268-5dc5-9039-4e7447cabecd" }
Output: "ok"
Tips: Use recall first to find the ID of the entry to remove. The ID is
a UUIDv5 derived from (project_id, bucket, title) — re-remembering the
same title under the same bucket produces the same ID, so an update overwrites
in place rather than creating a duplicate. Forgetting an ID that does not
exist is a no-op.
action: "refresh_anchors"
Re-hash the path anchors for a topic to clear a staleness warning without rewriting the memory content.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
topic | string | yes | The memory topic to refresh |
Example:
{ "action": "refresh_anchors", "topic": "architecture" }
Output: "ok"
When to use: workspace(action: status) includes a memory_staleness section listing topics whose anchored source files have changed since the memory was last written. If you review the memory and confirm it is still accurate (the files changed but the memory’s facts did not), call refresh_anchors to acknowledge — this updates the file hashes without changing the memory content. If the memory is genuinely outdated, use write to update it (which automatically re-anchors).
Using Memory Effectively
Storage layout
Memories are stored as plain Markdown files in .codescout/memories/ inside the project root. Each topic maps directly to a file path:
"architecture"→.codescout/memories/architecture.md"debugging/async-patterns"→.codescout/memories/debugging/async-patterns.md
You can inspect or version-control these files like any other project file.
Topic naming
Topics support path-like nesting with forward slashes:
| Category | Example topics |
|---|---|
| Project conventions | conventions/naming, conventions/error-handling, conventions/testing |
| Architecture | architecture, architecture/data-flow, architecture/module-boundaries |
| Debugging notes | debugging/async-patterns, debugging/known-issues |
| Team preferences | preferences/review-style, preferences/commit-format |
| Onboarding summary | onboarding (written automatically by the onboarding tool) |
What to store
Good candidates:
- Project conventions — naming rules, code style decisions not captured by linting
- Architectural decisions — why a module is structured a particular way, trade-offs consciously made
- Debugging insights — root causes of tricky bugs, non-obvious component interactions
- Gotchas — behaviours that surprised you and would surprise the next agent too
Avoid storing things already obvious from reading the code, or things that change so frequently the memory would immediately go stale.
Recommended workflow
- Start a new session → call
onboarding(lists available memories if already done) - Call
memory(action: "list")to see what topics exist - Call
memory(action: "read", topic: ...)for topics relevant to your current task - As you work, call
memory(action: "write", ...)when you learn something worth remembering - If you correct an earlier misunderstanding, overwrite the old entry with updated content
See also: Dashboard — the Memories page lets you browse, create, and delete topics directly in a browser UI without writing tool calls.
Workflow & Config Tools
These tools manage the agent’s working context: which project is active, whether it has been set up, how to run build and test commands, and how to inspect or change configuration.
onboarding
Purpose: Perform initial project discovery — detect languages, list the top-level directory structure, create a default config file, and write a startup memory entry.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
force | boolean | no | false | Re-run full discovery even if already onboarded |
Requires an active project (set one with workspace(action: activate) first).
Example:
{}
Output:
{
"languages": ["rust", "toml", "markdown"],
"top_level": [
".codescout/",
".git/",
"Cargo.lock",
"Cargo.toml",
"docs/",
"src/",
"tests/"
],
"config_created": true,
"hardware": {
"ollama_available": true,
"ollama_host": "http://localhost:11434",
"gpu": { "vendor": "nvidia", "name": "RTX 3080", "vram_mb": 10240 },
"ram_gb": 32,
"cpu_cores": 16
},
"model_options": [
{ "id": "ollama:nomic-embed-text", "dims": 768, "context_tokens": 8192,
"reason": "fast, good general baseline", "available": true, "recommended": true },
{ "id": "ollama:bge-m3", "dims": 1024, "context_tokens": 8192,
"reason": "best quality, slower indexing (~1.2 GB)", "available": true, "recommended": false },
{ "id": "local:JinaEmbeddingsV2BaseCode", "dims": 768, "context_tokens": 8192,
"reason": "code-specific, no Ollama needed (~300 MB)", "available": true, "recommended": false }
],
"instructions": "..."
}
config_created is true when .codescout/project.toml did not exist and was created by this call.
The hardware field reports what was detected (Ollama, GPU, RAM, CPU cores). The model_options
array contains exactly 3 ranked choices; recommended: true marks the one written to project.toml.
The instructions field contains a prompt — including a Phase 0.5 model selection menu — that
guides the LLM through confirming or changing the embedding model before indexing begins.
Tips: Call onboarding once per project, the first time you work on it. It writes a memory entry under the topic "onboarding" with a summary of what it found. On subsequent sessions, call onboarding with force: false (the default) — it detects previous onboarding and returns existing memories without re-running discovery.
Workspace-aware onboarding
For multi-project workspaces,
onboarding automatically detects all projects registered in workspace.toml
and generates per-project Navigation Strategy sections in the system prompt.
It also writes per-project memories and cross-project semantic search guidance.
Each project gets its own onboarding pass with language detection, entry point discovery, and memory writing scoped to that project.
run_command
Purpose: Run a shell command in the active project root. Short output is returned inline; large output is stored in a session buffer and returned as a @cmd_* handle that you can query in follow-up calls.
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
command | string | yes | — | Shell command to execute. May reference output buffer handles with @cmd_* syntax (e.g. grep FAILED @cmd_a1b2). Never prefix with cd /abs/path && — already in the project root. |
cwd | string | no | — | Subdirectory relative to project root to run the command in. Validated to stay within the project. |
timeout_secs | integer | no | 30 | Max execution time in seconds. Ignored when run_in_background is true. |
acknowledge_risk | boolean | no | false | Bypass the dangerous-command check directly. Prefer the @ack_* handle protocol — see below. |
run_in_background | boolean | no | false | Spawn the command detached and return immediately with a log path. Use for long-running processes or commands that background subprocesses with &. |
Example (run a test suite):
{ "command": "cargo test", "timeout_secs": 120 }
Example (run from a subdirectory):
{ "command": "npm test", "cwd": "frontend" }
Output shapes
Short output (< ~50 lines) — inline
{
"stdout": "running 42 tests\ntest result: ok. 42 passed; 0 failed",
"stderr": "",
"exit_code": 0
}
Large output — buffered
When output exceeds the inline threshold, the full content is stored in a session buffer and a smart summary is returned alongside the handle:
{
"output_id": "@cmd_a1b2c3",
"exit_code": 1,
"passed": 39,
"failed": 3,
"failures": ["test_foo", "test_bar", "test_baz"]
}
Query the buffer in a follow-up call — do not pipe:
{ "command": "grep 'FAILED\\|error' @cmd_a1b2c3" }
The output_id handle stays valid for the session (LRU, max 50 entries). Buffer queries can also use sed -n 'N,Mp' @cmd_a1b2c3 to page through output.
Dangerous command — pending acknowledgement
Commands matching dangerous patterns (e.g. rm -rf, git reset --hard) return a pending_ack handle instead of executing:
{
"pending_ack": "@ack_d4e5f6",
"message": "Dangerous command detected: rm -rf target/",
"command": "rm -rf target/"
}
Re-run with just the handle to confirm:
{ "command": "@ack_d4e5f6" }
Handles expire at end of session. Alternatively, pass acknowledge_risk: true on the original call to skip the confirmation step.
Background command
{
"status": "running",
"log_file": "/tmp/codescout-bg-xxxx.log",
"ref_id": "@cmd_a1b2c3"
}
Monitor with run_command("tail -50 /tmp/codescout-bg-xxxx.log").
Security
See Security & Permissions for the full permission model, including write sandboxing and the built-in credential deny list.
Shell execution is disabled by default. To enable it, add to .codescout/project.toml:
[security]
shell_command_mode = "warn" # or "unrestricted"
| Value | Behaviour |
|---|---|
"disabled" | All calls return an error. This is the default. |
"warn" | Commands execute normally. |
"unrestricted" | Commands execute normally (alias for warn, no functional difference). |
On Unix the command runs under sh -c. On Windows it runs under cmd /C.
Tips:
- Never pipe inside the command to filter output (
cargo test 2>&1 | grep FAILED). Run the command bare, then usegrep FAILED @cmd_idin a follow-up call. Buffer queries preserve your context window; piped commands waste it. - For slow build steps (
cargo build, full test suites), increasetimeout_secsto 120–300. - Use
cwdto run commands in subdirectories rather thancd subdir &&prefixes. - For commands that background subprocesses with
&, userun_in_background: true— otherwiserun_commandwill hang until timeout waiting for the shell to exit.
workspace (activate / status / list_projects)
Purpose: Switch the active project to a different directory. All subsequent tool calls operate relative to the new project root. Use workspace(action: activate, path: ...).
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
action | string | yes | — | "activate" |
path | string | yes | — | Absolute path to the project root directory |
read_only | boolean | no | true for non-home projects | Block write tools on this project |
Example:
{ "action": "activate", "path": "/home/user/projects/my-service" }
Output:
{
"status": "ok",
"activated": {
"project_root": "/home/user/projects/my-service",
"config": {
"project": {
"name": "my-service",
"languages": ["rust", "toml"],
"encoding": "utf-8",
"tool_timeout_secs": 60
},
"embeddings": { "model": "...", "chunk_size": 512, "chunk_overlap": 64 },
"ignored_paths": { "patterns": ["target/", "*.lock"] },
"security": { "shell_command_mode": "warn", "shell_output_limit_bytes": 102400, "shell_enabled": false, "file_write_enabled": true, "indexing_enabled": true }
}
}
}
The tool returns an error if the path does not exist or is not a directory.
Read-only default: Non-home projects activate in read-only mode by default — all write
tools are blocked until you pass read_only: false. This prevents accidental edits when
browsing another project for reference. See
Read-Only workspace for the full behavior matrix.
Tips: When working across multiple projects in a single session, call workspace(action: activate) to switch between them. After activating, call onboarding to see whether the new project has been set up. The server starts with no active project — you must call workspace(action: activate) (or have it activated via the --project CLI flag) before using any tool that requires a project context.
workspace(action: status)
Purpose: Display the full state of the active project in one call: config, semantic index health, library registry, and memory staleness. Use workspace(action: status). Combines what was previously get_config and index(action: status).
Parameters:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
action | string | yes | — | "status" |
threshold | number | no | — | When provided, includes drift data: minimum avg_drift to include (0.0–1.0) |
path | string | no | — | SQL LIKE pattern to filter drift results by file path (e.g. "src/tools/%") |
detail_level | string | no | "exploring" | Drift output detail: "full" includes most-drifted chunk content |
Example (basic):
{ "action": "status" }
Example (with drift query):
{ "action": "status", "threshold": 0.2 }
Output:
{
"project_root": "/home/user/projects/my-service",
"config": {
"project": { "name": "my-service", "languages": ["rust", "toml"] },
"embeddings": { "model": "ollama:mxbai-embed-large" }
},
"index": {
"indexed": true,
"files": 47,
"chunks": 312,
"model": "ollama:mxbai-embed-large",
"last_updated": "2026-03-08T10:30:00Z",
"git_sync": { "status": "up_to_date" }
},
"libraries": { "count": 2, "indexed": 1 },
"memory_staleness": {
"stale": ["architecture", "conventions/naming"],
"fresh": ["onboarding", "gotchas"],
"untracked": ["debugging/lsp-timeouts"]
}
}
memory_staleness section
This section is always included. It categorises memory topics by anchor health:
| Key | Meaning |
|---|---|
stale | Topics where anchored source files have changed since the memory was last written — the memory may be outdated |
fresh | Topics whose anchored files match the stored hashes — memory is current |
untracked | Topics with no anchor sidecars — staleness cannot be determined |
When topics appear in stale, review them and either rewrite the memory (action: "write") or confirm it is still accurate and call memory(action: "refresh_anchors", topic: ...) to clear the warning.
Tips:
- Use
workspace(action: status)to verify which project is active and to check security settings before attempting shell commands or indexing. - Pass
threshold: 0.1after re-indexing to surface files that changed semantically — a whitespace reformat scores near0.0, a full function rewrite approaches1.0. - If you need to change configuration, edit
.codescout/project.tomldirectly — the config is re-read on each tool call, so changes take effect immediately without restarting the server. - For full per-tool call stats with charts and time-window filtering, see the Dashboard.
approve_write
Grant write access to a directory outside the project root for this session.
Write tools (edit_file, edit_code, create_file) reject paths outside the active project root by default. approve_write lifts that restriction for a specific directory.
Parameters:
| Name | Type | Required | Description |
|---|---|---|---|
path | string | yes | Directory to approve (absolute or project-relative) |
Example — allow writes to a sibling plugin directory:
{ "path": "/home/user/plugins/my-plugin" }
Output: "ok" on success; an error if the path is protected or too broad.
Session scope. Approval lasts for the current activation — any workspace(action="activate", ...) call, including to the same project, clears all approvals.
Protected paths. Sensitive locations such as ~/.ssh and ~/.gnupg are permanently blocked and cannot be approved regardless of the argument.
Overly broad paths are rejected. Approving a root like /home/user or / is not allowed — the path must point to a specific subdirectory.
Read-Only Default for workspace(action: activate)
When you call workspace(action: activate) with a path different from the home project (the one
codescout started with), the project is now activated in read-only mode by default.
All write tools (edit_file, create_file, edit_code) are blocked until
you explicitly opt in.
Why
When an LLM activates a second project to browse code for reference, it shouldn’t be able to accidentally write to that project. Read-only-by-default makes cross-project navigation safe without any extra ceremony.
Behavior
| Activation | Write tools |
|---|---|
Home project (initial --project or CWD) | Always enabled |
Non-home project — no read_only param | Disabled (default) |
Non-home project — read_only: false | Enabled (explicit opt-in) |
| Return to home project | Restored automatically |
Response
workspace(action: activate) now includes a read_only field in its response:
{
"status": "ok",
"activated": { "project_root": "/path/to/other-project", "..." },
"read_only": true,
"hint": "Switched project. CWD: /path/to/other-project — ⚠ remember to call workspace(action: activate) to return when done. This project is activated in read-only mode. To enable writes, call workspace(action: activate) with read_only: false."
}
Usage
// Browse another project (read-only, safe)
workspace(action: activate, path: "/path/to/other-project")
// Activate with write access explicitly enabled
workspace(action: activate, path: "/path/to/other-project", read_only: false)
Tool Workflows
Named multi-tool chains for common agent tasks. Each workflow is a step-by-step recipe triggered by a recognizable intent.
Why Workflows?
The decision table in server instructions maps what you know to which tool to start with. But it doesn’t answer “what’s the full sequence?” Workflows fill that gap — they guide you through multi-step chains where each tool’s output feeds the next.
Editing a Markdown Document
Intent: “I need to read and edit parts of a structured markdown file.”
| Step | Tool | Purpose |
|---|---|---|
| 1 | read_file(path) | Get heading map — see all sections |
| 2 | read_file(path, headings=[...]) | Read target sections (one call, multiple sections) |
| 3a | edit_section(path, heading, action, content) | Whole-section: replace (body only — heading preserved), insert, remove |
| 3b | edit_file(path, heading=, old_string, new_string) | Surgical: string replacement scoped to a section |
| 3c | edit_file(path, edits=[...]) | Batch: multiple edits across sections, atomic |
Tips:
- Start with the heading map (step 1) — don’t jump straight to editing.
- Use
headings=[](step 2) instead ofmode="complete"unless you need the entire file. - Choose step 3a/3b/3c based on scope: whole section →
edit_section, single fix →edit_file(heading=), multiple fixes →edit_file(edits=[]).
Impact Analysis — “What breaks if I change X?”
Intent: “I’m about to modify a function/struct/trait and need to understand the blast radius.”
| Step | Tool | Purpose |
|---|---|---|
| 1 | symbols(name, include_body=true) | Read the current implementation |
| 2 | references(name_path, path) | Find all callers and dependents |
| 3 | symbol_at with fields: ["hover"] on key call sites | Reveal concrete types flowing through (especially generics/traits) |
| 4 | Edit with full knowledge of impact |
Why not grep? A regex search for a symbol name returns string
matches — including imports, type annotations, comments, and tests.
references returns only actual usages that will break if the API changes.
Tips:
- Step 2 may overflow on widely-used symbols. Check the
by_filedistribution to focus on the most important callers. - Step 3 is optional but valuable for generic code —
symbol_at(hover) shows the resolved concrete type, not the declared generic.
Dependency Tracing — “How does data flow from A to B?”
Intent: “I need to trace how a value flows through the call chain — request handling, pipeline stages, error propagation.”
| Step | Tool | Purpose |
|---|---|---|
| 1 | symbols(entry_point) | Locate the starting function |
| 2 | symbol_at with fields: ["def"] on called functions | Follow the call chain forward |
| 3 | symbol_at with fields: ["hover"] on parameters/return values | See resolved types at each stage |
| 4 | references at the destination | Confirm which callers reach this point |
Why not grep? symbol_at follows the actual dispatch — through
trait impls, re-exports, and type aliases. grep finds text matches
but can’t follow indirection.
Tips:
- Use
symbol_atiteratively — follow the chain function by function. - The
hoverfield at each step shows the concrete types, which is critical when tracing through generics or trait objects.
Safe Rename — “Rename X without breaking anything”
Intent: “I need to rename a symbol and verify nothing was missed.”
| Step | Tool | Purpose |
|---|---|---|
| 1 | references(name_path, path) | Map all usages before renaming |
| 2 | edit_code(symbol, path, action="rename", new_name) | LSP-powered rename across files |
| 3 | grep(old_name) | Catch stragglers in comments, strings, docs |
| 4 | run_command("cargo check") | Verify compilation |
Why both edit_code(action="rename") and grep? LSP rename handles code
references precisely, but it can miss occurrences in string literals, comments,
and documentation. Step 3 catches those stragglers. Step 1 gives you the
expected count to verify against.
Tips:
- Compare the count from step 1 with the results from step 3 — any remaining matches after step 2 are the stragglers that need manual attention.
- Always run step 4.
edit_code(action="rename")can occasionally corrupt string literals containing the old name.
Onboarding Improvements
Two improvements to the onboarding tool that make it safer for large
projects and more resilient to tool API changes.
Subagent Delegation
Onboarding now offloads project exploration to a dedicated subagent instead of performing it inline. This prevents the exploration phase — which can involve dozens of tool calls across a large codebase — from exhausting the main agent’s context window.
How it works
When onboarding is called on a project that hasn’t been onboarded yet,
it returns a two-part response:
main_agent_instructions(~200 tokens) — short instructions telling the calling agent to dispatch a Sonnet subagent.subagent_prompt— a self-contained prompt the calling agent passes verbatim to the subagent. Contains: preamble, step-by-step exploration instructions, memory templates, and an epilogue.
The subagent performs all exploration (file reads, symbol scans, language detection) and writes the project memories. The main agent’s context stays clean.
Agent calls onboarding()
→ receives dispatch instructions + subagent_prompt
→ spawns subagent with subagent_prompt
→ subagent explores codebase
→ subagent writes memory files
→ main agent continues with fresh context
Fast path unchanged
If the project is already onboarded, onboarding() returns a short status
message as before — no subagent is involved.
Version-Aware System Prompt Refresh
The onboarding tool now tracks a version number (ONBOARDING_VERSION)
stored in .codescout/project.toml. When a project’s stored version is
older than the current server’s version, onboarding automatically
dispatches a lightweight refresh subagent to regenerate the system prompt
from existing memories — without re-exploring the codebase.
Why it exists
When codescout’s tool API changes (renames, new tools, removed parameters), existing projects carry a system prompt that references old tool names. The refresh detects the version mismatch and rebuilds the prompt from current templates, so the agent’s guidance stays accurate without requiring a full re-onboard.
Behavior
| Stored version | Action |
|---|---|
| Missing (pre-versioning project) | Triggers refresh |
Lower than ONBOARDING_VERSION | Triggers refresh |
Equal to ONBOARDING_VERSION | No-op (already current) |
| Higher (downgrade scenario) | No-op (avoids churn) |
refresh_prompt parameter
To force a prompt refresh explicitly — for example, after updating memories
manually — pass refresh_prompt=true:
onboarding(refresh_prompt=true)
This regenerates the system prompt from current memories and templates
without re-scanning the project. Useful after bulk memory edits or after
upgrading codescout to a new version that bumps ONBOARDING_VERSION.
What gets refreshed
The refresh subagent reads existing project memories and rewrites the system
prompt section of .codescout/project.toml. It does not re-read source
files or re-scan the project structure — only the prompt template is
regenerated.
Experimental Features
These features are available on
masterand theexperimentsbranch. APIs and behaviour may change without notice. When a feature graduates to stable, its page moves into the main manual.
No experimental features pending in this release.
Architecture
This page describes how codescout works internally. It is written for users who want to understand the system, not for contributors adding new tools or languages (see the Extending chapter for that).
System Overview
codescout is an MCP server that gives LLMs IDE-grade code intelligence. It sits between the AI assistant (Claude Code, Cursor, or any MCP-capable client) and the project’s source code, providing 28 tools for navigation, search, editing, and analysis.
The server is a single Rust binary. It launches language servers, parses source files with tree-sitter, manages a vector embedding index, and reads git history – all behind a uniform MCP tool interface. The AI assistant never interacts with these backends directly; it calls tools, and codescout handles the rest.
Component Diagram
Claude Code ──MCP──▶ CodeScoutServer
│
┌─────┼─────┐
▼ ▼ ▼
Agent Tools Instructions
│ │
┌─────┼─────┼─────┐
▼ ▼ ▼ ▼
Config LSP AST Embeddings
│ │ │ │
▼ ▼ ▼ ▼
project Language tree-sitter SQLite
.toml Servers grammars index
CodeScoutServer is the MCP entry point. It holds the Agent, the tool registry, and the server instructions that get sent to the LLM.
Agent manages the active project: root path, configuration, memory store.
Tools are stateless structs dispatched by name. All state flows through
ToolContext, which holds references to the Agent and the LSP manager.
Instructions are markdown text sent to the LLM as part of the MCP server info. They guide the LLM on how to use the tools effectively.
Request Lifecycle
When the LLM calls a tool, here is what happens:
-
MCP request arrives. A JSON-RPC message comes in over stdio (single connection) or HTTP/SSE (multi-connection). The
rmcpcrate handles protocol framing. -
Tool lookup.
CodeScoutServer::call_tool()searches the tool registry – aVec<Arc<dyn Tool>>– for a tool matching the requested name. If no match is found, aninvalid_paramsMCP error is returned. -
Security check. Before the tool runs,
check_tool_access()verifies that the tool is not disabled by the project’s security configuration. For example, ifshell_enabledis false,run_commandis blocked here. -
ToolContext creation. A
ToolContextis assembled with clones of theAgentandArc<LspManager>. This is the only state a tool receives. -
Tool execution. The tool’s
call()method runs with the parsed JSON input and the context. Tools are async and can call into LSP servers, read files, query the embedding index, or run shell commands. -
Result or error. On success, the tool returns a
serde_json::Valuethat gets serialized to aCallToolResultwith text content. On failure, the error is wrapped inCallToolResult::error– it is surfaced to the LLM as an error message, not as an MCP protocol error. This means tool failures are recoverable: the LLM sees the error and can try a different approach.
Key Components
Agent
Source: src/agent.rs
The Agent manages the active project state. It holds:
- Project root – the filesystem path to the project being explored.
- Configuration – the parsed
project.tomlsettings. - Memory store – the markdown-backed key-value store for persistent knowledge.
The Agent is thread-safe via Arc<RwLock<AgentInner>>. It is cloned and shared
across all tool calls and, in HTTP mode, across all connections. Calling
workspace(action: activate) swaps the inner project state atomically.
Tool Registry
Source: src/server.rs
All 22 tools are registered at startup in CodeScoutServer::from_parts() as
a Vec<Arc<dyn Tool>>. Dispatch is by name: call_tool() iterates the vector
and matches on tool.name().
Each tool is a zero-size struct implementing the Tool trait:
#![allow(unused)]
fn main() {
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn input_schema(&self) -> Value;
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value>;
}
}
Tools are stateless. All project state lives in the ToolContext they receive
on each call. This means tools are trivially shareable across threads and
connections.
LSP Manager
Source: src/lsp/manager.rs, src/lsp/client.rs, src/lsp/servers/
The LSP Manager starts language server processes on demand. When a symbol tool is called for a file, the manager checks the file’s language, looks up the default server configuration, and either reuses an existing server process or spawns a new one.
Supported language servers:
| Language | Server |
|---|---|
| Rust | rust-analyzer |
| Python | pyright-langserver |
| TypeScript/JS/TSX | typescript-language-server |
| Go | gopls |
| Java | jdtls |
| Kotlin | kotlin-language-server |
| C/C++ | clangd |
| C# | OmniSharp |
| Ruby | solargraph |
Communication with language servers uses JSON-RPC over stdio. The LspClient
struct handles the LSP initialization handshake, sends requests
(textDocument/documentSymbol, textDocument/references, textDocument/rename,
etc.), and parses responses using lsp-types.
Server processes are long-lived: once started, they persist for the lifetime of
the MCP session. On shutdown (SIGINT, SIGTERM, or MCP connection close), the
server calls LspManager::shutdown_all(), which sends proper LSP
shutdown/exit messages to each language server for a clean exit. As a safety
net, the LspClient Drop implementation also sends SIGTERM to the child process
via libc::kill, ensuring cleanup even if the graceful path is bypassed.
AST Engine
Source: src/ast/
Tree-sitter provides offline parsing that works without a language server. It is faster than LSP for simple structural queries and has zero startup cost.
The AST engine is used internally for richer symbol extraction and semantic chunking. It is not exposed as a standalone tool — use symbols (LSP-backed) for interactive symbol navigation.
Supported languages for tree-sitter: Rust, Python, TypeScript, Go, Java, Kotlin. See the Language Support page for the full matrix.
Embedding Pipeline
Source: src/retrieval/, src/embed/ast_chunker.rs, crates/codescout-embed/
The embedding pipeline enables semantic search — finding code by meaning rather than by name. As of v0.12 it is a network-attached retrieval stack, not a local-database backend. Four stages, each running in a separate process:
-
Chunking (
src/embed/ast_chunker.rs,crates/codescout-embed/src/chunker.rs) — Source files are split into overlapping text chunks. For languages with tree-sitter support, the chunker uses AST boundaries (functions, classes, blocks) to create semantically coherent chunks. For other files, it falls back to line-based splitting with configurablechunk_sizeandchunk_overlap. -
Dense embedding (
src/retrieval/embedder.rs::EmbedderHttp) — Each chunk is POSTed to a dense embedding service over HTTP. The default stack ships a TEI-compatible embedder onlocalhost:48081; the same client also speaks the OpenAI-compatible protocol whenCODESCOUT_EMBEDDER_PROTOCOL=openaiis set, so Ollama, OpenAI, and Anthropic-compatible endpoints all work. -
Sparse embedding (
src/retrieval/embedder.rs, SPLADE) — In parallel, chunks are sent to a sparse SPLADE service onlocalhost:48084. The sparse vector captures lexical matches the dense vector misses (rare tokens, exact identifiers). -
Storage and search (
src/retrieval/qdrant.rs) — Both vectors plus chunk metadata are upserted into Qdrant’scode_chunkscollection over gRPC (localhost:6334). Query-time, the same dense + sparse embeddings are computed for the query text and Qdrant performs hybrid search with Reciprocal Rank Fusion (1/(1+rank)) across both legs. Top results are then re-ranked by a cross-encoder service onlocalhost:48083(TEI-compatible,bge-reranker-v2-m3by default; protocol switchable to Infinity viaCODESCOUT_RERANKER_PROTOCOL=infinity).
The index tracks file content hashes. On incremental re-indexing, only files
that changed since the last index build are re-chunked and re-embedded.
Memories live in a sibling Qdrant collection (memories) with the same
substrate but a separate schema and lifecycle.
The full stack is provided as docker-compose.yml at the repo root with cpu
and gpu profiles. Users migrating from pre-v0.12 installs run
codescout migrate-memories to re-embed legacy .codescout/embeddings/project.db
content into Qdrant.
Memory Store
Source: src/memory/
A lightweight key-value store backed by markdown files in
.codescout/memories/. Topics are path-like strings (e.g.,
debugging/async-patterns) that map to files on disk.
The file store supports four operations: write, read, list, and delete.
A second tier — semantic memory — stores entries as vector embeddings in
the same .codescout/embeddings.db. This enables natural-language recall
(action: "remember" / "recall" / "forget") in addition to the
key-based file store. Each write also cross-embeds into the semantic store
(best-effort, non-fatal). Memories auto-classify into buckets: code,
system, preferences, unstructured.
Transport Modes
codescout supports two transport modes, selected at startup via the
--transport flag.
stdio (default)
codescout start --project /path/to/project
Single connection. Claude Code launches the server as a subprocess and communicates over stdin/stdout. No authentication is needed because the connection is local and exclusive.
This is the standard mode for Claude Code integration. The MCP registration
command (claude mcp add) sets this up automatically.
HTTP/SSE
codescout start --project /path/to/project --transport http --port 8080
Multi-connection. The server binds to a port and accepts SSE (Server-Sent
Events) connections. Each connection gets its own CodeScoutServer instance
but shares the same Agent and LSP Manager.
An auth token is auto-generated and printed to stderr at startup. Clients must
send it as a Bearer token in the Authorization header. You can also
provide your own token via --auth-token.
Use HTTP mode when the MCP client runs on a different machine, or when multiple clients need to share a single server.
Storage
All persistent state is split between per-project local files and a shared retrieval stack running as containers.
Per-project (.codescout/ in the project root)
<project-root>/
└── .codescout/
├── project.toml # Configuration
├── call_edges.db # Cross-file call graph cache (SQLite)
└── memories/ # Markdown knowledge files
├── topic-a.md
└── debugging/
└── async-patterns.md
This directory is created automatically when a project is first activated.
Add .codescout/ to your .gitignore — it contains machine-local state
(call-graph cache, memory notes) that should not be committed.
The project.toml file is an exception: you may want to commit it so that
team members share the same configuration. See
Project Configuration for details.
Note: Pre-v0.12 installs had
.codescout/embeddings/project.db(a sqlite-vec store). That file is no longer created or read. To migrate its contents into Qdrant, runcodescout migrate-memoriesonce; you can then delete the legacy file. The active-project banner surfaces a “⚠ LEGACY INDEX” hint when it detects one.
Shared retrieval stack (Qdrant + embedding services)
| Service | Default port | Role |
|---|---|---|
| Qdrant | :6334 (gRPC) | Vector storage for code_chunks and memories collections |
| Dense embedder | :48081 (HTTP) | TEI- or OpenAI-protocol text → dense vector |
| Cross-encoder reranker | :48083 (HTTP) | TEI or Infinity-protocol pairwise reranking |
| Sparse SPLADE | :48084 (HTTP) | TEI-protocol text → sparse vector |
The repo ships a docker-compose.yml with cpu and gpu profiles that
brings up all four. The stack is shared across projects on a machine — there
is no per-project Qdrant instance.
Tech Stack
| Crate | Purpose |
|---|---|
rmcp | MCP protocol implementation (stdio and SSE transports) |
lsp-types | LSP type definitions |
tree-sitter + language grammars | Offline AST parsing |
git2 | Git operations (blame, log, diff) |
qdrant-client | gRPC client for Qdrant vector storage |
rusqlite | SQLite — call_edges.db cache + legacy memory migration reader |
reqwest (rustls + ring) | HTTP client for embedder / reranker / sparse services |
rustls | TLS via the ring crypto provider (small binary footprint) |
fastembed | Local CPU embeddings (optional, local-embed feature — not the default substrate) |
tokio | Async runtime |
clap | CLI argument parsing |
serde / serde_json | JSON serialization |
tracing | Structured logging |
libc | POSIX signals for LSP process cleanup |
Further Reading
- Progressive Disclosure – how output volume is controlled across all tools.
- Project Configuration – all settings in
project.toml. - Embedding Backends – configuring Ollama, OpenAI, or local embeddings.
- Semantic Search Concepts — how the embedding pipeline works, similarity scoring, and when to reach for semantic vs symbol search
- Dashboard — visual UI for project health, tool usage stats, index status, and memory browsing
- The full internal architecture with contributor-level detail is in
docs/ARCHITECTURE.mdin the repository root.
Adding Languages
codescout supports languages at three levels, each building on the previous. You can ship a partial implementation and add deeper support later.
| Level | What it enables | Effort |
|---|---|---|
| Detection only | File detection, semantic search chunking, basic file ops | 1 line |
| LSP support | All symbol tools (symbols, symbols, references, rename) | ~10 lines |
| Tree-sitter grammar | Richer offline AST extraction, improved symbol fallback | ~50–200 lines |
Level 1: Detection Only (easiest)
Add an extension mapping in src/ast/mod.rs in the detect_language() function:
#![allow(unused)]
fn main() {
pub fn detect_language(path: &Path) -> Option<&'static str> {
match path.extension()?.to_str()? {
"rs" => Some("rust"),
"py" => Some("python"),
"ts" => Some("typescript"),
"tsx" => Some("tsx"),
"js" => Some("javascript"),
"jsx" => Some("jsx"),
"go" => Some("go"),
"java" => Some("java"),
"kt" | "kts" => Some("kotlin"),
"c" => Some("c"),
"cpp" | "cc" | "cxx" => Some("cpp"),
"cs" => Some("csharp"),
"rb" => Some("ruby"),
"php" => Some("php"),
"swift" => Some("swift"),
"scala" => Some("scala"),
"ex" | "exs" => Some("elixir"),
"hs" => Some("haskell"),
"lua" => Some("lua"),
"sh" | "bash" => Some("bash"),
// Add your language here:
"zig" => Some("zig"),
_ => None,
}
}
}
The string you return (e.g. "zig") becomes the canonical language identifier
used throughout the codebase. Keep it lowercase, no spaces.
What this enables:
detect_language()calls throughout the codebase recognize your file type- The semantic search chunker can split files of this type into chunks
treereports the language for each filegrepandtree(with glob) include these files in results
This is enough to ship. Many languages in the current codebase (e.g. php,
swift, scala, elixir, haskell, lua, bash) have detection only.
Level 2: LSP Support (medium)
LSP support enables all seven symbol tools. You need two changes.
Add a server config
In src/lsp/servers/mod.rs, add a match arm to default_config():
#![allow(unused)]
fn main() {
pub fn default_config(language: &str, workspace_root: &Path) -> Option<LspServerConfig> {
let root = workspace_root.to_path_buf();
match language {
"rust" => Some(LspServerConfig {
command: "rust-analyzer".into(),
args: vec![],
workspace_root: root,
}),
// ... existing languages ...
"ruby" => Some(LspServerConfig {
command: "solargraph".into(),
args: vec!["stdio".into()],
workspace_root: root,
}),
// Add your language:
"zig" => Some(LspServerConfig {
command: "zls".into(),
args: vec![],
workspace_root: root,
}),
_ => None,
}
}
}
The LspServerConfig struct has three fields:
#![allow(unused)]
fn main() {
pub struct LspServerConfig {
/// Executable to launch (e.g. "rust-analyzer", "pyright-langserver")
pub command: String,
/// Arguments passed to the executable
pub args: Vec<String>,
/// Working directory (usually the project root)
pub workspace_root: PathBuf,
}
}
The server must speak LSP over stdio. Most language servers do this by default
or with a --stdio flag.
Add the language ID mapping (if needed)
The LSP spec sometimes uses a different language identifier than our canonical
name. For example, TSX files use "typescriptreact" in the LSP protocol. If
your language’s LSP ID differs from the canonical name, add a mapping in
lsp_language_id():
#![allow(unused)]
fn main() {
pub fn lsp_language_id(lang: &str) -> &str {
match lang {
"tsx" => "typescriptreact",
"jsx" => "javascriptreact",
// ... existing mappings ...
// Only add here if the LSP ID differs from your canonical name:
// "zig" => "zig", // Not needed — same as canonical name
other => other, // Falls through if names match
}
}
}
Most languages use the same identifier for both, so you likely do not need to touch this function.
What this enables:
symbols— symbol tree for files and directories + name searchreferences— find all callers/referencessymbol_at— definition + hover at a positioncall_graph— transitive caller/callee traversaledit_code— mutate code by symbol (action: replace | insert | remove | rename)
The LspManager starts the server lazily on first use and keeps it alive for
subsequent requests.
Level 3: Tree-sitter Grammar (full support)
Tree-sitter gives you offline symbol extraction without a running language server. This improves the fallback path when LSP is unavailable and enables richer AST extraction used internally by symbol tools.
Step 1: Add the tree-sitter crate
Add the grammar crate to Cargo.toml:
[dependencies]
tree-sitter-zig = "0.1" # Use the latest version
Tree-sitter grammars are compiled statically into the binary. There are no runtime grammar files to distribute.
Step 2: Add the language mapping
In src/ast/parser.rs, add a match arm to get_ts_language():
#![allow(unused)]
fn main() {
fn get_ts_language(lang: &str) -> Option<tree_sitter::Language> {
match lang {
"rust" => Some(tree_sitter_rust::LANGUAGE.into()),
"python" => Some(tree_sitter_python::LANGUAGE.into()),
"go" => Some(tree_sitter_go::LANGUAGE.into()),
"typescript" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
"tsx" => Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
"javascript" => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
"jsx" => Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
"java" => Some(tree_sitter_java::LANGUAGE.into()),
"kotlin" => Some(tree_sitter_kotlin_ng::LANGUAGE.into()),
// Add your language:
"zig" => Some(tree_sitter_zig::LANGUAGE.into()),
_ => None,
}
}
}
Note: the exact API varies by crate. Some expose LANGUAGE, others
language(). Check the crate’s docs.
Step 3: Write the symbol extractor
Create an extract_zig_symbols() function following the pattern of existing
extractors. Here is a simplified skeleton based on the Rust extractor:
#![allow(unused)]
fn main() {
fn extract_zig_symbols(
node: Node,
source: &str,
file: &PathBuf,
prefix: &str,
) -> Vec<SymbolInfo> {
let mut symbols = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"function_declaration" => {
if let Some(name) = child_name(child, source, "name") {
symbols.push(SymbolInfo {
name_path: make_name_path(prefix, &name),
name,
kind: SymbolKind::Function,
file: file.clone(),
start_line: child.start_position().row as u32,
end_line: child.end_position().row as u32,
start_col: child.start_position().column as u32,
children: vec![],
});
}
}
"struct_declaration" => {
if let Some(name) = child_name(child, source, "name") {
let np = make_name_path(prefix, &name);
symbols.push(SymbolInfo {
name_path: np,
name,
kind: SymbolKind::Struct,
file: file.clone(),
start_line: child.start_position().row as u32,
end_line: child.end_position().row as u32,
start_col: child.start_position().column as u32,
children: vec![],
});
}
}
// Add more node kinds as needed...
_ => {}
}
}
symbols
}
}
Key helpers already available in src/ast/parser.rs:
child_name(node, source, field)— extracts a named field from a tree-sitter nodemake_name_path(prefix, name)— builds"Parent/Child"name pathsfind_child_by_kind(node, kind)— finds a child node by its tree-sitter kind
To discover the correct node kinds for your language, use tree-sitter parse <file> on a sample source file, or inspect the grammar’s
node-types.json.
Step 4: Add the dispatch case
In extract_symbols_from_source(), add your language to the match:
#![allow(unused)]
fn main() {
pub fn extract_symbols_from_source(
source: &str,
language: Option<&'static str>,
path: &Path,
) -> Result<Vec<SymbolInfo>> {
// ... parser setup ...
match lang {
"rust" => Ok(extract_rust_symbols(root, source, &file, "")),
"python" => Ok(extract_python_symbols(root, source, &file, "")),
"go" => Ok(extract_go_symbols(root, source, &file, "")),
"typescript" | "javascript" | "tsx" | "jsx" => {
Ok(extract_ts_symbols(root, source, &file, ""))
}
"java" => Ok(extract_java_symbols(root, source, &file, "")),
"kotlin" => Ok(extract_kotlin_symbols(root, source, &file, "")),
// Add your language:
"zig" => Ok(extract_zig_symbols(root, source, &file, "")),
_ => Ok(vec![]),
}
}
}
Step 5: Add docstring extraction (optional)
If the language has a documentation comment convention, add a corresponding
extract_zig_docstrings() function and wire it into
extract_docstrings_from_source(). This follows the same pattern as
extract_symbols_from_source().
What this enables:
- Richer offline symbol extraction used internally by
symbolsand semantic chunking - Better fallback when the LSP server is unavailable or slow to start
Testing
Detection and AST
Run the full test suite:
cargo test
The AST tests in src/ast/parser.rs exercise each extractor with sample
source code. Add a test for your language following the existing pattern — parse
a small snippet, assert on the extracted symbols.
LSP
LSP support requires the actual language server binary to be installed on the system. This makes it impractical to test in CI, so manual testing is the norm:
- Install the language server (e.g.
zlsfor Zig) - Create or find a test project in that language
- Run the MCP server against it:
cargo run -- start --project /path/to/test-project - Use an MCP client (or
curlagainst the SSE endpoint) to invoke symbol tools and verify results
Checklist
When adding a new language, use this as a quick reference:
-
src/ast/mod.rs— extension mapping indetect_language() -
src/lsp/servers/mod.rs— server config indefault_config()(if LSP available) -
src/lsp/servers/mod.rs— ID mapping inlsp_language_id()(only if LSP ID differs) -
Cargo.toml— tree-sitter crate dependency (if adding grammar) -
src/ast/parser.rs— grammar inget_ts_language()(if adding grammar) -
src/ast/parser.rs—extract_<lang>_symbols()function (if adding grammar) -
src/ast/parser.rs— dispatch inextract_symbols_from_source()(if adding grammar) -
cargo test— all tests pass -
cargo clippy -- -D warnings— no warnings - Update
docs/manual/src/language-support.mdwith the new language
Writing Tools
This walkthrough creates a new tool from scratch. We will build a hypothetical
word_count tool that counts words in a file, then register it with the MCP
server. The same pattern applies to every tool in the codebase.
Step 1: Create the tool struct
Each tool is a unit struct. Create a new file or add to an existing tool module
(e.g. src/tools/file.rs for file-related tools):
#![allow(unused)]
fn main() {
pub struct WordCount;
}
That is it. Tools carry no state — all runtime state lives in ToolContext,
which is passed to every call().
Step 2: Implement the Tool trait
The Tool trait lives in src/tools/mod.rs:
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
/// Tool name as exposed over MCP (e.g. "symbols")
fn name(&self) -> &str;
/// Short description shown to the LLM
fn description(&self) -> &str;
/// JSON Schema for the input parameters
fn input_schema(&self) -> Value;
/// Execute the tool with the given input (already parsed from JSON)
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value>;
}
}
Here is the complete implementation for WordCount:
#![allow(unused)]
fn main() {
use async_trait::async_trait;
use anyhow::Result;
use serde_json::{json, Value};
use crate::tools::{Tool, ToolContext};
use crate::tools::output::OutputGuard;
pub struct WordCount;
#[async_trait]
impl Tool for WordCount {
fn name(&self) -> &str {
"word_count"
}
fn description(&self) -> &str {
"Count words in a file. Returns total word count and line count."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "File path (absolute or relative to project root)"
}
}
})
}
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value> {
// 1. Read and validate parameters
let path_str = input["path"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'path' parameter"))?;
// 2. Resolve and security-validate the path
let project_root = ctx.agent.project_root().await;
let security = ctx.agent.security_config().await;
let path = crate::util::path_security::validate_read_path(
path_str,
project_root.as_deref(),
&security,
)?;
// 3. Do the actual work
if !path.exists() {
anyhow::bail!("File not found: {}", path.display());
}
let content = std::fs::read_to_string(&path)?;
let word_count = content.split_whitespace().count();
let line_count = content.lines().count();
// 4. Return results as JSON
Ok(json!({
"file": path.display().to_string(),
"words": word_count,
"lines": line_count,
}))
}
}
}
Key patterns in call()
Parameter extraction. The input value is already parsed JSON. Use
input["field"].as_str(), .as_u64(), .as_bool(), etc. Always handle
missing required fields with a clear error message.
Path resolution. For any tool that reads files, always validate through
validate_read_path() (or validate_write_path() for write tools). This
resolves relative paths against the project root and blocks access to
sensitive system directories.
#![allow(unused)]
fn main() {
let project_root = ctx.agent.project_root().await;
let security = ctx.agent.security_config().await;
let path = crate::util::path_security::validate_read_path(
path_str,
project_root.as_deref(),
&security,
)?;
}
Using ctx.agent. The agent provides project state:
ctx.agent.project_root().await— active project root (Option<PathBuf>)ctx.agent.require_project_root().await?— same but returns an error if no project is activectx.agent.security_config().await— path security configurationctx.agent.with_project(|proj| { ... }).await?— access config, memory store
Using ctx.lsp. The LSP manager provides language server access:
ctx.lsp.get_or_start(language, workspace_root).await?— get or launch an LSP clientctx.lsp.get(language).await— get an existing client without starting one
Error handling. Use anyhow::bail!() for validation errors and ? for
propagating internal errors. The server catches all errors and surfaces them
to the LLM as text content (see The Tool Trait for details).
Return value. Always return Ok(json!({...})). The server serializes this
to pretty-printed JSON and wraps it in an MCP CallToolResult.
Step 3: Register the tool
Add the tool to the tool vector in src/server.rs in the from_parts()
method:
#![allow(unused)]
fn main() {
pub async fn from_parts(agent: Agent, lsp: Arc<LspManager>) -> Self {
// ...
let tools: Vec<Arc<dyn Tool>> = vec![
// File tools
Arc::new(ReadFile),
Arc::new(ListDir),
Arc::new(SearchForPattern),
Arc::new(CreateTextFile),
Arc::new(FindFile),
Arc::new(ReplaceContent),
Arc::new(EditLines),
// ... other categories ...
// Add your tool:
Arc::new(WordCount),
];
// ...
}
}
Tools are dispatched dynamically by name. The list_tools handler iterates
this vector to build the MCP tool list, and call_tool looks up tools by
matching tool.name() against the request. No other registration is needed.
Remember to add the use import at the top of server.rs:
#![allow(unused)]
fn main() {
use crate::tools::file::WordCount; // or wherever you placed it
}
Step 4: Using OutputGuard
If your tool returns a list of items that could be large, integrate the
progressive disclosure system. OutputGuard enforces two modes:
- Exploring (default): caps output at 200 items, no pagination
- Focused (
detail_level: "full"): paginated withoffset/limit
Add the standard parameters to your schema:
#![allow(unused)]
fn main() {
fn input_schema(&self) -> Value {
json!({
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "File path or directory"
},
"detail_level": {
"type": "string",
"description": "Output detail: omit for compact (default), 'full' for complete"
},
"offset": {
"type": "integer",
"description": "Skip this many results (focused mode pagination)"
},
"limit": {
"type": "integer",
"description": "Max results per page (focused mode, default 50)"
}
}
})
}
}
Then use OutputGuard in your call():
#![allow(unused)]
fn main() {
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value> {
let guard = OutputGuard::from_input(&input);
// ... collect all results ...
let all_items: Vec<SomeType> = do_work();
// Cap the output according to the active mode
let (items, overflow) = guard.cap_items(all_items, "Narrow with a path filter");
let mut result = json!({
"items": items,
"total": items.len(),
});
// Attach overflow metadata so the LLM knows there is more
if let Some(info) = overflow {
result["overflow"] = OutputGuard::overflow_json(&info);
}
Ok(result)
}
}
The overflow JSON tells the LLM how many results exist and how to get the next page:
{
"shown": 200,
"total": 1423,
"hint": "Narrow with a path filter",
"next_offset": 200
}
Use cap_items() for result lists and cap_files() for file lists. The
semantics are the same; the distinction exists so you can configure different
caps for items vs files if needed.
Use guard.should_include_body() to decide whether to include full source
bodies in symbol results:
#![allow(unused)]
fn main() {
if guard.should_include_body() {
// Include the "body" field with source text
}
}
See The Tool Trait for the full OutputGuard API reference.
Step 5: Testing
Tools are tested by constructing a ToolContext with a test agent and calling
tool.call() directly. The pattern from src/server.rs tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use crate::agent::Agent;
use crate::lsp::LspManager;
use std::sync::Arc;
async fn make_ctx() -> (tempfile::TempDir, ToolContext) {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let lsp = Arc::new(LspManager::new());
let ctx = ToolContext { agent, lsp };
(dir, ctx)
}
#[tokio::test]
async fn word_count_basic() {
let (dir, ctx) = make_ctx().await;
// Create a test file
let test_file = dir.path().join("hello.txt");
std::fs::write(&test_file, "hello world\nfoo bar baz\n").unwrap();
let tool = WordCount;
let result = tool.call(
json!({ "path": test_file.to_str().unwrap() }),
&ctx,
).await.unwrap();
assert_eq!(result["words"], 5);
assert_eq!(result["lines"], 2);
}
#[tokio::test]
async fn word_count_missing_file() {
let (_dir, ctx) = make_ctx().await;
let tool = WordCount;
let result = tool.call(
json!({ "path": "/nonexistent/file.txt" }),
&ctx,
).await;
assert!(result.is_err());
}
}
}
Run with:
cargo test word_count
The server-level tests in src/server.rs also verify invariants across all
registered tools:
server_registers_all_tools— checks that every tool appears inlist_toolstool_names_are_unique— no two tools share a nameall_tools_have_valid_schemas— every schema is valid JSON with atypefieldall_tools_have_descriptions— no empty descriptions
These run automatically when you add your tool to from_parts().
Summary
The full recipe:
- Create a struct:
pub struct MyTool; - Implement
Toolwithname(),description(),input_schema(),call() - Register in
from_parts()withArc::new(MyTool) - Add
OutputGuardif the tool returns unbounded lists - Write tests against
ToolContext
For the API reference of Tool, ToolContext, OutputGuard, and error
handling, see The Tool Trait.
The Tool Trait
API reference for the Tool trait and its supporting types. For a tutorial
walkthrough, see Writing Tools.
The Tool Trait
Defined in src/tools/mod.rs:
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
/// Tool name as exposed over MCP (e.g. "symbols").
/// Must be unique across all registered tools.
fn name(&self) -> &str;
/// Short description shown to the LLM in the tool listing.
/// Should be one or two sentences explaining what the tool does.
fn description(&self) -> &str;
/// JSON Schema for the tool's input parameters.
/// Must return a valid JSON Schema object with at minimum a "type" field.
fn input_schema(&self) -> Value;
/// Execute the tool with the given input.
/// `input` is already parsed from the MCP request's JSON arguments.
/// Returns a JSON value that will be serialized and sent to the LLM.
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value>;
}
}
name()
Returns the tool’s MCP identifier. This is the string the LLM uses to invoke
the tool. Must be unique across all registered tools — the
tool_names_are_unique test enforces this.
Convention: snake_case, matching the struct name in lowercase
(e.g. Symbols -> "symbols").
description()
A brief explanation shown in the MCP list_tools response. The LLM reads this
to decide which tool to use, so be precise about what the tool does and what it
does not do. Must not be empty.
input_schema()
Returns a JSON Schema object describing the tool’s parameters. Built with
serde_json::json!():
#![allow(unused)]
fn main() {
fn input_schema(&self) -> Value {
json!({
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"description": "File path (absolute or relative to project root)"
},
"detail_level": {
"type": "string",
"description": "Output detail: omit for compact, 'full' for complete"
},
"offset": {
"type": "integer",
"description": "Skip this many results (focused mode pagination)"
},
"limit": {
"type": "integer",
"description": "Max results per page (default 50)"
}
}
})
}
}
Must have "type": "object" at the root. The all_tools_have_valid_schemas
test verifies this for every registered tool.
call()
The main execution method. Receives the parsed JSON input and a ToolContext.
Returns Result<Value> — see Error Handling below.
ToolContext
Defined in src/tools/mod.rs:
#![allow(unused)]
fn main() {
pub struct ToolContext {
pub agent: Agent,
pub lsp: Arc<LspManager>,
}
}
A ToolContext is constructed fresh for each tool invocation in the server’s
call_tool() handler. Both fields are cheaply cloneable (Agent wraps an
Arc internally).
agent: Agent
The Agent holds the active project state. Key methods:
| Method | Returns | Description |
|---|---|---|
project_root().await | Option<PathBuf> | Active project root, or None |
require_project_root().await? | Result<PathBuf> | Same, but errors if no project |
security_config().await | PathSecurityConfig | Path deny-list configuration |
with_project(|proj| { ... }).await? | Result<T> | Access ActiveProject (config, memory) |
activate(path).await? | Result<()> | Switch active project |
The ActiveProject struct (accessible via with_project) contains:
| Field | Type | Description |
|---|---|---|
root | PathBuf | Project root directory |
config | ProjectConfig | Settings from .codescout/project.toml |
memory | MemoryStore | Markdown-based key-value store |
lsp: Arc<LspManager>
The LspManager manages LSP server lifecycles. Key methods:
| Method | Returns | Description |
|---|---|---|
get_or_start(lang, root).await? | Result<Arc<LspClient>> | Get or launch an LSP server |
get(lang).await | Option<Arc<LspClient>> | Get existing client without starting |
active_languages().await | Vec<String> | Languages with running servers |
shutdown_all().await | () | Stop all servers |
get_or_start() is the primary entry point. It starts a new server if none
exists, or returns the existing one if it is alive and pointed at the correct
workspace root. Dead or mismatched servers are automatically replaced.
OutputGuard
The progressive disclosure system. Defined in src/tools/output.rs.
OutputMode
#![allow(unused)]
fn main() {
pub enum OutputMode {
/// Compact output, capped at max_results / max_files.
Exploring,
/// Full detail with offset/limit pagination.
Focused,
}
}
OutputGuard struct
#![allow(unused)]
fn main() {
pub struct OutputGuard {
pub mode: OutputMode,
pub max_files: usize, // Default: 200
pub max_results: usize, // Default: 200
pub offset: usize, // Default: 0
pub limit: usize, // Default: 50
}
}
OutputGuard::from_input(input: &Value) -> Self
Constructs an OutputGuard from a tool’s JSON input by reading three optional
fields:
| Input field | Effect |
|---|---|
detail_level: "full" | Switches to Focused mode |
offset: N | Sets pagination offset (default 0) |
limit: N | Sets page size (default 50); also caps exploring mode when explicit |
Any other detail_level value (or omission) defaults to Exploring mode.
#![allow(unused)]
fn main() {
let guard = OutputGuard::from_input(&input);
}
guard.should_include_body() -> bool
Returns true in Focused mode, false in Exploring. Use this to decide
whether to include source code bodies in symbol results.
guard.cap_items<T>(items: Vec<T>, hint: &str) -> (Vec<T>, Option<OverflowInfo>)
Caps a list of items according to the active mode.
- Exploring: Keeps the first
max_resultsitems. If truncated, returnsOverflowInfowithnext_offset: None. - Focused: Applies
offset/limitpagination. If more pages remain, returnsOverflowInfowithnext_offset: Some(offset + limit).
The hint parameter is a human-readable suggestion included in the overflow
metadata (e.g. "Narrow with a path filter" or "Use offset/limit to paginate").
Returns (truncated_items, None) if everything fits.
guard.cap_files<T>(files: Vec<T>, hint: &str) -> (Vec<T>, Option<OverflowInfo>)
Same as cap_items() but uses max_files instead of max_results for the
exploring-mode cap. Use this for file-list results.
OutputGuard::overflow_json(info: &OverflowInfo) -> Value
Serializes overflow metadata to JSON for inclusion in tool responses:
{
"shown": 200,
"total": 1423,
"hint": "Narrow with a path filter",
"next_offset": 200
}
The next_offset field is only present in focused mode when more pages exist.
OverflowInfo
#![allow(unused)]
fn main() {
pub struct OverflowInfo {
pub shown: usize,
pub total: usize,
pub hint: String,
/// In focused mode, the offset for the next page (None in exploring mode).
pub next_offset: Option<usize>,
}
}
Typical usage pattern
#![allow(unused)]
fn main() {
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value> {
let guard = OutputGuard::from_input(&input);
let all_results = do_expensive_work();
let (results, overflow) = guard.cap_items(all_results, "Use offset/limit to paginate");
let mut response = json!({
"results": results,
});
if let Some(info) = overflow {
response["overflow"] = OutputGuard::overflow_json(&info);
}
Ok(response)
}
}
Error Handling
Tool errors are content, not protocol errors
When a tool’s call() returns Err(e), the server does not return an MCP
protocol error. Instead, it wraps the error message in a CallToolResult with
is_error: true:
#![allow(unused)]
fn main() {
// From src/server.rs call_tool():
match tool.call(input, &ctx).await {
Ok(output) => {
let text = serde_json::to_string_pretty(&output)
.unwrap_or_else(|_| output.to_string());
Ok(CallToolResult::success(vec![Content::text(text)]))
}
Err(e) => {
// Error surfaces to the LLM as text, not a protocol error
Ok(CallToolResult::error(vec![Content::text(e.to_string())]))
}
}
}
This means the LLM sees the error message and can react to it (e.g., try a
different path, fix a parameter). Protocol-level errors (McpError) are only
used for truly invalid requests like unknown tool names.
Error patterns
Validation errors — use anyhow::bail!() for clear, immediate failures:
#![allow(unused)]
fn main() {
if path_str.is_empty() {
anyhow::bail!("path must not be empty");
}
}
Propagation — use ? to propagate errors from I/O, LSP calls, etc.:
#![allow(unused)]
fn main() {
let content = std::fs::read_to_string(&path)?;
let client = ctx.lsp.get_or_start(lang, &root).await?;
}
Missing required parameters:
#![allow(unused)]
fn main() {
let path = input["path"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing 'path' parameter"))?;
}
Tool access control — the server checks tool access before dispatching.
Restricted tools (like run_command) are blocked at the server level,
not inside the tool itself.
What not to do
- Do not
panic!()in tools. Panics crash the server process. - Do not return
Errfor “no results found” — return an empty result set instead. Errors mean something went wrong, not that the result is empty. - Do not return
McpErrorfrom tools. That type is for the server layer only.
The #[async_trait] Requirement
All tools must be Send + Sync because the MCP server is async and supports
multiple concurrent connections. The Tool trait enforces this:
#![allow(unused)]
fn main() {
pub trait Tool: Send + Sync { ... }
}
In practice, this means:
- Tool structs must not hold non-
Sendtypes (useArc<Mutex<_>>if you need shared mutable state). - Unit structs (
pub struct MyTool;) are alwaysSend + Sync. - The
async fn call()implementation uses#[async_trait]to enable async methods in the trait. This desugars toPin<Box<dyn Future + Send>>. - Holding a
MutexGuardacross an.awaitpoint will cause a compile error. Release the guard before awaiting.
Tool Registration
Tools are registered in src/server.rs in the from_parts() method as a
Vec<Arc<dyn Tool>>. The server uses this vector for two things:
list_tools— iterates all tools and builds MCPToolInfofromname(),description(), andinput_schema().call_tool— looks up a tool by matchingtool.name()against the request’s tool name, then callstool.call().
There is no macro, attribute, or inventory system. Adding a tool is adding one line to the vector.
Diagnostic Logging
The --debug flag on the start command enables two things simultaneously:
-
Structured lifecycle logs — INFO-level events written to
.codescout/diagnostic-*.log, covering server startup, heartbeats, tool calls, and shutdown. Useful for diagnosing MCP disconnect and silence issues. -
Usage traceability — full
input_jsonandoutput_jsoncolumns written tousage.dbfor every tool call, enabling post-mortem replay of failures.
--diagnosticis a deprecated alias for--debugand will be removed in a future release.
Enabling
Pass --debug when starting the server:
codescout start --project /path/to/project --debug
Or, if using cargo run:
cargo run -- start --project . --debug
Log file
Each server instance writes to its own file:
.codescout/diagnostic-<4hex>.log
The <4hex> suffix is a random 4-character hex instance ID unique to that
process. Old files are rotated automatically: the 6 most recent
diagnostic-*.log files are kept by modification time; older ones are deleted
at startup.
What is logged
| Event | When | Fields |
|---|---|---|
codescout_start | Server boot | pid, version, project, transport, instance |
heartbeat | Every 30 s | uptime_secs, active_projects, lsp_servers |
tool_call | Tool invoked | tool, arg_keys |
tool_done | Tool returned | tool, duration_ms, ok |
service_exit | Shutdown | reason (signal name or quit reason) |
All events are INFO level and written in a structured format alongside the existing stderr INFO layer.
Reading the log
cat .codescout/diagnostic-*.log
## When to use it
- **MCP client disconnects silently** — `service_exit` captures the shutdown
reason (SIGHUP, SIGTERM, pipe close, etc.).
- **Tool call hangs** — compare `tool_call` and `tool_done` timestamps to find
which tool never returned.
- **Heartbeat gaps** — a missing heartbeat indicates the server process was
suspended or killed.
- **Reproduce a failure** — `input_json` + `output_json` in `usage.db` let you
replay a broken tool call at the exact codescout and project version that produced it.
## Debug Mode Coverage
`--debug` is the single flag for all verbose/debug behavior:
| What | Output |
|------|--------|
| Lifecycle events (start, heartbeat, tool call/done, exit) | `.codescout/diagnostic-*.log` (INFO, 6-file rotation) |
| Verbose internal state, LSP protocol traces | `.codescout/debug.log` (DEBUG level) |
| Full tool input/output JSON | `usage.db` `input_json` / `output_json` columns |
## Usage Traceability
Every tool call in `usage.db` records these columns (always, regardless of debug mode):
| Column | Description |
|--------|-------------|
| `codescout_sha` | Git SHA of the codescout binary (baked at compile time) |
| `project_sha` | Git HEAD of the active project at activation time |
| `session_id` | UUID identifying this server session |
In debug mode, two additional columns are populated:
| Column | Populated when |
|--------|---------------|
| `input_json` | Always (in debug mode) |
| `output_json` | Only on errors and recoverable errors |
### Replay Workflow
To reproduce a failed tool call:
1. Query `usage.db` for the failure (see [Reading the log](#reading-the-log) above)
2. `git checkout <project_sha>` — restore the project to its exact state
3. In the codescout repo: `git checkout <codescout_sha>` + `cargo build --release`
4. Start codescout, activate the project, re-invoke the tool with the stored `input_json`
Records are pruned after 30 days alongside normal usage records.
## Limitations
- The instance ID (`<4hex>`) is derived from `RandomState` seeded at process
start — it is not a cryptographic or globally unique ID.
- Log rotation is by mtime, not sequence number. If the filesystem does not
update mtime reliably (some network filesystems), rotation order may be
incorrect.
- `arg_keys` logs parameter names only, not values, to avoid capturing
sensitive content in log files.
- `project_sha` is captured once at `workspace(action: activate)`. If HEAD moves mid-session
(e.g. a commit lands while the server is running), the stored SHA reflects the
state at activation, not at call time — still a valid reproduction point.
# or tail the most recent:
ls -t .codescout/diagnostic-*.log | head -1 | xargs tail -f
To query failed calls with captured inputs from usage.db:
sqlite3 .codescout/usage.db \
"SELECT tool_name, input_json, codescout_sha, project_sha, called_at
FROM tool_calls
WHERE outcome != 'success' AND input_json IS NOT NULL
ORDER BY called_at DESC LIMIT 10;"
Heartbeat memory fields
When codescout runs with --debug, the periodic heartbeat tracing event now
includes per-instance memory snapshots taken from /proc/self/status:
heartbeat instance=6719 uptime_secs=30 active_projects=1 lsp_servers=[]
vm_size_kb=4911904 vm_rss_kb=47868 vm_data_kb=462344 vm_peak_kb=4912028
| Field | Source | What it tells you |
|---|---|---|
vm_size_kb | VmSize | Total virtual address space (includes jemalloc reservations). |
vm_rss_kb | VmRSS | Resident pages — the truthful “how much physical memory is this using right now”. |
vm_data_kb | VmData | Data + heap + stack pages. Grows with live allocations. |
vm_peak_kb | VmPeak | High-water mark for VmSize over the process lifetime. |
When to look
Useful when correlating long-running instance behaviour against per-project
tool call history (~/.codescout/usage.db and <project>/.codescout/usage.db).
The use case driving this addition is the open OOM investigation in
docs/issues/memory-leak-x-session-freeze.md: the kernel’s OOM dump only
captures the moment of death; the heartbeat captures the trajectory.
Platform notes
Linux only. On macOS/Windows the underlying /proc/self/status read fails
silently and all four fields log as 0; heartbeat continues regardless.
Cost
A read_to_string("/proc/self/status") plus a line-by-line parse. Runs once
every 30 seconds. Negligible.
Troubleshooting
This page covers common problems and their fixes, organized by symptom. If your issue is not listed here, see Getting Help at the bottom.
MCP Server Issues
“Tool not found” in Claude Code
The server is not registered in your MCP configuration.
Fix: Verify the server entry exists:
claude mcp list
You should see codescout listed with 28 tools. If it is missing,
register it:
claude mcp add --global codescout -- codescout start --project .
See Installation for the full setup.
Server starts but no tools appear
The project path is not set or points to a directory that does not exist.
Fix: Check the --project argument in your MCP configuration. The path
must be an absolute path to an existing directory, or . to use the current
working directory. Verify it resolves correctly:
codescout start --project /path/to/your/project
If you omit --project, codescout tries to auto-detect from the current
working directory. This works when Claude Code launches the server from within
a project, but can fail if the working directory is unexpected.
“Connection refused” or server won’t start
The binary is not installed, not on PATH, or (in HTTP mode) the port is already in use.
Fix:
# Check the binary is installed
which codescout
codescout --version
# If not found, install it
cargo install codescout
# For HTTP mode, check port conflicts
lsof -i :8080
Server crashes on startup
This usually means a required shared library is missing (common with the
local-embed feature, which needs ONNX Runtime as a native system library).
Fix: Check the error output on stderr. If it mentions libonnxruntime,
install ONNX Runtime on your system, or switch to Ollama (recommended) for
local-free embeddings without native dependencies:
# Rebuild using only the remote backend (supports Ollama, OpenAI, etc.)
git clone https://github.com/mareurs/codescout.git
cd codescout
cargo install --path . --no-default-features --features remote-embed
Then start Ollama and configure codescout to use it — see Ollama setup.
LSP and Symbol Tools
Symbol tools return empty results
The language server for that file’s language is not installed or not on PATH.
Fix: Install the required language server and verify it is accessible:
# Rust
which rust-analyzer
# Python
which pyright-langserver
# TypeScript/JavaScript
which typescript-language-server
# Go
which gopls
# Java
which jdtls
# Kotlin
which kotlin-language-server
# C/C++
which clangd
# C#
which OmniSharp
# Ruby
which solargraph
See the Language Support page for the full list.
Also: The language server may still be initializing. This is especially
common with jdtls (Java) and kotlin-language-server, which can take 10-30
seconds on first startup while they index the project. Wait a few seconds and
retry the tool call.
“No tree-sitter grammar for ‘X’”
A tree-sitter grammar was requested for a language that does not have one bundled.
Fix: Use LSP-based tools instead. symbols provides similar
information (file structure, symbol names and kinds) and works for all 9 LSP
languages.
If the language is not supported at all, only file operations and semantic search (after indexing) are available.
references returns nothing
Two common causes:
-
Language server not fully indexed. Some LSP servers need to scan the entire project before they can answer reference queries. This is especially true for Java (
jdtls), which builds a workspace model at startup. Wait for initialization to complete and retry. -
Symbol has no references. The symbol genuinely is not referenced anywhere in the project. This is correct behavior for unused code.
Symbol tools work for some files but not others
The file’s language may not be recognized, or the language server may have crashed on that specific file.
Fix: Check the server logs (stderr) for error messages from the language server. Restart the MCP server to reset all language server processes. If the problem persists with a specific file, the file may contain syntax errors that the language server cannot parse.
Semantic Search
“No results” from semantic_search
The embedding index has not been built for this project.
Fix: Build the index, then verify:
{ "tool": "index", "arguments": { "action": "build" } }
{ "tool": "workspace", "arguments": { "action": "status" } }
workspace(action: status) shows the number of indexed files and chunks. If both are zero,
the index build failed – check server logs for errors.
“Connection refused” when indexing
The default embedding backend is Ollama, which must be running locally.
Fix:
# Start Ollama
ollama serve
# Verify it's running
curl http://localhost:11434/v1/embeddings \
-d '{"model": "mxbai-embed-large", "input": "test"}'
If you do not want to run Ollama, switch to a different backend. See Embedding Backends.
“Model not found” when indexing
The configured embedding model has not been pulled into Ollama.
Fix:
ollama pull mxbai-embed-large
Or if you configured a different model in project.toml:
ollama pull <your-model-name>
Results seem wrong or irrelevant after changing the model
The index was built with a different embedding model. Vectors from different models are incompatible – mixing them produces meaningless similarity scores.
Fix: Rebuild the index from scratch:
{ "tool": "index", "arguments": { "action": "build", "force": true } }
Then verify the models match:
{ "tool": "workspace", "arguments": { "action": "status" } }
The response includes configured_model and indexed_with_model. They must
be the same.
Indexing is very slow
Embedding large codebases with Ollama on CPU can take minutes or longer.
Fix (pick one):
- Use a faster backend.
openai:text-embedding-3-smallis significantly faster than local Ollama for large projects. - Reduce scope. Add build artifacts, vendored code, and generated files to
ignored_pathsinproject.tomlso they are skipped during indexing. - Use GPU. If Ollama has GPU access, embedding is much faster. Check
ollama psto verify the model is loaded on GPU.
“No embedding backend compiled in”
The binary was built with --no-default-features and no embedding feature
was enabled.
Fix: Reinstall with an embedding backend:
# Remote backend — supports Ollama (recommended), OpenAI, and compatible servers
cargo install codescout --features remote-embed
# Local CPU backend — requires building from source (not available via crates.io)
git clone https://github.com/mareurs/codescout.git
cd codescout
cargo install --path . --features local-embed
Tip: For a free, local setup without building from source, use Ollama with the default
remote-embedbinary. See Embedding Backends.
Configuration
Changes to project.toml not taking effect
Configuration is loaded when a project is activated. Editing the file after activation does not automatically reload it.
Fix: Call workspace(action: activate) again to reload the configuration:
{ "tool": "workspace", "arguments": { "action": "activate", "path": "/path/to/project" } }
Or restart the MCP server.
“No active project” errors
The server was started without a --project flag and could not auto-detect
a project from the working directory.
Fix: Set the project explicitly:
{ "tool": "workspace", "arguments": { "action": "activate", "path": "/path/to/project" } }
Or restart the server with --project:
codescout start --project /path/to/project
File Operations
For a full explanation of the permission model, see Security & Permissions.
“Permission denied” or “Access denied” reading a file
The file is in the built-in deny list or matches a pattern in
denied_read_patterns.
The built-in deny list blocks access to sensitive locations regardless of configuration:
~/.ssh
~/.aws
~/.gnupg
~/.config/gcloud
~/.config/gh
~/.docker/config.json
~/.netrc
~/.npmrc
~/.kube/config
On Linux, /etc/shadow and /etc/gshadow are also blocked. On macOS,
/etc/master.passwd is blocked.
Fix: This is intentional security behavior. If you genuinely need access
to a blocked path, check whether it is in the built-in list (cannot be
overridden) or in denied_read_patterns in project.toml (can be removed).
See Project Configuration for details.
“Access denied” writing a file outside the project
File write tools are restricted to the project root by default.
Fix: Add the target directory to extra_write_roots in project.toml:
[security]
extra_write_roots = ["/path/to/other/directory"]
Shell commands return “shell execution is disabled”
Shell execution requires two settings to both be enabled.
Fix: Set both fields in project.toml:
[security]
shell_enabled = true
shell_command_mode = "warn" # or "unrestricted"
shell_enabled is the master switch (default: false). shell_command_mode
controls whether a warning is appended to shell output (default: "warn").
Even with shell_enabled = true, setting shell_command_mode = "disabled"
blocks all shell calls.
Git Tools
Git tools return errors
Two common causes:
-
Not a git repository. The project root does not contain a
.gitdirectory.Fix: Verify with
ls -la /path/to/project/.git. If the project is not a git repo, git tools will not work.
Performance
Slow responses on first tool call for a language
The first symbol tool call for a given language starts the language server process. Startup time varies:
| Server | Typical startup |
|---|---|
rust-analyzer | 2-5 seconds |
pyright-langserver | 1-3 seconds |
typescript-language-server | 1-2 seconds |
gopls | 1-3 seconds |
clangd | 1-2 seconds |
jdtls | 10-30 seconds |
kotlin-language-server | 5-15 seconds |
Fix: This is expected. Subsequent calls are fast because the server stays
running. If startup time is a problem for Java or Kotlin, use grep
or semantic_search for initial exploration — they have no startup delay.
Large project causes tool timeouts
The default tool timeout is 60 seconds. Operations on very large projects (indexing, initial LSP workspace scan) can exceed this.
Fix: Increase the timeout in project.toml:
[project]
tool_timeout_secs = 120
High memory usage
Language servers can use significant memory, especially jdtls and
rust-analyzer on large projects. Running multiple language servers
simultaneously compounds this.
Fix: codescout starts language servers on demand, so only languages
you actively use consume memory. When the MCP server exits (or receives
SIGINT/SIGTERM), it gracefully shuts down all language servers via the LSP
shutdown protocol. As a safety net, the LspClient Drop implementation also
sends SIGTERM to child processes, ensuring cleanup even on abrupt exits.
If you have multiple Claude Code sessions, each spawns its own codescout process with its own language servers. Close unused sessions to reclaim memory.
Getting Help
If none of the above resolves your issue:
-
Check server logs. codescout logs to stderr. In stdio mode, Claude Code captures this; look in Claude Code’s MCP server logs. In HTTP mode, stderr goes to the terminal where you started the server.
-
Enable debug logging. Set
RUST_LOG=debugfor verbose output:RUST_LOG=debug codescout start --project /path/to/projectThis shows every tool call, LSP message, and embedding operation.
-
Check the configuration. Use the
workspacetool to see the active configuration as the server sees it:{ "tool": "workspace", "arguments": { "action": "status" } } -
File an issue. Open a GitHub issue with:
- The error message (exact text)
- The tool call that triggered it
- Your
project.toml(redact any secrets) - Output from
RUST_LOG=debugif available