Coverage for src/mcp_pdb/main.py: 22%

589 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-10 13:55 +0300

1import atexit 

2import os 

3import queue 

4import re 

5import shlex 

6import shutil 

7import signal 

8import subprocess 

9import sys 

10import threading 

11import time 

12import traceback 

13 

14from mcp.server.fastmcp import FastMCP 

15 

16# Initialize FastMCP server 

17mcp = FastMCP("mcp-pdb") 

18 

19# --- Global Variables --- 

20pdb_process = None 

21pdb_output_queue = queue.Queue() 

22pdb_running = False 

23current_file = None # Absolute path of the file being debugged 

24current_project_root = None # Root directory of the project being debugged 

25current_args = "" # Additional args passed to the script/pytest 

26current_use_pytest = False # Flag indicating if pytest was used 

27breakpoints = {} # Tracks breakpoints: {abs_file_path: {line_num: {command_str, bp_number}}} 

28output_thread = None # Thread object for reading output 

29 

30# --- Helper Functions --- 

31 

32def read_pdb_output(process, output_queue): 

33 """Read output from the pdb process and put it in the queue.""" 

34 try: 

35 # Use iter() with readline to avoid blocking readline() indefinitely 

36 # if the process exits unexpectedly or stdout closes. 

37 for line_bytes in iter(process.stdout.readline, b''): 

38 output_queue.put(line_bytes.decode('utf-8', errors='replace').rstrip()) 

39 except ValueError: 

40 # Handle ValueError if stdout is closed prematurely (e.g., process killed) 

41 print("PDB output reader: ValueError (stdout likely closed).", file=sys.stderr) 

42 except Exception as e: 

43 print(f"PDB output reader: Unexpected error: {e}", file=sys.stderr) 

44 # Optionally log traceback here if needed 

45 finally: 

46 # Ensure stdout is closed if loop finishes normally or breaks 

47 if process and process.stdout and not process.stdout.closed: 

48 try: 

49 process.stdout.close() 

50 except Exception as e: 

51 print(f"PDB output reader: Error closing stdout: {e}", file=sys.stderr) 

52 print("PDB output reader thread finished.", file=sys.stderr) 

53 

54 

55def get_pdb_output(timeout=0.5): 

56 """Get accumulated output from the pdb process queue.""" 

57 output = [] 

58 start_time = time.monotonic() 

59 while True: 

60 try: 

61 # Calculate remaining time 

62 remaining_time = timeout - (time.monotonic() - start_time) 

63 if remaining_time <= 0: 

64 break 

65 line = pdb_output_queue.get(timeout=remaining_time) 

66 output.append(line) 

67 # Heuristic: If we see the pdb prompt, we likely have the main response 

68 # Be careful as some commands might produce output containing (Pdb) 

69 # Let's rely more on the timeout for now, but keep this in mind. 

70 if line.strip().endswith('(Pdb)'): 

71 break 

72 except queue.Empty: 

73 break # Timeout reached 

74 return '\n'.join(output) 

75 

76 

77def send_to_pdb(command, timeout_multiplier=1.0): 

78 """Send a command to the pdb process and get its response. 

79 

80 Args: 

81 command: The PDB command to send 

82 timeout_multiplier: Multiplier to adjust timeout for complex commands 

83 """ 

84 global pdb_process, pdb_running 

85 

86 if pdb_process and pdb_process.poll() is None: 

87 # Clear queue before sending command to get only relevant output 

88 while not pdb_output_queue.empty(): 

89 try: pdb_output_queue.get_nowait() 

90 except queue.Empty: break 

91 

92 try: 

93 # Determine appropriate timeout based on command type 

94 base_timeout = 1.5 

95 if command.strip().lower() in ('c', 'continue', 'r', 'run', 'until', 'unt'): 

96 timeout = base_timeout * 3 * timeout_multiplier 

97 else: 

98 timeout = base_timeout * timeout_multiplier 

99 

100 pdb_process.stdin.write((command + '\n').encode('utf-8')) 

101 pdb_process.stdin.flush() 

102 # Wait a bit for command processing. Adjust if needed. 

103 output = get_pdb_output(timeout=timeout) # Adjusted timeout for commands 

104 

105 # Check if process ended right after the command 

106 if pdb_process.poll() is not None: 

107 pdb_running = False 

108 # Try to get any final output 

109 final_output = get_pdb_output(timeout=0.1) 

110 return f"Command output:\n{output}\n{final_output}\n\n*** The debugging session has ended. ***" 

111 

112 return output 

113 

114 except (OSError, BrokenPipeError) as e: 

115 print(f"Error writing to PDB stdin: {e}", file=sys.stderr) 

116 pdb_running = False 

117 # Try to get final output 

118 final_output = get_pdb_output(timeout=0.1) 

119 if pdb_process: 

120 pdb_process.terminate() # Ensure process is stopped 

121 pdb_process.wait(timeout=0.5) 

122 return f"Error communicating with PDB: {e}\nFinal Output:\n{final_output}\n\n*** The debugging session has likely ended. ***" 

123 except Exception as e: 

124 print(f"Unexpected error in send_to_pdb: {e}", file=sys.stderr) 

125 pdb_running = False 

126 return f"Unexpected error sending command: {e}" 

127 

128 elif pdb_running: 

129 # Process exists but poll() is not None, means it terminated 

130 pdb_running = False 

131 final_output = get_pdb_output(timeout=0.1) 

132 return f"No active pdb process (it terminated).\nFinal Output:\n{final_output}" 

133 else: 

134 return "No active pdb process." 

135 

136 

137def find_project_root(start_path): 

138 """Find the project root containing pyproject.toml, .git or other indicators, searching upwards.""" 

139 current_dir = os.path.abspath(start_path) 

140 # Common project root indicators 

141 root_indicators = ["pyproject.toml", ".git", "setup.py", "requirements.txt", "Pipfile", "poetry.lock"] 

