linux-mcp-server 0.1.0a2__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,4 @@
1
+ import importlib.metadata
2
+
3
+
4
+ __version__ = importlib.metadata.version(__spec__.parent)
@@ -0,0 +1,30 @@
1
+ """Main entry point for the Linux MCP Server."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ from linux_mcp_server import __version__
7
+ from linux_mcp_server.logging_config import setup_logging
8
+ from linux_mcp_server.server import main
9
+
10
+
11
+ def cli():
12
+ """Console script entry point for the Linux MCP Server."""
13
+ setup_logging()
14
+
15
+ logger = logging.getLogger("linux-mcp-server")
16
+ logger.info(f"Starting Linux MCP Server {__version__}")
17
+
18
+ try:
19
+ # FastMCP.run() creates its own event loop, don't use asyncio.run()
20
+ main()
21
+ except KeyboardInterrupt:
22
+ logger.info("Linux MCP Server stopped by user")
23
+ sys.exit(0)
24
+ except Exception as e:
25
+ logger.critical(f"Fatal error in Linux MCP Server: {e}", exc_info=True)
26
+ sys.exit(1)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ cli()
@@ -0,0 +1,331 @@
1
+ """Audit logging utilities for Linux MCP Server.
2
+
3
+ This module provides helper functions for consistent audit logging across
4
+ the entire MCP server. All functions add structured context to log records
5
+ that can be output in both human-readable and JSON formats.
6
+ """
7
+
8
+ import functools
9
+ import inspect
10
+ import logging
11
+ import time
12
+ import typing as t
13
+
14
+ from contextlib import contextmanager
15
+ from datetime import timedelta
16
+
17
+ from linux_mcp_server.utils import StrEnum
18
+
19
+
20
+ Function: t.TypeAlias = t.Callable[..., t.Any]
21
+
22
+ # Sensitive field names that should be redacted in logs
23
+ SENSITIVE_FIELDS = {
24
+ "password",
25
+ "passwd",
26
+ "pwd",
27
+ "secret",
28
+ "api_key",
29
+ "apikey",
30
+ "token",
31
+ "auth",
32
+ "authorization",
33
+ "private_key",
34
+ "privatekey",
35
+ }
36
+
37
+
38
+ class Event(StrEnum):
39
+ LOCAL_EXEC_ERROR = "LOCAL_EXEC_ERROR"
40
+ REMOTE_EXEC = "REMOTE_EXEC"
41
+ REMOTE_EXEC_ERROR = "REMOTE_EXEC_ERROR"
42
+ SSH_AUTH_FAILED = "SSH_AUTH_FAILED"
43
+ SSH_CONNECT = "SSH_CONNECT"
44
+ SSH_CONNECTING = "SSH_CONNECTING"
45
+ TOOL_CALL = "TOOL_CALL"
46
+ TOOL_COMPLETE = "TOOL_COMPLETE"
47
+
48
+
49
+ class ExecutionMode(StrEnum):
50
+ REMOTE = "REMOTE"
51
+ LOCAL = "LOCAL"
52
+
53
+
54
+ class Status(StrEnum):
55
+ success = "success"
56
+ error = "error"
57
+
58
+
59
+ def sanitize_parameters(params: dict[str, t.Any]) -> dict[str, t.Any]:
60
+ """
61
+ Sanitize parameters by redacting sensitive fields.
62
+
63
+ Args:
64
+ params: Dictionary of parameters to sanitize
65
+
66
+ Returns:
67
+ Dictionary with sensitive fields redacted
68
+ """
69
+ if not params:
70
+ return params
71
+
72
+ sanitized = {}
73
+ for key, value in params.items():
74
+ # Check if key is sensitive
75
+ key_lower = key.lower().replace("_", "").replace("-", "")
76
+ is_sensitive = any(sensitive in key_lower for sensitive in [s.replace("_", "") for s in SENSITIVE_FIELDS])
77
+
78
+ if is_sensitive:
79
+ sanitized[key] = "***REDACTED***"
80
+ elif isinstance(value, dict):
81
+ # Recursively sanitize nested dicts
82
+ sanitized[key] = sanitize_parameters(value)
83
+ else:
84
+ sanitized[key] = value
85
+
86
+ return sanitized
87
+
88
+
89
+ @contextmanager
90
+ def AuditContext(**extra_fields):
91
+ """
92
+ Context manager for adding extra fields to all log records.
93
+
94
+ Usage:
95
+ with AuditContext(tool="list_services", host="server1.com") as logger:
96
+ logger.info("Starting operation")
97
+
98
+ Args:
99
+ **extra_fields: Additional fields to add to log records
100
+
101
+ Yields:
102
+ Logger with extra fields
103
+ """
104
+ logger = logging.getLogger()
105
+
106
+ # Create adapter with extra fields
107
+ class ContextAdapter(logging.LoggerAdapter):
108
+ def process(self, msg, kwargs):
109
+ # Add extra fields to the record
110
+ if "extra" not in kwargs:
111
+ kwargs["extra"] = {}
112
+
113
+ if isinstance(self.extra, t.Iterable):
114
+ kwargs["extra"].update(self.extra)
115
+
116
+ return msg, kwargs
117
+
118
+ adapter = ContextAdapter(logger, extra_fields)
119
+ yield adapter
120
+
121
+
122
+ def _log_event_start(
123
+ logger: logging.Logger,
124
+ tool_name: str,
125
+ params: dict[t.Any, t.Any],
126
+ ) -> int:
127
+ """
128
+ Emit a log event and return a performance counter timestamp.
129
+
130
+ The timestamp is in nanoseconds. It is meant to be used to calculate
131
+ total execution time.
132
+ """
133
+ execution_mode = ExecutionMode.REMOTE if params.get("host") else ExecutionMode.LOCAL
134
+ safe_params = sanitize_parameters(params)
135
+
136
+ extra = {
137
+ "tool": tool_name,
138
+ "execution_mode": execution_mode,
139
+ }
140
+ if "host" in params:
141
+ extra["host"] = params["host"]
142
+
143
+ if "username" in params:
144
+ extra["username"] = params["username"]
145
+
146
+ message = f"{Event.TOOL_CALL}: {tool_name}"
147
+
148
+ params_str = ", ".join(f"{k}={v}" for k, v in safe_params.items() if k not in ["host", "username"])
149
+ if params_str:
150
+ message += f" | {params_str}"
151
+
152
+ logger.info(message, extra=extra)
153
+
154
+ return time.perf_counter_ns()
155
+
156
+
157
+ def _log_event_complete(
158
+ logger: logging.Logger,
159
+ tool_name: str,
160
+ start_time: int,
161
+ error: Exception | None = None,
162
+ ) -> None:
163
+ """
164
+ Log the completion of a tool call and calculate the total execution time.
165
+ """
166
+ stop_time = time.perf_counter_ns()
167
+ duration = timedelta(microseconds=(stop_time - start_time) / 1_000)
168
+ status = Status.error if error else Status.success
169
+ extra = {
170
+ "tool": tool_name,
171
+ "status": status,
172
+ "duration": f"{duration}s",
173
+ }
174
+
175
+ message = f"{Event.TOOL_COMPLETE}: {tool_name}"
176
+
177
+ if error:
178
+ extra["error"] = str(error)
179
+ message += f" | error: {error}"
180
+ logger.error(message, extra=extra)
181
+ else:
182
+ logger.info(message, extra=extra)
183
+
184
+
185
+ def log_tool_call(func: t.Callable) -> Function:
186
+ """Decorator to log tool calls
187
+
188
+ Works with sync or async functions.
189
+ """
190
+ logger = logging.getLogger("linux-mcp-server")
191
+ tool_name = func.__name__
192
+
193
+ @functools.wraps(func)
194
+ def wrapper(*args, **kwargs):
195
+ start_time = _log_event_start(logger, tool_name, kwargs)
196
+ error = None
197
+ result = None
198
+
199
+ try:
200
+ result = func(*args, **kwargs)
201
+ except Exception as exc:
202
+ error = exc
203
+ _log_event_complete(logger, tool_name, start_time, error)
204
+ raise
205
+
206
+ _log_event_complete(logger, tool_name, start_time, error)
207
+
208
+ return result
209
+
210
+ @functools.wraps(func)
211
+ async def awrapper(*args, **kwargs):
212
+ start_time = _log_event_start(logger, tool_name, kwargs)
213
+ error = None
214
+ result = None
215
+
216
+ try:
217
+ result = await func(*args, **kwargs)
218
+ except Exception as exc:
219
+ error = exc
220
+ _log_event_complete(logger, tool_name, start_time, error)
221
+ raise
222
+
223
+ _log_event_complete(logger, tool_name, start_time, error)
224
+
225
+ return result
226
+
227
+ if inspect.iscoroutinefunction(func):
228
+ return awrapper
229
+
230
+ return wrapper
231
+
232
+
233
+ def log_ssh_connect(
234
+ host: str,
235
+ username: str,
236
+ status: str,
237
+ reused: bool = False,
238
+ key_path: str | None = None,
239
+ error: str | None = None,
240
+ ):
241
+ """
242
+ Log SSH connection event.
243
+
244
+ Verbosity is tiered based on log level:
245
+ - INFO: Basic connection success/failure
246
+ - DEBUG: Detailed information including key path, reuse status
247
+
248
+ Args:
249
+ host: Remote host
250
+ username: SSH username
251
+ status: Connection status ("success" or "failed")
252
+ reused: Whether connection was reused (shown at DEBUG level)
253
+ key_path: Path to SSH key used (shown at DEBUG level)
254
+ error: Optional error message
255
+ """
256
+ logger = logging.getLogger(__name__)
257
+
258
+ user_host = f"{username}@{host}"
259
+
260
+ if status == Status.success:
261
+ extra = {
262
+ "host": host,
263
+ "username": username,
264
+ "status": status,
265
+ }
266
+
267
+ # At INFO level, just log basic success
268
+ message = f"{Event.SSH_CONNECT}: {user_host}"
269
+
270
+ # At DEBUG level, add more details
271
+ if logger.isEnabledFor(logging.DEBUG):
272
+ if reused is not None:
273
+ extra["reused"] = str(reused)
274
+ if key_path:
275
+ extra["key"] = key_path
276
+
277
+ logger.info(message, extra=extra)
278
+
279
+ else:
280
+ # Connection failed
281
+ extra = {
282
+ "host": host,
283
+ "username": username,
284
+ "status": "failed",
285
+ }
286
+
287
+ if error:
288
+ extra["reason"] = error
289
+
290
+ message = f"{Event.SSH_AUTH_FAILED}: {user_host}"
291
+ if error:
292
+ message += f" | reason: {error}"
293
+
294
+ logger.warning(message, extra=extra)
295
+
296
+
297
+ def log_ssh_command(
298
+ command: str,
299
+ host: str,
300
+ exit_code: int,
301
+ duration: float | None = None,
302
+ ):
303
+ """
304
+ Log SSH command execution.
305
+
306
+ Verbosity is tiered based on log level:
307
+ - INFO: Command and exit code
308
+ - DEBUG: Also includes execution duration
309
+
310
+ Args:
311
+ command: Command that was executed
312
+ host: Remote host
313
+ exit_code: Command exit code
314
+ duration: Optional execution duration in seconds (shown at DEBUG level)
315
+ """
316
+ logger = logging.getLogger(__name__)
317
+
318
+ extra = {
319
+ "command": command,
320
+ "host": host,
321
+ "exit_code": exit_code,
322
+ }
323
+
324
+ message = f"{Event.REMOTE_EXEC}: {command} | host={host} | exit_code={exit_code}"
325
+
326
+ # At DEBUG level, include duration
327
+ if duration is not None and logger.isEnabledFor(logging.DEBUG):
328
+ extra["duration"] = f"{duration:.3f}s"
329
+ message += f" | duration={duration:.3f}s"
330
+
331
+ logger.info(message, extra=extra)
File without changes
@@ -0,0 +1,338 @@
1
+ """SSH executor for remote command execution.
2
+
3
+ This module provides functionality to execute commands on remote systems via SSH,
4
+ with connection pooling and SSH key discovery. It seamlessly routes commands to
5
+ either local or remote execution based on the provided parameters.
6
+ """
7
+
8
+ import asyncio
9
+ import getpass
10
+ import logging
11
+ import os
12
+ import shlex
13
+ import subprocess
14
+ import time
15
+
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ import asyncssh
20
+
21
+ from linux_mcp_server.audit import Event
22
+ from linux_mcp_server.audit import log_ssh_command
23
+ from linux_mcp_server.audit import log_ssh_connect
24
+ from linux_mcp_server.audit import Status
25
+
26
+
27
+ logger = logging.getLogger("linux-mcp-server")
28
+
29
+
30
+ def discover_ssh_key() -> str | None:
31
+ """
32
+ Discover SSH private key for authentication.
33
+
34
+ Checks in order:
35
+ 1. LINUX_MCP_SSH_KEY_PATH environment variable
36
+ 2. Default locations: ~/.ssh/id_ed25519, ~/.ssh/id_rsa, ~/.ssh/id_ecdsa
37
+
38
+ Returns:
39
+ Path to SSH private key if found, None otherwise.
40
+ """
41
+ logger.debug("Discovering SSH key for authentication")
42
+
43
+ # Check environment variable first
44
+ env_key = os.getenv("LINUX_MCP_SSH_KEY_PATH")
45
+ if env_key:
46
+ logger.debug(f"Checking SSH key from environment: {env_key}")
47
+ key_path = Path(env_key)
48
+ if key_path.exists() and key_path.is_file():
49
+ logger.info(f"Using SSH key from environment: {env_key}")
50
+ return str(key_path)
51
+ else:
52
+ logger.warning(f"SSH key specified in LINUX_MCP_SSH_KEY_PATH not found: {env_key}")
53
+ return None
54
+
55
+ # Check default locations (prefer modern algorithms)
56
+ if os.getenv("LINUX_MCP_SEARCH_FOR_SSH_KEY", False):
57
+ home = Path.home()
58
+ default_keys = [
59
+ home / ".ssh" / "id_ed25519",
60
+ home / ".ssh" / "id_ecdsa",
61
+ home / ".ssh" / "id_rsa",
62
+ ]
63
+
64
+ logger.debug(f"Checking default SSH key locations: {[str(k) for k in default_keys]}")
65
+
66
+ for key_path in default_keys:
67
+ if key_path.exists() and key_path.is_file():
68
+ logger.info(f"Using SSH key: {key_path}")
69
+ return str(key_path)
70
+
71
+ logger.warning("No SSH private key found in default locations")
72
+
73
+ logger.debug("Not providing an SSH key")
74
+
75
+
76
+ class SSHConnectionManager:
77
+ """
78
+ Manages SSH connections with connection pooling.
79
+
80
+ This class implements a singleton pattern to maintain a pool of SSH connections
81
+ across the lifetime of the application, improving performance by reusing
82
+ connections to the same hosts.
83
+ """
84
+
85
+ _instance: Optional["SSHConnectionManager"] = None
86
+ _connections: dict[str, asyncssh.SSHClientConnection]
87
+ _ssh_key: str | None
88
+
89
+ def __new__(cls):
90
+ """Implement singleton pattern."""
91
+ if cls._instance is None:
92
+ cls._instance = super().__new__(cls)
93
+ cls._instance._connections = {}
94
+ cls._instance._ssh_key = discover_ssh_key()
95
+ return cls._instance
96
+
97
+ async def get_connection(self, host: str, username: str) -> asyncssh.SSHClientConnection:
98
+ """
99
+ Get or create an SSH connection to a host.
100
+
101
+ Args:
102
+ host: Remote host address
103
+ username: SSH username
104
+
105
+ Returns:
106
+ SSH connection object
107
+
108
+ Raises:
109
+ ConnectionError: If connection fails
110
+ """
111
+ key = f"{username}@{host}"
112
+
113
+ # Return existing connection if available
114
+ if key in self._connections:
115
+ conn = self._connections[key]
116
+ if not conn.is_closed():
117
+ # DEBUG level: Log connection reuse and pool state
118
+ logger.debug(f"SSH_REUSE: {key} | pool_size={len(self._connections)}")
119
+ # Use audit log with connection reuse info
120
+ log_ssh_connect(host, username, status=Status.success, reused=True, key_path=self._ssh_key)
121
+ return conn
122
+ else:
123
+ # Connection was closed, remove it
124
+ logger.debug(f"SSH_POOL: remove_closed_connection | connection={key}")
125
+ del self._connections[key]
126
+
127
+ # Create new connection
128
+ # DEBUG level: Log connection attempt before it completes
129
+ logger.debug(f"{Event.SSH_CONNECTING}: {key} | key={self._ssh_key or 'none'}")
130
+
131
+ try:
132
+ connect_kwargs = {
133
+ "host": host,
134
+ "username": username,
135
+ "known_hosts": None, # Don't verify host keys for now
136
+ "passphrase": os.getenv("LINUX_MCP_KEY_PASSPHRASE"),
137
+ }
138
+
139
+ if self._ssh_key:
140
+ connect_kwargs["client_keys"] = [self._ssh_key]
141
+
142
+ conn = await asyncssh.connect(**connect_kwargs)
143
+ self._connections[key] = conn
144
+
145
+ # Log successful connection using audit function
146
+ log_ssh_connect(host, username, status=Status.success, reused=False, key_path=self._ssh_key)
147
+
148
+ # DEBUG level: Log pool state
149
+ logger.debug(f"SSH_POOL: add_connection | connections={len(self._connections)}")
150
+
151
+ return conn
152
+
153
+ except asyncssh.PermissionDenied as e:
154
+ # Use audit log for authentication failure
155
+ error_msg = str(e)
156
+ log_ssh_connect(host, username, status="failed", error=f"Permission denied: {error_msg}")
157
+ raise ConnectionError(f"Authentication failed for {username}@{host}") from e
158
+ except asyncssh.Error as e:
159
+ # Use audit log for connection failure
160
+ error_msg = str(e)
161
+ log_ssh_connect(host, username, status="failed", error=error_msg)
162
+ raise ConnectionError(f"Failed to connect to {username}@{host}: {e}") from e
163
+
164
+ async def execute_remote(
165
+ self,
166
+ command: list[str],
167
+ host: str,
168
+ username: str,
169
+ ) -> tuple[int, str, str]:
170
+ """
171
+ Execute a command on a remote host via SSH.
172
+
173
+ Args:
174
+ command: Command and arguments to execute
175
+ host: Remote host address
176
+ username: SSH username
177
+
178
+ Returns:
179
+ Tuple of (return_code, stdout, stderr)
180
+
181
+ Raises:
182
+ ConnectionError: If SSH connection fails
183
+ """
184
+ conn = await self.get_connection(host, username)
185
+
186
+ # Build command string with proper shell escaping
187
+ # Use shlex.quote() to ensure special characters (like \n in printf format) are preserved
188
+ cmd_str = shlex.join(command)
189
+
190
+ # Start timing for command execution
191
+ start_time = time.time()
192
+
193
+ try:
194
+ result = await conn.run(cmd_str, check=False)
195
+
196
+ return_code = result.exit_status if result.exit_status is not None else 0
197
+
198
+ # Ensure stdout and stderr are strings (asyncssh can return bytes or str)
199
+ stdout_raw = result.stdout if result.stdout else ""
200
+ stderr_raw = result.stderr if result.stderr else ""
201
+ stdout = stdout_raw if isinstance(stdout_raw, str) else stdout_raw.decode("utf-8", errors="replace")
202
+ stderr = stderr_raw if isinstance(stderr_raw, str) else stderr_raw.decode("utf-8", errors="replace")
203
+
204
+ # Calculate duration
205
+ duration = time.time() - start_time
206
+
207
+ # Use audit log for command execution
208
+ log_ssh_command(cmd_str, host, exit_code=return_code, duration=duration)
209
+
210
+ return return_code, stdout, stderr
211
+
212
+ except asyncssh.Error as e:
213
+ duration = time.time() - start_time
214
+ logger.error(
215
+ f"Error executing command on {username}@{host}: {e}",
216
+ extra={
217
+ "event": Event.REMOTE_EXEC_ERROR,
218
+ "command": cmd_str,
219
+ "host": host,
220
+ "duration": f"{duration:.3f}s",
221
+ "error": str(e),
222
+ },
223
+ )
224
+ raise ConnectionError(f"Failed to execute command on {username}@{host}: {e}") from e
225
+
226
+ async def close_all(self):
227
+ """Close all SSH connections."""
228
+ connection_count = len(self._connections)
229
+ logger.info(f"Closing {connection_count} SSH connections")
230
+
231
+ for key, conn in list(self._connections.items()):
232
+ try:
233
+ logger.debug(f"SSH_CLOSE: {key}")
234
+ conn.close()
235
+ await conn.wait_closed()
236
+ except Exception as e:
237
+ logger.warning(f"Error closing connection to {key}: {e}")
238
+
239
+ self._connections.clear()
240
+ logger.debug(f"SSH_POOL: cleared | closed_connections={connection_count}")
241
+
242
+
243
+ # Global connection manager instance
244
+ _connection_manager = SSHConnectionManager()
245
+
246
+
247
+ async def execute_command(
248
+ command: list[str],
249
+ host: str | None = None,
250
+ username: str | None = None,
251
+ **kwargs,
252
+ ) -> tuple[int, str, str]:
253
+ """
254
+ Execute a command locally or remotely.
255
+
256
+ This is the main entry point for command execution. It routes the command
257
+ to either local subprocess execution or remote SSH execution based on
258
+ whether host/username parameters are provided.
259
+
260
+ Args:
261
+ command: Command and arguments to execute
262
+ host: Optional remote host address
263
+ username: Optional SSH username (required if host is provided)
264
+ **kwargs: Additional arguments (reserved for future use)
265
+
266
+ Returns:
267
+ Tuple of (return_code, stdout, stderr)
268
+
269
+ Raises:
270
+ ValueError: If host is provided without username
271
+ ConnectionError: If remote connection fails
272
+
273
+ Examples:
274
+ # Local execution
275
+ >>> returncode, stdout, stderr = await execute_command(["ls", "-la"])
276
+
277
+ # Remote execution
278
+ >>> returncode, stdout, stderr = await execute_command(
279
+ ... ["ls", "-la"],
280
+ ... host="server.example.com",
281
+ ... username="admin"
282
+ ... )
283
+ """
284
+ cmd_str = " ".join(command)
285
+
286
+ # Route to remote execution if host is provided
287
+ if host:
288
+ if not username:
289
+ username = getpass.getuser()
290
+
291
+ logger.debug(f"Routing to remote execution: {username}@{host} | command={cmd_str}")
292
+ return await _connection_manager.execute_remote(command, host, username)
293
+
294
+ # Local execution
295
+ logger.debug(f"LOCAL_EXEC: {cmd_str}")
296
+ return await _execute_local(command)
297
+
298
+
299
+ async def _execute_local(command: list[str]) -> tuple[int, str, str]:
300
+ """
301
+ Execute a command locally using subprocess.
302
+
303
+ Args:
304
+ command: Command and arguments to execute
305
+
306
+ Returns:
307
+ Tuple of (return_code, stdout, stderr)
308
+ """
309
+ cmd_str = " ".join(command)
310
+ start_time = time.time()
311
+
312
+ try:
313
+ proc = await asyncio.create_subprocess_exec(*command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
314
+ stdout_bytes, stderr_bytes = await proc.communicate()
315
+
316
+ return_code = proc.returncode if proc.returncode is not None else 0
317
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
318
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
319
+
320
+ duration = time.time() - start_time
321
+
322
+ # DEBUG level: Log local command execution with timing
323
+ logger.debug(f"LOCAL_EXEC completed: {cmd_str} | exit_code={return_code} | duration={duration:.3f}s")
324
+
325
+ return return_code, stdout, stderr
326
+
327
+ except Exception as e:
328
+ duration = time.time() - start_time
329
+ logger.error(
330
+ f"Error executing local command: {cmd_str}",
331
+ extra={
332
+ "event": Event.LOCAL_EXEC_ERROR,
333
+ "command": cmd_str,
334
+ "duration": f"{duration:.3f}s",
335
+ "error": str(e),
336
+ },
337
+ )
338
+ return 1, "", str(e)