mssql-agent-mcp 1.0.0__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.
- mssql_agent_mcp/__init__.py +9 -0
- mssql_agent_mcp/__main__.py +6 -0
- mssql_agent_mcp/config.py +25 -0
- mssql_agent_mcp/server.py +64 -0
- mssql_agent_mcp/tools/__init__.py +58 -0
- mssql_agent_mcp/tools/database.py +193 -0
- mssql_agent_mcp/tools/jobs.py +670 -0
- mssql_agent_mcp/tools/procedures.py +349 -0
- mssql_agent_mcp/utils.py +127 -0
- mssql_agent_mcp-1.0.0.dist-info/METADATA +591 -0
- mssql_agent_mcp-1.0.0.dist-info/RECORD +14 -0
- mssql_agent_mcp-1.0.0.dist-info/WHEEL +4 -0
- mssql_agent_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- mssql_agent_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Stored procedure management tools."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from ..config import READONLY_MODE
|
|
8
|
+
from ..utils import (
|
|
9
|
+
format_result,
|
|
10
|
+
get_connection,
|
|
11
|
+
row_to_dict,
|
|
12
|
+
rows_to_dicts,
|
|
13
|
+
sanitize_filename,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def list_procedures(
|
|
18
|
+
schema: Annotated[str | None, "Schema name to filter procedures (optional)"] = None,
|
|
19
|
+
include_definition: Annotated[bool, "If true, include the full procedure definition"] = False
|
|
20
|
+
) -> str:
|
|
21
|
+
"""List all stored procedures in the current database."""
|
|
22
|
+
if include_definition:
|
|
23
|
+
base_query = """
|
|
24
|
+
SELECT SCHEMA_NAME(p.schema_id) as [schema], p.name as [procedure_name],
|
|
25
|
+
p.create_date, p.modify_date,
|
|
26
|
+
CASE p.is_auto_executed WHEN 1 THEN 'Yes' ELSE 'No' END as [auto_execute],
|
|
27
|
+
CASE WHEN m.definition IS NULL THEN 'Encrypted' ELSE 'Visible' END as [definition_status],
|
|
28
|
+
m.definition as [definition]
|
|
29
|
+
FROM sys.procedures p LEFT JOIN sys.sql_modules m ON p.object_id = m.object_id
|
|
30
|
+
"""
|
|
31
|
+
else:
|
|
32
|
+
base_query = """
|
|
33
|
+
SELECT SCHEMA_NAME(p.schema_id) as [schema], p.name as [procedure_name],
|
|
34
|
+
p.create_date, p.modify_date,
|
|
35
|
+
CASE p.is_auto_executed WHEN 1 THEN 'Yes' ELSE 'No' END as [auto_execute],
|
|
36
|
+
CASE WHEN m.definition IS NULL THEN 'Encrypted' ELSE 'Visible' END as [definition_status]
|
|
37
|
+
FROM sys.procedures p LEFT JOIN sys.sql_modules m ON p.object_id = m.object_id
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
conn = get_connection()
|
|
41
|
+
try:
|
|
42
|
+
cursor = conn.cursor()
|
|
43
|
+
if schema:
|
|
44
|
+
query = base_query + " WHERE SCHEMA_NAME(p.schema_id) = ? ORDER BY [schema], [procedure_name]"
|
|
45
|
+
cursor.execute(query, (schema,))
|
|
46
|
+
else:
|
|
47
|
+
query = base_query + " ORDER BY [schema], [procedure_name]"
|
|
48
|
+
cursor.execute(query)
|
|
49
|
+
return format_result(rows_to_dicts(cursor))
|
|
50
|
+
finally:
|
|
51
|
+
conn.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_all_procedures(
|
|
55
|
+
include_definition: Annotated[bool, "If true, include the full procedure definition"] = False,
|
|
56
|
+
database_filter: Annotated[str | None, "Filter databases by name (partial match)"] = None
|
|
57
|
+
) -> str:
|
|
58
|
+
"""List all stored procedures across ALL databases on the SQL Server instance."""
|
|
59
|
+
db_query = """
|
|
60
|
+
SELECT name FROM sys.databases
|
|
61
|
+
WHERE state_desc = 'ONLINE' AND name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
|
62
|
+
ORDER BY name
|
|
63
|
+
"""
|
|
64
|
+
conn = get_connection()
|
|
65
|
+
try:
|
|
66
|
+
cursor = conn.cursor()
|
|
67
|
+
cursor.execute(db_query)
|
|
68
|
+
databases = [row[0] for row in cursor.fetchall()]
|
|
69
|
+
finally:
|
|
70
|
+
conn.close()
|
|
71
|
+
|
|
72
|
+
if database_filter:
|
|
73
|
+
databases = [db for db in databases if database_filter.lower() in db.lower()]
|
|
74
|
+
|
|
75
|
+
all_procedures = []
|
|
76
|
+
for db_name in databases:
|
|
77
|
+
try:
|
|
78
|
+
safe_db_name = "".join(c for c in db_name if c.isalnum() or c in "_-")
|
|
79
|
+
if include_definition:
|
|
80
|
+
query = f"""
|
|
81
|
+
SELECT '{safe_db_name}' as [database], SCHEMA_NAME(p.schema_id) as [schema],
|
|
82
|
+
p.name as [procedure_name], p.create_date, p.modify_date,
|
|
83
|
+
CASE p.is_auto_executed WHEN 1 THEN 'Yes' ELSE 'No' END as [auto_execute],
|
|
84
|
+
CASE WHEN m.definition IS NULL THEN 'Encrypted' ELSE 'Visible' END as [definition_status],
|
|
85
|
+
m.definition as [definition]
|
|
86
|
+
FROM [{safe_db_name}].sys.procedures p
|
|
87
|
+
LEFT JOIN [{safe_db_name}].sys.sql_modules m ON p.object_id = m.object_id
|
|
88
|
+
ORDER BY SCHEMA_NAME(p.schema_id), p.name
|
|
89
|
+
"""
|
|
90
|
+
else:
|
|
91
|
+
query = f"""
|
|
92
|
+
SELECT '{safe_db_name}' as [database], SCHEMA_NAME(p.schema_id) as [schema],
|
|
93
|
+
p.name as [procedure_name], p.create_date, p.modify_date,
|
|
94
|
+
CASE p.is_auto_executed WHEN 1 THEN 'Yes' ELSE 'No' END as [auto_execute],
|
|
95
|
+
CASE WHEN m.definition IS NULL THEN 'Encrypted' ELSE 'Visible' END as [definition_status]
|
|
96
|
+
FROM [{safe_db_name}].sys.procedures p
|
|
97
|
+
LEFT JOIN [{safe_db_name}].sys.sql_modules m ON p.object_id = m.object_id
|
|
98
|
+
ORDER BY SCHEMA_NAME(p.schema_id), p.name
|
|
99
|
+
"""
|
|
100
|
+
conn = get_connection()
|
|
101
|
+
try:
|
|
102
|
+
cursor = conn.cursor()
|
|
103
|
+
cursor.execute(query)
|
|
104
|
+
all_procedures.extend(rows_to_dicts(cursor))
|
|
105
|
+
finally:
|
|
106
|
+
conn.close()
|
|
107
|
+
except Exception as e:
|
|
108
|
+
all_procedures.append({"database": db_name, "error": f"Cannot access: {str(e)}"})
|
|
109
|
+
|
|
110
|
+
return format_result(all_procedures)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_procedure_details(
|
|
114
|
+
procedure: Annotated[str, "The stored procedure name"],
|
|
115
|
+
schema: Annotated[str, "The schema name (defaults to 'dbo')"] = "dbo"
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Get detailed information about a specific stored procedure including its definition."""
|
|
118
|
+
query = """
|
|
119
|
+
SELECT SCHEMA_NAME(p.schema_id) as [schema], p.name as [procedure_name],
|
|
120
|
+
p.create_date, p.modify_date, p.type_desc as [type],
|
|
121
|
+
CASE p.is_auto_executed WHEN 1 THEN 'Yes' ELSE 'No' END as [auto_execute],
|
|
122
|
+
m.definition as [definition], m.uses_ansi_nulls, m.uses_quoted_identifier, m.is_schema_bound
|
|
123
|
+
FROM sys.procedures p LEFT JOIN sys.sql_modules m ON p.object_id = m.object_id
|
|
124
|
+
WHERE p.name = ? AND SCHEMA_NAME(p.schema_id) = ?
|
|
125
|
+
"""
|
|
126
|
+
conn = get_connection()
|
|
127
|
+
try:
|
|
128
|
+
cursor = conn.cursor()
|
|
129
|
+
cursor.execute(query, (procedure, schema))
|
|
130
|
+
result = row_to_dict(cursor)
|
|
131
|
+
if result:
|
|
132
|
+
return format_result(result)
|
|
133
|
+
return format_result({"error": f"Procedure '{schema}.{procedure}' not found"})
|
|
134
|
+
finally:
|
|
135
|
+
conn.close()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_procedure_parameters(
|
|
139
|
+
procedure: Annotated[str, "The stored procedure name"],
|
|
140
|
+
schema: Annotated[str, "The schema name (defaults to 'dbo')"] = "dbo"
|
|
141
|
+
) -> str:
|
|
142
|
+
"""Get parameters for a specific stored procedure."""
|
|
143
|
+
query = """
|
|
144
|
+
SELECT par.name as [parameter_name], TYPE_NAME(par.user_type_id) as [data_type],
|
|
145
|
+
par.max_length, par.precision, par.scale,
|
|
146
|
+
CASE par.is_output WHEN 1 THEN 'Yes' ELSE 'No' END as [is_output],
|
|
147
|
+
CASE par.has_default_value WHEN 1 THEN 'Yes' ELSE 'No' END as [has_default],
|
|
148
|
+
par.default_value
|
|
149
|
+
FROM sys.procedures p
|
|
150
|
+
JOIN sys.parameters par ON p.object_id = par.object_id
|
|
151
|
+
JOIN sys.schemas s ON p.schema_id = s.schema_id
|
|
152
|
+
WHERE p.name = ? AND s.name = ?
|
|
153
|
+
ORDER BY par.parameter_id
|
|
154
|
+
"""
|
|
155
|
+
conn = get_connection()
|
|
156
|
+
try:
|
|
157
|
+
cursor = conn.cursor()
|
|
158
|
+
cursor.execute(query, (procedure, schema))
|
|
159
|
+
return format_result(rows_to_dicts(cursor))
|
|
160
|
+
finally:
|
|
161
|
+
conn.close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def export_procedures_to_files(
|
|
165
|
+
output_dir: Annotated[str, "The root output directory path where database folders will be created"],
|
|
166
|
+
databases: Annotated[list[str] | None, "List of database names to export (optional, defaults to all)"] = None
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Export all stored procedures from specified databases to SQL files."""
|
|
169
|
+
if databases:
|
|
170
|
+
db_list = databases
|
|
171
|
+
else:
|
|
172
|
+
db_query = """
|
|
173
|
+
SELECT name FROM sys.databases
|
|
174
|
+
WHERE state_desc = 'ONLINE' AND name NOT IN ('master', 'tempdb', 'model', 'msdb')
|
|
175
|
+
ORDER BY name
|
|
176
|
+
"""
|
|
177
|
+
conn = get_connection()
|
|
178
|
+
try:
|
|
179
|
+
cursor = conn.cursor()
|
|
180
|
+
cursor.execute(db_query)
|
|
181
|
+
db_list = [row[0] for row in cursor.fetchall()]
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
|
|
185
|
+
output_path = Path(output_dir)
|
|
186
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
|
|
188
|
+
export_summary = {"output_directory": str(output_path), "databases": []}
|
|
189
|
+
|
|
190
|
+
for db_name in db_list:
|
|
191
|
+
db_summary = {"database": db_name, "schemas": [], "total_procedures": 0, "errors": []}
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
safe_db_name = "".join(c for c in db_name if c.isalnum() or c in "_-")
|
|
195
|
+
query = f"""
|
|
196
|
+
SELECT s.name as [schema_name], p.name as [procedure_name],
|
|
197
|
+
p.create_date, p.modify_date, m.definition
|
|
198
|
+
FROM [{safe_db_name}].sys.procedures p
|
|
199
|
+
JOIN [{safe_db_name}].sys.schemas s ON p.schema_id = s.schema_id
|
|
200
|
+
LEFT JOIN [{safe_db_name}].sys.sql_modules m ON p.object_id = m.object_id
|
|
201
|
+
ORDER BY s.name, p.name
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
conn = get_connection()
|
|
205
|
+
try:
|
|
206
|
+
cursor = conn.cursor()
|
|
207
|
+
cursor.execute(query)
|
|
208
|
+
procedures = rows_to_dicts(cursor)
|
|
209
|
+
finally:
|
|
210
|
+
conn.close()
|
|
211
|
+
|
|
212
|
+
if not procedures:
|
|
213
|
+
db_summary["errors"].append("No procedures found")
|
|
214
|
+
export_summary["databases"].append(db_summary)
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
db_dir = output_path / sanitize_filename(db_name)
|
|
218
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
|
|
220
|
+
schemas = {}
|
|
221
|
+
for proc in procedures:
|
|
222
|
+
schema_name = proc["schema_name"] or "dbo"
|
|
223
|
+
if schema_name not in schemas:
|
|
224
|
+
schemas[schema_name] = []
|
|
225
|
+
schemas[schema_name].append(proc)
|
|
226
|
+
|
|
227
|
+
for schema_name, procs in schemas.items():
|
|
228
|
+
schema_summary = {"schema": schema_name, "procedures": []}
|
|
229
|
+
schema_dir = db_dir / sanitize_filename(schema_name)
|
|
230
|
+
schema_dir.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
|
|
232
|
+
for proc in procs:
|
|
233
|
+
proc_name = proc["procedure_name"]
|
|
234
|
+
definition = proc["definition"]
|
|
235
|
+
|
|
236
|
+
if definition is None:
|
|
237
|
+
file_content = f"""-- Database: {db_name}
|
|
238
|
+
-- Schema: {schema_name}
|
|
239
|
+
-- Procedure: {proc_name}
|
|
240
|
+
-- Created: {proc["create_date"]}
|
|
241
|
+
-- Modified: {proc["modify_date"]}
|
|
242
|
+
-- ============================================
|
|
243
|
+
-- WARNING: This procedure is encrypted and cannot be exported.
|
|
244
|
+
"""
|
|
245
|
+
schema_summary["procedures"].append({"name": proc_name, "status": "encrypted"})
|
|
246
|
+
else:
|
|
247
|
+
file_content = f"""-- Database: {db_name}
|
|
248
|
+
-- Schema: {schema_name}
|
|
249
|
+
-- Procedure: {proc_name}
|
|
250
|
+
-- Created: {proc["create_date"]}
|
|
251
|
+
-- Modified: {proc["modify_date"]}
|
|
252
|
+
-- ============================================
|
|
253
|
+
|
|
254
|
+
{definition}
|
|
255
|
+
"""
|
|
256
|
+
schema_summary["procedures"].append({"name": proc_name, "status": "exported"})
|
|
257
|
+
|
|
258
|
+
file_path = schema_dir / f"{sanitize_filename(proc_name)}.sql"
|
|
259
|
+
file_path.write_text(file_content, encoding="utf-8")
|
|
260
|
+
db_summary["total_procedures"] += 1
|
|
261
|
+
|
|
262
|
+
db_summary["schemas"].append(schema_summary)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
db_summary["errors"].append(f"Error accessing database: {str(e)}")
|
|
265
|
+
|
|
266
|
+
export_summary["databases"].append(db_summary)
|
|
267
|
+
|
|
268
|
+
export_summary["total_databases"] = len(export_summary["databases"])
|
|
269
|
+
export_summary["total_procedures"] = sum(db["total_procedures"] for db in export_summary["databases"])
|
|
270
|
+
return format_result(export_summary)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def update_procedure_from_file(
|
|
274
|
+
file_path: Annotated[str, "The absolute path to the SQL file (e.g., /path/to/procedures/materialdb/dbo/usp_get_facility_info_data.sql)"]
|
|
275
|
+
) -> str:
|
|
276
|
+
"""Update a stored procedure from an edited SQL file. Automatically converts CREATE to ALTER."""
|
|
277
|
+
file_path_obj = Path(file_path)
|
|
278
|
+
|
|
279
|
+
if not file_path_obj.exists():
|
|
280
|
+
return format_result({"success": False, "error": f"File not found: {file_path}"})
|
|
281
|
+
|
|
282
|
+
if file_path_obj.suffix.lower() != ".sql":
|
|
283
|
+
return format_result({"success": False, "error": "File must be a .sql file"})
|
|
284
|
+
|
|
285
|
+
procedure_name = file_path_obj.stem
|
|
286
|
+
schema_name = file_path_obj.parent.name
|
|
287
|
+
database_name = file_path_obj.parent.parent.name
|
|
288
|
+
|
|
289
|
+
file_content = file_path_obj.read_text(encoding="utf-8")
|
|
290
|
+
|
|
291
|
+
# Extract metadata from header
|
|
292
|
+
header_db_match = re.search(r"--\s*Database:\s*(.+)", file_content)
|
|
293
|
+
header_schema_match = re.search(r"--\s*Schema:\s*(.+)", file_content)
|
|
294
|
+
header_proc_match = re.search(r"--\s*Procedure:\s*(.+)", file_content)
|
|
295
|
+
|
|
296
|
+
if header_db_match:
|
|
297
|
+
database_name = header_db_match.group(1).strip()
|
|
298
|
+
if header_schema_match:
|
|
299
|
+
schema_name = header_schema_match.group(1).strip()
|
|
300
|
+
if header_proc_match:
|
|
301
|
+
procedure_name = header_proc_match.group(1).strip()
|
|
302
|
+
|
|
303
|
+
# Skip header comments
|
|
304
|
+
lines = file_content.split("\n")
|
|
305
|
+
sql_start_index = 0
|
|
306
|
+
for i, line in enumerate(lines):
|
|
307
|
+
if line.strip() == "-- ============================================":
|
|
308
|
+
sql_start_index = i + 1
|
|
309
|
+
break
|
|
310
|
+
|
|
311
|
+
sql_content = "\n".join(lines[sql_start_index:]).strip()
|
|
312
|
+
|
|
313
|
+
if not sql_content:
|
|
314
|
+
return format_result({"success": False, "error": "SQL file is empty"})
|
|
315
|
+
|
|
316
|
+
if "This procedure is encrypted" in file_content:
|
|
317
|
+
return format_result({"success": False, "error": "Cannot update an encrypted procedure"})
|
|
318
|
+
|
|
319
|
+
# Convert CREATE to ALTER
|
|
320
|
+
sql_upper = sql_content.upper().strip()
|
|
321
|
+
if sql_upper.startswith("CREATE"):
|
|
322
|
+
sql_content = re.sub(r"^CREATE(\s+)PROC(EDURE)?", r"ALTER\1PROC\2", sql_content, count=1, flags=re.IGNORECASE)
|
|
323
|
+
elif not sql_upper.startswith("ALTER"):
|
|
324
|
+
return format_result({"success": False, "error": "SQL must contain CREATE or ALTER PROCEDURE statement"})
|
|
325
|
+
|
|
326
|
+
if READONLY_MODE:
|
|
327
|
+
return format_result({"success": False, "error": "Write operations are disabled in read-only mode."})
|
|
328
|
+
|
|
329
|
+
safe_db_name = "".join(c for c in database_name if c.isalnum() or c in "_-")
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
conn = get_connection()
|
|
333
|
+
try:
|
|
334
|
+
cursor = conn.cursor()
|
|
335
|
+
cursor.execute(f"USE [{safe_db_name}]")
|
|
336
|
+
cursor.execute(sql_content)
|
|
337
|
+
conn.commit()
|
|
338
|
+
finally:
|
|
339
|
+
conn.close()
|
|
340
|
+
|
|
341
|
+
return format_result({
|
|
342
|
+
"success": True,
|
|
343
|
+
"message": "Successfully updated procedure",
|
|
344
|
+
"database": database_name,
|
|
345
|
+
"schema": schema_name,
|
|
346
|
+
"procedure": procedure_name,
|
|
347
|
+
})
|
|
348
|
+
except Exception as e:
|
|
349
|
+
return format_result({"success": False, "error": f"Failed to update procedure: {str(e)}"})
|
mssql_agent_mcp/utils.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Database connection and utility functions for MSSQL MCP Server."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import struct
|
|
6
|
+
|
|
7
|
+
import pyodbc
|
|
8
|
+
import sqlparse
|
|
9
|
+
|
|
10
|
+
from .config import BLOCKED_STATEMENT_TYPES, DB_CONFIG, READONLY_MODE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_query(sql: str) -> tuple[bool, str]:
|
|
14
|
+
"""Validate SQL query against readonly restrictions."""
|
|
15
|
+
if not READONLY_MODE:
|
|
16
|
+
return True, ""
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
parsed = sqlparse.parse(sql)
|
|
20
|
+
for statement in parsed:
|
|
21
|
+
stmt_type = statement.get_type()
|
|
22
|
+
if stmt_type and stmt_type.upper() in BLOCKED_STATEMENT_TYPES:
|
|
23
|
+
return False, f"Blocked: {stmt_type} statements are not allowed in read-only mode."
|
|
24
|
+
|
|
25
|
+
tokens = [t for t in statement.tokens if not t.is_whitespace]
|
|
26
|
+
if tokens:
|
|
27
|
+
first_token = str(tokens[0]).upper().strip()
|
|
28
|
+
for blocked in BLOCKED_STATEMENT_TYPES:
|
|
29
|
+
if first_token.startswith(blocked):
|
|
30
|
+
return False, f"Blocked: {blocked} statements are not allowed in read-only mode."
|
|
31
|
+
except Exception as e:
|
|
32
|
+
return False, f"Query validation failed: {str(e)}"
|
|
33
|
+
|
|
34
|
+
return True, ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_azure_token() -> bytes:
|
|
38
|
+
"""Get Azure AD token for database authentication."""
|
|
39
|
+
try:
|
|
40
|
+
from azure.identity import DefaultAzureCredential
|
|
41
|
+
except ImportError:
|
|
42
|
+
raise ImportError(
|
|
43
|
+
"Azure AD authentication requires azure-identity. "
|
|
44
|
+
"Install with: pip install mssql-agent-mcp[azure]"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
credential = DefaultAzureCredential()
|
|
48
|
+
token = credential.get_token("https://database.windows.net/.default")
|
|
49
|
+
token_bytes = token.token.encode("utf-16-le")
|
|
50
|
+
return struct.pack(f"<I{len(token_bytes)}s", len(token_bytes), token_bytes)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_connection():
|
|
54
|
+
"""Create and return a database connection."""
|
|
55
|
+
auth_mode = DB_CONFIG["auth_mode"].lower()
|
|
56
|
+
|
|
57
|
+
conn_str_parts = [
|
|
58
|
+
f"DRIVER={{{DB_CONFIG['driver']}}}",
|
|
59
|
+
f"SERVER={DB_CONFIG['server']},{DB_CONFIG['port']}",
|
|
60
|
+
f"DATABASE={DB_CONFIG['database']}",
|
|
61
|
+
f"Encrypt={DB_CONFIG['encrypt']}",
|
|
62
|
+
f"TrustServerCertificate={DB_CONFIG['trust_server_certificate']}",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
if auth_mode == "windows":
|
|
66
|
+
conn_str_parts.append("Trusted_Connection=yes")
|
|
67
|
+
return pyodbc.connect(";".join(conn_str_parts))
|
|
68
|
+
elif auth_mode == "azure":
|
|
69
|
+
token_struct = get_azure_token()
|
|
70
|
+
return pyodbc.connect(";".join(conn_str_parts), attrs_before={1256: token_struct})
|
|
71
|
+
else:
|
|
72
|
+
conn_str_parts.extend([
|
|
73
|
+
f"UID={DB_CONFIG['user']}",
|
|
74
|
+
f"PWD={DB_CONFIG['password']}",
|
|
75
|
+
])
|
|
76
|
+
return pyodbc.connect(";".join(conn_str_parts))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def rows_to_dicts(cursor) -> list[dict]:
|
|
80
|
+
"""Convert pyodbc cursor results to list of dictionaries."""
|
|
81
|
+
columns = [column[0] for column in cursor.description] if cursor.description else []
|
|
82
|
+
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def row_to_dict(cursor) -> dict | None:
|
|
86
|
+
"""Convert single pyodbc cursor result to dictionary."""
|
|
87
|
+
if not cursor.description:
|
|
88
|
+
return None
|
|
89
|
+
columns = [column[0] for column in cursor.description]
|
|
90
|
+
row = cursor.fetchone()
|
|
91
|
+
return dict(zip(columns, row)) if row else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_result(data) -> str:
|
|
95
|
+
"""Format query results as JSON string."""
|
|
96
|
+
return json.dumps(data, indent=2, default=str)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sanitize_filename(name: str) -> str:
|
|
100
|
+
"""Sanitize a string for use as a filename."""
|
|
101
|
+
sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
|
|
102
|
+
sanitized = re.sub(r"[\s_]+", "_", sanitized)
|
|
103
|
+
return sanitized.strip("_ ")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_sql_syntax(sql: str) -> dict:
|
|
107
|
+
"""Validate SQL syntax using SET PARSEONLY."""
|
|
108
|
+
sql_stripped = sql.strip()
|
|
109
|
+
if not sql_stripped:
|
|
110
|
+
return {"valid": False, "error": "SQL command is empty"}
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
conn = get_connection()
|
|
114
|
+
try:
|
|
115
|
+
cursor = conn.cursor()
|
|
116
|
+
cursor.execute("SET PARSEONLY ON")
|
|
117
|
+
try:
|
|
118
|
+
cursor.execute(sql)
|
|
119
|
+
except Exception as parse_error:
|
|
120
|
+
return {"valid": False, "error": f"SQL syntax error: {str(parse_error)}"}
|
|
121
|
+
finally:
|
|
122
|
+
cursor.execute("SET PARSEONLY OFF")
|
|
123
|
+
finally:
|
|
124
|
+
conn.close()
|
|
125
|
+
return {"valid": True, "error": None}
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return {"valid": True, "error": None, "warning": f"Could not validate: {str(e)}"}
|