142 

143 # Guard against infinite loop if start_path is already root 

144 while current_dir and current_dir != os.path.dirname(current_dir): 

145 for indicator in root_indicators: 

146 if os.path.exists(os.path.join(current_dir, indicator)): 

147 print(f"Found project root indicator '{indicator}' at: {current_dir}") 

148 return current_dir 

149 current_dir = os.path.dirname(current_dir) 

150 

151 # Fallback to the starting path's directory if no indicator found 

152 fallback_dir = os.path.abspath(start_path) 

153 print(f"No common project root indicators found upwards. Falling back to: {fallback_dir}") 

154 return fallback_dir 

155 

156 

157def find_venv_details(project_root): 

158 """Check for virtual environment directories and return python path and bin dir.""" 

159 common_venv_names = ['.venv', 'venv', 'env', '.env', 'virtualenv', '.virtualenv'] 

160 common_venv_locations = [project_root] 

161 

162 # Also check parent directory as some projects keep venvs one level up 

163 parent_dir = os.path.dirname(project_root) 

164 if parent_dir != project_root: # Avoid infinite loop at filesystem root 

165 common_venv_locations.append(parent_dir) 

166 

167 # First check for environment variables pointing to active virtual env 

168 if 'VIRTUAL_ENV' in os.environ: 

169 venv_path = os.environ['VIRTUAL_ENV'] 

170 if os.path.isdir(venv_path): 

171 if sys.platform == "win32": 

172 python_exe = os.path.join(venv_path, 'Scripts', 'python.exe') 

173 bin_dir = os.path.join(venv_path, 'Scripts') 

174 else: 

175 python_exe = os.path.join(venv_path, 'bin', 'python') 

176 bin_dir = os.path.join(venv_path, 'bin') 

177 

178 if os.path.exists(python_exe): 

179 print(f"Found active virtual environment: {venv_path}") 

180 return python_exe, bin_dir 

181 

182 # Check for conda environment 

183 if 'CONDA_PREFIX' in os.environ: 

184 conda_path = os.environ['CONDA_PREFIX'] 

185 if sys.platform == "win32": 

186 python_exe = os.path.join(conda_path, 'python.exe') 

187 bin_dir = conda_path 

188 else: 

189 python_exe = os.path.join(conda_path, 'bin', 'python') 

190 bin_dir = os.path.join(conda_path, 'bin') 

191 

192 if os.path.exists(python_exe): 

193 print(f"Found conda environment: {conda_path}") 

194 return python_exe, bin_dir 

195 

196 for location in common_venv_locations: 

197 for name in common_venv_names: 

198 venv_path = os.path.join(location, name) 

199 if os.path.isdir(venv_path): 

200 if sys.platform == "win32": 

201 python_exe = os.path.join(venv_path, 'Scripts', 'python.exe') 

202 bin_dir = os.path.join(venv_path, 'Scripts') 

203 else: 

204 python_exe = os.path.join(venv_path, 'bin', 'python') 

205 bin_dir = os.path.join(venv_path, 'bin') 

206 

207 if os.path.exists(python_exe): 

208 print(f"Found virtual environment: {venv_path}") 

209 return python_exe, bin_dir 

210 

211 # Look for other common Python installations 

212 if sys.platform == "win32": 

213 for path in os.environ["PATH"].split(os.pathsep): 

214 py_exe = os.path.join(path, "python.exe") 

215 if os.path.exists(py_exe): 

216 return py_exe, path 

217 else: 

218 # On Unix, check if we have a user-installed Python in .local/bin 

219 local_bin = os.path.expanduser("~/.local/bin") 

220 if os.path.exists(local_bin): 

221 for f in os.listdir(local_bin): 

222 if f.startswith("python") and os.path.isfile(os.path.join(local_bin, f)): 

223 py_exe = os.path.join(local_bin, f) 

224 return py_exe, local_bin 

225 

226 print(f"No virtual environment found in: {project_root}") 

227 return None, None 

228 

229 

230def sanitize_arguments(args_str): 

231 """Validate and sanitize command line arguments to prevent injection.""" 

232 dangerous_patterns = [';', '&&', '||', '`', '$(', '|', '>', '<'] 

233 for pattern in dangerous_patterns: 

234 if pattern in args_str: 

235 raise ValueError(f"Invalid character in arguments: {pattern}") 

236 

237 try: 

238 parsed_args = shlex.split(args_str) 

239 return parsed_args 

240 except ValueError as e: 

241 raise ValueError(f"Error parsing arguments: {e}") 

242 

243 

244# --- MCP Tools --- 

245 

246@mcp.tool() 

247def start_debug(file_path: str, use_pytest: bool = False, args: str = "") -> str: 

248 """Start a debugging session on a Python file within its project context. 

249 

250 Args: 

251 file_path: Path to the Python file or test module to debug. 

252 use_pytest: If True, run using pytest with --pdb. 

253 args: Additional arguments to pass to the Python script or pytest (space-separated). 

254 """ 

255 global pdb_process, pdb_running, current_file, current_project_root, output_thread 

256 global pdb_output_queue, breakpoints, current_args, current_use_pytest 

257 

258 if pdb_running: 

259 # Check if the process is *really* still running 

260 if pdb_process and pdb_process.poll() is None: 

261 return f"Debugging session already running for {current_file}. Use restart_debug or end_debug first." 

262 else: 

263 print("Detected stale 'pdb_running' state. Resetting.") 

264 pdb_running = False # Reset state if process died 

265 

266 # --- Validate Input and Find Project --- 

267 # Try multiple potential locations for the file 

268 paths_to_check = [ 

269 file_path, # As provided 

270 os.path.abspath(file_path), # Absolute path 

271 os.path.join(os.getcwd(), file_path), # Relative to CWD 

272 ] 

273 

274 # Add common directories to search in 

275 for common_dir in ["src", "tests", "lib"]: 

