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.
@@ -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)}"})
@@ -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)}"}