codebase-brain-mcp 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/PKG-INFO +64 -11
  2. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/README.md +63 -10
  3. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/pyproject.toml +1 -1
  4. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/pyproject.toml.orig +1 -1
  5. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/server.py +130 -42
  6. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/sources.py +1 -1
  7. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/LICENSE +0 -0
  8. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/__init__.py +0 -0
  9. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/__main__.py +0 -0
  10. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/analysis.py +0 -0
  11. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/brain.py +0 -0
  12. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/chunking.py +0 -0
  13. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/chunking_js.py +0 -0
  14. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/config.py +0 -0
  15. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/embeddings.py +0 -0
  16. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/evals.py +0 -0
  17. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/history.py +0 -0
  18. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/py.typed +0 -0
  19. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/remote.py +0 -0
  20. {codebase_brain_mcp-0.2.0 → codebase_brain_mcp-0.2.1}/src/codebase_brain_mcp/store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-brain-mcp
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: An MCP server that gives AI agents a cited, searchable memory of a codebase.
5
5
  Keywords: mcp,model-context-protocol,rag,code-search,ai-agents,claude,git
6
6
  Author: Noman Nazir
@@ -103,7 +103,7 @@ A plug-in for any AI assistant that turns a codebase into something it can actua
103
103
 
104
104
  | Tool | What it does |
105
105
  |---|---|
106
- | `index_repo(source, name?)` | Index a local path or an `https://` git URL (shallow clone). Reports progress. |
106
+ | `index_repo(source, name?)` | Index a local path or an `https://` git URL (blobless clone with full history). Reports progress. |
107
107
  | `ask_codebase(question, repo?, k?)` | The most relevant code and docs as cited sources, plus related commits, with instructions to answer only from them. |
108
108
  | `search(query, repo?, k?, path_prefix?, kinds?)` | Raw hybrid search, filterable by path and chunk kind. |
109
109
  | `explain_history(symbol? \| path, start_line?, end_line?, repo?)` | Every commit that changed a function or line range, newest first, with the relevant diff, current authorship, and PR titles and descriptions (GitHub). |
@@ -117,15 +117,67 @@ Also included: the resource `brain://repos`, and the prompt `answer_with_citatio
117
117
 
118
118
  ## Install
119
119
 
120
- Codebase Brain runs on your own machine, so there's no server or account, and it's free. With
121
- [uv](https://docs.astral.sh/uv/):
120
+ Codebase Brain runs on your own machine, so there's no server or account, and it's free. It works with any
121
+ MCP-compatible assistant. All you need is [uv](https://docs.astral.sh/uv/), and every client runs the same command:
122
+ `uvx codebase-brain-mcp`.
123
+
124
+ **Claude Code**
122
125
 
123
126
  ```bash
124
- # Claude Code
125
127
  claude mcp add codebase-brain -- uvx codebase-brain-mcp
128
+ ```
129
+
130
+ **Cursor:** `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project)
131
+
132
+ ```json
133
+ {
134
+ "mcpServers": {
135
+ "codebase-brain": { "command": "uvx", "args": ["codebase-brain-mcp"] }
136
+ }
137
+ }
138
+ ```
126
139
 
