server-management-lib 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.
- server_management_lib/__init__.py +124 -0
- server_management_lib/audit.py +80 -0
- server_management_lib/base.py +55 -0
- server_management_lib/config.py +385 -0
- server_management_lib/executor.py +53 -0
- server_management_lib/http_clients.py +636 -0
- server_management_lib/local_executor.py +200 -0
- server_management_lib/operations.py +33 -0
- server_management_lib/output.py +61 -0
- server_management_lib/py.typed +0 -0
- server_management_lib/rate_limit.py +118 -0
- server_management_lib/redaction.py +191 -0
- server_management_lib/responses.py +126 -0
- server_management_lib/security.py +1552 -0
- server_management_lib/ssh_manager.py +447 -0
- server_management_lib/transport.py +128 -0
- server_management_lib/tunnel.py +380 -0
- server_management_lib-0.2.0.dist-info/METADATA +46 -0
- server_management_lib-0.2.0.dist-info/RECORD +21 -0
- server_management_lib-0.2.0.dist-info/WHEEL +5 -0
- server_management_lib-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Server Management Library
|
|
3
|
+
|
|
4
|
+
Shared components for server management MCPs:
|
|
5
|
+
|
|
6
|
+
- Configuration loading (YAML + environment + XDG paths)
|
|
7
|
+
- Security validation (commands, queries, paths)
|
|
8
|
+
- SSH connection management (host key verification by default)
|
|
9
|
+
- InfluxDB and Prometheus HTTP clients
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .audit import AuditLogger, create_audit_logger
|
|
13
|
+
from .base import (
|
|
14
|
+
ExecutorBase,
|
|
15
|
+
ExecutorError,
|
|
16
|
+
RemoteDirEntry,
|
|
17
|
+
RemoteFileError,
|
|
18
|
+
RemotePathError,
|
|
19
|
+
)
|
|
20
|
+
from .config import (
|
|
21
|
+
DEFAULT_CONFIG,
|
|
22
|
+
ConfigError,
|
|
23
|
+
ConfigNotFoundError,
|
|
24
|
+
config_schema,
|
|
25
|
+
load_config,
|
|
26
|
+
redact_config_values,
|
|
27
|
+
resolve_config_path,
|
|
28
|
+
)
|
|
29
|
+
from .executor import Executor, create_executor
|
|
30
|
+
from .http_clients import InfluxDBClient, PrometheusClient, parse_duration
|
|
31
|
+
from .local_executor import LocalExecutor
|
|
32
|
+
from .operations import Operation
|
|
33
|
+
from .output import DEFAULT_MAX_OUTPUT_BYTES, sanitize_output, strip_ansi
|
|
34
|
+
from .rate_limit import (
|
|
35
|
+
RateLimitConfig,
|
|
36
|
+
RateLimiter,
|
|
37
|
+
create_rate_limiter,
|
|
38
|
+
)
|
|
39
|
+
from .redaction import REDACTED_VALUE, redact_env_content, redact_secrets
|
|
40
|
+
from .responses import (
|
|
41
|
+
ToolOutput,
|
|
42
|
+
ToolResponse,
|
|
43
|
+
build_response,
|
|
44
|
+
make_structured_tool,
|
|
45
|
+
)
|
|
46
|
+
from .security import (
|
|
47
|
+
SNAPRAID_STATUS_SUDO_ARGV,
|
|
48
|
+
FileAccess,
|
|
49
|
+
SecurityValidator,
|
|
50
|
+
ServiceFilePath,
|
|
51
|
+
)
|
|
52
|
+
from .ssh_manager import SSHConfigurationError, SSHManager
|
|
53
|
+
from .transport import (
|
|
54
|
+
BearerTokenMiddleware,
|
|
55
|
+
is_loopback_host,
|
|
56
|
+
run_streamable_http,
|
|
57
|
+
validate_http_binding,
|
|
58
|
+
)
|
|
59
|
+
from .tunnel import (
|
|
60
|
+
ActiveTunnel,
|
|
61
|
+
SSHTunnel,
|
|
62
|
+
SSHTunnelError,
|
|
63
|
+
TunnelConfig,
|
|
64
|
+
TunnelEndpoint,
|
|
65
|
+
close_metric_tunnels,
|
|
66
|
+
open_metric_tunnels,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"DEFAULT_CONFIG",
|
|
71
|
+
"DEFAULT_MAX_OUTPUT_BYTES",
|
|
72
|
+
"REDACTED_VALUE",
|
|
73
|
+
"SNAPRAID_STATUS_SUDO_ARGV",
|
|
74
|
+
"ActiveTunnel",
|
|
75
|
+
"AuditLogger",
|
|
76
|
+
"BearerTokenMiddleware",
|
|
77
|
+
"ConfigError",
|
|
78
|
+
"ConfigNotFoundError",
|
|
79
|
+
"Executor",
|
|
80
|
+
"ExecutorBase",
|
|
81
|
+
"ExecutorError",
|
|
82
|
+
"FileAccess",
|
|
83
|
+
"InfluxDBClient",
|
|
84
|
+
"LocalExecutor",
|
|
85
|
+
"Operation",
|
|
86
|
+
"PrometheusClient",
|
|
87
|
+
"RateLimitConfig",
|
|
88
|
+
"RateLimiter",
|
|
89
|
+
"RemoteDirEntry",
|
|
90
|
+
"RemoteFileError",
|
|
91
|
+
"RemotePathError",
|
|
92
|
+
"SSHConfigurationError",
|
|
93
|
+
"SSHManager",
|
|
94
|
+
"SSHTunnel",
|
|
95
|
+
"SSHTunnelError",
|
|
96
|
+
"SecurityValidator",
|
|
97
|
+
"ServiceFilePath",
|
|
98
|
+
"ToolOutput",
|
|
99
|
+
"ToolResponse",
|
|
100
|
+
"TunnelConfig",
|
|
101
|
+
"TunnelEndpoint",
|
|
102
|
+
"__version__",
|
|
103
|
+
"build_response",
|
|
104
|
+
"close_metric_tunnels",
|
|
105
|
+
"config_schema",
|
|
106
|
+
"create_audit_logger",
|
|
107
|
+
"create_executor",
|
|
108
|
+
"create_rate_limiter",
|
|
109
|
+
"is_loopback_host",
|
|
110
|
+
"load_config",
|
|
111
|
+
"make_structured_tool",
|
|
112
|
+
"open_metric_tunnels",
|
|
113
|
+
"parse_duration",
|
|
114
|
+
"redact_config_values",
|
|
115
|
+
"redact_env_content",
|
|
116
|
+
"redact_secrets",
|
|
117
|
+
"resolve_config_path",
|
|
118
|
+
"run_streamable_http",
|
|
119
|
+
"sanitize_output",
|
|
120
|
+
"strip_ansi",
|
|
121
|
+
"validate_http_binding",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Append-only audit log for privileged operations.
|
|
3
|
+
|
|
4
|
+
Every command, file read and blocked operation can be recorded as one JSON
|
|
5
|
+
object per line. Logging is best effort: an unwritable log must never break the
|
|
6
|
+
operation, but it is reported once.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
REDACTED = "***REDACTED***"
|
|
19
|
+
SENSITIVE_KEY_PARTS = ("password", "token", "secret", "api_key", "apikey", "key")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _redact(value: Any, key: str = "") -> Any:
|
|
23
|
+
if any(part in key.lower() for part in SENSITIVE_KEY_PARTS):
|
|
24
|
+
return REDACTED
|
|
25
|
+
if isinstance(value, dict):
|
|
26
|
+
return {k: _redact(v, str(k)) for k, v in value.items()}
|
|
27
|
+
if isinstance(value, list | tuple):
|
|
28
|
+
return [_redact(item) for item in value]
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuditLogger:
|
|
33
|
+
"""Write JSONL audit records to ``path`` when enabled."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: str | Path | None = None, *, enabled: bool = False):
|
|
36
|
+
self.enabled = bool(enabled and path)
|
|
37
|
+
self.path = Path(path).expanduser() if path else None
|
|
38
|
+
self._lock = threading.Lock()
|
|
39
|
+
self._warned = False
|
|
40
|
+
|
|
41
|
+
def log(self, event: str, **fields: Any) -> None:
|
|
42
|
+
if not self.enabled or self.path is None:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
record = {
|
|
46
|
+
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"),
|
|
47
|
+
"event": event,
|
|
48
|
+
**_redact(fields),
|
|
49
|
+
}
|
|
50
|
+
try:
|
|
51
|
+
parent = self.path.parent
|
|
52
|
+
parent_existed = parent.exists()
|
|
53
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
54
|
+
if not parent_existed:
|
|
55
|
+
# mkdir honours the umask; make the new audit directory private.
|
|
56
|
+
parent.chmod(0o700)
|
|
57
|
+
|
|
58
|
+
line = json.dumps(record, default=str)
|
|
59
|
+
with self._lock, self.path.open("a", encoding="utf-8") as handle:
|
|
60
|
+
# The file may have been created by an older release with the
|
|
61
|
+
# default mode; tighten it on every write.
|
|
62
|
+
self.path.chmod(0o600)
|
|
63
|
+
handle.write(line + "\n")
|
|
64
|
+
except OSError as e: # pragma: no cover - environment dependent
|
|
65
|
+
if not self._warned:
|
|
66
|
+
logger.warning("Cannot write audit log %s: %s", self.path, e)
|
|
67
|
+
self._warned = True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_audit_logger(config: dict) -> AuditLogger:
|
|
71
|
+
"""Build an :class:`AuditLogger` from the ``audit`` config section.
|
|
72
|
+
|
|
73
|
+
The HTTP clients receive the same logger as the executors so query
|
|
74
|
+
metadata is recorded in one audit log.
|
|
75
|
+
"""
|
|
76
|
+
audit_config = config.get("audit", {}) or {}
|
|
77
|
+
return AuditLogger(
|
|
78
|
+
audit_config.get("path"),
|
|
79
|
+
enabled=audit_config.get("enabled", False),
|
|
80
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared executor primitives.
|
|
3
|
+
|
|
4
|
+
An executor runs validated commands and reads remote/local files. SSH and local
|
|
5
|
+
implementations share output limits, audit logging and path-confinement rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .audit import create_audit_logger
|
|
11
|
+
from .output import DEFAULT_MAX_OUTPUT_BYTES
|
|
12
|
+
from .security import SecurityValidator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExecutorError(RuntimeError):
|
|
16
|
+
"""Base class for executor errors."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RemotePathError(ExecutorError):
|
|
20
|
+
"""Raised when a path escapes its allowed base directory."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemoteFileError(ExecutorError):
|
|
24
|
+
"""Raised when a file cannot be read safely."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class RemoteDirEntry:
|
|
29
|
+
"""A single directory entry returned by an executor."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
longname: str
|
|
33
|
+
size: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_within(path: str, base: str) -> bool:
|
|
37
|
+
"""Return True when ``path`` is ``base`` or below it (component-aware)."""
|
|
38
|
+
base = base.rstrip("/")
|
|
39
|
+
return path == base or path.startswith(base + "/")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExecutorBase:
|
|
43
|
+
"""Configuration, output limits and audit logging shared by executors."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: dict, security: SecurityValidator):
|
|
46
|
+
self.config = config
|
|
47
|
+
self.security = security
|
|
48
|
+
self.services_base_path = security.services_base_path
|
|
49
|
+
|
|
50
|
+
output_config = config.get("output", {}) or {}
|
|
51
|
+
self.max_output_bytes = int(
|
|
52
|
+
output_config.get("max_bytes", DEFAULT_MAX_OUTPUT_BYTES)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self.audit = create_audit_logger(config)
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loader for Server Management Library.
|
|
3
|
+
|
|
4
|
+
Loads YAML configuration with sensible defaults for SSH, security,
|
|
5
|
+
InfluxDB, Prometheus, and host data source settings.
|
|
6
|
+
|
|
7
|
+
Resolution order (first match wins):
|
|
8
|
+
|
|
9
|
+
1. explicit ``config_path`` argument (``--config``)
|
|
10
|
+
2. the path in ``env_var`` (for example ``REMOTE_SERVER_MCP_CONFIG``)
|
|
11
|
+
3. ``$XDG_CONFIG_HOME/<app_name>/config.yaml`` (``~/.config`` by default)
|
|
12
|
+
4. ``./config.yaml`` (repo checkout / local development)
|
|
13
|
+
|
|
14
|
+
Values from the environment override the YAML file, so secrets can stay out of
|
|
15
|
+
the config file. A ``.env`` file next to the config file is loaded first (when
|
|
16
|
+
``python-dotenv`` is installed), which matches the ``.env.example`` files.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import copy
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
|
|
28
|
+
from .security import DEFAULT_HOST_UNITS
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
33
|
+
# "ssh" (default) or "local" (run on the host where the MCP server runs)
|
|
34
|
+
"executor": "ssh",
|
|
35
|
+
# Container runtime used by the remote-server MCP operation catalog.
|
|
36
|
+
"container_runtime": "docker",
|
|
37
|
+
# MCP transport. stdio is the default; streamable-http is opt-in.
|
|
38
|
+
"transport": {
|
|
39
|
+
"type": "stdio",
|
|
40
|
+
"host": "127.0.0.1",
|
|
41
|
+
"port": 8000,
|
|
42
|
+
"path": "/mcp",
|
|
43
|
+
# Required for a non-loopback bind. Prefer MCP_HTTP_TOKEN over YAML.
|
|
44
|
+
"token": None,
|
|
45
|
+
},
|
|
46
|
+
# Sliding-window limit for destructive/state-changing tool calls.
|
|
47
|
+
"rate_limit": {
|
|
48
|
+
"enabled": True,
|
|
49
|
+
"max_calls": 10,
|
|
50
|
+
"window_seconds": 60,
|
|
51
|
+
},
|
|
52
|
+
"ssh": {
|
|
53
|
+
"host": "localhost",
|
|
54
|
+
"port": 22,
|
|
55
|
+
# None means: use the current user / ~/.ssh/config / ssh-agent.
|
|
56
|
+
"username": None,
|
|
57
|
+
"password": None,
|
|
58
|
+
"key_path": None,
|
|
59
|
+
# Path to known_hosts, or "none" to disable verification (insecure).
|
|
60
|
+
"known_hosts": "~/.ssh/known_hosts",
|
|
61
|
+
"connect_timeout": 10,
|
|
62
|
+
},
|
|
63
|
+
"security": {
|
|
64
|
+
# Service directory base path (for remote-server-mcp)
|
|
65
|
+
"services_path": "/srv",
|
|
66
|
+
# NEVER allow generic command execution
|
|
67
|
+
"allow_generic_commands": False,
|
|
68
|
+
# Container names that may be managed without a matching service
|
|
69
|
+
# directory. Containers that belong to a compose project below
|
|
70
|
+
# services_path do not need to be listed here.
|
|
71
|
+
"allowed_containers": [],
|
|
72
|
+
# Host systemd units that get_host_service_status/logs may inspect.
|
|
73
|
+
"host_units": list(DEFAULT_HOST_UNITS),
|
|
74
|
+
},
|
|
75
|
+
"host": {
|
|
76
|
+
"enabled": False,
|
|
77
|
+
},
|
|
78
|
+
"health": {
|
|
79
|
+
# Absolute mount points reported by remote-server-mcp's
|
|
80
|
+
# get_server_health. NAS pools are separate mounts, so list each one.
|
|
81
|
+
"disk_paths": ["/"],
|
|
82
|
+
},
|
|
83
|
+
"influxdb": {
|
|
84
|
+
"enabled": False,
|
|
85
|
+
"host": "localhost",
|
|
86
|
+
"port": 8181,
|
|
87
|
+
"use_https": False,
|
|
88
|
+
"database": None,
|
|
89
|
+
"token": None,
|
|
90
|
+
"query_limit": 1000,
|
|
91
|
+
# Optional local port forward to a loopback-only InfluxDB. When
|
|
92
|
+
# enabled, host/port are rewritten to the tunnel endpoint at startup.
|
|
93
|
+
"ssh_tunnel": {
|
|
94
|
+
"enabled": False,
|
|
95
|
+
"remote_host": "127.0.0.1",
|
|
96
|
+
"remote_port": None,
|
|
97
|
+
"local_host": "127.0.0.1",
|
|
98
|
+
"local_port": 0,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
"prometheus": {
|
|
102
|
+
"enabled": False,
|
|
103
|
+
"host": "localhost",
|
|
104
|
+
"port": 9090,
|
|
105
|
+
"use_https": False,
|
|
106
|
+
"token": None,
|
|
107
|
+
"query_timeout": "30s",
|
|
108
|
+
"ssh_tunnel": {
|
|
109
|
+
"enabled": False,
|
|
110
|
+
"remote_host": "127.0.0.1",
|
|
111
|
+
"remote_port": None,
|
|
112
|
+
"local_host": "127.0.0.1",
|
|
113
|
+
"local_port": 0,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
"output": {
|
|
117
|
+
# Maximum bytes returned to the model per command/file/query.
|
|
118
|
+
"max_bytes": 64000,
|
|
119
|
+
},
|
|
120
|
+
"audit": {
|
|
121
|
+
"enabled": False,
|
|
122
|
+
"path": "~/.local/state/server-management/audit.jsonl",
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Environment variable -> (section, key, converter). ``section`` is ``None``
|
|
127
|
+
# for top-level scalar settings such as ``executor`` and ``container_runtime``.
|
|
128
|
+
ENV_OVERRIDES: dict[str, tuple[str | None, str, type]] = {
|
|
129
|
+
"EXECUTOR": (None, "executor", str),
|
|
130
|
+
"CONTAINER_RUNTIME": (None, "container_runtime", str),
|
|
131
|
+
"SSH_HOST": ("ssh", "host", str),
|
|
132
|
+
"SSH_PORT": ("ssh", "port", int),
|
|
133
|
+
"SSH_USERNAME": ("ssh", "username", str),
|
|
134
|
+
"SSH_PASSWORD": ("ssh", "password", str),
|
|
135
|
+
"SSH_KEY_PATH": ("ssh", "key_path", str),
|
|
136
|
+
"SSH_KNOWN_HOSTS": ("ssh", "known_hosts", str),
|
|
137
|
+
"INFLUXDB_HOST": ("influxdb", "host", str),
|
|
138
|
+
"INFLUXDB_PORT": ("influxdb", "port", int),
|
|
139
|
+
"INFLUXDB_USE_HTTPS": ("influxdb", "use_https", bool),
|
|
140
|
+
"INFLUXDB_DATABASE": ("influxdb", "database", str),
|
|
141
|
+
"INFLUXDB_TOKEN": ("influxdb", "token", str),
|
|
142
|
+
"PROMETHEUS_HOST": ("prometheus", "host", str),
|
|
143
|
+
"PROMETHEUS_PORT": ("prometheus", "port", int),
|
|
144
|
+
"PROMETHEUS_USE_HTTPS": ("prometheus", "use_https", bool),
|
|
145
|
+
"PROMETHEUS_TOKEN": ("prometheus", "token", str),
|
|
146
|
+
"MCP_TRANSPORT": ("transport", "type", str),
|
|
147
|
+
"MCP_HTTP_HOST": ("transport", "host", str),
|
|
148
|
+
"MCP_HTTP_PORT": ("transport", "port", int),
|
|
149
|
+
"MCP_HTTP_PATH": ("transport", "path", str),
|
|
150
|
+
"MCP_HTTP_TOKEN": ("transport", "token", str),
|
|
151
|
+
"OUTPUT_MAX_BYTES": ("output", "max_bytes", int),
|
|
152
|
+
"AUDIT_ENABLED": ("audit", "enabled", bool),
|
|
153
|
+
"AUDIT_LOG_PATH": ("audit", "path", str),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ConfigError(Exception):
|
|
158
|
+
"""Base class for configuration problems."""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ConfigNotFoundError(ConfigError, FileNotFoundError):
|
|
162
|
+
"""No configuration file could be located."""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _as_bool(value: str) -> bool:
|
|
166
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _convert(value: str, converter: type) -> Any:
|
|
170
|
+
if converter is bool:
|
|
171
|
+
return _as_bool(value)
|
|
172
|
+
return converter(value)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict:
|
|
176
|
+
"""Merge ``override`` on top of ``base`` without mutating either."""
|
|
177
|
+
result: dict[str, Any] = copy.deepcopy(dict(base))
|
|
178
|
+
for key, value in override.items():
|
|
179
|
+
current = result.get(key)
|
|
180
|
+
if value is None and isinstance(current, dict):
|
|
181
|
+
# An empty YAML section (e.g. ``influxdb:``) keeps the defaults.
|
|
182
|
+
continue
|
|
183
|
+
if isinstance(value, Mapping) and isinstance(current, Mapping):
|
|
184
|
+
result[key] = _deep_merge(current, value)
|
|
185
|
+
else:
|
|
186
|
+
result[key] = copy.deepcopy(value)
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def resolve_config_path(
|
|
191
|
+
explicit: Path | str | None = None,
|
|
192
|
+
*,
|
|
193
|
+
env_var: str | None = None,
|
|
194
|
+
app_name: str | None = None,
|
|
195
|
+
) -> Path | None:
|
|
196
|
+
"""Return the configuration file path, or ``None`` if none was found.
|
|
197
|
+
|
|
198
|
+
An explicit path is returned even if the file does not exist; the caller
|
|
199
|
+
decides whether that is an error.
|
|
200
|
+
"""
|
|
201
|
+
if explicit is not None:
|
|
202
|
+
return Path(explicit).expanduser()
|
|
203
|
+
|
|
204
|
+
if env_var:
|
|
205
|
+
value = os.environ.get(env_var)
|
|
206
|
+
if value:
|
|
207
|
+
return Path(value).expanduser()
|
|
208
|
+
|
|
209
|
+
if app_name:
|
|
210
|
+
xdg_home = os.environ.get("XDG_CONFIG_HOME")
|
|
211
|
+
base = Path(xdg_home).expanduser() if xdg_home else Path.home() / ".config"
|
|
212
|
+
candidate = base / app_name / "config.yaml"
|
|
213
|
+
if candidate.exists():
|
|
214
|
+
return candidate
|
|
215
|
+
|
|
216
|
+
cwd_candidate = Path.cwd() / "config.yaml"
|
|
217
|
+
if cwd_candidate.exists():
|
|
218
|
+
return cwd_candidate
|
|
219
|
+
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _apply_env_overrides(
|
|
224
|
+
config: dict[str, Any],
|
|
225
|
+
env: Mapping[str, str],
|
|
226
|
+
) -> None:
|
|
227
|
+
for env_name, (section, key, converter) in ENV_OVERRIDES.items():
|
|
228
|
+
value = env.get(env_name)
|
|
229
|
+
if value is None or value == "":
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
converted = _convert(value, converter)
|
|
233
|
+
if section is None:
|
|
234
|
+
config[key] = converted
|
|
235
|
+
continue
|
|
236
|
+
section_config = config.get(section)
|
|
237
|
+
if not isinstance(section_config, dict):
|
|
238
|
+
section_config = {}
|
|
239
|
+
config[section] = section_config
|
|
240
|
+
section_config[key] = converted
|
|
241
|
+
except (TypeError, ValueError):
|
|
242
|
+
logger.warning("Ignoring invalid value for %s", env_name)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _load_dotenv(config_file: Path) -> None:
|
|
246
|
+
"""Load ``.env`` next to the config file (best effort)."""
|
|
247
|
+
try:
|
|
248
|
+
from dotenv import load_dotenv
|
|
249
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
env_file = config_file.parent / ".env"
|
|
253
|
+
if env_file.exists():
|
|
254
|
+
load_dotenv(env_file)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def load_config(
|
|
258
|
+
config_path: Path | str | None = None,
|
|
259
|
+
*,
|
|
260
|
+
env_var: str | None = None,
|
|
261
|
+
app_name: str | None = None,
|
|
262
|
+
required: bool = False,
|
|
263
|
+
env: Mapping[str, str] | None = None,
|
|
264
|
+
use_dotenv: bool = True,
|
|
265
|
+
log_missing: bool = True,
|
|
266
|
+
) -> dict:
|
|
267
|
+
"""Load configuration from YAML, merged over :data:`DEFAULT_CONFIG`.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
config_path: Explicit path; when ``None`` the usual locations are probed.
|
|
271
|
+
env_var: Environment variable holding a config path.
|
|
272
|
+
app_name: Application name used for the XDG location.
|
|
273
|
+
required: Raise :class:`ConfigNotFoundError` instead of using defaults.
|
|
274
|
+
env: Environment mapping for overrides (defaults to ``os.environ``).
|
|
275
|
+
use_dotenv: Load a ``.env`` file next to the resolved config file.
|
|
276
|
+
log_missing: Log a warning when no config file is found. Disable this
|
|
277
|
+
for the best-effort import-time load in servers.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ConfigNotFoundError: No config found (or explicit path missing) and
|
|
281
|
+
``required`` is true.
|
|
282
|
+
ConfigError: The config file exists but cannot be parsed, and
|
|
283
|
+
``required`` is true.
|
|
284
|
+
"""
|
|
285
|
+
resolved = resolve_config_path(config_path, env_var=env_var, app_name=app_name)
|
|
286
|
+
|
|
287
|
+
searched = _describe_search_paths(config_path, env_var, app_name)
|
|
288
|
+
environment = os.environ if env is None else env
|
|
289
|
+
|
|
290
|
+
def defaults_with_overrides() -> dict:
|
|
291
|
+
config = _deep_merge(DEFAULT_CONFIG, {})
|
|
292
|
+
_apply_env_overrides(config, environment)
|
|
293
|
+
return config
|
|
294
|
+
|
|
295
|
+
if resolved is None or not resolved.exists():
|
|
296
|
+
if required:
|
|
297
|
+
raise ConfigNotFoundError(
|
|
298
|
+
"No configuration file found. Searched: "
|
|
299
|
+
f"{searched}. Create one from config.example.yaml or pass "
|
|
300
|
+
"--config / set the config environment variable."
|
|
301
|
+
)
|
|
302
|
+
if log_missing:
|
|
303
|
+
logger.warning(
|
|
304
|
+
"No configuration file found (searched: %s); using defaults",
|
|
305
|
+
searched,
|
|
306
|
+
)
|
|
307
|
+
return defaults_with_overrides()
|
|
308
|
+
|
|
309
|
+
if use_dotenv and env is None:
|
|
310
|
+
_load_dotenv(resolved)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
with open(resolved) as f:
|
|
314
|
+
loaded = yaml.safe_load(f)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
if required:
|
|
317
|
+
raise ConfigError(f"Error loading config from {resolved}: {e}") from e
|
|
318
|
+
logger.error("Error loading config from %s: %s", resolved, e)
|
|
319
|
+
logger.warning("Using default configuration")
|
|
320
|
+
return defaults_with_overrides()
|
|
321
|
+
|
|
322
|
+
if loaded is None:
|
|
323
|
+
loaded = {}
|
|
324
|
+
if not isinstance(loaded, Mapping):
|
|
325
|
+
message = f"Configuration in {resolved} must be a YAML mapping"
|
|
326
|
+
if required:
|
|
327
|
+
raise ConfigError(message)
|
|
328
|
+
logger.error(message)
|
|
329
|
+
return defaults_with_overrides()
|
|
330
|
+
|
|
331
|
+
config = _deep_merge(DEFAULT_CONFIG, loaded)
|
|
332
|
+
_apply_env_overrides(config, environment)
|
|
333
|
+
logger.info("Loaded configuration from %s", resolved)
|
|
334
|
+
return config
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# Values that must never be published by the config-schema MCP resource.
|
|
338
|
+
SECRET_CONFIG_KEYS = frozenset({"token", "password"})
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def redact_config_values(value: Any) -> Any:
|
|
342
|
+
"""Return a copy of a config mapping with credential-like values masked."""
|
|
343
|
+
if isinstance(value, Mapping):
|
|
344
|
+
return {
|
|
345
|
+
key: (
|
|
346
|
+
"***"
|
|
347
|
+
if str(key).lower() in SECRET_CONFIG_KEYS
|
|
348
|
+
else redact_config_values(item)
|
|
349
|
+
)
|
|
350
|
+
for key, item in value.items()
|
|
351
|
+
}
|
|
352
|
+
if isinstance(value, list):
|
|
353
|
+
return [redact_config_values(item) for item in value]
|
|
354
|
+
return value
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def config_schema() -> dict[str, Any]:
|
|
358
|
+
"""Describe the configuration defaults and environment overrides.
|
|
359
|
+
|
|
360
|
+
Used by the MCP config resource. Credential-like values are masked, and
|
|
361
|
+
environment variable names are listed without their values.
|
|
362
|
+
"""
|
|
363
|
+
return {
|
|
364
|
+
"defaults": redact_config_values(DEFAULT_CONFIG),
|
|
365
|
+
"environment_overrides": {
|
|
366
|
+
name: {"section": key if section is None else section, "key": key}
|
|
367
|
+
for name, (section, key, _converter) in sorted(ENV_OVERRIDES.items())
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _describe_search_paths(
|
|
373
|
+
config_path: Path | str | None,
|
|
374
|
+
env_var: str | None,
|
|
375
|
+
app_name: str | None,
|
|
376
|
+
) -> str:
|
|
377
|
+
if config_path is not None:
|
|
378
|
+
return str(Path(config_path).expanduser())
|
|
379
|
+
parts = []
|
|
380
|
+
if env_var:
|
|
381
|
+
parts.append(f"${env_var}")
|
|
382
|
+
if app_name:
|
|
383
|
+
parts.append(f"$XDG_CONFIG_HOME/{app_name}/config.yaml (~/.config fallback)")
|
|
384
|
+
parts.append("./config.yaml")
|
|
385
|
+
return ", ".join(parts)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Executor factory.
|
|
3
|
+
|
|
4
|
+
Selects the SSH or local implementation from ``config["executor"]``. Both
|
|
5
|
+
implement the same command/file interface, so MCP tools do not need to know
|
|
6
|
+
where they run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from .base import RemoteDirEntry
|
|
12
|
+
from .local_executor import LocalExecutor
|
|
13
|
+
from .security import SecurityValidator
|
|
14
|
+
from .ssh_manager import SSHManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Executor(Protocol):
|
|
18
|
+
"""Interface shared by :class:`SSHManager` and :class:`LocalExecutor`."""
|
|
19
|
+
|
|
20
|
+
services_base_path: str
|
|
21
|
+
max_output_bytes: int
|
|
22
|
+
|
|
23
|
+
async def connect(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
async def disconnect(self) -> None: ...
|
|
26
|
+
|
|
27
|
+
async def execute_safe_command(self, command: str, timeout: int = 30) -> str: ...
|
|
28
|
+
|
|
29
|
+
async def check_service_exists(self, service: str) -> bool: ...
|
|
30
|
+
|
|
31
|
+
async def read_remote_file(
|
|
32
|
+
self,
|
|
33
|
+
path: str,
|
|
34
|
+
*,
|
|
35
|
+
allowed_base: str | None = None,
|
|
36
|
+
max_bytes: int | None = None,
|
|
37
|
+
) -> str: ...
|
|
38
|
+
|
|
39
|
+
async def list_remote_dir(
|
|
40
|
+
self, path: str, *, allowed_base: str | None = None
|
|
41
|
+
) -> list[RemoteDirEntry]: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_executor(
|
|
45
|
+
config: dict, security: SecurityValidator
|
|
46
|
+
) -> SSHManager | LocalExecutor:
|
|
47
|
+
"""Create the executor selected by ``config["executor"]`` (default: ssh)."""
|
|
48
|
+
mode = str(config.get("executor", "ssh")).strip().lower()
|
|
49
|
+
if mode == "local":
|
|
50
|
+
return LocalExecutor(config, security)
|
|
51
|
+
if mode == "ssh":
|
|
52
|
+
return SSHManager(config, security)
|
|
53
|
+
raise ValueError(f"Unknown executor {mode!r}; expected 'ssh' or 'local'")
|