Skip to main content
Glama

LLDB-MCP

BSD 2-Clause "Simplified" License
59
lldb_mcp.py28.4 kB
#!/usr/bin/env python3 import os import re import sys import uuid import asyncio import subprocess import pty import fcntl import termios import struct import argparse from typing import Dict, List, Optional, Any, AsyncIterator from dataclasses import dataclass from contextlib import asynccontextmanager from mcp.server.fastmcp import FastMCP, Context # Global debug flag - set to False by default DEBUG = False def debug_log(message: str) -> None: """Print debug messages only when DEBUG flag is enabled""" if DEBUG: print(f"[DEBUG] {message}") # Class to handle LLDB sessions class LldbSession: def __init__(self, session_id: str, lldb_path: str, working_dir: Optional[str] = None): self.id = session_id self.lldb_path = lldb_path self.working_dir = working_dir or os.getcwd() self.process = None self.master_fd = None self.slave_fd = None self.target = None self.ready = False async def start(self) -> str: """Start the LLDB process with a PTY""" debug_log(f"Starting LLDB process with path: {self.lldb_path}") # Create a pseudo-terminal pair self.master_fd, self.slave_fd = pty.openpty() debug_log(f"Created PTY pair: master={self.master_fd}, slave={self.slave_fd}") # Make the terminal raw old_settings = termios.tcgetattr(self.slave_fd) new_settings = termios.tcgetattr(self.slave_fd) new_settings[3] = new_settings[3] & ~termios.ECHO # Disable echo termios.tcsetattr(self.slave_fd, termios.TCSADRAIN, new_settings) # Start LLDB process cmd = [self.lldb_path] self.process = await asyncio.create_subprocess_exec( *cmd, stdin=self.slave_fd, stdout=self.slave_fd, stderr=self.slave_fd, cwd=self.working_dir ) debug_log(f"LLDB process created with PID: {self.process.pid}") # Close slave end in parent process os.close(self.slave_fd) self.slave_fd = None # Make the master fd non-blocking flags = fcntl.fcntl(self.master_fd, fcntl.F_GETFL) fcntl.fcntl(self.master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) # Wait for initial prompt debug_log("Waiting for initial prompt...") output = await self.read_until_prompt() debug_log("Initial prompt received") self.ready = True # Send version command to confirm LLDB is working debug_log("Sending version command") version_output = await self.execute_command("version") output += version_output return output async def execute_command(self, command: str) -> str: """Execute an LLDB command and return the output""" if not self.ready: raise RuntimeError("LLDB session is not ready") if not self.process: raise RuntimeError("LLDB session is not ready: no process") if self.process.returncode is not None: raise RuntimeError("LLDB session is not ready: process has terminated, code: %d" % self.process.returncode) # Write command to master fd debug_log(f"Executing command: {command}") os.write(self.master_fd, f"{command}\n".encode()) # Read response until prompt return await self.read_until_prompt() async def read_until_prompt(self) -> str: """Read from LLDB until prompt is encountered""" if not self.master_fd: raise RuntimeError("PTY not initialized") buffer = b"" prompt_pattern = b"(lldb)" start_time = asyncio.get_event_loop().time() debug_log("Starting to read until prompt") # Read until we see the prompt or timeout while True: # Check for timeout current_time = asyncio.get_event_loop().time() if current_time - start_time > 10.0: debug_log(f"Global timeout reached after {current_time - start_time:.1f} seconds") return buffer.decode('utf-8', errors='replace') + "\n[Timeout waiting for LLDB response]" # Check if process has terminated if self.process and self.process.returncode is not None: debug_log(f"Process terminated with code: {self.process.returncode}") if buffer: return buffer.decode('utf-8', errors='replace') raise RuntimeError(f"LLDB process terminated with code {self.process.returncode}") try: # Try to read from the master fd chunk = os.read(self.master_fd, 1024) if chunk: debug_log(f"Read {len(chunk)} bytes from PTY") buffer += chunk # Print readable content for debugging try: decoded = chunk.decode('utf-8', errors='replace') debug_log(f"Read data: {decoded.strip()}") except: pass # Check if buffer contains the prompt if prompt_pattern in buffer: debug_log("Found LLDB prompt in buffer") return buffer.decode('utf-8', errors='replace') except BlockingIOError: # No data available yet, yield control await asyncio.sleep(0.1) except Exception as e: debug_log(f"Error reading from PTY: {str(e)}") if buffer: return buffer.decode('utf-8', errors='replace') + f"\n[Error reading from LLDB: {str(e)}]" raise RuntimeError(f"Error reading from LLDB: {str(e)}") async def cleanup(self): """Clean up LLDB resources""" debug_log("Cleaning up LLDB session") try: if self.master_fd is not None: # Send quit command try: os.write(self.master_fd, b"quit\n") # Wait briefly for the process to exit await asyncio.sleep(0.5) except Exception as e: debug_log(f"Error sending quit command: {e}") if self.process and self.process.returncode is None: debug_log("Terminating LLDB process") self.process.terminate() try: await asyncio.wait_for(self.process.wait(), 2.0) except asyncio.TimeoutError: debug_log("Force killing LLDB process") self.process.kill() await self.process.wait() # Close the PTY master fd if self.master_fd is not None: debug_log(f"Closing master fd: {self.master_fd}") os.close(self.master_fd) self.master_fd = None # Close the PTY slave fd if it's still open if self.slave_fd is not None: debug_log(f"Closing slave fd: {self.slave_fd}") os.close(self.slave_fd) self.slave_fd = None except Exception as e: debug_log(f"Error during cleanup: {e}") finally: self.process = None self.ready = False debug_log("LLDB session cleanup completed") @dataclass class AppContext: """Application context for storing active sessions""" sessions: Dict[str, LldbSession] @asynccontextmanager async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: """Manage application lifecycle with shared session state""" # Initialize on startup sessions = {} try: yield AppContext(sessions=sessions) finally: # Cleanup all sessions on shutdown for session_id, session in list(sessions.items()): await session.cleanup() sessions.pop(session_id, None) # Create an MCP server mcp = FastMCP("lldb-mcp", lifespan=app_lifespan) # Helper function to get session from context def get_session(ctx: Context, session_id: str) -> LldbSession: """Get a session by ID or raise an error if not found""" # Access the lifespan context through request_context sessions = ctx.request_context.lifespan_context.sessions if session_id not in sessions: raise ValueError(f"No active LLDB session with ID: {session_id}") return sessions[session_id] @mcp.tool() async def lldb_start(ctx: Context, lldb_path: str = "lldb", working_dir: str = None) -> str: """Start a new LLDB session""" session_id = str(uuid.uuid4()) debug_log(f"Starting new LLDB session with ID: {session_id}") try: # Verify lldb path try: debug_log(f"Verifying lldb path: {lldb_path}") proc = await asyncio.create_subprocess_exec( lldb_path, "--version", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) debug_log(f"Communicating with lldb") try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5.0) debug_log(f"Received response from lldb") if proc.returncode != 0: error_msg = stderr.decode('utf-8', errors='replace').strip() return f"Failed to start LLDB: Invalid lldb path '{lldb_path}'. Error: {error_msg}" except asyncio.TimeoutError: debug_log("Timeout while verifying lldb path") proc.kill() await proc.wait() return f"Failed to start LLDB: Timeout while verifying lldb path '{lldb_path}'" except Exception as e: debug_log(f"Error verifying lldb path: {str(e)}") return f"Failed to start LLDB: Invalid lldb path '{lldb_path}'. Error: {str(e)}" # Use provided working directory or current dir working_dir = working_dir or os.getcwd() debug_log(f"Using working directory: {working_dir}") # Create new LLDB session session = LldbSession(session_id=session_id, lldb_path=lldb_path, working_dir=working_dir) debug_log(f"Created new LLDB session") # Start LLDB process with timeout try: debug_log("Starting LLDB process...") output = await asyncio.wait_for(session.start(), timeout=10.0) debug_log(f"LLDB session started successfully") except asyncio.TimeoutError: debug_log("Timeout while starting LLDB session") await session.cleanup() return f"Failed to start LLDB: Timeout while initializing LLDB session" except Exception as e: debug_log(f"Error starting LLDB session: {str(e)}") await session.cleanup() return f"Failed to start LLDB: {str(e)}" # Store session in context ctx.request_context.lifespan_context.sessions[session_id] = session debug_log(f"Stored session in context") return f"LLDB session started with ID: {session_id}\n\nOutput:\n{output}" except Exception as e: debug_log(f"Unexpected error in lldb_start: {str(e)}") return f"Failed to start LLDB: {str(e)}" @mcp.tool() async def lldb_load(ctx: Context, session_id: str, program: str, arguments: List[str] = None) -> str: """Load a program into LLDB""" try: session = get_session(ctx, session_id) # Normalize path if working directory is set if session.working_dir and not os.path.isabs(program): program = os.path.join(session.working_dir, program) # Create target output = await session.execute_command(f"file \"{program}\"") # Set program arguments if provided if arguments: args_str = " ".join(f'"{arg}"' for arg in arguments) args_output = await session.execute_command(f"settings set -- target.run-args {args_str}") output += f"\n{args_output}" # Update session target session.target = program return f"Program loaded: {program}\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to load program: {str(e)}" @mcp.tool() async def lldb_command(ctx: Context, session_id: str, command: str) -> str: """Execute an LLDB command""" try: session = get_session(ctx, session_id) output = await session.execute_command(command) return f"Command: {command}\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to execute command: {str(e)}" @mcp.tool() async def lldb_terminate(ctx: Context, session_id: str) -> str: """Terminate an LLDB session""" try: session = get_session(ctx, session_id) await session.cleanup() ctx.request_context.lifespan_context.sessions.pop(session_id, None) return f"LLDB session terminated: {session_id}" except ValueError as e: return str(e) except Exception as e: return f"Failed to terminate LLDB session: {str(e)}" @mcp.tool() def lldb_list_sessions(ctx: Context) -> str: """List all active LLDB sessions""" sessions = ctx.request_context.lifespan_context.sessions session_info = [] for session_id, session in sessions.items(): session_info.append({ "id": session_id, "target": session.target or "No program loaded", "working_dir": session.working_dir }) return f"Active LLDB Sessions ({len(sessions)}):\n\n{session_info}" @mcp.tool() async def lldb_attach(ctx: Context, session_id: str, pid: int) -> str: """Attach to a running process""" try: session = get_session(ctx, session_id) output = await session.execute_command(f"process attach -p {pid}") return f"Attached to process {pid}\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to attach to process: {str(e)}" @mcp.tool() async def lldb_load_core(ctx: Context, session_id: str, program: str, core_path: str) -> str: """Load a core dump file""" try: session = get_session(ctx, session_id) # First load the program file_output = await session.execute_command(f"file \"{program}\"") # Then load the core file core_output = await session.execute_command(f"target core \"{core_path}\"") # Get backtrace to show initial state backtrace_output = await session.execute_command("bt") return f"Core file loaded: {core_path}\n\nOutput:\n{file_output}\n{core_output}\n\nBacktrace:\n{backtrace_output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to load core file: {str(e)}" @mcp.tool() async def lldb_set_breakpoint(ctx: Context, session_id: str, location: str, condition: str = None) -> str: """Set a breakpoint""" try: session = get_session(ctx, session_id) # Set breakpoint output = await session.execute_command(f"breakpoint set --name \"{location}\"") # Set condition if provided if condition: # Extract breakpoint number from output (example: "Breakpoint 1: where = ...") match = re.search(r"Breakpoint (\d+):", output) if match: bp_num = match.group(1) condition_output = await session.execute_command(f"breakpoint modify -c \"{condition}\" {bp_num}") output += f"\n{condition_output}" return f"Breakpoint set at: {location}{' with condition: ' + condition if condition else ''}\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to set breakpoint: {str(e)}" @mcp.tool() async def lldb_continue(ctx: Context, session_id: str) -> str: """Continue program execution""" try: session = get_session(ctx, session_id) output = await session.execute_command("continue") return f"Continued execution\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to continue execution: {str(e)}" @mcp.tool() async def lldb_step(ctx: Context, session_id: str, instructions: bool = False) -> str: """Step program execution""" try: session = get_session(ctx, session_id) command = "si" if instructions else "s" # step instruction vs. step output = await session.execute_command(command) return f"Stepped {instructions and 'instruction' or 'line'}\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to step: {str(e)}" @mcp.tool() async def lldb_next(ctx: Context, session_id: str, instructions: bool = False) -> str: """Step over function calls""" try: session = get_session(ctx, session_id) command = "ni" if instructions else "n" # next instruction vs. next output = await session.execute_command(command) return f"Stepped over {instructions and 'instruction' or 'function call'}\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to step over: {str(e)}" @mcp.tool() async def lldb_finish(ctx: Context, session_id: str) -> str: """Execute until the current function returns""" try: session = get_session(ctx, session_id) output = await session.execute_command("finish") return f"Finished current function\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to finish function: {str(e)}" @mcp.tool() async def lldb_backtrace(ctx: Context, session_id: str, full: bool = False, limit: int = None) -> str: """Show call stack""" try: session = get_session(ctx, session_id) # Build backtrace command with options command = "bt" if full: command += " all" # Show all frame variables if limit is not None: command += f" -c {limit}" # Frame count limit output = await session.execute_command(command) return f"Backtrace{' (full)' if full else ''}{f' (limit: {limit})' if limit else ''}:\n\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to get backtrace: {str(e)}" @mcp.tool() async def lldb_print(ctx: Context, session_id: str, expression: str) -> str: """Print value of expression""" try: session = get_session(ctx, session_id) output = await session.execute_command(f"p {expression}") return f"Print {expression}:\n\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to print expression: {str(e)}" @mcp.tool() async def lldb_examine(ctx: Context, session_id: str, expression: str, format: str = "x", count: int = 1) -> str: """Examine memory""" try: session = get_session(ctx, session_id) # Map format codes to LLDB format specifiers format_map = { "x": "x", # hex "d": "d", # decimal "u": "u", # unsigned decimal "o": "o", # octal "t": "t", # binary "i": "i", # instruction "c": "c", # character "f": "f", # float "s": "s" # string } # Get LLDB format or default to hex lldb_format = format_map.get(format, "x") # Build memory examine command command = f"memory read -f {lldb_format} -c {count} {expression}" output = await session.execute_command(command) return f"Examine {expression} (format: {format}, count: {count}):\n\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to examine memory: {str(e)}" @mcp.tool() async def lldb_info_registers(ctx: Context, session_id: str, register: str = None) -> str: """Display registers""" try: session = get_session(ctx, session_id) # Build register info command command = "register read" if register: command += f" {register}" output = await session.execute_command(command) return f"Register info{f' for {register}' if register else ''}:\n\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to get register info: {str(e)}" @mcp.tool() async def lldb_watchpoint(ctx: Context, session_id: str, expression: str, watch_type: str = "write") -> str: """Set a watchpoint on a variable or memory address""" try: session = get_session(ctx, session_id) # Map watch types to LLDB options watch_options = { "read": "r", "write": "w", "read_write": "rw" } option = watch_options.get(watch_type, "w") output = await session.execute_command(f"watchpoint set expression -- {expression} -w {option}") return f"Watchpoint set on {expression} (type: {watch_type})\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to set watchpoint: {str(e)}" @mcp.tool() async def lldb_frame_info(ctx: Context, session_id: str, frame_index: int = 0) -> str: """Get detailed information about a stack frame""" try: session = get_session(ctx, session_id) # First select the frame frame_output = await session.execute_command(f"frame select {frame_index}") # Get frame variables vars_output = await session.execute_command("frame variable") # Get frame source info source_output = await session.execute_command("source list") return f"Frame {frame_index} info:\n\n{frame_output}\n\nVariables:\n{vars_output}\n\nSource:\n{source_output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to get frame info: {str(e)}" @mcp.tool() async def lldb_run(ctx: Context, session_id: str) -> str: """Run the loaded program""" try: session = get_session(ctx, session_id) output = await session.execute_command("run") return f"Running program\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to run program: {str(e)}" @mcp.tool() async def lldb_kill(ctx: Context, session_id: str) -> str: """Kill the running process""" try: session = get_session(ctx, session_id) output = await session.execute_command("process kill") return f"Killed process\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to kill process: {str(e)}" @mcp.tool() async def lldb_thread_list(ctx: Context, session_id: str) -> str: """List all threads in the current process""" try: session = get_session(ctx, session_id) output = await session.execute_command("thread list") return f"Thread list:\n\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to list threads: {str(e)}" @mcp.tool() async def lldb_thread_select(ctx: Context, session_id: str, thread_id: int) -> str: """Select a specific thread""" try: session = get_session(ctx, session_id) output = await session.execute_command(f"thread select {thread_id}") # Get backtrace for the selected thread bt_output = await session.execute_command("bt") return f"Selected thread {thread_id}\n\nOutput:\n{output}\n\nBacktrace:\n{bt_output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to select thread: {str(e)}" @mcp.tool() async def lldb_breakpoint_list(ctx: Context, session_id: str) -> str: """List all breakpoints""" try: session = get_session(ctx, session_id) output = await session.execute_command("breakpoint list") return f"Breakpoint list:\n\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to list breakpoints: {str(e)}" @mcp.tool() async def lldb_breakpoint_delete(ctx: Context, session_id: str, breakpoint_id: int) -> str: """Delete a breakpoint""" try: session = get_session(ctx, session_id) output = await session.execute_command(f"breakpoint delete {breakpoint_id}") return f"Deleted breakpoint {breakpoint_id}\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to delete breakpoint: {str(e)}" @mcp.tool() async def lldb_expression(ctx: Context, session_id: str, expression: str) -> str: """Evaluate an expression in the current frame""" try: session = get_session(ctx, session_id) output = await session.execute_command(f"expression -- {expression}") return f"Expression evaluation: {expression}\n\nOutput:\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to evaluate expression: {str(e)}" @mcp.tool() async def lldb_process_info(ctx: Context, session_id: str) -> str: """Get information about the current process""" try: session = get_session(ctx, session_id) output = await session.execute_command("process status") # Get additional process info pid_output = await session.execute_command("process info") return f"Process information:\n\n{output}\n\nDetails:\n{pid_output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to get process info: {str(e)}" @mcp.tool() async def lldb_disassemble(ctx: Context, session_id: str, location: str = None, count: int = 10) -> str: """Disassemble code""" try: session = get_session(ctx, session_id) # Build disassemble command command = "disassemble" if location: command += f" --name {location}" command += f" -c {count}" output = await session.execute_command(command) return f"Disassembly{f' of {location}' if location else ''}:\n\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to disassemble: {str(e)}" @mcp.tool() async def lldb_help(ctx: Context, session_id: str, command: str = None) -> str: """Get help for LLDB commands""" try: session = get_session(ctx, session_id) debug_log(f"Getting help for command: {command}") if command: output = await session.execute_command(f"help {command}") return f"Help for '{command}':\n\n{output}" else: output = await session.execute_command("help") return f"LLDB help overview:\n\n{output}" except ValueError as e: return str(e) except Exception as e: return f"Failed to get help: {str(e)}" if __name__ == "__main__": # Parse command line arguments parser = argparse.ArgumentParser(description="LLDB MCP Server") parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() # Set global debug flag if enabled via command line if args.debug: DEBUG = True debug_log("Debug logging enabled") try: mcp.run() except KeyboardInterrupt: debug_log("LLDB-MCP server stopped")

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/stass/lldb-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server