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
« 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
14from mcp.server.fastmcp import FastMCP
16# Initialize FastMCP server
17mcp = FastMCP("mcp-pdb")
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
30# --- Helper Functions ---
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)
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)
77def send_to_pdb(command, timeout_multiplier=1.0):
78 """Send a command to the pdb process and get its response.
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
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
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
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
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. ***"
112 return output
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}"
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."
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"]
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)
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
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]
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)
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')
178 if os.path.exists(python_exe):
179 print(f"Found active virtual environment: {venv_path}")
180 return python_exe, bin_dir
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')
192 if os.path.exists(python_exe):
193 print(f"Found conda environment: {conda_path}")
194 return python_exe, bin_dir
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')
207 if os.path.exists(python_exe):
208 print(f"Found virtual environment: {venv_path}")
209 return python_exe, bin_dir
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
226 print(f"No virtual environment found in: {project_root}")
227 return None, None
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}")
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}")
244# --- MCP Tools ---
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.
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
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
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 ]
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))
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
285 if not abs_file_path:
286 return f"Error: File not found at '{file_path}' (checked multiple locations including CWD, src/, tests/, lib/)"
288 file_dir = os.path.dirname(abs_file_path)
289 project_root = find_project_root(file_dir)
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
297 # Store original working directory before changing
298 original_working_dir = os.getcwd()
299 print(f"Original working directory: {original_working_dir}")
301 # Initialize breakpoints structure for this file if new
302 if current_file not in breakpoints:
303 breakpoints[current_file] = {}
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
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
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
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)
332 # --- Prepare Command and Subprocess Environment ---
333 cmd = []
334 # Start with a clean environment copy, modify selectively
335 env = os.environ.copy()
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)
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
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}"
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)
376 # Critical addition: Set PYTHONPATH to include project root
377 env['PYTHONPATH'] = f"{project_root}{os.pathsep}{env.get('PYTHONPATH', '')}"
379 # Force unbuffered output for better debugging experience
380 env['PYTHONUNBUFFERED'] = '1'
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."
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
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
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.
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 )
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()
442 pdb_running = True # Set running state *before* waiting for output
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
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---")
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
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---")
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
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"
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 }
518 restored_bps_output += "--- Breakpoint Restore Complete ---\n"
520 return f"Debugging session started for {rel_file_path} (in {project_root})\n\n{initial_output}\n{restored_bps_output}"
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()}"
529@mcp.tool()
530def send_pdb_command(command: str) -> str:
531 """Send a command to the running PDB instance.
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)
542 Args:
543 command: The PDB command string.
544 """
545 global pdb_running, pdb_process
547 if not pdb_running:
548 return "No active debugging session. Use start_debug first."
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}"
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
563 response = send_to_pdb(command, timeout_multiplier)
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
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
585 return f"Command output:\n{response}"
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()}"
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.
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
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."
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)."
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
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] = {}
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.")
639 command = f"b {rel_file_path}:{line_number}"
640 response = send_to_pdb(command)
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
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}"
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.
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
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."
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)."
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
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"]
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}"
709 response = send_to_pdb(command)
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
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."
727 return f"Clear breakpoint result:\n{response}\n({status_msg})"
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
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")
755 pdb_response = send_to_pdb("b")
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}")
772 # Add a comparison note
773 comparison_note = "\n(Compare PDB list above with tracked list below. Use set/clear to synchronize if needed.)"
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)
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
785 if not current_file:
786 return "No debugging session was previously started (or state lost) to restart."
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}")
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}")
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
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
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)
815 # Note: Breakpoints are now restored within start_debug using the tracked 'breakpoints' dict
817 return f"--- Restart Attempt ---\nPrevious session end result: {end_result}\n\nNew session status:\n{start_result}"
820@mcp.tool()
821def examine_variable(variable_name: str) -> str:
822 """Examine a variable's type, value (print), and attributes (dir) using PDB.
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."
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}"
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}"
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}"
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}"
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 ---")
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
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."
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)."
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}")
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 }
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 --"
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 ---"
928@mcp.tool()
929def end_debug() -> str:
930 """End the current debugging session forcefully."""
931 global pdb_process, pdb_running, output_thread
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."
936 print("Ending debugging session...")
937 result_message = "Debugging session ended."
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}")
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.")
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}"
982 # Clean up state
983 pdb_process = None
984 pdb_running = False
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)
993 output_thread = None # Clear thread object reference
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
1000 print("Debugging session ended and state cleared.")
1001 return result_message
1003# --- Cleanup on Exit ---
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()
1011atexit.register(cleanup)
1013# --- Main Execution ---
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 ---")
1024if __name__ == "__main__":
1025 main()