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,670 @@
|
|
|
1
|
+
"""SQL Server Agent job management tools."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from ..config import READONLY_MODE
|
|
10
|
+
from ..utils import (
|
|
11
|
+
format_result,
|
|
12
|
+
get_connection,
|
|
13
|
+
row_to_dict,
|
|
14
|
+
rows_to_dicts,
|
|
15
|
+
sanitize_filename,
|
|
16
|
+
validate_sql_syntax,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_agent_jobs() -> str:
|
|
21
|
+
"""List all SQL Server Agent jobs with their basic info, status, and category."""
|
|
22
|
+
query = """
|
|
23
|
+
SELECT j.job_id, j.name as job_name, j.enabled, j.description,
|
|
24
|
+
j.date_created, j.date_modified, c.name as category,
|
|
25
|
+
SUSER_SNAME(j.owner_sid) as owner,
|
|
26
|
+
CASE WHEN ja.run_requested_date IS NOT NULL AND ja.stop_execution_date IS NULL
|
|
27
|
+
THEN 'Running' ELSE 'Idle' END as current_status
|
|
28
|
+
FROM msdb.dbo.sysjobs j
|
|
29
|
+
LEFT JOIN msdb.dbo.syscategories c ON j.category_id = c.category_id
|
|
30
|
+
LEFT JOIN msdb.dbo.sysjobactivity ja ON j.job_id = ja.job_id
|
|
31
|
+
AND ja.session_id = (SELECT MAX(session_id) FROM msdb.dbo.sysjobactivity)
|
|
32
|
+
ORDER BY j.name
|
|
33
|
+
"""
|
|
34
|
+
conn = get_connection()
|
|
35
|
+
try:
|
|
36
|
+
cursor = conn.cursor()
|
|
37
|
+
cursor.execute(query)
|
|
38
|
+
return format_result(rows_to_dicts(cursor))
|
|
39
|
+
finally:
|
|
40
|
+
conn.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_job_steps(
|
|
44
|
+
job_name: Annotated[str, "The name of the SQL Server Agent job"]
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Get all steps for a specific SQL Server Agent job, including commands and flow control."""
|
|
47
|
+
query = """
|
|
48
|
+
SELECT js.step_id, js.step_name, js.subsystem, js.command, js.database_name,
|
|
49
|
+
js.on_success_action,
|
|
50
|
+
CASE js.on_success_action
|
|
51
|
+
WHEN 1 THEN 'Quit with success' WHEN 2 THEN 'Quit with failure'
|
|
52
|
+
WHEN 3 THEN 'Go to next step' WHEN 4 THEN 'Go to step ' + CAST(js.on_success_step_id AS VARCHAR)
|
|
53
|
+
END as on_success_action_desc,
|
|
54
|
+
js.on_fail_action,
|
|
55
|
+
CASE js.on_fail_action
|
|
56
|
+
WHEN 1 THEN 'Quit with success' WHEN 2 THEN 'Quit with failure'
|
|
57
|
+
WHEN 3 THEN 'Go to next step' WHEN 4 THEN 'Go to step ' + CAST(js.on_fail_step_id AS VARCHAR)
|
|
58
|
+
END as on_fail_action_desc,
|
|
59
|
+
js.retry_attempts, js.retry_interval
|
|
60
|
+
FROM msdb.dbo.sysjobsteps js
|
|
61
|
+
JOIN msdb.dbo.sysjobs j ON js.job_id = j.job_id
|
|
62
|
+
WHERE j.name = ?
|
|
63
|
+
ORDER BY js.step_id
|
|
64
|
+
"""
|
|
65
|
+
conn = get_connection()
|
|
66
|
+
try:
|
|
67
|
+
cursor = conn.cursor()
|
|
68
|
+
cursor.execute(query, (job_name,))
|
|
69
|
+
return format_result(rows_to_dicts(cursor))
|
|
70
|
+
finally:
|
|
71
|
+
conn.close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_job_details(
|
|
75
|
+
job_name: Annotated[str, "The name of the SQL Server Agent job"]
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Get detailed settings and status of a specific SQL Server Agent job, including last run info."""
|
|
78
|
+
query = """
|
|
79
|
+
SELECT j.job_id, j.name as job_name, j.enabled, j.description, j.start_step_id,
|
|
80
|
+
j.date_created, j.date_modified, c.name as category, SUSER_SNAME(j.owner_sid) as owner,
|
|
81
|
+
j.notify_level_eventlog, j.notify_level_email, j.notify_level_page, j.delete_level,
|
|
82
|
+
(SELECT COUNT(*) FROM msdb.dbo.sysjobschedules jsc WHERE jsc.job_id = j.job_id) as schedule_count,
|
|
83
|
+
(SELECT TOP 1 CASE jh.run_status WHEN 0 THEN 'Failed' WHEN 1 THEN 'Succeeded'
|
|
84
|
+
WHEN 2 THEN 'Retry' WHEN 3 THEN 'Canceled' WHEN 4 THEN 'In Progress' END
|
|
85
|
+
FROM msdb.dbo.sysjobhistory jh WHERE jh.job_id = j.job_id AND jh.step_id = 0
|
|
86
|
+
ORDER BY jh.run_date DESC, jh.run_time DESC) as last_run_status,
|
|
87
|
+
(SELECT TOP 1 CONVERT(DATETIME,
|
|
88
|
+
STUFF(STUFF(CAST(jh.run_date AS VARCHAR), 5, 0, '-'), 8, 0, '-') + ' ' +
|
|
89
|
+
STUFF(STUFF(RIGHT('000000' + CAST(jh.run_time AS VARCHAR), 6), 3, 0, ':'), 6, 0, ':'))
|
|
90
|
+
FROM msdb.dbo.sysjobhistory jh WHERE jh.job_id = j.job_id AND jh.step_id = 0
|
|
91
|
+
ORDER BY jh.run_date DESC, jh.run_time DESC) as last_run_datetime,
|
|
92
|
+
(SELECT TOP 1 jh.run_duration FROM msdb.dbo.sysjobhistory jh
|
|
93
|
+
WHERE jh.job_id = j.job_id AND jh.step_id = 0
|
|
94
|
+
ORDER BY jh.run_date DESC, jh.run_time DESC) as last_run_duration_seconds
|
|
95
|
+
FROM msdb.dbo.sysjobs j
|
|
96
|
+
LEFT JOIN msdb.dbo.syscategories c ON j.category_id = c.category_id
|
|
97
|
+
WHERE j.name = ?
|
|
98
|
+
"""
|
|
99
|
+
conn = get_connection()
|
|
100
|
+
try:
|
|
101
|
+
cursor = conn.cursor()
|
|
102
|
+
cursor.execute(query, (job_name,))
|
|
103
|
+
return format_result(rows_to_dicts(cursor))
|
|
104
|
+
finally:
|
|
105
|
+
conn.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_job_schedules(
|
|
109
|
+
job_name: Annotated[str, "The name of the SQL Server Agent job"]
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Get schedules for a specific SQL Server Agent job."""
|
|
112
|
+
query = """
|
|
113
|
+
SELECT s.schedule_id, s.name as schedule_name, s.enabled,
|
|
114
|
+
CASE s.freq_type
|
|
115
|
+
WHEN 1 THEN 'Once' WHEN 4 THEN 'Daily' WHEN 8 THEN 'Weekly'
|
|
116
|
+
WHEN 16 THEN 'Monthly' WHEN 32 THEN 'Monthly relative'
|
|
117
|
+
WHEN 64 THEN 'When SQL Server Agent starts' WHEN 128 THEN 'When computer is idle'
|
|
118
|
+
END as frequency_type,
|
|
119
|
+
s.freq_interval, s.freq_subday_type, s.freq_subday_interval,
|
|
120
|
+
s.freq_relative_interval, s.freq_recurrence_factor,
|
|
121
|
+
CONVERT(TIME, STUFF(STUFF(RIGHT('000000' + CAST(s.active_start_time AS VARCHAR), 6), 3, 0, ':'), 6, 0, ':')) as active_start_time,
|
|
122
|
+
CONVERT(TIME, STUFF(STUFF(RIGHT('000000' + CAST(s.active_end_time AS VARCHAR), 6), 3, 0, ':'), 6, 0, ':')) as active_end_time,
|
|
123
|
+
s.date_created, s.date_modified
|
|
124
|
+
FROM msdb.dbo.sysschedules s
|
|
125
|
+
JOIN msdb.dbo.sysjobschedules js ON s.schedule_id = js.schedule_id
|
|
126
|
+
JOIN msdb.dbo.sysjobs j ON js.job_id = j.job_id
|
|
127
|
+
WHERE j.name = ?
|
|
128
|
+
ORDER BY s.name
|
|
129
|
+
"""
|
|
130
|
+
conn = get_connection()
|
|
131
|
+
try:
|
|
132
|
+
cursor = conn.cursor()
|
|
133
|
+
cursor.execute(query, (job_name,))
|
|
134
|
+
return format_result(rows_to_dicts(cursor))
|
|
135
|
+
finally:
|
|
136
|
+
conn.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_job_history(
|
|
140
|
+
job_name: Annotated[str, "The name of the SQL Server Agent job"],
|
|
141
|
+
limit: Annotated[int, "Number of history records to return (default 50, max 500)"] = 50
|
|
142
|
+
) -> str:
|
|
143
|
+
"""Get execution history for a specific SQL Server Agent job."""
|
|
144
|
+
safe_limit = min(max(1, int(limit)), 500)
|
|
145
|
+
query = f"""
|
|
146
|
+
SELECT TOP {safe_limit} jh.step_id, jh.step_name,
|
|
147
|
+
CASE jh.run_status WHEN 0 THEN 'Failed' WHEN 1 THEN 'Succeeded'
|
|
148
|
+
WHEN 2 THEN 'Retry' WHEN 3 THEN 'Canceled' WHEN 4 THEN 'In Progress' END as run_status,
|
|
149
|
+
CONVERT(DATETIME,
|
|
150
|
+
STUFF(STUFF(CAST(jh.run_date AS VARCHAR), 5, 0, '-'), 8, 0, '-') + ' ' +
|
|
151
|
+
STUFF(STUFF(RIGHT('000000' + CAST(jh.run_time AS VARCHAR), 6), 3, 0, ':'), 6, 0, ':')) as run_datetime,
|
|
152
|
+
jh.run_duration, jh.message
|
|
153
|
+
FROM msdb.dbo.sysjobhistory jh
|
|
154
|
+
JOIN msdb.dbo.sysjobs j ON jh.job_id = j.job_id
|
|
155
|
+
WHERE j.name = ?
|
|
156
|
+
ORDER BY jh.run_date DESC, jh.run_time DESC, jh.step_id
|
|
157
|
+
"""
|
|
158
|
+
conn = get_connection()
|
|
159
|
+
try:
|
|
160
|
+
cursor = conn.cursor()
|
|
161
|
+
cursor.execute(query, (job_name,))
|
|
162
|
+
return format_result(rows_to_dicts(cursor))
|
|
163
|
+
finally:
|
|
164
|
+
conn.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_procedure_definition(database: str | None, schema: str | None, procedure: str) -> str | None:
|
|
168
|
+
"""Get the definition of a stored procedure from the database."""
|
|
169
|
+
schema = schema or "dbo"
|
|
170
|
+
conn = get_connection()
|
|
171
|
+
try:
|
|
172
|
+
cursor = conn.cursor()
|
|
173
|
+
if database:
|
|
174
|
+
db_safe = "".join(c for c in database if c.isalnum() or c in "_-")
|
|
175
|
+
query = f"""
|
|
176
|
+
SELECT m.definition FROM [{db_safe}].sys.sql_modules m
|
|
177
|
+
JOIN [{db_safe}].sys.objects o ON m.object_id = o.object_id
|
|
178
|
+
JOIN [{db_safe}].sys.schemas s ON o.schema_id = s.schema_id
|
|
179
|
+
WHERE o.name = ? AND s.name = ? AND o.type = 'P'
|
|
180
|
+
"""
|
|
181
|
+
cursor.execute(query, (procedure, schema))
|
|
182
|
+
else:
|
|
183
|
+
query = """
|
|
184
|
+
SELECT m.definition FROM sys.sql_modules m
|
|
185
|
+
JOIN sys.objects o ON m.object_id = o.object_id
|
|
186
|
+
JOIN sys.schemas s ON o.schema_id = s.schema_id
|
|
187
|
+
WHERE o.name = ? AND s.name = ? AND o.type = 'P'
|
|
188
|
+
"""
|
|
189
|
+
cursor.execute(query, (procedure, schema))
|
|
190
|
+
result = row_to_dict(cursor)
|
|
191
|
+
return result.get("definition") if result else None
|
|
192
|
+
except Exception:
|
|
193
|
+
return None
|
|
194
|
+
finally:
|
|
195
|
+
conn.close()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _get_procedure_definitions_for_command(sql_command: str, default_database: str = None) -> str:
|
|
199
|
+
"""Get all stored procedure definitions referenced in a SQL command."""
|
|
200
|
+
pattern = r"(?i)(?:exec|execute)\s+(?:@\w+\s*=\s*)?([\[\]\w]+(?:\.[\[\]\w]+)*)(?=\s|$|;|\(|--)"
|
|
201
|
+
matches = re.findall(pattern, sql_command)
|
|
202
|
+
|
|
203
|
+
if not matches:
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
definitions = []
|
|
207
|
+
seen = set()
|
|
208
|
+
|
|
209
|
+
for match in matches:
|
|
210
|
+
full_name = match.replace("[", "").replace("]", "")
|
|
211
|
+
parts = full_name.split(".")
|
|
212
|
+
|
|
213
|
+
if len(parts) == 3:
|
|
214
|
+
db, schema, name = parts
|
|
215
|
+
elif len(parts) == 2:
|
|
216
|
+
db, schema, name = default_database, parts[0], parts[1]
|
|
217
|
+
else:
|
|
218
|
+
db, schema, name = default_database, None, parts[0]
|
|
219
|
+
|
|
220
|
+
key = f"{db or ''}.{schema or 'dbo'}.{name}"
|
|
221
|
+
if key in seen:
|
|
222
|
+
continue
|
|
223
|
+
seen.add(key)
|
|
224
|
+
|
|
225
|
+
definition = _get_procedure_definition(db, schema, name)
|
|
226
|
+
if definition:
|
|
227
|
+
full_name_str = f"{db + '.' if db else ''}{schema + '.' if schema else 'dbo.'}{name}"
|
|
228
|
+
definitions.append(f"""
|
|
229
|
+
/*
|
|
230
|
+
================================================================================
|
|
231
|
+
Stored Procedure: {full_name_str}
|
|
232
|
+
================================================================================
|
|
233
|
+
{definition}
|
|
234
|
+
*/
|
|
235
|
+
""")
|
|
236
|
+
|
|
237
|
+
return "\n".join(definitions)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def export_enabled_jobs_to_files(
|
|
241
|
+
output_dir: Annotated[str, "The output directory path where job folders and SQL files will be created"]
|
|
242
|
+
) -> str:
|
|
243
|
+
"""Export all enabled (non-deprecated) SQL Server Agent jobs and their steps to SQL files."""
|
|
244
|
+
steps_query = """
|
|
245
|
+
SELECT j.job_id, j.name AS job_name, j.description AS job_description,
|
|
246
|
+
js.step_id, js.step_name, js.subsystem, js.command, js.database_name
|
|
247
|
+
FROM msdb.dbo.sysjobs j
|
|
248
|
+
INNER JOIN msdb.dbo.sysjobsteps js ON j.job_id = js.job_id
|
|
249
|
+
WHERE j.enabled = 1
|
|
250
|
+
ORDER BY j.name, js.step_id
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
jobs_info_query = """
|
|
254
|
+
SELECT j.job_id, j.name AS job_name, j.enabled, j.description, j.start_step_id,
|
|
255
|
+
j.date_created, j.date_modified, c.name AS category, SUSER_SNAME(j.owner_sid) AS owner,
|
|
256
|
+
j.notify_level_eventlog, j.notify_level_email, j.notify_level_page, j.delete_level
|
|
257
|
+
FROM msdb.dbo.sysjobs j
|
|
258
|
+
LEFT JOIN msdb.dbo.syscategories c ON j.category_id = c.category_id
|
|
259
|
+
WHERE j.enabled = 1
|
|
260
|
+
ORDER BY j.name
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
schedules_query = """
|
|
264
|
+
SELECT j.name AS job_name, s.schedule_id, s.name AS schedule_name, s.enabled AS schedule_enabled,
|
|
265
|
+
CASE s.freq_type
|
|
266
|
+
WHEN 1 THEN 'Once' WHEN 4 THEN 'Daily' WHEN 8 THEN 'Weekly'
|
|
267
|
+
WHEN 16 THEN 'Monthly' WHEN 32 THEN 'Monthly relative'
|
|
268
|
+
WHEN 64 THEN 'When SQL Server Agent starts' WHEN 128 THEN 'When computer is idle'
|
|
269
|
+
END AS frequency_type,
|
|
270
|
+
s.freq_type AS freq_type_code, s.freq_interval,
|
|
271
|
+
CASE s.freq_subday_type
|
|
272
|
+
WHEN 1 THEN 'At the specified time' WHEN 2 THEN 'Seconds'
|
|
273
|
+
WHEN 4 THEN 'Minutes' WHEN 8 THEN 'Hours'
|
|
274
|
+
END AS subday_type,
|
|
275
|
+
s.freq_subday_type AS freq_subday_type_code, s.freq_subday_interval,
|
|
276
|
+
s.freq_relative_interval, s.freq_recurrence_factor,
|
|
277
|
+
s.active_start_date, s.active_end_date, s.active_start_time, s.active_end_time,
|
|
278
|
+
s.date_created AS schedule_created, s.date_modified AS schedule_modified
|
|
279
|
+
FROM msdb.dbo.sysschedules s
|
|
280
|
+
JOIN msdb.dbo.sysjobschedules js ON s.schedule_id = js.schedule_id
|
|
281
|
+
JOIN msdb.dbo.sysjobs j ON js.job_id = j.job_id
|
|
282
|
+
WHERE j.enabled = 1
|
|
283
|
+
ORDER BY j.name, s.name
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
conn = get_connection()
|
|
287
|
+
try:
|
|
288
|
+
cursor = conn.cursor()
|
|
289
|
+
cursor.execute(steps_query)
|
|
290
|
+
steps_rows = rows_to_dicts(cursor)
|
|
291
|
+
cursor.execute(jobs_info_query)
|
|
292
|
+
jobs_info_rows = rows_to_dicts(cursor)
|
|
293
|
+
cursor.execute(schedules_query)
|
|
294
|
+
schedules_rows = rows_to_dicts(cursor)
|
|
295
|
+
finally:
|
|
296
|
+
conn.close()
|
|
297
|
+
|
|
298
|
+
if not steps_rows:
|
|
299
|
+
return "No enabled jobs found in the database."
|
|
300
|
+
|
|
301
|
+
# Build jobs info dictionary
|
|
302
|
+
jobs_info = {}
|
|
303
|
+
for row in jobs_info_rows:
|
|
304
|
+
job_name = row["job_name"]
|
|
305
|
+
jobs_info[job_name] = {
|
|
306
|
+
"job_id": str(row["job_id"]),
|
|
307
|
+
"job_name": job_name,
|
|
308
|
+
"enabled": bool(row["enabled"]),
|
|
309
|
+
"description": row["description"] or "",
|
|
310
|
+
"start_step_id": row["start_step_id"],
|
|
311
|
+
"date_created": str(row["date_created"]) if row["date_created"] else None,
|
|
312
|
+
"date_modified": str(row["date_modified"]) if row["date_modified"] else None,
|
|
313
|
+
"category": row["category"] or "",
|
|
314
|
+
"owner": row["owner"] or "",
|
|
315
|
+
"schedules": [],
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Add schedules
|
|
319
|
+
for row in schedules_rows:
|
|
320
|
+
job_name = row["job_name"]
|
|
321
|
+
if job_name in jobs_info:
|
|
322
|
+
jobs_info[job_name]["schedules"].append({
|
|
323
|
+
"schedule_id": row["schedule_id"],
|
|
324
|
+
"schedule_name": row["schedule_name"] or "",
|
|
325
|
+
"enabled": bool(row["schedule_enabled"]),
|
|
326
|
+
"frequency_type": row["frequency_type"] or "",
|
|
327
|
+
"freq_interval": row["freq_interval"],
|
|
328
|
+
"subday_type": row["subday_type"] or "",
|
|
329
|
+
"freq_subday_interval": row["freq_subday_interval"],
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
output_path = Path(output_dir)
|
|
333
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
|
|
335
|
+
jobs_created = []
|
|
336
|
+
current_job = None
|
|
337
|
+
job_dir = None
|
|
338
|
+
|
|
339
|
+
for row in steps_rows:
|
|
340
|
+
job_name = row["job_name"]
|
|
341
|
+
|
|
342
|
+
if current_job != job_name:
|
|
343
|
+
current_job = job_name
|
|
344
|
+
job_dir = output_path / sanitize_filename(job_name)
|
|
345
|
+
job_dir.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
jobs_created.append({"job_name": job_name, "directory": str(job_dir), "steps": []})
|
|
347
|
+
|
|
348
|
+
if job_name in jobs_info:
|
|
349
|
+
job_info_file = job_dir / "job_info.json"
|
|
350
|
+
job_info_file.write_text(json.dumps(jobs_info[job_name], indent=2, default=str), encoding="utf-8")
|
|
351
|
+
|
|
352
|
+
step_id = row["step_id"]
|
|
353
|
+
step_name = row["step_name"]
|
|
354
|
+
command = row["command"] or ""
|
|
355
|
+
database_name = row["database_name"] or ""
|
|
356
|
+
subsystem = row["subsystem"] or ""
|
|
357
|
+
|
|
358
|
+
filename = f"{step_id:02d}_{sanitize_filename(step_name)}.sql"
|
|
359
|
+
|
|
360
|
+
procedure_definitions = ""
|
|
361
|
+
if subsystem == "TSQL" and command:
|
|
362
|
+
procedure_definitions = _get_procedure_definitions_for_command(command, database_name)
|
|
363
|
+
|
|
364
|
+
file_content = f"""-- Job: {job_name}
|
|
365
|
+
-- Step ID: {step_id}
|
|
366
|
+
-- Step Name: {step_name}
|
|
367
|
+
-- Subsystem: {subsystem}
|
|
368
|
+
-- Database: {database_name}
|
|
369
|
+
-- ============================================
|
|
370
|
+
|
|
371
|
+
{command}
|
|
372
|
+
{procedure_definitions}"""
|
|
373
|
+
|
|
374
|
+
(job_dir / filename).write_text(file_content, encoding="utf-8")
|
|
375
|
+
jobs_created[-1]["steps"].append({"step_id": step_id, "step_name": step_name, "filename": filename})
|
|
376
|
+
|
|
377
|
+
summary = {
|
|
378
|
+
"output_directory": str(output_path),
|
|
379
|
+
"total_jobs": len(jobs_created),
|
|
380
|
+
"total_steps": sum(len(job["steps"]) for job in jobs_created),
|
|
381
|
+
"jobs": jobs_created,
|
|
382
|
+
}
|
|
383
|
+
return format_result(summary)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _get_existing_step_ids_from_folder(job_dir: Path) -> list[int]:
|
|
387
|
+
"""Scan job folder for existing SQL files and extract their step_ids."""
|
|
388
|
+
step_ids = []
|
|
389
|
+
for sql_file in job_dir.glob("*.sql"):
|
|
390
|
+
match = re.match(r"^(\d+)_", sql_file.stem)
|
|
391
|
+
if match:
|
|
392
|
+
step_ids.append(int(match.group(1)))
|
|
393
|
+
return sorted(step_ids)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _get_next_available_step_id(job_dir: Path, job_name: str = None) -> int:
|
|
397
|
+
"""Determine the next available step_id by checking both folder and database."""
|
|
398
|
+
folder_step_ids = _get_existing_step_ids_from_folder(job_dir)
|
|
399
|
+
|
|
400
|
+
db_step_ids = []
|
|
401
|
+
if job_name:
|
|
402
|
+
try:
|
|
403
|
+
query = """
|
|
404
|
+
SELECT step_id FROM msdb.dbo.sysjobsteps js
|
|
405
|
+
JOIN msdb.dbo.sysjobs j ON js.job_id = j.job_id
|
|
406
|
+
WHERE j.name = ? ORDER BY step_id
|
|
407
|
+
"""
|
|
408
|
+
conn = get_connection()
|
|
409
|
+
try:
|
|
410
|
+
cursor = conn.cursor()
|
|
411
|
+
cursor.execute(query, (job_name,))
|
|
412
|
+
db_step_ids = [row[0] for row in cursor.fetchall()]
|
|
413
|
+
finally:
|
|
414
|
+
conn.close()
|
|
415
|
+
except Exception:
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
all_step_ids = set(folder_step_ids) | set(db_step_ids)
|
|
419
|
+
return max(all_step_ids) + 1 if all_step_ids else 1
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def update_job_step_from_file(
|
|
423
|
+
file_path: Annotated[str, "The absolute path to the SQL file (e.g., /path/to/agent_server_jobs/Job_Name/02_step_name.sql)"]
|
|
424
|
+
) -> str:
|
|
425
|
+
"""Update a SQL Server Agent job step from an edited SQL file."""
|
|
426
|
+
file_path_obj = Path(file_path)
|
|
427
|
+
|
|
428
|
+
if not file_path_obj.exists():
|
|
429
|
+
return format_result({"success": False, "error": f"File not found: {file_path}"})
|
|
430
|
+
|
|
431
|
+
if file_path_obj.suffix.lower() != ".sql":
|
|
432
|
+
return format_result({"success": False, "error": "File must be a .sql file"})
|
|
433
|
+
|
|
434
|
+
job_dir_name = file_path_obj.parent.name
|
|
435
|
+
filename = file_path_obj.stem
|
|
436
|
+
match = re.match(r"^(\d+)_", filename)
|
|
437
|
+
if not match:
|
|
438
|
+
return format_result({"success": False, "error": "Invalid filename format. Expected: {step_id}_{step_name}.sql"})
|
|
439
|
+
|
|
440
|
+
step_id = int(match.group(1))
|
|
441
|
+
file_content = file_path_obj.read_text(encoding="utf-8")
|
|
442
|
+
|
|
443
|
+
# Skip header
|
|
444
|
+
lines = file_content.split("\n")
|
|
445
|
+
sql_start_index = 0
|
|
446
|
+
for i, line in enumerate(lines):
|
|
447
|
+
if line.strip() == "-- ============================================":
|
|
448
|
+
sql_start_index = i + 1
|
|
449
|
+
break
|
|
450
|
+
|
|
451
|
+
sql_command = "\n".join(lines[sql_start_index:]).strip()
|
|
452
|
+
|
|
453
|
+
if not sql_command:
|
|
454
|
+
return format_result({"success": False, "error": "SQL file is empty"})
|
|
455
|
+
|
|
456
|
+
# Find actual job name
|
|
457
|
+
job_info_file = file_path_obj.parent / "job_info.json"
|
|
458
|
+
actual_job_name = None
|
|
459
|
+
if job_info_file.exists():
|
|
460
|
+
try:
|
|
461
|
+
job_info = json.loads(job_info_file.read_text(encoding="utf-8"))
|
|
462
|
+
actual_job_name = job_info.get("job_name")
|
|
463
|
+
except (json.JSONDecodeError, KeyError):
|
|
464
|
+
pass
|
|
465
|
+
|
|
466
|
+
if not actual_job_name:
|
|
467
|
+
actual_job_name = job_dir_name.replace("_", " ")
|
|
468
|
+
|
|
469
|
+
# Verify job step exists
|
|
470
|
+
conn = get_connection()
|
|
471
|
+
try:
|
|
472
|
+
cursor = conn.cursor()
|
|
473
|
+
cursor.execute("""
|
|
474
|
+
SELECT j.job_id, j.name as job_name, js.step_id, js.step_name, js.subsystem, js.database_name
|
|
475
|
+
FROM msdb.dbo.sysjobs j
|
|
476
|
+
INNER JOIN msdb.dbo.sysjobsteps js ON j.job_id = js.job_id
|
|
477
|
+
WHERE j.name = ? AND js.step_id = ?
|
|
478
|
+
""", (actual_job_name, step_id))
|
|
479
|
+
step_info = row_to_dict(cursor)
|
|
480
|
+
finally:
|
|
481
|
+
conn.close()
|
|
482
|
+
|
|
483
|
+
if not step_info:
|
|
484
|
+
return format_result({"success": False, "error": f"Job step not found: job='{actual_job_name}', step_id={step_id}"})
|
|
485
|
+
|
|
486
|
+
# Validate SQL
|
|
487
|
+
validation_result = validate_sql_syntax(sql_command)
|
|
488
|
+
if not validation_result["valid"]:
|
|
489
|
+
return format_result({"success": False, "error": f"SQL validation failed: {validation_result['error']}"})
|
|
490
|
+
|
|
491
|
+
if READONLY_MODE:
|
|
492
|
+
return format_result({"success": False, "error": "Write operations are disabled in read-only mode."})
|
|
493
|
+
|
|
494
|
+
# Update job step
|
|
495
|
+
try:
|
|
496
|
+
conn = get_connection()
|
|
497
|
+
try:
|
|
498
|
+
cursor = conn.cursor()
|
|
499
|
+
cursor.execute("EXEC msdb.dbo.sp_update_jobstep @job_name = ?, @step_id = ?, @command = ?",
|
|
500
|
+
(actual_job_name, step_id, sql_command))
|
|
501
|
+
conn.commit()
|
|
502
|
+
finally:
|
|
503
|
+
conn.close()
|
|
504
|
+
|
|
505
|
+
return format_result({
|
|
506
|
+
"success": True,
|
|
507
|
+
"message": "Successfully updated job step",
|
|
508
|
+
"job_name": actual_job_name,
|
|
509
|
+
"step_id": step_id,
|
|
510
|
+
"step_name": step_info["step_name"],
|
|
511
|
+
})
|
|
512
|
+
except Exception as e:
|
|
513
|
+
return format_result({"success": False, "error": f"Failed to update job step: {str(e)}"})
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def create_job_step_from_file(
|
|
517
|
+
file_path: Annotated[str, "The absolute path to the new SQL file (e.g., /path/to/agent_server_jobs/Job_Name/my_new_step.sql)"],
|
|
518
|
+
database_name: Annotated[str | None, "The database name for the step (defaults to 'master')"] = None,
|
|
519
|
+
auto_rename: Annotated[bool, "If true, automatically rename files with incorrect format"] = True
|
|
520
|
+
) -> str:
|
|
521
|
+
"""Create a new SQL Server Agent job step from a SQL file. Auto-renames files to {step_id}_{step_name}.sql format."""
|
|
522
|
+
file_path_obj = Path(file_path)
|
|
523
|
+
|
|
524
|
+
if not file_path_obj.exists():
|
|
525
|
+
return format_result({"success": False, "error": f"File not found: {file_path}"})
|
|
526
|
+
|
|
527
|
+
job_dir = file_path_obj.parent
|
|
528
|
+
|
|
529
|
+
# Get job name from job_info.json
|
|
530
|
+
job_info_file = job_dir / "job_info.json"
|
|
531
|
+
actual_job_name = None
|
|
532
|
+
if job_info_file.exists():
|
|
533
|
+
try:
|
|
534
|
+
job_info = json.loads(job_info_file.read_text(encoding="utf-8"))
|
|
535
|
+
actual_job_name = job_info.get("job_name")
|
|
536
|
+
except (json.JSONDecodeError, KeyError):
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
if not actual_job_name:
|
|
540
|
+
return format_result({"success": False, "error": "Could not find job_info.json. Cannot determine job name."})
|
|
541
|
+
|
|
542
|
+
# Validate/fix filename format
|
|
543
|
+
if file_path_obj.suffix.lower() != ".sql":
|
|
544
|
+
if auto_rename:
|
|
545
|
+
new_path = file_path_obj.with_suffix(".sql")
|
|
546
|
+
shutil.move(str(file_path_obj), str(new_path))
|
|
547
|
+
file_path_obj = new_path
|
|
548
|
+
else:
|
|
549
|
+
return format_result({"success": False, "error": "File must have .sql extension"})
|
|
550
|
+
|
|
551
|
+
filename = file_path_obj.stem
|
|
552
|
+
match = re.match(r"^(\d+)_(.+)$", filename)
|
|
553
|
+
|
|
554
|
+
if not match:
|
|
555
|
+
step_name = re.sub(r"^\d+[\s_-]*", "", filename).strip() or "new_step"
|
|
556
|
+
step_name_sanitized = sanitize_filename(step_name)
|
|
557
|
+
next_step_id = _get_next_available_step_id(job_dir, actual_job_name)
|
|
558
|
+
new_filename = f"{next_step_id:02d}_{step_name_sanitized}.sql"
|
|
559
|
+
new_path = job_dir / new_filename
|
|
560
|
+
|
|
561
|
+
if auto_rename:
|
|
562
|
+
shutil.move(str(file_path_obj), str(new_path))
|
|
563
|
+
file_path_obj = new_path
|
|
564
|
+
filename = file_path_obj.stem
|
|
565
|
+
match = re.match(r"^(\d+)_(.+)$", filename)
|
|
566
|
+
else:
|
|
567
|
+
return format_result({
|
|
568
|
+
"success": False,
|
|
569
|
+
"error": "Invalid filename format. Expected: {step_id}_{step_name}.sql",
|
|
570
|
+
"suggestion": {"suggested_name": new_filename, "suggested_step_id": next_step_id},
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
step_id = int(match.group(1))
|
|
574
|
+
step_name_from_file = match.group(2).replace("_", " ")
|
|
575
|
+
|
|
576
|
+
# Verify job exists
|
|
577
|
+
conn = get_connection()
|
|
578
|
+
try:
|
|
579
|
+
cursor = conn.cursor()
|
|
580
|
+
cursor.execute("SELECT job_id, name FROM msdb.dbo.sysjobs WHERE name = ?", (actual_job_name,))
|
|
581
|
+
job_info_db = row_to_dict(cursor)
|
|
582
|
+
finally:
|
|
583
|
+
conn.close()
|
|
584
|
+
|
|
585
|
+
if not job_info_db:
|
|
586
|
+
return format_result({"success": False, "error": f"Job not found: '{actual_job_name}'"})
|
|
587
|
+
|
|
588
|
+
# Check if step_id already exists
|
|
589
|
+
conn = get_connection()
|
|
590
|
+
try:
|
|
591
|
+
cursor = conn.cursor()
|
|
592
|
+
cursor.execute("""
|
|
593
|
+
SELECT step_id, step_name FROM msdb.dbo.sysjobsteps js
|
|
594
|
+
JOIN msdb.dbo.sysjobs j ON js.job_id = j.job_id
|
|
595
|
+
WHERE j.name = ? AND js.step_id = ?
|
|
596
|
+
""", (actual_job_name, step_id))
|
|
597
|
+
existing_step = row_to_dict(cursor)
|
|
598
|
+
finally:
|
|
599
|
+
conn.close()
|
|
600
|
+
|
|
601
|
+
if existing_step:
|
|
602
|
+
next_available = _get_next_available_step_id(job_dir, actual_job_name)
|
|
603
|
+
return format_result({
|
|
604
|
+
"success": False,
|
|
605
|
+
"error": f"Step ID {step_id} already exists",
|
|
606
|
+
"suggestion": {"next_available_step_id": next_available},
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
# Read SQL content
|
|
610
|
+
file_content = file_path_obj.read_text(encoding="utf-8")
|
|
611
|
+
lines = file_content.split("\n")
|
|
612
|
+
sql_start_index = 0
|
|
613
|
+
for i, line in enumerate(lines):
|
|
614
|
+
if line.strip() == "-- ============================================":
|
|
615
|
+
sql_start_index = i + 1
|
|
616
|
+
break
|
|
617
|
+
|
|
618
|
+
sql_command = "\n".join(lines[sql_start_index:]).strip() if sql_start_index > 0 else file_content.strip()
|
|
619
|
+
|
|
620
|
+
if not sql_command:
|
|
621
|
+
return format_result({"success": False, "error": "SQL file is empty"})
|
|
622
|
+
|
|
623
|
+
# Validate SQL
|
|
624
|
+
validation_result = validate_sql_syntax(sql_command)
|
|
625
|
+
if not validation_result["valid"]:
|
|
626
|
+
return format_result({"success": False, "error": f"SQL validation failed: {validation_result['error']}"})
|
|
627
|
+
|
|
628
|
+
db_name = database_name or "master"
|
|
629
|
+
|
|
630
|
+
if READONLY_MODE:
|
|
631
|
+
return format_result({"success": False, "error": "Write operations are disabled in read-only mode."})
|
|
632
|
+
|
|
633
|
+
# Create job step
|
|
634
|
+
try:
|
|
635
|
+
conn = get_connection()
|
|
636
|
+
try:
|
|
637
|
+
cursor = conn.cursor()
|
|
638
|
+
cursor.execute("""
|
|
639
|
+
EXEC msdb.dbo.sp_add_jobstep
|
|
640
|
+
@job_name = ?, @step_id = ?, @step_name = ?,
|
|
641
|
+
@subsystem = N'TSQL', @command = ?, @database_name = ?,
|
|
642
|
+
@on_success_action = 1, @on_fail_action = 2,
|
|
643
|
+
@retry_attempts = 0, @retry_interval = 0
|
|
644
|
+
""", (actual_job_name, step_id, step_name_from_file, sql_command, db_name))
|
|
645
|
+
conn.commit()
|
|
646
|
+
finally:
|
|
647
|
+
conn.close()
|
|
648
|
+
|
|
649
|
+
# Update file with proper header
|
|
650
|
+
updated_content = f"""-- Job: {actual_job_name}
|
|
651
|
+
-- Step ID: {step_id}
|
|
652
|
+
-- Step Name: {step_name_from_file}
|
|
653
|
+
-- Subsystem: TSQL
|
|
654
|
+
-- Database: {db_name}
|
|
655
|
+
-- ============================================
|
|
656
|
+
|
|
657
|
+
{sql_command}
|
|
658
|
+
"""
|
|
659
|
+
file_path_obj.write_text(updated_content, encoding="utf-8")
|
|
660
|
+
|
|
661
|
+
return format_result({
|
|
662
|
+
"success": True,
|
|
663
|
+
"message": "Successfully created new job step",
|
|
664
|
+
"job_name": actual_job_name,
|
|
665
|
+
"step_id": step_id,
|
|
666
|
+
"step_name": step_name_from_file,
|
|
667
|
+
"database_name": db_name,
|
|
668
|
+
})
|
|
669
|
+
except Exception as e:
|
|
670
|
+
return format_result({"success": False, "error": f"Failed to create job step: {str(e)}"})
|