dynamic-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ """BPFtrace script execution and management."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BPFtraceExecutor:
15
+ """Executes BPFtrace scripts with proper permission handling."""
16
+
17
+ def __init__(self, timeout: int = 30):
18
+ """Initialize BPFtrace executor.
19
+
20
+ Args:
21
+ timeout: Default timeout for script execution in seconds
22
+ """
23
+ self.timeout = timeout
24
+ self.bpftrace_path = self._find_bpftrace()
25
+
26
+ def _find_bpftrace(self) -> Optional[str]:
27
+ """Find bpftrace binary in system PATH."""
28
+ try:
29
+ result = subprocess.run(
30
+ ["which", "bpftrace"],
31
+ stdout=subprocess.PIPE,
32
+ stderr=subprocess.PIPE,
33
+ universal_newlines=True,
34
+ timeout=5
35
+ )
36
+ if result.returncode == 0:
37
+ path = result.stdout.strip()
38
+ logger.info(f"Found bpftrace at: {path}")
39
+ return path
40
+ except Exception as e:
41
+ logger.warning(f"Could not find bpftrace: {e}")
42
+ return None
43
+
44
+ def is_available(self) -> bool:
45
+ """Check if bpftrace is available on the system."""
46
+ return self.bpftrace_path is not None
47
+
48
+ def get_version(self) -> Optional[str]:
49
+ """Get bpftrace version."""
50
+ if not self.bpftrace_path:
51
+ return None
52
+ try:
53
+ result = subprocess.run(
54
+ [self.bpftrace_path, "--version"],
55
+ stdout=subprocess.PIPE,
56
+ stderr=subprocess.PIPE,
57
+ universal_newlines=True,
58
+ timeout=5
59
+ )
60
+ return result.stdout.strip() if result.returncode == 0 else None
61
+ except Exception as e:
62
+ logger.warning(f"Could not get bpftrace version: {e}")
63
+ return None
64
+
65
+ async def execute_script(
66
+ self,
67
+ script: str,
68
+ timeout: Optional[int] = None,
69
+ use_sudo: bool = True
70
+ ) -> Tuple[str, str, int]:
71
+ """Execute a BPFtrace script.
72
+
73
+ Args:
74
+ script: BPFtrace script content
75
+ timeout: Execution timeout in seconds (uses default if None)
76
+ use_sudo: Whether to use sudo for execution
77
+
78
+ Returns:
79
+ Tuple of (stdout, stderr, return_code)
80
+ """
81
+ if not self.bpftrace_path:
82
+ return "", "BPFtrace not available on system", 1
83
+
84
+ timeout = timeout or self.timeout
85
+
86
+ # Write script to temporary file
87
+ try:
88
+ with tempfile.NamedTemporaryFile(
89
+ mode='w',
90
+ suffix='.bt',
91
+ delete=False
92
+ ) as f:
93
+ f.write(script)
94
+ script_path = f.name
95
+
96
+ return await self._execute_script_file(
97
+ script_path,
98
+ timeout,
99
+ use_sudo
100
+ )
101
+ finally:
102
+ # Clean up temporary file
103
+ try:
104
+ os.unlink(script_path)
105
+ except Exception as e:
106
+ logger.warning(f"Could not delete temp script: {e}")
107
+
108
+ async def _execute_script_file(
109
+ self,
110
+ script_path: str,
111
+ timeout: int,
112
+ use_sudo: bool
113
+ ) -> Tuple[str, str, int]:
114
+ """Execute a BPFtrace script from file."""
115
+ cmd = [self.bpftrace_path, script_path]
116
+
117
+ if use_sudo:
118
+ cmd = ["sudo", "-n"] + cmd
119
+
120
+ try:
121
+ process = await asyncio.create_subprocess_exec(
122
+ *cmd,
123
+ stdout=asyncio.subprocess.PIPE,
124
+ stderr=asyncio.subprocess.PIPE
125
+ )
126
+
127
+ try:
128
+ stdout, stderr = await asyncio.wait_for(
129
+ process.communicate(),
130
+ timeout=timeout
131
+ )
132
+ return (
133
+ stdout.decode('utf-8', errors='ignore'),
134
+ stderr.decode('utf-8', errors='ignore'),
135
+ process.returncode
136
+ )
137
+ except asyncio.TimeoutError:
138
+ logger.info(f"BPFtrace script execution timeout after {timeout}s, terminating process")
139
+
140
+ # Try graceful termination first with SIGTERM
141
+ process.terminate()
142
+ logger.debug("Sent SIGTERM to process")
143
+ try:
144
+ # Wait for graceful termination with a short timeout
145
+ logger.debug("Waiting for graceful termination...")
146
+ await asyncio.wait_for(process.wait(), timeout=2)
147
+ logger.debug("Process terminated gracefully")
148
+ except asyncio.TimeoutError:
149
+ # If graceful termination fails, force kill
150
+ logger.warning("Graceful termination failed, force killing process")
151
+ process.kill()
152
+ logger.debug("Sent SIGKILL to process")
153
+ try:
154
+ logger.debug("Waiting for process to be killed...")
155
+ await asyncio.wait_for(process.wait(), timeout=1)
156
+ logger.debug("Process killed successfully")
157
+ except asyncio.TimeoutError:
158
+ logger.error("Process did not respond to SIGKILL")
159
+
160
+ # Capture any partial output that was produced before timeout
161
+ # Don't try to read from streams as they may block
162
+ stdout_text = ""
163
+ stderr_text = f"Script execution timed out after {timeout}s"
164
+
165
+ logger.debug(f"Returning timeout result: exit_code=124")
166
+ return stdout_text, stderr_text, 124
167
+
168
+ except Exception as e:
169
+ logger.error(f"Error executing BPFtrace script: {e}")
170
+ return "", str(e), 1
171
+
172
+ def validate_script(self, script: str) -> Tuple[bool, str]:
173
+ """Validate BPFtrace script syntax.
174
+
175
+ Args:
176
+ script: BPFtrace script content
177
+
178
+ Returns:
179
+ Tuple of (is_valid, error_message)
180
+ """
181
+ if not self.bpftrace_path:
182
+ return False, "BPFtrace not available"
183
+
184
+ try:
185
+ with tempfile.NamedTemporaryFile(
186
+ mode='w',
187
+ suffix='.bt',
188
+ delete=False
189
+ ) as f:
190
+ f.write(script)
191
+ script_path = f.name
192
+
193
+ try:
194
+ result = subprocess.run(
195
+ ["sudo", "-n", self.bpftrace_path, "-c", script_path],
196
+ stdout=subprocess.PIPE,
197
+ stderr=subprocess.PIPE,
198
+ universal_newlines=True,
199
+ timeout=5
200
+ )
201
+ if result.returncode == 0:
202
+ return True, ""
203
+ else:
204
+ return False, result.stderr
205
+ finally:
206
+ os.unlink(script_path)
207
+
208
+ except Exception as e:
209
+ return False, str(e)
210
+
dynamic_mcp/config.py ADDED
@@ -0,0 +1,106 @@
1
+ """Configuration for dynamic MCP server."""
2
+
3
+ import logging
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Dict, Any
8
+
9
+ from dynamic_mcp.permission_manager import check_crash_dump_access, configure_crash_dump_permissions
10
+
11
+
12
+ class Config:
13
+ """Configuration class for crash MCP server."""
14
+
15
+ def __init__(self):
16
+ self.crash_dump_path = Path(os.getenv("CRASH_DUMP_PATH", "/var/crash"))
17
+ self.kernel_path = Path(os.getenv("KERNEL_PATH", "/boot"))
18
+ self.log_level = os.getenv("LOG_LEVEL", "INFO")
19
+ self.crash_timeout = int(os.getenv("CRASH_TIMEOUT", "360"))
20
+ self.max_crash_dumps = int(os.getenv("MAX_CRASH_DUMPS", "10"))
21
+ self.session_init_timeout = int(os.getenv("SESSION_INIT_TIMEOUT", "1024"))
22
+
23
+
24
+ def setup_logging():
25
+ """Setup logging configuration."""
26
+ logging.basicConfig(
27
+ level=getattr(logging, os.getenv("LOG_LEVEL", "INFO")),
28
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
29
+ )
30
+
31
+
32
+ def check_system_requirements() -> Dict[str, Any]:
33
+ """Check system requirements for crash analysis."""
34
+ requirements = {
35
+ "crash_utility": False,
36
+ "crash_dump_access": False,
37
+ "kernel_access": False,
38
+ "root_access": False,
39
+ "crash_dump_readable": False
40
+ }
41
+
42
+ # Check crash utility
43
+ try:
44
+ result = subprocess.run(["crash", "--version"], capture_output=True, text=True, timeout=10)
45
+ requirements["crash_utility"] = result.returncode == 0
46
+ except (subprocess.TimeoutExpired, FileNotFoundError):
47
+ pass
48
+
49
+ # Check crash dump access
50
+ crash_path = Path("/var/crash")
51
+ requirements["crash_dump_access"] = crash_path.exists() and crash_path.is_dir()
52
+
53
+ # Check if crash dump directory is readable
54
+ requirements["crash_dump_readable"] = check_crash_dump_access(crash_path)
55
+
56
+ # Check kernel access
57
+ kernel_path = Path("/boot")
58
+ requirements["kernel_access"] = kernel_path.exists() and kernel_path.is_dir()
59
+
60
+ # Check root access
61
+ requirements["root_access"] = os.geteuid() == 0
62
+
63
+ return requirements
64
+
65
+
66
+ def validate_crash_utility() -> str:
67
+ """Validate crash utility availability and return version."""
68
+ try:
69
+ result = subprocess.run(["crash", "--version"], capture_output=True, text=True, timeout=10)
70
+ if result.returncode == 0:
71
+ return result.stdout.strip()
72
+ except (subprocess.TimeoutExpired, FileNotFoundError):
73
+ pass
74
+ return ""
75
+
76
+
77
+ def ensure_crash_dump_access(crash_path: Path = Path("/var/crash")) -> bool:
78
+ """Ensure crash dump directory is readable at runtime.
79
+
80
+ Attempts to configure permissions if not already readable.
81
+
82
+ Args:
83
+ crash_path: Path to crash dump directory
84
+
85
+ Returns:
86
+ True if readable or successfully configured, False otherwise
87
+ """
88
+ logger = logging.getLogger(__name__)
89
+
90
+ # Check if already readable
91
+ if check_crash_dump_access(crash_path):
92
+ logger.debug(f"Crash dump directory already readable: {crash_path}")
93
+ return True
94
+
95
+ logger.info(f"Crash dump directory not readable, attempting to configure permissions...")
96
+
97
+ # Try to configure permissions
98
+ success, message = configure_crash_dump_permissions(crash_path)
99
+
100
+ if success:
101
+ logger.info(f"Permission configuration: {message}")
102
+ # Verify it worked
103
+ return check_crash_dump_access(crash_path)
104
+ else:
105
+ logger.warning(f"Permission configuration failed: {message}")
106
+ return False
@@ -0,0 +1,134 @@
1
+ """Crash dump discovery functionality."""
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import List, NamedTuple, Optional
7
+ from datetime import datetime
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class CrashDump(NamedTuple):
14
+ """Represents a crash dump file."""
15
+ name: str
16
+ path: Path
17
+ size: int
18
+ timestamp: datetime
19
+
20
+ @property
21
+ def mtime(self) -> datetime:
22
+ """Get modification time (alias for timestamp)."""
23
+ return self.timestamp
24
+
25
+ def to_dict(self) -> dict:
26
+ """Convert crash dump to dictionary."""
27
+ return {
28
+ "name": self.name,
29
+ "path": str(self.path),
30
+ "size": self.size,
31
+ "size_mb": round(self.size / (1024 * 1024), 2),
32
+ "timestamp": self.timestamp.isoformat(),
33
+ "mtime": self.mtime.isoformat(),
34
+ "readable": os.access(self.path, os.R_OK)
35
+ }
36
+
37
+
38
+ class CrashDumpDiscovery:
39
+ """Discovers crash dump files in the system."""
40
+
41
+ def __init__(self, crash_dump_path: str):
42
+ self.crash_dump_path = Path(crash_dump_path)
43
+ self.dump_patterns = [
44
+ "vmcore*",
45
+ "core*",
46
+ "crash*",
47
+ "dump*"
48
+ ]
49
+
50
+ def find_crash_dumps(self, max_dumps: int = 10) -> List[CrashDump]:
51
+ """Find crash dump files in the system."""
52
+ dumps = []
53
+
54
+ if not self.crash_dump_path.exists():
55
+ logger.warning(f"Crash dump path does not exist: {self.crash_dump_path}")
56
+ return dumps
57
+
58
+ try:
59
+ # Search in main directory and subdirectories
60
+ for root, dirs, files in os.walk(self.crash_dump_path):
61
+ root_path = Path(root)
62
+
63
+ for file in files:
64
+ file_path = root_path / file
65
+
66
+ # Check if file matches dump patterns
67
+ if any(file_path.match(pattern) for pattern in self.dump_patterns):
68
+ try:
69
+ stat = file_path.stat()
70
+ dump = CrashDump(
71
+ name=file,
72
+ path=file_path,
73
+ size=stat.st_size,
74
+ timestamp=datetime.fromtimestamp(stat.st_mtime)
75
+ )
76
+ dumps.append(dump)
77
+ except (OSError, PermissionError) as e:
78
+ logger.warning(f"Cannot access dump file {file_path}: {e}")
79
+ continue
80
+
81
+ # Limit search depth to avoid excessive recursion
82
+ if len(str(root_path).split(os.sep)) - len(str(self.crash_dump_path).split(os.sep)) > 2:
83
+ dirs.clear()
84
+
85
+ except PermissionError as e:
86
+ logger.error(f"Permission denied accessing crash dump directory: {e}")
87
+
88
+ # Sort by timestamp (newest first) and limit results
89
+ dumps.sort(key=lambda x: x.timestamp, reverse=True)
90
+ return dumps[:max_dumps]
91
+
92
+ def get_dump_info(self, dump: CrashDump) -> dict:
93
+ """Get detailed information about a crash dump."""
94
+ return {
95
+ "name": dump.name,
96
+ "path": str(dump.path),
97
+ "size": dump.size,
98
+ "size_mb": round(dump.size / (1024 * 1024), 2),
99
+ "timestamp": dump.timestamp.isoformat(),
100
+ "readable": os.access(dump.path, os.R_OK)
101
+ }
102
+
103
+ def get_latest_crash_dump(self) -> Optional[CrashDump]:
104
+ """Get the most recent crash dump."""
105
+ dumps = self.find_crash_dumps(max_dumps=1)
106
+ return dumps[0] if dumps else None
107
+
108
+ def get_crash_dump_by_name(self, name: str) -> Optional[CrashDump]:
109
+ """Get a crash dump by name."""
110
+ dumps = self.find_crash_dumps()
111
+ for dump in dumps:
112
+ if dump.name == name:
113
+ return dump
114
+ return None
115
+
116
+ def is_valid_crash_dump(self, dump) -> bool:
117
+ """Check if a file is a valid crash dump."""
118
+ try:
119
+ # Handle both string paths and CrashDump objects
120
+ if isinstance(dump, CrashDump):
121
+ path = dump.path
122
+ filename = dump.name
123
+ else:
124
+ path = Path(dump)
125
+ filename = path.name
126
+
127
+ if not path.exists() or not path.is_file():
128
+ return False
129
+
130
+ # Check if filename matches dump patterns
131
+ return any(Path(filename).match(pattern) for pattern in self.dump_patterns)
132
+ except Exception as e:
133
+ logger.warning(f"Error validating crash dump {getattr(dump, 'name', str(dump))}: {e}")
134
+ return False
@@ -0,0 +1,209 @@
1
+ """Crash session management."""
2
+
3
+ import logging
4
+ import pexpect
5
+ import subprocess
6
+ import time
7
+ from typing import Optional, Tuple
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class CrashSession:
14
+ """Represents an active crash analysis session."""
15
+
16
+ def __init__(self, dump_path: str, kernel_path: str):
17
+ self.dump_path = dump_path
18
+ self.kernel_path = kernel_path
19
+ self.process = None
20
+ self.session_id = f"crash_{int(time.time())}"
21
+ self.active = False
22
+ self.prompt_patterns = [
23
+ r'crash> ', # Standard prompt with space
24
+ r'crash>', # Prompt without space
25
+ r'crash>\s*', # Prompt with optional whitespace
26
+ ]
27
+
28
+ def is_active(self) -> bool:
29
+ """Check if the session is active."""
30
+ return self.active
31
+
32
+ def start(self, timeout: int = 180) -> bool:
33
+ """Start the crash session.
34
+
35
+ Detects kernel file type and uses appropriate crash command format:
36
+ - For debug symbols (vmlinux): crash --no_scroll vmcore vmlinux
37
+ - For compressed kernels (vmlinuz): crash --no_scroll -f vmlinuz vmcore
38
+ """
39
+ try:
40
+ # Detect kernel file type and build appropriate command
41
+ kernel_name = self.kernel_path.split('/')[-1]
42
+ is_debug_symbol = kernel_name == "vmlinux" or kernel_name.startswith("vmlinux-")
43
+
44
+ if is_debug_symbol:
45
+ # Debug symbols: crash --no_scroll vmcore vmlinux (no -f flag needed)
46
+ cmd_parts = ['crash', '--no_scroll', self.dump_path, self.kernel_path]
47
+ else:
48
+ # Compressed kernel: crash --no_scroll -f vmlinuz vmcore
49
+ cmd_parts = ['crash', '--no_scroll', '-f', self.kernel_path, self.dump_path]
50
+
51
+ cmd = ' '.join(cmd_parts)
52
+
53
+ logger.info(f"Starting crash process: {cmd}")
54
+
55
+ # Start crash process
56
+ self.process = pexpect.spawn(cmd, timeout=timeout)
57
+
58
+ # Wait for initial prompt
59
+ expect_list = self.prompt_patterns + ['crash: .*', pexpect.TIMEOUT, pexpect.EOF]
60
+
61
+ index = self.process.expect(expect_list, timeout=timeout)
62
+
63
+ if index < len(self.prompt_patterns):
64
+ logger.info(f"Crash session started successfully: {self.session_id}")
65
+ self.active = True
66
+ return True
67
+ elif index == len(self.prompt_patterns):
68
+ # Error pattern
69
+ error_msg = self.process.after.decode('utf-8', errors='ignore')
70
+ logger.error(f"Crash startup error: {error_msg}")
71
+ return False
72
+ elif index == len(self.prompt_patterns) + 1:
73
+ # Timeout
74
+ logger.error(f"Crash startup timed out after {timeout} seconds")
75
+ return False
76
+ else:
77
+ # EOF
78
+ logger.error("Crash process terminated during startup")
79
+ return False
80
+
81
+ except Exception as e:
82
+ logger.error(f"Failed to start crash session: {e}")
83
+ return False
84
+
85
+ def execute_command(self, command: str, timeout: int = 120) -> Tuple[str, str, int]:
86
+ """Execute a command in the crash session."""
87
+ if not self.is_active() or not self.process:
88
+ return "", "Session not active", 1
89
+
90
+ try:
91
+ logger.info(f"Executing crash command: {command}")
92
+
93
+ # Send command to crash process
94
+ self.process.sendline(command)
95
+
96
+ # Wait for prompt to return
97
+ expect_list = self.prompt_patterns + ['crash: .*', pexpect.TIMEOUT, pexpect.EOF]
98
+
99
+ index = self.process.expect(expect_list, timeout=timeout)
100
+
101
+ if index < len(self.prompt_patterns):
102
+ # Successfully got prompt back
103
+ output = self.process.before.decode('utf-8', errors='ignore')
104
+ # Clean up the output by removing the command echo
105
+ lines = output.split('\n')
106
+ if lines and lines[0].strip() == command.strip():
107
+ output = '\n'.join(lines[1:])
108
+ return output.strip(), "", 0
109
+ elif index == len(self.prompt_patterns):
110
+ # Error pattern matched
111
+ error_msg = self.process.after.decode('utf-8', errors='ignore')
112
+ return "", f"Crash error: {error_msg}", 1
113
+ elif index == len(self.prompt_patterns) + 1:
114
+ # Timeout
115
+ return "", f"Command '{command}' timed out after {timeout} seconds", 1
116
+ else:
117
+ # EOF - crash process died
118
+ self.active = False
119
+ return "", "Crash process terminated unexpectedly", 1
120
+
121
+ except Exception as e:
122
+ logger.error(f"Error executing command '{command}': {e}")
123
+ return "", str(e), 1
124
+
125
+ def close(self):
126
+ """Close the crash session."""
127
+ if self.process:
128
+ try:
129
+ # Try to quit gracefully first
130
+ if self.active:
131
+ try:
132
+ self.process.sendline('quit')
133
+ self.process.expect(pexpect.EOF, timeout=5)
134
+ except:
135
+ pass
136
+
137
+ # Force close if still alive
138
+ if self.process.isalive():
139
+ self.process.terminate()
140
+ if self.process.isalive():
141
+ self.process.kill()
142
+
143
+ except Exception as e:
144
+ logger.error(f"Error closing session: {e}")
145
+ finally:
146
+ self.process = None
147
+ self.active = False
148
+
149
+
150
+ class CrashSessionManager:
151
+ """Manages crash analysis sessions."""
152
+
153
+ def __init__(self):
154
+ self.active_session: Optional[CrashSession] = None
155
+
156
+ def start_session(self, crash_dump, kernel_file, timeout: int = 180) -> bool:
157
+ """Start a new crash analysis session."""
158
+ # Close existing session if any
159
+ if self.active_session:
160
+ self.close_session()
161
+
162
+ try:
163
+ logger.info(f"Starting crash session with dump: {crash_dump.name}, kernel: {kernel_file.name}")
164
+
165
+ # Create new session
166
+ session = CrashSession(str(crash_dump.path), str(kernel_file.path))
167
+
168
+ # Actually start the crash process
169
+ if session.start(timeout):
170
+ self.active_session = session
171
+ logger.info(f"Crash session started successfully: {session.session_id}")
172
+ return True
173
+ else:
174
+ logger.error("Failed to start crash process")
175
+ return False
176
+
177
+ except Exception as e:
178
+ logger.error(f"Failed to start crash session: {e}")
179
+ return False
180
+
181
+ def execute_command(self, command: str, timeout: int = 120) -> Tuple[str, str, int]:
182
+ """Execute a command in the active session."""
183
+ if not self.active_session:
184
+ return "", "No active crash session", 1
185
+
186
+ return self.active_session.execute_command(command, timeout)
187
+
188
+ def is_session_active(self) -> bool:
189
+ """Check if there's an active session."""
190
+ return self.active_session is not None and self.active_session.is_active()
191
+
192
+ def get_session_info(self) -> dict:
193
+ """Get information about the active session."""
194
+ if not self.active_session:
195
+ return {"active": False}
196
+
197
+ return {
198
+ "active": True,
199
+ "session_id": self.active_session.session_id,
200
+ "dump_path": self.active_session.dump_path,
201
+ "kernel_path": self.active_session.kernel_path
202
+ }
203
+
204
+ def close_session(self):
205
+ """Close the active session."""
206
+ if self.active_session:
207
+ logger.info(f"Closing crash session: {self.active_session.session_id}")
208
+ self.active_session.close()
209
+ self.active_session = None