hanzo-mcp 0.5.1__py3-none-any.whl → 0.5.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.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/__init__.py +1 -1
- hanzo_mcp/tools/__init__.py +135 -4
- hanzo_mcp/tools/common/base.py +7 -2
- hanzo_mcp/tools/common/stats.py +261 -0
- hanzo_mcp/tools/common/tool_disable.py +144 -0
- hanzo_mcp/tools/common/tool_enable.py +182 -0
- hanzo_mcp/tools/common/tool_list.py +263 -0
- hanzo_mcp/tools/database/__init__.py +71 -0
- hanzo_mcp/tools/database/database_manager.py +246 -0
- hanzo_mcp/tools/database/graph_add.py +257 -0
- hanzo_mcp/tools/database/graph_query.py +536 -0
- hanzo_mcp/tools/database/graph_remove.py +267 -0
- hanzo_mcp/tools/database/graph_search.py +348 -0
- hanzo_mcp/tools/database/graph_stats.py +345 -0
- hanzo_mcp/tools/database/sql_query.py +229 -0
- hanzo_mcp/tools/database/sql_search.py +296 -0
- hanzo_mcp/tools/database/sql_stats.py +254 -0
- hanzo_mcp/tools/editor/__init__.py +11 -0
- hanzo_mcp/tools/editor/neovim_command.py +272 -0
- hanzo_mcp/tools/editor/neovim_edit.py +290 -0
- hanzo_mcp/tools/editor/neovim_session.py +356 -0
- hanzo_mcp/tools/filesystem/__init__.py +15 -5
- hanzo_mcp/tools/filesystem/{unified_search.py → batch_search.py} +254 -131
- hanzo_mcp/tools/filesystem/find_files.py +348 -0
- hanzo_mcp/tools/filesystem/git_search.py +505 -0
- hanzo_mcp/tools/llm/__init__.py +27 -0
- hanzo_mcp/tools/llm/consensus_tool.py +351 -0
- hanzo_mcp/tools/llm/llm_manage.py +413 -0
- hanzo_mcp/tools/llm/llm_tool.py +346 -0
- hanzo_mcp/tools/llm/provider_tools.py +412 -0
- hanzo_mcp/tools/mcp/__init__.py +11 -0
- hanzo_mcp/tools/mcp/mcp_add.py +263 -0
- hanzo_mcp/tools/mcp/mcp_remove.py +127 -0
- hanzo_mcp/tools/mcp/mcp_stats.py +165 -0
- hanzo_mcp/tools/shell/__init__.py +27 -7
- hanzo_mcp/tools/shell/logs.py +265 -0
- hanzo_mcp/tools/shell/npx.py +194 -0
- hanzo_mcp/tools/shell/npx_background.py +254 -0
- hanzo_mcp/tools/shell/pkill.py +262 -0
- hanzo_mcp/tools/shell/processes.py +279 -0
- hanzo_mcp/tools/shell/run_background.py +326 -0
- hanzo_mcp/tools/shell/uvx.py +187 -0
- hanzo_mcp/tools/shell/uvx_background.py +249 -0
- hanzo_mcp/tools/vector/__init__.py +5 -0
- hanzo_mcp/tools/vector/git_ingester.py +3 -0
- hanzo_mcp/tools/vector/index_tool.py +358 -0
- hanzo_mcp/tools/vector/infinity_store.py +98 -0
- hanzo_mcp/tools/vector/vector_search.py +11 -6
- {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.5.2.dist-info}/METADATA +1 -1
- {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.5.2.dist-info}/RECORD +54 -16
- {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.5.2.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.5.2.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.5.2.dist-info}/licenses/LICENSE +0 -0
- {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.5.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Graph query tool for querying the graph database."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from typing import Annotated, Optional, TypedDict, Unpack, final, override, List, Dict, Any
|
|
6
|
+
from collections import deque
|
|
7
|
+
|
|
8
|
+
from fastmcp import Context as MCPContext
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
12
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
13
|
+
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
14
|
+
from hanzo_mcp.tools.database.database_manager import DatabaseManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Query = Annotated[
|
|
18
|
+
str,
|
|
19
|
+
Field(
|
|
20
|
+
description="Query type: neighbors, path, subgraph, connected, ancestors, descendants",
|
|
21
|
+
min_length=1,
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
NodeId = Annotated[
|
|
26
|
+
Optional[str],
|
|
27
|
+
Field(
|
|
28
|
+
description="Starting node ID",
|
|
29
|
+
default=None,
|
|
30
|
+
),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
TargetId = Annotated[
|
|
34
|
+
Optional[str],
|
|
35
|
+
Field(
|
|
36
|
+
description="Target node ID (for path queries)",
|
|
37
|
+
default=None,
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
Depth = Annotated[
|
|
42
|
+
int,
|
|
43
|
+
Field(
|
|
44
|
+
description="Maximum depth for traversal",
|
|
45
|
+
default=2,
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
Relationship = Annotated[
|
|
50
|
+
Optional[str],
|
|
51
|
+
Field(
|
|
52
|
+
description="Filter by relationship type",
|
|
53
|
+
default=None,
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
NodeType = Annotated[
|
|
58
|
+
Optional[str],
|
|
59
|
+
Field(
|
|
60
|
+
description="Filter by node type",
|
|
61
|
+
default=None,
|
|
62
|
+
),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
Direction = Annotated[
|
|
66
|
+
str,
|
|
67
|
+
Field(
|
|
68
|
+
description="Direction: both, incoming, outgoing",
|
|
69
|
+
default="both",
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
ProjectPath = Annotated[
|
|
74
|
+
Optional[str],
|
|
75
|
+
Field(
|
|
76
|
+
description="Project path (defaults to current directory)",
|
|
77
|
+
default=None,
|
|
78
|
+
),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GraphQueryParams(TypedDict, total=False):
|
|
83
|
+
"""Parameters for graph query tool."""
|
|
84
|
+
|
|
85
|
+
query: str
|
|
86
|
+
node_id: Optional[str]
|
|
87
|
+
target_id: Optional[str]
|
|
88
|
+
depth: int
|
|
89
|
+
relationship: Optional[str]
|
|
90
|
+
node_type: Optional[str]
|
|
91
|
+
direction: str
|
|
92
|
+
project_path: Optional[str]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@final
|
|
96
|
+
class GraphQueryTool(BaseTool):
|
|
97
|
+
"""Tool for querying the graph database."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
|
100
|
+
"""Initialize the graph query tool.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
permission_manager: Permission manager for access control
|
|
104
|
+
db_manager: Database manager instance
|
|
105
|
+
"""
|
|
106
|
+
self.permission_manager = permission_manager
|
|
107
|
+
self.db_manager = db_manager
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
@override
|
|
111
|
+
def name(self) -> str:
|
|
112
|
+
"""Get the tool name."""
|
|
113
|
+
return "graph_query"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
@override
|
|
117
|
+
def description(self) -> str:
|
|
118
|
+
"""Get the tool description."""
|
|
119
|
+
return """Query the project's graph database for relationships and patterns.
|
|
120
|
+
|
|
121
|
+
Query types:
|
|
122
|
+
- neighbors: Find direct neighbors of a node
|
|
123
|
+
- path: Find shortest path between two nodes
|
|
124
|
+
- subgraph: Get subgraph around a node up to depth
|
|
125
|
+
- connected: Find all nodes connected to a node
|
|
126
|
+
- ancestors: Find nodes that point TO this node
|
|
127
|
+
- descendants: Find nodes that this node points TO
|
|
128
|
+
|
|
129
|
+
Options:
|
|
130
|
+
- --depth: Max traversal depth (default 2)
|
|
131
|
+
- --relationship: Filter by edge type
|
|
132
|
+
- --node-type: Filter by node type
|
|
133
|
+
- --direction: both, incoming, outgoing
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
- graph_query --query neighbors --node-id "main.py"
|
|
137
|
+
- graph_query --query path --node-id "main.py" --target-id "utils.py"
|
|
138
|
+
- graph_query --query subgraph --node-id "MyClass" --depth 3
|
|
139
|
+
- graph_query --query ancestors --node-id "error_handler" --relationship "calls"
|
|
140
|
+
- graph_query --query descendants --node-id "BaseClass" --relationship "inherits"
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
async def call(
|
|
145
|
+
self,
|
|
146
|
+
ctx: MCPContext,
|
|
147
|
+
**params: Unpack[GraphQueryParams],
|
|
148
|
+
) -> str:
|
|
149
|
+
"""Execute graph query.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
ctx: MCP context
|
|
153
|
+
**params: Tool parameters
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Query results
|
|
157
|
+
"""
|
|
158
|
+
tool_ctx = create_tool_context(ctx)
|
|
159
|
+
await tool_ctx.set_tool_info(self.name)
|
|
160
|
+
|
|
161
|
+
# Extract parameters
|
|
162
|
+
query = params.get("query")
|
|
163
|
+
if not query:
|
|
164
|
+
return "Error: query is required"
|
|
165
|
+
|
|
166
|
+
node_id = params.get("node_id")
|
|
167
|
+
target_id = params.get("target_id")
|
|
168
|
+
depth = params.get("depth", 2)
|
|
169
|
+
relationship = params.get("relationship")
|
|
170
|
+
node_type = params.get("node_type")
|
|
171
|
+
direction = params.get("direction", "both")
|
|
172
|
+
project_path = params.get("project_path")
|
|
173
|
+
|
|
174
|
+
# Validate query type
|
|
175
|
+
valid_queries = ["neighbors", "path", "subgraph", "connected", "ancestors", "descendants"]
|
|
176
|
+
if query not in valid_queries:
|
|
177
|
+
return f"Error: Invalid query '{query}'. Must be one of: {', '.join(valid_queries)}"
|
|
178
|
+
|
|
179
|
+
# Validate required parameters
|
|
180
|
+
if query in ["neighbors", "subgraph", "connected", "ancestors", "descendants"] and not node_id:
|
|
181
|
+
return f"Error: node_id is required for '{query}' query"
|
|
182
|
+
|
|
183
|
+
if query == "path" and (not node_id or not target_id):
|
|
184
|
+
return "Error: Both node_id and target_id are required for 'path' query"
|
|
185
|
+
|
|
186
|
+
# Get project database
|
|
187
|
+
try:
|
|
188
|
+
if project_path:
|
|
189
|
+
project_db = self.db_manager.get_project_db(project_path)
|
|
190
|
+
else:
|
|
191
|
+
import os
|
|
192
|
+
project_db = self.db_manager.get_project_for_path(os.getcwd())
|
|
193
|
+
|
|
194
|
+
if not project_db:
|
|
195
|
+
return "Error: Could not find project database"
|
|
196
|
+
|
|
197
|
+
except PermissionError as e:
|
|
198
|
+
return str(e)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
return f"Error accessing project database: {str(e)}"
|
|
201
|
+
|
|
202
|
+
# Get graph connection
|
|
203
|
+
graph_conn = project_db.get_graph_connection()
|
|
204
|
+
|
|
205
|
+
await tool_ctx.info(f"Executing {query} query")
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
if query == "neighbors":
|
|
209
|
+
return self._query_neighbors(graph_conn, node_id, relationship, node_type, direction)
|
|
210
|
+
elif query == "path":
|
|
211
|
+
return self._query_path(graph_conn, node_id, target_id, relationship)
|
|
212
|
+
elif query == "subgraph":
|
|
213
|
+
return self._query_subgraph(graph_conn, node_id, depth, relationship, node_type, direction)
|
|
214
|
+
elif query == "connected":
|
|
215
|
+
return self._query_connected(graph_conn, node_id, relationship, node_type, direction)
|
|
216
|
+
elif query == "ancestors":
|
|
217
|
+
return self._query_ancestors(graph_conn, node_id, depth, relationship, node_type)
|
|
218
|
+
elif query == "descendants":
|
|
219
|
+
return self._query_descendants(graph_conn, node_id, depth, relationship, node_type)
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
await tool_ctx.error(f"Failed to execute query: {str(e)}")
|
|
223
|
+
return f"Error executing query: {str(e)}"
|
|
224
|
+
|
|
225
|
+
def _query_neighbors(self, conn: sqlite3.Connection, node_id: str,
|
|
226
|
+
relationship: Optional[str], node_type: Optional[str],
|
|
227
|
+
direction: str) -> str:
|
|
228
|
+
"""Get direct neighbors of a node."""
|
|
229
|
+
cursor = conn.cursor()
|
|
230
|
+
|
|
231
|
+
# Check if node exists
|
|
232
|
+
cursor.execute("SELECT type, properties FROM nodes WHERE id = ?", (node_id,))
|
|
233
|
+
node_info = cursor.fetchone()
|
|
234
|
+
if not node_info:
|
|
235
|
+
return f"Error: Node '{node_id}' not found"
|
|
236
|
+
|
|
237
|
+
neighbors = []
|
|
238
|
+
|
|
239
|
+
# Get outgoing edges
|
|
240
|
+
if direction in ["both", "outgoing"]:
|
|
241
|
+
query = """SELECT e.target, e.relationship, e.weight, n.type, n.properties
|
|
242
|
+
FROM edges e JOIN nodes n ON e.target = n.id
|
|
243
|
+
WHERE e.source = ?"""
|
|
244
|
+
params = [node_id]
|
|
245
|
+
|
|
246
|
+
if relationship:
|
|
247
|
+
query += " AND e.relationship = ?"
|
|
248
|
+
params.append(relationship)
|
|
249
|
+
if node_type:
|
|
250
|
+
query += " AND n.type = ?"
|
|
251
|
+
params.append(node_type)
|
|
252
|
+
|
|
253
|
+
cursor.execute(query, params)
|
|
254
|
+
for row in cursor.fetchall():
|
|
255
|
+
neighbors.append({
|
|
256
|
+
"direction": "outgoing",
|
|
257
|
+
"node_id": row[0],
|
|
258
|
+
"relationship": row[1],
|
|
259
|
+
"weight": row[2],
|
|
260
|
+
"node_type": row[3],
|
|
261
|
+
"properties": json.loads(row[4]) if row[4] else {}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
# Get incoming edges
|
|
265
|
+
if direction in ["both", "incoming"]:
|
|
266
|
+
query = """SELECT e.source, e.relationship, e.weight, n.type, n.properties
|
|
267
|
+
FROM edges e JOIN nodes n ON e.source = n.id
|
|
268
|
+
WHERE e.target = ?"""
|
|
269
|
+
params = [node_id]
|
|
270
|
+
|
|
271
|
+
if relationship:
|
|
272
|
+
query += " AND e.relationship = ?"
|
|
273
|
+
params.append(relationship)
|
|
274
|
+
if node_type:
|
|
275
|
+
query += " AND n.type = ?"
|
|
276
|
+
params.append(node_type)
|
|
277
|
+
|
|
278
|
+
cursor.execute(query, params)
|
|
279
|
+
for row in cursor.fetchall():
|
|
280
|
+
neighbors.append({
|
|
281
|
+
"direction": "incoming",
|
|
282
|
+
"node_id": row[0],
|
|
283
|
+
"relationship": row[1],
|
|
284
|
+
"weight": row[2],
|
|
285
|
+
"node_type": row[3],
|
|
286
|
+
"properties": json.loads(row[4]) if row[4] else {}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
if not neighbors:
|
|
290
|
+
return f"No neighbors found for node '{node_id}'"
|
|
291
|
+
|
|
292
|
+
# Format output
|
|
293
|
+
output = [f"Neighbors of '{node_id}' ({node_info[0]}):\n"]
|
|
294
|
+
for n in neighbors:
|
|
295
|
+
arrow = "<--" if n["direction"] == "incoming" else "-->"
|
|
296
|
+
output.append(f" {node_id} {arrow}[{n['relationship']}]--> {n['node_id']} ({n['node_type']})")
|
|
297
|
+
if n["properties"]:
|
|
298
|
+
output.append(f" Properties: {json.dumps(n['properties'], indent=6)[:100]}")
|
|
299
|
+
|
|
300
|
+
output.append(f"\nTotal neighbors: {len(neighbors)}")
|
|
301
|
+
return "\n".join(output)
|
|
302
|
+
|
|
303
|
+
def _query_path(self, conn: sqlite3.Connection, start: str, end: str,
|
|
304
|
+
relationship: Optional[str]) -> str:
|
|
305
|
+
"""Find shortest path between two nodes using BFS."""
|
|
306
|
+
cursor = conn.cursor()
|
|
307
|
+
|
|
308
|
+
# Check if nodes exist
|
|
309
|
+
cursor.execute("SELECT id FROM nodes WHERE id IN (?, ?)", (start, end))
|
|
310
|
+
existing = [row[0] for row in cursor.fetchall()]
|
|
311
|
+
if start not in existing:
|
|
312
|
+
return f"Error: Start node '{start}' not found"
|
|
313
|
+
if end not in existing:
|
|
314
|
+
return f"Error: End node '{end}' not found"
|
|
315
|
+
|
|
316
|
+
# BFS to find shortest path
|
|
317
|
+
queue = deque([(start, [start])])
|
|
318
|
+
visited = {start}
|
|
319
|
+
|
|
320
|
+
while queue:
|
|
321
|
+
current, path = queue.popleft()
|
|
322
|
+
|
|
323
|
+
if current == end:
|
|
324
|
+
# Found path, get edge details
|
|
325
|
+
output = [f"Shortest path from '{start}' to '{end}':\n"]
|
|
326
|
+
|
|
327
|
+
for i in range(len(path) - 1):
|
|
328
|
+
src, tgt = path[i], path[i + 1]
|
|
329
|
+
|
|
330
|
+
# Get edge details
|
|
331
|
+
query = "SELECT relationship, weight FROM edges WHERE source = ? AND target = ?"
|
|
332
|
+
cursor.execute(query, (src, tgt))
|
|
333
|
+
edge = cursor.fetchone()
|
|
334
|
+
|
|
335
|
+
if edge:
|
|
336
|
+
output.append(f" {src} --[{edge[0]}]--> {tgt}")
|
|
337
|
+
else:
|
|
338
|
+
output.append(f" {src} --> {tgt}")
|
|
339
|
+
|
|
340
|
+
output.append(f"\nPath length: {len(path) - 1} edge(s)")
|
|
341
|
+
return "\n".join(output)
|
|
342
|
+
|
|
343
|
+
# Get neighbors
|
|
344
|
+
query = "SELECT target FROM edges WHERE source = ?"
|
|
345
|
+
params = [current]
|
|
346
|
+
if relationship:
|
|
347
|
+
query += " AND relationship = ?"
|
|
348
|
+
params.append(relationship)
|
|
349
|
+
|
|
350
|
+
cursor.execute(query, params)
|
|
351
|
+
|
|
352
|
+
for (neighbor,) in cursor.fetchall():
|
|
353
|
+
if neighbor not in visited:
|
|
354
|
+
visited.add(neighbor)
|
|
355
|
+
queue.append((neighbor, path + [neighbor]))
|
|
356
|
+
|
|
357
|
+
return f"No path found from '{start}' to '{end}'" + (f" with relationship '{relationship}'" if relationship else "")
|
|
358
|
+
|
|
359
|
+
def _query_subgraph(self, conn: sqlite3.Connection, node_id: str, depth: int,
|
|
360
|
+
relationship: Optional[str], node_type: Optional[str],
|
|
361
|
+
direction: str) -> str:
|
|
362
|
+
"""Get subgraph around a node up to specified depth."""
|
|
363
|
+
cursor = conn.cursor()
|
|
364
|
+
|
|
365
|
+
# Check if node exists
|
|
366
|
+
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node_id,))
|
|
367
|
+
if not cursor.fetchone():
|
|
368
|
+
return f"Error: Node '{node_id}' not found"
|
|
369
|
+
|
|
370
|
+
# BFS to collect nodes and edges
|
|
371
|
+
nodes = {node_id: 0} # node_id -> depth
|
|
372
|
+
edges = set() # (source, target, relationship)
|
|
373
|
+
queue = deque([(node_id, 0)])
|
|
374
|
+
|
|
375
|
+
while queue:
|
|
376
|
+
current, current_depth = queue.popleft()
|
|
377
|
+
|
|
378
|
+
if current_depth >= depth:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
# Get edges based on direction
|
|
382
|
+
if direction in ["both", "outgoing"]:
|
|
383
|
+
query = """SELECT e.target, e.relationship, n.type
|
|
384
|
+
FROM edges e JOIN nodes n ON e.target = n.id
|
|
385
|
+
WHERE e.source = ?"""
|
|
386
|
+
params = [current]
|
|
387
|
+
|
|
388
|
+
if relationship:
|
|
389
|
+
query += " AND e.relationship = ?"
|
|
390
|
+
params.append(relationship)
|
|
391
|
+
if node_type:
|
|
392
|
+
query += " AND n.type = ?"
|
|
393
|
+
params.append(node_type)
|
|
394
|
+
|
|
395
|
+
cursor.execute(query, params)
|
|
396
|
+
|
|
397
|
+
for target, rel, _ in cursor.fetchall():
|
|
398
|
+
edges.add((current, target, rel))
|
|
399
|
+
if target not in nodes or nodes[target] > current_depth + 1:
|
|
400
|
+
nodes[target] = current_depth + 1
|
|
401
|
+
queue.append((target, current_depth + 1))
|
|
402
|
+
|
|
403
|
+
if direction in ["both", "incoming"]:
|
|
404
|
+
query = """SELECT e.source, e.relationship, n.type
|
|
405
|
+
FROM edges e JOIN nodes n ON e.source = n.id
|
|
406
|
+
WHERE e.target = ?"""
|
|
407
|
+
params = [current]
|
|
408
|
+
|
|
409
|
+
if relationship:
|
|
410
|
+
query += " AND e.relationship = ?"
|
|
411
|
+
params.append(relationship)
|
|
412
|
+
if node_type:
|
|
413
|
+
query += " AND n.type = ?"
|
|
414
|
+
params.append(node_type)
|
|
415
|
+
|
|
416
|
+
cursor.execute(query, params)
|
|
417
|
+
|
|
418
|
+
for source, rel, _ in cursor.fetchall():
|
|
419
|
+
edges.add((source, current, rel))
|
|
420
|
+
if source not in nodes or nodes[source] > current_depth + 1:
|
|
421
|
+
nodes[source] = current_depth + 1
|
|
422
|
+
queue.append((source, current_depth + 1))
|
|
423
|
+
|
|
424
|
+
# Format output
|
|
425
|
+
output = [f"Subgraph around '{node_id}' (depth={depth}):\n"]
|
|
426
|
+
output.append(f"Nodes ({len(nodes)}):")
|
|
427
|
+
|
|
428
|
+
# Get node details
|
|
429
|
+
for node, d in sorted(nodes.items(), key=lambda x: (x[1], x[0])):
|
|
430
|
+
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node,))
|
|
431
|
+
node_type = cursor.fetchone()[0]
|
|
432
|
+
output.append(f" [{d}] {node} ({node_type})")
|
|
433
|
+
|
|
434
|
+
output.append(f"\nEdges ({len(edges)}):")
|
|
435
|
+
for src, tgt, rel in sorted(edges):
|
|
436
|
+
output.append(f" {src} --[{rel}]--> {tgt}")
|
|
437
|
+
|
|
438
|
+
return "\n".join(output)
|
|
439
|
+
|
|
440
|
+
def _query_connected(self, conn: sqlite3.Connection, node_id: str,
|
|
441
|
+
relationship: Optional[str], node_type: Optional[str],
|
|
442
|
+
direction: str) -> str:
|
|
443
|
+
"""Find all nodes connected to a node (transitive closure)."""
|
|
444
|
+
cursor = conn.cursor()
|
|
445
|
+
|
|
446
|
+
# Check if node exists
|
|
447
|
+
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node_id,))
|
|
448
|
+
if not cursor.fetchone():
|
|
449
|
+
return f"Error: Node '{node_id}' not found"
|
|
450
|
+
|
|
451
|
+
# BFS to find all connected nodes
|
|
452
|
+
visited = {node_id}
|
|
453
|
+
queue = deque([node_id])
|
|
454
|
+
connections = [] # (node_id, node_type, distance)
|
|
455
|
+
distance = {node_id: 0}
|
|
456
|
+
|
|
457
|
+
while queue:
|
|
458
|
+
current = queue.popleft()
|
|
459
|
+
current_dist = distance[current]
|
|
460
|
+
|
|
461
|
+
# Get edges based on direction
|
|
462
|
+
neighbors = []
|
|
463
|
+
|
|
464
|
+
if direction in ["both", "outgoing"]:
|
|
465
|
+
query = """SELECT e.target, n.type FROM edges e
|
|
466
|
+
JOIN nodes n ON e.target = n.id
|
|
467
|
+
WHERE e.source = ?"""
|
|
468
|
+
params = [current]
|
|
469
|
+
|
|
470
|
+
if relationship:
|
|
471
|
+
query += " AND e.relationship = ?"
|
|
472
|
+
params.append(relationship)
|
|
473
|
+
if node_type:
|
|
474
|
+
query += " AND n.type = ?"
|
|
475
|
+
params.append(node_type)
|
|
476
|
+
|
|
477
|
+
cursor.execute(query, params)
|
|
478
|
+
neighbors.extend(cursor.fetchall())
|
|
479
|
+
|
|
480
|
+
if direction in ["both", "incoming"]:
|
|
481
|
+
query = """SELECT e.source, n.type FROM edges e
|
|
482
|
+
JOIN nodes n ON e.source = n.id
|
|
483
|
+
WHERE e.target = ?"""
|
|
484
|
+
params = [current]
|
|
485
|
+
|
|
486
|
+
if relationship:
|
|
487
|
+
query += " AND e.relationship = ?"
|
|
488
|
+
params.append(relationship)
|
|
489
|
+
if node_type:
|
|
490
|
+
query += " AND n.type = ?"
|
|
491
|
+
params.append(node_type)
|
|
492
|
+
|
|
493
|
+
cursor.execute(query, params)
|
|
494
|
+
neighbors.extend(cursor.fetchall())
|
|
495
|
+
|
|
496
|
+
for neighbor, n_type in neighbors:
|
|
497
|
+
if neighbor not in visited:
|
|
498
|
+
visited.add(neighbor)
|
|
499
|
+
queue.append(neighbor)
|
|
500
|
+
distance[neighbor] = current_dist + 1
|
|
501
|
+
connections.append((neighbor, n_type, current_dist + 1))
|
|
502
|
+
|
|
503
|
+
if not connections:
|
|
504
|
+
return f"No connected nodes found for '{node_id}'"
|
|
505
|
+
|
|
506
|
+
# Format output
|
|
507
|
+
output = [f"Nodes connected to '{node_id}' ({direction}):"]
|
|
508
|
+
output.append(f"\nTotal connected: {len(connections)}\n")
|
|
509
|
+
|
|
510
|
+
# Group by distance
|
|
511
|
+
by_distance = {}
|
|
512
|
+
for node, n_type, dist in connections:
|
|
513
|
+
if dist not in by_distance:
|
|
514
|
+
by_distance[dist] = []
|
|
515
|
+
by_distance[dist].append((node, n_type))
|
|
516
|
+
|
|
517
|
+
for dist in sorted(by_distance.keys()):
|
|
518
|
+
output.append(f"Distance {dist}:")
|
|
519
|
+
for node, n_type in sorted(by_distance[dist]):
|
|
520
|
+
output.append(f" {node} ({n_type})")
|
|
521
|
+
|
|
522
|
+
return "\n".join(output)
|
|
523
|
+
|
|
524
|
+
def _query_ancestors(self, conn: sqlite3.Connection, node_id: str, depth: int,
|
|
525
|
+
relationship: Optional[str], node_type: Optional[str]) -> str:
|
|
526
|
+
"""Find nodes that point TO this node (incoming edges only)."""
|
|
527
|
+
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "incoming")
|
|
528
|
+
|
|
529
|
+
def _query_descendants(self, conn: sqlite3.Connection, node_id: str, depth: int,
|
|
530
|
+
relationship: Optional[str], node_type: Optional[str]) -> str:
|
|
531
|
+
"""Find nodes that this node points TO (outgoing edges only)."""
|
|
532
|
+
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "outgoing")
|
|
533
|
+
|
|
534
|
+
def register(self, mcp_server) -> None:
|
|
535
|
+
"""Register this tool with the MCP server."""
|
|
536
|
+
pass
|