semcode-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,284 @@
1
+ #!/usr/bin/env python3
2
+ """semantic-code-mcp 入口。
3
+
4
+ 暴露单一 MCP 工具:
5
+ codebase_search 自然语言语义检索代码库(首次自动全量索引,后续 watcher 增量同步)
6
+
7
+ 工程特性(对齐 augment-context-mcp):
8
+ - 多工作区 LRU 管理(SCM_MAX_WORKSPACES,默认 8)
9
+ - watchdog 文件监控 + 2s debounce(SCM_WATCH=0 禁用)
10
+ - MCP progress notification(索引进度实时回报)
11
+ - Cancel signal 支持(客户端取消即刻中止)
12
+ - 首次全量索引 / 后续 watcher 增量(O(变更数) 极速)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import logging
18
+ import os
19
+ import sys
20
+ import time
21
+ from logging.handlers import RotatingFileHandler
22
+ from pathlib import Path
23
+
24
+ from dotenv import load_dotenv
25
+ from mcp.server.fastmcp import Context, FastMCP
26
+
27
+ from .embedder import create_embedder
28
+ from .workspace import WorkspaceManager
29
+
30
+ # MCP 由 IDE 启动时 CWD 不定:先按 CWD 向上搜 .env(pip/uvx 安装场景,
31
+ # key 通常来自 MCP 客户端 env 配置,此步多为 no-op),再从仓库根加载(源码运行场景)
32
+ load_dotenv()
33
+ load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
34
+
35
+ # 日志只写本地文件(MCP_HANG_PROOF_DESIGN 原则 2):
36
+ # host 不消费 stderr 时 64KB pipe buffer 写满会同步阻塞进程,
37
+ # stderr 仅 SCM_LOG_STDERR=1 显式开启(调试用)。
38
+ LOG_FILE = os.getenv("SCM_LOG_FILE") or str(
39
+ Path(os.getenv("SCM_DB_DIR") or Path.home() / ".semantic-code-mcp") / "server.log"
40
+ )
41
+ _handlers: list[logging.Handler] = []
42
+ if os.getenv("SCM_LOG_DISABLE") != "1":
43
+ Path(LOG_FILE).parent.mkdir(parents=True, exist_ok=True)
44
+ _handlers.append(RotatingFileHandler(
45
+ LOG_FILE,
46
+ maxBytes=int(os.getenv("SCM_LOG_MAX_BYTES", str(10 * 1024 * 1024))),
47
+ backupCount=1,
48
+ encoding="utf-8",
49
+ ))
50
+ if os.getenv("SCM_LOG_STDERR") == "1":
51
+ _handlers.append(logging.StreamHandler(sys.stderr))
52
+ if not _handlers:
53
+ _handlers.append(logging.NullHandler())
54
+ logging.basicConfig(
55
+ level=logging.INFO,
56
+ format="%(asctime)s [%(name)s] %(message)s",
57
+ datefmt="%m-%d %H:%M:%S",
58
+ handlers=_handlers,
59
+ )
60
+ logger = logging.getLogger("semantic-code-mcp")
61
+
62
+ mcp = FastMCP("semantic-code-mcp")
63
+
64
+ # 单文件最多展示行数(控制 token 开销):前几名全量,后面的递减
65
+ _MAX_FILE_LINES = 120
66
+ # 排名超过此名次的文件用精简行预算(越靠后相关性越低,不值得等额 token)
67
+ _FULL_BUDGET_TOP = 3
68
+ _MAX_FILE_LINES_TAIL = 40
69
+ # 实体/DTO 块单块上限:字段列表看前几十行足够,140 行全量吐是浪费
70
+ _MAX_ENTITY_LINES = 30
71
+ # 任意单块上限:类级大块(整个 Service 类头几百行)价值密度低,
72
+ # 单块吃满文件预算会挤掉同文件其它命中块
73
+ _MAX_CHUNK_LINES = 80
74
+ # 单行最大字符数:builder 链/SQL 拼接行能到几百字符,截尾不影响理解
75
+ _MAX_LINE_CHARS = 200
76
+ # 单文件字符总预算:行数预算管不住长行密集文件(120 行×200 字符最坏 24KB),
77
+ # 字符级预算顶住 token 上限
78
+ _MAX_FILE_CHARS = 8000
79
+ # 关联(call graph)块最多展示行数
80
+ _MAX_RELATED_LINES = 20
81
+
82
+ # 全局单例
83
+ _embedder = None
84
+ _manager: WorkspaceManager | None = None
85
+
86
+
87
+ def _get_embedder():
88
+ global _embedder
89
+ if _embedder is None:
90
+ _embedder = create_embedder(model=os.getenv("SCM_EMBED_MODEL", "voyage-code-3"))
91
+ return _embedder
92
+
93
+
94
+ def _get_manager() -> WorkspaceManager:
95
+ global _manager
96
+ if _manager is None:
97
+ _manager = WorkspaceManager(_get_embedder())
98
+ return _manager
99
+
100
+
101
+ _RELATION_LABEL = {"callee": "\u2192 \u88ab\u8c03\u7528", "caller": "\u2190 \u8c03\u7528\u8005"}
102
+
103
+
104
+ def _numbered_lines(code: str, start_line: int, budget: int) -> tuple[list[str], int]:
105
+ """把代码渲染为带行号的行列表,超出预算截断,超长行截尾。返回 (行列表, 消耗行数)。"""
106
+ code_lines = code.split("\n")
107
+ take = code_lines[:budget]
108
+ out = [
109
+ f"{start_line + i:>6}\t"
110
+ + (ln if len(ln) <= _MAX_LINE_CHARS else ln[:_MAX_LINE_CHARS] + " …")
111
+ for i, ln in enumerate(take)
112
+ ]
113
+ omitted = len(code_lines) - len(take)
114
+ if omitted > 0:
115
+ out.append(f"... ({omitted} more lines)")
116
+ return out, len(take)
117
+
118
+
119
+ def _format_file_group(idx: int, file_path: str, chunks: list[dict]) -> str:
120
+ """按文件聚合渲染:块按行号排序,非连续块之间用 ... 分隔。
121
+
122
+ 分层预算:前 _FULL_BUDGET_TOP 名全量展示,之后的文件用精简预算。"""
123
+ chunks = sorted(chunks, key=lambda c: c.get("start_line", 0))
124
+ best = max(c.get("score", 0.0) for c in chunks)
125
+ symbols = ", ".join(dict.fromkeys(c.get("symbol", "") for c in chunks if c.get("symbol")))
126
+ lang = chunks[0].get("language", "")
127
+ header = f"## {idx}. {file_path} :: {symbols} (score={best:.3f})"
128
+ body: list[str] = []
129
+ budget = _MAX_FILE_LINES if idx <= _FULL_BUDGET_TOP else _MAX_FILE_LINES_TAIL
130
+ prev_end: int | None = None
131
+ for c in chunks:
132
+ if budget <= 0:
133
+ break
134
+ start = c.get("start_line", 1)
135
+ code = c.get("code", "")
136
+ if prev_end is not None:
137
+ if start > prev_end + 1:
138
+ body.append("...")
139
+ elif start <= prev_end:
140
+ # 行窗口切分的重叠块:裁掉已展示过的行,避免重复渲染
141
+ skip = prev_end - start + 1
142
+ code_lines = code.split("\n")[skip:]
143
+ if not code_lines:
144
+ continue
145
+ code = "\n".join(code_lines)
146
+ start = prev_end + 1
147
+ chunk_budget = min(budget, _MAX_ENTITY_LINES if c.get("entity") else _MAX_CHUNK_LINES)
148
+ lines, used = _numbered_lines(code, start, chunk_budget)
149
+ body.extend(lines)
150
+ budget -= used
151
+ prev_end = max(prev_end or 0, c.get("end_line", start + used - 1))
152
+ if sum(len(ln) + 1 for ln in body) >= _MAX_FILE_CHARS:
153
+ # 超出字符预算:从尾部丢行直到预算内,再标记截断
154
+ total = 0
155
+ keep = 0
156
+ for ln in body:
157
+ total += len(ln) + 1
158
+ if total > _MAX_FILE_CHARS:
159
+ break
160
+ keep += 1
161
+ body = body[:keep]
162
+ body.append("... (file output budget reached)")
163
+ break
164
+ return f"{header}\n```{lang}\n" + "\n".join(body) + "\n```"
165
+
166
+
167
+ def _format_results(results: list[dict]) -> str:
168
+ main = [r for r in results if not r.get("relation")]
169
+ related = [r for r in results if r.get("relation")]
170
+ blocks: list[str] = []
171
+ # 主结果按文件聚合(文件顺序 = 该文件最佳块的排名顺序)
172
+ order: list[str] = []
173
+ groups: dict[str, list[dict]] = {}
174
+ for r in main:
175
+ fp = r["file_path"]
176
+ if fp not in groups:
177
+ groups[fp] = []
178
+ order.append(fp)
179
+ groups[fp].append(r)
180
+ for i, fp in enumerate(order, 1):
181
+ blocks.append(_format_file_group(i, fp, groups[fp]))
182
+ # 关联块(call graph 扩展)保持紧凑单块展示
183
+ for r in related:
184
+ tag = _RELATION_LABEL.get(r.get("relation"), r.get("relation"))
185
+ header = (
186
+ f"## [\u5173\u8054 {tag}] {r['file_path']} :: {r['symbol']} "
187
+ f"(L{r['start_line']}-{r['end_line']})"
188
+ )
189
+ lines, _ = _numbered_lines(r.get("code", ""), r.get("start_line", 1), _MAX_RELATED_LINES)
190
+ blocks.append(f"{header}\n```{r.get('language', '')}\n" + "\n".join(lines) + "\n```")
191
+ return "\n\n".join(blocks)
192
+
193
+
194
+ @mcp.tool()
195
+ async def codebase_search(
196
+ information_request: str,
197
+ directory_path: str,
198
+ ctx: Context,
199
+ top_n: int = 0,
200
+ path_filter: str = "",
201
+ include_related: bool = True,
202
+ ) -> str:
203
+ """语义检索代码库,返回最相关的代码片段。
204
+
205
+ Args:
206
+ information_request: 自然语言查询,例如"用户登录鉴权逻辑在哪里"
207
+ directory_path: 要检索的代码库绝对路径
208
+ top_n: 可选,返回结果数(0 = 用默认值 SCM_TOP_N,默认 10)
209
+ path_filter: 可选,文件路径过滤;含通配符按 glob 匹配(如 "*.java"、"**/service/**"),
210
+ 否则按路径子串匹配(如 "controller")
211
+ include_related: 可选,是否附带 call graph 关联块(调用者/被调用者),默认 True
212
+ """
213
+ directory = Path(directory_path).resolve()
214
+ t0 = time.time()
215
+ logger.info("[Tool] → codebase_search start dir=%s query=%.80s", directory, information_request)
216
+ if not directory.is_dir():
217
+ return f"Error: \u76ee\u5f55\u4e0d\u5b58\u5728\u6216\u4e0d\u662f\u6587\u4ef6\u5939: {directory_path}"
218
+ try:
219
+ manager = _get_manager()
220
+ ws = manager.get(str(directory))
221
+ # Progress: 索引阶段
222
+ await ctx.report_progress(0, 100)
223
+ await ctx.info(f"\u5de5\u4f5c\u533a: {directory} (LRU {manager.active_count}/{os.getenv('SCM_MAX_WORKSPACES', '8')})")
224
+
225
+ # 增量同步(watcher dirty 或全量)
226
+ loop = asyncio.get_running_loop()
227
+ _last_pct = [0]
228
+
229
+ def _sync_with_progress():
230
+ def _progress(done, total, fp):
231
+ if total <= 0:
232
+ return
233
+ pct = 10 + int(done / total * 50)
234
+ if pct - _last_pct[0] >= 5:
235
+ _last_pct[0] = pct
236
+ asyncio.run_coroutine_threadsafe(ctx.report_progress(pct, 100), loop)
237
+ return manager.sync_workspace(ws, on_progress=_progress)
238
+
239
+ await ctx.report_progress(10, 100)
240
+ stats = await loop.run_in_executor(None, _sync_with_progress)
241
+ changed = stats.get("changed", 0)
242
+ if changed > 0:
243
+ await ctx.info(f"\u7d22\u5f15\u540c\u6b65: {changed} \u4e2a\u6587\u4ef6\u53d8\u66f4")
244
+ await ctx.report_progress(60, 100)
245
+
246
+ # 检索阶段
247
+ top_k = int(os.getenv("SCM_TOP_K", "50"))
248
+ n = top_n if top_n > 0 else int(os.getenv("SCM_TOP_N", "10"))
249
+ pf = path_filter.strip() or None
250
+ results = await loop.run_in_executor(
251
+ None,
252
+ lambda: ws.search(
253
+ information_request,
254
+ top_k=top_k,
255
+ top_n=n,
256
+ expand_graph=include_related,
257
+ path_filter=pf,
258
+ ),
259
+ )
260
+ await ctx.report_progress(100, 100)
261
+ logger.info("[Tool] ✓ codebase_search done %.1fs results=%d", time.time() - t0, len(results))
262
+ if not results:
263
+ return "\u672a\u627e\u5230\u76f8\u5173\u4ee3\u7801\u3002"
264
+ return _format_results(results)
265
+ except asyncio.CancelledError:
266
+ logger.info("[Tool] ⊗ codebase_search cancelled %.1fs(后台索引线程会继续跑完并持久化)", time.time() - t0)
267
+ return "Error: \u8bf7\u6c42\u5df2\u88ab\u53d6\u6d88"
268
+ except Exception as e:
269
+ logger.exception("[Tool] ✗ codebase_search FAIL %.1fs", time.time() - t0)
270
+ return f"Error: {e}\n\uff08\u8bf7\u786e\u8ba4\u5df2\u5728\u73af\u5883\u53d8\u91cf\u4e2d\u914d\u7f6e VOYAGE_API_KEY\uff09"
271
+
272
+
273
+ def main() -> None:
274
+ logger.info(
275
+ f"========== semantic-code-mcp 启动 pid={os.getpid()} "
276
+ f"(max_workspaces={os.getenv('SCM_MAX_WORKSPACES', '8')}, "
277
+ f"watch={'enabled' if os.getenv('SCM_WATCH', '1') != '0' else 'disabled'}, "
278
+ f"log={LOG_FILE}) =========="
279
+ )
280
+ mcp.run()
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()