276 paths_to_check.append(os.path.join(os.getcwd(), common_dir, file_path)) 

277 

278 # Check all possible paths 

279 abs_file_path = None 

280 for path in paths_to_check: 

281 if os.path.exists(path): 

282 abs_file_path = path 

283 break 

284 

285 if not abs_file_path: 

286 return f"Error: File not found at '{file_path}' (checked multiple locations including CWD, src/, tests/, lib/)" 

287 

288 file_dir = os.path.dirname(abs_file_path) 

289 project_root = find_project_root(file_dir) 

290 

291 # --- Update Global State --- 

292 current_project_root = project_root 

293 current_file = abs_file_path 

294 current_args = args 

295 current_use_pytest = use_pytest 

296 

297 # Store original working directory before changing 

298 original_working_dir = os.getcwd() 

299 print(f"Original working directory: {original_working_dir}") 

300 

301 # Initialize breakpoints structure for this file if new 

302 if current_file not in breakpoints: 

303 breakpoints[current_file] = {} 

304 

305 # Clear the output queue rigorously 

306 while not pdb_output_queue.empty(): 

307 try: pdb_output_queue.get_nowait() 

308 except queue.Empty: break 

309 

310 try: 

311 # --- Determine Execution Environment --- 

312 use_uv = False 

313 uv_path = shutil.which("uv") 

314 venv_python_path = None 

315 venv_bin_dir = None 

316 

317 if uv_path and os.path.exists(os.path.join(project_root, "pyproject.toml")): 

318 # More reliably check for uv.lock as primary indicator 

319 if os.path.exists(os.path.join(project_root, "uv.lock")): 

320 print("Found uv.lock, assuming uv project.") 

321 use_uv = True 

322 else: 

323 # Optional: Could check pyproject.toml for [tool.uv] 

324 print("Found pyproject.toml and uv executable, tentatively trying uv.") 

325 # We'll let `uv run` determine if it's actually a uv project. 

326 use_uv = True # Tentatively true 

327 

328 if not use_uv: 

329 # Look for a standard venv if uv isn't detected/used 

330 venv_python_path, venv_bin_dir = find_venv_details(project_root) 

331 

332 # --- Prepare Command and Subprocess Environment --- 

333 cmd = [] 

334 # Start with a clean environment copy, modify selectively 

335 env = os.environ.copy() 

336 

337 # Calculate relative path from project root (preferred for tools) 

338 try: 

339 rel_file_path = os.path.relpath(abs_file_path, project_root) 

340 # Handle edge case where file is the project root itself (e.g., debugging a script there) 

341 if rel_file_path == '.': 

342 rel_file_path = os.path.basename(abs_file_path) 

343 

344 except ValueError: 

345 # Handle cases where file is on a different drive (Windows) 

346 print(f"Warning: File '{abs_file_path}' not relative to project root '{project_root}'. Using absolute path.") 

347 rel_file_path = abs_file_path # Use absolute path if relative fails 

348 

349 

350 # Safely parse arguments using sanitize_arguments 

351 try: 

352 parsed_args = sanitize_arguments(args) 

353 except ValueError as e: 

354 return f"Error in arguments: {e}" 

355 

356 # Determine command based on environment 

357 if use_uv: 

358 print(f"Using uv run in: {project_root}") 

359 # Clean potentially conflicting env vars for uv run 

360 env.pop('VIRTUAL_ENV', None) 

361 env.pop('PYTHONHOME', None) 

362 base_cmd = ["uv", "run", "--"] 

363 if use_pytest: 

364 # -s: show stdout/stderr, --pdbcls: use standard pdb 

365 base_cmd.extend(["pytest", "--pdb", "-s", "--pdbcls=pdb:Pdb"]) 

366 else: 

367 base_cmd.extend(["python", "-m", "pdb"]) 

368 cmd = base_cmd + [rel_file_path] + parsed_args 

369 elif venv_python_path: 

370 print(f"Using venv Python: {venv_python_path}") 

371 venv_dir = os.path.dirname(os.path.dirname(venv_bin_dir)) # Get actual venv root 

372 env['VIRTUAL_ENV'] = venv_dir 

373 env['PATH'] = f"{venv_bin_dir}{os.pathsep}{env.get('PATH', '')}" 

374 env.pop('PYTHONHOME', None) 

375 

376 # Critical addition: Set PYTHONPATH to include project root 

377 env['PYTHONPATH'] = f"{project_root}{os.pathsep}{env.get('PYTHONPATH', '')}" 

378 

379 # Force unbuffered output for better debugging experience 

380 env['PYTHONUNBUFFERED'] = '1' 

381 

382 if use_pytest: 

383 # Find pytest within the venv 

384 pytest_exe = os.path.join(venv_bin_dir, 'pytest' + ('.exe' if sys.platform == 'win32' else '')) 

385 if not os.path.exists(pytest_exe): 

386 # Try finding via the venv python itself 

387 try: 

388 result = subprocess.run([venv_python_path, "-m", "pytest", "--version"], capture_output=True, text=True, check=True, cwd=project_root, env=env) 

389 print(f"Found pytest via '{venv_python_path} -m pytest'") 

390 cmd = [venv_python_path, "-m", "pytest", "--pdb", "-s", "--pdbcls=pdb:Pdb", rel_file_path] + parsed_args 

391 except (subprocess.CalledProcessError, FileNotFoundError): 

392 return f"Error: pytest not found or executable in the virtual environment at {venv_bin_dir}. Cannot run with --pytest." 

393 else: 

394 cmd = [pytest_exe, "--pdb", "-s", "--pdbcls=pdb:Pdb", rel_file_path] + parsed_args 

395 else: 

396 cmd = [venv_python_path, "-m", "pdb", rel_file_path] + parsed_args 

397 else: 

398 print("Warning: No uv or standard venv detected in project root. Using system Python/pytest.") 

