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,411 @@
1
+ """Unified SQL database tool."""
2
+
3
+ from typing import Annotated, TypedDict, Unpack, final, override, Optional, List, Dict, Any
4
+ import sqlite3
5
+ from pathlib import Path
6
+
7
+ from mcp.server.fastmcp import Context as MCPContext
8
+ from pydantic import Field
9
+
10
+ from hanzo_mcp.tools.common.base import BaseTool
11
+ from hanzo_mcp.tools.common.permissions import PermissionManager
12
+ from hanzo_mcp.tools.database.database_manager import DatabaseManager
13
+
14
+
15
+ # Parameter types
16
+ Query = Annotated[
17
+ Optional[str],
18
+ Field(
19
+ description="SQL query to execute",
20
+ default=None,
21
+ ),
22
+ ]
23
+
24
+ Pattern = Annotated[
25
+ Optional[str],
26
+ Field(
27
+ description="Search pattern for table/column names or data",
28
+ default=None,
29
+ ),
30
+ ]
31
+
32
+ Table = Annotated[
33
+ Optional[str],
34
+ Field(
35
+ description="Table name for operations",
36
+ default=None,
37
+ ),
38
+ ]
39
+
40
+ Action = Annotated[
41
+ str,
42
+ Field(
43
+ description="Action: query (default), search, schema, stats",
44
+ default="query",
45
+ ),
46
+ ]
47
+
48
+ Limit = Annotated[
49
+ int,
50
+ Field(
51
+ description="Maximum rows to return",
52
+ default=100,
53
+ ),
54
+ ]
55
+
56
+
57
+ class SQLParams(TypedDict, total=False):
58
+ """Parameters for SQL tool."""
59
+ query: Optional[str]
60
+ pattern: Optional[str]
61
+ table: Optional[str]
62
+ action: str
63
+ limit: int
64
+
65
+
66
+ @final
67
+ class SQLTool(BaseTool):
68
+ """Unified SQL database tool."""
69
+
70
+ def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
71
+ """Initialize the SQL tool."""
72
+ super().__init__(permission_manager)
73
+ self.db_manager = db_manager
74
+
75
+ @property
76
+ @override
77
+ def name(self) -> str:
78
+ """Get the tool name."""
79
+ return "sql"
80
+
81
+ @property
82
+ @override
83
+ def description(self) -> str:
84
+ """Get the tool description."""
85
+ return """SQLite database. Actions: query (default), search, schema, stats.
86
+
87
+ Usage:
88
+ sql "SELECT * FROM users WHERE active = 1"
89
+ sql --action schema
90
+ sql --action search --pattern "john"
91
+ sql --action stats --table users
92
+ """
93
+
94
+ @override
95
+ async def call(
96
+ self,
97
+ ctx: MCPContext,
98
+ **params: Unpack[SQLParams],
99
+ ) -> str:
100
+ """Execute SQL operation."""
101
+ tool_ctx = self.create_tool_context(ctx)
102
+
103
+ # Get current project database
104
+ project_db = self.db_manager.get_current_project_db()
105
+ if not project_db:
106
+ return "Error: No project database found. Are you in a project directory?"
107
+
108
+ # Extract action
109
+ action = params.get("action", "query")
110
+
111
+ # Route to appropriate handler
112
+ if action == "query":
113
+ return await self._handle_query(project_db, params, tool_ctx)
114
+ elif action == "search":
115
+ return await self._handle_search(project_db, params, tool_ctx)
116
+ elif action == "schema":
117
+ return await self._handle_schema(project_db, params, tool_ctx)
118
+ elif action == "stats":
119
+ return await self._handle_stats(project_db, params, tool_ctx)
120
+ else:
121
+ return f"Error: Unknown action '{action}'. Valid actions: query, search, schema, stats"
122
+
123
+ async def _handle_query(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
124
+ """Execute SQL query."""
125
+ query = params.get("query")
126
+ if not query:
127
+ return "Error: query required for query action"
128
+
129
+ limit = params.get("limit", 100)
130
+
131
+ try:
132
+ with project_db.get_sqlite_connection() as conn:
133
+ # Enable row factory for dict-like access
134
+ conn.row_factory = sqlite3.Row
135
+
136
+ # Add LIMIT if not present in SELECT queries
137
+ query_upper = query.upper().strip()
138
+ if query_upper.startswith("SELECT") and "LIMIT" not in query_upper:
139
+ query = f"{query} LIMIT {limit}"
140
+
141
+ cursor = conn.execute(query)
142
+
143
+ # Handle different query types
144
+ if query_upper.startswith("SELECT"):
145
+ rows = cursor.fetchall()
146
+
147
+ if not rows:
148
+ return "No results found"
149
+
150
+ # Get column names
151
+ columns = [description[0] for description in cursor.description]
152
+
153
+ # Format as table
154
+ output = ["=== Query Results ==="]
155
+ output.append(f"Columns: {', '.join(columns)}")
156
+ output.append("-" * 60)
157
+
158
+ for row in rows:
159
+ row_data = []
160
+ for col in columns:
161
+ value = row[col]
162
+ if value is None:
163
+ value = "NULL"
164
+ elif isinstance(value, str) and len(value) > 50:
165
+ value = value[:50] + "..."
166
+ row_data.append(str(value))
167
+ output.append(" | ".join(row_data))
168
+
169
+ output.append(f"\nRows returned: {len(rows)}")
170
+ if len(rows) == limit:
171
+ output.append(f"(Limited to {limit} rows)")
172
+
173
+ return "\n".join(output)
174
+
175
+ else:
176
+ # For INSERT, UPDATE, DELETE
177
+ conn.commit()
178
+ rows_affected = cursor.rowcount
179
+
180
+ if query_upper.startswith("INSERT"):
181
+ return f"Inserted {rows_affected} row(s)"
182
+ elif query_upper.startswith("UPDATE"):
183
+ return f"Updated {rows_affected} row(s)"
184
+ elif query_upper.startswith("DELETE"):
185
+ return f"Deleted {rows_affected} row(s)"
186
+ else:
187
+ return f"Query executed successfully. Rows affected: {rows_affected}"
188
+
189
+ except Exception as e:
190
+ await tool_ctx.error(f"Query failed: {str(e)}")
191
+ return f"Error executing query: {str(e)}"
192
+
193
+ async def _handle_search(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
194
+ """Search for data in tables."""
195
+ pattern = params.get("pattern")
196
+ if not pattern:
197
+ return "Error: pattern required for search action"
198
+
199
+ table = params.get("table")
200
+ limit = params.get("limit", 100)
201
+
202
+ try:
203
+ with project_db.get_sqlite_connection() as conn:
204
+ conn.row_factory = sqlite3.Row
205
+
206
+ # Get all tables if not specified
207
+ if not table:
208
+ cursor = conn.execute("""
209
+ SELECT name FROM sqlite_master
210
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
211
+ """)
212
+ tables = [row[0] for row in cursor.fetchall()]
213
+ else:
214
+ tables = [table]
215
+
216
+ all_results = []
217
+
218
+ for tbl in tables:
219
+ # Get columns
220
+ cursor = conn.execute(f"PRAGMA table_info({tbl})")
221
+ columns = [row[1] for row in cursor.fetchall()]
222
+
223
+ # Build search query
224
+ where_clauses = [f"{col} LIKE ?" for col in columns]
225
+ query = f"SELECT * FROM {tbl} WHERE {' OR '.join(where_clauses)} LIMIT {limit}"
226
+
227
+ # Search
228
+ cursor = conn.execute(query, [f"%{pattern}%"] * len(columns))
229
+ rows = cursor.fetchall()
230
+
231
+ if rows:
232
+ all_results.append((tbl, columns, rows))
233
+
234
+ if not all_results:
235
+ return f"No results found for pattern '{pattern}'"
236
+
237
+ # Format results
238
+ output = [f"=== Search Results for '{pattern}' ==="]
239
+
240
+ for tbl, columns, rows in all_results:
241
+ output.append(f"\nTable: {tbl}")
242
+ output.append(f"Columns: {', '.join(columns)}")
243
+ output.append("-" * 60)
244
+
245
+ for row in rows:
246
+ row_data = []
247
+ for col in columns:
248
+ value = row[col]
249
+ if value is None:
250
+ value = "NULL"
251
+ elif isinstance(value, str):
252
+ # Highlight matches
253
+ if pattern.lower() in str(value).lower():
254
+ value = f"**{value}**"
255
+ if len(value) > 50:
256
+ value = value[:50] + "..."
257
+ row_data.append(str(value))
258
+ output.append(" | ".join(row_data))
259
+
260
+ output.append(f"Found {len(rows)} row(s) in {tbl}")
261
+
262
+ return "\n".join(output)
263
+
264
+ except Exception as e:
265
+ await tool_ctx.error(f"Search failed: {str(e)}")
266
+ return f"Error during search: {str(e)}"
267
+
268
+ async def _handle_schema(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
269
+ """Show database schema."""
270
+ table = params.get("table")
271
+
272
+ try:
273
+ with project_db.get_sqlite_connection() as conn:
274
+ if table:
275
+ # Show specific table schema
276
+ cursor = conn.execute(f"PRAGMA table_info({table})")
277
+ columns = cursor.fetchall()
278
+
279
+ if not columns:
280
+ return f"Table '{table}' not found"
281
+
282
+ output = [f"=== Schema for table '{table}' ==="]
283
+ output.append("Column | Type | Not Null | Default | Primary Key")
284
+ output.append("-" * 60)
285
+
286
+ for col in columns:
287
+ output.append(f"{col[1]} | {col[2]} | {col[3]} | {col[4]} | {col[5]}")
288
+
289
+ # Get indexes
290
+ cursor = conn.execute(f"PRAGMA index_list({table})")
291
+ indexes = cursor.fetchall()
292
+
293
+ if indexes:
294
+ output.append("\nIndexes:")
295
+ for idx in indexes:
296
+ output.append(f" {idx[1]} (unique: {idx[2]})")
297
+
298
+ else:
299
+ # Show all tables
300
+ cursor = conn.execute("""
301
+ SELECT name, sql FROM sqlite_master
302
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
303
+ ORDER BY name
304
+ """)
305
+ tables = cursor.fetchall()
306
+
307
+ if not tables:
308
+ return "No tables found in database"
309
+
310
+ output = ["=== Database Schema ==="]
311
+
312
+ for table_name, create_sql in tables:
313
+ output.append(f"\nTable: {table_name}")
314
+
315
+ # Get row count
316
+ cursor = conn.execute(f"SELECT COUNT(*) FROM {table_name}")
317
+ count = cursor.fetchone()[0]
318
+ output.append(f"Rows: {count}")
319
+
320
+ # Get columns
321
+ cursor = conn.execute(f"PRAGMA table_info({table_name})")
322
+ columns = cursor.fetchall()
323
+ output.append(f"Columns: {', '.join([col[1] for col in columns])}")
324
+
325
+ return "\n".join(output)
326
+
327
+ except Exception as e:
328
+ await tool_ctx.error(f"Failed to get schema: {str(e)}")
329
+ return f"Error getting schema: {str(e)}"
330
+
331
+ async def _handle_stats(self, project_db, params: Dict[str, Any], tool_ctx) -> str:
332
+ """Get database statistics."""
333
+ table = params.get("table")
334
+
335
+ try:
336
+ with project_db.get_sqlite_connection() as conn:
337
+ output = ["=== Database Statistics ==="]
338
+ output.append(f"Database: {project_db.sqlite_path}")
339
+
340
+ # Get file size
341
+ db_size = project_db.sqlite_path.stat().st_size
342
+ output.append(f"Size: {db_size / 1024 / 1024:.2f} MB")
343
+
344
+ if table:
345
+ # Stats for specific table
346
+ cursor = conn.execute(f"SELECT COUNT(*) FROM {table}")
347
+ count = cursor.fetchone()[0]
348
+
349
+ output.append(f"\nTable: {table}")
350
+ output.append(f"Total rows: {count}")
351
+
352
+ # Get column stats
353
+ cursor = conn.execute(f"PRAGMA table_info({table})")
354
+ columns = cursor.fetchall()
355
+
356
+ output.append("\nColumn statistics:")
357
+ for col in columns:
358
+ col_name = col[1]
359
+ col_type = col[2]
360
+
361
+ # Get basic stats based on type
362
+ if "INT" in col_type.upper() or "REAL" in col_type.upper():
363
+ cursor = conn.execute(f"""
364
+ SELECT
365
+ MIN({col_name}) as min_val,
366
+ MAX({col_name}) as max_val,
367
+ AVG({col_name}) as avg_val,
368
+ COUNT(DISTINCT {col_name}) as distinct_count
369
+ FROM {table}
370
+ """)
371
+ stats = cursor.fetchone()
372
+ output.append(f" {col_name}: min={stats[0]}, max={stats[1]}, avg={stats[2]:.2f}, distinct={stats[3]}")
373
+ else:
374
+ cursor = conn.execute(f"""
375
+ SELECT
376
+ COUNT(DISTINCT {col_name}) as distinct_count,
377
+ COUNT(*) - COUNT({col_name}) as null_count
378
+ FROM {table}
379
+ """)
380
+ stats = cursor.fetchone()
381
+ output.append(f" {col_name}: distinct={stats[0]}, nulls={stats[1]}")
382
+
383
+ else:
384
+ # Overall database stats
385
+ cursor = conn.execute("""
386
+ SELECT name FROM sqlite_master
387
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
388
+ """)
389
+ tables = cursor.fetchall()
390
+
391
+ output.append(f"\nTotal tables: {len(tables)}")
392
+ output.append("\nTable row counts:")
393
+
394
+ total_rows = 0
395
+ for (table_name,) in tables:
396
+ cursor = conn.execute(f"SELECT COUNT(*) FROM {table_name}")
397
+ count = cursor.fetchone()[0]
398
+ total_rows += count
399
+ output.append(f" {table_name}: {count} rows")
400
+
401
+ output.append(f"\nTotal rows across all tables: {total_rows}")
402
+
403
+ return "\n".join(output)
404
+
405
+ except Exception as e:
406
+ await tool_ctx.error(f"Failed to get stats: {str(e)}")
407
+ return f"Error getting stats: {str(e)}"
408
+
409
+ def register(self, mcp_server) -> None:
410
+ """Register this tool with the MCP server."""
411
+ 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 mcp.server.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