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,345 @@
|
|
|
1
|
+
"""Graph statistics tool for analyzing the graph database."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from typing import Annotated, Optional, TypedDict, Unpack, final, override
|
|
6
|
+
from collections import Counter, defaultdict
|
|
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
|
+
ProjectPath = Annotated[
|
|
18
|
+
Optional[str],
|
|
19
|
+
Field(
|
|
20
|
+
description="Project path (defaults to current directory)",
|
|
21
|
+
default=None,
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
Detailed = Annotated[
|
|
26
|
+
bool,
|
|
27
|
+
Field(
|
|
28
|
+
description="Show detailed statistics",
|
|
29
|
+
default=False,
|
|
30
|
+
),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
NodeType = Annotated[
|
|
34
|
+
Optional[str],
|
|
35
|
+
Field(
|
|
36
|
+
description="Filter stats by node type",
|
|
37
|
+
default=None,
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
Relationship = Annotated[
|
|
42
|
+
Optional[str],
|
|
43
|
+
Field(
|
|
44
|
+
description="Filter stats by relationship type",
|
|
45
|
+
default=None,
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GraphStatsParams(TypedDict, total=False):
|
|
51
|
+
"""Parameters for graph stats tool."""
|
|
52
|
+
|
|
53
|
+
project_path: Optional[str]
|
|
54
|
+
detailed: bool
|
|
55
|
+
node_type: Optional[str]
|
|
56
|
+
relationship: Optional[str]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@final
|
|
60
|
+
class GraphStatsTool(BaseTool):
|
|
61
|
+
"""Tool for getting graph database statistics."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
|
64
|
+
"""Initialize the graph stats tool.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
permission_manager: Permission manager for access control
|
|
68
|
+
db_manager: Database manager instance
|
|
69
|
+
"""
|
|
70
|
+
self.permission_manager = permission_manager
|
|
71
|
+
self.db_manager = db_manager
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
@override
|
|
75
|
+
def name(self) -> str:
|
|
76
|
+
"""Get the tool name."""
|
|
77
|
+
return "graph_stats"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
@override
|
|
81
|
+
def description(self) -> str:
|
|
82
|
+
"""Get the tool description."""
|
|
83
|
+
return """Get statistics about the project's graph database.
|
|
84
|
+
|
|
85
|
+
Shows:
|
|
86
|
+
- Node and edge counts
|
|
87
|
+
- Node type distribution
|
|
88
|
+
- Relationship type distribution
|
|
89
|
+
- Degree statistics (connections per node)
|
|
90
|
+
- Connected components
|
|
91
|
+
- Most connected nodes (hubs)
|
|
92
|
+
- Orphaned nodes
|
|
93
|
+
|
|
94
|
+
Examples:
|
|
95
|
+
- graph_stats # Basic stats
|
|
96
|
+
- graph_stats --detailed # Detailed analysis
|
|
97
|
+
- graph_stats --node-type "class" # Stats for specific node type
|
|
98
|
+
- graph_stats --relationship "calls" # Stats for specific relationship
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
@override
|
|
102
|
+
async def call(
|
|
103
|
+
self,
|
|
104
|
+
ctx: MCPContext,
|
|
105
|
+
**params: Unpack[GraphStatsParams],
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Get graph statistics.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
ctx: MCP context
|
|
111
|
+
**params: Tool parameters
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Graph statistics
|
|
115
|
+
"""
|
|
116
|
+
tool_ctx = create_tool_context(ctx)
|
|
117
|
+
await tool_ctx.set_tool_info(self.name)
|
|
118
|
+
|
|
119
|
+
# Extract parameters
|
|
120
|
+
project_path = params.get("project_path")
|
|
121
|
+
detailed = params.get("detailed", False)
|
|
122
|
+
node_type_filter = params.get("node_type")
|
|
123
|
+
relationship_filter = params.get("relationship")
|
|
124
|
+
|
|
125
|
+
# Get project database
|
|
126
|
+
try:
|
|
127
|
+
if project_path:
|
|
128
|
+
project_db = self.db_manager.get_project_db(project_path)
|
|
129
|
+
else:
|
|
130
|
+
import os
|
|
131
|
+
project_db = self.db_manager.get_project_for_path(os.getcwd())
|
|
132
|
+
|
|
133
|
+
if not project_db:
|
|
134
|
+
return "Error: Could not find project database"
|
|
135
|
+
|
|
136
|
+
except PermissionError as e:
|
|
137
|
+
return str(e)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
return f"Error accessing project database: {str(e)}"
|
|
140
|
+
|
|
141
|
+
await tool_ctx.info(f"Getting graph statistics for project: {project_db.project_path}")
|
|
142
|
+
|
|
143
|
+
# Get graph connection
|
|
144
|
+
graph_conn = project_db.get_graph_connection()
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
cursor = graph_conn.cursor()
|
|
148
|
+
output = []
|
|
149
|
+
output.append(f"=== Graph Database Statistics ===")
|
|
150
|
+
output.append(f"Project: {project_db.project_path}")
|
|
151
|
+
output.append(f"Database: {project_db.graph_path}")
|
|
152
|
+
output.append("")
|
|
153
|
+
|
|
154
|
+
# Basic counts
|
|
155
|
+
if node_type_filter:
|
|
156
|
+
cursor.execute("SELECT COUNT(*) FROM nodes WHERE type = ?", (node_type_filter,))
|
|
157
|
+
node_count = cursor.fetchone()[0]
|
|
158
|
+
output.append(f"Nodes (type='{node_type_filter}'): {node_count:,}")
|
|
159
|
+
else:
|
|
160
|
+
cursor.execute("SELECT COUNT(*) FROM nodes")
|
|
161
|
+
node_count = cursor.fetchone()[0]
|
|
162
|
+
output.append(f"Total Nodes: {node_count:,}")
|
|
163
|
+
|
|
164
|
+
if relationship_filter:
|
|
165
|
+
cursor.execute("SELECT COUNT(*) FROM edges WHERE relationship = ?", (relationship_filter,))
|
|
166
|
+
edge_count = cursor.fetchone()[0]
|
|
167
|
+
output.append(f"Edges (relationship='{relationship_filter}'): {edge_count:,}")
|
|
168
|
+
else:
|
|
169
|
+
cursor.execute("SELECT COUNT(*) FROM edges")
|
|
170
|
+
edge_count = cursor.fetchone()[0]
|
|
171
|
+
output.append(f"Total Edges: {edge_count:,}")
|
|
172
|
+
|
|
173
|
+
if node_count == 0:
|
|
174
|
+
output.append("\nGraph is empty.")
|
|
175
|
+
return "\n".join(output)
|
|
176
|
+
|
|
177
|
+
output.append("")
|
|
178
|
+
|
|
179
|
+
# Node type distribution
|
|
180
|
+
output.append("=== Node Types ===")
|
|
181
|
+
cursor.execute("SELECT type, COUNT(*) as count FROM nodes GROUP BY type ORDER BY count DESC")
|
|
182
|
+
node_types = cursor.fetchall()
|
|
183
|
+
|
|
184
|
+
for n_type, count in node_types[:10]:
|
|
185
|
+
pct = (count / node_count) * 100
|
|
186
|
+
output.append(f"{n_type}: {count:,} ({pct:.1f}%)")
|
|
187
|
+
|
|
188
|
+
if len(node_types) > 10:
|
|
189
|
+
output.append(f"... and {len(node_types) - 10} more types")
|
|
190
|
+
|
|
191
|
+
output.append("")
|
|
192
|
+
|
|
193
|
+
# Relationship distribution
|
|
194
|
+
output.append("=== Relationship Types ===")
|
|
195
|
+
cursor.execute("SELECT relationship, COUNT(*) as count FROM edges GROUP BY relationship ORDER BY count DESC")
|
|
196
|
+
rel_types = cursor.fetchall()
|
|
197
|
+
|
|
198
|
+
if rel_types:
|
|
199
|
+
for rel, count in rel_types[:10]:
|
|
200
|
+
pct = (count / edge_count) * 100 if edge_count > 0 else 0
|
|
201
|
+
output.append(f"{rel}: {count:,} ({pct:.1f}%)")
|
|
202
|
+
|
|
203
|
+
if len(rel_types) > 10:
|
|
204
|
+
output.append(f"... and {len(rel_types) - 10} more types")
|
|
205
|
+
else:
|
|
206
|
+
output.append("No edges in graph")
|
|
207
|
+
|
|
208
|
+
output.append("")
|
|
209
|
+
|
|
210
|
+
# Degree statistics
|
|
211
|
+
output.append("=== Connectivity ===")
|
|
212
|
+
|
|
213
|
+
# Calculate degrees
|
|
214
|
+
degrees = defaultdict(int)
|
|
215
|
+
|
|
216
|
+
# Out-degree
|
|
217
|
+
query = "SELECT source, COUNT(*) FROM edges"
|
|
218
|
+
if relationship_filter:
|
|
219
|
+
query += " WHERE relationship = ?"
|
|
220
|
+
cursor.execute(query + " GROUP BY source", (relationship_filter,))
|
|
221
|
+
else:
|
|
222
|
+
cursor.execute(query + " GROUP BY source")
|
|
223
|
+
|
|
224
|
+
for node, out_degree in cursor.fetchall():
|
|
225
|
+
degrees[node] += out_degree
|
|
226
|
+
|
|
227
|
+
# In-degree
|
|
228
|
+
query = "SELECT target, COUNT(*) FROM edges"
|
|
229
|
+
if relationship_filter:
|
|
230
|
+
query += " WHERE relationship = ?"
|
|
231
|
+
cursor.execute(query + " GROUP BY target", (relationship_filter,))
|
|
232
|
+
else:
|
|
233
|
+
cursor.execute(query + " GROUP BY target")
|
|
234
|
+
|
|
235
|
+
for node, in_degree in cursor.fetchall():
|
|
236
|
+
degrees[node] += in_degree
|
|
237
|
+
|
|
238
|
+
if degrees:
|
|
239
|
+
degree_values = list(degrees.values())
|
|
240
|
+
avg_degree = sum(degree_values) / len(degree_values)
|
|
241
|
+
max_degree = max(degree_values)
|
|
242
|
+
min_degree = min(degree_values)
|
|
243
|
+
|
|
244
|
+
output.append(f"Average degree: {avg_degree:.2f}")
|
|
245
|
+
output.append(f"Max degree: {max_degree}")
|
|
246
|
+
output.append(f"Min degree: {min_degree}")
|
|
247
|
+
|
|
248
|
+
# Most connected nodes
|
|
249
|
+
output.append("\nMost connected nodes:")
|
|
250
|
+
sorted_nodes = sorted(degrees.items(), key=lambda x: x[1], reverse=True)
|
|
251
|
+
|
|
252
|
+
for node, degree in sorted_nodes[:5]:
|
|
253
|
+
cursor.execute("SELECT type FROM nodes WHERE id = ?", (node,))
|
|
254
|
+
node_type = cursor.fetchone()
|
|
255
|
+
type_str = f" ({node_type[0]})" if node_type else ""
|
|
256
|
+
output.append(f" {node}{type_str}: {degree} connections")
|
|
257
|
+
|
|
258
|
+
# Orphaned nodes
|
|
259
|
+
cursor.execute("""
|
|
260
|
+
SELECT COUNT(*) FROM nodes n
|
|
261
|
+
WHERE NOT EXISTS (SELECT 1 FROM edges WHERE source = n.id OR target = n.id)
|
|
262
|
+
""")
|
|
263
|
+
orphan_count = cursor.fetchone()[0]
|
|
264
|
+
if orphan_count > 0:
|
|
265
|
+
orphan_pct = (orphan_count / node_count) * 100
|
|
266
|
+
output.append(f"\nOrphaned nodes: {orphan_count} ({orphan_pct:.1f}%)")
|
|
267
|
+
|
|
268
|
+
if detailed:
|
|
269
|
+
output.append("\n=== Detailed Analysis ===")
|
|
270
|
+
|
|
271
|
+
# Node properties usage
|
|
272
|
+
cursor.execute("SELECT COUNT(*) FROM nodes WHERE properties IS NOT NULL")
|
|
273
|
+
nodes_with_props = cursor.fetchone()[0]
|
|
274
|
+
if nodes_with_props > 0:
|
|
275
|
+
props_pct = (nodes_with_props / node_count) * 100
|
|
276
|
+
output.append(f"Nodes with properties: {nodes_with_props} ({props_pct:.1f}%)")
|
|
277
|
+
|
|
278
|
+
# Edge properties usage
|
|
279
|
+
cursor.execute("SELECT COUNT(*) FROM edges WHERE properties IS NOT NULL")
|
|
280
|
+
edges_with_props = cursor.fetchone()[0]
|
|
281
|
+
if edges_with_props > 0 and edge_count > 0:
|
|
282
|
+
props_pct = (edges_with_props / edge_count) * 100
|
|
283
|
+
output.append(f"Edges with properties: {edges_with_props} ({props_pct:.1f}%)")
|
|
284
|
+
|
|
285
|
+
# Weight distribution
|
|
286
|
+
cursor.execute("SELECT MIN(weight), MAX(weight), AVG(weight) FROM edges")
|
|
287
|
+
weight_stats = cursor.fetchone()
|
|
288
|
+
if weight_stats[0] is not None:
|
|
289
|
+
output.append(f"\nEdge weights:")
|
|
290
|
+
output.append(f" Min: {weight_stats[0]}")
|
|
291
|
+
output.append(f" Max: {weight_stats[1]}")
|
|
292
|
+
output.append(f" Avg: {weight_stats[2]:.2f}")
|
|
293
|
+
|
|
294
|
+
# Most common patterns
|
|
295
|
+
if not relationship_filter:
|
|
296
|
+
output.append("\n=== Common Patterns ===")
|
|
297
|
+
|
|
298
|
+
# Most common node type connections
|
|
299
|
+
cursor.execute("""
|
|
300
|
+
SELECT n1.type, e.relationship, n2.type, COUNT(*) as count
|
|
301
|
+
FROM edges e
|
|
302
|
+
JOIN nodes n1 ON e.source = n1.id
|
|
303
|
+
JOIN nodes n2 ON e.target = n2.id
|
|
304
|
+
GROUP BY n1.type, e.relationship, n2.type
|
|
305
|
+
ORDER BY count DESC
|
|
306
|
+
LIMIT 10
|
|
307
|
+
""")
|
|
308
|
+
|
|
309
|
+
patterns = cursor.fetchall()
|
|
310
|
+
if patterns:
|
|
311
|
+
output.append("Most common connections:")
|
|
312
|
+
for src_type, rel, tgt_type, count in patterns:
|
|
313
|
+
output.append(f" {src_type} --[{rel}]--> {tgt_type}: {count} times")
|
|
314
|
+
|
|
315
|
+
# Component analysis (simplified)
|
|
316
|
+
output.append("\n=== Graph Structure ===")
|
|
317
|
+
|
|
318
|
+
# Check if graph is fully connected (simplified)
|
|
319
|
+
cursor.execute("""
|
|
320
|
+
SELECT COUNT(DISTINCT node_id) FROM (
|
|
321
|
+
SELECT source as node_id FROM edges
|
|
322
|
+
UNION
|
|
323
|
+
SELECT target as node_id FROM edges
|
|
324
|
+
)
|
|
325
|
+
""")
|
|
326
|
+
connected_nodes = cursor.fetchone()[0]
|
|
327
|
+
|
|
328
|
+
if connected_nodes < node_count:
|
|
329
|
+
output.append(f"Connected nodes: {connected_nodes} / {node_count}")
|
|
330
|
+
output.append("Graph has disconnected components")
|
|
331
|
+
else:
|
|
332
|
+
output.append("All nodes are connected")
|
|
333
|
+
|
|
334
|
+
return "\n".join(output)
|
|
335
|
+
|
|
336
|
+
except sqlite3.Error as e:
|
|
337
|
+
await tool_ctx.error(f"SQL error: {str(e)}")
|
|
338
|
+
return f"SQL error: {str(e)}"
|
|
339
|
+
except Exception as e:
|
|
340
|
+
await tool_ctx.error(f"Unexpected error: {str(e)}")
|
|
341
|
+
return f"Error getting statistics: {str(e)}"
|
|
342
|
+
|
|
343
|
+
def register(self, mcp_server) -> None:
|
|
344
|
+
"""Register this tool with the MCP server."""
|
|
345
|
+
pass
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""SQL query tool for direct database queries."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from typing import Annotated, Optional, TypedDict, Unpack, final, override
|
|
5
|
+
|
|
6
|
+
from fastmcp import Context as MCPContext
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
10
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
11
|
+
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
12
|
+
from hanzo_mcp.tools.database.database_manager import DatabaseManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
Query = Annotated[
|
|
16
|
+
str,
|
|
17
|
+
Field(
|
|
18
|
+
description="SQL query to execute",
|
|
19
|
+
min_length=1,
|
|
20
|
+
),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
ProjectPath = Annotated[
|
|
24
|
+
Optional[str],
|
|
25
|
+
Field(
|
|
26
|
+
description="Project path (defaults to current directory)",
|
|
27
|
+
default=None,
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
ReadOnly = Annotated[
|
|
32
|
+
bool,
|
|
33
|
+
Field(
|
|
34
|
+
description="Execute in read-only mode (no INSERT/UPDATE/DELETE)",
|
|
35
|
+
default=True,
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SqlQueryParams(TypedDict, total=False):
|
|
41
|
+
"""Parameters for SQL query tool."""
|
|
42
|
+
|
|
43
|
+
query: str
|
|
44
|
+
project_path: Optional[str]
|
|
45
|
+
read_only: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@final
|
|
49
|
+
class SqlQueryTool(BaseTool):
|
|
50
|
+
"""Tool for executing SQL queries on project databases."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
|
53
|
+
"""Initialize the SQL query tool.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
permission_manager: Permission manager for access control
|
|
57
|
+
db_manager: Database manager instance
|
|
58
|
+
"""
|
|
59
|
+
self.permission_manager = permission_manager
|
|
60
|
+
self.db_manager = db_manager
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
@override
|
|
64
|
+
def name(self) -> str:
|
|
65
|
+
"""Get the tool name."""
|
|
66
|
+
return "sql_query"
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
@override
|
|
70
|
+
def description(self) -> str:
|
|
71
|
+
"""Get the tool description."""
|
|
72
|
+
return """Execute SQL queries on the project's embedded SQLite database.
|
|
73
|
+
|
|
74
|
+
Each project has its own SQLite database with tables:
|
|
75
|
+
- metadata: Key-value store for project metadata
|
|
76
|
+
- files: File information and content
|
|
77
|
+
- symbols: Code symbols (functions, classes, etc.)
|
|
78
|
+
|
|
79
|
+
Features:
|
|
80
|
+
- Direct SQL query execution
|
|
81
|
+
- Read-only mode by default (safety)
|
|
82
|
+
- Returns results in tabular format
|
|
83
|
+
- Automatic project detection
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
- sql_query --query "SELECT * FROM files LIMIT 10"
|
|
87
|
+
- sql_query --query "SELECT name, type FROM symbols WHERE type='function'"
|
|
88
|
+
- sql_query --query "INSERT INTO metadata (key, value) VALUES ('version', '1.0')" --read-only false
|
|
89
|
+
|
|
90
|
+
Note: Use sql_search for text search operations."""
|
|
91
|
+
|
|
92
|
+
@override
|
|
93
|
+
async def call(
|
|
94
|
+
self,
|
|
95
|
+
ctx: MCPContext,
|
|
96
|
+
**params: Unpack[SqlQueryParams],
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Execute SQL query.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
ctx: MCP context
|
|
102
|
+
**params: Tool parameters
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Query results
|
|
106
|
+
"""
|
|
107
|
+
tool_ctx = create_tool_context(ctx)
|
|
108
|
+
await tool_ctx.set_tool_info(self.name)
|
|
109
|
+
|
|
110
|
+
# Extract parameters
|
|
111
|
+
query = params.get("query")
|
|
112
|
+
if not query:
|
|
113
|
+
return "Error: query is required"
|
|
114
|
+
|
|
115
|
+
project_path = params.get("project_path")
|
|
116
|
+
read_only = params.get("read_only", True)
|
|
117
|
+
|
|
118
|
+
# Get project database
|
|
119
|
+
try:
|
|
120
|
+
if project_path:
|
|
121
|
+
project_db = self.db_manager.get_project_db(project_path)
|
|
122
|
+
else:
|
|
123
|
+
import os
|
|
124
|
+
project_db = self.db_manager.get_project_for_path(os.getcwd())
|
|
125
|
+
|
|
126
|
+
if not project_db:
|
|
127
|
+
return "Error: Could not find project database"
|
|
128
|
+
|
|
129
|
+
except PermissionError as e:
|
|
130
|
+
return str(e)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
return f"Error accessing project database: {str(e)}"
|
|
133
|
+
|
|
134
|
+
# Check if query is read-only
|
|
135
|
+
if read_only:
|
|
136
|
+
# Simple check for write operations
|
|
137
|
+
write_keywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER']
|
|
138
|
+
query_upper = query.upper()
|
|
139
|
+
for keyword in write_keywords:
|
|
140
|
+
if keyword in query_upper:
|
|
141
|
+
return f"Error: Query contains {keyword} operation. Set --read-only false to allow write operations."
|
|
142
|
+
|
|
143
|
+
await tool_ctx.info(f"Executing SQL query on project: {project_db.project_path}")
|
|
144
|
+
|
|
145
|
+
# Execute query
|
|
146
|
+
conn = None
|
|
147
|
+
try:
|
|
148
|
+
conn = project_db.get_sqlite_connection()
|
|
149
|
+
cursor = conn.cursor()
|
|
150
|
+
|
|
151
|
+
# Execute the query
|
|
152
|
+
cursor.execute(query)
|
|
153
|
+
|
|
154
|
+
# Handle different query types
|
|
155
|
+
if query.strip().upper().startswith('SELECT'):
|
|
156
|
+
# Fetch results
|
|
157
|
+
results = cursor.fetchall()
|
|
158
|
+
|
|
159
|
+
if not results:
|
|
160
|
+
return "No results found."
|
|
161
|
+
|
|
162
|
+
# Get column names
|
|
163
|
+
columns = [desc[0] for desc in cursor.description]
|
|
164
|
+
|
|
165
|
+
# Format as table
|
|
166
|
+
output = self._format_results_table(columns, results)
|
|
167
|
+
|
|
168
|
+
return f"Query executed successfully. Found {len(results)} row(s).\n\n{output}"
|
|
169
|
+
|
|
170
|
+
else:
|
|
171
|
+
# For non-SELECT queries, commit and return affected rows
|
|
172
|
+
conn.commit()
|
|
173
|
+
affected = cursor.rowcount
|
|
174
|
+
return f"Query executed successfully. Affected {affected} row(s)."
|
|
175
|
+
|
|
176
|
+
except sqlite3.Error as e:
|
|
177
|
+
await tool_ctx.error(f"SQL error: {str(e)}")
|
|
178
|
+
return f"SQL error: {str(e)}"
|
|
179
|
+
except Exception as e:
|
|
180
|
+
await tool_ctx.error(f"Unexpected error: {str(e)}")
|
|
181
|
+
return f"Error executing query: {str(e)}"
|
|
182
|
+
finally:
|
|
183
|
+
if conn:
|
|
184
|
+
conn.close()
|
|
185
|
+
|
|
186
|
+
def _format_results_table(self, columns: list[str], rows: list[tuple]) -> str:
|
|
187
|
+
"""Format query results as a table."""
|
|
188
|
+
if not rows:
|
|
189
|
+
return "No results"
|
|
190
|
+
|
|
191
|
+
# Calculate column widths
|
|
192
|
+
col_widths = []
|
|
193
|
+
for i, col in enumerate(columns):
|
|
194
|
+
max_width = len(col)
|
|
195
|
+
for row in rows[:100]: # Check first 100 rows
|
|
196
|
+
val_str = str(row[i]) if row[i] is not None else "NULL"
|
|
197
|
+
max_width = max(max_width, len(val_str))
|
|
198
|
+
col_widths.append(min(max_width, 50)) # Cap at 50 chars
|
|
199
|
+
|
|
200
|
+
# Build header
|
|
201
|
+
header = " | ".join(col.ljust(width) for col, width in zip(columns, col_widths))
|
|
202
|
+
separator = "-+-".join("-" * width for width in col_widths)
|
|
203
|
+
|
|
204
|
+
# Build rows
|
|
205
|
+
output_rows = []
|
|
206
|
+
for row in rows[:1000]: # Limit to 1000 rows
|
|
207
|
+
row_str = " | ".join(
|
|
208
|
+
self._truncate(str(val) if val is not None else "NULL", width).ljust(width)
|
|
209
|
+
for val, width in zip(row, col_widths)
|
|
210
|
+
)
|
|
211
|
+
output_rows.append(row_str)
|
|
212
|
+
|
|
213
|
+
# Combine
|
|
214
|
+
output = [header, separator] + output_rows
|
|
215
|
+
|
|
216
|
+
if len(rows) > 1000:
|
|
217
|
+
output.append(f"\n... and {len(rows) - 1000} more rows")
|
|
218
|
+
|
|
219
|
+
return "\n".join(output)
|
|
220
|
+
|
|
221
|
+
def _truncate(self, text: str, max_width: int) -> str:
|
|
222
|
+
"""Truncate text to max width."""
|
|
223
|
+
if len(text) <= max_width:
|
|
224
|
+
return text
|
|
225
|
+
return text[:max_width-3] + "..."
|
|
226
|
+
|
|
227
|
+
def register(self, mcp_server) -> None:
|
|
228
|
+
"""Register this tool with the MCP server."""
|
|
229
|
+
pass
|