399 # Fallback to system python/pytest found in PATH 

400 python_exe = shutil.which("python") or sys.executable # Find system python more reliably 

401 if not python_exe: 

402 return "Error: Could not find 'python' executable in system PATH." 

403 

404 if use_pytest: 

405 pytest_exe = shutil.which("pytest") 

406 if not pytest_exe: 

407 return "Error: pytest command not found in system PATH. Cannot run with --pytest." 

408 cmd = [pytest_exe, "--pdb", "-s", "--pdbcls=pdb:Pdb", rel_file_path] + parsed_args 

409 else: 

410 cmd = [python_exe, "-m", "pdb", rel_file_path] + parsed_args 

411 

412 # --- Launch Subprocess --- 

413 print(f"Executing command: {' '.join(map(shlex.quote, cmd))}") 

414 print(f"Working directory: {project_root}") 

415 print(f"Using VIRTUAL_ENV: {env.get('VIRTUAL_ENV', 'Not Set')}") 

416 # print(f"Using PATH: {env.get('PATH', 'Not Set')}") # Can be very long 

417 

418 # Ensure previous thread is not running (important for restarts) 

419 if output_thread and output_thread.is_alive(): 

420 print("Warning: Previous output thread was still alive.", file=sys.stderr) 

421 # Attempting to join might hang if readline blocks, so we just detach. 

422 

423 pdb_process = subprocess.Popen( 

424 cmd, 

425 stdin=subprocess.PIPE, 

426 stdout=subprocess.PIPE, 

427 stderr=subprocess.STDOUT, # Merge stderr to stdout for easier capture 

428 text=False, # Read bytes for reliable readline behavior 

429 cwd=project_root, # <<< CRITICAL: Run from project root 

430 env=env, # Pass the prepared environment 

431 bufsize=0 # Use system default buffering (often line-buffered) 

432 ) 

433 

434 # Start the output reader thread anew 

435 output_thread = threading.Thread( 

436 target=read_pdb_output, 

437 args=(pdb_process, pdb_output_queue), 

438 daemon=True # Allows main program to exit even if thread is running 

439 ) 

440 output_thread.start() 

441 

442 pdb_running = True # Set running state *before* waiting for output 

443 

444 # --- Wait for Initial Output & Verify Start --- 

445 print("Waiting for PDB to start...") 

446 initial_output = get_pdb_output(timeout=3.0) # Longer timeout for potentially slow starts/imports 

447 

448 # Check if process died immediately 

449 if pdb_process.poll() is not None: 

450 exit_code = pdb_process.poll() 

451 pdb_running = False 

452 # Attempt to get any remaining output directly if thread missed it 

453 final_out_bytes, _ = pdb_process.communicate() 

454 final_out_str = final_out_bytes.decode('utf-8', errors='replace') 

455 full_output = initial_output + "\n" + final_out_str.strip() 

456 return (f"Error: PDB process exited immediately (Code: {exit_code}). " 

457 f"Command: {' '.join(map(shlex.quote, cmd))}\n" 

458 f"Working Dir: {project_root}\n" 

459 f"Output:\n---\n{full_output}\n---") 

460 

461 # Check for typical PDB prompt indicators 

462 # Needs to be somewhat lenient as initial output varies (e.g., pytest header) 

463 has_pdb_prompt = "-> " in initial_output or "(Pdb)" in initial_output 

464 has_error = "Error:" in initial_output or "Exception:" in initial_output 

465 

466 if not has_pdb_prompt: 

467 # If no prompt but also no obvious error and process is running, 

468 # it might be okay, just slower startup or waiting. 

469 if pdb_process.poll() is None and not has_error: 

470 warning_msg = ("Warning: PDB started but initial prompt ('-> ' or '(Pdb)') " 

471 "not detected in first few seconds. It might be running.") 

472 print(warning_msg, file=sys.stderr) 

473 # Proceed but include the warning in the return message 

474 initial_output = f"{warning_msg}\n\n{initial_output}" 

475 else: 

476 # No prompt, process might have died silently or has error message 

477 pdb_running = False 

478 # Try to get more output 

479 final_output = get_pdb_output(timeout=0.5) 

480 full_output = initial_output + "\n" + final_output 

481 return (f"Error starting PDB. No prompt detected and process may have issues.\n" 

482 f"Command: {' '.join(map(shlex.quote, cmd))}\n" 

483 f"Working Dir: {project_root}\n" 

484 f"Output:\n---\n{full_output}\n---") 

485 

486 # --- Restore Breakpoints --- 

487 restored_bps_output = "" 

488 if current_file in breakpoints and breakpoints[current_file]: 

489 print(f"Restoring {len(breakpoints[current_file])} breakpoints for {rel_file_path}...") 

490 # Use relative path for consistency in breakpoint commands 

491 try: 

492 bp_rel_path = os.path.relpath(current_file, project_root) 

493 if bp_rel_path == '.': bp_rel_path = os.path.basename(current_file) 

494 except ValueError: 

495 bp_rel_path = current_file # Fallback 

496 

497 restored_bps_output += "\n--- Restoring Breakpoints ---\n" 

498 # Sort by line number for clarity 

499 for line_num in sorted(breakpoints[current_file].keys()): 

500 bp_command_rel = f"b {bp_rel_path}:{line_num}" 

501 print(f"Sending restore cmd: {bp_command_rel}") 

502 restore_out = send_to_pdb(bp_command_rel) 

503 restored_bps_output += f"Set {bp_rel_path}:{line_num}: {restore_out or '[No Response]'}\n" 

504 

505 # Extract and update BP number if available 

506 match = re.search(r"Breakpoint (\d+) at", restore_out) 

507 if match: 

508 bp_data = breakpoints[current_file][line_num] 

509 if isinstance(bp_data, dict): 

510 bp_data["bp_number"] = match.group(1) 

511 else: 

512 # Backward compatibility with older format 

