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,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)}"})