codebeacon 0.1.2__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.
- codebeacon/__init__.py +1 -0
- codebeacon/__main__.py +3 -0
- codebeacon/cache.py +136 -0
- codebeacon/cli.py +391 -0
- codebeacon/common/__init__.py +0 -0
- codebeacon/common/filters.py +170 -0
- codebeacon/common/symbols.py +121 -0
- codebeacon/common/types.py +98 -0
- codebeacon/config.py +144 -0
- codebeacon/contextmap/__init__.py +0 -0
- codebeacon/contextmap/generator.py +602 -0
- codebeacon/discover/__init__.py +0 -0
- codebeacon/discover/detector.py +388 -0
- codebeacon/discover/scanner.py +192 -0
- codebeacon/export/__init__.py +0 -0
- codebeacon/export/mcp.py +515 -0
- codebeacon/export/obsidian.py +812 -0
- codebeacon/extract/__init__.py +22 -0
- codebeacon/extract/base.py +372 -0
- codebeacon/extract/components.py +357 -0
- codebeacon/extract/dependencies.py +140 -0
- codebeacon/extract/entities.py +575 -0
- codebeacon/extract/queries/README.md +116 -0
- codebeacon/extract/queries/actix.scm +115 -0
- codebeacon/extract/queries/angular.scm +155 -0
- codebeacon/extract/queries/aspnet.scm +159 -0
- codebeacon/extract/queries/django.scm +122 -0
- codebeacon/extract/queries/express.scm +124 -0
- codebeacon/extract/queries/fastapi.scm +152 -0
- codebeacon/extract/queries/flask.scm +120 -0
- codebeacon/extract/queries/gin.scm +142 -0
- codebeacon/extract/queries/ktor.scm +144 -0
- codebeacon/extract/queries/laravel.scm +172 -0
- codebeacon/extract/queries/nestjs.scm +183 -0
- codebeacon/extract/queries/rails.scm +114 -0
- codebeacon/extract/queries/react.scm +111 -0
- codebeacon/extract/queries/spring_boot.scm +204 -0
- codebeacon/extract/queries/svelte.scm +73 -0
- codebeacon/extract/queries/vapor.scm +130 -0
- codebeacon/extract/queries/vue.scm +123 -0
- codebeacon/extract/routes.py +910 -0
- codebeacon/extract/semantic.py +280 -0
- codebeacon/extract/services.py +597 -0
- codebeacon/graph/__init__.py +1 -0
- codebeacon/graph/analyze.py +281 -0
- codebeacon/graph/build.py +320 -0
- codebeacon/graph/cluster.py +160 -0
- codebeacon/graph/enrich.py +206 -0
- codebeacon/skill/SKILL.md +127 -0
- codebeacon/wave.py +292 -0
- codebeacon/wiki/__init__.py +0 -0
- codebeacon/wiki/generator.py +376 -0
- codebeacon/wiki/index.py +95 -0
- codebeacon/wiki/templates.py +467 -0
- codebeacon-0.1.2.dist-info/METADATA +319 -0
- codebeacon-0.1.2.dist-info/RECORD +59 -0
- codebeacon-0.1.2.dist-info/WHEEL +4 -0
- codebeacon-0.1.2.dist-info/entry_points.txt +2 -0
- codebeacon-0.1.2.dist-info/licenses/LICENSE +21 -0
codebeacon/export/mcp.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
"""stdio MCP server for codebeacon.
|
|
2
|
+
|
|
3
|
+
Exposes the knowledge graph and wiki as MCP tools for AI agents.
|
|
4
|
+
|
|
5
|
+
Tools:
|
|
6
|
+
beacon_wiki_index - global wiki index (short token budget)
|
|
7
|
+
beacon_wiki_article - read a specific wiki article by path
|
|
8
|
+
beacon_query - search nodes/edges by label substring
|
|
9
|
+
beacon_path - shortest path between two named nodes
|
|
10
|
+
beacon_blast_radius - downstream + upstream neighbours of a node
|
|
11
|
+
beacon_routes - list all routes (optional: filter by project)
|
|
12
|
+
beacon_services - list all services (optional: filter by project)
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
codebeacon serve --dir /path/to/.codebeacon
|
|
16
|
+
codebeacon serve # defaults to .codebeacon in cwd
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Graph loader ──────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
class BeaconIndex:
|
|
31
|
+
"""Loaded graph + wiki index, built once at startup."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, beacon_dir: Path) -> None:
|
|
34
|
+
self.beacon_dir = beacon_dir
|
|
35
|
+
self.wiki_dir = beacon_dir / "wiki"
|
|
36
|
+
self.G = None
|
|
37
|
+
self._label_to_ids: dict[str, list[str]] = {}
|
|
38
|
+
|
|
39
|
+
def load(self) -> None:
|
|
40
|
+
import networkx as nx
|
|
41
|
+
import networkx.readwrite.json_graph as nxjson
|
|
42
|
+
|
|
43
|
+
beacon_json = self.beacon_dir / "beacon.json"
|
|
44
|
+
if not beacon_json.exists():
|
|
45
|
+
raise FileNotFoundError(
|
|
46
|
+
f"beacon.json not found at {beacon_json}. "
|
|
47
|
+
"Run 'codebeacon scan <path>' first."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
data = json.loads(beacon_json.read_text(encoding="utf-8"))
|
|
51
|
+
self.G = nxjson.node_link_graph(data, directed=True, multigraph=False)
|
|
52
|
+
|
|
53
|
+
# Build label → [node_ids] lookup (case-insensitive key)
|
|
54
|
+
for node_id, node_data in self.G.nodes(data=True):
|
|
55
|
+
label = node_data.get("label", node_id).lower()
|
|
56
|
+
self._label_to_ids.setdefault(label, []).append(node_id)
|
|
57
|
+
|
|
58
|
+
def find_node_ids(self, name: str) -> list[str]:
|
|
59
|
+
"""Return node IDs whose label contains `name` (case-insensitive)."""
|
|
60
|
+
name_lower = name.lower()
|
|
61
|
+
results: list[str] = []
|
|
62
|
+
for label, ids in self._label_to_ids.items():
|
|
63
|
+
if name_lower in label:
|
|
64
|
+
results.extend(ids)
|
|
65
|
+
return results
|
|
66
|
+
|
|
67
|
+
def node_summary(self, node_id: str) -> dict[str, Any]:
|
|
68
|
+
"""Return a compact dict for a single node."""
|
|
69
|
+
data = self.G.nodes[node_id]
|
|
70
|
+
return {
|
|
71
|
+
"id": node_id,
|
|
72
|
+
"label": data.get("label", node_id),
|
|
73
|
+
"type": data.get("type", ""),
|
|
74
|
+
"project": data.get("project", ""),
|
|
75
|
+
"source_file": data.get("source_file", ""),
|
|
76
|
+
"framework": data.get("framework", ""),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── Tool implementations ──────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def tool_beacon_wiki_index(idx: BeaconIndex, _args: dict) -> str:
|
|
83
|
+
"""Read the global wiki index."""
|
|
84
|
+
index_md = idx.wiki_dir / "index.md"
|
|
85
|
+
if index_md.exists():
|
|
86
|
+
return index_md.read_text(encoding="utf-8")
|
|
87
|
+
return "_No wiki index found. Run 'codebeacon scan' to generate._"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def tool_beacon_wiki_article(idx: BeaconIndex, args: dict) -> str:
|
|
91
|
+
"""Read a wiki article by relative path (e.g. 'api-server/services/UserService.md').
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
path: relative path under wiki/ dir
|
|
95
|
+
"""
|
|
96
|
+
rel = args.get("path", "").lstrip("/")
|
|
97
|
+
if not rel:
|
|
98
|
+
return "Error: 'path' argument required."
|
|
99
|
+
target = idx.wiki_dir / rel
|
|
100
|
+
# Security: ensure we stay inside wiki_dir
|
|
101
|
+
try:
|
|
102
|
+
target.resolve().relative_to(idx.wiki_dir.resolve())
|
|
103
|
+
except ValueError:
|
|
104
|
+
return "Error: path escapes wiki directory."
|
|
105
|
+
if not target.exists():
|
|
106
|
+
return f"Article not found: {rel}"
|
|
107
|
+
return target.read_text(encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tool_beacon_query(idx: BeaconIndex, args: dict) -> str:
|
|
111
|
+
"""Search nodes by label substring.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
term: search term (case-insensitive substring match)
|
|
115
|
+
limit: max results (default 20)
|
|
116
|
+
"""
|
|
117
|
+
if idx.G is None:
|
|
118
|
+
return "Graph not loaded."
|
|
119
|
+
term = args.get("term", "")
|
|
120
|
+
limit = int(args.get("limit", 20))
|
|
121
|
+
if not term:
|
|
122
|
+
return "Error: 'term' argument required."
|
|
123
|
+
|
|
124
|
+
node_ids = idx.find_node_ids(term)[:limit]
|
|
125
|
+
if not node_ids:
|
|
126
|
+
return f"No nodes matching '{term}'."
|
|
127
|
+
|
|
128
|
+
lines = [f"## Nodes matching '{term}' ({len(node_ids)} found)\n"]
|
|
129
|
+
for nid in node_ids:
|
|
130
|
+
s = idx.node_summary(nid)
|
|
131
|
+
lines.append(f"- **{s['label']}** ({s['type']}) — {s['project']} — `{s['source_file']}`")
|
|
132
|
+
|
|
133
|
+
# Immediate edges
|
|
134
|
+
out_edges = [
|
|
135
|
+
f" → {idx.G.nodes[t].get('label', t)} [{d.get('relation','')}]"
|
|
136
|
+
for _, t, d in idx.G.out_edges(nid, data=True)
|
|
137
|
+
][:5]
|
|
138
|
+
in_edges = [
|
|
139
|
+
f" ← {idx.G.nodes[s].get('label', s)} [{d.get('relation','')}]"
|
|
140
|
+
for s, _, d in idx.G.in_edges(nid, data=True)
|
|
141
|
+
][:5]
|
|
142
|
+
lines.extend(out_edges + in_edges)
|
|
143
|
+
|
|
144
|
+
return "\n".join(lines)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def tool_beacon_path(idx: BeaconIndex, args: dict) -> str:
|
|
148
|
+
"""Find shortest path between two nodes by label.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
source: source node label (substring match)
|
|
152
|
+
target: target node label (substring match)
|
|
153
|
+
"""
|
|
154
|
+
import networkx as nx
|
|
155
|
+
|
|
156
|
+
if idx.G is None:
|
|
157
|
+
return "Graph not loaded."
|
|
158
|
+
source = args.get("source", "")
|
|
159
|
+
target = args.get("target", "")
|
|
160
|
+
if not source or not target:
|
|
161
|
+
return "Error: 'source' and 'target' arguments required."
|
|
162
|
+
|
|
163
|
+
src_ids = idx.find_node_ids(source)
|
|
164
|
+
tgt_ids = idx.find_node_ids(target)
|
|
165
|
+
if not src_ids:
|
|
166
|
+
return f"No node matching source '{source}'."
|
|
167
|
+
if not tgt_ids:
|
|
168
|
+
return f"No node matching target '{target}'."
|
|
169
|
+
|
|
170
|
+
# Try all combinations, return first found
|
|
171
|
+
for sid in src_ids[:3]:
|
|
172
|
+
for tid in tgt_ids[:3]:
|
|
173
|
+
try:
|
|
174
|
+
path = nx.shortest_path(idx.G, sid, tid)
|
|
175
|
+
labels = [idx.G.nodes[n].get("label", n) for n in path]
|
|
176
|
+
edges = []
|
|
177
|
+
for i in range(len(path) - 1):
|
|
178
|
+
e = idx.G.edges[path[i], path[i + 1]]
|
|
179
|
+
edges.append(e.get("relation", "→"))
|
|
180
|
+
# Interleave labels and relations
|
|
181
|
+
parts = [labels[0]]
|
|
182
|
+
for rel, lbl in zip(edges, labels[1:]):
|
|
183
|
+
parts.append(f" --[{rel}]--> {lbl}")
|
|
184
|
+
return f"## Path ({len(path)} hops)\n" + "".join(parts)
|
|
185
|
+
except nx.NetworkXNoPath:
|
|
186
|
+
continue
|
|
187
|
+
except nx.NodeNotFound:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
return f"No path found between '{source}' and '{target}'."
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def tool_beacon_blast_radius(idx: BeaconIndex, args: dict) -> str:
|
|
194
|
+
"""Show blast radius: downstream + upstream neighbours of a node.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
node: node label (substring match)
|
|
198
|
+
depth: max traversal depth (default 2)
|
|
199
|
+
"""
|
|
200
|
+
import networkx as nx
|
|
201
|
+
|
|
202
|
+
if idx.G is None:
|
|
203
|
+
return "Graph not loaded."
|
|
204
|
+
node_name = args.get("node", "")
|
|
205
|
+
depth = int(args.get("depth", 2))
|
|
206
|
+
if not node_name:
|
|
207
|
+
return "Error: 'node' argument required."
|
|
208
|
+
|
|
209
|
+
ids = idx.find_node_ids(node_name)
|
|
210
|
+
if not ids:
|
|
211
|
+
return f"No node matching '{node_name}'."
|
|
212
|
+
|
|
213
|
+
nid = ids[0]
|
|
214
|
+
label = idx.G.nodes[nid].get("label", nid)
|
|
215
|
+
|
|
216
|
+
# Downstream (descendants)
|
|
217
|
+
downstream = set()
|
|
218
|
+
frontier = {nid}
|
|
219
|
+
for _ in range(depth):
|
|
220
|
+
next_frontier = set()
|
|
221
|
+
for n in frontier:
|
|
222
|
+
for succ in idx.G.successors(n):
|
|
223
|
+
if succ not in downstream and succ != nid:
|
|
224
|
+
downstream.add(succ)
|
|
225
|
+
next_frontier.add(succ)
|
|
226
|
+
frontier = next_frontier
|
|
227
|
+
|
|
228
|
+
# Upstream (immediate callers only — one level)
|
|
229
|
+
upstream = set(idx.G.predecessors(nid))
|
|
230
|
+
upstream.discard(nid)
|
|
231
|
+
|
|
232
|
+
lines = [f"## Blast Radius: {label}\n"]
|
|
233
|
+
lines.append(f"**Upstream callers** ({len(upstream)}):")
|
|
234
|
+
for u in sorted(upstream, key=lambda n: idx.G.nodes[n].get("label", n)):
|
|
235
|
+
s = idx.node_summary(u)
|
|
236
|
+
lines.append(f"- {s['label']} ({s['type']}) — {s['project']}")
|
|
237
|
+
|
|
238
|
+
lines.append(f"\n**Downstream affected** (depth={depth}, {len(downstream)} nodes):")
|
|
239
|
+
for d in sorted(downstream, key=lambda n: idx.G.nodes[n].get("label", n)):
|
|
240
|
+
s = idx.node_summary(d)
|
|
241
|
+
lines.append(f"- {s['label']} ({s['type']}) — {s['project']}")
|
|
242
|
+
|
|
243
|
+
if not upstream and not downstream:
|
|
244
|
+
lines.append("_No connections found._")
|
|
245
|
+
|
|
246
|
+
return "\n".join(lines)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def tool_beacon_routes(idx: BeaconIndex, args: dict) -> str:
|
|
250
|
+
"""List all routes, optionally filtered by project.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
project: filter by project name (optional)
|
|
254
|
+
limit: max results (default 50)
|
|
255
|
+
"""
|
|
256
|
+
if idx.G is None:
|
|
257
|
+
return "Graph not loaded."
|
|
258
|
+
project_filter = args.get("project", "").lower()
|
|
259
|
+
limit = int(args.get("limit", 50))
|
|
260
|
+
|
|
261
|
+
routes = []
|
|
262
|
+
for nid, data in idx.G.nodes(data=True):
|
|
263
|
+
if data.get("type") != "route":
|
|
264
|
+
continue
|
|
265
|
+
proj = data.get("project", "")
|
|
266
|
+
if project_filter and project_filter not in proj.lower():
|
|
267
|
+
continue
|
|
268
|
+
routes.append({
|
|
269
|
+
"method": data.get("method", ""),
|
|
270
|
+
"path": data.get("path", ""),
|
|
271
|
+
"handler": data.get("label", ""),
|
|
272
|
+
"project": proj,
|
|
273
|
+
"framework": data.get("framework", ""),
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
routes.sort(key=lambda r: (r["project"], r["method"], r["path"]))
|
|
277
|
+
routes = routes[:limit]
|
|
278
|
+
|
|
279
|
+
if not routes:
|
|
280
|
+
return "No routes found."
|
|
281
|
+
|
|
282
|
+
lines = [f"## Routes ({len(routes)})\n"]
|
|
283
|
+
lines.append(f"{'Method':<8} {'Path':<40} {'Handler':<30} {'Project'}")
|
|
284
|
+
lines.append("-" * 90)
|
|
285
|
+
for r in routes:
|
|
286
|
+
lines.append(
|
|
287
|
+
f"{r['method']:<8} {r['path']:<40} {r['handler']:<30} {r['project']}"
|
|
288
|
+
)
|
|
289
|
+
return "\n".join(lines)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def tool_beacon_services(idx: BeaconIndex, args: dict) -> str:
|
|
293
|
+
"""List all services/classes, optionally filtered by project.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
project: filter by project name (optional)
|
|
297
|
+
limit: max results (default 50)
|
|
298
|
+
"""
|
|
299
|
+
if idx.G is None:
|
|
300
|
+
return "Graph not loaded."
|
|
301
|
+
project_filter = args.get("project", "").lower()
|
|
302
|
+
limit = int(args.get("limit", 50))
|
|
303
|
+
|
|
304
|
+
services = []
|
|
305
|
+
for nid, data in idx.G.nodes(data=True):
|
|
306
|
+
if data.get("type") not in ("class", "service"):
|
|
307
|
+
continue
|
|
308
|
+
proj = data.get("project", "")
|
|
309
|
+
if project_filter and project_filter not in proj.lower():
|
|
310
|
+
continue
|
|
311
|
+
services.append({
|
|
312
|
+
"label": data.get("label", nid),
|
|
313
|
+
"type": data.get("type", ""),
|
|
314
|
+
"project": proj,
|
|
315
|
+
"framework": data.get("framework", ""),
|
|
316
|
+
"source_file": data.get("source_file", ""),
|
|
317
|
+
"annotations": data.get("annotations", []),
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
services.sort(key=lambda s: (s["project"], s["label"]))
|
|
321
|
+
services = services[:limit]
|
|
322
|
+
|
|
323
|
+
if not services:
|
|
324
|
+
return "No services found."
|
|
325
|
+
|
|
326
|
+
lines = [f"## Services ({len(services)})\n"]
|
|
327
|
+
for s in services:
|
|
328
|
+
annots = ", ".join(s["annotations"][:3]) if s["annotations"] else ""
|
|
329
|
+
suffix = f" [{annots}]" if annots else ""
|
|
330
|
+
lines.append(f"- **{s['label']}** ({s['project']}){suffix} `{s['source_file']}`")
|
|
331
|
+
return "\n".join(lines)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ── Tool registry ─────────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
TOOLS = {
|
|
337
|
+
"beacon_wiki_index": {
|
|
338
|
+
"fn": tool_beacon_wiki_index,
|
|
339
|
+
"description": "Return the global wiki index (short overview of all projects and node counts).",
|
|
340
|
+
"inputSchema": {
|
|
341
|
+
"type": "object",
|
|
342
|
+
"properties": {},
|
|
343
|
+
"required": [],
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
"beacon_wiki_article": {
|
|
347
|
+
"fn": tool_beacon_wiki_article,
|
|
348
|
+
"description": "Read a specific wiki article by its relative path under wiki/ (e.g. 'api-server/services/UserService.md').",
|
|
349
|
+
"inputSchema": {
|
|
350
|
+
"type": "object",
|
|
351
|
+
"properties": {
|
|
352
|
+
"path": {"type": "string", "description": "Relative path under wiki/ directory"},
|
|
353
|
+
},
|
|
354
|
+
"required": ["path"],
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
"beacon_query": {
|
|
358
|
+
"fn": tool_beacon_query,
|
|
359
|
+
"description": "Search graph nodes by label substring. Returns matching nodes with their edges.",
|
|
360
|
+
"inputSchema": {
|
|
361
|
+
"type": "object",
|
|
362
|
+
"properties": {
|
|
363
|
+
"term": {"type": "string", "description": "Search term (case-insensitive)"},
|
|
364
|
+
"limit": {"type": "integer", "description": "Max results (default 20)"},
|
|
365
|
+
},
|
|
366
|
+
"required": ["term"],
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
"beacon_path": {
|
|
370
|
+
"fn": tool_beacon_path,
|
|
371
|
+
"description": "Find the shortest dependency path between two nodes by label.",
|
|
372
|
+
"inputSchema": {
|
|
373
|
+
"type": "object",
|
|
374
|
+
"properties": {
|
|
375
|
+
"source": {"type": "string", "description": "Source node label"},
|
|
376
|
+
"target": {"type": "string", "description": "Target node label"},
|
|
377
|
+
},
|
|
378
|
+
"required": ["source", "target"],
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
"beacon_blast_radius": {
|
|
382
|
+
"fn": tool_beacon_blast_radius,
|
|
383
|
+
"description": "Show upstream callers and downstream affected nodes for a given node.",
|
|
384
|
+
"inputSchema": {
|
|
385
|
+
"type": "object",
|
|
386
|
+
"properties": {
|
|
387
|
+
"node": {"type": "string", "description": "Node label to analyze"},
|
|
388
|
+
"depth": {"type": "integer", "description": "Downstream traversal depth (default 2)"},
|
|
389
|
+
},
|
|
390
|
+
"required": ["node"],
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
"beacon_routes": {
|
|
394
|
+
"fn": tool_beacon_routes,
|
|
395
|
+
"description": "List all HTTP routes in the knowledge graph, optionally filtered by project.",
|
|
396
|
+
"inputSchema": {
|
|
397
|
+
"type": "object",
|
|
398
|
+
"properties": {
|
|
399
|
+
"project": {"type": "string", "description": "Filter by project name (optional)"},
|
|
400
|
+
"limit": {"type": "integer", "description": "Max results (default 50)"},
|
|
401
|
+
},
|
|
402
|
+
"required": [],
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
"beacon_services": {
|
|
406
|
+
"fn": tool_beacon_services,
|
|
407
|
+
"description": "List all service/class nodes in the knowledge graph, optionally filtered by project.",
|
|
408
|
+
"inputSchema": {
|
|
409
|
+
"type": "object",
|
|
410
|
+
"properties": {
|
|
411
|
+
"project": {"type": "string", "description": "Filter by project name (optional)"},
|
|
412
|
+
"limit": {"type": "integer", "description": "Max results (default 50)"},
|
|
413
|
+
},
|
|
414
|
+
"required": [],
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ── JSON-RPC 2.0 / MCP protocol ───────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
def _write(obj: dict) -> None:
|
|
423
|
+
"""Write a JSON-RPC response to stdout."""
|
|
424
|
+
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
425
|
+
sys.stdout.flush()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _error(req_id: Any, code: int, message: str) -> dict:
|
|
429
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _dispatch(idx: BeaconIndex, message: dict) -> dict | None:
|
|
433
|
+
"""Dispatch a single JSON-RPC 2.0 message; return response dict or None."""
|
|
434
|
+
req_id = message.get("id")
|
|
435
|
+
method = message.get("method", "")
|
|
436
|
+
params = message.get("params") or {}
|
|
437
|
+
|
|
438
|
+
# Notifications (no id) — no response required
|
|
439
|
+
if req_id is None:
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
if method == "initialize":
|
|
443
|
+
return {
|
|
444
|
+
"jsonrpc": "2.0",
|
|
445
|
+
"id": req_id,
|
|
446
|
+
"result": {
|
|
447
|
+
"protocolVersion": "2024-11-05",
|
|
448
|
+
"capabilities": {"tools": {}},
|
|
449
|
+
"serverInfo": {"name": "codebeacon", "version": "0.1.2"},
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if method == "tools/list":
|
|
454
|
+
tools_list = [
|
|
455
|
+
{
|
|
456
|
+
"name": name,
|
|
457
|
+
"description": info["description"],
|
|
458
|
+
"inputSchema": info["inputSchema"],
|
|
459
|
+
}
|
|
460
|
+
for name, info in TOOLS.items()
|
|
461
|
+
]
|
|
462
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": tools_list}}
|
|
463
|
+
|
|
464
|
+
if method == "tools/call":
|
|
465
|
+
tool_name = params.get("name", "")
|
|
466
|
+
tool_args = params.get("arguments") or {}
|
|
467
|
+
|
|
468
|
+
if tool_name not in TOOLS:
|
|
469
|
+
return _error(req_id, -32601, f"Unknown tool: {tool_name}")
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
result_text = TOOLS[tool_name]["fn"](idx, tool_args)
|
|
473
|
+
except Exception as exc:
|
|
474
|
+
print(f"[codebeacon-mcp] error in {tool_name}: {exc}", file=sys.stderr)
|
|
475
|
+
return _error(req_id, -32603, str(exc))
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
"jsonrpc": "2.0",
|
|
479
|
+
"id": req_id,
|
|
480
|
+
"result": {
|
|
481
|
+
"content": [{"type": "text", "text": result_text}],
|
|
482
|
+
"isError": False,
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
# Unknown method
|
|
487
|
+
return _error(req_id, -32601, f"Method not found: {method}")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def serve(beacon_dir: str | Path) -> None:
|
|
491
|
+
"""Start the stdio MCP server. Blocks until stdin is closed."""
|
|
492
|
+
beacon_dir = Path(beacon_dir)
|
|
493
|
+
idx = BeaconIndex(beacon_dir)
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
idx.load()
|
|
497
|
+
except FileNotFoundError as e:
|
|
498
|
+
print(f"[codebeacon-mcp] {e}", file=sys.stderr)
|
|
499
|
+
# Still start server so MCP client can connect — tools will explain the error
|
|
500
|
+
|
|
501
|
+
print(f"[codebeacon-mcp] serving from {beacon_dir}", file=sys.stderr)
|
|
502
|
+
|
|
503
|
+
for raw_line in sys.stdin:
|
|
504
|
+
raw_line = raw_line.strip()
|
|
505
|
+
if not raw_line:
|
|
506
|
+
continue
|
|
507
|
+
try:
|
|
508
|
+
message = json.loads(raw_line)
|
|
509
|
+
except json.JSONDecodeError as e:
|
|
510
|
+
_write({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": f"Parse error: {e}"}})
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
response = _dispatch(idx, message)
|
|
514
|
+
if response is not None:
|
|
515
|
+
_write(response)
|