513 breakpoints[current_file][line_num] = { 

514 "command": bp_data, 

515 "bp_number": match.group(1) 

516 } 

517 

518 restored_bps_output += "--- Breakpoint Restore Complete ---\n" 

519 

520 return f"Debugging session started for {rel_file_path} (in {project_root})\n\n{initial_output}\n{restored_bps_output}" 

521 

522 except FileNotFoundError as e: 

523 pdb_running = False 

524 return f"Error starting debugging session: Command not found ({e.filename}). Is '{cmd[0]}' installed and in the correct PATH (system or venv)?\n{traceback.format_exc()}" 

525 except Exception as e: 

526 pdb_running = False 

527 return f"Error starting debugging session: {str(e)}\n{traceback.format_exc()}" 

528 

529@mcp.tool() 

530def send_pdb_command(command: str) -> str: 

531 """Send a command to the running PDB instance. 

532 

533 Examples: 

534 n (next line), c (continue), s (step into), r (return from function) 

535 p variable (print variable), pp variable (pretty print) 

536 b line_num (set breakpoint in current file), b file:line_num 

537 cl num (clear breakpoint number), cl file:line_num 

538 l (list source code), ll (list longer source code) 

539 a (print arguments of current function) 

540 q (quit) 

541 

542 Args: 

543 command: The PDB command string. 

544 """ 

545 global pdb_running, pdb_process 

546 

547 if not pdb_running: 

548 return "No active debugging session. Use start_debug first." 

549 

550 # Double check process liveness 

551 if pdb_process is None or pdb_process.poll() is not None: 

552 pdb_running = False 

553 final_output = get_pdb_output(timeout=0.1) 

554 return f"The debugging session appears to have ended.\nFinal Output:\n{final_output}" 

555 

556 try: 

557 # Determine appropriate timeout based on command complexity 

558 timeout_multiplier = 1.0 

559 if command.strip().lower() in ('c', 'continue', 'r', 'run'): 

560 # These commands might take longer to complete 

561 timeout_multiplier = 2.0 

562 

563 response = send_to_pdb(command, timeout_multiplier) 

564 

565 # Check if the session ended after this specific command (e.g., 'q' or fatal error) 

566 if not pdb_running: # send_to_pdb might set this if process ended 

567 return f"Command output:\n{response}" # Response already includes end notice 

568 

569 # Provide extra context for common navigation commands 

570 # Only do this if the session is still running 

571 nav_commands = ['n', 's', 'c', 'r', 'unt', 'until', 'next', 'step', 'continue', 'return'] 

572 if command.strip().lower() in nav_commands and pdb_running and pdb_process.poll() is None: 

573 # Give PDB a tiny bit more time after navigation before asking for location 

574 # Check again if it's running before sending 'l .' 

575 if pdb_running and pdb_process.poll() is None: 

576 print("Fetching context after navigation...") 

577 line_context = send_to_pdb("l .") 

578 # Check again after sending 'l .' 

579 if pdb_running and pdb_process.poll() is None: 

580 response += f"\n\n-- Current location --\n{line_context}" 

581 else: 

582 response += "\n\n-- Session ended after navigation --" 

583 pdb_running = False # Ensure state is correct 

584 

585 return f"Command output:\n{response}" 

586 

587 except Exception as e: 

588 # Catch unexpected errors during command sending/processing 

589 print(f"Error in send_pdb_command: {e}", file=sys.stderr) 

590 # Check process status again 

591 if pdb_process and pdb_process.poll() is not None: 

592 pdb_running = False 

593 return f"Error sending command: {str(e)}\n\n*** The debugging session has likely ended. ***\n{traceback.format_exc()}" 

594 else: 

595 return f"Error sending command: {str(e)}\n{traceback.format_exc()}" 

596 

597 

598@mcp.tool() 

599def set_breakpoint(file_path: str, line_number: int) -> str: 

600 """Set a breakpoint at a specific line in a file. Uses relative path if possible. 

601 

602 Args: 

603 file_path: Path to the file (can be relative to project root or absolute). 

604 line_number: Line number for the breakpoint. 

605 """ 

606 global breakpoints, current_project_root 

607 

608 if not pdb_running: 

609 return "No active debugging session. Use start_debug first." 

610 if not current_project_root: 

611 return "Error: Project root not identified. Cannot reliably set breakpoint." 

612 

613 abs_file_path = os.path.abspath(os.path.join(current_project_root, file_path)) # Resolve relative to root first 

614 if not os.path.exists(abs_file_path): 

615 abs_file_path = os.path.abspath(file_path) # Try absolute directly 

616 if not os.path.exists(abs_file_path): 

617 return f"Error: File not found at '{file_path}' (checked relative to project and absolute)." 

618 

619 # Use relative path for the breakpoint command if possible 

620 try: 

621 rel_file_path = os.path.relpath(abs_file_path, current_project_root) 

622 if rel_file_path == '.': rel_file_path = os.path.basename(abs_file_path) 

623 except ValueError: 

624 rel_file_path = abs_file_path # Fallback to absolute 

625 

626 # Track breakpoints using the *absolute* path as the key for internal consistency 

627 if abs_file_path not in breakpoints: 

628 breakpoints[abs_file_path] = {} 

629 

630 if line_number in breakpoints[abs_file_path]: 

631 # Verify with pdb if it's actually set there 

632 current_bps = send_to_pdb("b") 

633 if f"{rel_file_path}:{line_number}" in current_bps: 

634 return f"Breakpoint already exists and is tracked at {abs_file_path}:{line_number}" 

635 else: 

636 print(f"Warning: Breakpoint tracked locally but not found in PDB output for {rel_file_path}:{line_number}. Will attempt to set.") 

637 

638 

639 command = f"b {rel_file_path}:{line_number}" 

640 response = send_to_pdb(command) 

641 

642 # More robust verification using pattern matching 

643 bp_markers = ["Breakpoint", "at", str(line_number)] 