127
- # Any MCP client (Claude Desktop, Cursor, ...): add this server to its config
128
- { "command": "uvx", "args": ["codebase-brain-mcp"] }
140
+ **Claude Desktop:** Settings → Developer → Edit Config (`claude_desktop_config.json`), then restart the app
141
+
142
+ ```json
143
+ {
144
+ "mcpServers": {
145
+ "codebase-brain": { "command": "uvx", "args": ["codebase-brain-mcp"] }
146
+ }
147
+ }
148
+ ```
149
+
150
+ **VS Code (GitHub Copilot agent mode):** `.vscode/mcp.json`
151
+
152
+ ```json
153
+ {
154
+ "servers": {
155
+ "codebase-brain": { "type": "stdio", "command": "uvx", "args": ["codebase-brain-mcp"] }
156
+ }
157
+ }
158
+ ```
159
+
160
+ **Windsurf:** `~/.codeium/windsurf/mcp_config.json`. **Gemini CLI:** `~/.gemini/settings.json`. Both use the same
161
+ `mcpServers` block as Cursor.
162
+
163
+ **OpenAI Codex CLI**
164
+
165
+ ```bash
166
+ codex mcp add codebase-brain -- uvx codebase-brain-mcp
167
+ ```
168
+
169
+ > **Tool doesn't start?** Desktop apps often don't see your shell's `PATH`. Run `which uvx` and put that full
170
+ > path in `"command"`, for example `"/Users/you/.local/bin/uvx"`.
171
+
172
+ Optional: install the [skill](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/skills/codebase-brain/SKILL.md).
173
+ It tells the agent which tool fits which question, to cite every claim, to check history before removing odd code,
174
+ and to run impact analysis before editing. For Claude Code, run the commands below. Other agents that read
175
+ `SKILL.md` files take the same file in their own skills folder.
176
+
177
+ ```bash
178
+ mkdir -p ~/.claude/skills/codebase-brain
179
+ curl -fsSL https://raw.githubusercontent.com/Nomannazir/codebase-brain-mcp/main/skills/codebase-brain/SKILL.md \
180
+ -o ~/.claude/skills/codebase-brain/SKILL.md
129
181
  ```
130
182
 
131
183
  ## Quick start
@@ -154,7 +206,7 @@ src/itsdangerous/timed.py:72-158
154
206
  ...
155
207
  ```
156
208
 
157
- ### Use it from Claude Code
209
+ ### Use a local checkout instead (for development)
158
210
 
159
211
  ```bash
160
212
  claude mcp add codebase-brain -- uv run --directory /path/to/codebase-brain-mcp codebase-brain
@@ -288,13 +340,14 @@ How it's measured:
288
340
  - **nDCG@10:** each target is credited once, so duplicate chunks don't inflate the score.
289
341
  - **Confidence intervals:** 95% bootstrap over questions, seeded.
290
342
  - **Relevance:** a result is relevant if it's in the target file and, when the target names a symbol, is that symbol
291
- or one of its methods.
343
+ or one of its methods. A docs target can name a section (`FAQ > How do I stop retrying early?`), and then that
344
+ section or one of its subsections counts.
292
345
  - **Dataset rot:** the harness fails if a target no longer exists at the pinned commit.
293
346
 
294
347
  ## How it works
295
348
 
296
349
  ```
297
- source ─► resolve (local dir / shallow https clone) ─► list files (.gitignore-aware, skips binaries and lockfiles)
350
+ source ─► resolve (local dir / blobless https clone) ─► list files (.gitignore-aware, skips binaries and lockfiles)
298
351
  ─► chunk (ast / tree-sitter / headings / windows) ─► embed (fastembed) ─► SQLite: chunks + vectors + FTS5
299
352
  query ─► BM25 top 50 ┐
300
353
  ─► vector top 50 ┴─► reciprocal-rank fusion ─► filters ─► cited sources
@@ -314,7 +367,7 @@ query ─► BM25 top 50 ┐
314
367
  ## Development
315
368
 
316
369
  ```bash
317
- uv run pytest # 54 tests, offline (hash embedder, temporary git repos, in-process MCP client)
370
+ uv run pytest # 67 tests, offline (hash embedder, temporary git repos, in-process MCP client)
318
371
  uv run ruff check . # lint
319
372
  uv run ruff format . # format
320
373
  uv run pyright # types
@@ -74,7 +74,7 @@ A plug-in for any AI assistant that turns a codebase into something it can actua
74
74
 
75
75
  | Tool | What it does |
76
76
  |---|---|
77
- | `index_repo(source, name?)` | Index a local path or an `https://` git URL (shallow clone). Reports progress. |
77
+ | `index_repo(source, name?)` | Index a local path or an `https://` git URL (blobless clone with full history). Reports progress. |
78
78
  | `ask_codebase(question, repo?, k?)` | The most relevant code and docs as cited sources, plus related commits, with instructions to answer only from them. |
