mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
- Fix import sorting (I001) in all packages - Fix bare except (E722) in lsp_tool.py - Fix duplicate set item (B033) in refactor_tool.py
590 lines
19 KiB
Python
590 lines
19 KiB
Python
"""Graph query tool for querying the graph database."""
|
|
|
|
import json
|
|
import sqlite3
|
|
from typing import (
|
|
Unpack,
|
|
Optional,
|
|
Annotated,
|
|
TypedDict,
|
|
final,
|
|
override,
|
|
)
|
|
from collections import deque
|
|
|
|
from pydantic import Field
|
|
from mcp.server.fastmcp import Context as MCPContext
|
|
|
|
from hanzo_tools.core import BaseTool, PermissionManager, auto_timeout, create_tool_context
|
|
|
|
from .database_manager import DatabaseManager
|
|
|
|
Query = Annotated[
|
|
str,
|
|
Field(
|
|
description="Query type: neighbors, path, subgraph, connected, ancestors, descendants",
|
|
min_length=1,
|
|
),
|
|
]
|
|
|
|
NodeId = Annotated[
|
|
Optional[str],
|
|
Field(
|
|
description="Starting node ID",
|
|
default=None,
|
|
),
|
|
]
|
|
|
|
TargetId = Annotated[
|
|
Optional[str],
|
|
Field(
|
|
description="Target node ID (for path queries)",
|
|
default=None,
|
|
),
|
|
]
|
|
|
|
Depth = Annotated[
|
|
int,
|
|
Field(
|
|
description="Maximum depth for traversal",
|
|
default=2,
|
|
),
|
|
]
|
|
|
|
Relationship = Annotated[
|
|
Optional[str],
|
|
Field(
|
|
description="Filter by relationship type",
|
|
default=None,
|
|
),
|
|
]
|
|
|
|
NodeType = Annotated[
|
|
Optional[str],
|
|
Field(
|
|
description="Filter by node type",
|
|
default=None,
|
|
),
|
|
]
|
|
|
|
Direction = Annotated[
|
|
str,
|
|
Field(
|
|
description="Direction: both, incoming, outgoing",
|
|
default="both",
|
|
),
|
|
]
|
|
|
|
ProjectPath = Annotated[
|
|
Optional[str],
|
|
Field(
|
|
description="Project path (defaults to current directory)",
|
|
default=None,
|
|
),
|
|
]
|
|
|
|
|
|
class GraphQueryParams(TypedDict, total=False):
|
|
"""Parameters for graph query tool."""
|
|
|
|
query: str
|
|
node_id: Optional[str]
|
|
target_id: Optional[str]
|
|
depth: int
|
|
relationship: Optional[str]
|
|
node_type: Optional[str]
|
|
direction: str
|
|
project_path: Optional[str]
|
|
|
|
|
|
@final
|
|
class GraphQueryTool(BaseTool):
|
|
"""Tool for querying the graph database."""
|
|
|
|
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
|
"""Initialize the graph query tool.
|
|
|
|
Args:
|
|
permission_manager: Permission manager for access control
|
|
db_manager: Database manager instance
|
|
"""
|
|
self.permission_manager = permission_manager
|
|
self.db_manager = db_manager
|
|
|
|
@property
|
|
@override
|
|
def name(self) -> str:
|
|
"""Get the tool name."""
|
|
return "graph_query"
|
|
|
|
@property
|
|
@override
|
|
def description(self) -> str:
|
|
"""Get the tool description."""
|
|
return """Query the project's graph database for relationships and patterns.
|
|
|
|
Query types:
|
|
- neighbors: Find direct neighbors of a node
|
|
- path: Find shortest path between two nodes
|
|
- subgraph: Get subgraph around a node up to depth
|
|
- connected: Find all nodes connected to a node
|
|
- ancestors: Find nodes that point TO this node
|
|
- descendants: Find nodes that this node points TO
|
|
|
|
Options:
|
|
- --depth: Max traversal depth (default 2)
|
|
- --relationship: Filter by edge type
|
|
- --node-type: Filter by node type
|
|
- --direction: both, incoming, outgoing
|
|
|
|
Examples:
|
|
- graph_query --query neighbors --node-id "main.py"
|
|
- graph_query --query path --node-id "main.py" --target-id "utils.py"
|
|
- graph_query --query subgraph --node-id "MyClass" --depth 3
|
|
- graph_query --query ancestors --node-id "error_handler" --relationship "calls"
|
|
- graph_query --query descendants --node-id "BaseClass" --relationship "inherits"
|
|
"""
|
|
|
|
@override
|
|
@auto_timeout("graph_query")
|
|
async def call(
|
|
self,
|
|
ctx: MCPContext,
|
|
**params: Unpack[GraphQueryParams],
|
|
) -> str:
|
|
"""Execute graph query.
|
|
|
|
Args:
|
|
ctx: MCP context
|
|
**params: Tool parameters
|
|
|
|
Returns:
|
|
Query results
|
|
"""
|
|
tool_ctx = create_tool_context(ctx)
|
|
await tool_ctx.set_tool_info(self.name)
|
|
|
|
# Extract parameters
|
|
query = params.get("query")
|
|
if not query:
|
|
return "Error: query is required"
|
|
|
|
node_id = params.get("node_id")
|
|
target_id = params.get("target_id")
|
|
depth = params.get("depth", 2)
|
|
relationship = params.get("relationship")
|
|
node_type = params.get("node_type")
|
|
direction = params.get("direction", "both")
|
|
project_path = params.get("project_path")
|
|
|
|
# Validate query type
|
|
valid_queries = [
|
|
"neighbors",
|
|
"path",
|
|
"subgraph",
|
|
"connected",
|
|
"ancestors",
|
|
"descendants",
|
|
]
|
|
if query not in valid_queries:
|
|
return f"Error: Invalid query '{query}'. Must be one of: {', '.join(valid_queries)}"
|
|
|
|
# Validate required parameters
|
|
if query in ["neighbors", "subgraph", "connected", "ancestors", "descendants"] and not node_id:
|
|
return f"Error: node_id is required for '{query}' query"
|
|
|
|
if query == "path" and (not node_id or not target_id):
|
|
return "Error: Both node_id and target_id are required for 'path' query"
|
|
|
|
# Get project database
|
|
try:
|
|
if project_path:
|
|
project_db = self.db_manager.get_project_db(project_path)
|
|
else:
|
|
import os
|
|
|
|
project_db = self.db_manager.get_project_for_path(os.getcwd())
|
|
|
|
if not project_db:
|
|
return "Error: Could not find project database"
|
|
|
|
except PermissionError as e:
|
|
return str(e)
|
|
except Exception as e:
|
|
return f"Error accessing project database: {str(e)}"
|
|
|
|
# Get graph connection
|
|
graph_conn = project_db.get_graph_connection()
|
|
|
|
await tool_ctx.info(f"Executing {query} query")
|
|
|
|
try:
|
|
if query == "neighbors":
|
|
return self._query_neighbors(graph_conn, node_id, relationship, node_type, direction)
|
|
elif query == "path":
|
|
return self._query_path(graph_conn, node_id, target_id, relationship)
|
|
elif query == "subgraph":
|
|
return self._query_subgraph(graph_conn, node_id, depth, relationship, node_type, direction)
|
|
elif query == "connected":
|
|
return self._query_connected(graph_conn, node_id, relationship, node_type, direction)
|
|
elif query == "ancestors":
|
|
return self._query_ancestors(graph_conn, node_id, depth, relationship, node_type)
|
|
elif query == "descendants":
|
|
return self._query_descendants(graph_conn, node_id, depth, relationship, node_type)
|
|
|
|
except Exception as e:
|
|
await tool_ctx.error(f"Failed to execute query: {str(e)}")
|
|
return f"Error executing query: {str(e)}"
|
|
|
|
def _query_neighbors(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
node_id: str,
|
|
relationship: Optional[str],
|
|
node_type: Optional[str],
|
|
direction: str,
|
|
) -> str:
|
|
"""Get direct neighbors of a node."""
|
|
cursor = conn.cursor()
|
|
|
|
# Check if node exists
|
|
cursor.execute("SELECT type, properties FROM nodes WHERE id = ?", (node_id,))
|
|
node_info = cursor.fetchone()
|
|
if not node_info:
|
|
return f"Error: Node '{node_id}' not found"
|
|
|
|
neighbors = []
|
|
|
|
# Get outgoing edges
|
|
if direction in ["both", "outgoing"]:
|
|
query = """SELECT e.target, e.relationship, e.weight, n.type, n.properties
|
|
FROM edges e JOIN nodes n ON e.target = n.id
|
|
WHERE e.source = ?"""
|
|
params = [node_id]
|
|
|
|
if relationship:
|
|
query += " AND e.relationship = ?"
|
|
params.append(relationship)
|
|
if node_type:
|
|
query += " AND n.type = ?"
|
|
params.append(node_type)
|
|
|
|
cursor.execute(query, params)
|
|
for row in cursor.fetchall():
|
|
neighbors.append(
|
|
{
|
|
"direction": "outgoing",
|
|
"node_id": row[0],
|
|
"relationship": row[1],
|
|
"weight": row[2],
|
|
"node_type": row[3],
|
|
"properties": json.loads(row[4]) if row[4] else {},
|
|
}
|
|
)
|
|
|
|
# Get incoming edges
|
|
if direction in ["both", "incoming"]:
|
|
query = """SELECT e.source, e.relationship, e.weight, n.type, n.properties
|
|
FROM edges e JOIN nodes n ON e.source = n.id
|
|
WHERE e.target = ?"""
|
|
params = [node_id]
|
|
|
|
if relationship:
|
|
query += " AND e.relationship = ?"
|
|
params.append(relationship)
|
|
if node_type:
|
|
query += " AND n.type = ?"
|
|
params.append(node_type)
|
|
|
|
cursor.execute(query, params)
|
|
for row in cursor.fetchall():
|
|
neighbors.append(
|
|
{
|
|
"direction": "incoming",
|
|
"node_id": row[0],
|
|
"relationship": row[1],
|
|
"weight": row[2],
|
|
"node_type": row[3],
|
|
"properties": json.loads(row[4]) if row[4] else {},
|
|
}
|
|
)
|
|
|
|
if not neighbors:
|
|
return f"No neighbors found for node '{node_id}'"
|
|
|
|
# Format output
|
|
output = [f"Neighbors of '{node_id}' ({node_info[0]}):\n"]
|
|
for n in neighbors:
|
|
arrow = "<--" if n["direction"] == "incoming" else "-->"
|
|
output.append(f" {node_id} {arrow}[{n['relationship']}]--> {n['node_id']} ({n['node_type']})")
|
|
if n["properties"]:
|
|
output.append(f" Properties: {json.dumps(n['properties'], indent=6)[:100]}")
|
|
|
|
output.append(f"\nTotal neighbors: {len(neighbors)}")
|
|
return "\n".join(output)
|
|
|
|
def _query_path(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
start: str,
|
|
end: str,
|
|
relationship: Optional[str],
|
|
) -> str:
|
|
"""Find shortest path between two nodes using BFS."""
|
|
cursor = conn.cursor()
|
|
|
|
# Check if nodes exist
|
|
cursor.execute("SELECT id FROM nodes WHERE id IN (?, ?)", (start, end))
|
|
existing = [row[0] for row in cursor.fetchall()]
|
|
if start not in existing:
|
|
return f"Error: Start node '{start}' not found"
|
|
if end not in existing:
|
|
return f"Error: End node '{end}' not found"
|
|
|
|
# BFS to find shortest path
|
|
queue = deque([(start, [start])])
|
|
visited = {start}
|
|
|
|
while queue:
|
|
current, path = queue.popleft()
|
|
|
|
if current == end:
|
|
# Found path, get edge details
|
|
output = [f"Shortest path from '{start}' to '{end}':\n"]
|
|
|
|
for i in range(len(path) - 1):
|
|
src, tgt = path[i], path[i + 1]
|
|
|
|
# Get edge details
|
|
query = "SELECT relationship, weight FROM edges WHERE source = ? AND target = ?"
|
|
cursor.execute(query, (src, tgt))
|
|
edge = cursor.fetchone()
|
|
|
|
if edge:
|
|
output.append(f" {src} --[{edge[0]}]--> {tgt}")
|
|
else:
|
|
output.append(f" {src} --> {tgt}")
|
|
|
|
output.append(f"\nPath length: {len(path) - 1} edge(s)")
|
|
return "\n".join(output)
|
|
|
|
# Get neighbors
|
|
query = "SELECT target FROM edges WHERE source = ?"
|
|
params = [current]
|
|
if relationship:
|
|
query += " AND relationship = ?"
|
|
params.append(relationship)
|
|
|
|
cursor.execute(query, params)
|
|
|
|
for (neighbor,) in cursor.fetchall():
|
|
if neighbor not in visited:
|
|
visited.add(neighbor)
|
|
queue.append((neighbor, path + [neighbor]))
|
|
|
|
return f"No path found from '{start}' to '{end}'" + (
|
|
f" with relationship '{relationship}'" if relationship else ""
|
|
)
|
|
|
|
def _query_subgraph(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
node_id: str,
|
|
depth: int,
|
|
relationship: Optional[str],
|
|
node_type: Optional[str],
|
|
direction: str,
|
|
) -> str:
|
|
"""Get subgraph around a node up to specified depth."""
|
|
cursor = conn.cursor()
|
|
|
|
# Check if node exists
|
|
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node_id,))
|
|
if not cursor.fetchone():
|
|
return f"Error: Node '{node_id}' not found"
|
|
|
|
# BFS to collect nodes and edges
|
|
nodes = {node_id: 0} # node_id -> depth
|
|
edges = set() # (source, target, relationship)
|
|
queue = deque([(node_id, 0)])
|
|
|
|
while queue:
|
|
current, current_depth = queue.popleft()
|
|
|
|
if current_depth >= depth:
|
|
continue
|
|
|
|
# Get edges based on direction
|
|
if direction in ["both", "outgoing"]:
|
|
query = """SELECT e.target, e.relationship, n.type
|
|
FROM edges e JOIN nodes n ON e.target = n.id
|
|
WHERE e.source = ?"""
|
|
params = [current]
|
|
|
|
if relationship:
|
|
query += " AND e.relationship = ?"
|
|
params.append(relationship)
|
|
if node_type:
|
|
query += " AND n.type = ?"
|
|
params.append(node_type)
|
|
|
|
cursor.execute(query, params)
|
|
|
|
for target, rel, _ in cursor.fetchall():
|
|
edges.add((current, target, rel))
|
|
if target not in nodes or nodes[target] > current_depth + 1:
|
|
nodes[target] = current_depth + 1
|
|
queue.append((target, current_depth + 1))
|
|
|
|
if direction in ["both", "incoming"]:
|
|
query = """SELECT e.source, e.relationship, n.type
|
|
FROM edges e JOIN nodes n ON e.source = n.id
|
|
WHERE e.target = ?"""
|
|
params = [current]
|
|
|
|
if relationship:
|
|
query += " AND e.relationship = ?"
|
|
params.append(relationship)
|
|
if node_type:
|
|
query += " AND n.type = ?"
|
|
params.append(node_type)
|
|
|
|
cursor.execute(query, params)
|
|
|
|
for source, rel, _ in cursor.fetchall():
|
|
edges.add((source, current, rel))
|
|
if source not in nodes or nodes[source] > current_depth + 1:
|
|
nodes[source] = current_depth + 1
|
|
queue.append((source, current_depth + 1))
|
|
|
|
# Format output
|
|
output = [f"Subgraph around '{node_id}' (depth={depth}):\n"]
|
|
output.append(f"Nodes ({len(nodes)}):")
|
|
|
|
# Get node details
|
|
for node, d in sorted(nodes.items(), key=lambda x: (x[1], x[0])):
|
|
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node,))
|
|
node_type = cursor.fetchone()[0]
|
|
output.append(f" [{d}] {node} ({node_type})")
|
|
|
|
output.append(f"\nEdges ({len(edges)}):")
|
|
for src, tgt, rel in sorted(edges):
|
|
output.append(f" {src} --[{rel}]--> {tgt}")
|
|
|
|
return "\n".join(output)
|
|
|
|
def _query_connected(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
node_id: str,
|
|
relationship: Optional[str],
|
|
node_type: Optional[str],
|
|
direction: str,
|
|
) -> str:
|
|
"""Find all nodes connected to a node (transitive closure)."""
|
|
cursor = conn.cursor()
|
|
|
|
# Check if node exists
|
|
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node_id,))
|
|
if not cursor.fetchone():
|
|
return f"Error: Node '{node_id}' not found"
|
|
|
|
# BFS to find all connected nodes
|
|
visited = {node_id}
|
|
queue = deque([node_id])
|
|
connections = [] # (node_id, node_type, distance)
|
|
distance = {node_id: 0}
|
|
|
|
while queue:
|
|
current = queue.popleft()
|
|
current_dist = distance[current]
|
|
|
|
# Get edges based on direction
|
|
neighbors = []
|
|
|
|
if direction in ["both", "outgoing"]:
|
|
query = """SELECT e.target, n.type FROM edges e
|
|
JOIN nodes n ON e.target = n.id
|
|
WHERE e.source = ?"""
|
|
params = [current]
|
|
|
|
if relationship:
|
|
query += " AND e.relationship = ?"
|
|
params.append(relationship)
|
|
if node_type:
|
|
query += " AND n.type = ?"
|
|
params.append(node_type)
|
|
|
|
cursor.execute(query, params)
|
|
neighbors.extend(cursor.fetchall())
|
|
|
|
if direction in ["both", "incoming"]:
|
|
query = """SELECT e.source, n.type FROM edges e
|
|
JOIN nodes n ON e.source = n.id
|
|
WHERE e.target = ?"""
|
|
params = [current]
|
|
|
|
if relationship:
|
|
query += " AND e.relationship = ?"
|
|
params.append(relationship)
|
|
if node_type:
|
|
query += " AND n.type = ?"
|
|
params.append(node_type)
|
|
|
|
cursor.execute(query, params)
|
|
neighbors.extend(cursor.fetchall())
|
|
|
|
for neighbor, n_type in neighbors:
|
|
if neighbor not in visited:
|
|
visited.add(neighbor)
|
|
queue.append(neighbor)
|
|
distance[neighbor] = current_dist + 1
|
|
connections.append((neighbor, n_type, current_dist + 1))
|
|
|
|
if not connections:
|
|
return f"No connected nodes found for '{node_id}'"
|
|
|
|
# Format output
|
|
output = [f"Nodes connected to '{node_id}' ({direction}):"]
|
|
output.append(f"\nTotal connected: {len(connections)}\n")
|
|
|
|
# Group by distance
|
|
by_distance = {}
|
|
for node, n_type, dist in connections:
|
|
if dist not in by_distance:
|
|
by_distance[dist] = []
|
|
by_distance[dist].append((node, n_type))
|
|
|
|
for dist in sorted(by_distance.keys()):
|
|
output.append(f"Distance {dist}:")
|
|
for node, n_type in sorted(by_distance[dist]):
|
|
output.append(f" {node} ({n_type})")
|
|
|
|
return "\n".join(output)
|
|
|
|
def _query_ancestors(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
node_id: str,
|
|
depth: int,
|
|
relationship: Optional[str],
|
|
node_type: Optional[str],
|
|
) -> str:
|
|
"""Find nodes that point TO this node (incoming edges only)."""
|
|
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "incoming")
|
|
|
|
def _query_descendants(
|
|
self,
|
|
conn: sqlite3.Connection,
|
|
node_id: str,
|
|
depth: int,
|
|
relationship: Optional[str],
|
|
node_type: Optional[str],
|
|
) -> str:
|
|
"""Find nodes that this node points TO (outgoing edges only)."""
|
|
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "outgoing")
|
|
|
|
def register(self, mcp_server) -> None:
|
|
"""Register this tool with the MCP server."""
|
|
pass
|