644 if all(marker in response for marker in bp_markers): 

645 # Extract breakpoint number from response 

646 match = re.search(r"Breakpoint (\d+) at", response) 

647 bp_number = match.group(1) if match else None 

648 

649 # Store both command and breakpoint number 

650 breakpoints[abs_file_path][line_number] = { 

651 "command": command, 

652 "bp_number": bp_number 

653 } 

654 return f"Breakpoint #{bp_number} set and tracked:\n{response}" 

655 elif "Error" not in response and "multiple files" not in response.lower(): 

656 # Maybe pdb didn't confirm explicitly but didn't error? (e.g., line doesn't exist yet) 

657 # We won't track it reliably unless PDB confirms it. 

658 return f"Breakpoint command sent. PDB response might indicate an issue (e.g., invalid line) or success without standard confirmation:\n{response}\n(Breakpoint NOT reliably tracked. Verify with list_breakpoints)" 

659 else: 

660 # PDB reported an error or ambiguity 

661 return f"Failed to set breakpoint. PDB response:\n{response}" 

662 

663 

664@mcp.tool() 

665def clear_breakpoint(file_path: str, line_number: int) -> str: 

666 """Clear a breakpoint at a specific line in a file. Uses relative path if possible. 

667 

668 Args: 

669 file_path: Path to the file where the breakpoint exists. 

670 line_number: Line number of the breakpoint to clear. 

671 """ 

672 global breakpoints, current_project_root 

673 

674 if not pdb_running: 

675 return "No active debugging session. Use start_debug first." 

676 if not current_project_root: 

677 return "Error: Project root not identified. Cannot reliably clear breakpoint." 

678 

679 abs_file_path = os.path.abspath(os.path.join(current_project_root, file_path)) 

680 if not os.path.exists(abs_file_path): 

681 abs_file_path = os.path.abspath(file_path) 

682 if not os.path.exists(abs_file_path): 

683 # If file doesn't exist, we likely don't have a BP anyway 

684 if abs_file_path in breakpoints and line_number in breakpoints[abs_file_path]: 

685 del breakpoints[abs_file_path][line_number] 

686 if not breakpoints[abs_file_path]: del breakpoints[abs_file_path] 

687 return f"Warning: File not found at '{file_path}'. Breakpoint untracked (if it was tracked)." 

688 

689 

690 try: 

691 rel_file_path = os.path.relpath(abs_file_path, current_project_root) 

692 if rel_file_path == '.': rel_file_path = os.path.basename(abs_file_path) 

693 except ValueError: 

694 rel_file_path = abs_file_path 

695 

696 # Check if we have a breakpoint number stored, which is more reliable for clearing 

697 bp_number = None 

698 if abs_file_path in breakpoints and line_number in breakpoints[abs_file_path]: 

699 bp_data = breakpoints[abs_file_path][line_number] 

700 if isinstance(bp_data, dict) and "bp_number" in bp_data: 

701 bp_number = bp_data["bp_number"] 

702 

703 # Use the breakpoint number if available, otherwise use file:line 

704 if bp_number: 

705 command = f"cl {bp_number}" 

706 else: 

707 command = f"cl {rel_file_path}:{line_number}" 

708 

709 response = send_to_pdb(command) 

710 

711 # Check response for confirmation (e.g., "Deleted breakpoint", "No breakpoint") 

712 breakpoint_cleared_in_pdb = "Deleted breakpoint" in response or "No breakpoint" in response or "Error: " not in response 

713 

714 # Update internal tracking 

715 if abs_file_path in breakpoints and line_number in breakpoints[abs_file_path]: 

716 if breakpoint_cleared_in_pdb: 

717 del breakpoints[abs_file_path][line_number] 

718 if not breakpoints[abs_file_path]: # Remove file entry if no more bps 

719 del breakpoints[abs_file_path] 

720 status_msg = "Breakpoint untracked." 

721 else: 

722 status_msg = "Breakpoint potentially still exists in PDB despite local tracking. Verify with list_breakpoints." 

723 else: 

724 status_msg = "Breakpoint was not tracked locally." 

725 

726 

727 return f"Clear breakpoint result:\n{response}\n({status_msg})" 

728 

729 

730@mcp.tool() 

731def list_breakpoints() -> str: 

732 """List breakpoints known by PDB and compare with internally tracked breakpoints.""" 

733 global breakpoints, current_project_root 

734 

735 if not pdb_running: 

736 return "No active debugging session. Use start_debug first." 

737 if not current_project_root: 

738 # List only tracked BPs if PDB isn't running or root unknown 

739 tracked_bps_formatted = [] 

740 for abs_path, lines in breakpoints.items(): 

741 # Try to show relative if possible, else absolute 

742 try: 

743 disp_path = os.path.relpath(abs_path, os.getcwd()) # Relative to current dir might be useful 

744 except ValueError: 

745 disp_path = abs_path 

746 for line_num in sorted(lines.keys()): 

747 bp_data = lines[line_num] 

748 if isinstance(bp_data, dict) and "bp_number" in bp_data: 

749 tracked_bps_formatted.append(f"{disp_path}:{line_num} (BP #{bp_data['bp_number']})") 

750 else: 

751 tracked_bps_formatted.append(f"{disp_path}:{line_num}") 

752 return "No active PDB session or project root unknown.\n\n--- Tracked Breakpoints ---\n" + ('\n'.join(tracked_bps_formatted) if tracked_bps_formatted else "None") 

753 

754 

755 pdb_response = send_to_pdb("b") 

756 

757 # Format our tracked breakpoints using relative paths from project root where possible 

758 tracked_bps_formatted = [] 

759 for abs_path, lines in breakpoints.items(): 

760 try: 

761 rel_path = os.path.relpath(abs_path, current_project_root) 

762 if rel_path == '.': rel_path = os.path.basename(abs_path) 

763 except ValueError: 