79
79
  | `search(query, repo?, k?, path_prefix?, kinds?)` | Raw hybrid search, filterable by path and chunk kind. |
80
80
  | `explain_history(symbol? \| path, start_line?, end_line?, repo?)` | Every commit that changed a function or line range, newest first, with the relevant diff, current authorship, and PR titles and descriptions (GitHub). |
@@ -88,15 +88,67 @@ Also included: the resource `brain://repos`, and the prompt `answer_with_citatio
88
88
 
89
89
  ## Install
90
90
 
91
- Codebase Brain runs on your own machine, so there's no server or account, and it's free. With
92
- [uv](https://docs.astral.sh/uv/):
91
+ Codebase Brain runs on your own machine, so there's no server or account, and it's free. It works with any
92
+ MCP-compatible assistant. All you need is [uv](https://docs.astral.sh/uv/), and every client runs the same command:
93
+ `uvx codebase-brain-mcp`.
94
+
95
+ **Claude Code**
93
96
 
94
97
  ```bash
95
- # Claude Code
96
98
  claude mcp add codebase-brain -- uvx codebase-brain-mcp
99
+ ```
100
+
101
+ **Cursor:** `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project)
102
+
103
+ ```json
104
+ {
105
+ "mcpServers": {
106
+ "codebase-brain": { "command": "uvx", "args": ["codebase-brain-mcp"] }
107
+ }
108
+ }
109
+ ```
97
110
 
