codebase-brain-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,112 @@
1
+ """Serving over Streamable HTTP: bearer-token auth, a health check, and safe defaults for a public host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import ipaddress
7
+ import json
8
+ from dataclasses import dataclass
9
+
10
+ from mcp.server.mcpserver import MCPServer
11
+ from mcp.server.transport_security import TransportSecuritySettings
12
+ from starlette.requests import Request
13
+ from starlette.responses import JSONResponse
14
+ from starlette.types import ASGIApp, Receive, Scope, Send
15
+
16
+ HEALTH_PATH = "/healthz"
17
+ MCP_PATH = "/mcp"
18
+ MIN_TOKEN_LENGTH = 24
19
+
20
+
21
+ class RemoteConfigError(ValueError):
22
+ """The HTTP settings are unsafe or incomplete."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class RemoteConfig:
27
+ host: str
28
+ port: int
29
+ token: str | None
30
+ allowed_hosts: tuple[str, ...] = ()
31
+
32
+ @property
33
+ def is_loopback(self) -> bool:
34
+ if self.host == "localhost":
35
+ return True
36
+ try:
37
+ return ipaddress.ip_address(self.host).is_loopback
38
+ except ValueError:
39
+ return False
40
+
41
+ def validate(self) -> None:
42
+ """Refuse to expose the server beyond this machine without a strong token."""
43
+ if self.token is not None and len(self.token) < MIN_TOKEN_LENGTH:
44
+ raise RemoteConfigError(
45
+ f"CODEBASE_BRAIN_TOKEN must be at least {MIN_TOKEN_LENGTH} characters; "
46
+ 'generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
47
+ )
48
+ if not self.is_loopback and not self.token:
49
+ raise RemoteConfigError(
50
+ f"Refusing to listen on {self.host} without CODEBASE_BRAIN_TOKEN. Anyone who can reach the port "
51
+ "could use the server. Set a token, or bind to 127.0.0.1."
52
+ )
53
+
54
+ def transport_security(self) -> TransportSecuritySettings:
55
+ """DNS-rebinding protection: on for localhost (where it matters most) and for explicit host lists.
56
+ A public, token-protected server behind a platform's router has no fixed Host to check against."""
57
+ if self.allowed_hosts:
58
+ return TransportSecuritySettings(allowed_hosts=list(self.allowed_hosts))
59
+ if self.is_loopback:
60
+ hosts = [f"{name}:{self.port}" for name in ("127.0.0.1", "localhost", "[::1]")]
61
+ return TransportSecuritySettings(allowed_hosts=hosts)
62
+ return TransportSecuritySettings(enable_dns_rebinding_protection=False)
63
+
64
+
65
+ class BearerTokenMiddleware:
66
+ """Requires ``Authorization: Bearer <token>`` on every HTTP request except the health check."""
67
+
68
+ def __init__(self, app: ASGIApp, token: str | None):
69
+ self.app = app
70
+ self.token = token.encode() if token else None
71
+
72
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
73
+ if scope["type"] != "http" or self.token is None or scope["path"] == HEALTH_PATH:
74
+ await self.app(scope, receive, send)
75
+ return
76
+ header = dict(scope.get("headers") or []).get(b"authorization", b"")
77
+ scheme, _, presented = header.partition(b" ")
78
+ if scheme.lower() == b"bearer" and hmac.compare_digest(presented.strip(), self.token):
79
+ await self.app(scope, receive, send)
80
+ return
81
+ body = json.dumps({"error": "unauthorized", "message": "Missing or invalid bearer token."}).encode()
82
+ await send(
83
+ {
84
+ "type": "http.response.start",
85
+ "status": 401,
86
+ "headers": [
87
+ (b"content-type", b"application/json"),
88
+ (b"www-authenticate", b'Bearer realm="codebase-brain"'),
89
+ (b"content-length", str(len(body)).encode()),
90
+ ],
91
+ }
92
+ )
93
+ await send({"type": "http.response.body", "body": body})
94
+
95
+
96
+ def build_app(server: MCPServer, config: RemoteConfig) -> ASGIApp:
97
+ config.validate()
98
+
99
+ @server.custom_route(HEALTH_PATH, methods=["GET"], include_in_schema=False)
100
+ async def health(_: Request) -> JSONResponse:
101
+ return JSONResponse({"status": "ok"})
102
+
103
+ app = server.streamable_http_app(
104
+ streamable_http_path=MCP_PATH, transport_security=config.transport_security(), host=config.host
105
+ )
106
+ return BearerTokenMiddleware(app, config.token)
107
+
108
+
109
+ def serve(server: MCPServer, config: RemoteConfig) -> None:
110
+ import uvicorn
111
+
112
+ uvicorn.run(build_app(server, config), host=config.host, port=config.port, log_level="info")
@@ -0,0 +1,415 @@
1
+ """The MCP server: tools, a resource and a prompt over the Brain."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import anyio
6
+ import anyio.from_thread
7
+ import anyio.to_thread
8
+ from mcp.server.mcpserver import Context, MCPServer
9
+ from mcp.server.mcpserver.exceptions import ToolError
10
+ from mcp.types import ToolAnnotations
11
+ from pydantic import BaseModel, Field
12
+
13
+ from . import __version__
14
+ from .analysis import DriftReport, ImpactReport
15
+ from .brain import Brain, BrainError, HistoryReport, Hit
16
+ from .config import load_settings
17
+ from .history import Commit
18
+ from .sources import SourceError
19
+
20
+ INSTRUCTIONS = """\
21
+ Codebase Brain indexes code repositories and answers questions about them with citations.
22
+ Start with index_repo (a local path or an https git URL). Then use ask_codebase for questions,
23
+ search for raw hybrid search, and read_lines to open the exact lines a citation points at.
24
+ For "why is it like this?" questions, use explain_history on a symbol or line range, and
25
+ search_history to find commits by topic. Before changing a function, run impact_of_change to see
26
+ what depends on it. find_doc_drift lists documentation that no longer matches the code.
27
+ Cite code as path:start-end and commits by short sha."""
28
+ READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
29
+ MAX_RESULTS = 25
30
+ SNIPPET_CHARS = 1_800
31
+
32
+
33
+ class IndexResult(BaseModel):
34
+ repo: str
35
+ root: str
36
+ commit: str | None
37
+ files_seen: int
38
+ files_indexed: int = Field(description="Files embedded this run (new or changed).")
39
+ files_unchanged: int
40
+ files_removed: int
41
+ chunks_total: int
42
+ commits_added: int
43
+ commits_total: int
44
+ seconds: float
45
+ full_rebuild: bool
46
+
47
+
48
+ class Source(BaseModel):
49
+ citation: str = Field(description="path:start-end, ready to quote.")
50
+ path: str
51
+ start_line: int
52
+ end_line: int
53
+ kind: str
54
+ symbol: str | None
55
+ score: float
56
+ matched_by: list[str] = Field(description="Which retrievers found it: keyword, vector, or both.")
57
+ snippet: str
58
+
59
+
60
+ class SearchResult(BaseModel):
61
+ repo: str
62
+ query: str
63
+ results: list[Source]
64
+
65
+
66
+ class CommitInfo(BaseModel):
67
+ sha: str = Field(description="Short sha, ready to cite.")
68
+ author: str
69
+ date: str
70
+ subject: str
71
+ body: str
72
+ pull_requests: list[int]
73
+
74
+
75
+ class AskResult(BaseModel):
76
+ repo: str
77
+ question: str
78
+ instructions: str
79
+ sources: list[Source]
80
+ related_commits: list[CommitInfo] = Field(description="Commits whose messages match the question.")
81
+
82
+
83
+ class Change(BaseModel):
84
+ commit: CommitInfo
85
+ diff: str = Field(description="The part of the commit's diff that touched the range.")
86
+
87
+
88
+ class Authorship(BaseModel):
89
+ sha: str
90
+ author: str
91
+ date: str
92
+ subject: str
93
+ lines: int = Field(description="How many of the current lines come from this commit.")
94
+
95
+
96
+ class PullRequestInfo(BaseModel):
97
+ number: int
98
+ url: str
99
+ title: str | None
100
+ body: str | None
101
+ state: str | None
102
+ merged_at: str | None
103
+
104
+
105
+ class HistoryResult(BaseModel):
106
+ repo: str
107
+ citation: str
108
+ symbol: str | None
109
+ instructions: str
110
+ authorship: list[Authorship]
111
+ changes: list[Change] = Field(description="Commits that changed these lines, newest first.")
112
+ pull_requests: list[PullRequestInfo]
113
+
114
+
115
+ class DependentInfo(BaseModel):
116
+ citation: str
117
+ symbol: str | None
118
+ kind: str
119
+ via: str = Field(description="The name through which it depends on the level above.")
120
+ level: int = Field(description="1 = uses the target directly, 2 = uses something that does.")
121
+ is_test: bool
122
+
123
+
124
+ class ImpactResult(BaseModel):
125
+ target: str = Field(description="path:start-end of the definition analysed.")
126
+ symbol: str
127
+ summary: str
128
+ direct: list[DependentInfo]
129
+ indirect: list[DependentInfo]
130
+ tests: list[str] = Field(description="Citations of tests that exercise the target, directly or not.")
131
+ same_name_definitions: list[str]
132
+ notes: list[str]
133
+
134
+
135
+ class FindingInfo(BaseModel):
136
+ severity: str = Field(description="high (almost certainly wrong), medium (probably wrong), low (worth a look).")
137
+ kind: str
138
+ doc: str = Field(description="path:start-end of the doc section.")
139
+ claim: str
140
+ detail: str
141
+ suggestion: str | None
142
+
143
+
144
+ class DriftResult(BaseModel):
145
+ repo: str
146
+ summary: str
147
+ sections_checked: int
148
+ references_checked: int
149
+ findings: list[FindingInfo]
150
+
151
+
152
+ class RepoSummary(BaseModel):
153
+ name: str
154
+ source: str
155
+ commit: str | None
156
+ indexed_at: str
157
+ files: int
158
+ chunks: int
159
+ commits: int
160
+ languages: dict[str, int]
161
+
162
+
163
+ def build_server(brain: Brain | None = None) -> MCPServer:
164
+ brain = brain or Brain(load_settings())
165
+ server = MCPServer("codebase-brain", instructions=INSTRUCTIONS, version=__version__)
166
+
167
+ @server.tool(
168
+ annotations=ToolAnnotations(
169
+ read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True
170
+ )
171
+ )
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."""
175
+
176
+ def progress(done: int, total: int, message: str) -> None:
177
+ anyio.from_thread.run(ctx.report_progress, done, total, message)
178
+
179
+ try:
180
+ report = await anyio.to_thread.run_sync(lambda: brain.index(source, name=name, progress=progress))
181
+ except (BrainError, SourceError) as exc:
182
+ raise ToolError(str(exc)) from exc
183
+ return IndexResult(**report.__dict__)
184
+
185
+ @server.tool(annotations=READ_ONLY)
186
+ async def search(
187
+ query: str, repo: str | None = None, k: int = 8, path_prefix: str | None = None, kinds: list[str] | None = None
188
+ ) -> SearchResult:
189
+ """Hybrid search (BM25 keywords + embeddings, fused by rank). Filter by `path_prefix`
190
+ (e.g. "src/auth/") or `kinds` (function, class, method, module, section, code)."""
191
+ hits = await _search(brain, query, repo, k, path_prefix, kinds)
192
+ return SearchResult(repo=brain.resolve_repo(repo), query=query, results=[_source(h) for h in hits])
193
+
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."""
198
+ hits = await _search(brain, question, repo, k, None, None)
199
+ commits = await _call(lambda: brain.search_history(question, repo, 3))
200
+ instructions = (
201
+ "Answer the question using only these sources. Cite every claim as path:start-end. "
202
+ "If the sources don't contain the answer, say so and suggest a better search or a file to read "
203
+ "with read_lines."
204
+ if hits
205
+ else "Nothing relevant was found. Try search with different keywords, or check the repo is indexed."
206
+ )
207
+ return AskResult(
208
+ repo=brain.resolve_repo(repo),
209
+ question=question,
210
+ instructions=instructions,
211
+ sources=[_source(h) for h in hits],
212
+ related_commits=[_commit(c) for c in commits],
213
+ )
214
+
215
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True))
216
+ 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,
223
+ ) -> 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."""
227
+ report = await _call(lambda: brain.explain_history(repo, path, symbol, start_line, end_line, max_commits))
228
+ return _history(report)
229
+
230
+ @server.tool(annotations=READ_ONLY)
231
+ async def impact_of_change(
232
+ symbol: str, repo: str | None = None, path: str | None = None, depth: int = 2
233
+ ) -> 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 is analysed structurally."""
236
+ report = await _call(lambda: brain.impact_of_change(repo, symbol, path, depth))
237
+ return _impact(report)
238
+
239
+ @server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
240
+ async def find_doc_drift(
241
+ repo: str | None = None, path_prefix: str | None = None, check_history: bool = True, limit: int = 50
242
+ ) -> 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."""
246
+ report = await _call(lambda: brain.find_doc_drift(repo, path_prefix, check_history, max(1, min(limit, 200))))
247
+ return _drift(brain.resolve_repo(repo), report)
248
+
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"."""
252
+ commits = await _call(lambda: brain.search_history(query, repo, max(1, min(k, MAX_RESULTS))))
253
+ return [_commit(c) for c in commits]
254
+
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."""
258
+ try:
259
+ return brain.read_lines(repo, path, start, end)
260
+ except BrainError as exc:
261
+ raise ToolError(str(exc)) from exc
262
+
263
+ @server.tool(annotations=READ_ONLY)
264
+ async def list_repos() -> list[RepoSummary]:
265
+ """Repositories indexed so far, with size, languages and the commit they were indexed at."""
266
+ return [_summary(info) for info in brain.repos()]
267
+
268
+ @server.resource(
269
+ "brain://repos",
270
+ name="indexed-repos",
271
+ mime_type="application/json",
272
+ description="Every indexed repository and its stats.",
273
+ )
274
+ def repos_resource() -> str:
275
+ return "[" + ",".join(_summary(info).model_dump_json() for info in brain.repos()) + "]"
276
+
277
+ @server.prompt(description="Answer a question about a codebase with strict citations.")
278
+ def answer_with_citations(question: str, repo: str = "") -> str:
279
+ target = f" in the {repo} repo" if repo else ""
280
+ return (
281
+ f"Question about the codebase{target}: {question}\n\n"
282
+ "1. Call ask_codebase with this question" + (f' and repo="{repo}"' if repo else "") + ".\n"
283
+ "2. If a source looks relevant but is cut off, open it with read_lines.\n"
284
+ "3. Answer in a few short paragraphs. Cite every claim as path:start-end.\n"
285
+ "4. If the sources don't answer it, say what's missing instead of guessing."
286
+ )
287
+
288
+ return server
289
+
290
+
291
+ async def _search(
292
+ brain: Brain, query: str, repo: str | None, k: int, path_prefix: str | None, kinds: list[str] | None
293
+ ) -> list[Hit]:
294
+ k = max(1, min(k, MAX_RESULTS))
295
+ return await _call(lambda: brain.search(query, repo, k, path_prefix, kinds))
296
+
297
+
298
+ async def _call(fn):
299
+ """Run blocking engine work off the event loop; user-facing errors become tool errors."""
300
+ try:
301
+ return await anyio.to_thread.run_sync(fn)
302
+ except BrainError as exc:
303
+ raise ToolError(str(exc)) from exc
304
+
305
+
306
+ def _commit(commit: Commit) -> CommitInfo:
307
+ return CommitInfo(
308
+ sha=commit.sha[:10],
309
+ author=commit.author,
310
+ date=commit.date,
311
+ subject=commit.subject,
312
+ body=commit.body,
313
+ pull_requests=list(commit.prs),
314
+ )
315
+
316
+
317
+ def _impact(report: ImpactReport) -> ImpactResult:
318
+ def info(d) -> DependentInfo:
319
+ return DependentInfo(
320
+ citation=d.chunk.citation,
321
+ symbol=d.chunk.symbol,
322
+ kind=d.chunk.kind,
323
+ via=d.via,
324
+ level=d.level,
325
+ is_test=d.is_test,
326
+ )
327
+
328
+ direct = [info(d) for d in report.dependents if d.level == 1]
329
+ indirect = [info(d) for d in report.dependents if d.level > 1]
330
+ tests = [d.chunk.citation for d in report.dependents if d.is_test]
331
+ files = len({d.chunk.path for d in report.dependents})
332
+ summary = (
333
+ f"{report.symbol} is used directly in {len(direct)} place(s) and indirectly in {len(indirect)}, "
334
+ f"across {files} file(s); {len(tests)} test chunk(s) exercise it."
335
+ )
336
+ return ImpactResult(
337
+ target=f"{report.path}:{report.start_line}-{report.end_line}",
338
+ symbol=report.symbol,
339
+ summary=summary,
340
+ direct=direct,
341
+ indirect=indirect,
342
+ tests=tests,
343
+ same_name_definitions=report.same_name_definitions,
344
+ notes=report.notes,
345
+ )
346
+
347
+
348
+ def _drift(repo: str, report: DriftReport) -> DriftResult:
349
+ counts = {level: sum(f.severity == level for f in report.findings) for level in ("high", "medium", "low")}
350
+ summary = (
351
+ f"{len(report.findings)} finding(s) in {report.sections_checked} doc section(s): "
352
+ f"{counts['high']} high, {counts['medium']} medium, {counts['low']} low."
353
+ if report.findings
354
+ else f"No drift found in {report.sections_checked} doc section(s)."
355
+ )
356
+ return DriftResult(
357
+ repo=repo,
358
+ summary=summary,
359
+ sections_checked=report.sections_checked,
360
+ references_checked=report.references_checked,
361
+ findings=[FindingInfo(**f.__dict__) for f in report.findings],
362
+ )
363
+
364
+
365
+ def _history(report: HistoryReport) -> HistoryResult:
366
+ citation = f"{report.path}:{report.start_line}-{report.end_line}"
367
+ instructions = (
368
+ "Explain why this code looks the way it does, oldest change first: what each commit changed and the "
369
+ "reason its message or PR gives. Cite commits by short sha and code as path:start-end. If the messages "
370
+ "don't say why, say so rather than guessing."
371
+ if report.changes
372
+ else "No commits changed these lines (they may be uncommitted)."
373
+ )
374
+ return HistoryResult(
375
+ repo=report.repo,
376
+ citation=citation,
377
+ symbol=report.symbol,
378
+ instructions=instructions,
379
+ authorship=[
380
+ Authorship(sha=b.sha[:10], author=b.author, date=b.date, subject=b.subject, lines=b.lines)
381
+ for b in report.blame
382
+ ],
383
+ changes=[Change(commit=_commit(c.commit), diff=c.diff) for c in report.changes],
384
+ pull_requests=[PullRequestInfo(**pr.__dict__) for pr in report.pull_requests],
385
+ )
386
+
387
+
388
+ def _source(hit: Hit) -> Source:
389
+ chunk = hit.chunk
390
+ matched = [name for name, rank in (("keyword", hit.keyword_rank), ("vector", hit.vector_rank)) if rank]
391
+ snippet = chunk.text if len(chunk.text) <= SNIPPET_CHARS else chunk.text[:SNIPPET_CHARS] + "\n…"
392
+ return Source(
393
+ citation=chunk.citation,
394
+ path=chunk.path,
395
+ start_line=chunk.start_line,
396
+ end_line=chunk.end_line,
397
+ kind=chunk.kind,
398
+ symbol=chunk.symbol,
399
+ score=hit.score,
400
+ matched_by=matched,
401
+ snippet=snippet,
402
+ )
403
+
404
+
405
+ def _summary(info) -> RepoSummary:
406
+ return RepoSummary(
407
+ name=info.name,
408
+ source=info.source,
409
+ commit=info.commit,
410
+ indexed_at=info.indexed_at,
411
+ files=info.files,
412
+ chunks=info.chunks,
413
+ commits=info.commits,
414
+ languages=info.languages,
415
+ )