764 rel_path = abs_path # Fallback if not relative 

765 for line_num in sorted(lines.keys()): 

766 bp_data = lines[line_num] 

767 if isinstance(bp_data, dict) and "bp_number" in bp_data: 

768 tracked_bps_formatted.append(f"{rel_path}:{line_num} (BP #{bp_data['bp_number']})") 

769 else: 

770 tracked_bps_formatted.append(f"{rel_path}:{line_num}") 

771 

772 # Add a comparison note 

773 comparison_note = "\n(Compare PDB list above with tracked list below. Use set/clear to synchronize if needed.)" 

774 

775 return (f"--- PDB Breakpoints ---\n{pdb_response}\n\n" 

776 f"--- Tracked Breakpoints ---\n" + 

777 ('\n'.join(tracked_bps_formatted) if tracked_bps_formatted else "None") + 

778 comparison_note) 

779 

780@mcp.tool() 

781def restart_debug() -> str: 

782 """Restart the debugging session with the same file, arguments, and pytest flag.""" 

783 global pdb_process, pdb_running, current_file, current_args, current_use_pytest 

784 

785 if not current_file: 

786 return "No debugging session was previously started (or state lost) to restart." 

787 

788 # Store details before ending the current session 

789 file_to_debug = current_file 

790 args_to_use = current_args 

791 use_pytest_flag = current_use_pytest 

792 print(f"Attempting to restart debug for: {file_to_debug} with args='{args_to_use}' pytest={use_pytest_flag}") 

793 

794 # End the current session forcefully if running 

795 end_result = "Previous session not running or already ended." 

796 if pdb_running: 

797 print("Ending current session before restart...") 

798 end_result = end_debug() # Use the dedicated end function 

799 print(f"Restart: {end_result}") 

800 

801 # Reset state explicitly (end_debug should handle most, but belt-and-suspenders) 

802 pdb_process = None 

803 pdb_running = False 

804 # output_thread should be handled by new start_debug call 

805 

806 # Clear the output queue again just in case 

807 while not pdb_output_queue.empty(): 

808 try: pdb_output_queue.get_nowait() 

809 except queue.Empty: break 

810 

811 # Start a new session using stored parameters 

812 print("Calling start_debug for restart...") 

813 start_result = start_debug(file_path=file_to_debug, use_pytest=use_pytest_flag, args=args_to_use) 

814 

815 # Note: Breakpoints are now restored within start_debug using the tracked 'breakpoints' dict 

816 

817 return f"--- Restart Attempt ---\nPrevious session end result: {end_result}\n\nNew session status:\n{start_result}" 

818 

819 

820@mcp.tool() 

821def examine_variable(variable_name: str) -> str: 

822 """Examine a variable's type, value (print), and attributes (dir) using PDB. 

823 

824 Args: 

825 variable_name: Name of the variable to examine (e.g., 'my_var', 'self.data'). 

826 """ 

827 if not pdb_running: 

828 return "No active debugging session. Use start_debug first." 

829 

830 # Basic print 

831 p_command = f"p {variable_name}" 

832 print(f"Sending command: {p_command}") 

833 basic_info = send_to_pdb(p_command) 

834 if not pdb_running: return f"Session ended after 'p {variable_name}'. Output:\n{basic_info}" 

835 

836 # Type info 

837 type_command = f"p type({variable_name})" 

838 print(f"Sending command: {type_command}") 

839 type_info = send_to_pdb(type_command) 

840 # Check if session ended, but proceed if possible 

841 if not pdb_running and "Session ended" not in basic_info : 

842 return f"Value:\n{basic_info}\n\nSession ended after 'p type({variable_name})'. Type Output:\n{type_info}" 

843 

844 # Attributes/methods using dir(), protect with try-except in PDB 

845 dir_command = f"import inspect; print(dir({variable_name}))" # More robust than just dir() 

846 print("Sending command: (inspect dir)") 

847 dir_info = send_to_pdb(dir_command) 

848 if not pdb_running and "Session ended" not in type_info : 

849 return f"Value:\n{basic_info}\n\nType:\n{type_info}\n\nSession ended after 'dir()'. Dir Output:\n{dir_info}" 

850 

851 # Pretty print (useful for complex objects) 

852 pp_command = f"pp {variable_name}" 

853 print(f"Sending command: {pp_command}") 

854 pretty_info = send_to_pdb(pp_command) 

855 if not pdb_running and "Session ended" not in dir_info : 

856 return f"Value:\n{basic_info}\n\nType:\n{type_info}\n\nAttributes/Methods:\n{dir_info}\n\nSession ended after 'pp'. PP Output:\n{pretty_info}" 

857 

858 return (f"--- Variable Examination: {variable_name} ---\n\n" 

859 f"Value (p):\n{basic_info}\n\n" 

860 f"Pretty Value (pp):\n{pretty_info}\n\n" 

861 f"Type (p type()):\n{type_info}\n\n" 

862 f"Attributes/Methods (dir()):\n{dir_info}\n" 

863 f"--- End Examination ---") 

864 

865 

866@mcp.tool() 

867def get_debug_status() -> str: 

868 """Get the current status of the debugging session and tracked state.""" 

869 global pdb_running, current_file, current_project_root, breakpoints, pdb_process 

870 

871 if not pdb_running: 

872 # Check if process exists but isn't running 

873 if pdb_process and pdb_process.poll() is not None: 

874 return "Debugging session ended. Process terminated." 

875 return "No active debugging session." 

876 

877 # Check process liveness again 

878 if pdb_process and pdb_process.poll() is not None: 

879 pdb_running = False 

880 return "Debugging session has ended (process terminated)." 

881 

882 # Format tracked breakpoints for status 

883 bp_list = [] 

884 for abs_path, lines in breakpoints.items(): 

885 try: 

886 rel_path = os.path.relpath(abs_path, current_project_root or os.getcwd()) 