98
- # Any MCP client (Claude Desktop, Cursor, ...): add this server to its config
99
- { "command": "uvx", "args": ["codebase-brain-mcp"] }
111
+ **Claude Desktop:** Settings → Developer → Edit Config (`claude_desktop_config.json`), then restart the app
112
+
113
+ ```json
114
+ {
115
+ "mcpServers": {
116
+ "codebase-brain": { "command": "uvx", "args": ["codebase-brain-mcp"] }
117
+ }
118
+ }
119
+ ```
120
+
121
+ **VS Code (GitHub Copilot agent mode):** `.vscode/mcp.json`
122
+
123
+ ```json
124
+ {
125
+ "servers": {
126
+ "codebase-brain": { "type": "stdio", "command": "uvx", "args": ["codebase-brain-mcp"] }
127
+ }
128
+ }
129
+ ```
130
+
131
+ **Windsurf:** `~/.codeium/windsurf/mcp_config.json`. **Gemini CLI:** `~/.gemini/settings.json`. Both use the same
132
+ `mcpServers` block as Cursor.
133
+
134
+ **OpenAI Codex CLI**
135
+
136
+ ```bash
137
+ codex mcp add codebase-brain -- uvx codebase-brain-mcp
138
+ ```
139
+
140
+ > **Tool doesn't start?** Desktop apps often don't see your shell's `PATH`. Run `which uvx` and put that full
141
+ > path in `"command"`, for example `"/Users/you/.local/bin/uvx"`.
142
+
143
+ Optional: install the [skill](https://github.com/Nomannazir/codebase-brain-mcp/blob/main/skills/codebase-brain/SKILL.md).
144
+ It tells the agent which tool fits which question, to cite every claim, to check history before removing odd code,
145
+ and to run impact analysis before editing. For Claude Code, run the commands below. Other agents that read
146
+ `SKILL.md` files take the same file in their own skills folder.
147
+
148
+ ```bash
149
+ mkdir -p ~/.claude/skills/codebase-brain
150
+ curl -fsSL https://raw.githubusercontent.com/Nomannazir/codebase-brain-mcp/main/skills/codebase-brain/SKILL.md \
151
+ -o ~/.claude/skills/codebase-brain/SKILL.md
100
152
  ```
101
153
 
102
154
  ## Quick start
@@ -125,7 +177,7 @@ src/itsdangerous/timed.py:72-158
125
177
  ...
126
178
  ```
127
179
 
128
- ### Use it from Claude Code
180
+ ### Use a local checkout instead (for development)
129
181
 
130
182
  ```bash
131
183
  claude mcp add codebase-brain -- uv run --directory /path/to/codebase-brain-mcp codebase-brain
@@ -259,13 +311,14 @@ How it's measured:
259
311
  - **nDCG@10:** each target is credited once, so duplicate chunks don't inflate the score.
260
312
  - **Confidence intervals:** 95% bootstrap over questions, seeded.
261
313
  - **Relevance:** a result is relevant if it's in the target file and, when the target names a symbol, is that symbol
262
- or one of its methods.
314
+ or one of its methods. A docs target can name a section (`FAQ > How do I stop retrying early?`), and then that
315
+ section or one of its subsections counts.
263
316
  - **Dataset rot:** the harness fails if a target no longer exists at the pinned commit.
264
317
 
265
318
  ## How it works
266
319
 
267
320
  ```
268
- source ─► resolve (local dir / shallow https clone) ─► list files (.gitignore-aware, skips binaries and lockfiles)
321
+ source ─► resolve (local dir / blobless https clone) ─► list files (.gitignore-aware, skips binaries and lockfiles)
269
322
  ─► chunk (ast / tree-sitter / headings / windows) ─► embed (fastembed) ─► SQLite: chunks + vectors + FTS5
270
323
  query ─► BM25 top 50 ┐
271
324
  ─► vector top 50 ┴─► reciprocal-rank fusion ─► filters ─► cited sources
@@ -285,7 +338,7 @@ query ─► BM25 top 50 ┐
285
338
  ## Development
286
339
 
287
340
  ```bash
288
- uv run pytest # 54 tests, offline (hash embedder, temporary git repos, in-process MCP client)
341
+ uv run pytest # 67 tests, offline (hash embedder, temporary git repos, in-process MCP client)
289
342
  uv run ruff check . # lint
290
343
  uv run ruff format . # format
291
344
  uv run pyright # types
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebase-brain-mcp"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "An MCP server that gives AI agents a cited, searchable memory of a codebase."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebase-brain-mcp"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "An MCP server that gives AI agents a cited, searchable memory of a codebase."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,6 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Annotated, Any
8
+
5
9
  import anyio
6
10
  import anyio.from_thread
7
11
  import anyio.to_thread
@@ -29,6 +33,16 @@ READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
29
33
  MAX_RESULTS = 25
30
34
  SNIPPET_CHARS = 1_800
31
35
 
36
+ # Parameters shared by several tools, described once so every tool explains them the same way.
37
+ Repo = Annotated[
38
+ str | None,
39
+ Field(description="Name of an indexed repo, as list_repos shows it. Leave out when only one repo is indexed."),
40
+ ]
41
+ ResultCount = Annotated[int, Field(description=f"How many results to return, 1 to {MAX_RESULTS}.")]
42
+ PathPrefix = Annotated[
43
+ str | None, Field(description='Only consider files under this path, e.g. "src/auth/" or "docs/".')
44
+ ]
45
+
32
46
 
33
47
  class IndexResult(BaseModel):
34
48
  repo: str
@@ -164,14 +178,23 @@ def build_server(brain: Brain | None = None) -> MCPServer:
164
178
  brain = brain or Brain(load_settings())
165
179
  server = MCPServer("codebase-brain", instructions=INSTRUCTIONS, version=__version__)
166
180
 
167
- @server.tool(
168
- annotations=ToolAnnotations(
169
- read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True
170
- )
181
+ @_tool(
182
+ server,
183
+ ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True),
171
184
  )
172
- async def index_repo(source: str, ctx: Context, name: str | None = None) -> IndexResult:
173
- """Index a repository so it can be searched. `source` is a local directory or an https git URL
174
- (cloned shallowly). Re-running only re-embeds files that changed since the last run."""
185
+ async def index_repo(
186
+ source: Annotated[
187
+ str, Field(description="A local directory, or an https git URL such as https://github.com/owner/repo.")
188
+ ],
189
+ ctx: Context,
190
+ name: Annotated[
191
+ str | None, Field(description="Name to index it under. Defaults to the folder or repository name.")
192
+ ] = None,
193
+ ) -> IndexResult:
194
+ """Index a repository so the other tools can search it. Use this first, and again after the code
195
+ changes: re-running only re-embeds files whose content changed. A URL is cloned with full history
196
+ (blobless), so explain_history works. Reports progress while it runs, and returns file counts and
197
+ the commit indexed."""
175
198
 
176
199
  def progress(done: int, total: int, message: str) -> None:
177
200
  anyio.from_thread.run(ctx.report_progress, done, total, message)
@@ -182,19 +205,37 @@ def build_server(brain: Brain | None = None) -> MCPServer:
182
205
  raise ToolError(str(exc)) from exc
183
206
  return IndexResult(**report.__dict__)
184
207
 
185
- @server.tool(annotations=READ_ONLY)
208
+ @_tool(server, READ_ONLY)
186
209
  async def search(
187
- query: str, repo: str | None = None, k: int = 8, path_prefix: str | None = None, kinds: list[str] | None = None
210
+ query: Annotated[str, Field(description="An identifier (normalizeRetryOptions) or plain words.")],
211
+ repo: Repo = None,
212
+ k: ResultCount = 8,
213
+ path_prefix: PathPrefix = None,
214
+ kinds: Annotated[
215
+ list[str] | None,
216
+ Field(
217
+ description="Only these chunk kinds: function, class, method, interface, type, enum, "
218
+ "module, section (docs) or code (other files)."
219
+ ),
220
+ ] = None,
188
221
  ) -> SearchResult:
189
- """Hybrid search (BM25 keywords + embeddings, fused by rank). Filter by `path_prefix`
190
- (e.g. "src/auth/") or `kinds` (function, class, method, interface, type, enum, module, section, code)."""
222
+ """Hybrid search over code and docs (BM25 keywords plus local embeddings, fused by rank). Use this
223
+ for a targeted lookup, or when you need filters by folder or chunk kind; for a question you'll answer
224
+ in prose, use ask_codebase instead. Returns ranked chunks, each with a path:start-end citation,
225
+ its symbol and a snippet."""
191
226
  hits = await _search(brain, query, repo, k, path_prefix, kinds)
192
227
  return SearchResult(repo=brain.resolve_repo(repo), query=query, results=[_source(h) for h in hits])
193
228
 
194
- @server.tool(annotations=READ_ONLY)
195
- async def ask_codebase(question: str, repo: str | None = None, k: int = 8) -> AskResult:
196
- """Gather the code and docs most relevant to a question, as numbered, cited sources.
197
- Answer from these sources only, citing each claim as path:start-end."""
229
+ @_tool(server, READ_ONLY)
230
+ async def ask_codebase(
231
+ question: Annotated[str, Field(description='A question in plain words, e.g. "How are sessions expired?"')],
232
+ repo: Repo = None,
233
+ k: ResultCount = 8,
234
+ ) -> AskResult:
235
+ """Gather the code and docs most relevant to a question, plus related commits, as numbered,
236
+ cited sources. Use this to answer "how does X work?" or "where is Y handled?" questions. Answer
237
+ from these sources only and cite each claim as path:start-end; open a source in full with
238
+ read_lines. For filters, use search."""
198
239
  hits = await _search(brain, question, repo, k, None, None)
199
240
  commits = await _call(lambda: brain.search_history(question, repo, 3))
200
241
  instructions = (
@@ -212,57 +253,95 @@ def build_server(brain: Brain | None = None) -> MCPServer:
212
253
  related_commits=[_commit(c) for c in commits],
213
254
  )
214
255
 
215
- @server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True))
256
+ @_tool(server, ToolAnnotations(read_only_hint=True, open_world_hint=True))
216
257
  async def explain_history(
217
- repo: str | None = None,
218
- symbol: str | None = None,
219
- path: str | None = None,
220
- start_line: int | None = None,
221
- end_line: int | None = None,
222
- max_commits: int = 8,
258
+ repo: Repo = None,
259
+ symbol: Annotated[
260
+ str | None,
261
+ Field(description='A function, class or method, e.g. "unsign" or "Signer.sign". Use this or path.'),
262
+ ] = None,
263
+ path: Annotated[
264
+ str | None, Field(description="A file in the repo. Narrow it with start_line and end_line.")
265
+ ] = None,
266
+ start_line: Annotated[int | None, Field(description="First line of the range (with path).")] = None,
267
+ end_line: Annotated[int | None, Field(description="Last line of the range (with path).")] = None,
268
+ max_commits: Annotated[int, Field(description="How many commits to return, newest first.")] = 8,
223
269
  ) -> HistoryResult:
