archgraph-mcp 1.0.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.
- archgraph_mcp/__init__.py +3 -0
- archgraph_mcp/__main__.py +144 -0
- archgraph_mcp/enums.py +27 -0
- archgraph_mcp/graph/__init__.py +0 -0
- archgraph_mcp/graph/builder.py +142 -0
- archgraph_mcp/graph/query_engine.py +211 -0
- archgraph_mcp/logging_config.py +27 -0
- archgraph_mcp/models.py +56 -0
- archgraph_mcp/parser/__init__.py +0 -0
- archgraph_mcp/parser/base.py +61 -0
- archgraph_mcp/parser/java.py +182 -0
- archgraph_mcp/parser/kotlin.py +226 -0
- archgraph_mcp/parser/typescript.py +192 -0
- archgraph_mcp/semantic/__init__.py +1 -0
- archgraph_mcp/semantic/build.py +29 -0
- archgraph_mcp/semantic/embeddings.py +170 -0
- archgraph_mcp/semantic/vector_index.py +98 -0
- archgraph_mcp/server/__init__.py +0 -0
- archgraph_mcp/server/mcp_server.py +339 -0
- archgraph_mcp/storage/__init__.py +0 -0
- archgraph_mcp/storage/kuzu_store.py +246 -0
- archgraph_mcp/utils/__init__.py +0 -0
- archgraph_mcp/utils/scanner.py +89 -0
- archgraph_mcp-1.0.0.dist-info/METADATA +27 -0
- archgraph_mcp-1.0.0.dist-info/RECORD +29 -0
- archgraph_mcp-1.0.0.dist-info/WHEEL +5 -0
- archgraph_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- archgraph_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- archgraph_mcp-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""CLI entry-point for ArchGraph MCP.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m archgraph_mcp analyze ./my_repo
|
|
5
|
+
python -m archgraph_mcp serve ./my_repo
|
|
6
|
+
python -m archgraph_mcp serve ./my_repo --transport sse --port 3847
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal, cast
|
|
17
|
+
|
|
18
|
+
from archgraph_mcp.graph.builder import GraphBuilder
|
|
19
|
+
from archgraph_mcp.graph.query_engine import QueryEngine
|
|
20
|
+
from archgraph_mcp.logging_config import setup_logging
|
|
21
|
+
from archgraph_mcp.storage.kuzu_store import KuzuStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _default_store_path() -> str:
|
|
25
|
+
return os.environ.get("ARCHGRAPH_STORE") or "archgraph.kuzu"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> None:
|
|
29
|
+
setup_logging()
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="archgraph-mcp",
|
|
32
|
+
description="Build and query a knowledge graph of your codebase.",
|
|
33
|
+
)
|
|
34
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
35
|
+
|
|
36
|
+
# ---- analyze ----
|
|
37
|
+
p_analyze = sub.add_parser("analyze", help="Analyze a repository and print summary")
|
|
38
|
+
p_analyze.add_argument(
|
|
39
|
+
"repo", type=Path, nargs="?", default=Path(os.environ.get("REPO_PATH", ".")), help="Path to the repository root"
|
|
40
|
+
)
|
|
41
|
+
p_analyze.add_argument(
|
|
42
|
+
"--store",
|
|
43
|
+
type=str,
|
|
44
|
+
default=_default_store_path(),
|
|
45
|
+
help="Kuzu database path (default: archgraph.kuzu or ARCHGRAPH_STORE)",
|
|
46
|
+
)
|
|
47
|
+
p_analyze.add_argument(
|
|
48
|
+
"--semantic-index",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Build embedding vector index (requires pip install archgraph-mcp[semantic])",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# ---- serve ----
|
|
54
|
+
p_serve = sub.add_parser("serve", help="Start the MCP server")
|
|
55
|
+
p_serve.add_argument(
|
|
56
|
+
"repo", type=Path, nargs="?", default=Path(os.environ.get("REPO_PATH", ".")), help="Path to the repository root"
|
|
57
|
+
)
|
|
58
|
+
p_serve.add_argument(
|
|
59
|
+
"--store",
|
|
60
|
+
type=str,
|
|
61
|
+
default=_default_store_path(),
|
|
62
|
+
help="Kuzu database path (default: archgraph.kuzu or ARCHGRAPH_STORE)",
|
|
63
|
+
)
|
|
64
|
+
p_serve.add_argument(
|
|
65
|
+
"--transport",
|
|
66
|
+
type=str,
|
|
67
|
+
default=os.environ.get("MCP_TRANSPORT", "stdio"),
|
|
68
|
+
choices=["stdio", "sse", "streamable-http"],
|
|
69
|
+
help="MCP transport: stdio (local), sse, or streamable-http (remote)",
|
|
70
|
+
)
|
|
71
|
+
p_serve.add_argument(
|
|
72
|
+
"--port",
|
|
73
|
+
type=int,
|
|
74
|
+
default=int(os.environ.get("PORT", "3847")),
|
|
75
|
+
help="Port for HTTP transports (default 3847, or PORT env)",
|
|
76
|
+
)
|
|
77
|
+
p_serve.add_argument(
|
|
78
|
+
"--graph-ui",
|
|
79
|
+
action="store_true",
|
|
80
|
+
help="Expose GET /graph and /api/graph (HTTP transports only; not stdio)",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
args = parser.parse_args(argv)
|
|
84
|
+
|
|
85
|
+
if args.command == "analyze":
|
|
86
|
+
_run_analyze(args.repo, args.store, semantic_index=args.semantic_index)
|
|
87
|
+
elif args.command == "serve":
|
|
88
|
+
graph_ui_flag = args.graph_ui or os.environ.get("GRAPH_UI", "").strip().lower() in (
|
|
89
|
+
"1",
|
|
90
|
+
"true",
|
|
91
|
+
"yes",
|
|
92
|
+
)
|
|
93
|
+
_run_serve(args.repo, args.store, args.transport, args.port, graph_ui=graph_ui_flag)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run_analyze(repo: Path, store_path: str, *, semantic_index: bool = False) -> None:
|
|
97
|
+
if semantic_index:
|
|
98
|
+
os.environ["ARCHGRAPH_BUILD_SEMANTIC_INDEX"] = "1"
|
|
99
|
+
|
|
100
|
+
builder = GraphBuilder()
|
|
101
|
+
builder.build_from_repository(repo.resolve())
|
|
102
|
+
|
|
103
|
+
resolved = str(Path(store_path).resolve())
|
|
104
|
+
store = KuzuStore(resolved)
|
|
105
|
+
store.save_graph(builder.all_nodes(), builder.all_edges())
|
|
106
|
+
|
|
107
|
+
from archgraph_mcp.semantic.build import maybe_build_semantic_index
|
|
108
|
+
|
|
109
|
+
maybe_build_semantic_index(resolved, builder.all_nodes())
|
|
110
|
+
|
|
111
|
+
engine = QueryEngine(builder.graph, builder._node_index, fts_store=store)
|
|
112
|
+
summary = engine.architecture_summary()
|
|
113
|
+
store.close()
|
|
114
|
+
print(json.dumps(summary.model_dump(), indent=2))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_serve(
|
|
118
|
+
repo: Path,
|
|
119
|
+
store_path: str,
|
|
120
|
+
transport: str,
|
|
121
|
+
port: int,
|
|
122
|
+
*,
|
|
123
|
+
graph_ui: bool = False,
|
|
124
|
+
) -> None:
|
|
125
|
+
# FastMCP binds ``self.settings.port`` from values captured when ``mcp`` is constructed
|
|
126
|
+
# (import time). Set these *before* importing ``mcp_server`` so ``--port`` is honored.
|
|
127
|
+
os.environ["PORT"] = str(port)
|
|
128
|
+
os.environ["FASTMCP_PORT"] = str(port)
|
|
129
|
+
|
|
130
|
+
from archgraph_mcp.server.mcp_server import initialize, mcp
|
|
131
|
+
|
|
132
|
+
log = logging.getLogger("archgraph_mcp")
|
|
133
|
+
graph_ui_effective = graph_ui and transport != "stdio"
|
|
134
|
+
if graph_ui and transport == "stdio":
|
|
135
|
+
log.warning(
|
|
136
|
+
"--graph-ui is ignored for stdio transport (no HTTP server). "
|
|
137
|
+
"Use --transport sse or streamable-http to enable /graph.",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Build or load the graph
|
|
141
|
+
initialize(str(repo), store_path, graph_ui=graph_ui_effective)
|
|
142
|
+
|
|
143
|
+
# Let FastMCP handle server startup
|
|
144
|
+
mcp.run(transport=cast(Literal["stdio", "sse", "streamable-http"], transport))
|
archgraph_mcp/enums.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Deterministic enums for graph node and edge types."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NodeType(StrEnum):
|
|
7
|
+
"""Types of nodes in the code knowledge graph."""
|
|
8
|
+
|
|
9
|
+
REPOSITORY = "repository"
|
|
10
|
+
MODULE = "module"
|
|
11
|
+
FILE = "file"
|
|
12
|
+
CLASS = "class"
|
|
13
|
+
FUNCTION = "function"
|
|
14
|
+
API = "api"
|
|
15
|
+
DATABASE = "database"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EdgeType(StrEnum):
|
|
19
|
+
"""Types of edges (relationships) in the code knowledge graph."""
|
|
20
|
+
|
|
21
|
+
IMPORTS = "imports"
|
|
22
|
+
CALLS = "calls"
|
|
23
|
+
DEFINES = "defines"
|
|
24
|
+
READS_TABLE = "reads_table"
|
|
25
|
+
WRITES_TABLE = "writes_table"
|
|
26
|
+
EXPOSES_API = "exposes_api"
|
|
27
|
+
DEPENDS_ON = "depends_on"
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Graph builder — converts parser output into a NetworkX directed graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import networkx as nx
|
|
10
|
+
|
|
11
|
+
from archgraph_mcp.enums import EdgeType, NodeType
|
|
12
|
+
from archgraph_mcp.models import Edge, Node
|
|
13
|
+
from archgraph_mcp.parser.base import BaseParser, ParseResult
|
|
14
|
+
from archgraph_mcp.parser.java import JavaParser
|
|
15
|
+
from archgraph_mcp.parser.kotlin import KotlinParser
|
|
16
|
+
from archgraph_mcp.parser.typescript import TypeScriptParser
|
|
17
|
+
from archgraph_mcp.utils.scanner import (
|
|
18
|
+
detect_language,
|
|
19
|
+
scan_repository,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("archgraph_mcp.graph.builder")
|
|
23
|
+
|
|
24
|
+
# Registry: language string → parser instance
|
|
25
|
+
_PARSERS: dict[str, BaseParser] = {
|
|
26
|
+
"typescript": TypeScriptParser(),
|
|
27
|
+
"java": JavaParser(),
|
|
28
|
+
"kotlin": KotlinParser(),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GraphBuilder:
|
|
33
|
+
"""Incrementally builds a NetworkX DiGraph from a repository."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.graph: nx.DiGraph = nx.DiGraph()
|
|
37
|
+
self._node_index: dict[str, Node] = {}
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Public API
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def build_from_repository(self, repo_path: Path) -> nx.DiGraph:
|
|
44
|
+
"""Scan, parse, and build the graph for an entire repository."""
|
|
45
|
+
start = time.perf_counter()
|
|
46
|
+
|
|
47
|
+
# Repository root node
|
|
48
|
+
repo_id = f"repository:{repo_path.name}"
|
|
49
|
+
self._add_node(
|
|
50
|
+
Node(
|
|
51
|
+
id=repo_id,
|
|
52
|
+
type=NodeType.REPOSITORY,
|
|
53
|
+
name=repo_path.name,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
for source_file in scan_repository(repo_path):
|
|
58
|
+
self._process_file(source_file, repo_id)
|
|
59
|
+
|
|
60
|
+
elapsed = time.perf_counter() - start
|
|
61
|
+
logger.info(
|
|
62
|
+
"Graph built: %d nodes, %d edges in %.2fs",
|
|
63
|
+
self.graph.number_of_nodes(),
|
|
64
|
+
self.graph.number_of_edges(),
|
|
65
|
+
elapsed,
|
|
66
|
+
)
|
|
67
|
+
return self.graph
|
|
68
|
+
|
|
69
|
+
def add_parse_result(self, result: ParseResult) -> None:
|
|
70
|
+
"""Merge a ``ParseResult`` into the graph (for incremental use)."""
|
|
71
|
+
for node in result.nodes:
|
|
72
|
+
self._add_node(node)
|
|
73
|
+
for edge in result.edges:
|
|
74
|
+
self._add_edge(edge)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Accessors
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def get_node(self, node_id: str) -> Node | None:
|
|
81
|
+
"""Return the parsed ``Node`` for *node_id*, or ``None`` if unknown."""
|
|
82
|
+
return self._node_index.get(node_id)
|
|
83
|
+
|
|
84
|
+
def all_nodes(self) -> list[Node]:
|
|
85
|
+
"""Return every node currently in the index (order not guaranteed)."""
|
|
86
|
+
return list(self._node_index.values())
|
|
87
|
+
|
|
88
|
+
def all_edges(self) -> list[Edge]:
|
|
89
|
+
"""Return all edges in the graph, with types taken from edge attributes."""
|
|
90
|
+
edges: list[Edge] = []
|
|
91
|
+
for u, v, data in self.graph.edges(data=True):
|
|
92
|
+
edges.append(Edge(source=u, target=v, type=data.get("type", EdgeType.DEPENDS_ON)))
|
|
93
|
+
return edges
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# Internals
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def _process_file(self, path: Path, repo_id: str) -> None:
|
|
100
|
+
"""Parse *path* if supported and wire its file node to *repo_id*."""
|
|
101
|
+
language = detect_language(path)
|
|
102
|
+
if language is None:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
parser = _PARSERS.get(language)
|
|
106
|
+
if parser is None:
|
|
107
|
+
logger.debug("No parser for language %s, skipping %s", language, path)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
source = path.read_bytes()
|
|
112
|
+
except (OSError, PermissionError):
|
|
113
|
+
logger.warning("Cannot read file: %s", path)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
result = parser.parse_file(path, source)
|
|
117
|
+
self.add_parse_result(result)
|
|
118
|
+
|
|
119
|
+
# Link file node to repository
|
|
120
|
+
for node in result.nodes:
|
|
121
|
+
if node.type == NodeType.FILE:
|
|
122
|
+
self._add_edge(
|
|
123
|
+
Edge(
|
|
124
|
+
source=repo_id,
|
|
125
|
+
target=node.id,
|
|
126
|
+
type=EdgeType.DEPENDS_ON,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _add_node(self, node: Node) -> None:
|
|
131
|
+
"""Insert *node* into the index and NetworkX graph (first write wins)."""
|
|
132
|
+
if node.id not in self._node_index:
|
|
133
|
+
self._node_index[node.id] = node
|
|
134
|
+
self.graph.add_node(node.id, **node.model_dump())
|
|
135
|
+
|
|
136
|
+
def _add_edge(self, edge: Edge) -> None:
|
|
137
|
+
"""Add *edge*; create stub endpoints if missing so NetworkX stays consistent."""
|
|
138
|
+
# Ensure both endpoints exist as graph nodes (at minimum as stubs)
|
|
139
|
+
for nid in (edge.source, edge.target):
|
|
140
|
+
if nid not in self.graph:
|
|
141
|
+
self.graph.add_node(nid)
|
|
142
|
+
self.graph.add_edge(edge.source, edge.target, type=edge.type)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Query engine — graph traversal algorithms on the code knowledge graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
import networkx as nx
|
|
11
|
+
|
|
12
|
+
from archgraph_mcp.enums import EdgeType, NodeType
|
|
13
|
+
from archgraph_mcp.models import (
|
|
14
|
+
ArchitectureSummary,
|
|
15
|
+
Edge,
|
|
16
|
+
GraphQuery,
|
|
17
|
+
ImpactResult,
|
|
18
|
+
Node,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("archgraph_mcp.graph.query_engine")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _SupportsFtsSearch(Protocol):
|
|
25
|
+
def search_nodes_fts(
|
|
26
|
+
self,
|
|
27
|
+
query: str,
|
|
28
|
+
node_type: NodeType | None,
|
|
29
|
+
limit: int,
|
|
30
|
+
node_by_id: dict[str, Node],
|
|
31
|
+
) -> list[Node]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class QueryEngine:
|
|
35
|
+
"""Executes queries against a built NetworkX graph."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
graph: nx.DiGraph,
|
|
40
|
+
node_index: dict[str, Node] | None = None,
|
|
41
|
+
*,
|
|
42
|
+
fts_store: _SupportsFtsSearch | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._g = graph
|
|
45
|
+
self._nodes = node_index or {}
|
|
46
|
+
self._fts_store = fts_store
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def node_index(self) -> dict[str, Node]:
|
|
50
|
+
"""Map of node id → :class:`Node` (for tools that need lookup after search)."""
|
|
51
|
+
return self._nodes
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# MCP tool: search_nodes
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def search_nodes(
|
|
58
|
+
self,
|
|
59
|
+
query: str,
|
|
60
|
+
node_type: NodeType | None = None,
|
|
61
|
+
limit: int = 50,
|
|
62
|
+
) -> list[Node]:
|
|
63
|
+
"""FTS-ranked search when a store is configured; else substring on name/id."""
|
|
64
|
+
start = time.perf_counter()
|
|
65
|
+
if self._fts_store is not None:
|
|
66
|
+
fts_matches = self._fts_store.search_nodes_fts(query, node_type, limit, self._nodes)
|
|
67
|
+
if fts_matches:
|
|
68
|
+
elapsed = time.perf_counter() - start
|
|
69
|
+
logger.info("search_nodes(%r) fts → %d results in %.3fs", query, len(fts_matches), elapsed)
|
|
70
|
+
return fts_matches
|
|
71
|
+
|
|
72
|
+
query_lower = query.lower()
|
|
73
|
+
matches: list[Node] = []
|
|
74
|
+
for node in self._nodes.values():
|
|
75
|
+
if node_type and node.type != node_type:
|
|
76
|
+
continue
|
|
77
|
+
if query_lower in node.name.lower() or query_lower in node.id.lower():
|
|
78
|
+
matches.append(node)
|
|
79
|
+
if len(matches) >= limit:
|
|
80
|
+
break
|
|
81
|
+
logger.info("search_nodes(%r) → %d results in %.3fs", query, len(matches), time.perf_counter() - start)
|
|
82
|
+
return matches
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# MCP tool: trace_dependencies (downstream)
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def trace_dependencies(self, gq: GraphQuery) -> list[Node]:
|
|
89
|
+
"""BFS downstream — what does *node_id* depend on?"""
|
|
90
|
+
return self._bfs(gq.node_id, gq.max_depth, successors=True)
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# MCP tool: trace_dependents (upstream)
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def trace_dependents(self, gq: GraphQuery) -> list[Node]:
|
|
97
|
+
"""BFS upstream — what depends on *node_id*?"""
|
|
98
|
+
return self._bfs(gq.node_id, gq.max_depth, successors=False)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# MCP tool: impact_analysis
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def impact_analysis(self, gq: GraphQuery) -> ImpactResult:
|
|
105
|
+
"""BFS upstream to determine everything affected by a change to *node_id*."""
|
|
106
|
+
start = time.perf_counter()
|
|
107
|
+
affected = self._bfs(gq.node_id, gq.max_depth, successors=False)
|
|
108
|
+
affected_edges: list[Edge] = []
|
|
109
|
+
|
|
110
|
+
affected_ids = {n.id for n in affected} | {gq.node_id}
|
|
111
|
+
for u, v, data in self._g.edges(data=True):
|
|
112
|
+
if u in affected_ids and v in affected_ids:
|
|
113
|
+
affected_edges.append(
|
|
114
|
+
Edge(
|
|
115
|
+
source=u,
|
|
116
|
+
target=v,
|
|
117
|
+
type=data.get("type", EdgeType.DEPENDS_ON),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
logger.info("impact_analysis(%s) → %d nodes in %.3fs", gq.node_id, len(affected), time.perf_counter() - start)
|
|
122
|
+
return ImpactResult(
|
|
123
|
+
source_node=gq.node_id,
|
|
124
|
+
affected_nodes=affected,
|
|
125
|
+
affected_edges=affected_edges,
|
|
126
|
+
depth=gq.max_depth,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# MCP tool: trace_path
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def trace_path(self, source_id: str, target_id: str) -> list[Node]:
|
|
134
|
+
"""Shortest path between *source_id* and *target_id* (undirected graph).
|
|
135
|
+
|
|
136
|
+
Only nodes present in the internal index are included; intermediate
|
|
137
|
+
graph ids without metadata are skipped.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
path_ids = nx.shortest_path(self._g.to_undirected(), source_id, target_id)
|
|
141
|
+
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
|
142
|
+
return []
|
|
143
|
+
return [self._nodes[nid] for nid in path_ids if nid in self._nodes]
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# MCP tool: architecture_summary
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def architecture_summary(self) -> ArchitectureSummary:
|
|
150
|
+
"""Return a compact high-level summary of the graph."""
|
|
151
|
+
node_counts: dict[str, int] = {}
|
|
152
|
+
languages: set[str] = set()
|
|
153
|
+
files_analyzed = 0
|
|
154
|
+
|
|
155
|
+
for node in self._nodes.values():
|
|
156
|
+
key = node.type.value
|
|
157
|
+
node_counts[key] = node_counts.get(key, 0) + 1
|
|
158
|
+
if node.type == NodeType.FILE:
|
|
159
|
+
files_analyzed += 1
|
|
160
|
+
if node.language:
|
|
161
|
+
languages.add(node.language)
|
|
162
|
+
|
|
163
|
+
edge_counts: dict[str, int] = {}
|
|
164
|
+
for _, _, data in self._g.edges(data=True):
|
|
165
|
+
etype = data.get("type", EdgeType.DEPENDS_ON)
|
|
166
|
+
key = etype.value if isinstance(etype, EdgeType) else str(etype)
|
|
167
|
+
edge_counts[key] = edge_counts.get(key, 0) + 1
|
|
168
|
+
|
|
169
|
+
return ArchitectureSummary(
|
|
170
|
+
total_nodes=self._g.number_of_nodes(),
|
|
171
|
+
total_edges=self._g.number_of_edges(),
|
|
172
|
+
node_counts=node_counts,
|
|
173
|
+
edge_counts=edge_counts,
|
|
174
|
+
files_analyzed=files_analyzed,
|
|
175
|
+
languages=sorted(languages),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Internal helpers
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _bfs(
|
|
183
|
+
self,
|
|
184
|
+
start: str,
|
|
185
|
+
max_depth: int,
|
|
186
|
+
*,
|
|
187
|
+
successors: bool,
|
|
188
|
+
) -> list[Node]:
|
|
189
|
+
"""Generic BFS. *successors=True* traverses downstream, else upstream."""
|
|
190
|
+
if start not in self._g:
|
|
191
|
+
return []
|
|
192
|
+
|
|
193
|
+
visited: set[str] = set()
|
|
194
|
+
queue: deque[tuple[str, int]] = deque([(start, 0)])
|
|
195
|
+
result: list[Node] = []
|
|
196
|
+
|
|
197
|
+
while queue:
|
|
198
|
+
current, depth = queue.popleft()
|
|
199
|
+
if current in visited or depth > max_depth:
|
|
200
|
+
continue
|
|
201
|
+
visited.add(current)
|
|
202
|
+
|
|
203
|
+
if current != start and current in self._nodes:
|
|
204
|
+
result.append(self._nodes[current])
|
|
205
|
+
|
|
206
|
+
neighbors = self._g.successors(current) if successors else self._g.predecessors(current)
|
|
207
|
+
for neighbor in neighbors:
|
|
208
|
+
if neighbor not in visited:
|
|
209
|
+
queue.append((neighbor, depth + 1))
|
|
210
|
+
|
|
211
|
+
return result
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Structured logging configuration for ArchGraph MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_logging(level: int = logging.INFO) -> logging.Logger:
|
|
10
|
+
"""Configure and return the root logger for archgraph_mcp."""
|
|
11
|
+
logger = logging.getLogger("archgraph_mcp")
|
|
12
|
+
if logger.handlers:
|
|
13
|
+
return logger
|
|
14
|
+
|
|
15
|
+
logger.setLevel(level)
|
|
16
|
+
|
|
17
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
18
|
+
handler.setLevel(level)
|
|
19
|
+
|
|
20
|
+
formatter = logging.Formatter(
|
|
21
|
+
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
22
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
23
|
+
)
|
|
24
|
+
handler.setFormatter(formatter)
|
|
25
|
+
logger.addHandler(handler)
|
|
26
|
+
|
|
27
|
+
return logger
|
archgraph_mcp/models.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Pydantic data models for structured serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from archgraph_mcp.enums import EdgeType, NodeType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Node(BaseModel):
|
|
13
|
+
"""A single node in the code knowledge graph."""
|
|
14
|
+
|
|
15
|
+
id: str = Field(..., description="Unique identifier, e.g. 'function:auth.loginUser'")
|
|
16
|
+
type: NodeType
|
|
17
|
+
name: str
|
|
18
|
+
file: str | None = None
|
|
19
|
+
language: str | None = None
|
|
20
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Edge(BaseModel):
|
|
24
|
+
"""A directed relationship between two nodes."""
|
|
25
|
+
|
|
26
|
+
source: str = Field(..., description="Source node id")
|
|
27
|
+
target: str = Field(..., description="Target node id")
|
|
28
|
+
type: EdgeType
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GraphQuery(BaseModel):
|
|
32
|
+
"""Parameters for a graph query."""
|
|
33
|
+
|
|
34
|
+
node_id: str
|
|
35
|
+
max_depth: int = Field(default=10, ge=1, le=100)
|
|
36
|
+
edge_types: list[EdgeType] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ImpactResult(BaseModel):
|
|
40
|
+
"""Result of an impact analysis query."""
|
|
41
|
+
|
|
42
|
+
source_node: str
|
|
43
|
+
affected_nodes: list[Node] = Field(default_factory=list)
|
|
44
|
+
affected_edges: list[Edge] = Field(default_factory=list)
|
|
45
|
+
depth: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ArchitectureSummary(BaseModel):
|
|
49
|
+
"""High-level architecture summary of the graph."""
|
|
50
|
+
|
|
51
|
+
total_nodes: int = 0
|
|
52
|
+
total_edges: int = 0
|
|
53
|
+
node_counts: dict[str, int] = Field(default_factory=dict)
|
|
54
|
+
edge_counts: dict[str, int] = Field(default_factory=dict)
|
|
55
|
+
files_analyzed: int = 0
|
|
56
|
+
languages: list[str] = Field(default_factory=list)
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Abstract base parser interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from tree_sitter import Node as TSNode
|
|
10
|
+
|
|
11
|
+
from archgraph_mcp.models import Edge, Node
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("archgraph_mcp.parser")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def utf8_node_text(node: TSNode) -> str:
|
|
17
|
+
"""Return UTF-8 text for a tree-sitter node, or empty string if absent."""
|
|
18
|
+
raw = node.text
|
|
19
|
+
if raw is None:
|
|
20
|
+
return ""
|
|
21
|
+
return raw.decode("utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ParseResult:
|
|
25
|
+
"""Container for nodes and edges extracted from a single file."""
|
|
26
|
+
|
|
27
|
+
__slots__ = ("nodes", "edges")
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
"""Create empty node and edge lists."""
|
|
31
|
+
self.nodes: list[Node] = []
|
|
32
|
+
self.edges: list[Edge] = []
|
|
33
|
+
|
|
34
|
+
def add_node(self, node: Node) -> None:
|
|
35
|
+
"""Append *node* to the result list."""
|
|
36
|
+
self.nodes.append(node)
|
|
37
|
+
|
|
38
|
+
def add_edge(self, edge: Edge) -> None:
|
|
39
|
+
"""Append *edge* to the result list."""
|
|
40
|
+
self.edges.append(edge)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BaseParser(ABC):
|
|
44
|
+
"""Interface every language parser must implement."""
|
|
45
|
+
|
|
46
|
+
language: str # e.g. "typescript"
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def parse_file(self, path: Path, source: bytes) -> ParseResult:
|
|
50
|
+
"""Parse *source* and return extracted nodes and edges.
|
|
51
|
+
|
|
52
|
+
Implementations must catch internal errors and return a
|
|
53
|
+
partial ``ParseResult`` rather than raising.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# ------- helpers available to subclasses ------
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _make_id(kind: str, *parts: str) -> str:
|
|
60
|
+
"""Build a deterministic node id, e.g. ``function:auth.login``."""
|
|
61
|
+
return f"{kind}:{'.'.join(parts)}"
|