graphitect 0.2.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.
Files changed (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/serve.py ADDED
@@ -0,0 +1,2608 @@
1
+ # MCP stdio server - exposes graph query tools to Claude and other agents
2
+ from __future__ import annotations
3
+ import json
4
+ import math
5
+ import os
6
+ import re
7
+ import sys
8
+ from array import array
9
+ from collections import OrderedDict
10
+ from pathlib import Path
11
+ import threading
12
+ from typing import NamedTuple
13
+ import networkx as nx
14
+ from networkx.readwrite import json_graph
15
+ from graphify.security import sanitize_label, check_graph_file_size_cap
16
+ from graphify.build import edge_data, edge_datas
17
+ from graphify.paths import default_graph_json as _default_graph_json
18
+
19
+ try:
20
+ import jieba as _jieba # type: ignore[import-untyped]
21
+ except ImportError:
22
+ _jieba = None
23
+
24
+
25
+ class ToolError(Exception):
26
+ """Raised by a tool handler to signal an error result.
27
+
28
+ A normal string return is sent as an ordinary (successful) text result. A
29
+ ToolError is instead turned into a tool result with ``isError: true`` so a
30
+ client that only checks ``isError`` can tell a genuine failure — e.g. the
31
+ ``gh`` CLI missing or a PR that cannot be resolved — from success.
32
+ """
33
+
34
+
35
+ def _load_graph(graph_path: str) -> nx.Graph:
36
+ try:
37
+ resolved = Path(graph_path).resolve()
38
+ if resolved.suffix != ".json":
39
+ raise ValueError(f"Graph path must be a .json file, got: {graph_path!r}")
40
+ if not resolved.exists():
41
+ raise FileNotFoundError(f"Graph file not found: {resolved}")
42
+ check_graph_file_size_cap(resolved)
43
+ safe = resolved
44
+ data = json.loads(safe.read_text(encoding="utf-8"))
45
+ if "links" not in data and "edges" in data:
46
+ data = dict(data, links=data["edges"])
47
+ # Stash the on-disk logical flag before the load-time override below:
48
+ # `directed: True` exists only so renderers can recover stored arc
49
+ # order (#2309); tools that care about logical direction (#2487) must
50
+ # not mistake the override for graph truth.
51
+ _logical_directed = bool(data.get("directed", False))
52
+ data = {**data, "directed": True}
53
+ try:
54
+ from graphify.build import graph_has_legacy_ids as _legacy
55
+ if _legacy(data.get("nodes", [])):
56
+ print(
57
+ "[graphify] note: this graph uses the pre-#1504 node-ID scheme; "
58
+ "rebuild with `graphify extract --force` for path-qualified IDs.",
59
+ file=sys.stderr,
60
+ )
61
+ except Exception:
62
+ pass
63
+ try:
64
+ G = json_graph.node_link_graph(data, edges="links")
65
+ except TypeError:
66
+ G = json_graph.node_link_graph(data)
67
+ G.graph["_logical_directed"] = _logical_directed
68
+ # Attach the work-memory overlay (derived sidecar next to graph.json) so
69
+ # the query/MCP read surface can annotate NODE lines display-only. Empty
70
+ # when no sidecar exists, leaving un-annotated output byte-identical.
71
+ try:
72
+ from graphify.reflect import load_learning_overlay as _llo
73
+ G.graph["_learning_overlay"] = _llo(resolved)
74
+ except Exception:
75
+ G.graph["_learning_overlay"] = {}
76
+ return G
77
+ except json.JSONDecodeError as exc:
78
+ print(f"error: graph.json is corrupted ({exc}). Re-run /graphify to rebuild.", file=sys.stderr)
79
+ sys.exit(1)
80
+ except (ValueError, FileNotFoundError) as exc:
81
+ print(f"error: {exc}", file=sys.stderr)
82
+ sys.exit(1)
83
+
84
+
85
+ def _communities_from_graph(G: nx.Graph) -> dict[int, list[str]]:
86
+ """Reconstruct community dict from community property stored on nodes."""
87
+ communities: dict[int, list[str]] = {}
88
+ for node_id, data in G.nodes(data=True):
89
+ cid = data.get("community")
90
+ if cid is not None:
91
+ communities.setdefault(int(cid), []).append(node_id)
92
+ return communities
93
+
94
+
95
+ def _max_server_contexts() -> int:
96
+ """Return the project-context LRU capacity (default 8, minimum 1).
97
+
98
+ ``GRAPHIFY_MAX_CONTEXTS`` overrides the default. Invalid or blank values
99
+ use 8; zero and negative values clamp to 1, since each request needs a
100
+ graph context. The server's configured default graph is pinned separately
101
+ and does not count against this limit.
102
+ """
103
+ raw = os.environ.get("GRAPHIFY_MAX_CONTEXTS", "").strip()
104
+ if not raw:
105
+ return 8
106
+ try:
107
+ return max(1, int(raw))
108
+ except ValueError:
109
+ return 8
110
+
111
+
112
+ class _GraphContextCache:
113
+ """Thread-safe graph contexts: one pinned default plus an LRU of projects."""
114
+
115
+ def __init__(self, max_contexts: int):
116
+ self._max_contexts = max_contexts
117
+ self._entries: OrderedDict[str, dict] = OrderedDict()
118
+ self._pinned: dict[str, dict] = {}
119
+ self._lock = threading.Lock()
120
+
121
+ def _load_entry(self, resolved_path: str, key: tuple[int, int]) -> dict:
122
+ """Build one entry for an already-resolved path and known file key.
123
+
124
+ ``_load_graph`` is also used by the CLI, where invalid input terminates
125
+ the process. A client-supplied ``project_path`` must instead become a
126
+ tool error, so the shared MCP server can continue serving other graphs.
127
+ """
128
+ try:
129
+ graph = _load_graph(resolved_path)
130
+ except SystemExit as exc:
131
+ raise RuntimeError(f"could not load graph.json at {resolved_path}") from exc
132
+ # Warm the index before exposing the graph so its first query does not
133
+ # pay the expensive build cost.
134
+ _get_trigram_index(graph)
135
+ communities = _communities_from_graph(graph)
136
+ entry = {
137
+ "key": key,
138
+ "G": graph,
139
+ "communities": communities,
140
+ }
141
+ return entry
142
+
143
+ def load(self, resolved_path: str, *, pinned: bool = False) -> tuple[nx.Graph, dict[int, list[str]]]:
144
+ """Return a fresh context, retaining project contexts by LRU order.
145
+
146
+ ``resolved_path`` is resolved by the caller, making this method the
147
+ sole owner of file statting and cache-key construction.
148
+
149
+ ``pinned=True`` is reserved for the server's configured default graph;
150
+ it remains warm without consuming a project-cache slot.
151
+ """
152
+ with self._lock:
153
+ try:
154
+ stat_result = Path(resolved_path).stat()
155
+ except FileNotFoundError:
156
+ raise FileNotFoundError(f"graph.json not found: {resolved_path}") from None
157
+ key = (stat_result.st_mtime_ns, stat_result.st_size)
158
+ entries = self._pinned if pinned else self._entries
159
+ entry = entries.get(resolved_path)
160
+ if entry is not None and entry["key"] == key:
161
+ if not pinned:
162
+ self._entries.move_to_end(resolved_path)
163
+ return entry["G"], entry["communities"]
164
+
165
+ entry = self._load_entry(resolved_path, key)
166
+ entries[resolved_path] = entry
167
+ if not pinned:
168
+ self._entries.move_to_end(resolved_path)
169
+ while len(self._entries) > self._max_contexts:
170
+ self._entries.popitem(last=False)
171
+ return entry["G"], entry["communities"]
172
+
173
+
174
+ def _strip_diacritics(text: str | None) -> str:
175
+ import unicodedata
176
+ if not isinstance(text, str):
177
+ text = "" if text is None else str(text)
178
+ nfkd = unicodedata.normalize("NFKD", text)
179
+ return "".join(c for c in nfkd if not unicodedata.combining(c))
180
+
181
+
182
+ def _search_tokens(text: str) -> list[str]:
183
+ """Split text into word tokens, stripping punctuation and diacritics.
184
+
185
+ `_` is a separator, exactly like `-`. `\\w` counts underscore as a word
186
+ character but not hyphen, so `graph_first_guard` stayed one token while the
187
+ label `graph-first-guard.py` split into three — and the query matched
188
+ nothing. Both the query and the node label pass through here, so splitting
189
+ on `_` keeps the two sides consistent and snake_case lookups still resolve
190
+ (their tokens simply match the same way). Found 2026-07-29: the graph could
191
+ not find the underscore spelling of its own `local_id`.
192
+ """
193
+ return re.findall(r"[^\W_]+", _strip_diacritics(str(text)).lower())
194
+
195
+
196
+ def _has_chinese(text: str) -> bool:
197
+ return any("一" <= ch <= "鿿" for ch in text)
198
+
199
+
200
+ def _segment_chinese(text: str) -> list[str]:
201
+ """Segment Chinese text and keep the original term for exact matching."""
202
+ if _jieba is not None:
203
+ segments = [w for w in _jieba.cut(text) if len(w.strip()) > 0]
204
+ else:
205
+ segments = [text[i:i + 2] for i in range(len(text) - 1)] or [text]
206
+ if len(text) > 1 and text not in segments:
207
+ segments.append(text)
208
+ return segments
209
+
210
+
211
+ def _is_searchable(term: str) -> bool:
212
+ """True if term is Chinese, non-English, or an English word longer than 2 chars."""
213
+ if all("a" <= ch <= "z" for ch in term):
214
+ return len(term) > 2
215
+ return True
216
+
217
+
218
+ # Question/filler words dropped from query terms so content words drive BFS
219
+ # seeding. Without this, "how does the frontier cache work" seeds on "how"/
220
+ # "the"/"work" (which prefix-match prose labels like "Working Principles" at 100x)
221
+ # instead of "frontier"/"cache", and lands in the wrong part of the graph. Applied
222
+ # to query terms only — node text is never filtered, so a symbol literally named
223
+ # `work` stays findable via explain/path. `work`/`works`/`working` are included
224
+ # because "how does X work" / "how X works" is the most common question phrasing.
225
+ #
226
+ # Non-English question words are just as damaging (#1900): in a mostly-English
227
+ # code corpus, German "wie"/"funktioniert" are rare, so they get HIGH IDF weight
228
+ # and out-seed the actual content noun by orders of magnitude. So this also
229
+ # carries a curated German set plus a trimmed French/Spanish/Portuguese/Italian
230
+ # set of question/filler words. Diacritics are kept intact (the query tokenizer
231
+ # does not NFKD-strip).
232
+ #
233
+ # Collision tradeoff: a few foreign stopwords are also English content words.
234
+ # We include high-German-value ones like "die"/"hat" (the all-stopword fallback
235
+ # in _query_terms and the unfiltered find_node path keep an English "die"/"hat"
236
+ # query workable), but deliberately OMIT "war"/"bald" (German was/soon) so
237
+ # English queries about "war" or "bald" are not clobbered. On the Romance side
238
+ # we likewise omit "comment" (FR how), "come" (IT how), "son"/"sin"/"con" (ES),
239
+ # and "pour"/"des" (FR) — all too common as English/code terms.
240
+ _QUERY_STOPWORDS = frozenset({
241
+ # English
242
+ "how", "what", "why", "when", "where", "which", "who", "whom", "whose",
243
+ "does", "did", "is", "are", "was", "were", "be", "been", "being",
244
+ "can", "could", "should", "would", "will", "shall", "may", "might", "must",
245
+ "has", "have", "had", "the", "and", "but", "not", "for", "from", "with",
246
+ "without", "into", "onto", "off", "that", "this", "these", "those", "there",
247
+ "here", "its", "their", "them", "they", "about", "any", "all", "some",
248
+ "work", "works", "working",
249
+ # German (articles/conjunctions/question words/auxiliaries/prepositions)
250
+ "der", "die", "das", "den", "dem", "ein", "eine", "und", "oder", "nicht",
251
+ "wie", "wer", "wann", "wo", "warum", "wieso",
252
+ "welche", "welcher", "welches",
253
+ "ist", "sind", "wird", "wurde", "hat", "haben",
254
+ "kann", "koennen", "können", "soll", "muss", "sich",
255
+ "bei", "mit", "von", "fuer", "für", "ueber", "über", "nach", "aus",
256
+ "gibt", "es",
257
+ "funktioniert", "geaendert", "geändert", "aendert", "ändert",
258
+ # French
259
+ "pourquoi", "quand", "quel", "quelle", "quels", "quelles", "quoi",
260
+ "qui", "que", "est", "sont", "fonctionne", "cette", "dans", "avec", "où",
261
+ # Spanish
262
+ "cómo", "como", "qué", "cuál", "cuáles", "cuándo", "dónde", "donde",
263
+ "porque", "por", "para", "funciona", "está", "están", "hay",
264
+ # Portuguese
265
+ "qual", "quais", "quando", "onde", "são", "estão", "tem", "uma", "não",
266
+ # Italian
267
+ "perché", "cosa", "quale", "quali", "dove", "funziona", "sono", "che",
268
+ "della",
269
+ })
270
+
271
+
272
+ def _query_terms(question: str) -> list[str]:
273
+ """Split a query into searchable terms, segmenting Chinese text, then drop
274
+ question/filler words (`_QUERY_STOPWORDS`, English plus common German/
275
+ Romance-language fillers) so content words drive seeding. Falls back to the
276
+ unfiltered terms if the query is all stopwords, so a question like "how does
277
+ it work" or "wie funktioniert das" still seeds on something."""
278
+ terms: list[str] = []
279
+ for raw in question.split():
280
+ if _has_chinese(raw):
281
+ for seg in _segment_chinese(raw.lower().strip()):
282
+ seg = seg.strip()
283
+ if seg and _is_searchable(seg):
284
+ terms.append(seg)
285
+ else:
286
+ # Strip punctuation without touching Unicode characters (avoid NFKD mangling non-Latin scripts)
287
+ for tok in re.findall(r"\w+", raw.lower()):
288
+ if _is_searchable(tok):
289
+ terms.append(tok)
290
+ content = [t for t in terms if t not in _QUERY_STOPWORDS]
291
+ return content or terms
292
+
293
+
294
+ _EXACT_MATCH_BONUS = 1000.0
295
+ _PREFIX_MATCH_BONUS = 100.0
296
+ _SUBSTRING_MATCH_BONUS = 1.0
297
+ _SOURCE_MATCH_BONUS = 0.5
298
+ # The extraction spec stores the WHY of a concept as a `rationale` attribute
299
+ # on the node, not as a node of its own, so for a "why does X …" question that
300
+ # prose is often the only place the question's words occur (#2293). Score it
301
+ # as its own tier: below a label substring hit (the label still names the
302
+ # thing), above a source-path hit, and — like the source tier — never counted
303
+ # toward term coverage, so a long rationale adds recall without winning back
304
+ # an exact-label tier it did not earn.
305
+ _RATIONALE_MATCH_BONUS = 0.75
306
+
307
+
308
+ def _compute_idf(G: nx.Graph, terms: list[str]) -> dict[str, float]:
309
+ """IDF weights for query terms, cached in G.graph['_idf_cache'].
310
+
311
+ Common terms like 'error' or 'exception' that match hundreds of nodes get
312
+ low weights; rare identifiers like 'FooBarService' get high weights.
313
+ Cache is stored on the graph object itself so it auto-invalidates when
314
+ a hot-reload replaces G with a new object.
315
+ """
316
+ cache: dict[str, float] = G.graph.setdefault("_idf_cache", {})
317
+ N = G.number_of_nodes() or 1
318
+ uncached = [t for t in terms if t not in cache]
319
+ if uncached:
320
+ df: dict[str, int] = {t: 0 for t in uncached}
321
+ for _, data in G.nodes(data=True):
322
+ norm_label = (
323
+ data.get("norm_label") or _strip_diacritics(data.get("label") or "")
324
+ ).lower()
325
+ for t in uncached:
326
+ if t in norm_label:
327
+ df[t] += 1
328
+ for t in uncached:
329
+ cache[t] = math.log(1 + N / (1 + df[t]))
330
+ return {t: cache.get(t, math.log(1 + N)) for t in terms}
331
+
332
+
333
+ def _trigrams(text: str) -> set[str]:
334
+ """Character trigrams of `text`; for <3-char text the whole string is the key."""
335
+ if len(text) < 3:
336
+ return {text} if text else set()
337
+ return {text[i:i + 3] for i in range(len(text) - 2)}
338
+
339
+
340
+ def _node_rationale_text(data: dict) -> str:
341
+ """The node's `rationale` attribute normalized like a label (diacritics
342
+ folded, lower-cased) for substring matching. Semantic cleanup writes it as
343
+ one string (several sources joined with blank lines); an extractor may hand
344
+ over a list — join it. Missing or empty -> "" so callers can `if rationale`.
345
+ """
346
+ raw = data.get("rationale")
347
+ if not raw:
348
+ return ""
349
+ if isinstance(raw, (list, tuple)):
350
+ raw = " ".join(str(part) for part in raw if part)
351
+ return _strip_diacritics(str(raw)).lower()
352
+
353
+
354
+ def _node_search_text(data: dict, nid: str) -> str:
355
+ """Concatenate every field _score_nodes / _find_node match a query against, so
356
+ one trigram index over this text is a complete candidate generator for both.
357
+
358
+ - `rationale` (normalized via `_node_rationale_text`) feeds _score_nodes'
359
+ rationale tier (#2293); appended last, and only when present, so every
360
+ other field position is unchanged.
361
+
362
+ - `norm_label` and `source_file` feed _score_nodes' per-term substring tiers.
363
+ - `label_tokens` (the space-joined token form) feeds _find_node's
364
+ `term in label_tokens` branch, where a multi-word `term` can span a token
365
+ boundary that punctuation hides in `norm_label` (e.g. query "foo bar" matches
366
+ label "foo.bar" only via its tokenized form).
367
+ - `source_tokens` feeds _find_node's exact source-file path lookup, where a
368
+ query like "app/api/example/route.ts" tokenizes to "app api example route ts".
369
+ - `nid` feeds the whole-query `joined == nid_lower` tier.
370
+ - a trailing diacritic-folded `nid` feeds _find_node's `norm_query == nid_norm`
371
+ tier. Every query path folds through `_strip_diacritics` (NFKD), so a raw-only
372
+ id field leaves the needle and the posting under different normal forms and
373
+ the node is dropped before any predicate runs (#2467). Hangul is the common
374
+ case: NFKD decomposes a syllable into conjoining jamo, which have combining
375
+ class 0 and therefore survive the combining-character filter. The field is
376
+ appended only when the fold actually differs, so the text an all-ASCII graph
377
+ indexes — and every field position the other readers rely on — is unchanged.
378
+
379
+ NUL separators stop a trigram from spanning two fields (a query never contains
380
+ NUL, so a cross-field trigram can never be a real match).
381
+ """
382
+ norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower()
383
+ label_tokens = " ".join(_search_tokens(data.get("label") or ""))
384
+ source = (data.get("source_file") or "").lower()
385
+ source_tokens = " ".join(_search_tokens(data.get("source_file") or ""))
386
+ nid_text = str(nid).lower()
387
+ fields = (norm_label, label_tokens, nid_text, source, source_tokens)
388
+ if not nid_text.isascii():
389
+ nid_folded = _strip_diacritics(str(nid)).lower()
390
+ if nid_folded != nid_text:
391
+ fields += (nid_folded,)
392
+ rationale = _node_rationale_text(data)
393
+ if rationale:
394
+ fields += (rationale,)
395
+ return "\x00".join(fields)
396
+
397
+
398
+ def _get_trigram_index(G: nx.Graph) -> dict:
399
+ """Lazily build and cache a trigram -> node-position postings map on the graph.
400
+
401
+ Cached on `G.graph` so it auto-invalidates when a hot-reload swaps in a
402
+ fresh graph object, exactly like `_idf_cache`. `set_cache` memoizes per-trigram
403
+ id-sets across queries within one graph generation.
404
+ """
405
+ idx = G.graph.get("_trigram_index")
406
+ if idx is not None:
407
+ return idx
408
+ ids = list(G.nodes())
409
+ postings: dict[str, array] = {}
410
+ for i, nid in enumerate(ids):
411
+ for g in _trigrams(_node_search_text(G.nodes[nid], nid)):
412
+ bucket = postings.get(g)
413
+ if bucket is None:
414
+ bucket = array("i")
415
+ postings[g] = bucket
416
+ bucket.append(i)
417
+ idx = {"ids": ids, "postings": postings, "set_cache": {}}
418
+ G.graph["_trigram_index"] = idx
419
+ return idx
420
+
421
+
422
+ def _trigram_candidates(G: nx.Graph, needles: list[str], *, guard_frac: float = 0.10) -> list[str] | None:
423
+ """Node IDs whose text could contain any `needle` as a substring, via the
424
+ trigram index — a *superset* the caller then re-scores with the exact predicates.
425
+
426
+ Returns candidates in graph-iteration order (so order-sensitive callers like
427
+ _find_node stay byte-identical to a full scan), or **None** when the index isn't
428
+ worth it — a needle is too short to trigram, or its rarest trigram is still
429
+ common enough that the candidate set would approach the whole graph. The caller
430
+ falls back to the full scan, preserving the never-worse contract. The guard is
431
+ cheap: postings-length lookups only, no set intersection.
432
+ """
433
+ idx = _get_trigram_index(G)
434
+ ids, postings, set_cache = idx["ids"], idx["postings"], idx["set_cache"]
435
+ n = len(ids)
436
+ if n == 0:
437
+ return []
438
+ needles = [s for s in needles if s]
439
+ thresh = int(n * guard_frac)
440
+ for s in needles:
441
+ tgs = _trigrams(s)
442
+ if not tgs or any(len(g) < 3 for g in tgs):
443
+ return None # too short to trigram-filter
444
+ present = [len(postings[g]) for g in tgs if g in postings]
445
+ if not present:
446
+ continue # this needle matches nothing — contributes no candidates
447
+ if min(present) > thresh:
448
+ return None # rarest trigram still too common -> not worth the index
449
+ cand: set[int] = set()
450
+ for s in needles:
451
+ sets: list[set] | None = []
452
+ for g in _trigrams(s):
453
+ bucket = postings.get(g)
454
+ if bucket is None:
455
+ sets = None # a trigram absent everywhere -> needle matches nothing
456
+ break
457
+ cached = set_cache.get(g)
458
+ if cached is None:
459
+ cached = set(bucket)
460
+ set_cache[g] = cached
461
+ sets.append(cached)
462
+ if not sets:
463
+ continue
464
+ sets.sort(key=len) # intersect smallest-first
465
+ hit = set(sets[0])
466
+ for other in sets[1:]:
467
+ hit &= other
468
+ if not hit:
469
+ break
470
+ cand |= hit
471
+ return [ids[i] for i in sorted(cand)]
472
+
473
+
474
+ class _QueryScores(NamedTuple):
475
+ """Per-query scoring result, returned by the private `_score_query` helper.
476
+
477
+ `ranked` is the existing ordered `(score, node_id)` ranking produced by the
478
+ combined query scorer (the value `_score_nodes` always returned). When the
479
+ caller asks for it via `collect_per_term_seeds=True`, `best_seed_by_term`
480
+ additionally carries the winning node id for each normalized search token —
481
+ the seed `_pick_seeds` would have picked for that token via the now-retired
482
+ per-token `_score_nodes([token])` rescoring pass — computed in the *same*
483
+ per-node traversal so the query path makes exactly one graph scoring pass
484
+ regardless of query length. Empty when `collect_per_term_seeds=False`.
485
+ """
486
+ ranked: list[tuple[float, str]]
487
+ best_seed_by_term: dict[str, str]
488
+
489
+
490
+ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]:
491
+ """Combined query scorer returning the existing ranked `(score, node_id)` list.
492
+
493
+ Backwards-compatible thin wrapper around `_score_query` for path, explain,
494
+ tests, and every other caller that only needs the combined ranking. The
495
+ per-term seed metadata computed by `_score_query` (when requested) is
496
+ discarded here so existing callers see no API or runtime-cost change.
497
+ """
498
+ return _score_query(G, terms, collect_per_term_seeds=False).ranked
499
+
500
+
501
+ def _score_query(
502
+ G: nx.Graph, terms: list[str], *, collect_per_term_seeds: bool
503
+ ) -> _QueryScores:
504
+ """Single-pass combined scorer that optionally also records the best seed
505
+ for each normalized query token.
506
+
507
+ The combined ranking is byte-identical to what `_score_nodes` produced
508
+ before the refactor; `_score_nodes` is now a thin wrapper that asks for
509
+ `collect_per_term_seeds=False` and returns only `.ranked`.
510
+
511
+ When `collect_per_term_seeds=True`, the per-token singleton winner is
512
+ computed alongside the combined score in the *same* per-node visit (it
513
+ reuses the same `norm_label` / `label_tokens` / `source` already evaluated
514
+ for the combined tier), so `_query_graph_text` can feed `best_seed_by_term`
515
+ straight into `_pick_seeds` and skip the T additional whole-graph rescoring
516
+ passes the old per-token `_score_nodes([token])` loop ran.
517
+
518
+ Singleton-winner semantics match the legacy per-token path exactly. The
519
+ score itself mirrors `_score_nodes([token])` with `n_terms == 1` (so the
520
+ coverage term is 1 and the per-token tier is unscaled) plus the broader
521
+ joined-singlet tier (which also checks `label_tokens` and `nid_lower`).
522
+ Tie-break order is (1) highest singleton score, (2) highest graph degree,
523
+ (3) shortest displayed label, (4) lexicographically smallest node id —
524
+ exactly what `max(tied, key=degree)` over a sort by `(-score, label_len,
525
+ nid)` produced in the legacy `_pick_seeds` per-token loop. The combined
526
+ trigram candidate set (needles `norm_terms + [joined]`) is a superset of
527
+ each per-token `[t]` candidate set, so iterating combined candidates
528
+ discovers every non-zero singleton-score node for every term.
529
+ """
530
+ scored: list[tuple[float, str]] = []
531
+ # Dedupe tokens, order-preserving (as _pick_seeds already does): a repeated
532
+ # query word must not double-count every tier, and with coverage scaling
533
+ # below it would also inflate the matched-term ratio (#1602).
534
+ norm_terms = list(dict.fromkeys(tok for t in terms for tok in _search_tokens(t)))
535
+ n_terms = len(norm_terms)
536
+ idf = _compute_idf(G, norm_terms)
537
+ # Whole-query string for full-label matching (mirrors _find_node's `term`).
538
+ joined = " ".join(norm_terms)
539
+ # Weight the full-query bonus by the rarest constituent term so a specific
540
+ # multi-word label still outweighs common-token noise; floor at 1.0.
541
+ joined_w = max((idf.get(t, 1.0) for t in norm_terms), default=1.0)
542
+ # Trigram prefilter: score only nodes whose text could match a term, falling
543
+ # back to the whole graph when the index isn't selective. The result is
544
+ # identical either way — the per-node scoring below is unchanged and a
545
+ # non-candidate node always scores 0. (IDF above stays a whole-graph statistic.)
546
+ candidate_ids = _trigram_candidates(G, norm_terms + ([joined] if joined else []))
547
+ node_iter = (
548
+ G.nodes(data=True) if candidate_ids is None
549
+ else ((nid, G.nodes[nid]) for nid in candidate_ids)
550
+ )
551
+ # Per-token best tracking, only when the caller (the query path) wants the
552
+ # seed metadata. The key tuple is the full multi-key tie-break
553
+ # (`(-singleton_score, -degree, label_len, nid)`), so `min` over the
554
+ # stored key mirrors the legacy `max(tied, key=degree)` over a
555
+ # (-score, label_len, nid)-sorted term_scored list. `None` is comparable
556
+ # as "smaller" than every tuple, so the first non-zero candidate seeds the
557
+ # entry without a separate `if t not in best_by_term` branch.
558
+ best_by_term: dict[str, tuple[tuple, str]] | None = (
559
+ {} if collect_per_term_seeds else None
560
+ )
561
+ for nid, data in node_iter:
562
+ norm_label = data.get("norm_label") or _strip_diacritics(data.get("label") or "").lower()
563
+ bare_label = norm_label.rstrip("()")
564
+ # Tokenized form of the label (punctuation stripped, same transform as the
565
+ # query). norm_label may still carry punctuation like ':' or '-', which a
566
+ # tokenized query can never equal; comparing token-joined forms on both
567
+ # sides makes "uoce: dehumidifier driver" match query "uoce dehumidifier
568
+ # driver".
569
+ label_tokens = " ".join(_search_tokens(data.get("label") or ""))
570
+ source = (data.get("source_file") or "").lower()
571
+ rationale = _node_rationale_text(data)
572
+ # `nid_lower` is needed both by the full-query tier (`if joined`) and by
573
+ # the per-token singleton tier (joined-singlet exact-match check). When
574
+ # neither runs (`joined` empty AND not collecting seeds) skip the call;
575
+ # this preserves the single-query-time perf where nid_lower was lazy.
576
+ nid_lower = nid.lower() if (joined or collect_per_term_seeds) else ""
577
+ score = 0.0
578
+ # Full-query tier: a multi-word query that equals (or prefixes) the whole
579
+ # label must dominate the per-token bag-of-words sums below, so `path`/
580
+ # `query` resolve the same node `explain` does (via _find_node). Without
581
+ # this, no single token equals a multi-word label, the per-token exact
582
+ # tier never fires, and every node sharing the token set ties -> arbitrary
583
+ # node-id sort -> wrong/disconnected endpoint -> false "No path found".
584
+ if joined:
585
+ if joined in (norm_label, bare_label, label_tokens, nid_lower):
586
+ score += _EXACT_MATCH_BONUS * 10 * joined_w
587
+ elif (
588
+ norm_label.startswith(joined)
589
+ or bare_label.startswith(joined)
590
+ or label_tokens.startswith(joined)
591
+ ):
592
+ score += _PREFIX_MATCH_BONUS * 10 * joined_w
593
+ # Term coverage (#1602): scale the per-term exact/prefix tiers by the
594
+ # squared fraction of query terms the node's LABEL matches, so a lone
595
+ # generic word that happens to equal a short label (query term "home"
596
+ # vs. a home() leaf) cannot bury nodes that match several of the
597
+ # query's terms. Squaring matters because the exact tier is 10x the
598
+ # prefix tier: at linear coverage a 1-of-10-terms exact match still
599
+ # outscores a 3-of-10 prefix+substring match. Single-term and
600
+ # full-coverage queries are unchanged (coverage == 1), so identifier
601
+ # lookups keep exact-match dominance. Source-file hits score but do
602
+ # not count as coverage: a colliding leaf whose directory shares
603
+ # tokens with the query (common near the intended target) must not
604
+ # win back its exact tier via path fragments. The substring/source
605
+ # bonuses and the full-query tier above stay unscaled.
606
+ matched = 0
607
+ tiered = 0.0
608
+ for t in norm_terms:
609
+ w = idf.get(t, 1.0)
610
+ # Per-tier contributions for this token, kept separate so the
611
+ # singleton tracking below can reuse them without re-evaluating
612
+ # the same predicates. Three-tier precedence: exact > prefix >
613
+ # substring (take the strongest tier per term so a single term
614
+ # cannot double-count).
615
+ tier_value = 0.0
616
+ substr_value = 0.0
617
+ source_value = 0.0
618
+ if t == norm_label or t == bare_label:
619
+ tier_value = _EXACT_MATCH_BONUS * w
620
+ matched += 1
621
+ elif norm_label.startswith(t) or bare_label.startswith(t):
622
+ tier_value = _PREFIX_MATCH_BONUS * w
623
+ matched += 1
624
+ elif t in norm_label:
625
+ substr_value = _SUBSTRING_MATCH_BONUS * w
626
+ score += substr_value
627
+ matched += 1
628
+ if t in source:
629
+ source_value = _SOURCE_MATCH_BONUS * w
630
+ score += source_value
631
+ # Rationale tier (#2293): recall for "why" questions whose words
632
+ # live only in the attribute. Adds to the score, not to `matched`.
633
+ rationale_value = 0.0
634
+ if rationale and t in rationale:
635
+ rationale_value = _RATIONALE_MATCH_BONUS * w
636
+ score += rationale_value
637
+ tiered += tier_value
638
+ if collect_per_term_seeds and best_by_term is not None:
639
+ # Singleton score for [t] on this node, mirroring
640
+ # `_score_nodes(G, [t])` exactly (n_terms == 1, no coverage
641
+ # scaling). The joined-singlet tier is broader than the per-
642
+ # token tier: it also checks `label_tokens` and `nid_lower`,
643
+ # matching the legacy single-token `_score_nodes([t])` call
644
+ # (where `joined == t`).
645
+ if t in (norm_label, bare_label, label_tokens, nid_lower):
646
+ singleton = _EXACT_MATCH_BONUS * 10 * w
647
+ elif (
648
+ norm_label.startswith(t)
649
+ or bare_label.startswith(t)
650
+ or label_tokens.startswith(t)
651
+ ):
652
+ singleton = _PREFIX_MATCH_BONUS * 10 * w
653
+ else:
654
+ singleton = 0.0
655
+ singleton += tier_value + substr_value + source_value + rationale_value
656
+ if singleton > 0:
657
+ # Tie-break key mirrors the legacy sort+max(degree):
658
+ # (-singleton, -degree, label_len, nid) — the minimum
659
+ # tuple wins, exactly matching max(tied, key=degree)
660
+ # over (label_len asc, nid asc)-sorted ties.
661
+ key = (-singleton, -G.degree(nid), len(data.get("label") or nid), nid)
662
+ cur = best_by_term.get(t)
663
+ if cur is None or key < cur[0]:
664
+ best_by_term[t] = (key, nid)
665
+ if tiered:
666
+ score += tiered * (matched / n_terms) ** 2
667
+ if score > 0:
668
+ scored.append((score, nid))
669
+ # Sort by score desc; break ties toward the shorter label so a concise exact
670
+ # match beats a longer superset that happens to share the same score.
671
+ scored.sort(key=lambda s: (-s[0], len(G.nodes[s[1]].get("label") or s[1]), s[1]))
672
+ best_seed_by_term: dict[str, str] = {}
673
+ if collect_per_term_seeds and best_by_term:
674
+ best_seed_by_term = {t: nid for t, (_key, nid) in best_by_term.items()}
675
+ return _QueryScores(ranked=scored, best_seed_by_term=best_seed_by_term)
676
+
677
+
678
+ def _pick_scored_endpoint(G: nx.Graph, scored: list[tuple[float, str]], query: str) -> str:
679
+ """Pick a path endpoint from a _score_nodes result, preferring full-token matches.
680
+
681
+ The full-query tier in _score_nodes only fires when the query equals or
682
+ prefixes a label, so a query that is a token *subset* of the intended label
683
+ (query "Reject-everything judge" vs. label "Degenerate Reject-Everything
684
+ Judge") gets no bonus, and a node prefix-matching one rare token (label
685
+ "Rejection Summary") can out-score it on IDF alone. Committing to scored[0]
686
+ then anchors the path on an unrelated — often disconnected — node and yields
687
+ a false "No path found". Scan the score-ordered list and take the first
688
+ candidate whose label contains EVERY query token; when the top candidate
689
+ already full-matches, or no candidate does, this is exactly scored[0].
690
+
691
+ `scored` must be non-empty (both callers return early on no match).
692
+ """
693
+ qtokens = set(_search_tokens(query))
694
+ if not qtokens:
695
+ return scored[0][1]
696
+ for _score, nid in scored:
697
+ if qtokens <= set(_search_tokens(G.nodes[nid].get("label") or nid)):
698
+ return nid
699
+ return scored[0][1]
700
+
701
+
702
+ def _pick_seeds(
703
+ scored: list[tuple[float, str]],
704
+ max_k: int = 3,
705
+ gap_ratio: float = 0.2,
706
+ *,
707
+ G: "nx.Graph | None" = None,
708
+ best_seed_by_term: dict[str, str] | None = None,
709
+ ) -> list[str]:
710
+ """Select BFS seed nodes, stopping when score drops too far below the top.
711
+
712
+ Prevents high-frequency noise terms (error, exception) from stealing seed
713
+ slots from a dominant identifier match. When FooBarService scores 1000 and
714
+ error nodes score 1.0, only FooBarService is seeded — the score gap is 99.9%
715
+ which is well above the 20% threshold that would allow additional seeds.
716
+
717
+ That same gap_ratio cutoff has a failure mode on multi-term natural-language
718
+ queries: if one term happens to hit an EXACT label match on a node that is
719
+ otherwise unrelated to the query's intent (e.g. a common word that is also
720
+ used as an unrelated identifier or field name elsewhere in the corpus), it
721
+ can outscore every SUBSTRING match on the query's other, actually-relevant
722
+ terms by ~1000x (see `_EXACT_MATCH_BONUS` vs. `_SUBSTRING_MATCH_BONUS`).
723
+ The 20%-gap cutoff then silently discards all of those substring-tier
724
+ seeds, so the BFS traversal only ever explores the neighborhood of the one
725
+ unrelated exact match — see #1445.
726
+
727
+ When `G` and `best_seed_by_term` are supplied, this guarantees at least one
728
+ seed per distinct query term that has any match at all, so one term's
729
+ incidental collision cannot starve out the others. The per-token winners
730
+ in `best_seed_by_term` are precomputed by `_score_query` (during the same
731
+ traversal that produced `scored`) so this function no longer rescores the
732
+ graph per term — see #1445 and the `_score_query` docstring.
733
+
734
+ Coverage scaling in _score_nodes (#1602) now dampens a lone collision's
735
+ exact tier on multi-term queries, which brings label-matching relevant
736
+ nodes back inside the gap window; this per-term guarantee remains
737
+ load-bearing for relevant nodes matched only via substrings, whose flat
738
+ scores a dampened collision can still exceed.
739
+ """
740
+ if not scored:
741
+ return []
742
+
743
+ # Deduplicate seeds by (normalized) label so a generic, homonymous symbol —
744
+ # e.g. dozens of route handlers all labelled `GET`/`POST`, or a `handler`
745
+ # repeated across a framework — contributes at most one seed instead of
746
+ # consuming every slot and flooding the BFS with near-identical neighborhoods
747
+ # (#1766). The key mirrors _score_nodes' normalization so `GET`/`Get`/`get`
748
+ # collapse together. When G is absent we can't read labels, so fall back to
749
+ # the (unique) node id, which is a no-op — preserving the old behavior.
750
+ def _seed_label_key(nid: str) -> str:
751
+ if G is None:
752
+ return nid
753
+ data = G.nodes[nid]
754
+ return (data.get("norm_label")
755
+ or _strip_diacritics(data.get("label") or "").lower()) or nid
756
+
757
+ top_score = scored[0][0]
758
+ seeds: list[str] = []
759
+ seen_labels: set[str] = set()
760
+ for score, nid in scored:
761
+ if len(seeds) >= max_k:
762
+ break
763
+ if seeds and score < top_score * gap_ratio:
764
+ break
765
+ key = _seed_label_key(nid)
766
+ if key in seen_labels:
767
+ continue
768
+ seen_labels.add(key)
769
+ seeds.append(nid)
770
+
771
+ if G is not None and best_seed_by_term:
772
+ # Guarantee one seed per distinct query term that has any match at all,
773
+ # so an incidental exact match on one term cannot starve matches on
774
+ # other terms (#1445). Iterate tokens in a deterministic sorted order
775
+ # so seeds added by this loop have a stable order independent of dict
776
+ # iteration — preserving the legacy `_pick_seeds(terms=...)` behavior
777
+ # which iterated `sorted({tok ...})`. Per-token winners arrive
778
+ # precomputed in `best_seed_by_term` from `_score_query`'s single
779
+ # traversal, so `_pick_seeds` no longer rescoring the graph per term.
780
+ # The per-label dedup cap also gates these additions, so the guarantee
781
+ # cannot reintroduce a second copy of an already-seeded generic label
782
+ # (#1766).
783
+ for term in sorted(best_seed_by_term):
784
+ best_nid = best_seed_by_term[term]
785
+ # Honor the same per-label cap so the per-term guarantee can't
786
+ # reintroduce a second copy of an already-seeded generic label.
787
+ key = _seed_label_key(best_nid)
788
+ if best_nid not in seeds and key not in seen_labels:
789
+ seen_labels.add(key)
790
+ seeds.append(best_nid)
791
+ return seeds
792
+
793
+
794
+ # Verb-shaped tokens that express the RELATION a query asks about ("who calls
795
+ # X", "what uses Y") rather than a symbol to look up. `_query_terms` keeps them
796
+ # on purpose (a corpus can legitimately define an identifier named `calls`, see
797
+ # #1597), but they must not be handed a guaranteed seed slot in `_pick_seeds`:
798
+ # an incidental prefix match (e.g. "calls" prefixing `.callStoreWithAmount()`)
799
+ # would otherwise seat an unrelated decoy as a BFS root (#2507). Demotion
800
+ # happens at the `_query_graph_text` call site, so `_score_query`'s ranking —
801
+ # where such a verb can still win a seat on merit via the gap window — is
802
+ # untouched. Deliberately verbs only; relation NOUNS (module, field, return)
803
+ # stay eligible for the guarantee.
804
+ _RELATIONAL_INTENT_TERMS: frozenset[str] = frozenset({
805
+ "call", "calls", "called", "caller", "callers",
806
+ "invoke", "invokes", "invoked",
807
+ "use", "uses", "used", "using",
808
+ "import", "imports", "imported",
809
+ "export", "exports", "exported",
810
+ "extend", "extends", "extended",
811
+ "implement", "implements", "implemented",
812
+ "depend", "depends",
813
+ "reference", "references", "referenced",
814
+ })
815
+
816
+
817
+ _CONTEXT_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
818
+ ("call", ("call", "calls", "called", "caller", "callers", "invoke", "invokes", "invoked")),
819
+ ("import", ("import", "imports", "imported", "module", "modules")),
820
+ ("field", ("field", "fields", "member", "members", "property", "properties")),
821
+ ("parameter_type", ("parameter", "parameters", "param", "params", "argument", "arguments")),
822
+ ("return_type", ("return", "returns", "returned")),
823
+ ("generic_arg", ("generic", "generics", "template", "templates")),
824
+ )
825
+
826
+
827
+ _CONTEXT_FILTER_ALIASES: dict[str, str] = {
828
+ "param": "parameter_type",
829
+ "params": "parameter_type",
830
+ "parameter": "parameter_type",
831
+ "parameters": "parameter_type",
832
+ "argument": "parameter_type",
833
+ "arguments": "parameter_type",
834
+ "arg": "parameter_type",
835
+ "args": "parameter_type",
836
+ "return": "return_type",
837
+ "returns": "return_type",
838
+ "returned": "return_type",
839
+ "generic": "generic_arg",
840
+ "generics": "generic_arg",
841
+ "template": "generic_arg",
842
+ "templates": "generic_arg",
843
+ "annotation": "attribute",
844
+ "annotations": "attribute",
845
+ "decorator": "attribute",
846
+ "decorators": "attribute",
847
+ "calls": "call",
848
+ "called": "call",
849
+ "invoke": "call",
850
+ "invocation": "call",
851
+ "fields": "field",
852
+ "property": "field",
853
+ "properties": "field",
854
+ "member": "field",
855
+ "members": "field",
856
+ "imports": "import",
857
+ "imported": "import",
858
+ "module": "import",
859
+ "modules": "import",
860
+ "exports": "export",
861
+ "exported": "export",
862
+ }
863
+
864
+
865
+ def _normalize_context_filters(filters: list[str] | None) -> list[str]:
866
+ if not filters:
867
+ return []
868
+ normalized: list[str] = []
869
+ seen: set[str] = set()
870
+ for value in filters:
871
+ key = _strip_diacritics(str(value)).strip().lower()
872
+ if not key:
873
+ continue
874
+ key = _CONTEXT_FILTER_ALIASES.get(key, key)
875
+ if key not in seen:
876
+ seen.add(key)
877
+ normalized.append(key)
878
+ return normalized
879
+
880
+
881
+ def _infer_context_filters(question: str) -> list[str]:
882
+ lowered = {
883
+ _strip_diacritics(token).lower()
884
+ for token in question.replace("?", " ").replace(",", " ").split()
885
+ }
886
+ inferred: list[str] = []
887
+ for context, hints in _CONTEXT_HINTS:
888
+ if any(hint in lowered for hint in hints):
889
+ inferred.append(context)
890
+ return inferred
891
+
892
+
893
+ def _resolve_context_filters(question: str, explicit_filters: list[str] | None = None) -> tuple[list[str], str | None]:
894
+ normalized = _normalize_context_filters(explicit_filters)
895
+ if normalized:
896
+ return normalized, "explicit"
897
+ inferred = _infer_context_filters(question)
898
+ if inferred:
899
+ return inferred, "heuristic"
900
+ return [], None
901
+
902
+
903
+ def _filter_graph_by_context(G: nx.Graph, context_filters: list[str] | None) -> nx.Graph:
904
+ filters = set(_normalize_context_filters(context_filters))
905
+ if not filters:
906
+ return G
907
+ H = G.__class__()
908
+ H.add_nodes_from(G.nodes(data=True))
909
+ if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)):
910
+ for u, v, key, data in G.edges(keys=True, data=True):
911
+ if data.get("context") in filters:
912
+ H.add_edge(u, v, key=key, **data)
913
+ else:
914
+ for u, v, data in G.edges(data=True):
915
+ if data.get("context") in filters:
916
+ H.add_edge(u, v, **data)
917
+ return H
918
+
919
+
920
+ def _complete_induced_edges(G: nx.Graph, visited: set[str], edges_seen: list[tuple]) -> None:
921
+ """Append edges between visited nodes that the traversal never recorded (#2323).
922
+
923
+ Both traversals only record an edge that *discovers* an unvisited neighbour,
924
+ so what they return is a traversal tree, not the induced subgraph over the
925
+ nodes they return. `_bfs` marks every seed visited up front, so an edge
926
+ between two seeds can never be recorded — the reported symptom, where both
927
+ endpoints render and the edge between them does not. It drops ordinary
928
+ cross-edges for the same reason. `_dfs` appends on push rather than on
929
+ visit, so it already captured those; its one gap is an edge between two
930
+ non-seed hubs, since neither endpoint is ever expanded.
931
+
932
+ Scans only edges incident to `visited`, so cost tracks the subgraph rather
933
+ than the whole graph, bounded by O(2E) overall. A visited hub is rescanned
934
+ in full even though the traversal deliberately did not expand it — that is
935
+ unavoidable, since a hub-to-hub edge is exactly the case `_dfs` misses.
936
+ `G` here is the context-filtered `traversal_graph` (see
937
+ `_query_graph_text`), so a filtered-out relation cannot reappear.
938
+
939
+ Self-loops are skipped. A recursive function legitimately carries one, but
940
+ neither traversal has ever recorded one (`n` is always already visited when
941
+ its own self-loop is examined), and surfacing them is a separate output
942
+ change from the missing edges reported here.
943
+
944
+ Dedup keys on the ordered pair for directed graphs and the unordered pair
945
+ otherwise: on a DiGraph `u->v` and `v->u` are genuinely distinct edges
946
+ (mutual recursion, circular imports), and collapsing them would drop a real
947
+ one. On a multigraph parallel edges collapse to one entry, matching the
948
+ renderer, which already shows only the first (`_subgraph_to_text`).
949
+
950
+ Traversal edges keep their discovery order; completions are appended after.
951
+ """
952
+ directed = G.is_directed()
953
+
954
+ def _key(u: str, v: str):
955
+ return (u, v) if directed else frozenset((u, v))
956
+
957
+ seen = {_key(u, v) for u, v in edges_seen}
958
+ # sorted() so the appended order can't shift run-to-run with CPython's
959
+ # per-process string-hash seed, the same reason the renderer sorts (#1753).
960
+ for u, v in G.edges(sorted(visited)):
961
+ if u == v or v not in visited:
962
+ continue
963
+ key = _key(u, v)
964
+ if key in seen:
965
+ continue
966
+ seen.add(key)
967
+ edges_seen.append((u, v))
968
+
969
+
970
+ def _bfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], list[tuple]]:
971
+ # Compute hub threshold: nodes above this degree are not expanded as transit.
972
+ # p99 of degree distribution, floored at 50 to avoid over-blocking small graphs.
973
+ degrees = [G.degree(n) for n in G.nodes()]
974
+ if degrees:
975
+ degrees_sorted = sorted(degrees)
976
+ p99_idx = int(len(degrees_sorted) * 0.99)
977
+ hub_threshold = max(50, degrees_sorted[p99_idx])
978
+ else:
979
+ hub_threshold = 50
980
+ seed_set = set(start_nodes)
981
+ visited: set[str] = set(start_nodes)
982
+ frontier = set(start_nodes)
983
+ edges_seen: list[tuple] = []
984
+ for _ in range(depth):
985
+ next_frontier: set[str] = set()
986
+ for n in frontier:
987
+ # Don't expand through high-degree hubs (except seeds - a hub that
988
+ # is the starting node should still be explored).
989
+ if n not in seed_set and G.degree(n) >= hub_threshold:
990
+ continue
991
+ for neighbor in G.neighbors(n):
992
+ if neighbor not in visited:
993
+ next_frontier.add(neighbor)
994
+ edges_seen.append((n, neighbor))
995
+ visited.update(next_frontier)
996
+ frontier = next_frontier
997
+ _complete_induced_edges(G, visited, edges_seen)
998
+ return visited, edges_seen
999
+
1000
+
1001
+ def _dfs(G: nx.Graph, start_nodes: list[str], depth: int) -> tuple[set[str], list[tuple]]:
1002
+ degrees = [G.degree(n) for n in G.nodes()]
1003
+ if degrees:
1004
+ degrees_sorted = sorted(degrees)
1005
+ p99_idx = int(len(degrees_sorted) * 0.99)
1006
+ hub_threshold = max(50, degrees_sorted[p99_idx])
1007
+ else:
1008
+ hub_threshold = 50
1009
+ seed_set = set(start_nodes)
1010
+ visited: set[str] = set()
1011
+ edges_seen: list[tuple] = []
1012
+ stack = [(n, 0) for n in reversed(start_nodes)]
1013
+ while stack:
1014
+ node, d = stack.pop()
1015
+ if node in visited or d > depth:
1016
+ continue
1017
+ visited.add(node)
1018
+ if node not in seed_set and G.degree(node) >= hub_threshold:
1019
+ continue
1020
+ for neighbor in G.neighbors(node):
1021
+ if neighbor not in visited:
1022
+ stack.append((neighbor, d + 1))
1023
+ edges_seen.append((node, neighbor))
1024
+ _complete_induced_edges(G, visited, edges_seen)
1025
+ return visited, edges_seen
1026
+
1027
+
1028
+ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_budget: int = 2000, *, seeds: list[str] | None = None) -> str:
1029
+ """Render subgraph as text, cutting at token_budget (approx 3 chars/token).
1030
+
1031
+ seeds: exact-match nodes rendered first before the degree-sorted expansion,
1032
+ so the queried symbol always appears at the top of the output.
1033
+ """
1034
+ char_budget = token_budget * 3
1035
+ lines = []
1036
+ # Work-memory overlay (derived sidecar) stashed on the graph at load time.
1037
+ # Empty when no sidecar exists, so un-annotated output stays byte-identical.
1038
+ overlay = getattr(G, "graph", {}).get("_learning_overlay", {}) or {}
1039
+ seed_set = set(seeds or [])
1040
+ seed_hits = [n for n in (seeds or []) if n in nodes]
1041
+ # Rank non-seed nodes by hop distance from the seeds so the node that answers
1042
+ # the query (a direct hit or its close neighbors) survives the budget cut
1043
+ # instead of being pushed past it by incidental high-degree hubs (#BUG2). BFS
1044
+ # discovery order was discarded upstream (_bfs returns a set), so recompute
1045
+ # layers here over BOTH edge directions. Deterministic: neighbor iteration is
1046
+ # insertion-ordered and the sort key ends in str(n) (no hash-order).
1047
+ def _adj(n):
1048
+ if G.is_directed():
1049
+ yield from G.successors(n)
1050
+ yield from G.predecessors(n)
1051
+ else:
1052
+ yield from G.neighbors(n)
1053
+ dist: dict[str, int] = {n: 0 for n in seed_hits}
1054
+ frontier, hop = seed_hits, 0
1055
+ while frontier:
1056
+ hop += 1
1057
+ nxt = []
1058
+ for n in frontier:
1059
+ for nb in _adj(n):
1060
+ if nb in nodes and nb not in dist:
1061
+ dist[nb] = hop
1062
+ nxt.append(nb)
1063
+ frontier = nxt
1064
+ ordered = seed_hits + sorted(
1065
+ nodes - seed_set,
1066
+ key=lambda n: (dist.get(n, 1 << 30), -G.degree(n), str(n)),
1067
+ )
1068
+ for nid in ordered:
1069
+ d = G.nodes[nid]
1070
+ # Every LLM-derived field passes through sanitize_label before being
1071
+ # concatenated into MCP tool output (F-010): an attacker who controls a
1072
+ # corpus document can otherwise inject ANSI escapes, fake graphify-out
1073
+ # log lines, or prompt-injection markup into the model's context via
1074
+ # source_file / source_location / community.
1075
+ # The learning= suffix is appended INSIDE the bracket and BEFORE the
1076
+ # budget check below, so it counts in char_budget accounting.
1077
+ entry = overlay.get(str(nid))
1078
+ learning_suffix = ""
1079
+ if entry:
1080
+ status = sanitize_label(str(entry.get("status", "")))
1081
+ if status:
1082
+ learning_suffix = f" learning={status}{':stale' if entry.get('stale') else ''}"
1083
+ line = (
1084
+ f"NODE {sanitize_label(d.get('label', nid))} "
1085
+ f"[src={sanitize_label(str(d.get('source_file', '')))} "
1086
+ f"loc={sanitize_label(str(d.get('source_location', '')))} "
1087
+ f"community={sanitize_label(str(d.get('community_name') or d.get('community', '')))}"
1088
+ f"{learning_suffix}]"
1089
+ )
1090
+ lines.append(line)
1091
+ for u, v in edges:
1092
+ if u in nodes and v in nodes:
1093
+ raw = G[u][v]
1094
+ d = next(iter(raw.values()), {}) if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)) else raw
1095
+ # (u, v) is BFS/DFS visit order, not necessarily the true edge
1096
+ # direction: on an undirected graph G.neighbors() walks callers
1097
+ # and callees alike, so a caller->callee edge renders backwards
1098
+ # whenever the callee is visited first. _src/_tgt (stashed on the
1099
+ # edge data by the `query` CLI loader) carry the real direction;
1100
+ # fall back to (u, v) for graphs/edges that don't set them.
1101
+ src = d.get("_src", u)
1102
+ tgt = d.get("_tgt", v)
1103
+ # Guard against a stray/dangling _src/_tgt (hand-edited or adversarial
1104
+ # graph.json): only trust them when they name exactly this edge's
1105
+ # endpoints, else fall back to (u, v). Without this, G.nodes[src]
1106
+ # would KeyError on an unknown id (#2080 review).
1107
+ if {src, tgt} != {u, v}:
1108
+ src, tgt = u, v
1109
+ context = d.get("context")
1110
+ context_suffix = f" context={sanitize_label(str(context))}" if context else ""
1111
+ # The relation SITE (call/import/reference line in the source's
1112
+ # file), not a def line — so "who calls X" cites a clickable call
1113
+ # location, not the caller's def (#BUG1).
1114
+ _loc = str(d.get("source_location") or "")
1115
+ at_suffix = (
1116
+ f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(_loc)}"
1117
+ if _loc else ""
1118
+ )
1119
+ line = (
1120
+ f"EDGE {sanitize_label(G.nodes[src].get('label', src))} "
1121
+ f"--{sanitize_label(str(d.get('relation', '')))} "
1122
+ f"[{sanitize_label(str(d.get('confidence', '')))}{context_suffix}]--> "
1123
+ f"{sanitize_label(G.nodes[tgt].get('label', tgt))}{at_suffix}"
1124
+ )
1125
+ lines.append(line)
1126
+ output = "\n".join(lines)
1127
+ if len(output) > char_budget:
1128
+ cut_at = output[:char_budget].rfind("\n")
1129
+ cut_at = cut_at if cut_at > 0 else char_budget
1130
+ # Never cut the seed nodes: they render first, so if the budget lands
1131
+ # inside the seed block, extend the cut to cover it. The symbol the
1132
+ # question named must always be in the answer (#BUG2). Seeds are bounded
1133
+ # (_pick_seeds max_k + one per term), so the overshoot is a few lines.
1134
+ if seed_hits:
1135
+ seed_block_end = sum(len(lines[i]) + 1 for i in range(len(seed_hits))) - 1
1136
+ cut_at = max(cut_at, min(seed_block_end, len(output)))
1137
+ total_nodes = sum(1 for l in lines if l.startswith("NODE "))
1138
+ shown_nodes = output[:cut_at].count("\nNODE ") + (1 if output.startswith("NODE ") else 0)
1139
+ cut_count = total_nodes - shown_nodes
1140
+ # Nodes render before edges, so a char-budget overflow whose cut lands
1141
+ # past the last NODE line drops only trailing edges — no whole node is
1142
+ # lost. Announcing "showing N of N nodes … among the 0 cut nodes" then
1143
+ # reads as a false truncation warning that teaches an agent to distrust a
1144
+ # complete answer and burn follow-up narrowing calls for nodes that were
1145
+ # never cut (#2601). When every node is shown the answer is complete, so
1146
+ # edges are never dropped either (returning output[:cut_at] here would
1147
+ # silently truncate them) — but that completeness guarantee is exactly
1148
+ # why a query can quietly cost 4-6x its requested budget once the last
1149
+ # node crosses the fit line (#2784): the check above only ever compared
1150
+ # the FULL output (nodes+edges) against char_budget, so this branch was
1151
+ # already known to be over budget, yet said nothing about it. Report the
1152
+ # real size instead of silence — still the complete, non-truncated
1153
+ # answer, just an honest one.
1154
+ if cut_count == 0:
1155
+ # Reached only inside `len(output) > char_budget`, so every node
1156
+ # fits but the full nodes+edges output does not: an honest
1157
+ # over-budget notice, never a truncation.
1158
+ total_edges = sum(1 for l in lines if l.startswith("EDGE "))
1159
+ est_tokens = len(output) // 3
1160
+ return (
1161
+ f"[i] Complete answer over budget: all {total_nodes} nodes and "
1162
+ f"{total_edges} edges shown (~{est_tokens} tokens vs the "
1163
+ f"requested ~{token_budget}-token budget). Edges are never "
1164
+ f"dropped once every node fits, so this is already the full "
1165
+ f"answer — raising --budget further will not shrink it. Narrow "
1166
+ f"with context_filter=['call'] or use get_node for a specific "
1167
+ f"symbol to reduce size instead.\n\n"
1168
+ ) + output
1169
+ # Prominent notice at the TOP so a truncated answer can never be mistaken
1170
+ # for a complete one — silence used to read as absence (#BUG2). The
1171
+ # notice + end marker sit OUTSIDE char_budget by design (two bounded
1172
+ # wrapper lines, like the existing end marker).
1173
+ output = (
1174
+ f"[!] TRUNCATED: showing {shown_nodes} of {total_nodes} nodes "
1175
+ f"(~{token_budget}-token budget). The answer may be among the "
1176
+ f"{cut_count} cut nodes — raise the token budget (CLI: --budget) or "
1177
+ f"narrow the query (e.g. context_filter=['call'], or get_node for a "
1178
+ f"specific symbol).\n\n"
1179
+ + output[:cut_at]
1180
+ + f"\n... (truncated — {cut_count} more nodes cut by ~{token_budget}-token budget."
1181
+ f" Narrow with context_filter=['call'] or use get_node for a specific symbol)"
1182
+ )
1183
+ return output
1184
+
1185
+
1186
+ def _cut_lines_to_budget(lines: list[str], token_budget: int, narrow_hint: str) -> str:
1187
+ """Render pre-built lines under the same ~3-chars/token budget rule as
1188
+ _subgraph_to_text; over-budget output is cut at a line boundary with a count and a
1189
+ narrowing hint instead of flooding the caller's context window."""
1190
+ output = "\n".join(lines)
1191
+ char_budget = token_budget * 3
1192
+ if len(output) <= char_budget:
1193
+ return output
1194
+ cut_at = output[:char_budget].rfind("\n")
1195
+ cut_at = cut_at if cut_at > 0 else char_budget
1196
+ kept = output[:cut_at]
1197
+ shown = kept.count("\n") + 1
1198
+ cut_count = len(lines) - shown
1199
+ # Announce truncation at the TOP as well, matching _subgraph_to_text — a
1200
+ # bottom-only marker reads as silence/absence (the BUG-2 fix rationale). The
1201
+ # notice sits outside char_budget by design (one bounded wrapper line).
1202
+ return (
1203
+ f"[!] TRUNCATED: showing {shown} of {len(lines)} lines "
1204
+ f"(~{token_budget}-token budget). {narrow_hint}\n\n"
1205
+ + kept
1206
+ + f"\n... (truncated — {cut_count} more lines cut by ~{token_budget}-token budget. "
1207
+ + narrow_hint
1208
+ + ")"
1209
+ )
1210
+
1211
+
1212
+ def _display_graph_path(graph_path: str) -> str:
1213
+ """Render a graph path for the query header.
1214
+
1215
+ Relative to the CWD when it sits underneath it — `graphify-out/graph.json`,
1216
+ which is the ordinary case and stays short. Absolute otherwise, because a
1217
+ graph outside the directory you are standing in is precisely the situation
1218
+ the header exists to make visible (#2789). Always POSIX separators so the
1219
+ line reads the same on either platform. Falls back to the path as given if
1220
+ it cannot be resolved; this is a display helper and must never be the reason
1221
+ a query fails.
1222
+ """
1223
+ try:
1224
+ p = Path(graph_path).resolve()
1225
+ try:
1226
+ return p.relative_to(Path.cwd().resolve()).as_posix()
1227
+ except ValueError:
1228
+ return p.as_posix()
1229
+ except (OSError, RuntimeError, ValueError):
1230
+ return str(graph_path)
1231
+
1232
+
1233
+ def _traversal_view(G: nx.Graph) -> nx.Graph:
1234
+ """Undirected copy of `G` for BFS/DFS, with true direction kept per edge.
1235
+
1236
+ `_load_graph` forces `directed: True` so renderers can recover stored arc
1237
+ order (#2309), and on a DiGraph `G.neighbors()` yields successors only. The
1238
+ query traversals rely on `neighbors()`, so a seed with no outgoing edges — a
1239
+ leaf function that is only ever called, imported and contained — expanded
1240
+ to nothing: `query_graph` over MCP answered with the seed alone while the
1241
+ CLI `query`, which loads the same file undirected, returned the callers,
1242
+ the test and the neighbouring modules. Every other MCP tool was unaffected:
1243
+ `get_neighbors` walks successors and predecessors explicitly, and
1244
+ `shortest_path` builds its own graph from `_src`/`_tgt`.
1245
+
1246
+ Mirrors the CLI loader: traverse undirected, stash `_src`/`_tgt` on each
1247
+ edge so `_subgraph_to_text` still renders caller->callee regardless of the
1248
+ side the traversal reached the edge from. Markers already present on an
1249
+ edge win, for the same reason as in the CLI (#2309). An undirected input is
1250
+ returned as-is, so the CLI path is unchanged.
1251
+
1252
+ Mutual arcs `u->v` and `v->u` (mutual recursion, a circular import) fold
1253
+ into one undirected edge on a plain `DiGraph` input, the later one winning
1254
+ — the same fold the CLI loader performs when `json_graph.node_link_graph`
1255
+ reads the undirected on-disk graph into an `nx.Graph`, which is what keeps
1256
+ the two surfaces' output identical. The renderer shows one edge per pair
1257
+ in any case. On a `MultiDiGraph` input the copy is a `MultiGraph` and the
1258
+ stored keys are not carried over: a key is unique per unordered pair on an
1259
+ undirected multigraph, so mutual arcs that happen to share a key would
1260
+ fold there too; letting networkx assign the keys keeps both, and nothing
1261
+ downstream reads the key.
1262
+
1263
+ A fresh copy per query rather than a cached one: `_filter_graph_by_context`
1264
+ already copies per query when a filter applies, and the copy shares node
1265
+ data dicts with `G`, so only the edge dicts are duplicated.
1266
+ """
1267
+ if not G.is_directed():
1268
+ return G
1269
+ H = nx.MultiGraph() if G.is_multigraph() else nx.Graph()
1270
+ H.graph.update(G.graph)
1271
+ H.add_nodes_from(G.nodes(data=True))
1272
+ for u, v, d in G.edges(data=True):
1273
+ H.add_edge(u, v, **{**d, "_src": d.get("_src", u), "_tgt": d.get("_tgt", v)})
1274
+ return H
1275
+
1276
+ def _query_graph_text(
1277
+ G: nx.Graph,
1278
+ question: str,
1279
+ *,
1280
+ mode: str = "bfs",
1281
+ depth: int = 3,
1282
+ token_budget: int = 2000,
1283
+ context_filters: list[str] | None = None,
1284
+ graph_path: str | None = None,
1285
+ ) -> str:
1286
+ terms = _query_terms(question)
1287
+ # One graph scoring pass produces both the combined ranking (used to drive
1288
+ # the gap-based seed selection below) and the per-token singleton winners
1289
+ # (used by _pick_seeds' per-term guarantee). Previously this was T+1 passes
1290
+ # — one combined + one per query token — re-walking the whole graph each
1291
+ # time; on a 100k-node, three-term benchmark ~71% of scoring time was
1292
+ # spent in those redundant per-term passes.
1293
+ qs = _score_query(G, terms, collect_per_term_seeds=True)
1294
+ # Relational-intent verbs ("calls", "uses", ...) describe the relation the
1295
+ # question asks about, not a symbol to seed from; drop them from the
1296
+ # per-term seed GUARANTEE so an incidental verb match cannot seat a decoy
1297
+ # BFS root (#2507). They keep their place in `qs.ranked`, so a genuine
1298
+ # identifier named after a verb can still win a seat on merit via the gap
1299
+ # window — and when the query consists ONLY of intent words (bare "calls"),
1300
+ # the guarantee is left intact so such an identifier stays reachable.
1301
+ best_seed_by_term = qs.best_seed_by_term
1302
+ intent = {t for t in best_seed_by_term if t in _RELATIONAL_INTENT_TERMS}
1303
+ if intent and any(t not in _RELATIONAL_INTENT_TERMS for t in terms):
1304
+ best_seed_by_term = {
1305
+ t: nid for t, nid in best_seed_by_term.items() if t not in intent
1306
+ }
1307
+ start_nodes = _pick_seeds(qs.ranked, G=G, best_seed_by_term=best_seed_by_term)
1308
+ if not start_nodes:
1309
+ return "No matching nodes found."
1310
+ resolved_filters, filter_source = _resolve_context_filters(question, context_filters)
1311
+ traversal_graph = _filter_graph_by_context(_traversal_view(G), resolved_filters)
1312
+ nodes, edges = _dfs(traversal_graph, start_nodes, depth) if mode == "dfs" else _bfs(traversal_graph, start_nodes, depth)
1313
+ header_parts = [
1314
+ f"Traversal: {mode.upper()} depth={depth}",
1315
+ f"Start: {[G.nodes[n].get('label', n) for n in start_nodes]}",
1316
+ ]
1317
+ # Name the graph this answer came from. `graphify-out/` resolves against the
1318
+ # CWD, so running a query from a parent project while thinking about a
1319
+ # vendored subproject silently answers from the wrong corpus — the output is
1320
+ # well-formed and confidently wrong, and nothing in it said which graph was
1321
+ # opened (#2789). Shown relative when the graph is under the CWD (the normal
1322
+ # case, and short), absolute when it is not — which is exactly the case worth
1323
+ # noticing. The node count travels with it because "355 nodes" vs "3178
1324
+ # nodes" is often the first thing that looks wrong.
1325
+ if graph_path:
1326
+ header_parts.insert(0, f"Graph: {_display_graph_path(graph_path)} "
1327
+ f"({G.number_of_nodes()} nodes)")
1328
+ if resolved_filters:
1329
+ header_parts.append(f"Context: {', '.join(resolved_filters)} ({filter_source})")
1330
+ header_parts.append(f"{len(nodes)} nodes found")
1331
+ header = " | ".join(header_parts) + "\n\n"
1332
+ # Pass the seeds so the queried symbol renders first and survives truncation
1333
+ # (#BUG2): a branch merge had silently dropped this argument, leaving the
1334
+ # seed-first ordering as dead code.
1335
+ return header + _subgraph_to_text(traversal_graph, nodes, edges, token_budget, seeds=start_nodes)
1336
+
1337
+
1338
+ def _resolve_path_scoped_symbol(G: nx.Graph, path_part: str, symbol_part: str) -> list[str]:
1339
+ """Nodes whose source_file matches path_part and label/id matches symbol_part.
1340
+
1341
+ Backs the `path::Symbol` query form (#3485): a bare path resolves to the
1342
+ FILE node (`_find_node_tiers`'s own `source_exact` tier, which prefers
1343
+ the file over its members once #2032-disambiguated), and a bare symbol
1344
+ name can be ambiguous across files -- the same-named local declaration
1345
+ guard from #3176 exists for exactly that case, and its own suggested
1346
+ retry ("the repo-relative path") pointed at a form that resolved to the
1347
+ wrong node or nothing at all, since no prior tier combined a path with
1348
+ a label. This combines both constraints in one query, so a specific
1349
+ symbol in a specific file is reachable without needing its opaque id.
1350
+ """
1351
+ path_tokens = " ".join(_search_tokens(path_part))
1352
+ norm_path_query = _strip_diacritics(path_part).lower().strip()
1353
+ symbol_term = " ".join(_search_tokens(symbol_part))
1354
+ norm_symbol_query = _strip_diacritics(symbol_part).lower().strip()
1355
+ if not (path_tokens or norm_path_query) or not (symbol_term or norm_symbol_query):
1356
+ return []
1357
+ candidate_ids = _trigram_candidates(G, [symbol_term, norm_symbol_query])
1358
+ node_iter = (
1359
+ G.nodes(data=True) if candidate_ids is None
1360
+ else ((nid, G.nodes[nid]) for nid in candidate_ids)
1361
+ )
1362
+ matches: list[str] = []
1363
+ for nid, d in node_iter:
1364
+ source_file = d.get("source_file") or ""
1365
+ source_tokens = " ".join(_search_tokens(source_file))
1366
+ norm_source = _strip_diacritics(source_file).lower().strip()
1367
+ if not (
1368
+ source_tokens == path_tokens
1369
+ or norm_source == norm_path_query
1370
+ or (norm_path_query and norm_source.endswith("/" + norm_path_query))
1371
+ or (path_tokens and source_tokens.endswith(" " + path_tokens))
1372
+ ):
1373
+ continue
1374
+ norm_label = d.get("norm_label") or _strip_diacritics(d.get("label") or "").lower()
1375
+ bare_label = norm_label.rstrip("()")
1376
+ label_tokens = " ".join(_search_tokens(d.get("label") or ""))
1377
+ nid_lower = nid.lower()
1378
+ if (
1379
+ symbol_term == norm_label or symbol_term == bare_label
1380
+ or symbol_term == label_tokens or symbol_term == nid_lower
1381
+ or norm_symbol_query == norm_label or norm_symbol_query == bare_label
1382
+ ):
1383
+ matches.append(nid)
1384
+ return matches
1385
+
1386
+
1387
+ def _label_has_literal_exact_match(G: nx.Graph, term: str, norm_query: str) -> bool:
1388
+ """Does the RAW, unsplit query already exact-match some node's own label/id?
1389
+
1390
+ Mirrors the `exact` tier's own condition below, run early and standalone so
1391
+ `_find_node_tiers` can tell a literal `::`-bearing label (Rust modules, C++
1392
+ namespaces) from a deliberately path-scoped query before choosing between
1393
+ them. A real label essentially never equals a whole `path::symbol` string
1394
+ verbatim, so this is a safe way to prefer the literal interpretation
1395
+ whenever one genuinely exists.
1396
+ """
1397
+ candidate_ids = _trigram_candidates(G, [term, norm_query])
1398
+ node_iter = (
1399
+ G.nodes(data=True) if candidate_ids is None
1400
+ else ((nid, G.nodes[nid]) for nid in candidate_ids)
1401
+ )
1402
+ for nid, d in node_iter:
1403
+ norm_label = d.get("norm_label") or _strip_diacritics(d.get("label") or "").lower()
1404
+ bare_label = norm_label.rstrip("()")
1405
+ label_tokens = " ".join(_search_tokens(d.get("label") or ""))
1406
+ nid_lower = nid.lower()
1407
+ nid_norm = nid_lower if nid.isascii() else _strip_diacritics(nid).lower()
1408
+ if (
1409
+ term == norm_label or term == bare_label or term == label_tokens or term == nid_lower
1410
+ or norm_query == norm_label or norm_query == bare_label or norm_query == nid_norm
1411
+ ):
1412
+ return True
1413
+ return False
1414
+
1415
+
1416
+ def _find_node_tiers(
1417
+ G: nx.Graph, label: str
1418
+ ) -> tuple[list[str], list[str], list[str], list[str]]:
1419
+ """Return match tiers in precedence order: (source_exact, exact, prefix, substring).
1420
+
1421
+ Split out of `_find_node` so callers that must not guess between equally-good
1422
+ matches can inspect the winning tier alone. `_find_node` flattens these, and
1423
+ its consumers take `[0]` — which resolves by graph-iteration order when one
1424
+ tier holds several nodes from different files. See `find_node_ambiguity`.
1425
+ """
1426
+ term = " ".join(_search_tokens(label))
1427
+ # Punctuation-preserving normalized query. `term` tokenizes on \w+ (so
1428
+ # "blockStream.ts" -> "blockstream ts", space where the '.' was), but a node's
1429
+ # stored `norm_label` keeps punctuation ("blockstream.ts"). Matching only via
1430
+ # `term`/`label_tokens` works when the node label tokenizes the same way, but is
1431
+ # fragile if `label` and `norm_label` diverge. `norm_query` matches `norm_label`
1432
+ # symmetrically so an exactly-typed punctuated label always resolves (#1704).
1433
+ # `nid_norm` below extends that symmetry to node ids, which keep their
1434
+ # punctuation too and are compared raw against the tokenized `term` (#2467).
1435
+ norm_query = _strip_diacritics(str(label)).lower().strip()
1436
+
1437
+ # `path::Symbol` restricts the label match to nodes defined in that
1438
+ # file (#3485) -- checked before the ordinary tiers below so a
1439
+ # deliberately path-scoped query never falls back to guessing among
1440
+ # same-named symbols in other files. Returned as source_exact (the
1441
+ # tier `find_node_ambiguity` already treats as maximally specific) so
1442
+ # existing callers need no changes; an empty result falls through to
1443
+ # ordinary matching rather than reporting no match outright, in case
1444
+ # "::" is meaningful some other way to a caller this was not designed
1445
+ # for. Skipped when the raw label already literally matches a node (a
1446
+ # native `::`-bearing label, e.g. Rust modules or C++ namespaces) so that
1447
+ # an unrelated file whose path happens to resemble the label's prefix
1448
+ # cannot hijack a query that was never meant to be path-scoped.
1449
+ if "::" in label and not _label_has_literal_exact_match(G, term, norm_query):
1450
+ path_part, _, symbol_part = label.partition("::")
1451
+ path_part, symbol_part = path_part.strip(), symbol_part.strip()
1452
+ if path_part and symbol_part:
1453
+ scoped = _resolve_path_scoped_symbol(G, path_part, symbol_part)
1454
+ if scoped:
1455
+ return scoped, [], [], []
1456
+
1457
+ if not term:
1458
+ return [], [], [], []
1459
+ source_exact: list[str] = []
1460
+ exact: list[str] = []
1461
+ prefix: list[str] = []
1462
+ substring: list[str] = []
1463
+ # Trigram prefilter (graph-iteration order preserved so exact/prefix/substring
1464
+ # ordering — and thus matches[0] — is byte-identical to the full scan).
1465
+ candidate_ids = _trigram_candidates(G, [term, norm_query])
1466
+ node_iter = (
1467
+ G.nodes(data=True) if candidate_ids is None
1468
+ else ((nid, G.nodes[nid]) for nid in candidate_ids)
1469
+ )
1470
+ for nid, d in node_iter:
1471
+ norm_label = d.get("norm_label") or _strip_diacritics(d.get("label") or "").lower()
1472
+ bare_label = norm_label.rstrip("()")
1473
+ label_tokens = " ".join(_search_tokens(d.get("label") or ""))
1474
+ source_tokens = " ".join(_search_tokens(d.get("source_file") or ""))
1475
+ nid_lower = nid.lower()
1476
+ # `_strip_diacritics` is the identity on ASCII, so the NFKD fold is only
1477
+ # paid for ids that actually carry non-ASCII text.
1478
+ nid_norm = nid_lower if nid.isascii() else _strip_diacritics(nid).lower()
1479
+ if term == source_tokens:
1480
+ source_exact.append(nid)
1481
+ elif (
1482
+ term == norm_label or term == bare_label or term == label_tokens or term == nid_lower
1483
+ or norm_query == norm_label or norm_query == bare_label or norm_query == nid_norm
1484
+ ):
1485
+ exact.append(nid)
1486
+ elif (
1487
+ norm_label.startswith(term)
1488
+ or bare_label.startswith(term)
1489
+ or label_tokens.startswith(term)
1490
+ or nid_lower.startswith(term)
1491
+ or norm_label.startswith(norm_query)
1492
+ or bare_label.startswith(norm_query)
1493
+ ):
1494
+ prefix.append(nid)
1495
+ elif term in norm_label or term in label_tokens or norm_query in norm_label:
1496
+ substring.append(nid)
1497
+
1498
+ if source_exact:
1499
+ query_basename = _strip_diacritics(Path(label).name).lower()
1500
+ preferred = []
1501
+ for nid in source_exact:
1502
+ if str(G.nodes[nid].get("source_location", "")) != "L1":
1503
+ continue
1504
+ # File-node label is the bare basename OR a directory-qualified form
1505
+ # from the #2032 disambiguation pass (e.g. "process-order/index.ts").
1506
+ lbl = _strip_diacritics(str(G.nodes[nid].get("label") or "")).lower()
1507
+ if lbl == query_basename or lbl.endswith("/" + query_basename):
1508
+ preferred.append(nid)
1509
+ if len(preferred) == 1:
1510
+ source_exact = preferred + [nid for nid in source_exact if nid != preferred[0]]
1511
+
1512
+ return source_exact, exact, prefix, substring
1513
+
1514
+
1515
+ def _find_node(G: nx.Graph, label: str) -> list[str]:
1516
+ """Return node IDs whose label or ID matches the search term (diacritic-insensitive).
1517
+
1518
+ Results are ordered by precedence: exact source-file path match first, then
1519
+ exact (label/ID) match, then prefix match, then substring match. Node-ID exact
1520
+ matches are grouped with label exact matches.
1521
+ """
1522
+ source_exact, exact, prefix, substring = _find_node_tiers(G, label)
1523
+ return source_exact + exact + prefix + substring
1524
+
1525
+
1526
+ def find_node_ambiguity(G: nx.Graph, label: str) -> list[str]:
1527
+ """Return rival candidates when the winning match tier spans several source files.
1528
+
1529
+ `_find_node` ranks matches but never reports that a tie was broken, so callers
1530
+ taking `[0]` present one arbitrary file as the answer. Two workspaces that each
1531
+ define `MetricsPort` put both nodes in the same `exact` tier, separated only by
1532
+ `G.nodes()` iteration order — reorder the graph and the same query answers with
1533
+ a different file, equally confidently.
1534
+
1535
+ Returns one representative node id per distinct source file when the winning
1536
+ tier is split that way, else `[]`. Several matches *within one file* (a file
1537
+ node plus its members) are ordinary precedence, not ambiguity, and return `[]`.
1538
+
1539
+ `_disambiguate_file_node_labels` (#2032) already relabels colliding *file*
1540
+ nodes; this covers the symbol case it does not reach.
1541
+ """
1542
+ for tier in _find_node_tiers(G, label):
1543
+ if not tier:
1544
+ continue
1545
+ by_source: dict[str, str] = {}
1546
+ for nid in tier:
1547
+ source = str(G.nodes[nid].get("source_file") or "")
1548
+ by_source.setdefault(source, nid)
1549
+ return list(by_source.values()) if len(by_source) > 1 else []
1550
+ return []
1551
+
1552
+
1553
+ def _resolve_single_node(G: nx.Graph, label: str) -> tuple[str | None, str | None]:
1554
+ """Shared node resolution for the get_node / get_neighbors tools.
1555
+
1556
+ Returns ``(node_id, None)`` when *label* resolves to a single winner via the
1557
+ tiered `_find_node` ranking, or ``(None, message)`` when there is no match or
1558
+ the winning tier spans several source files. Routing both tools through this
1559
+ keeps get_node from silently returning a `G.nodes()` iteration-order match for
1560
+ a hub name while get_neighbors reports the same lookup as ambiguous (#ADR-0001).
1561
+ """
1562
+ matches = _find_node(G, label)
1563
+ if not matches:
1564
+ return None, f"No node matching '{label}' found."
1565
+ rivals = find_node_ambiguity(G, label)
1566
+ if rivals:
1567
+ listing = "\n".join(
1568
+ f" {G.nodes[r].get('source_file') or r}\n id: {r}" for r in rivals
1569
+ )
1570
+ return None, (
1571
+ f"Ambiguous: '{label}' matches {len(rivals)} nodes in different files.\n"
1572
+ f"{listing}\n"
1573
+ f"Retry with path::symbol using one of the paths above (e.g. "
1574
+ f"<path>::{label}) or the full node id."
1575
+ )
1576
+ return matches[0], None
1577
+
1578
+
1579
+ def _shortest_path_text(G: nx.Graph, arguments: dict) -> str:
1580
+ """Body of the `shortest_path` MCP tool (module-level so tests can call it
1581
+ without an mcp install).
1582
+
1583
+ Directed by default (#2487): the returned path must follow stored
1584
+ caller→callee direction; pass ``undirected=True`` to ignore it.
1585
+ """
1586
+ src_scored = _score_nodes(G, [t.lower() for t in arguments["source"].split()])
1587
+ tgt_scored = _score_nodes(G, [t.lower() for t in arguments["target"].split()])
1588
+ if not src_scored:
1589
+ return f"No node matching source '{arguments['source']}' found."
1590
+ if not tgt_scored:
1591
+ return f"No node matching target '{arguments['target']}' found."
1592
+ src_nid = _pick_scored_endpoint(G, src_scored, arguments["source"])
1593
+ tgt_nid = _pick_scored_endpoint(G, tgt_scored, arguments["target"])
1594
+ # Ambiguity guard: when both queries resolve to the same node, the
1595
+ # shortest path is trivially zero hops, which is almost never what the
1596
+ # caller wanted (see bug #828).
1597
+ if src_nid == tgt_nid:
1598
+ return (
1599
+ f"'{arguments['source']}' and '{arguments['target']}' both resolved to "
1600
+ f"the same node '{src_nid}'. Use a more specific label or the exact node ID."
1601
+ )
1602
+ warnings: list[str] = []
1603
+ for name, scored, nid in (
1604
+ ("source", src_scored, src_nid),
1605
+ ("target", tgt_scored, tgt_nid),
1606
+ ):
1607
+ # Only meaningful when the raw score head is what got picked — a
1608
+ # full-token override was chosen on token coverage, not score.
1609
+ if len(scored) >= 2 and nid == scored[0][1]:
1610
+ top, runner = scored[0][0], scored[1][0]
1611
+ if top > 0 and (top - runner) / top < 0.10:
1612
+ warnings.append(
1613
+ f"warning: {name} match was ambiguous "
1614
+ f"(top score {top:g}, runner-up {runner:g})"
1615
+ )
1616
+ max_hops = int(arguments.get("max_hops", 8))
1617
+ undirected = bool(arguments.get("undirected", False))
1618
+ try:
1619
+ # Deterministic path (#2074): the hash-seeded undirected view picked an
1620
+ # arbitrary route among equal-length paths. Build a sorted, materialized
1621
+ # graph so the chosen path is canonical. Serve's shared G is left
1622
+ # untouched (its degree feeds query-seed tie-breaks).
1623
+ if undirected:
1624
+ _und = nx.Graph()
1625
+ _und.add_nodes_from(sorted(G.nodes))
1626
+ _und.add_edges_from(sorted((min(u, v), max(u, v)) for u, v in G.edges()))
1627
+ path_nodes = nx.shortest_path(_und, src_nid, tgt_nid)
1628
+ else:
1629
+ # Directed by default (#2487). True direction is NOT raw arc
1630
+ # order: legacy canonicalized files persist a flipped arc with
1631
+ # _src/_tgt markers (#2309), so build the digraph from _src/_tgt
1632
+ # (falling back to the loaded arc) rather than to_directed().
1633
+ _dg = nx.DiGraph()
1634
+ _dg.add_nodes_from(sorted(G.nodes))
1635
+ _dg.add_edges_from(sorted(
1636
+ (d.get("_src", u), d.get("_tgt", v)) for u, v, d in G.edges(data=True)
1637
+ ))
1638
+ path_nodes = nx.shortest_path(_dg, src_nid, tgt_nid)
1639
+ except (nx.NetworkXNoPath, nx.NodeNotFound):
1640
+ src_label = G.nodes[src_nid].get("label", src_nid)
1641
+ tgt_label = G.nodes[tgt_nid].get("label", tgt_nid)
1642
+ if undirected:
1643
+ return f"No path found between '{src_label}' and '{tgt_label}'."
1644
+ return (
1645
+ f"No directed path found between '{src_label}' and '{tgt_label}'. "
1646
+ "Retry with undirected=true to search ignoring edge direction."
1647
+ )
1648
+ hops = len(path_nodes) - 1
1649
+ if hops > max_hops:
1650
+ return f"Path exceeds max_hops={max_hops} ({hops} hops found)."
1651
+ segments = []
1652
+ for i in range(len(path_nodes) - 1):
1653
+ u, v = path_nodes[i], path_nodes[i + 1]
1654
+ # Report the actual stored relation(s), never a fabricated `calls`;
1655
+ # fall back to an honest "related" when the edge has no relation (#2074).
1656
+ # Direction truth lives in the per-link _src/_tgt markers (#2309): a
1657
+ # legacy canonicalized file can persist a flipped arc, so classify each
1658
+ # hop by _src (falling back to the arc tail) instead of raw arc order.
1659
+ fwd, bwd = [], []
1660
+ for a, b in ((u, v), (v, u)):
1661
+ if G.has_edge(a, b):
1662
+ for d in edge_datas(G, a, b):
1663
+ (fwd if d.get("_src", a) == u else bwd).append(d)
1664
+ datas = fwd or bwd
1665
+ forward = bool(fwd)
1666
+ rels = sorted({d.get("relation") for d in datas if d.get("relation")})
1667
+ rel = "/".join(rels) if rels else "related"
1668
+ confs = sorted({d.get("confidence") for d in datas if d.get("confidence")})
1669
+ conf_str = f" [{'/'.join(confs)}]" if confs else ""
1670
+ if i == 0:
1671
+ segments.append(G.nodes[u].get("label", u))
1672
+ if forward:
1673
+ segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}")
1674
+ else:
1675
+ segments.append(f"<--{rel}{conf_str}-- {G.nodes[v].get('label', v)}")
1676
+ prefix = ("\n".join(warnings) + "\n") if warnings else ""
1677
+ return prefix + f"Shortest path ({hops} hops):\n " + " ".join(segments)
1678
+
1679
+
1680
+ def _filter_blank_stdin() -> None:
1681
+ """Filter blank lines from stdin before MCP reads it.
1682
+
1683
+ Some MCP clients (Claude Desktop, etc.) send blank lines between JSON
1684
+ messages. The MCP stdio transport tries to parse every line as a
1685
+ JSONRPCMessage, so a bare newline triggers a Pydantic ValidationError.
1686
+ This installs an OS-level pipe that relays stdin while dropping blanks.
1687
+ """
1688
+ r_fd, w_fd = os.pipe()
1689
+ saved_fd = os.dup(sys.stdin.fileno())
1690
+
1691
+ def _relay() -> None:
1692
+ try:
1693
+ with open(saved_fd, "rb") as src, open(w_fd, "wb") as dst:
1694
+ for line in src:
1695
+ if line.strip():
1696
+ dst.write(line)
1697
+ dst.flush()
1698
+ except Exception:
1699
+ pass
1700
+
1701
+ threading.Thread(target=_relay, daemon=True).start()
1702
+ os.dup2(r_fd, sys.stdin.fileno())
1703
+ os.close(r_fd)
1704
+ sys.stdin = open(0, "r", closefd=False)
1705
+
1706
+
1707
+ def _community_header(cid: int, community_name) -> str:
1708
+ # Header for get_community: "Community N — Name", matching get_node / query
1709
+ # output which read the community_name attribute to_json writes onto nodes.
1710
+ # Skip the name when it is just the "Community N" placeholder (written for
1711
+ # unnamed communities) so the header never reads "Community 12 — Community 12";
1712
+ # also falls back to the bare id when there is no name. Name is sanitised
1713
+ # (F-010) like every other LLM-derived field.
1714
+ base = f"Community {cid}"
1715
+ if community_name:
1716
+ clean = sanitize_label(str(community_name))
1717
+ if clean and clean != base:
1718
+ return f"{base} — {clean}"
1719
+ return base
1720
+
1721
+
1722
+ def _build_server(graph_path: str):
1723
+ """Build the configured low-level MCP Server (shared by every transport).
1724
+
1725
+ All graph query tools and resources are registered here over a single
1726
+ ``mcp.server.Server`` instance; the caller picks the transport (stdio or
1727
+ Streamable HTTP) and runs it. Hot-reload of graph.json works the same way
1728
+ regardless of transport, since reloads happen inside the tool handlers.
1729
+ """
1730
+ try:
1731
+ from mcp.server import Server
1732
+ from mcp import types
1733
+ except ImportError as e:
1734
+ raise ImportError('mcp not installed. Run: pip install "graphifyy[mcp]"') from e
1735
+ try:
1736
+ from mcp.types import AnyUrl
1737
+ except ImportError:
1738
+ # mcp >= 2.0 dropped the AnyUrl re-export; it was always pydantic's
1739
+ # AnyUrl (pydantic is an mcp dependency, so this import cannot miss).
1740
+ from pydantic import AnyUrl
1741
+
1742
+ from graphify import paths as _paths
1743
+
1744
+ # Graph contexts comprise one pinned configured default plus a bounded LRU
1745
+ # of project_path graphs. This preserves the configured graph's warm index
1746
+ # while preventing a shared server from retaining every project it serves.
1747
+ _default_graph_path = str(Path(graph_path).resolve())
1748
+ _ctx_cache = _GraphContextCache(_max_server_contexts())
1749
+
1750
+ def _load_ctx(path: str):
1751
+ """Return the current default or project graph context as a tool error.
1752
+
1753
+ Unlike ``_load_graph``, this never lets a missing or corrupt client
1754
+ graph terminate the MCP process; it raises so other projects remain
1755
+ available on the same server.
1756
+ """
1757
+ resolved_path = str(Path(path).resolve())
1758
+ return _ctx_cache.load(resolved_path, pinned=resolved_path == _default_graph_path)
1759
+
1760
+ def _resolve_graph_path(project_path) -> str:
1761
+ """Map an optional project_path to a concrete graph.json path. ``None``
1762
+ keeps the server's default graph (backward-compatible); a project_path
1763
+ resolves to ``<project_path>/<GRAPHIFY_OUT>/graph.json``, honouring the
1764
+ GRAPHIFY_OUT override so worktree/shared-output setups keep working."""
1765
+ if not project_path:
1766
+ return _default_graph_path
1767
+ return str(Path(project_path) / _paths.GRAPHIFY_OUT / "graph.json")
1768
+
1769
+ # Active per-request context, rebound by _select_graph() and read by the tool
1770
+ # handlers below. No lock needed on the hot path: _select_graph and the
1771
+ # handler run in one synchronous stretch of each call_tool coroutine (no
1772
+ # await between them), so a concurrent call never observes a half-applied
1773
+ # swap.
1774
+ active_graph_path = _default_graph_path
1775
+ try:
1776
+ G, communities = _load_ctx(_default_graph_path)
1777
+ except (FileNotFoundError, RuntimeError):
1778
+ # No default graph at startup → run as a pure multi-project server. Tools
1779
+ # then require project_path; a call without one gets a clear error rather
1780
+ # than the process refusing to start (which is what _load_graph would do).
1781
+ G, communities = None, {}
1782
+
1783
+ def _select_graph(project_path) -> None:
1784
+ nonlocal G, communities, active_graph_path
1785
+ path = _resolve_graph_path(project_path)
1786
+ G, communities = _load_ctx(path)
1787
+ active_graph_path = str(Path(path).resolve())
1788
+
1789
+ # NOTE: no decorators here — the handlers below are plain coroutines,
1790
+ # bound to the Server at the END of this function in a version-aware way:
1791
+ # mcp 1.x exposes the @server.list_tools()/... decorator API, mcp 2.x
1792
+ # replaced it with on_list_tools=/... constructor callbacks.
1793
+ async def list_tools() -> list[types.Tool]:
1794
+ _tools = [
1795
+ types.Tool(
1796
+ name="query_graph",
1797
+ description="Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.",
1798
+ inputSchema={
1799
+ "type": "object",
1800
+ "properties": {
1801
+ "question": {"type": "string", "description": "Natural language question or keyword search"},
1802
+ "mode": {"type": "string", "enum": ["bfs", "dfs"], "default": "bfs",
1803
+ "description": "bfs=broad context, dfs=trace a specific path"},
1804
+ "depth": {"type": "integer", "default": 3, "description": "Traversal depth (1-6)"},
1805
+ "token_budget": {"type": "integer", "default": 2000, "description": "Max output tokens"},
1806
+ "context_filter": {
1807
+ "type": "array",
1808
+ "items": {"type": "string"},
1809
+ "description": "Optional explicit edge-context filter, e.g. ['call', 'field']",
1810
+ },
1811
+ },
1812
+ "required": ["question"],
1813
+ },
1814
+ ),
1815
+ types.Tool(
1816
+ name="get_node",
1817
+ description="Get full details for a specific node by label or ID.",
1818
+ inputSchema={
1819
+ "type": "object",
1820
+ "properties": {"label": {"type": "string", "description": "Node label or ID to look up"}},
1821
+ "required": ["label"],
1822
+ },
1823
+ ),
1824
+ types.Tool(
1825
+ name="get_neighbors",
1826
+ description="Get all direct neighbors of a node with edge details.",
1827
+ inputSchema={
1828
+ "type": "object",
1829
+ "properties": {
1830
+ "label": {"type": "string"},
1831
+ "relation_filter": {"type": "string", "description": "Optional: filter by relation type"},
1832
+ "token_budget": {"type": "integer", "default": 2000, "description": "Max output tokens"},
1833
+ },
1834
+ "required": ["label"],
1835
+ },
1836
+ ),
1837
+ types.Tool(
1838
+ name="get_community",
1839
+ description="Get all nodes in a community by community ID.",
1840
+ inputSchema={
1841
+ "type": "object",
1842
+ "properties": {
1843
+ "community_id": {"type": "integer", "description": "Community ID (0-indexed by size)"},
1844
+ "token_budget": {"type": "integer", "default": 2000, "description": "Max output tokens"},
1845
+ },
1846
+ "required": ["community_id"],
1847
+ },
1848
+ ),
1849
+ types.Tool(
1850
+ name="god_nodes",
1851
+ description="Return the most connected nodes - the core abstractions of the knowledge graph.",
1852
+ inputSchema={"type": "object", "properties": {
1853
+ "top_n": {"type": "integer", "default": 10},
1854
+ "exclude_hubs_percentile": {"type": "number",
1855
+ "description": "Suppress nodes whose degree exceeds this percentile (0-100) of the degree distribution, matching cluster()'s hub exclusion"},
1856
+ }},
1857
+ ),
1858
+ types.Tool(
1859
+ name="graph_stats",
1860
+ description="Return summary statistics: node count, edge count, communities, confidence breakdown.",
1861
+ inputSchema={"type": "object", "properties": {}},
1862
+ ),
1863
+ types.Tool(
1864
+ name="shortest_path",
1865
+ description=(
1866
+ "Find the shortest path between two concepts in the knowledge graph. "
1867
+ "Follows stored edge direction by default; set undirected=true to ignore it."
1868
+ ),
1869
+ inputSchema={
1870
+ "type": "object",
1871
+ "properties": {
1872
+ "source": {"type": "string", "description": "Source concept label or keyword"},
1873
+ "target": {"type": "string", "description": "Target concept label or keyword"},
1874
+ "max_hops": {"type": "integer", "default": 8, "description": "Maximum hops to consider"},
1875
+ "undirected": {"type": "boolean", "default": False,
1876
+ "description": "Ignore stored edge direction when searching"},
1877
+ },
1878
+ "required": ["source", "target"],
1879
+ },
1880
+ ),
1881
+ types.Tool(
1882
+ name="list_prs",
1883
+ description=(
1884
+ "List open GitHub PRs with CI status, review state, and graph impact "
1885
+ "(which communities each PR touches, blast radius). Use this before starting "
1886
+ "work to check if a PR already covers the area you're about to change."
1887
+ ),
1888
+ inputSchema={
1889
+ "type": "object",
1890
+ "properties": {
1891
+ "base": {"type": "string", "description": "Base branch to filter PRs by (auto-detected if omitted)"},
1892
+ "repo": {"type": "string", "description": "GitHub repo (owner/repo). Defaults to current repo."},
1893
+ },
1894
+ },
1895
+ ),
1896
+ types.Tool(
1897
+ name="get_pr_impact",
1898
+ description=(
1899
+ "Get detailed graph impact for a specific PR: which files it changes, "
1900
+ "which knowledge-graph communities are affected, and how many nodes are touched. "
1901
+ "Use this to assess merge risk or check for overlap with your current work."
1902
+ ),
1903
+ inputSchema={
1904
+ "type": "object",
1905
+ "properties": {
1906
+ "pr_number": {"type": "integer", "description": "PR number to analyse"},
1907
+ "repo": {"type": "string", "description": "GitHub repo (owner/repo). Defaults to current repo."},
1908
+ },
1909
+ "required": ["pr_number"],
1910
+ },
1911
+ ),
1912
+ types.Tool(
1913
+ name="triage_prs",
1914
+ description=(
1915
+ "Return all actionable open PRs (correct base, not stale) with full graph impact data "
1916
+ "so you can reason about review priority, merge order, and conflict risk. "
1917
+ "Call this when the user asks 'what PRs should I review?' or 'what's ready to merge?'"
1918
+ ),
1919
+ inputSchema={
1920
+ "type": "object",
1921
+ "properties": {
1922
+ "base": {"type": "string", "description": "Base branch to filter PRs by (auto-detected if omitted)"},
1923
+ "repo": {"type": "string", "description": "GitHub repo (owner/repo). Defaults to current repo."},
1924
+ },
1925
+ },
1926
+ ),
1927
+ ]
1928
+ # Multi-project support: every tool accepts an optional project_path.
1929
+ # Injected here (rather than repeated in 11 literal schemas) so the set
1930
+ # stays in lockstep as tools are added. Omitting it keeps the historical
1931
+ # single-graph behaviour, so this is purely additive for existing callers.
1932
+ for _t in _tools:
1933
+ # The constructor accepts the camelCase alias in both majors, but
1934
+ # attribute access is inputSchema on mcp 1.x and input_schema on 2.x.
1935
+ _schema = getattr(_t, "inputSchema", None)
1936
+ if _schema is None:
1937
+ _schema = _t.input_schema
1938
+ _schema.setdefault("properties", {})["project_path"] = {
1939
+ "type": "string",
1940
+ "description": (
1941
+ "Absolute path to a project directory containing "
1942
+ "graphify-out/graph.json. Optional — defaults to the graph "
1943
+ "this server was started with."
1944
+ ),
1945
+ }
1946
+ return _tools
1947
+
1948
+ def _tool_query_graph(arguments: dict) -> str:
1949
+ import time as _time
1950
+ from graphify import querylog
1951
+ question = arguments["question"]
1952
+ mode = arguments.get("mode", "bfs")
1953
+ depth = min(int(arguments.get("depth", 3)), 6)
1954
+ budget = int(arguments.get("token_budget", 2000))
1955
+ context_filter = arguments.get("context_filter")
1956
+ _t0 = _time.perf_counter()
1957
+ result = _query_graph_text(
1958
+ G,
1959
+ question,
1960
+ mode=mode,
1961
+ depth=depth,
1962
+ token_budget=budget,
1963
+ context_filters=context_filter,
1964
+ graph_path=str(active_graph_path),
1965
+ )
1966
+ querylog.log_query(
1967
+ kind="mcp_query",
1968
+ question=question,
1969
+ corpus=str(active_graph_path),
1970
+ result=result,
1971
+ mode=mode,
1972
+ depth=depth,
1973
+ token_budget=budget,
1974
+ duration_ms=(_time.perf_counter() - _t0) * 1000,
1975
+ )
1976
+ return result
1977
+
1978
+ def _tool_get_node(arguments: dict) -> str:
1979
+ label = arguments["label"].lower()
1980
+ nid, err = _resolve_single_node(G, label)
1981
+ if err:
1982
+ return err
1983
+ d = G.nodes[nid]
1984
+ # Sanitise every LLM-derived field before concatenation (F-010).
1985
+ return "\n".join([
1986
+ f"Node: {sanitize_label(d.get('label', nid))}",
1987
+ f" ID: {sanitize_label(nid)}",
1988
+ f" Source: {sanitize_label(str(d.get('source_file', '')))} {sanitize_label(str(d.get('source_location', '')))}",
1989
+ # A C/C++/ObjC symbol declared in a header and defined in the sibling
1990
+ # impl file is ONE node keyed to the header, so Source alone points at
1991
+ # the declaration. Name where it is implemented too, when known.
1992
+ *([f" Defined in: {sanitize_label(str(d.get('definition_file', '')))} "
1993
+ f"{sanitize_label(str(d.get('definition_location', '')))}"]
1994
+ if d.get("definition_file") else []),
1995
+ f" Type: {sanitize_label(str(d.get('file_type', '')))}",
1996
+ f" Community: {sanitize_label(str(d.get('community_name') or d.get('community', '')))}",
1997
+ f" Degree: {G.degree(nid)}",
1998
+ ])
1999
+
2000
+ def _tool_get_neighbors(arguments: dict) -> str:
2001
+ label = arguments["label"].lower()
2002
+ rel_filter = arguments.get("relation_filter", "").lower()
2003
+ nid, err = _resolve_single_node(G, label)
2004
+ if err:
2005
+ return err
2006
+ lines = [f"Neighbors of {sanitize_label(G.nodes[nid].get('label', nid))}:"]
2007
+ def _edge_at(d: dict) -> str:
2008
+ # Edge location = the relation SITE (call/import line) in the source
2009
+ # node's file, not a def line (#BUG1).
2010
+ loc = str(d.get("source_location") or "")
2011
+ return (
2012
+ f" at={sanitize_label(str(d.get('source_file') or ''))}:{sanitize_label(loc)}"
2013
+ if loc else ""
2014
+ )
2015
+ for nb in G.successors(nid):
2016
+ d = edge_data(G, nid, nb)
2017
+ rel = d.get("relation", "")
2018
+ if rel_filter and rel_filter not in rel.lower():
2019
+ continue
2020
+ lines.append(
2021
+ f" --> {sanitize_label(G.nodes[nb].get('label', nb))} "
2022
+ f"[{sanitize_label(str(rel))}] [{sanitize_label(str(d.get('confidence', '')))}]{_edge_at(d)}"
2023
+ )
2024
+ for nb in G.predecessors(nid):
2025
+ d = edge_data(G, nb, nid)
2026
+ rel = d.get("relation", "")
2027
+ if rel_filter and rel_filter not in rel.lower():
2028
+ continue
2029
+ lines.append(
2030
+ f" <-- {sanitize_label(G.nodes[nb].get('label', nb))} "
2031
+ f"[{sanitize_label(str(rel))}] [{sanitize_label(str(d.get('confidence', '')))}]{_edge_at(d)}"
2032
+ )
2033
+ budget = int(arguments.get("token_budget", 2000))
2034
+ return _cut_lines_to_budget(
2035
+ lines, budget, "Narrow with relation_filter or use get_node for a specific symbol"
2036
+ )
2037
+
2038
+ def _tool_get_community(arguments: dict) -> str:
2039
+ cid = int(arguments["community_id"])
2040
+ nodes = communities.get(cid, [])
2041
+ if not nodes:
2042
+ return f"Community {cid} not found."
2043
+ header = _community_header(cid, G.nodes[nodes[0]].get("community_name"))
2044
+ lines = [f"{header} ({len(nodes)} nodes):"]
2045
+ for n in nodes:
2046
+ d = G.nodes[n]
2047
+ # Sanitise label and source_file (F-010).
2048
+ lines.append(
2049
+ f" {sanitize_label(d.get('label', n))} "
2050
+ f"[{sanitize_label(str(d.get('source_file', '')))}]"
2051
+ )
2052
+ budget = int(arguments.get("token_budget", 2000))
2053
+ return _cut_lines_to_budget(
2054
+ lines, budget, "Raise token_budget or use get_node for specific members"
2055
+ )
2056
+
2057
+ def _tool_god_nodes(arguments: dict) -> str:
2058
+ from graphify.analyze import god_nodes as _god_nodes
2059
+ _pct = arguments.get("exclude_hubs_percentile")
2060
+ nodes = _god_nodes(
2061
+ G, top_n=int(arguments.get("top_n", 10)),
2062
+ exclude_hubs_percentile=float(_pct) if _pct is not None else None,
2063
+ )
2064
+ lines = ["God nodes (most connected):"]
2065
+ lines += [f" {i}. {n['label']} - {n['degree']} edges" for i, n in enumerate(nodes, 1)]
2066
+ return "\n".join(lines)
2067
+
2068
+ def _tool_graph_stats(_: dict) -> str:
2069
+ confs = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
2070
+ total = len(confs) or 1
2071
+ return (
2072
+ f"Nodes: {G.number_of_nodes()}\n"
2073
+ f"Edges: {G.number_of_edges()}\n"
2074
+ f"Communities: {len(communities)}\n"
2075
+ f"EXTRACTED: {round(confs.count('EXTRACTED')/total*100)}%\n"
2076
+ f"INFERRED: {round(confs.count('INFERRED')/total*100)}%\n"
2077
+ f"AMBIGUOUS: {round(confs.count('AMBIGUOUS')/total*100)}%\n"
2078
+ )
2079
+
2080
+ def _tool_shortest_path(arguments: dict) -> str:
2081
+ return _shortest_path_text(G, arguments)
2082
+
2083
+ def _tool_list_prs(arguments: dict) -> str:
2084
+ from graphify.prs import fetch_prs, fetch_worktrees, format_prs_text, _detect_default_branch
2085
+ repo = arguments.get("repo") or None
2086
+ base = arguments.get("base") or _detect_default_branch(repo)
2087
+ try:
2088
+ prs = fetch_prs(repo=repo, base=base)
2089
+ except RuntimeError as e:
2090
+ raise ToolError(f"Error: {e}") from e
2091
+ worktrees = fetch_worktrees()
2092
+ for pr in prs:
2093
+ pr.worktree_path = worktrees.get(pr.branch)
2094
+ return format_prs_text(prs, base)
2095
+
2096
+ def _tool_get_pr_impact(arguments: dict) -> str:
2097
+ from graphify.prs import fetch_pr_files, compute_pr_impact, _gh, _parse_ci
2098
+ number = int(arguments["pr_number"])
2099
+ repo = arguments.get("repo") or None
2100
+ # Use gh pr view directly — works for any base branch, not just the default
2101
+ view_args = ["pr", "view", str(number), "--json",
2102
+ "title,headRefName,baseRefName,author,isDraft,reviewDecision,statusCheckRollup,updatedAt"]
2103
+ if repo:
2104
+ view_args += ["--repo", repo]
2105
+ pr_data = _gh(*view_args)
2106
+ if pr_data is None:
2107
+ raise ToolError(f"PR #{number} not found or gh not authenticated.")
2108
+ files = fetch_pr_files(number, repo)
2109
+ if not files:
2110
+ return f"PR #{number}: no changed files found (may require gh auth)."
2111
+ comms, nodes = compute_pr_impact(files, G)
2112
+ ci = _parse_ci(pr_data.get("statusCheckRollup") or [])
2113
+ lines = [
2114
+ f"PR #{number}: {pr_data['title']}",
2115
+ f"CI: {ci} Review: {pr_data.get('reviewDecision') or 'none'}",
2116
+ f"Base: {pr_data['baseRefName']} Author: {(pr_data.get('author') or {}).get('login', '?')}",
2117
+ f"\nGraph impact: {nodes} nodes across {len(comms)} communities",
2118
+ f"Communities touched: {comms}",
2119
+ f"Files changed ({len(files)}):",
2120
+ ]
2121
+ lines += [f" {f}" for f in files[:20]]
2122
+ if len(files) > 20:
2123
+ lines.append(f" … and {len(files) - 20} more")
2124
+ return "\n".join(lines)
2125
+
2126
+ def _tool_triage_prs(arguments: dict) -> str:
2127
+ from concurrent.futures import ThreadPoolExecutor, as_completed
2128
+ from graphify.prs import fetch_prs, fetch_worktrees, fetch_pr_files, compute_pr_impact, _STATUS_ORDER, _detect_default_branch
2129
+ repo = arguments.get("repo") or None
2130
+ base = arguments.get("base") or _detect_default_branch(repo)
2131
+ try:
2132
+ prs = fetch_prs(repo=repo, base=base)
2133
+ except RuntimeError as e:
2134
+ raise ToolError(f"Error: {e}") from e
2135
+ worktrees = fetch_worktrees()
2136
+ for pr in prs:
2137
+ pr.worktree_path = worktrees.get(pr.branch)
2138
+ actionable = [p for p in prs if p.base_branch == base and p.status not in ("WRONG-BASE", "STALE")]
2139
+ if not actionable:
2140
+ return f"No actionable PRs targeting {base}."
2141
+ # Fetch diffs concurrently then compute graph impact using in-memory G
2142
+ workers = min(8, len(actionable))
2143
+ with ThreadPoolExecutor(max_workers=workers) as pool:
2144
+ future_to_pr = {pool.submit(fetch_pr_files, pr.number, repo): pr for pr in actionable}
2145
+ for fut in as_completed(future_to_pr):
2146
+ pr = future_to_pr[fut]
2147
+ try:
2148
+ files = fut.result()
2149
+ except Exception:
2150
+ files = []
2151
+ if files:
2152
+ pr.files_changed = files
2153
+ pr.communities_touched, pr.nodes_affected = compute_pr_impact(files, G)
2154
+ header = (
2155
+ f"Actionable PRs targeting {base}: {len(actionable)}\n"
2156
+ "Rank these by review priority. Higher blast_radius = more graph communities affected = higher merge risk.\n"
2157
+ )
2158
+ lines = [header]
2159
+ for p in sorted(actionable, key=lambda x: (_STATUS_ORDER.index(x.status) if x.status in _STATUS_ORDER else 99)):
2160
+ impact = f" blast_radius={p.blast_radius}" if p.blast_radius else ""
2161
+ wt = f" worktree={p.worktree_path}" if p.worktree_path else ""
2162
+ lines.append(
2163
+ f"PR #{p.number} [{p.status}] CI={p.ci_status} review={p.review_decision or 'none'} "
2164
+ f"age={p.days_old}d author={p.author}{impact}{wt}\n title: {p.title}"
2165
+ )
2166
+ return "\n\n".join(lines)
2167
+
2168
+ _handlers = {
2169
+ "query_graph": _tool_query_graph,
2170
+ "get_node": _tool_get_node,
2171
+ "get_neighbors": _tool_get_neighbors,
2172
+ "get_community": _tool_get_community,
2173
+ "god_nodes": _tool_god_nodes,
2174
+ "graph_stats": _tool_graph_stats,
2175
+ "shortest_path": _tool_shortest_path,
2176
+ "list_prs": _tool_list_prs,
2177
+ "get_pr_impact": _tool_get_pr_impact,
2178
+ "triage_prs": _tool_triage_prs,
2179
+ }
2180
+
2181
+ def _load_community_labels() -> dict[int, str]:
2182
+ labels_path = Path(active_graph_path).parent / ".graphify_labels.json"
2183
+ if labels_path.exists():
2184
+ try:
2185
+ return {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()}
2186
+ except Exception:
2187
+ pass
2188
+ return {cid: f"Community {cid}" for cid in communities}
2189
+
2190
+ async def list_resources() -> list[types.Resource]:
2191
+ # Plain-string URIs on purpose: mcp 1.x types the field as AnyUrl and
2192
+ # coerces strings, mcp 2.x types it as str and REJECTS AnyUrl objects.
2193
+ return [
2194
+ types.Resource(uri="graphify://report", name="Graph Report", description="Full GRAPH_REPORT.md", mimeType="text/markdown"),
2195
+ types.Resource(uri="graphify://stats", name="Graph Stats", description="Node/edge/community counts and confidence breakdown", mimeType="text/plain"),
2196
+ types.Resource(uri="graphify://god-nodes", name="God Nodes", description="Top 10 most-connected nodes", mimeType="text/plain"),
2197
+ types.Resource(uri="graphify://surprises", name="Surprising Connections", description="Cross-community surprising connections", mimeType="text/plain"),
2198
+ types.Resource(uri="graphify://audit", name="Confidence Audit", description="EXTRACTED/INFERRED/AMBIGUOUS edge breakdown", mimeType="text/plain"),
2199
+ types.Resource(uri="graphify://questions", name="Suggested Questions", description="Suggested questions for this codebase", mimeType="text/plain"),
2200
+ ]
2201
+
2202
+ async def read_resource(uri: AnyUrl) -> str:
2203
+ _select_graph(None) # resources read the server's default graph
2204
+ uri_str = str(uri)
2205
+ if uri_str == "graphify://report":
2206
+ report_path = Path(active_graph_path).parent / "GRAPH_REPORT.md"
2207
+ if report_path.exists():
2208
+ return report_path.read_text(encoding="utf-8")
2209
+ return "GRAPH_REPORT.md not found. Run graphify extract first."
2210
+ if uri_str == "graphify://stats":
2211
+ return _tool_graph_stats({})
2212
+ if uri_str == "graphify://god-nodes":
2213
+ return _tool_god_nodes({"top_n": 10})
2214
+ if uri_str == "graphify://surprises":
2215
+ try:
2216
+ from graphify.analyze import surprising_connections
2217
+ surprises = surprising_connections(G, communities, top_n=10)
2218
+ if not surprises:
2219
+ return "No surprising connections found."
2220
+ lines = ["Surprising cross-community connections:"]
2221
+ for s in surprises:
2222
+ lines.append(f" {s.get('source', '')} <-> {s.get('target', '')} [{s.get('relation', '')}]")
2223
+ return "\n".join(lines)
2224
+ except Exception as exc:
2225
+ return f"Could not compute surprising connections: {exc}"
2226
+ if uri_str == "graphify://audit":
2227
+ confs = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)]
2228
+ total = len(confs) or 1
2229
+ return (
2230
+ f"Total edges: {total}\n"
2231
+ f"EXTRACTED: {confs.count('EXTRACTED')} ({round(confs.count('EXTRACTED')/total*100)}%)\n"
2232
+ f"INFERRED: {confs.count('INFERRED')} ({round(confs.count('INFERRED')/total*100)}%)\n"
2233
+ f"AMBIGUOUS: {confs.count('AMBIGUOUS')} ({round(confs.count('AMBIGUOUS')/total*100)}%)\n"
2234
+ )
2235
+ if uri_str == "graphify://questions":
2236
+ try:
2237
+ from graphify.analyze import suggest_questions
2238
+ community_labels = _load_community_labels()
2239
+ questions = suggest_questions(G, communities, community_labels, top_n=10)
2240
+ if not questions:
2241
+ return "No suggested questions available."
2242
+ lines = ["Suggested questions:"]
2243
+ for q in questions:
2244
+ if isinstance(q, dict):
2245
+ lines.append(f" - {q.get('question', '')}")
2246
+ else:
2247
+ lines.append(f" - {q}")
2248
+ return "\n".join(lines)
2249
+ except Exception as exc:
2250
+ return f"Could not generate questions: {exc}"
2251
+ raise ValueError(f"Unknown resource: {uri_str}")
2252
+
2253
+ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
2254
+ arguments = dict(arguments or {})
2255
+ project_path = arguments.pop("project_path", None)
2256
+ handler = _handlers.get(name)
2257
+ if not handler:
2258
+ return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
2259
+ try:
2260
+ _select_graph(project_path) # bind G/communities to the target graph
2261
+ return [types.TextContent(type="text", text=handler(arguments))]
2262
+ except ToolError:
2263
+ # A handler-signalled error: propagate so the result is marked
2264
+ # isError:true (the mcp 1.x decorator wraps a raised exception into
2265
+ # an error result; the 2.x path catches it in _on_call_tool).
2266
+ raise
2267
+ except Exception as exc:
2268
+ return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")]
2269
+
2270
+ if hasattr(Server, "list_tools"):
2271
+ # mcp 1.x: decorator-based registration. The SDK wraps the raw returns
2272
+ # (list[Tool] -> ListToolsResult, str -> resource contents) itself.
2273
+ server = Server("graphify")
2274
+ server.list_tools()(list_tools)
2275
+ server.call_tool()(call_tool)
2276
+ server.list_resources()(list_resources)
2277
+ server.read_resource()(read_resource)
2278
+ else:
2279
+ # mcp 2.x: handlers ride the Server constructor as on_* callbacks with
2280
+ # the (ctx, params) -> Result contract, so wrap the same impls and
2281
+ # build the result models the 1.x decorators used to build for us.
2282
+ async def _on_list_tools(ctx, params) -> types.ListToolsResult:
2283
+ return types.ListToolsResult(tools=await list_tools())
2284
+
2285
+ async def _on_call_tool(ctx, params) -> types.CallToolResult:
2286
+ try:
2287
+ content = await call_tool(params.name, dict(params.arguments or {}))
2288
+ except ToolError as exc:
2289
+ return types.CallToolResult(
2290
+ content=[types.TextContent(type="text", text=str(exc))],
2291
+ isError=True,
2292
+ )
2293
+ return types.CallToolResult(content=content)
2294
+
2295
+ async def _on_list_resources(ctx, params) -> types.ListResourcesResult:
2296
+ return types.ListResourcesResult(resources=await list_resources())
2297
+
2298
+ async def _on_read_resource(ctx, params) -> types.ReadResourceResult:
2299
+ text = await read_resource(params.uri)
2300
+ mime = "text/markdown" if str(params.uri).startswith("graphify://report") else "text/plain"
2301
+ return types.ReadResourceResult(
2302
+ contents=[types.TextResourceContents(uri=params.uri, mimeType=mime, text=text)]
2303
+ )
2304
+
2305
+ try:
2306
+ from importlib.metadata import version as _pkg_version
2307
+ _version = _pkg_version("graphifyy")
2308
+ except Exception:
2309
+ _version = "0"
2310
+ server = Server(
2311
+ "graphify",
2312
+ version=_version,
2313
+ on_list_tools=_on_list_tools,
2314
+ on_call_tool=_on_call_tool,
2315
+ on_list_resources=_on_list_resources,
2316
+ on_read_resource=_on_read_resource,
2317
+ )
2318
+
2319
+ return server
2320
+
2321
+
2322
+ def serve(graph_path: str | None = None) -> None:
2323
+ """Start the MCP server over stdio (the default, per-developer transport)."""
2324
+ graph_path = graph_path or _default_graph_json()
2325
+ try:
2326
+ from mcp.server.stdio import stdio_server
2327
+ except ImportError as e:
2328
+ raise ImportError('mcp not installed. Run: pip install "graphifyy[mcp]"') from e
2329
+ import asyncio
2330
+
2331
+ server = _build_server(graph_path)
2332
+
2333
+ async def main() -> None:
2334
+ async with stdio_server() as streams:
2335
+ await server.run(streams[0], streams[1], server.create_initialization_options())
2336
+
2337
+ _filter_blank_stdin()
2338
+ asyncio.run(main())
2339
+
2340
+
2341
+ class _MCPASGIApp:
2342
+ """Raw-ASGI wrapper around the Streamable HTTP session manager.
2343
+
2344
+ Passed to a Starlette ``Route`` as a class instance (not a function) so
2345
+ Starlette treats it as an ASGI app: it serves the exact mount path for all
2346
+ methods (GET/POST/DELETE) with no request/response wrapping and no
2347
+ trailing-slash redirect — mirroring how FastMCP mounts the same manager.
2348
+ """
2349
+
2350
+ def __init__(self, manager) -> None:
2351
+ self._manager = manager
2352
+
2353
+ async def __call__(self, scope, receive, send) -> None:
2354
+ await self._manager.handle_request(scope, receive, send)
2355
+
2356
+
2357
+ class _ApiKeyMiddleware:
2358
+ """Pure-ASGI API-key gate for the HTTP transport.
2359
+
2360
+ Implemented as raw ASGI (not Starlette's BaseHTTPMiddleware) on purpose:
2361
+ BaseHTTPMiddleware buffers responses and breaks the Streamable HTTP SSE
2362
+ stream. This short-circuits with 401 before the request ever reaches the
2363
+ session manager, leaving the streaming path untouched for authorized calls.
2364
+ """
2365
+
2366
+ def __init__(self, app, api_key: str) -> None:
2367
+ self.app = app
2368
+ self._expected = api_key.encode("utf-8")
2369
+
2370
+ async def __call__(self, scope, receive, send) -> None:
2371
+ if scope["type"] != "http":
2372
+ await self.app(scope, receive, send)
2373
+ return
2374
+ import hmac
2375
+ headers = dict(scope.get("headers") or [])
2376
+ provided = headers.get(b"x-api-key")
2377
+ if provided is None:
2378
+ # RFC 6750: the auth scheme token is case-insensitive.
2379
+ scheme, _, token = headers.get(b"authorization", b"").partition(b" ")
2380
+ if scheme.lower() == b"bearer" and token:
2381
+ provided = token.strip()
2382
+ # Constant-time compare; reject when no key was supplied at all.
2383
+ if provided is None or not hmac.compare_digest(provided, self._expected):
2384
+ body = b'{"error": "unauthorized"}'
2385
+ await send({
2386
+ "type": "http.response.start",
2387
+ "status": 401,
2388
+ "headers": [
2389
+ (b"content-type", b"application/json"),
2390
+ (b"content-length", str(len(body)).encode("ascii")),
2391
+ ],
2392
+ })
2393
+ await send({"type": "http.response.body", "body": body})
2394
+ return
2395
+ await self.app(scope, receive, send)
2396
+
2397
+
2398
+ def _build_http_app(
2399
+ graph_path: str,
2400
+ *,
2401
+ host: str = "127.0.0.1",
2402
+ port: int = 8080,
2403
+ api_key: str | None = None,
2404
+ path: str = "/mcp",
2405
+ json_response: bool = False,
2406
+ stateless: bool = False,
2407
+ session_timeout: float | None = 3600.0,
2408
+ ):
2409
+ """Build the Starlette ASGI app for the Streamable HTTP transport.
2410
+
2411
+ Split out from :func:`serve_http` (which blocks on uvicorn) so the wiring
2412
+ can be exercised with an in-process ASGI test client.
2413
+
2414
+ ``session_timeout`` reaps stateful sessions idle for that many seconds so a
2415
+ long-running shared server does not leak memory when IDE clients disconnect
2416
+ without sending a DELETE. ``None`` (or <= 0) disables reaping; it is forced
2417
+ to ``None`` in stateless mode, which has no sessions to reap.
2418
+ """
2419
+ try:
2420
+ import contextlib
2421
+
2422
+ from starlette.applications import Starlette
2423
+ from starlette.middleware import Middleware
2424
+ from starlette.routing import Route
2425
+
2426
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
2427
+ from mcp.server.transport_security import TransportSecuritySettings
2428
+ except ImportError as e:
2429
+ raise ImportError(
2430
+ 'HTTP transport needs the mcp extra (mcp + starlette + uvicorn). '
2431
+ 'Run: pip install "graphifyy[mcp]"'
2432
+ ) from e
2433
+
2434
+ # A blank key (e.g. --api-key "" or an empty GRAPHIFY_API_KEY) must not be
2435
+ # mistaken for "auth on" — normalize it to None so the gate is unambiguous.
2436
+ api_key = (api_key or "").strip() or None
2437
+
2438
+ server = _build_server(graph_path)
2439
+
2440
+ # DNS-rebinding protection. When the operator binds a wildcard address they
2441
+ # are intentionally exposing the server, so accept any Host header; for a
2442
+ # loopback/specific bind, restrict Host to that address (with and without
2443
+ # the port) plus the localhost aliases.
2444
+ if host in ("0.0.0.0", "::", ""):
2445
+ security = TransportSecuritySettings(enable_dns_rebinding_protection=False)
2446
+ else:
2447
+ allowed = {host, "localhost", "127.0.0.1"}
2448
+ allowed |= {f"{h}:{port}" for h in list(allowed)}
2449
+ security = TransportSecuritySettings(allowed_hosts=sorted(allowed))
2450
+
2451
+ # The SDK rejects a non-positive timeout and forbids one in stateless mode.
2452
+ idle_timeout = None if (stateless or not session_timeout or session_timeout <= 0) else session_timeout
2453
+
2454
+ manager = StreamableHTTPSessionManager(
2455
+ app=server,
2456
+ json_response=json_response,
2457
+ stateless=stateless,
2458
+ security_settings=security,
2459
+ session_idle_timeout=idle_timeout,
2460
+ )
2461
+
2462
+ @contextlib.asynccontextmanager
2463
+ async def lifespan(_app):
2464
+ # The session manager owns an anyio task group that must wrap the whole
2465
+ # server lifetime, so enter it here rather than per-request.
2466
+ async with manager.run():
2467
+ yield
2468
+
2469
+ middleware = []
2470
+ if api_key:
2471
+ middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))
2472
+
2473
+ return Starlette(
2474
+ routes=[Route(path, endpoint=_MCPASGIApp(manager))],
2475
+ middleware=middleware,
2476
+ lifespan=lifespan,
2477
+ )
2478
+
2479
+
2480
+ def serve_http(
2481
+ graph_path: str | None = None,
2482
+ *,
2483
+ host: str = "127.0.0.1",
2484
+ port: int = 8080,
2485
+ api_key: str | None = None,
2486
+ path: str = "/mcp",
2487
+ json_response: bool = False,
2488
+ stateless: bool = False,
2489
+ session_timeout: float | None = 3600.0,
2490
+ ) -> None:
2491
+ """Start the MCP server over Streamable HTTP (MCP spec 2025-03-26).
2492
+
2493
+ Serves the same tools/resources as the stdio transport, so a single shared
2494
+ process can host the graph for a whole team. Clients point their IDE MCP
2495
+ config at ``http://<host>:<port><path>`` (default ``/mcp``).
2496
+
2497
+ ``api_key`` (or the ``GRAPHIFY_API_KEY`` env var) enables a simple header
2498
+ check (``Authorization: Bearer <key>`` or ``X-API-Key: <key>``). OAuth is a
2499
+ deliberate follow-up. Binding ``0.0.0.0`` exposes the server beyond
2500
+ localhost — set an api_key when you do.
2501
+ """
2502
+ graph_path = graph_path or _default_graph_json()
2503
+ try:
2504
+ import uvicorn
2505
+ except ImportError as e:
2506
+ raise ImportError(
2507
+ 'HTTP transport needs the mcp extra (mcp + starlette + uvicorn). '
2508
+ 'Run: pip install "graphifyy[mcp]"'
2509
+ ) from e
2510
+
2511
+ api_key = (api_key or "").strip() or None
2512
+
2513
+ app = _build_http_app(
2514
+ graph_path,
2515
+ host=host,
2516
+ port=port,
2517
+ api_key=api_key,
2518
+ path=path,
2519
+ json_response=json_response,
2520
+ stateless=stateless,
2521
+ session_timeout=session_timeout,
2522
+ )
2523
+
2524
+ auth_note = "api-key required" if api_key else "no auth (set --api-key to require one)"
2525
+ print(
2526
+ f"graphify MCP server (streamable-http) on http://{host}:{port}{path} - {auth_note}",
2527
+ file=sys.stderr,
2528
+ )
2529
+ if host in ("0.0.0.0", "::", "") and not api_key:
2530
+ print(
2531
+ f"WARNING: binding {host or '0.0.0.0'} with no api-key exposes the graph "
2532
+ "unauthenticated on the network. Set --api-key (or GRAPHIFY_API_KEY).",
2533
+ file=sys.stderr,
2534
+ )
2535
+ uvicorn.run(app, host=host, port=port)
2536
+
2537
+
2538
+ def _main(argv: list[str] | None = None) -> None:
2539
+ import argparse
2540
+ import os
2541
+
2542
+ parser = argparse.ArgumentParser(
2543
+ prog="python -m graphify.serve",
2544
+ description="Serve a graphify knowledge graph over MCP (stdio or Streamable HTTP).",
2545
+ )
2546
+ parser.add_argument(
2547
+ "graph_path",
2548
+ nargs="?",
2549
+ default=None,
2550
+ help="Path to graph.json (default: graphify-out/graph.json)",
2551
+ )
2552
+ parser.add_argument(
2553
+ "--graph",
2554
+ dest="graph_flag",
2555
+ default=None,
2556
+ metavar="PATH",
2557
+ help="Path to graph.json — alias for the positional argument",
2558
+ )
2559
+ parser.add_argument(
2560
+ "--transport",
2561
+ choices=["stdio", "http"],
2562
+ default="stdio",
2563
+ help="Transport to serve on (default: stdio)",
2564
+ )
2565
+ parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (default: 127.0.0.1)")
2566
+ parser.add_argument("--port", type=int, default=8080, help="HTTP bind port (default: 8080)")
2567
+ parser.add_argument(
2568
+ "--api-key",
2569
+ default=os.environ.get("GRAPHIFY_API_KEY"),
2570
+ help="Require this key on the HTTP transport (env: GRAPHIFY_API_KEY)",
2571
+ )
2572
+ parser.add_argument("--path", default="/mcp", help="HTTP mount path (default: /mcp)")
2573
+ parser.add_argument(
2574
+ "--json-response",
2575
+ action="store_true",
2576
+ help="Return plain JSON responses instead of SSE streams",
2577
+ )
2578
+ parser.add_argument(
2579
+ "--stateless",
2580
+ action="store_true",
2581
+ help="Run without per-session state (for load-balanced / CI deployments)",
2582
+ )
2583
+ parser.add_argument(
2584
+ "--session-timeout",
2585
+ type=float,
2586
+ default=3600.0,
2587
+ help="Reap stateful sessions idle this many seconds (default: 3600; 0 disables)",
2588
+ )
2589
+ args = parser.parse_args(argv)
2590
+ graph_path = args.graph_flag or args.graph_path or _default_graph_json()
2591
+
2592
+ if args.transport == "http":
2593
+ serve_http(
2594
+ graph_path,
2595
+ host=args.host,
2596
+ port=args.port,
2597
+ api_key=args.api_key,
2598
+ path=args.path,
2599
+ json_response=args.json_response,
2600
+ stateless=args.stateless,
2601
+ session_timeout=args.session_timeout,
2602
+ )
2603
+ else:
2604
+ serve(graph_path)
2605
+
2606
+
2607
+ if __name__ == "__main__":
2608
+ _main()