224
- """Why code looks the way it does. Give a `symbol` (e.g. "unsign" or "Signer.sign"), or a `path`
225
- with optional line range. Returns who wrote the current lines, every commit that changed them
226
- (with the relevant diff), and the pull requests behind those commits."""
270
+ """Why code looks the way it does. Use this before removing or rewriting code that looks odd, or
271
+ to answer "why is it like this?". Give a symbol, or a path with an optional line range. Returns
272
+ who wrote the current lines, every commit that changed them (with the relevant diff), and the
273
+ title and description of the pull request behind each commit (GitHub only)."""
227
274
  report = await _call(lambda: brain.explain_history(repo, path, symbol, start_line, end_line, max_commits))
228
275
  return _history(report)
229
276
 
230
- @server.tool(annotations=READ_ONLY)
277
+ @_tool(server, READ_ONLY)
231
278
  async def impact_of_change(
232
- symbol: str, repo: str | None = None, path: str | None = None, depth: int = 2
279
+ symbol: Annotated[
280
+ str, Field(description='The function, class or method you plan to change, e.g. "discount" or "Cart.total".')
281
+ ],
282
+ repo: Repo = None,
283
+ path: Annotated[
284
+ str | None, Field(description="The file that defines symbol, when several files define that name.")
285
+ ] = None,
286
+ depth: Annotated[
287
+ int, Field(description="1 = direct users only; 2 or 3 also follows users of those users.")
288
+ ] = 2,
233
289
  ) -> ImpactResult:
