Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 symbol
  • symbol_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 analysis
  • edit_code — mutate code by symbol name with action: replace | insert | remove | rename (consolidates the older replace_symbol, insert_code, remove_symbol, rename_symbol into 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 optional scope parameter 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 (use index(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, not code-explorer.
  • Update your MCP config: change the server key from code-explorer to codescout.
  • 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:

WhatBeforeAfter
Binary namecode-explorercodescout
MCP server key (.mcp.json)"code-explorer""codescout"
Claude Code settings key"code-explorer""codescout"
Cargo cratecode-explorercodescout

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 consolidatedinsert_before_symbol and insert_after_symbol merged into insert_code(position: "before"|"after"). is_onboarded folded into onboarding(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.

Read the analysis →

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:

  1. Detect your project — languages, entry points, key files
  2. Start LSP servers — one per detected language (Rust analyzer, Pyright, ts-server, etc.)
  3. Generate a system prompt — a project-specific guidance block injected into every future session, covering tool selection rules, entry points, and navigation tips
  4. 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.toml manually
  • 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, and symbol_at will 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-embed Cargo feature still exists for air-gapped use but is no longer the default and is no longer the path the team benchmarks against — local-embed skips 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.

FeatureWhat it doesWhen 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-embedIn-process CPU embeddings via fastembed-rs + ONNX RuntimeAir-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 indexerAlways — runtime-enabled by default; opt out via LIBRARIAN_ENABLED=0

Want free, local embeddings without running docker? Two options:

  1. Ollama as the dense embedder. Install Ollama, pull a model (ollama pull nomic-embed-text), and set CODESCOUT_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.
  2. Build with local-embed for 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:

  1. Creates a configuration file at .codescout/project.toml with sensible defaults.
  2. Detects the languages present in the repository (based on file extensions and tree-sitter grammar support).
  3. 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 }
}

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:

  1. onboarding — discover and remember the project structure.
  2. index(action: build) — build the semantic search index.
  3. tree on the root and key subdirectories — build a mental map.
  4. symbols("src/") — see what is defined at the top level.
  5. semantic_search("entry point") or symbols("main") — find where execution starts.
  6. From there, use references to trace callers and symbols to 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

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, and ls and 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 grep for pattern search and cat for 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 grep over grep for regex search across files.
  • Prefer symbols and symbols over cat/Read when exploring code structure.
  • Prefer tree over ls and tree (with glob) over find.
  • Use semantic_search when 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 calledRedirected to
grepgrep
Readsymbols or symbols (for source files)
catsymbols or symbols (for source files)
findtree (with glob)
lstree

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

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

FeatureClaude CodeGitHub CopilotCursor
MCP protocolstdiostdiostdio
System prompt injectionAutomaticAutomaticAutomatic
Tool enforcement (routing plugin)Plugin with hooksCopilot Skill guidanceCursor Rules guidance
Workspace supportFullFullFull
OnboardingAutomaticAutomaticAutomatic

Guides

AgentGuide
Claude CodeClaude Code — primary integration with routing plugin enforcement
GitHub CopilotGitHub Copilot — VS Code extension with Skills-based guidance
CursorCursor — 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:

  • Read on source files (.rs, .ts, .py, etc.) → redirects to list_symbols / find_symbol
  • Grep / Glob on source files → redirects to search_pattern / find_file
  • Bash for shell commands → redirects to run_command

What it allows:

  • Read on 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")

See Multi-Project Workspaces.

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". If cargo is not in PATH, use the full path as the command value (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

  1. Open Settings (Ctrl+, / Cmd+,)
  2. Search for chat.useAgentSkills
  3. 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/readFile on source files (.ts, .js, .py, .rs, etc.) and redirects to the appropriate codescout tool
  • Blocks search/codebase and redirects to mcp__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" } }

See Multi-Project Workspaces.


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.md already 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 saySkill 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" (with s), unlike VS Code’s "servers". If cargo is not in PATH, use the full path as the command value (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/readFile on source files (.ts, .js, .py, .rs, etc.) and redirects to the appropriate codescout tool
  • Blocks search/codebase and redirects to mcp__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 sayRule 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 .mdc file exists and the description field 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" } }

See Multi-Project Workspaces.


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

FeatureGitHub CopilotCursor
Skills location.github/skills/<name>/SKILL.md.cursor/rules/<name>.mdc
Skills activationchat.useAgentSkills: truealwaysApply: 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:

ToolWorst case
symbols(dir)Walks the entire project, dumps every symbol in every file
symbols(pattern)Project-wide search with thousands of matches
referencesPopular 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 explicit limit in 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 (or max_files) items, discard the rest, attach an overflow object describing what was omitted.
  • In Focused mode: apply offset/limit pagination, attach an overflow object that includes next_offset when 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:

  1. Explore broadly. Use tools in their default Exploring mode to get a compact map of the area you care about.
  2. Identify your target. Read the compact output to find the file, symbol, or range that contains what you need.
  3. 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, and limit parameters 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:

ToolExploring outputFocused output
symbolsNames, kinds, files, linesFull symbol trees with bodies
symbolsNames, kinds, locations+ bodies (when include_body=true)
referencesReference locationsPaginated reference list
treeFile pathsPaginated entries
grepTop matchesPaginated full matches
semantic_searchTop matches with scoresPaginated full chunks

Tools With Fixed Output

Some tools always cap their output at a fixed limit and do not support mode switching:

ToolBehaviour
grepAlways returns up to max_results matches (default: 50)
tree (with glob)Always returns up to max_results paths (default: 100)
symbolsReturns 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

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:

  1. semantic_search("how are database errors handled") — get a list of relevant files and line ranges.
  2. symbols(found_file) — see the symbol structure around those lines.
  3. 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 pathsymbols(file)
Function/class namesymbols(pattern)
Who calls a functionreferences(name_path, file)
A concept or behavioursemantic_search(query)
Nothing (unfamiliar area)treesymbolssemantic_search
Exact string or importgrep(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 patternRedirect to
cat src/foo.rsread_file("src/foo.rs") or symbols("Foo")
grep 'fn parse' src/grep("fn parse", path="src/")
head -20 main.pyread_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_id refs rather than dumped into context
  • Workflow & Config Tools — full run_command reference 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 Integrationrun_command in full detail: safety layer, dangerous command detection, and source file access blocking
  • Workflow & Config Tools — full reference for run_command including the cwd, acknowledge_risk, and timeout_secs parameters

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:

  1. Reads available output (with a 150 ms settle window to batch bursts).
  2. Shows accumulated output in an elicitation dialog.
  3. Waits for the user to type input (or leave empty to cancel).
  4. Sends the input to the process stdin.
  5. Repeats until the process exits naturally or the user cancels.

Parameters

ParameterTypeNotes
commandstringShell command to run (required).
interactivebooleanSet true to enable interactive mode.
cwdstringSubdirectory relative to project root.
timeout_secsintegerIgnored 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: true blocks dangerous commands outright (no elicitation confirmation). Use the standard non-interactive path with acknowledge_risk: true if needed.
  • No elicitation fallback: if the MCP client does not support elicitation, a RecoverableError is returned immediately. There is no non-interactive fallback — use interactive: 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:

ScoreMeaning
> 0.85Almost certainly what you’re looking for
0.70 – 0.85Likely relevant — worth inspecting
0.50 – 0.70Tangentially related
< 0.50Probably 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.

You know…Use
The exact namesymbols(pattern)
The file it’s insymbols(path)
A text fragmentgrep(regex)
The concept, not the namesemantic_search(query)
The concept, inside a librarysemantic_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

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-embed Cargo 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-embed and accept the older sqlite-vec code path. See Migration from local-embed below.

What runs where

ServiceDefault portImage / binaryRole
Qdrant6334 (gRPC), 6333 (HTTP)qdrant/qdrant:v1.17.0Vector storage. Two collections: code_chunks, memories.
Dense embedder48081 (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 SPLADE48084 (HTTP)text-embeddings-inference running prithivida/Splade_PP_en_v1Text → sparse vector for lexical complement.
Reranker48083 (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:

ServicePortImageNotes
Qdrant6333 HTTP / 6334 gRPCqdrant/qdrant:v1.17.0Shared across all profiles.
Dense (dense-amd)48081rocm/llama.cpp:llama.cpp-b6652.amd0_rocm7.0.0_ubuntu24.04_serverllama-server --embedding --pooling mean, CodeRankEmbed-Q4_K_M.gguf.
Reranker (reranker-amd)48083same imagellama-server --reranking --pooling rank, bge-reranker-v2-m3-Q4_K_M.gguf.
Sparse (sparse-amd)48084codescout/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 video and render groups

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.cpp is AMD-built.
  • Same binary serves the dense embedder and the cross-encoder reranker (--reranking mode), 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:

EnvDefaultEffect
CODESCOUT_QDRANT_URLhttp://127.0.0.1:6334Qdrant gRPC URL
CODESCOUT_EMBEDDER_URLhttp://127.0.0.1:48081Dense embedder base URL
CODESCOUT_RERANKER_URLhttp://127.0.0.1:48083Reranker base URL
CODESCOUT_SPARSE_URLhttp://127.0.0.1:48084Sparse SPLADE base URL
CODESCOUT_EMBEDDER_PROTOCOLteitei (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_PROTOCOLteitei (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

ModelQuantizationQuery prefixScore (out of 75)Notes
CodeRankEmbedQ4_K_M (90 MB)none37Champion. Best on env-var / identifier-bag queries. Q4 loses asymmetric subspace if a prefix is forced.
CodeRankEmbedf16 (~550 MB)required34f16 with prefix peaked one point below Q4 no-prefix.
jina-embeddings-v2-base-code(native)none36Strong general-code model; +2 vs jina without sparse fusion.
Nomic Embed Code 7BQ4required24“Claimed CoIR SOTA” failed on real-world queries — bigger is not better.
Tavily-stack baseline (CodeRank, no rerank, sqlite-vec + tantivy)Q4_K_Mnone28Reference 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

ModelProtocolT5 (real-usage tier, /15)Full /75Latency (p95)
bge-reranker-v2-m3TEI1037~80 ms (GPU)
bge-reranker-baseTEI935~250 ms (CPU)
jina-rerank-v2Infinity1138 (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)

StageCPU profileGPU 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):

ProfileWall timeThroughput
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

SymptomLikely causeFix
semantic_search returns “stack unreachable”dense/sparse/rerank/qdrant container not runningdocker compose ps then start the missing profile
Empty results despite indexed datawrong project_id namespaceworkspace status to confirm the active project_id; codescout index --force to rebuild
Slow first query (10+ s)model warmup on cold containernormal — subsequent queries hit the loaded model
migrate-memories reports “db not found”legacy file at unexpected pathpass --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:

PrefixExampleWhen to use
ollama:ollama:mxbai-embed-largeLocal development — free, private, no API key
openai:openai:text-embedding-3-smallBest retrieval quality, cloud cost
custom:custom:my-model@http://host:8080Any OpenAI-compatible endpoint
local:local:AllMiniLML6V2QOffline / 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:

  1. codescout walks the project tree, skipping directories listed in ignored_paths (by default: .git, node_modules, target, __pycache__, .venv, dist, build, .codescout).
  2. 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.
  3. Each chunk is sent to the configured embedding backend, which returns a dense vector.
  4. 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 rangeInterpretation
> 0.85Strong match — the chunk directly addresses your query
0.6 – 0.85Related — the concept is present but may not be the primary focus
< 0.6Tangential — 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.

Semantic search is the entry point for concept-first exploration. After finding relevant chunks, use the symbol tools to navigate the surrounding code:

  1. semantic_search — find the files and line ranges where a concept lives.
  2. symbols on those files — see the surrounding structure.
  3. symbols with include_body: true — read the exact implementation.
  4. 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:

ModelContextChunk budget
ollama:mxbai-embed-large512 tokens~1 300 chars
ollama:nomic-embed-text8 192 tokens~20 900 chars
openai:text-embedding-3-small8 191 tokens~20 900 chars
local:JinaEmbeddingsV2BaseCode8 192 tokens~20 900 chars
local:AllMiniLML6V2Q512 tokens~1 300 chars
local:AllMiniLML6V2Q256 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 containsQuery prefix
coderank (any case)Represent this query for searching relevant code:
any other modelnone (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 to embed with no prefix; RemoteEmbedder overrides 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_query if you run an asymmetric model locally.
  • The match is a simple substring check; if a non-asymmetric model happens to contain coderank in 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 metadata column populated during indexing.
  • Embedding input is metadata + "\n" + content when 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 = NULL and 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 symbols instead, 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:

  1. 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
  2. The approximate raw source size exceeds the threshold (default 500 MB of eligible content, respecting .gitignore and hidden-file rules — same filter index(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.rs module have been deleted.

How it works

  1. Dense leg — query embedded by the dense embedder service (default localhost:48081, TEI or OpenAI protocol) and matched against the code_chunks collection’s dense vector field.
  2. Sparse leg — query embedded by the SPLADE service (default localhost:48084, TEI protocol) and matched against the same collection’s sparse vector field.
  3. 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 academic k=60 formula).
  4. 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 the lib:NAME project_id namespace.
  • If the retrieval stack is unreachable, semantic_search returns a structured error with stack-inspection hints (see src/tools/semantic/search.rs error classification).

Configuration

EnvDefaultEffect
CODESCOUT_EMBEDDER_PROTOCOLteitei 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_PROTOCOLteitei 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-varlen branch). We pin commit 1588129f93… because requirements-amd.txt and Dockerfile-amd landed 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/pytorch base. 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:

  1. Skip the flash-attention build. Upstream pins ROCm/flash-attention to gfx942. We delete that build step; PyTorch SDPA covers the gap.
  2. Force-reinstall numpy / scipy / scikit-learn after make install. requirements-amd.txt pins numpy==1.26.4 and an old accelerate that wants numpy<2. The rocm/pytorch base image ships numpy 2.x and scipy 1.15 already, so the downgrade leaves scipy._fitpack_impl linked against the wrong numpy ABI and import fails with a TypeError. We restore the base versions instead.
  3. Add three missing deps. more_itertools, psutil, and backports.tarfile are transitive requirements of transformers that 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-cpusparse-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/pytorch base. 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:

ValueSearches
"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

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/pkgorg--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:

EcosystemManifestSource Location
RustCargo.toml~/.cargo/registry/src/
Node/TypeScriptpackage.jsonnode_modules/
Pythonpyproject.toml / requirements.txt.venv/lib/pythonX.Y/site-packages/
Gogo.mod$GOMODCACHE (via go env)
Java/Kotlinbuild.gradle.kts / build.gradle / pom.xml(no local source)

How It Works

  1. Discovery — each ecosystem’s manifest file is parsed to extract dependency names. Only production dependencies are included (dev/test dependencies are skipped).

  2. Source location — for each dependency, codescout checks whether local source code exists (e.g., in node_modules/, the Cargo registry, or a Python venv).

  3. Registration — dependencies are batch-registered with DiscoveryMethod::ManifestScan. The source_available flag indicates whether the source was found locally.

  4. PrecedenceManifestScan never overwrites Manual or LspFollowThrough registrations. 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.

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

BeforeAfter
Full config object (all TOML fields)Slim orientation card
Security fields always presentSecurity fields only in RW mode
No workspace on activationworkspace array included when multi-project
No memory listmemories array (topic names)
No index statusindex.status field
Focus-switch returned minimal {activated: {project_root}}Focus-switch returns the same full card
auto_registered_libs was an array of objectsNow a summary {count, without_source}

Hint scenarios

ScenarioHint
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

FieldMeaning
primary_languageLanguage inferred from the detected manifest. null when no manifest recognised.
manifestFilename that drove detection (Cargo.toml, package.json, etc.).
entry_pointsCanonical entry-point files that exist on disk. Capped at 3.
build_commandsBuild / test / run commands for the detected manifest.
onboardedtrue when an onboarding memory exists (indicates a full onboarding has been performed at some point).

Supported manifests

ManifestLanguageNotes
package.jsontypescript if tsconfig.json also exists, else javascriptChecked first — Node projects sometimes ship a pyproject.toml for tooling.
Cargo.tomlrust
pyproject.toml / setup.pypython
go.modgo
pom.xmljava
build.gradle.kts / build.gradlekotlin

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:

SignalSource
Primary language, entry points, build commandsproject_hints — always populated when a manifest exists
README summary, architecture notes, memory writesonboarding — only after full scan
System prompt draftonboarding — 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.json presence for TS vs JS disambiguation.
  • First manifest match wins. package.json takes 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:

URIContents
doc://progressive-disclosuredocs/PROGRESSIVE_DISCOVERABILITY.md — output sizing, overflow hints
doc://tool-misbehaviorsdocs/TODO-tool-misbehaviors.md — living log of observed tool bugs
doc://codescout-tool-guideGenerated per-tool long-form usage notes
memory://<name>One resource per file in the active project’s memory directory
project://summaryJSON 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 text
  • semantic_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

FieldMeaning
windowTime window analysed (default 30d).
low_call_thresholdTools called fewer than this many times are flagged as prune_candidates.
total_callsSum of calls across all tools in the window.
toolsPer-tool stats from usage.db, ordered by call count descending.
prune_candidatesKnown tools with 1 ≤ calls < low_call_threshold.
unused_toolsCurrently-registered tools that were never called in the window.

Behaviour

  • If usage.db doesn’t exist for the active project (fresh install), all counts are zero and every registered tool appears in unused_tools.
  • The window is currently fixed at 30d. Future work: accept a query parameter to change it.
  • prune_candidates uses a strict < comparison, so a tool called exactly low_call_threshold times is not flagged.
  • Sorting: tools mirrors usage-DB ordering (descending calls). unused_tools is sorted alphabetically for deterministic output.

Typical workflow

  1. Review the report monthly.
  2. For every name in unused_tools: check if any known MCP client would ever plausibly use it. If not, consider unregistering.
  3. For every name in prune_candidates: look at the p99_ms and error_rate_pct. Expensive + rarely-useful tools are the highest-value pruning targets.
  4. 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

  1. Seed project registry. librarian-mcp reads a user-maintained TOML listing every repo to index. By default: ~/.codescout-registry.toml (or override via CODESCOUT_REGISTRY env). Format:

    [[projects]]
    name = "codescout"
    path = "/home/you/work/codescout"
    
    [[projects]]
    name = "backend"
    path = "/home/you/work/backend"
    
  2. Generate workspace config. Run once:

    ./target/release/librarian-mcp import-codescout
    

    Writes ~/.config/librarian/workspace.toml with the seeded projects plus 9 default classification rules (spec / plan / memory / roadmap / adr / audit / handoff / runbook / doc).

  3. Index. Populate the catalog:

    ./target/release/librarian-mcp reindex          # incremental
    ./target/release/librarian-mcp reindex --force  # wipe + rebuild
    
  4. Wire into Claude Code:

    claude mcp add librarian-mcp /absolute/path/to/target/release/librarian-mcp
    

    Full Claude Code restart to surface the tools.

Tools

ToolPurpose
artifact_findSearch by filter AST (kind/status/tags/updated_at) with optional semantic query
artifact_getFetch one artifact, optionally with observations + link neighbourhood
artifact_list_by_kindThin wrapper — {kind, status?}
artifact_linksOutgoing / incoming edges, optionally filtered by relation
artifact_graphBFS neighbourhood, depth 1–3
artifact_createWrite a new markdown file with frontmatter + index it
artifact_updatePatch frontmatter or body; round-trips through the file
artifact_linkAdd a relation edge; supersedes transitions dst.status = “superseded”
artifact_observeAppend a note to an artifact’s observation log
librarian_reindexManual re-scan. {repo?, force?}
librarian_contextPack 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:

  1. Frontmatter (authoritative if present) — kind, status, title, owners, tags, topic, time_scope.
  2. Rule match on relative path — compiled glob patterns from workspace.toml under [[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 the Embedder trait + 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 via rmcp.
  • ~/.config/librarian/workspace.toml — roots + ignore globs + classification rules.
  • ~/.local/share/librarian/catalog.db — SQLite database (override with LIBRARIAN_DB).

Known limits

  • Indexing is on-demand via librarian_reindex or 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-codescout reads a user-maintained TOML.
  • Title derivation falls back to the first # H1 when frontmatter has no title — files with neither land with title: null.
  • 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.

KnobValueEffect
LIBRARIAN_ENABLED env0 / false / off / noDisable for this codescout process
LIBRARIAN_ENABLED env1 / true / on / yesForce enable (overrides project.toml)
[librarian] enabled = false in <project>/.codescout/project.tomlboolPer-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 instructions field, 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.

ScopeCoverage
project (default)Current sub-project only
repoWhole workspace root (all sub-projects under it)
umbrellaAll members of the declared umbrella for the current project
allWorkspace-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

ToolActionsReplaces
artifactfind, get, create, update, link, graph, state_atartifact_find, artifact_get, artifact_create, artifact_update, artifact_link, artifact_graph, artifact_state_at
artifact_eventcreate, listartifact_event_create, artifact_timeline
artifact_augment(unchanged)artifact_augment
artifact_refreshgather, list_staleartifact_refresh, artifact_refresh_stale
librariancontext, reindex, tracker_design, workspace_state_atlibrarian_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 lifecyclegather / commit_refresh / append_mode / history_cap
  • Archiving / Movingartifact(action="update", patch={status:"archived"}) and artifact(action="move")
  • Common mistakes — filter format inversion, forgetting repo on 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.

FieldDefaultNotes
threshold_hours24Hours since last refresh to consider stale
limit10Max 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=umbrella is 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

ParameterRequiredDescription
idyes16-hex artifact ID
new_rel_pathyesDestination 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

  1. Resolves the artifact’s current file path from the catalog
  2. Calls std::fs::rename (atomic on same filesystem)
  3. Creates any missing parent directories at the destination
  4. Updates rel_path, updated_at, file_mtime, and file_sha256 in 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 idRecoverableError
  • 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

  1. Pick an archetype — match intent to one of 6 archetypes.
  2. Write the augmentation prompt — imperative, names sources, states conflict resolution.
  3. Design params — live state only, flat, stable keys.
  4. Decide schema discipline — loose early, lock when mature.
  5. Compose render_template — MiniJinja projecting params to markdown.
  6. Sketch body skeleton — prose sections + History block.
  7. Check for collisionsexisting_trackers prevents duplicate concerns.

Archetypes

NameWhen to use
deployment_stateFeature flag / env rollout state per environment
failure_tableNumbered F-N list from a test/eval suite
metric_baselineLiving benchmark log with baseline + session deltas
audit_issuesNumbered audit output with severity + status
task_listPhase-based task list with done/open/blocked status
reflectiveDesign 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_trackers is capped at 30 entries (scope=repo).
  • intent field 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

ParameterTypeDefaultDescription
commitstringCommit hash as cutoff. Exactly one of commit / timestamp required.
timestampintegerUnix epoch ms as cutoff. Exactly one of commit / timestamp required.
scopestringprojectproject | repo | umbrella | all
kindsstring[]allFilter by artifact kind (e.g. ["spec", "adr"]).
include_archivedbooleanfalseInclude archived / superseded artifacts.
freshness_filterstring[]allOnly 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 with created_at ≤ cutoff. This is the true historical freshness.
  • freshness_now — computed from all events without a cutoff (current state). Uses file_mtime from the current artifact row in both cases.
  • freshness_changed = true when 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_context read time against current params.
  • Errors in template evaluation are surfaced inline (not fatal).
  • Omit the field for reflective trackers — 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, pattern constraints 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_context call — 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

LanguageIdle TTL
Kotlin2 hours
All others30 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:

  1. codescout sends a shutdown request and exit notification to the server.
  2. The server process is removed from the pool.
  3. 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:

  1. Call onboarding — it lists existing memories and skips heavy discovery if memories are already written
  2. 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 Toolsonboarding integrates 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 behaviorArchitectural decisions and their rationale
Security or access rulesGotchas and non-obvious interactions
Things that must fire before the agent does anythingDebugging 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

AgentConfig file
Claude CodeCLAUDE.md
GitHub Copilot.github/copilot-instructions.md
Cursor.cursorrules or AGENTS.md
Codex / OpenAI agentsAGENTS.md
GenericAGENTS.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 memory tool reference
  • Onboarding — what onboarding writes 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

ParameterTypeNotes
topicstringMemory topic to read (required).
sectionsstring[]One or more ### Heading names to return. Omit to return the full file.
privatebooleantrue 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

FlagDefaultDescription
--host127.0.0.1Bind address
--port8099Port
--no-openoffDisable 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 memory tool that backs the Memories browser
  • Semantic Search Toolsworkspace(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.db backs 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
  • Reasonnew_session, idle_evicted, lru_evicted, or crashed
  • Handshake duration — time for the LSP initialize round 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_ms may be null if 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 Toolsworkspace(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:

OperationDefaultRestriction
ReadPermissive — anywhere on diskDeny-list of sensitive locations
WriteRestricted — project root onlyHard boundary; opt-in escapes via config
ShellDisabled by defaultTwo-field opt-in; cwd sandboxed to project root
GitEnabledCan 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:

  1. Deny list first — the target path is checked against the built-in deny list and denied_read_patterns. Even extra_write_roots cannot bypass this.
  2. Boundary check — the canonicalized path must fall under the project root or an explicitly configured extra_write_roots entry.
  3. 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_modeBehaviour
"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) require acknowledge_risk: true in run_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 bypassedrun_command executes 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 MUST workspace(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:

NeedPattern
Quick lookup (1–3 calls)Pass project: "<id>" on the tool call — no state change, no risk
Sustained explorationworkspace(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_compact parameter)
  • codescout-companion plugin with the PostCompact hook registered in hooks/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:

  1. In-process mutextokio::sync::Mutex<()> per ActiveProject. Acquired first. Serializes concurrent write-tool calls within a single codescout process.
  2. Cross-process flockfs4 advisory 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.md file — generated by the onboarding() tool — verbatim into every agent’s context. This file contains project-specific navigation hints, entry points, and memory pointers that onboarding() tailored to the codebase.

The plugin should be updated whenever codescout adds features that affect exploration workflows.

Hooks

HookEventWhat it does
session-start.shSessionStartInjects tool guide, memory hints, drift warnings, and onboarding nudge into the main agent
subagent-guidance.shSubagentStartInjects compact tool-use directive + system-prompt.md into every subagent
pre-tool-guard.shPreToolUse on Read/Grep/Glob/BashHard-blocks Read/Grep/Glob and Bash cat/grep/head/tail/sed -i on source files; redirects to codescout equivalents
worktree-activate.shPostToolUse on EnterWorktreeSymlinks .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:

  1. Queries the embeddings DB for the last-indexed commit
  2. Compares against git rev-parse HEAD
  3. If stale, triggers codescout index --project . in the background (non-blocking)
  4. 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

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:

  1. brainstorming — refines rough ideas, explores alternatives, validates design
  2. using-git-worktrees — creates an isolated workspace on a new branch
  3. writing-plans — breaks the design into 2–5 minute tasks with exact file paths and verification steps
  4. subagent-driven-development / executing-plans — dispatches fresh subagents per task, or works in human-checkpoint batches
  5. 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.sh hook auto-calls workspace(action: activate) when EnterWorktree fires

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
FieldTypeDefaultDescription
namestringdirectory basenameHuman-readable project name shown in tool output.
languagesarray of strings[]Languages detected in the project. Populated automatically by onboarding. You can set this manually if auto-detection is wrong.
encodingstring"utf-8"Character encoding used when reading source files.
tool_timeout_secsinteger60Maximum 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
FieldTypeDefaultDescription
modelstring"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_enabledbooltrueEnable 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/token formula at 85 % utilisation. There is no chunk_size or chunk_overlap setting — they were removed because manual tuning was error-prone and the model string already encodes everything needed. Existing project.toml files 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",
]
FieldTypeDefaultDescription
patternsarray 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
FieldTypeDefaultDescription
denied_read_patternsarray of strings[]Additional path prefixes to block from read_file and other read tools, beyond the built-in deny-list (see below).
extra_write_rootsarray of strings[]Additional directories where file write tools are allowed. By default writes are restricted to the project root.
shell_command_modestring"warn"Controls run_command behaviour. One of "unrestricted", "warn", or "disabled".
shell_output_limit_bytesinteger102400Maximum bytes captured from shell command stdout or stderr. Output beyond this limit is truncated and flagged in the response.
shell_enabledboolfalseMaster switch for shell execution. Must be true for run_command to run any command regardless of shell_command_mode.
file_write_enabledbooltrueEnables file write tools: create_file and the symbol write tools. Set to false for a read-only session.
indexing_enabledbooltrueEnables 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):

ValueBehaviour
"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:

  1. Looks for .codescout/project.toml in the project root.
  2. If found, parses it. Any section that is missing falls back to its defaults.
  3. 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

FieldRequiredDescription
idYesUnique project identifier, used in project parameter across tools
rootYesPath relative to workspace root
languagesNoRestrict LSP servers to listed languages
depends_onNoProject 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

LayerPath
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.
  • HOME fallback used when XDG_CONFIG_HOME is 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

BackendSpeedQualityCostPrivacySetup
Local (fastembed, default)Fast (CPU)Good–ExcellentFreeFully localBundled — no setup needed
OllamaMediumGoodFreeLocalInstall Ollama + pull model
OpenAIFast (network)ExcellentPay-per-tokenData sent to OpenAISet OPENAI_API_KEY
Custom endpointVariesVariesVariesDepends on hostPoint at any compatible server

onboarding picks the best model for your machine automatically. This table is a reference for manual overrides or when comparing options.

Model stringBackendDimsContextCode qualityNotes
local:AllMiniLML6V2Qfastembed384256 tokGoodDefault. 22 MB, zero-config, bundled.
local:JinaEmbeddingsV2BaseCodefastembed7688192 tokExcellentRecommended (CPU-only). Code-specific.
ollama:nomic-embed-textOllama7688192 tokGoodRecommended if Ollama is already running.
ollama:bge-m3Ollama10248192 tokExcellentBest Ollama quality; slower, ~1.2 GB.
openai:text-embedding-3-smallOpenAI1536ExcellentBest quality/cost if cloud is acceptable.
openai:text-embedding-3-largeOpenAI3072BestOverkill for most codebases.
ollama:mxbai-embed-largeOllama1024512 tokGoodLegacy. 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

  1. Install Ollama from ollama.com.

  2. Pull the embedding model:

    ollama pull mxbai-embed-large
    
  3. 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]
ModelDimensionsContextNotes
nomic-embed-text7688192 tokRecommended default. Fast indexing, 137 MB.
bge-m310248192 tokBest retrieval quality; ~1.2 GB download.
mxbai-embed-large1024512 tokLegacy; 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"
ModelDimensionsNotes
text-embedding-3-small1536Low cost, good quality, recommended
text-embedding-3-large3072Highest quality, higher cost
text-embedding-ada-0021536Legacy 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 stringDimensionsDownload sizeNotes
local:JinaEmbeddingsV2BaseCode768~300 MBCode-specific, highest quality for source code
local:AllMiniLML6V2Q384~22 MBINT8-quantized, CPU-safe, recommended for most users
local:BGESmallENV15Q384~20 MBGPU-optimized export; may fail on CPU-only machines
local:BGESmallENV15384~65 MBFull f32 precision variant of BGESmallENV15Q
local:AllMiniLML6V2384~90 MBFull 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 chooseonboarding probes your hardware and writes the recommended model into .codescout/project.toml automatically. The decision tree below is for manual overrides.

  • Default / getting startedlocal:AllMiniLML6V2Q — bundled, 22 MB, no setup. Already active out of the box; no config change needed.
  • Better code search, can build from sourcelocal:JinaEmbeddingsV2BaseCode (code-specific, 8192-token context, ~300 MB). Build with --features local-embed from the repository. Outperforms general-purpose models on code.
  • Ollama is already runningollama:nomic-embed-text (fast, 8192-token context, 137 MB). Upgrade to ollama:bge-m3 for higher retrieval quality at the cost of a 1.2 GB download.
  • Best search quality, cloud acceptableopenai:text-embedding-3-small.
  • Air-gapped or full data privacy requiredlocal:JinaEmbeddingsV2BaseCode or local: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] in project.toml). The [embeddings] config block still loads but only the model = "local:..." path is honoured — and only when the binary was built with the local-embed Cargo 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_key fields in project.toml no longer drive search. Run codescout migrate-memories to 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.

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:43300
  • http://127.0.0.1:43300/v1
  • http://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

FieldTypeDefaultDescription
modelstring"local:AllMiniLML6V2Q"Model name. With url: sent in API body. Without url: prefix determines backend.
urlstring(none)Base URL for any OpenAI-compatible /v1/embeddings endpoint.
api_keystring(none)API key sent as Bearer token. Also available via EMBED_API_KEY env var.
drift_detection_enabledbooltrueTrack how much code meaning changes between index builds.

Resolution Order

When codescout needs to embed text, it resolves the backend in this order:

  1. url is set → use it as an OpenAI-compatible endpoint
  2. model starts with local: → bundled ONNX model via fastembed
  3. model starts with ollama: → Ollama API (deprecated — use url instead)
  4. model starts with openai: → OpenAI API with OPENAI_API_KEY
  5. No url, no prefix → try as a local model name, then error with suggestions

Environment Variables

VariableDescription
EMBED_API_KEYAPI key for the embedding endpoint (alternative to config field)
OPENAI_API_KEYOpenAI API key (used with openai: prefix)
OLLAMA_HOSTOllama daemon URL (deprecated — use url field)

Model Recommendations

Minimum recommended: 768 dimensions for good code search quality.

ModelDimsDownloadContextBest For
nomic-embed-text-v1.5768~158 MB (Q) / ~547 MB8192General purpose, good quality
jina-embeddings-v2-base-en768~300 MB8192Code-specialized
bge-m31024~1.2 GB8192Best quality, needs external server
CodeSage-small-v21024~500 MBPurpose-built for code retrieval
text-embedding-3-small1536API only8191OpenAI hosted, no self-hosting

Bundled Local Models

These work with the local: prefix (no server needed):

Model IDDimsSizeContextNotes
NomicEmbedTextV15Q768~158 MB8192General purpose, good quality
NomicEmbedTextV15768~547 MB8192Full precision variant
JinaEmbeddingsV2BaseCode768~300 MB8192Code-specialized
AllMiniLML6V2Q384~22 MB256Default — bundled, zero-config
AllMiniLML6V2384~90 MB256Full precision lightweight

How It Works

  1. AST-aware chunking — tree-sitter extracts top-level definitions (functions, classes, structs). Each chunk is a complete semantic unit, not an arbitrary text window.

  2. Chunk size auto-derived — codescout calculates chunk size from the model’s context window. No manual tuning needed.

  3. Vector storage — embeddings are upserted into Qdrant’s code_chunks collection over gRPC (default localhost: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.

  4. Bundled model lifecycle — when using the local: prefix (compile-time local-embed feature), the ONNX model is loaded lazily on first semantic_search or index(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:

  1. Download the model on an unrestricted machine
  2. Copy to ~/.cache/huggingface/hub/models--nomic-ai--nomic-embed-text-v1.5/
  3. 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

ModelDimsContextSizeBackendSetup
local:AllMiniLML6V2Q384256 tok22 MBBundled ONNX (CPU)None — works out of the box
nomic-embed-text7688,192 tok274 MBOllamaollama pull nomic-embed-text
nomic-embed-code (Q4_K_M)358432,768 tok4.1 GBllama.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

TierWhat it testsBest modelScore
1. Direct Concept (5 queries)Single named type, module, or featurenomic-embed-text12/15
2. Two-Concept (7 queries)Relationship between two conceptsnomic-embed-code17/21
3. Cross-Cutting (5 queries)Three+ concepts, architectural flowsnomic-embed-code7/15
4. Architectural (3 queries)Design invariants, consistency patternsAllMiniLML6V2Q5/9

No single model dominates all tiers.

Practical Metrics

MetricAllMiniLML6V2Qnomic-embed-textnomic-embed-code
Index time (417 files)70 seconds60 seconds25 minutes
DB size71 MB55 MB372 MB
Chunk count32,09811,88711,868
RequiresNothingOllama runningGPU + llama.cpp server

Analysis of 31,674 tool calls across 70+ real projects:

  • symbols17.8% of all calls (the workhorse)
  • grep2.3%
  • semantic_search1.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.

FactorWhy the default wins
Score34/60 — within 2 points of the best model (36/60)
Speed70 seconds vs 25 minutes — 21x faster indexing
SetupZero. No Ollama, no GPU, no server to manage
Storage71 MB — reasonable for any machine
PrecisionBest 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" and model = "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

LanguageExtensionsLSP ServerSupport Level
Bash.sh, .bashbash-language-serverFull
Go.gogoplsFull
Java.javajdtlsFull
Kotlin.kt, .ktskotlin-lsp (JetBrains)Full
Python.pypyright-langserverFull
Rust.rsrust-analyzerFull
TypeScript.tstypescript-language-serverFull
TSX.tsxtypescript-language-serverFull
JavaScript.jstypescript-language-serverLSP only
JSX.jsxtypescript-language-serverLSP only
C.cclangdLSP only
C++.cpp, .cc, .cxxclangdLSP only
C#.csOmniSharpLSP only
Ruby.rbsolargraphLSP only
HTML.html, .htmvscode-html-language-serverLSP only
CSS.cssvscode-css-language-serverLSP only
SCSS.scssvscode-css-language-serverLSP only
Less.lessvscode-css-language-serverLSP 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.

LanguageExtensions
PHP.php
Swift.swift
Scala.scala
Elixir.ex, .exs
Haskell.hs
Lua.lua
Markdown.md

Feature Matrix

FeatureFull supportLSP onlyDetection only
symbolsYesYesNo
referencesYesYesNo
symbol_atYesYesNo
call_graphYesYesNo
edit_codeYesYesNo
semantic_searchYesYesYes
File toolsYesYesYes

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

CapabilityAvailable
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-server is invoked with start (positional argument), not --stdio — the invocation differs from most other LSP servers.
  • Symbol extraction covers function_definition nodes 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

  1. First codescout instance needing Kotlin LSP acquires an exclusive file lock (flock), spawns codescout mux, and connects as a client.
  2. Subsequent instances find the lock held, skip spawning, and connect directly to the existing mux socket.
  3. 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).
  4. 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

MetricBefore (2 instances)After (2 instances)
kotlin-lsp JVMs2 (~3-4GB total)1 (~2GB)
Gradle daemons2 (competing)1 (shared)
Cold start on 2nd session8-15s0s (mux already warm)
Typical LSP response120s+ timeout30-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 mux flag in LspServerConfig makes 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 serializationworkspace/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-analyzer per (project-root) across all codescout instances 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 extra codescout instance.

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-analyzer must be on PATH.
  • If two clients connect before rust-analyzer completes 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.

ToolDescription
symbolsFind symbols by name pattern across the project or within a file; also provides a symbol tree for a file, directory, or glob
symbol_atInspect a symbol at a position via LSP — definition location and/or hover (type + docs); auto-discovers libraries
referencesAll callers and usages of a given symbol
call_graphTransitive call graph for a symbol — callers, callees, or both
edit_codeMutate 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.

ToolDescription
read_fileRead lines from a file, with optional range and pagination
read_markdownRead a Markdown file with heading-based navigation
treeList files and directories, optionally recursive; also finds files by glob pattern, respecting .gitignore
grepSearch file contents with a regex pattern
create_fileCreate or overwrite a file with given content
edit_fileFind-and-replace editing within a file
edit_markdownEdit 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).

ToolDescription
semantic_searchSearch code by natural language description or code snippet
indexBuild 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.

ToolDescription
libraryShow 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:

ValueWhat 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.

ToolDescription
memoryRead, write, list, or delete memory entries via the action param

Workflow & Config

Project setup, shell execution, and server configuration.

ToolDescription
onboardingInitial project discovery: detect languages, read key files, write startup memory
run_commandRun a shell command in the project root and return stdout/stderr
approve_writeGrant write access to a directory outside the project root for this session
workspaceSwitch 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 containssymbols
Find where a function is definedsymbols
Jump to a symbol’s definitionsymbol_at with fields: ["def"]
Get type info or docs for a symbolsymbol_at with fields: ["hover"]
Find all callers of a functionreferences
Rewrite a function bodyedit_code(action="replace")
Add a new function next to an existing oneedit_code(action="insert")
Delete a symbol entirelyedit_code(action="remove")
Rename a function everywhereedit_code(action="rename")
Find code that does something (concept, not name)semantic_search
Find code by concept inside a librarysemantic_search with scope: "lib:<name>" (after index(action: build) on the library)
See what third-party libraries are registeredlibrary(action: list)
Check index health, file count, drift scoresindex(action: status)
Check project config and usage statsworkspace(action: status)
Search for a string or regex across filesgrep
Find files matching a name patterntree (with glob)
Read a specific part of a fileread_file (with start_line/end_line)
Remember a decision for the next sessionmemory with action: "write"
Run a build or test commandrun_command
Orient yourself in a new projectonboarding

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

ToolOld paramNew param
symbolsname_pathsymbol
symbolspatternquery
referencesname_pathsymbol
symbol_atname_pathsymbol
replace_symbolname_pathsymbol
remove_symbolname_pathsymbol
rename_symbolname_pathsymbol
semantic_searchprojectproject_id
memoryprojectproject_id

Tool renames

Old nameNew name
search_patterngrep
find_fileglob

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

QueryDetected asAction
handle_eventplain substringnormal symbol search
handle_.*_eventregex-likeRecoverableError → redirect to grep
^MyStruct$regex-likeRecoverableError → redirect to grep
foo|barregex-likeRecoverableError → 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_level controls 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:

NameTypeRequiredDefaultDescription
relative_pathstringnoproject rootFile, directory, or glob pattern (e.g. src/**/*.rs)
depthintegerno1Depth of children to include (0 = top-level names only, 1 = direct children)
detail_levelstringnoexploring"full" activates focused mode with symbol bodies
offsetintegerno0Skip this many files (focused mode pagination)
limitintegerno50Max 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 overflow object with a hint. Narrow with a more specific path or glob.
  • For deep class hierarchies, increase depth to 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:

NameTypeRequiredDefaultDescription
patternstringyesSymbol name or substring (case-insensitive)
relative_pathstringnoRestrict to this file or glob pattern
include_bodybooleannofalseInclude source body in results
depthintegerno0Depth of children to include
detail_levelstringnoexploring"full" for bodies and pagination
offsetintegerno0Skip this many results
limitintegerno50Max 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" matches AuthService, authenticate_user, and reauth_token.
  • Without relative_path, uses workspace/symbol (one LSP request per language), which is fast. With relative_path, uses per-file document symbols, which is slower but scoped.
  • name_path in the result uses / as a separator for nested symbols, e.g. AuthService/authenticate_user. You need this value for references, edit_code, and related editing tools.
  • Use include_body: true in 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:

NameTypeRequiredDefaultDescription
name_pathstringyesSymbol identifier, e.g. "MyStruct/my_method"
relative_pathstringyesFile that contains the symbol definition
detail_levelstringnoexploring"full" for pagination
offsetintegerno0Skip this many results
limitintegerno50Max 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_path and relative_path are required. The LSP needs to locate the symbol’s definition position before it can find references.
  • name_path must match the name_path value from symbols or symbols output, 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 context line 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" with offset/limit pagination.

replace_symbol

Renamed in v0.11. The standalone replace_symbol tool was consolidated into the unified edit_code tool. Use edit_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. Use edit_code(action="insert", symbol, path, body, position="before"|"after") instead — see edit_code.


rename_symbol

Renamed in v0.11. Consolidated into edit_code. Use edit_code(action="rename", symbol, path, new_name) instead — see edit_code. The implementation still goes through LSP workspace/rename and sweeps textual occurrences in comments and strings.


remove_symbol

Renamed in v0.11. Consolidated into edit_code. Use edit_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:

NameTypeRequiredDefaultDescription
pathstringyesFile path (relative or absolute)
lineintegeryes1-indexed line number
colintegerno1-indexed column. Preferred when known — LSP-native, no identifier-mismatch risk
identifierstringnoOptional identifier on the line to target (fallback when col not known)
fieldsarray of stringsno["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_at to 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, or fields: ["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 to identifier to locate by name on the line; prefer col to avoid identifier-mismatch errors.
  • When the definition is in an external library, run index(action: build, scope: "lib:<name>") to enable semantic_search across it.
  • For hover, if content is null, the language server has no information at that position — try adjusting line/col or supplying identifier.

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

ModeTriggered whenShows
full_tree≤ 15 filesAll symbols in all files — same as before
class_overview16–80 filesClass / struct / type names only, one line per file
directory_map> 80 filesSubdirectory 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_overview requires 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 callHierarchy is partial; expect source: "ts" edges for most Kotlin queries.
  • Cross-project edges are not supported in v1.
  • direction="callees" via tree-sitter fallback is not supported; a RecoverableError is returned for languages where LSP callHierarchy is unavailable. See docs/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_id refs rather than dumped into context.


read_file

Purpose: Read the contents of a file, optionally restricted to a line range.

Parameters:

NameTypeRequiredDefaultDescription
pathstringyesFile path relative to project root
start_lineintegernoFirst line to return (1-indexed)
end_lineintegernoLast 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 symbols or symbols to locate the line range of a function before calling read_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_line over reading the whole file.
  • If you want to search for a pattern rather than read, use grep instead.

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:

NameTypeRequiredDefaultDescription
pathstringyesDirectory path relative to project root
recursivebooleannofalseDescend into subdirectories
detail_levelstringnocompact"full" to show all entries without the exploring-mode cap
offsetintegerno0Skip this many entries (focused-mode pagination)
limitintegerno50Max 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=true only when you need the full tree. On large repositories a recursive walk can produce many entries; narrow it with a more specific path if 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:

NameTypeRequiredDefaultDescription
patternstringyesRegular expression to search for
pathstringnoproject rootDirectory to restrict the search to
max_resultsintegerno50Maximum 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 path to narrow the search when you already know which part of the codebase is relevant — this is significantly faster on large repos.
  • Increase max_results if you expect many matches and need to see them all.
  • When you know a symbol name, symbols is more precise than a regex search because it uses the LSP index. Use grep when 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:

NameTypeRequiredDefaultDescription
patternstringyesGlob pattern (e.g. **/*.rs, src/**/mod.rs)
pathstringnoproject rootDirectory to search within
max_resultsintegerno100Maximum 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) over tree when you are looking for files by name — the glob match is more expressive than scanning a directory tree manually.
  • Use grep when 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 **/*.rs or 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:

NameTypeRequiredDefaultDescription
pathstringyesFile path relative to project root
contentstringyesFull 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_file instead.

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:

NameTypeRequiredDefaultDescription
pathstringyesFile path relative to project root
old_stringstringyesExact text to find (must match including whitespace)
new_stringstringyesReplacement text
replace_allbooleannofalseReplace every occurrence instead of just the first
insertstringno"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_string must 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 editingedit_file finds and replaces an exact string in a file.
  • Symbol-level editingedit_code mutates 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

SituationRecommended tool
Rewrite a function or method bodyedit_code(action="replace")
Add a new function next to an existing oneedit_code(action="insert")
Delete a function, struct, or methodedit_code(action="remove")
Rename a symbol everywhere it is usededit_code(action="rename")
Change a string, constant, or small code fragmentedit_file
Edit a config file, Markdown, or other non-code fileedit_file
Create a new filecreate_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:

NameTypeRequiredDefaultDescription
pathstringyesDestination path, relative to project root
contentstringyesFull 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_file or 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.rs and ./src/util/helpers.rs both 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:

NameTypeRequiredDefaultDescription
pathstringyesFile to edit, relative to project root
old_stringstringyes*Exact text to find, including whitespace and indentation. *Not required when using insert.
new_stringstringyesReplacement text. Set to "" to delete the match.
replace_allbooleannofalseReplace every occurrence instead of requiring a unique match.
insertstringno"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_string must match exactly — include any leading whitespace and indentation.
  • Use grep first to verify the exact text if you are unsure what to match.
  • If you get a multiple-matches error, expand old_string to include enough surrounding context to make it unique, or use replace_all: true.
  • Use insert: "prepend" or insert: "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:

edit_code documentation →

Quick mapping of the four actions:

ActionPurpose
replaceOverwrite the body of a named symbol
insertInject code before or after a named symbol (position: "before"|"after")
removeDelete a named symbol and its full body
renameRename 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

NameTypeRequiredDescription
symbolstringyesSymbol identifier — plain name (my_fn) or hierarchical (MyStruct/my_method)
pathstringyesFile containing the symbol
actionstringyesOne of replace, insert, remove, rename
bodystringaction-dependentreplace: new full body; insert: code to inject
positionstringnoinsert only — "before" or "after" the symbol (default "after")
new_namestringrename onlyNew 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

ScenarioTool
Change a function or method bodyedit_code(action="replace")
Add a sibling method or definitionedit_code(action="insert")
Delete a function, struct, or methodedit_code(action="remove")
Rename a symbol project-wideedit_code(action="rename")
Change an import or use lineedit_file
Change a constant valueedit_file
Edit a config or data fileedit_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:

  1. The edit spans multiple lines (single-line edits always pass through)
  2. The target file is a source file (not markdown, TOML, JSON, etc.)
  3. The language has LSP support in codescout (Rust, Python, Go, TypeScript/JS, Java, Kotlin, C/C++, C#, Ruby)
  4. The old_string contains 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

LanguageBlocked keywords
Rustfn, async fn, struct, impl, trait, enum
Pythondef, async def, class
Gofunc, struct, interface
TypeScript / JSfunction, async function, class, interface, enum
Javaclass, interface, enum
Kotlinfun, class, interface, enum
C / C++struct, class, enum
C#class, struct, interface, enum
Rubydef, 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 shapeSuggested tool
new_string is emptyedit_code(action="remove")
new_string is longer than old_stringedit_code(action="insert")
Replacing a function/struct bodyedit_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:

FeatureToolPurpose
edit_sectionNew toolReplace, insert, or remove entire sections by heading
headings=[]read_file paramRead multiple sections in one call
heading=edit_file paramScope string matching to a section
edits=[]edit_file paramAtomic batch edits, optionally heading-scoped
mode="complete"read_file paramFull plan file inline with delivery receipt
Fuzzy heading matchingAll heading paramsStrips formatting, prefix/substring fallback
Section coverageAutomaticTracks which sections you’ve read, hints on writes
StepToolPurpose
1read_file(path)Get heading map — see all sections
2read_file(path, headings=[...])Read target sections (one call)
3aedit_section(path, heading, action, content)Whole-section: replace, insert, remove
3bedit_file(path, heading=, old_string, new_string)Surgical: scoped string replacement
3cedit_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:

NameTypeRequiredDescription
pathstringyesFile path relative to project root
headingstringyesSection heading to target (e.g. ## Auth)
actionstringyesreplace, insert_before, insert_after, or remove
contentstringfor replace/insertNew 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:

  1. Exact match## Auth matches ## Auth
  2. Format-stripped## \Auth`matches## Auth` (backticks, bold, italic stripped)
  3. Prefix match## Auth matches ## Authentication & Authorization
  4. Substring matchAuth matches ## 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 unread list 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 contentedit_section(action="replace")
Add a new sectionedit_section(action="insert_before/after")
Delete a sectionedit_section(action="remove")
Fix a typo in a sectionedit_file(heading=, old_string, new_string)
Toggle multiple checkboxesedit_file(edits=[...]) with per-edit heading
Read specific sectionsread_file(headings=[...])
Read a full plan fileread_file(mode="complete")
See what sections existread_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

ParamTypeDescription
pathstringMarkdown file path (relative to project root)
headingstringSingle section to read (fuzzy matched)
headingsstring[]Multiple sections in one call (mutually exclusive with heading)
start_line / end_lineintRaw 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

ActionDescription
replaceReplace section body (heading line is preserved)
insert_beforeInsert content before the heading
insert_afterInsert content after the section (before next heading)
removeDelete the section and its body
editSurgical string replacement within a section (old_stringnew_string)

Parameters

ParamTypeDescription
pathstringMarkdown file path
headingstringTarget section heading (fuzzy matched)
actionstringOne of the actions above
contentstringNew body for replace/insert_* (heading not included)
old_stringstringFor edit: exact text to find
new_stringstringFor edit: replacement text
replace_allboolFor edit: replace all occurrences (default: false)
editsarrayBatch 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:

Scenarioedit_fileedit_markdown
Replace a section bodyError-prone: must match surrounding blank lines exactlyaction=replace — heading preserved automatically
Edit text inside a sectionWorks, but edits anywhere in the fileaction=edit scoped to one section
Remove a sectionMust know exact start/end linesaction=remove — no line numbers needed
Multiple editsMultiple calls, each can conflictedits=[] 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 sizeOutput
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.


Purpose: Find code by natural language description or code snippet. Returns ranked chunks with file path, line range, and similarity score.

Parameters:

NameTypeRequiredDefaultDescription
querystringyesNatural language description or code snippet to search for
limitintegerno10Maximum number of results to return
detail_levelstringnocompact"full" returns the complete chunk content instead of a 150-character preview
offsetintegerno0Skip this many results (for pagination)
scopestringno"project"Search scope: "project" (default), "lib:<name>" for a specific library, "libraries" for all libraries, "all" for everything
include_memoriesbooleannofalseIf 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_search when 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 query to 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, and index(action: build) to rebuild if files have changed.
  • For finding a symbol by name, symbols is 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:

NameTypeRequiredDefaultDescription
actionstringyes"build"
forcebooleannofalseForce full reindex, ignoring cached file hashes
scopestringno"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: true if you have changed the embedding model in project.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:

NameTypeRequiredDefaultDescription
actionstringyes"status"
thresholdfloatnoWhen set, include drift scores for files whose avg_drift exceeds this value (0.0–1.0). Higher = more changed.
pathstringnoLimit 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

  1. semantic_search requests limit * MAX_CHUNKS_PER_FILE candidates from the vector index (default: limit * 3).
  2. Results come back sorted by cosine similarity, high to low.
  3. A post-filter iterates in score order and drops any chunk whose file_path already appears MAX_CHUNKS_PER_FILE times in the kept set.
  4. 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.
  • 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 in src/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:

ValueWhat 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:

NameTypeRequiredDefaultDescription
actionstringyes"list" or "register"
pathstringfor registerRoot path of the library to register
namestringnoFriendly 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": false support symbol navigation (LSP + tree-sitter) but not semantic_search. Run index(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.

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 in library(action: list) output.

Git

Note: The git_blame tool was removed in the v1 tool restructure. Git history is still fully accessible via run_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_functions and list_docs tools 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 toolReplacementNotes
list_functionssymbolsReturns symbol tree with line ranges; requires LSP server
list_docssymbols + 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:

NameTypeRequiredDefaultDescription
actionstringyesOne of: "read", "write", "list", "delete"
topicstringrequired for read/write/deletePath-like key, e.g. "architecture" or "debugging/async-patterns"
contentstringrequired for writeMarkdown text to persist
privatebooleannofalseIf true, use the gitignored private store (personal notes not shared with teammates)
include_privatebooleannofalseFor 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:

NameTypeRequiredDescription
contentstringyesThe text to embed and store
titlestringnoShort label. Auto-extracted from the first sentence of content if omitted
bucketstringnoCategory: "code", "system", "preferences", or "unstructured" (default). Always specify — it improves recall precision

Bucket guide:

BucketUse 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:

NameTypeRequiredDefaultDescription
querystringyesNatural language query
limitintegerno5Max results
bucketstringnoFilter to a specific bucket
detail_levelstringnocompactPass "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:

NameTypeRequiredDescription
idstring (UUID)yesThe 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:

NameTypeRequiredDescription
topicstringyesThe 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:

CategoryExample topics
Project conventionsconventions/naming, conventions/error-handling, conventions/testing
Architecturearchitecture, architecture/data-flow, architecture/module-boundaries
Debugging notesdebugging/async-patterns, debugging/known-issues
Team preferencespreferences/review-style, preferences/commit-format
Onboarding summaryonboarding (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.

  1. Start a new session → call onboarding (lists available memories if already done)
  2. Call memory(action: "list") to see what topics exist
  3. Call memory(action: "read", topic: ...) for topics relevant to your current task
  4. As you work, call memory(action: "write", ...) when you learn something worth remembering
  5. 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:

NameTypeRequiredDefaultDescription
forcebooleannofalseRe-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:

NameTypeRequiredDefaultDescription
commandstringyesShell 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.
cwdstringnoSubdirectory relative to project root to run the command in. Validated to stay within the project.
timeout_secsintegerno30Max execution time in seconds. Ignored when run_in_background is true.
acknowledge_riskbooleannofalseBypass the dangerous-command check directly. Prefer the @ack_* handle protocol — see below.
run_in_backgroundbooleannofalseSpawn 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"
ValueBehaviour
"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 use grep FAILED @cmd_id in a follow-up call. Buffer queries preserve your context window; piped commands waste it.
  • For slow build steps (cargo build, full test suites), increase timeout_secs to 120–300.
  • Use cwd to run commands in subdirectories rather than cd subdir && prefixes.
  • For commands that background subprocesses with &, use run_in_background: true — otherwise run_command will 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:

NameTypeRequiredDefaultDescription
actionstringyes"activate"
pathstringyesAbsolute path to the project root directory
read_onlybooleannotrue for non-home projectsBlock 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:

NameTypeRequiredDefaultDescription
actionstringyes"status"
thresholdnumbernoWhen provided, includes drift data: minimum avg_drift to include (0.0–1.0)
pathstringnoSQL LIKE pattern to filter drift results by file path (e.g. "src/tools/%")
detail_levelstringno"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:

KeyMeaning
staleTopics where anchored source files have changed since the memory was last written — the memory may be outdated
freshTopics whose anchored files match the stored hashes — memory is current
untrackedTopics 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.1 after re-indexing to surface files that changed semantically — a whitespace reformat scores near 0.0, a full function rewrite approaches 1.0.
  • If you need to change configuration, edit .codescout/project.toml directly — 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:

NameTypeRequiredDescription
pathstringyesDirectory 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

ActivationWrite tools
Home project (initial --project or CWD)Always enabled
Non-home project — no read_only paramDisabled (default)
Non-home project — read_only: falseEnabled (explicit opt-in)
Return to home projectRestored 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.”

StepToolPurpose
1read_file(path)Get heading map — see all sections
2read_file(path, headings=[...])Read target sections (one call, multiple sections)
3aedit_section(path, heading, action, content)Whole-section: replace (body only — heading preserved), insert, remove
3bedit_file(path, heading=, old_string, new_string)Surgical: string replacement scoped to a section
3cedit_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 of mode="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.”

StepToolPurpose
1symbols(name, include_body=true)Read the current implementation
2references(name_path, path)Find all callers and dependents
3symbol_at with fields: ["hover"] on key call sitesReveal concrete types flowing through (especially generics/traits)
4Edit 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_file distribution 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.”

StepToolPurpose
1symbols(entry_point)Locate the starting function
2symbol_at with fields: ["def"] on called functionsFollow the call chain forward
3symbol_at with fields: ["hover"] on parameters/return valuesSee resolved types at each stage
4references at the destinationConfirm 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_at iteratively — follow the chain function by function.
  • The hover field 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.”

StepToolPurpose
1references(name_path, path)Map all usages before renaming
2edit_code(symbol, path, action="rename", new_name)LSP-powered rename across files
3grep(old_name)Catch stragglers in comments, strings, docs
4run_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:

  1. main_agent_instructions (~200 tokens) — short instructions telling the calling agent to dispatch a Sonnet subagent.
  2. 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 versionAction
Missing (pre-versioning project)Triggers refresh
Lower than ONBOARDING_VERSIONTriggers refresh
Equal to ONBOARDING_VERSIONNo-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 master and the experiments branch. 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:

  1. MCP request arrives. A JSON-RPC message comes in over stdio (single connection) or HTTP/SSE (multi-connection). The rmcp crate handles protocol framing.

  2. Tool lookup. CodeScoutServer::call_tool() searches the tool registry – a Vec<Arc<dyn Tool>> – for a tool matching the requested name. If no match is found, an invalid_params MCP error is returned.

  3. Security check. Before the tool runs, check_tool_access() verifies that the tool is not disabled by the project’s security configuration. For example, if shell_enabled is false, run_command is blocked here.

  4. ToolContext creation. A ToolContext is assembled with clones of the Agent and Arc<LspManager>. This is the only state a tool receives.

  5. 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.

  6. Result or error. On success, the tool returns a serde_json::Value that gets serialized to a CallToolResult with text content. On failure, the error is wrapped in CallToolResult::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.toml settings.
  • 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:

LanguageServer
Rustrust-analyzer
Pythonpyright-langserver
TypeScript/JS/TSXtypescript-language-server
Gogopls
Javajdtls
Kotlinkotlin-language-server
C/C++clangd
C#OmniSharp
Rubysolargraph

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:

  1. 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 configurable chunk_size and chunk_overlap.

  2. 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 on localhost:48081; the same client also speaks the OpenAI-compatible protocol when CODESCOUT_EMBEDDER_PROTOCOL=openai is set, so Ollama, OpenAI, and Anthropic-compatible endpoints all work.

  3. Sparse embedding (src/retrieval/embedder.rs, SPLADE) — In parallel, chunks are sent to a sparse SPLADE service on localhost:48084. The sparse vector captures lexical matches the dense vector misses (rare tokens, exact identifiers).

  4. Storage and search (src/retrieval/qdrant.rs) — Both vectors plus chunk metadata are upserted into Qdrant’s code_chunks collection 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 on localhost:48083 (TEI-compatible, bge-reranker-v2-m3 by default; protocol switchable to Infinity via CODESCOUT_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, run codescout migrate-memories once; 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)

ServiceDefault portRole
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

CratePurpose
rmcpMCP protocol implementation (stdio and SSE transports)
lsp-typesLSP type definitions
tree-sitter + language grammarsOffline AST parsing
git2Git operations (blame, log, diff)
qdrant-clientgRPC client for Qdrant vector storage
rusqliteSQLite — call_edges.db cache + legacy memory migration reader
reqwest (rustls + ring)HTTP client for embedder / reranker / sparse services
rustlsTLS via the ring crypto provider (small binary footprint)
fastembedLocal CPU embeddings (optional, local-embed feature — not the default substrate)
tokioAsync runtime
clapCLI argument parsing
serde / serde_jsonJSON serialization
tracingStructured logging
libcPOSIX 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.md in 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.

LevelWhat it enablesEffort
Detection onlyFile detection, semantic search chunking, basic file ops1 line
LSP supportAll symbol tools (symbols, symbols, references, rename)~10 lines
Tree-sitter grammarRicher 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
  • tree reports the language for each file
  • grep and tree (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 search
  • references — find all callers/references
  • symbol_at — definition + hover at a position
  • call_graph — transitive caller/callee traversal
  • edit_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 node
  • make_name_path(prefix, name) — builds "Parent/Child" name paths
  • find_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 symbols and 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:

  1. Install the language server (e.g. zls for Zig)
  2. Create or find a test project in that language
  3. Run the MCP server against it:
    cargo run -- start --project /path/to/test-project
    
  4. Use an MCP client (or curl against 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 in detect_language()
  • src/lsp/servers/mod.rs — server config in default_config() (if LSP available)
  • src/lsp/servers/mod.rs — ID mapping in lsp_language_id() (only if LSP ID differs)
  • Cargo.toml — tree-sitter crate dependency (if adding grammar)
  • src/ast/parser.rs — grammar in get_ts_language() (if adding grammar)
  • src/ast/parser.rsextract_<lang>_symbols() function (if adding grammar)
  • src/ast/parser.rs — dispatch in extract_symbols_from_source() (if adding grammar)
  • cargo test — all tests pass
  • cargo clippy -- -D warnings — no warnings
  • Update docs/manual/src/language-support.md with 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 active
  • ctx.agent.security_config().await — path security configuration
  • ctx.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 client
  • ctx.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 with offset/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 in list_tools
  • tool_names_are_unique — no two tools share a name
  • all_tools_have_valid_schemas — every schema is valid JSON with a type field
  • all_tools_have_descriptions — no empty descriptions

These run automatically when you add your tool to from_parts().


Summary

The full recipe:

  1. Create a struct: pub struct MyTool;
  2. Implement Tool with name(), description(), input_schema(), call()
  3. Register in from_parts() with Arc::new(MyTool)
  4. Add OutputGuard if the tool returns unbounded lists
  5. 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:

MethodReturnsDescription
project_root().awaitOption<PathBuf>Active project root, or None
require_project_root().await?Result<PathBuf>Same, but errors if no project
security_config().awaitPathSecurityConfigPath 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:

FieldTypeDescription
rootPathBufProject root directory
configProjectConfigSettings from .codescout/project.toml
memoryMemoryStoreMarkdown-based key-value store

lsp: Arc<LspManager>

The LspManager manages LSP server lifecycles. Key methods:

MethodReturnsDescription
get_or_start(lang, root).await?Result<Arc<LspClient>>Get or launch an LSP server
get(lang).awaitOption<Arc<LspClient>>Get existing client without starting
active_languages().awaitVec<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 fieldEffect
detail_level: "full"Switches to Focused mode
offset: NSets pagination offset (default 0)
limit: NSets 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_results items. If truncated, returns OverflowInfo with next_offset: None.
  • Focused: Applies offset/limit pagination. If more pages remain, returns OverflowInfo with next_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 Err for “no results found” — return an empty result set instead. Errors mean something went wrong, not that the result is empty.
  • Do not return McpError from 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-Send types (use Arc<Mutex<_>> if you need shared mutable state).
  • Unit structs (pub struct MyTool;) are always Send + Sync.
  • The async fn call() implementation uses #[async_trait] to enable async methods in the trait. This desugars to Pin<Box<dyn Future + Send>>.
  • Holding a MutexGuard across an .await point 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:

  1. list_tools — iterates all tools and builds MCP ToolInfo from name(), description(), and input_schema().
  2. call_tool — looks up a tool by matching tool.name() against the request’s tool name, then calls tool.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:

  1. 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.

  2. Usage traceability — full input_json and output_json columns written to usage.db for every tool call, enabling post-mortem replay of failures.

--diagnostic is a deprecated alias for --debug and 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

EventWhenFields
codescout_startServer bootpid, version, project, transport, instance
heartbeatEvery 30 suptime_secs, active_projects, lsp_servers
tool_callTool invokedtool, arg_keys
tool_doneTool returnedtool, duration_ms, ok
service_exitShutdownreason (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
FieldSourceWhat it tells you
vm_size_kbVmSizeTotal virtual address space (includes jemalloc reservations).
vm_rss_kbVmRSSResident pages — the truthful “how much physical memory is this using right now”.
vm_data_kbVmDataData + heap + stack pages. Grows with live allocations.
vm_peak_kbVmPeakHigh-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:

  1. 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.

  2. 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

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-small is significantly faster than local Ollama for large projects.
  • Reduce scope. Add build artifacts, vendored code, and generated files to ignored_paths in project.toml so they are skipped during indexing.
  • Use GPU. If Ollama has GPU access, embedding is much faster. Check ollama ps to 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-embed binary. 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:

  1. Not a git repository. The project root does not contain a .git directory.

    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:

ServerTypical startup
rust-analyzer2-5 seconds
pyright-langserver1-3 seconds
typescript-language-server1-2 seconds
gopls1-3 seconds
clangd1-2 seconds
jdtls10-30 seconds
kotlin-language-server5-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:

  1. 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.

  2. Enable debug logging. Set RUST_LOG=debug for verbose output:

    RUST_LOG=debug codescout start --project /path/to/project
    

    This shows every tool call, LSP message, and embedding operation.

  3. Check the configuration. Use the workspace tool to see the active configuration as the server sees it:

    { "tool": "workspace", "arguments": { "action": "status" } }
    
  4. 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=debug if available