887 if rel_path == '.': rel_path = os.path.basename(abs_path) 

888 except ValueError: 

889 rel_path = abs_path 

890 for line_num in sorted(lines.keys()): 

891 bp_data = lines[line_num] 

892 if isinstance(bp_data, dict) and "bp_number" in bp_data: 

893 bp_list.append(f"{rel_path}:{line_num} (BP #{bp_data['bp_number']})") 

894 else: 

895 bp_list.append(f"{rel_path}:{line_num}") 

896 

897 status = { 

898 "running": pdb_running, 

899 "current_file": current_file, 

900 "project_root": current_project_root, 

901 "use_pytest": current_use_pytest, 

902 "arguments": current_args, 

903 "process_id": pdb_process.pid if pdb_process else None, 

904 "tracked_breakpoints": bp_list, 

905 } 

906 

907 # Try to get current location from PDB without advancing 

908 current_loc_output = "[Could not query PDB location]" 

909 if pdb_running and pdb_process and pdb_process.poll() is None: 

910 current_loc_output = send_to_pdb("l .") # Get location without changing state 

911 if not pdb_running: # Check if the query itself ended the session 

912 status["running"] = False 

913 current_loc_output += "\n -- Session ended during status check --" 

914 

915 

916 return "--- Debug Session Status ---\n" + \ 

917 f"Running: {status['running']}\n" + \ 

918 f"PID: {status['process_id']}\n" + \ 

919 f"Project Root: {status['project_root']}\n" + \ 

920 f"Debugging File: {status['current_file']}\n" + \ 

921 f"Using Pytest: {status['use_pytest']}\n" + \ 

922 f"Arguments: '{status['arguments']}'\n" + \ 

923 f"Tracked Breakpoints: {status['tracked_breakpoints'] or 'None'}\n\n" + \ 

924 f"-- Current PDB Location --\n{current_loc_output}\n" + \ 

925 "--- End Status ---" 

926 

927 

928@mcp.tool() 

929def end_debug() -> str: 

930 """End the current debugging session forcefully.""" 

931 global pdb_process, pdb_running, output_thread 

932 

933 if not pdb_running and (pdb_process is None or pdb_process.poll() is not None): 

934 return "No active debugging session to end." 

935 

936 print("Ending debugging session...") 

937 result_message = "Debugging session ended." 

938 

939 if pdb_process and pdb_process.poll() is None: 

940 try: 

941 # Try sending SIGINT (Ctrl+C) first for cleaner exit 

942 if sys.platform != "win32": 

943 try: 

944 os.kill(pdb_process.pid, signal.SIGINT) 

945 try: 

946 pdb_process.wait(timeout=0.5) 

947 except subprocess.TimeoutExpired: 

948 pass 

949 except (OSError, ProcessLookupError) as e: 

950 print(f"SIGINT failed: {e}") 

951 

952 # Next try sending quit command for graceful exit 

953 if pdb_process.poll() is None: 

954 try: 

955 print("Attempting graceful exit with 'q'...") 

956 pdb_process.stdin.write(b'q\n') 

957 pdb_process.stdin.flush() 

958 # Wait briefly for potential cleanup 

959 pdb_process.wait(timeout=0.5) 

960 print("PDB process quit gracefully.") 

961 except (subprocess.TimeoutExpired, OSError, BrokenPipeError) as e: 

962 print(f"Graceful quit failed or timed out ({e}). Terminating forcefully.") 

963 

964 # If still running, terminate forcefully 

965 if pdb_process.poll() is None: 

966 try: 

967 pdb_process.terminate() # Send SIGTERM 

968 pdb_process.wait(timeout=1.0) # Wait for termination 

969 print("PDB process terminated.") 

970 except subprocess.TimeoutExpired: 

971 print("Terminate timed out. Killing process.") 

972 pdb_process.kill() # Send SIGKILL 

973 pdb_process.wait(timeout=0.5) # Wait for kill 

974 print("PDB process killed.") 

975 except Exception as term_err: 

976 print(f"Error during terminate/kill: {term_err}", file=sys.stderr) 

977 result_message = f"Debugging session ended with errors during termination: {term_err}" 

978 except Exception as e: 

979 print(f"Error during end_debug: {e}", file=sys.stderr) 

980 result_message = f"Debugging session ended with errors: {e}" 

981 

982 # Clean up state 

983 pdb_process = None 

984 pdb_running = False 

985 

986 # Wait briefly for the output thread to potentially finish reading remaining output 

987 if output_thread and output_thread.is_alive(): 

988 print("Waiting for output thread to finish...") 

989 output_thread.join(timeout=0.5) 

990 if output_thread.is_alive(): 

991 print("Warning: Output thread did not finish cleanly.", file=sys.stderr) 

992 

993 output_thread = None # Clear thread object reference 

994 

995 # Clear the queue one last time 

996 while not pdb_output_queue.empty(): 

997 try: pdb_output_queue.get_nowait() 

998 except queue.Empty: break 

999 

1000 print("Debugging session ended and state cleared.") 

1001 return result_message 

1002 

1003# --- Cleanup on Exit --- 

1004 

1005def cleanup(): 

1006 """Ensure the PDB process is terminated when the MCP server exits.""" 

1007 print("Running atexit cleanup...") 

1008 if pdb_running or (pdb_process and pdb_process.poll() is None): 

1009 end_debug() 

1010 

1011atexit.register(cleanup) 

1012 

1013# --- Main Execution --- 

1014 

1015def main(): 

1016 """Initialize and run the FastMCP server.""" 

1017 print("--- Starting MCP PDB Tool Server ---") 

1018 print(f"Python Executable: {sys.executable}") 

1019 print(f"Working Directory: {os.getcwd()}") 

1020 # Add any other relevant startup info here 

1021 mcp.run() 

1022 print("--- MCP PDB Tool Server Shutdown ---") 

1023 

1024if __name__ == "__main__": 

1025 main()