234
- """What breaks if `symbol` changes: code that uses it directly, code that uses that code
235
- (up to `depth`, max 3), and the tests involved. Python, JavaScript and TypeScript are analysed structurally."""
290
+ """What breaks if a symbol changes: code that uses it directly, code that uses that code, and the
291
+ tests involved. Use this before editing a shared function, and run or update the tests it lists.
292
+ Python, JavaScript and TypeScript are analysed structurally. Matches are by name, so check the
293
+ same-name notes it returns."""
236
294
  report = await _call(lambda: brain.impact_of_change(repo, symbol, path, depth))
237
295
  return _impact(report)
238
296
 
239
- @server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
297
+ @_tool(server, ToolAnnotations(read_only_hint=True, open_world_hint=False))
240
298
  async def find_doc_drift(
241
- repo: str | None = None, path_prefix: str | None = None, check_history: bool = True, limit: int = 50
299
+ repo: Repo = None,
300
+ path_prefix: PathPrefix = None,
301
+ check_history: Annotated[
302
+ bool, Field(description="Also flag doc sections last edited long before the code they describe.")
303
+ ] = True,
304
+ limit: Annotated[int, Field(description="Most findings to return, 1 to 200, most severe first.")] = 50,
242
305
  ) -> DriftResult:
243
- """Documentation that no longer matches the code: files, functions, settings and flags that don't
244
- exist; examples passing parameters a function doesn't take; imports that would fail; and docs last
245
- edited long before the code they describe changed. Each finding cites the doc section."""
306
+ """Documentation that no longer matches the code. Use this to check a README or docs folder, or
307
+ before a release. Finds files, functions, settings and flags that don't exist; code examples that
308
+ pass parameters a function doesn't take; imports that would fail; and stale sections. Each finding
309
+ has a severity, cites the doc section and suggests a fix. Changelogs are skipped."""
246
310
  report = await _call(lambda: brain.find_doc_drift(repo, path_prefix, check_history, max(1, min(limit, 200))))
