context-router-cli 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.
cli/commands/graph.py ADDED
@@ -0,0 +1,338 @@
1
+ """context-router graph command — generates an interactive D3.js symbol graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import webbrowser
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ graph_app = typer.Typer(help="Generate an interactive graph visualization.")
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # D3.js HTML template
16
+ # ---------------------------------------------------------------------------
17
+
18
+ _HTML_TEMPLATE = '''<!DOCTYPE html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="UTF-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
23
+ <title>context-router — Symbol Graph</title>
24
+ <style>
25
+ * { box-sizing: border-box; margin: 0; padding: 0; }
26
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
27
+ background: #0d1117; color: #e6edf3; height: 100vh; overflow: hidden; }
28
+ #toolbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px;
29
+ background: #161b22; border-bottom: 1px solid #30363d; z-index: 10; }
30
+ #toolbar h1 { font-size: 14px; font-weight: 600; color: #58a6ff; white-space: nowrap; }
31
+ #search { flex: 1; max-width: 320px; padding: 5px 10px; border-radius: 6px;
32
+ border: 1px solid #30363d; background: #0d1117; color: #e6edf3;
33
+ font-size: 13px; outline: none; }
34
+ #search:focus { border-color: #58a6ff; }
35
+ #stats { font-size: 12px; color: #8b949e; white-space: nowrap; }
36
+ #legend { display: flex; gap: 10px; flex-wrap: wrap; }
37
+ .legend-item { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #8b949e; }
38
+ .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
39
+ #canvas { width: 100%; height: calc(100vh - 49px); }
40
+ #panel { position: fixed; right: 0; top: 49px; width: 320px; height: calc(100vh - 49px);
41
+ background: #161b22; border-left: 1px solid #30363d; padding: 16px;
42
+ overflow-y: auto; transform: translateX(100%); transition: transform 0.2s;
43
+ z-index: 5; }
44
+ #panel.open { transform: translateX(0); }
45
+ #panel h2 { font-size: 13px; font-weight: 600; color: #58a6ff; margin-bottom: 8px;
46
+ word-break: break-all; }
47
+ #panel .meta { font-size: 11px; color: #8b949e; margin-bottom: 4px; }
48
+ #panel .sig { font-size: 11px; font-family: monospace; background: #0d1117; padding: 8px;
49
+ border-radius: 4px; margin-top: 8px; white-space: pre-wrap;
50
+ word-break: break-all; border: 1px solid #30363d; }
51
+ #panel .close-btn { position: absolute; top: 12px; right: 12px; background: none;
52
+ border: none; color: #8b949e; cursor: pointer; font-size: 16px; }
53
+ .node { cursor: pointer; }
54
+ .node circle { stroke-width: 1.5px; }
55
+ .node text { font-size: 10px; fill: #8b949e; pointer-events: none; }
56
+ .link { stroke-opacity: 0.3; }
57
+ .node.highlighted circle { stroke: #f0e040 !important; stroke-width: 2.5px; }
58
+ .node.dimmed { opacity: 0.15; }
59
+ .link.dimmed { opacity: 0.05; }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <div id="toolbar">
64
+ <h1>⬡ context-router graph</h1>
65
+ <input id="search" type="text" placeholder="Search nodes…" autocomplete="off">
66
+ <div id="stats"></div>
67
+ <div id="legend"></div>
68
+ </div>
69
+ <svg id="canvas"></svg>
70
+ <div id="panel">
71
+ <button class="close-btn" onclick="closePanel()">✕</button>
72
+ <h2 id="p-title"></h2>
73
+ <div class="meta" id="p-kind"></div>
74
+ <div class="meta" id="p-file"></div>
75
+ <div class="sig" id="p-sig"></div>
76
+ </div>
77
+ <script src="https://d3js.org/d3.v7.min.js"></script>
78
+ <script>
79
+ const GRAPH = __GRAPH_DATA__;
80
+
81
+ const KIND_COLOR = {
82
+ "function": "#3fb950",
83
+ "class": "#58a6ff",
84
+ "method": "#79c0ff",
85
+ "k8s_resource": "#ffa657",
86
+ "helm_chart": "#ff7b72",
87
+ "github_actions_workflow": "#d2a8ff",
88
+ "github_actions_job": "#c0a6ff",
89
+ "import": "#8b949e",
90
+ "file": "#8b949e",
91
+ };
92
+ const DEFAULT_COLOR = "#8b949e";
93
+
94
+ function kindColor(k) { return KIND_COLOR[k] || DEFAULT_COLOR; }
95
+
96
+ // Build legend
97
+ const kinds = [...new Set(GRAPH.nodes.map(n => n.kind))].sort();
98
+ const legend = document.getElementById("legend");
99
+ kinds.forEach(k => {
100
+ const el = document.createElement("div");
101
+ el.className = "legend-item";
102
+ el.innerHTML = `<div class="legend-dot" style="background:${kindColor(k)}"></div>${k}`;
103
+ legend.appendChild(el);
104
+ });
105
+
106
+ document.getElementById("stats").textContent =
107
+ `${GRAPH.nodes.length} nodes · ${GRAPH.links.length} edges`;
108
+
109
+ const svg = d3.select("#canvas");
110
+ const container = svg.append("g");
111
+ const W = () => svg.node().clientWidth;
112
+ const H = () => svg.node().clientHeight;
113
+
114
+ // Zoom
115
+ svg.call(d3.zoom().scaleExtent([0.05, 4])
116
+ .on("zoom", e => container.attr("transform", e.transform)));
117
+
118
+ // Degree map for node sizing
119
+ const degMap = {};
120
+ GRAPH.nodes.forEach(n => { degMap[n.id] = 0; });
121
+ GRAPH.links.forEach(l => {
122
+ degMap[l.source] = (degMap[l.source] || 0) + 1;
123
+ degMap[l.target] = (degMap[l.target] || 0) + 1;
124
+ });
125
+ const maxDeg = Math.max(...Object.values(degMap), 1);
126
+ const nodeRadius = d => 4 + (degMap[d.id] || 0) / maxDeg * 14;
127
+
128
+ // Simulation
129
+ const sim = d3.forceSimulation(GRAPH.nodes)
130
+ .force("link", d3.forceLink(GRAPH.links).id(d => d.id).distance(60).strength(0.4))
131
+ .force("charge", d3.forceManyBody().strength(-120))
132
+ .force("center", d3.forceCenter(W() / 2, H() / 2))
133
+ .force("collision", d3.forceCollide().radius(d => nodeRadius(d) + 3));
134
+
135
+ // Links
136
+ const link = container.append("g")
137
+ .selectAll("line")
138
+ .data(GRAPH.links)
139
+ .join("line")
140
+ .attr("class", "link")
141
+ .attr("stroke", "#30363d")
142
+ .attr("stroke-width", 1);
143
+
144
+ // Nodes
145
+ const node = container.append("g")
146
+ .selectAll(".node")
147
+ .data(GRAPH.nodes)
148
+ .join("g")
149
+ .attr("class", "node")
150
+ .call(d3.drag()
151
+ .on("start", (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
152
+ .on("drag", (e, d) => { d.fx=e.x; d.fy=e.y; })
153
+ .on("end", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }))
154
+ .on("click", (e, d) => { e.stopPropagation(); showPanel(d); highlight(d); });
155
+
156
+ node.append("circle")
157
+ .attr("r", nodeRadius)
158
+ .attr("fill", d => kindColor(d.kind))
159
+ .attr("stroke", d => d3.color(kindColor(d.kind)).darker(0.8));
160
+
161
+ node.append("text")
162
+ .attr("dx", d => nodeRadius(d) + 3)
163
+ .attr("dy", "0.35em")
164
+ .text(d => d.name.length > 20 ? d.name.slice(0, 18) + "…" : d.name);
165
+
166
+ sim.on("tick", () => {
167
+ link.attr("x1", d => d.source.x).attr("y1", d => d.source.y)
168
+ .attr("x2", d => d.target.x).attr("y2", d => d.target.y);
169
+ node.attr("transform", d => `translate(${d.x},${d.y})`);
170
+ });
171
+
172
+ svg.on("click", () => { closePanel(); clearHighlight(); });
173
+
174
+ // Panel
175
+ function showPanel(d) {
176
+ document.getElementById("p-title").textContent = d.name;
177
+ document.getElementById("p-kind").textContent = `kind: ${d.kind}`;
178
+ document.getElementById("p-file").textContent = d.file ? d.file.split("/").slice(-2).join("/") : "";
179
+ document.getElementById("p-sig").textContent = [d.signature, d.docstring].filter(Boolean).join("\\n\\n");
180
+ document.getElementById("panel").classList.add("open");
181
+ }
182
+ function closePanel() { document.getElementById("panel").classList.remove("open"); }
183
+
184
+ // Highlight
185
+ const neighborSet = new Set();
186
+ function highlight(d) {
187
+ clearHighlight();
188
+ neighborSet.clear();
189
+ neighborSet.add(d.id);
190
+ GRAPH.links.forEach(l => {
191
+ const s = typeof l.source === "object" ? l.source.id : l.source;
192
+ const t = typeof l.target === "object" ? l.target.id : l.target;
193
+ if (s === d.id) neighborSet.add(t);
194
+ if (t === d.id) neighborSet.add(s);
195
+ });
196
+ node.classed("highlighted", n => n.id === d.id)
197
+ .classed("dimmed", n => !neighborSet.has(n.id));
198
+ link.classed("dimmed", l => {
199
+ const s = typeof l.source === "object" ? l.source.id : l.source;
200
+ const t = typeof l.target === "object" ? l.target.id : l.target;
201
+ return !neighborSet.has(s) || !neighborSet.has(t);
202
+ });
203
+ }
204
+ function clearHighlight() {
205
+ node.classed("highlighted", false).classed("dimmed", false);
206
+ link.classed("dimmed", false);
207
+ }
208
+
209
+ // Search
210
+ document.getElementById("search").addEventListener("input", e => {
211
+ const q = e.target.value.toLowerCase().trim();
212
+ if (!q) { clearHighlight(); return; }
213
+ const matches = new Set(GRAPH.nodes.filter(n =>
214
+ n.name.toLowerCase().includes(q) ||
215
+ (n.file || "").toLowerCase().includes(q) ||
216
+ (n.kind || "").toLowerCase().includes(q)
217
+ ).map(n => n.id));
218
+ node.classed("highlighted", n => matches.has(n.id))
219
+ .classed("dimmed", n => !matches.has(n.id));
220
+ link.classed("dimmed", true);
221
+ });
222
+ </script>
223
+ </body>
224
+ </html>'''
225
+
226
+
227
+ @graph_app.callback(invoke_without_command=True)
228
+ def graph(
229
+ project_root: Annotated[
230
+ str,
231
+ typer.Option("--project-root", help="Project root. Auto-detected when omitted."),
232
+ ] = "",
233
+ output: Annotated[
234
+ str,
235
+ typer.Option("--output", "-o", help="Output HTML file path. Default: graph.html"),
236
+ ] = "graph.html",
237
+ open_browser: Annotated[
238
+ bool,
239
+ typer.Option("--open/--no-open", help="Open in browser after generating."),
240
+ ] = False,
241
+ json_only: Annotated[
242
+ bool,
243
+ typer.Option("--json", help="Output graph JSON instead of HTML."),
244
+ ] = False,
245
+ ) -> None:
246
+ """Generate an interactive D3.js force-directed symbol graph as a standalone HTML file."""
247
+ from storage_sqlite.database import Database
248
+ from storage_sqlite.repositories import EdgeRepository, SymbolRepository
249
+
250
+ root = Path(project_root).resolve() if project_root else _find_project_root()
251
+ db_path = root / ".context-router" / "context-router.db"
252
+
253
+ if not db_path.exists():
254
+ typer.echo(
255
+ "No index found. Run 'context-router init' and 'context-router index' first.",
256
+ err=True,
257
+ )
258
+ raise typer.Exit(1)
259
+
260
+ with Database(db_path) as db:
261
+ sym_repo = SymbolRepository(db.connection)
262
+ edge_repo = EdgeRepository(db.connection)
263
+ symbols = sym_repo.get_all("default")
264
+
265
+ # Build node list
266
+ sym_id_map: dict[int, str] = {} # rowid → uuid-like id
267
+ nodes = []
268
+ for sym in symbols:
269
+ sym_id = sym_repo.get_id(
270
+ "default", str(sym.file), sym.name, sym.kind
271
+ )
272
+ if sym_id is None:
273
+ continue
274
+ node_id = f"sym_{sym_id}"
275
+ sym_id_map[sym_id] = node_id
276
+ nodes.append({
277
+ "id": node_id,
278
+ "name": sym.name,
279
+ "kind": sym.kind,
280
+ "file": str(sym.file),
281
+ "signature": sym.signature,
282
+ "docstring": sym.docstring,
283
+ "line": sym.line_start,
284
+ })
285
+
286
+ # Build edge list from raw DB
287
+ rows = db.connection.execute(
288
+ """
289
+ SELECT e.from_symbol_id, e.to_symbol_id, e.edge_type, e.weight
290
+ FROM edges e
291
+ WHERE e.repo = 'default'
292
+ """
293
+ ).fetchall()
294
+ links = []
295
+ for row in rows:
296
+ src = sym_id_map.get(row["from_symbol_id"])
297
+ tgt = sym_id_map.get(row["to_symbol_id"])
298
+ if src and tgt and src != tgt:
299
+ links.append({
300
+ "source": src,
301
+ "target": tgt,
302
+ "type": row["edge_type"],
303
+ "weight": row["weight"],
304
+ })
305
+
306
+ graph_data = {"nodes": nodes, "links": links}
307
+
308
+ if json_only:
309
+ typer.echo(json.dumps(graph_data, indent=2))
310
+ return
311
+
312
+ # Embed into HTML
313
+ graph_json = json.dumps(graph_data)
314
+ html_content = _HTML_TEMPLATE.replace("__GRAPH_DATA__", graph_json)
315
+
316
+ out_path = Path(output) if Path(output).is_absolute() else root / output
317
+ out_path.write_text(html_content, encoding="utf-8")
318
+
319
+ if not json_only:
320
+ typer.echo(f"Graph: {out_path} ({len(nodes)} nodes, {len(links)} edges)")
321
+
322
+ if open_browser:
323
+ webbrowser.open(f"file://{out_path}")
324
+
325
+
326
+ def _find_project_root() -> Path:
327
+ """Walk up from cwd to find .context-router/."""
328
+ from pathlib import Path as P
329
+ current = P.cwd().resolve()
330
+ while True:
331
+ if (current / ".context-router").is_dir():
332
+ return current
333
+ parent = current.parent
334
+ if parent == current:
335
+ raise typer.BadParameter(
336
+ "No .context-router/ found. Run 'context-router init' first."
337
+ )
338
+ current = parent
cli/commands/index.py ADDED
@@ -0,0 +1,148 @@
1
+ """context-router index command — scans and indexes a repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from contracts.config import load_config
13
+ from core.plugin_loader import PluginLoader
14
+ from graph_index.git_diff import GitDiffParser
15
+ from graph_index.indexer import Indexer
16
+ from storage_sqlite.database import Database
17
+
18
+ index_app = typer.Typer(help="Scan and index a repository's symbols and dependencies.")
19
+
20
+
21
+ @index_app.callback(invoke_without_command=True)
22
+ def index(
23
+ project_root: Annotated[
24
+ Path,
25
+ typer.Option(
26
+ "--project-root",
27
+ "-p",
28
+ help="Root of the project to index. Defaults to current directory.",
29
+ ),
30
+ ] = Path("."),
31
+ since: Annotated[
32
+ str | None,
33
+ typer.Option(
34
+ "--since",
35
+ help="Git ref for incremental index (e.g. HEAD~1, a1b2c3d).",
36
+ ),
37
+ ] = None,
38
+ repo_name: Annotated[
39
+ str,
40
+ typer.Option("--repo", help="Logical repository name stored with symbols."),
41
+ ] = "default",
42
+ json_output: Annotated[
43
+ bool,
44
+ typer.Option("--json", help="Output result as JSON."),
45
+ ] = False,
46
+ ) -> None:
47
+ """Index source files into the context-router database.
48
+
49
+ Scans PROJECT_ROOT for source files, runs language analyzers, and writes
50
+ symbols and dependency edges to the SQLite database.
51
+
52
+ For incremental indexing pass --since <git-ref> to re-index only files
53
+ that changed since that ref.
54
+
55
+ Exit codes:
56
+ 0 — success (even with per-file errors)
57
+ 1 — configuration / setup error
58
+ 2 — unexpected internal error
59
+ """
60
+ try:
61
+ project_root = project_root.resolve()
62
+ config_dir = project_root / ".context-router"
63
+
64
+ try:
65
+ config = load_config(project_root)
66
+ except Exception as exc: # noqa: BLE001
67
+ _err(f"Failed to load config: {exc}", json_output, exit_code=1)
68
+ return
69
+
70
+ db_path = config_dir / "context-router.db"
71
+ if not db_path.exists():
72
+ _err(
73
+ f"Database not found at {db_path}. Run 'context-router init' first.",
74
+ json_output,
75
+ exit_code=1,
76
+ )
77
+ return
78
+
79
+ db = Database(db_path)
80
+ db.initialize()
81
+
82
+ try:
83
+ plugin_loader = PluginLoader()
84
+ plugin_loader.discover()
85
+
86
+ indexer = Indexer(db, plugin_loader, config, repo_name)
87
+
88
+ if since is not None:
89
+ try:
90
+ diff = GitDiffParser.from_git(project_root, since)
91
+ changed = [
92
+ cf.path if cf.path.is_absolute() else project_root / cf.path
93
+ for cf in diff
94
+ if cf.status != "deleted"
95
+ ]
96
+ # Also include deleted files so the indexer can clean them up
97
+ deleted = [
98
+ cf.path if cf.path.is_absolute() else project_root / cf.path
99
+ for cf in diff
100
+ if cf.status == "deleted"
101
+ ]
102
+ result = indexer.run_incremental(changed + deleted)
103
+ except Exception as exc: # noqa: BLE001
104
+ _err(f"Git diff failed: {exc}", json_output, exit_code=1)
105
+ return
106
+ else:
107
+ result = indexer.run(project_root)
108
+ finally:
109
+ db.close()
110
+
111
+ if json_output:
112
+ typer.echo(
113
+ json.dumps(
114
+ {
115
+ "files_scanned": result.files_scanned,
116
+ "symbols_written": result.symbols_written,
117
+ "edges_written": result.edges_written,
118
+ "duration_seconds": round(result.duration_seconds, 3),
119
+ "errors": result.errors,
120
+ }
121
+ )
122
+ )
123
+ else:
124
+ typer.echo(
125
+ f"Indexed {result.files_scanned} files — "
126
+ f"{result.symbols_written} symbols, {result.edges_written} edges "
127
+ f"({result.duration_seconds:.2f}s)"
128
+ )
129
+ if result.errors:
130
+ typer.echo(f" {len(result.errors)} file(s) had errors:", err=True)
131
+ for err in result.errors[:10]:
132
+ typer.echo(f" {err}", err=True)
133
+ if len(result.errors) > 10:
134
+ typer.echo(
135
+ f" ... and {len(result.errors) - 10} more", err=True
136
+ )
137
+
138
+ except Exception as exc: # noqa: BLE001
139
+ _err(f"Unexpected error: {exc}", json_output, exit_code=2)
140
+
141
+
142
+ def _err(message: str, json_output: bool, exit_code: int) -> None:
143
+ """Print an error to stderr and exit with the given code."""
144
+ if json_output:
145
+ typer.echo(json.dumps({"status": "error", "message": message}), err=True)
146
+ else:
147
+ typer.echo(f"Error: {message}", err=True)
148
+ raise typer.Exit(code=exit_code)
cli/commands/init.py ADDED
@@ -0,0 +1,79 @@
1
+ """context-router init command — bootstraps a project's .context-router directory and SQLite DB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from contracts.config import DEFAULT_CONFIG_YAML
13
+ from storage_sqlite.database import Database
14
+
15
+ init_app = typer.Typer(help="Initialize context-router in the current project.")
16
+
17
+
18
+ @init_app.callback(invoke_without_command=True)
19
+ def init(
20
+ project_root: Annotated[
21
+ Path,
22
+ typer.Option(
23
+ "--project-root",
24
+ "-p",
25
+ help="Root of the project to initialize. Defaults to current directory.",
26
+ exists=False,
27
+ ),
28
+ ] = Path("."),
29
+ json_output: Annotated[
30
+ bool,
31
+ typer.Option("--json", help="Output result as JSON."),
32
+ ] = False,
33
+ ) -> None:
34
+ """Bootstrap .context-router/ and the SQLite database in PROJECT_ROOT.
35
+
36
+ Creates:
37
+ - .context-router/config.yaml (default config, if not already present)
38
+ - .context-router/context-router.db (SQLite database with full schema)
39
+
40
+ Exit codes:
41
+ 0 — success
42
+ 1 — user error (bad path, permission denied)
43
+ 2 — internal error (unexpected failure)
44
+ """
45
+ try:
46
+ project_root = project_root.resolve()
47
+ config_dir = project_root / ".context-router"
48
+
49
+ try:
50
+ config_dir.mkdir(parents=True, exist_ok=True)
51
+ except PermissionError as exc:
52
+ _err(f"Cannot create {config_dir}: {exc}", json_output, exit_code=1)
53
+ return
54
+
55
+ config_yaml = config_dir / "config.yaml"
56
+ if not config_yaml.exists():
57
+ config_yaml.write_text(DEFAULT_CONFIG_YAML, encoding="utf-8")
58
+
59
+ db_path = config_dir / "context-router.db"
60
+ db = Database(db_path)
61
+ db.initialize()
62
+ db.close()
63
+
64
+ if json_output:
65
+ typer.echo(json.dumps({"status": "ok", "db_path": str(db_path)}))
66
+ else:
67
+ typer.echo(f"Initialized context-router in {config_dir}")
68
+
69
+ except Exception as exc: # noqa: BLE001
70
+ _err(f"Unexpected error: {exc}", json_output, exit_code=2)
71
+
72
+
73
+ def _err(message: str, json_output: bool, exit_code: int) -> None:
74
+ """Print an error to stderr and exit with the given code."""
75
+ if json_output:
76
+ typer.echo(json.dumps({"status": "error", "message": message}), err=True)
77
+ else:
78
+ typer.echo(f"Error: {message}", err=True)
79
+ raise typer.Exit(code=exit_code)
cli/commands/mcp.py ADDED
@@ -0,0 +1,30 @@
1
+ """context-router mcp command — starts the local MCP server over stdio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ mcp_app = typer.Typer(help="Start the context-router MCP server over stdio transport.")
8
+
9
+
10
+ @mcp_app.callback(invoke_without_command=True)
11
+ def mcp() -> None:
12
+ """Start the context-router MCP server over stdio transport.
13
+
14
+ Reads JSON-RPC 2.0 requests from stdin and writes responses to stdout.
15
+ This is the entry point for MCP-compatible AI coding agents (Claude Code,
16
+ Copilot, Codex) to discover and call context-router tools.
17
+
18
+ Example configuration for Claude Code (.mcp.json)::
19
+
20
+ {
21
+ "mcpServers": {
22
+ "context-router": {
23
+ "command": "context-router",
24
+ "args": ["mcp"]
25
+ }
26
+ }
27
+ }
28
+ """
29
+ from mcp_server.main import main as _mcp_main
30
+ _mcp_main()