hanzo-mcp 0.5.1__py3-none-any.whl → 0.6.1__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.

Files changed (118) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +32 -0
  3. hanzo_mcp/dev_server.py +246 -0
  4. hanzo_mcp/prompts/__init__.py +1 -1
  5. hanzo_mcp/prompts/project_system.py +43 -7
  6. hanzo_mcp/server.py +5 -1
  7. hanzo_mcp/tools/__init__.py +168 -6
  8. hanzo_mcp/tools/agent/__init__.py +1 -1
  9. hanzo_mcp/tools/agent/agent.py +401 -0
  10. hanzo_mcp/tools/agent/agent_tool.py +3 -4
  11. hanzo_mcp/tools/common/__init__.py +1 -1
  12. hanzo_mcp/tools/common/base.py +9 -4
  13. hanzo_mcp/tools/common/batch_tool.py +3 -5
  14. hanzo_mcp/tools/common/config_tool.py +1 -1
  15. hanzo_mcp/tools/common/context.py +1 -1
  16. hanzo_mcp/tools/common/palette.py +344 -0
  17. hanzo_mcp/tools/common/palette_loader.py +108 -0
  18. hanzo_mcp/tools/common/stats.py +261 -0
  19. hanzo_mcp/tools/common/thinking_tool.py +3 -5
  20. hanzo_mcp/tools/common/tool_disable.py +144 -0
  21. hanzo_mcp/tools/common/tool_enable.py +182 -0
  22. hanzo_mcp/tools/common/tool_list.py +260 -0
  23. hanzo_mcp/tools/config/__init__.py +10 -0
  24. hanzo_mcp/tools/config/config_tool.py +212 -0
  25. hanzo_mcp/tools/config/index_config.py +176 -0
  26. hanzo_mcp/tools/config/palette_tool.py +166 -0
  27. hanzo_mcp/tools/database/__init__.py +71 -0
  28. hanzo_mcp/tools/database/database_manager.py +246 -0
  29. hanzo_mcp/tools/database/graph.py +482 -0
  30. hanzo_mcp/tools/database/graph_add.py +257 -0
  31. hanzo_mcp/tools/database/graph_query.py +536 -0
  32. hanzo_mcp/tools/database/graph_remove.py +267 -0
  33. hanzo_mcp/tools/database/graph_search.py +348 -0
  34. hanzo_mcp/tools/database/graph_stats.py +345 -0
  35. hanzo_mcp/tools/database/sql.py +411 -0
  36. hanzo_mcp/tools/database/sql_query.py +229 -0
  37. hanzo_mcp/tools/database/sql_search.py +296 -0
  38. hanzo_mcp/tools/database/sql_stats.py +254 -0
  39. hanzo_mcp/tools/editor/__init__.py +11 -0
  40. hanzo_mcp/tools/editor/neovim_command.py +272 -0
  41. hanzo_mcp/tools/editor/neovim_edit.py +290 -0
  42. hanzo_mcp/tools/editor/neovim_session.py +356 -0
  43. hanzo_mcp/tools/filesystem/__init__.py +52 -13
  44. hanzo_mcp/tools/filesystem/base.py +1 -1
  45. hanzo_mcp/tools/filesystem/batch_search.py +812 -0
  46. hanzo_mcp/tools/filesystem/content_replace.py +3 -5
  47. hanzo_mcp/tools/filesystem/diff.py +193 -0
  48. hanzo_mcp/tools/filesystem/directory_tree.py +3 -5
  49. hanzo_mcp/tools/filesystem/edit.py +3 -5
  50. hanzo_mcp/tools/filesystem/find.py +443 -0
  51. hanzo_mcp/tools/filesystem/find_files.py +348 -0
  52. hanzo_mcp/tools/filesystem/git_search.py +505 -0
  53. hanzo_mcp/tools/filesystem/grep.py +2 -2
  54. hanzo_mcp/tools/filesystem/multi_edit.py +3 -5
  55. hanzo_mcp/tools/filesystem/read.py +17 -5
  56. hanzo_mcp/tools/filesystem/{grep_ast_tool.py → symbols.py} +17 -27
  57. hanzo_mcp/tools/filesystem/symbols_unified.py +376 -0
  58. hanzo_mcp/tools/filesystem/tree.py +268 -0
  59. hanzo_mcp/tools/filesystem/unified_search.py +465 -443
  60. hanzo_mcp/tools/filesystem/unix_aliases.py +99 -0
  61. hanzo_mcp/tools/filesystem/watch.py +174 -0
  62. hanzo_mcp/tools/filesystem/write.py +3 -5
  63. hanzo_mcp/tools/jupyter/__init__.py +9 -12
  64. hanzo_mcp/tools/jupyter/base.py +1 -1
  65. hanzo_mcp/tools/jupyter/jupyter.py +326 -0
  66. hanzo_mcp/tools/jupyter/notebook_edit.py +3 -4
  67. hanzo_mcp/tools/jupyter/notebook_read.py +3 -5
  68. hanzo_mcp/tools/llm/__init__.py +31 -0
  69. hanzo_mcp/tools/llm/consensus_tool.py +351 -0
  70. hanzo_mcp/tools/llm/llm_manage.py +413 -0
  71. hanzo_mcp/tools/llm/llm_tool.py +346 -0
  72. hanzo_mcp/tools/llm/llm_unified.py +851 -0
  73. hanzo_mcp/tools/llm/provider_tools.py +412 -0
  74. hanzo_mcp/tools/mcp/__init__.py +15 -0
  75. hanzo_mcp/tools/mcp/mcp_add.py +263 -0
  76. hanzo_mcp/tools/mcp/mcp_remove.py +127 -0
  77. hanzo_mcp/tools/mcp/mcp_stats.py +165 -0
  78. hanzo_mcp/tools/mcp/mcp_unified.py +503 -0
  79. hanzo_mcp/tools/shell/__init__.py +21 -23
  80. hanzo_mcp/tools/shell/base.py +1 -1
  81. hanzo_mcp/tools/shell/base_process.py +303 -0
  82. hanzo_mcp/tools/shell/bash_unified.py +134 -0
  83. hanzo_mcp/tools/shell/logs.py +265 -0
  84. hanzo_mcp/tools/shell/npx.py +194 -0
  85. hanzo_mcp/tools/shell/npx_background.py +254 -0
  86. hanzo_mcp/tools/shell/npx_unified.py +101 -0
  87. hanzo_mcp/tools/shell/open.py +107 -0
  88. hanzo_mcp/tools/shell/pkill.py +262 -0
  89. hanzo_mcp/tools/shell/process_unified.py +131 -0
  90. hanzo_mcp/tools/shell/processes.py +279 -0
  91. hanzo_mcp/tools/shell/run_background.py +326 -0
  92. hanzo_mcp/tools/shell/run_command.py +3 -4
  93. hanzo_mcp/tools/shell/run_command_windows.py +3 -4
  94. hanzo_mcp/tools/shell/uvx.py +187 -0
  95. hanzo_mcp/tools/shell/uvx_background.py +249 -0
  96. hanzo_mcp/tools/shell/uvx_unified.py +101 -0
  97. hanzo_mcp/tools/todo/__init__.py +1 -1
  98. hanzo_mcp/tools/todo/base.py +1 -1
  99. hanzo_mcp/tools/todo/todo.py +265 -0
  100. hanzo_mcp/tools/todo/todo_read.py +3 -5
  101. hanzo_mcp/tools/todo/todo_write.py +3 -5
  102. hanzo_mcp/tools/vector/__init__.py +6 -1
  103. hanzo_mcp/tools/vector/git_ingester.py +3 -0
  104. hanzo_mcp/tools/vector/index_tool.py +358 -0
  105. hanzo_mcp/tools/vector/infinity_store.py +98 -0
  106. hanzo_mcp/tools/vector/project_manager.py +27 -5
  107. hanzo_mcp/tools/vector/vector.py +311 -0
  108. hanzo_mcp/tools/vector/vector_index.py +1 -1
  109. hanzo_mcp/tools/vector/vector_search.py +12 -7
  110. hanzo_mcp-0.6.1.dist-info/METADATA +336 -0
  111. hanzo_mcp-0.6.1.dist-info/RECORD +134 -0
  112. hanzo_mcp-0.6.1.dist-info/entry_points.txt +3 -0
  113. hanzo_mcp-0.5.1.dist-info/METADATA +0 -276
  114. hanzo_mcp-0.5.1.dist-info/RECORD +0 -68
  115. hanzo_mcp-0.5.1.dist-info/entry_points.txt +0 -2
  116. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/WHEEL +0 -0
  117. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.dist-info}/licenses/LICENSE +0 -0
  118. {hanzo_mcp-0.5.1.dist-info → hanzo_mcp-0.6.1.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 mcp.server.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