247
311
  return _drift(brain.resolve_repo(repo), report)
248
312
 
249
- @server.tool(annotations=READ_ONLY)
250
- async def search_history(query: str, repo: str | None = None, k: int = 5) -> list[CommitInfo]:
251
- """Find commits by what their messages say, e.g. "fix timezone" or "drop python 3.8"."""
313
+ @_tool(server, READ_ONLY)
314
+ async def search_history(
315
+ query: Annotated[
316
+ str, Field(description='Words from commit messages, e.g. "fix timezone" or "drop python 3.8".')
317
+ ],
318
+ repo: Repo = None,
319
+ k: ResultCount = 5,
320
+ ) -> list[CommitInfo]:
321
+ """Find commits by what their messages say. Use this to answer "when did we change X?" or "which
322
+ commit fixed Y?". For the history of one function or file, use explain_history instead."""
252
323
  commits = await _call(lambda: brain.search_history(query, repo, max(1, min(k, MAX_RESULTS))))
253
324
  return [_commit(c) for c in commits]
254
325
 
255
- @server.tool(annotations=READ_ONLY)
256
- async def read_lines(path: str, start: int, end: int, repo: str | None = None) -> str:
257
- """Exact, numbered lines from a file in an indexed repo, e.g. to open a citation in full."""
326
+ @_tool(server, READ_ONLY)
327
+ async def read_lines(
328
+ path: Annotated[str, Field(description="The file, relative to the repo root, as a citation shows it.")],
329
+ start: Annotated[int, Field(description="First line to return (1-based).")],
330
+ end: Annotated[int, Field(description="Last line to return (inclusive).")],
331
+ repo: Repo = None,
332
+ ) -> str:
333
+ """Exact, numbered lines from a file in an indexed repo. Use this to open a citation in full
334
+ before quoting or editing it, since search results are trimmed snippets. Refuses paths outside
335
+ the repo."""
258
336
  try:
259
337
  return brain.read_lines(repo, path, start, end)
260
338
  except BrainError as exc:
261
339
  raise ToolError(str(exc)) from exc
262
340
 
263
- @server.tool(annotations=READ_ONLY)
341
+ @_tool(server, READ_ONLY)
264
342
  async def list_repos() -> list[RepoSummary]:
265
- """Repositories indexed so far, with size, languages and the commit they were indexed at."""
343
+ """Repositories indexed so far, with their size, languages and the commit they were indexed at.
344
+ Use this first to see what's available and to get the repo names the other tools take."""
266
345
  return [_summary(info) for info in brain.repos()]
267
346
 
268
347
  @server.resource(
@@ -288,6 +367,15 @@ def build_server(brain: Brain | None = None) -> MCPServer:
288
367
  return server
289
368
 
290
369
 
370
+ def _tool(server: MCPServer, annotations: ToolAnnotations) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
371
+ """Register a tool with its docstring, dedented: clients show descriptions as they are sent."""
372
+
373
+ def register(fn: Callable[..., Any]) -> Callable[..., Any]:
374
+ return server.tool(annotations=annotations, description=inspect.cleandoc(fn.__doc__ or ""))(fn)
375
+
376
+ return register
377
+
378
+
291
379
  async def _search(
292
380
  brain: Brain, query: str, repo: str | None, k: int, path_prefix: str | None, kinds: list[str] | None
293
381
  ) -> list[Hit]:
@@ -97,7 +97,7 @@ def default_name(source: str) -> str:
97
97
 
98
98
 
99
99
  def resolve(source: str, clone_dir: Path) -> Path:
100
- """The repository's root on disk. URLs are shallow-cloned (or refreshed) under ``clone_dir``."""
100
+ """The repository's root on disk. URLs are cloned blobless (or refreshed) under ``clone_dir``."""
101
101
  if not is_url(source):
102
102
  root = Path(source).expanduser().resolve()
103
103
  if not root.is_dir():