ellmos-servercommander-mcp 0.1.0-alpha.1

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.
package/KONZEPT.md ADDED
@@ -0,0 +1,91 @@
1
+ # ellmos-servercommander-mcp — Konzept
2
+
3
+ > Server-Operations als MCP: Deploy, Mail, Logs, Health-Checks.
4
+ > Aus .UMBRUCH-Tools extrahiert und parametrisiert.
5
+
6
+ ## Tools
7
+
8
+ | Tool | Quelle | Beschreibung |
9
+ |---|---|---|
10
+ | `sc_deploy` | .UMBRUCH deploy.py (~221 LoC) | SFTP-Delta-Deploy mit SHA256 (parametrisiert: host, user, remote_path) |
11
+ | `sc_deploy_status` | .UMBRUCH deploy.py | Letztes Deployment prüfen (Zeitstempel, geänderte Dateien, Dauer) |
12
+ | `sc_mail_list` | .UMBRUCH cli.py (~516 LoC) | IMAP-Postfach auflisten (Ordner, ungelesen, letzte N) |
13
+ | `sc_mail_read` | .UMBRUCH cli.py | E-Mail lesen (nach ID oder Suchfilter) |
14
+ | `sc_mail_send` | .UMBRUCH cli.py | E-Mail senden (SMTP, mit Anhängen) |
15
+ | `sc_mail_search` | .UMBRUCH cli.py | E-Mails durchsuchen (FTS, Datum, Absender) |
16
+ | `sc_logs_analyze` | .UMBRUCH traffic_analyzer.py | Server-Logs parsen (Apache/Nginx), Bot-Filterung, Traffic-Stats |
17
+ | `sc_health_check` | neu | HTTP-Endpoint(s) prüfen, Status-Codes + Latenz melden |
18
+
19
+ ## Vorarbeiten
20
+
21
+ ### De-Hardcoding (deploy.py)
22
+ - Aktuell hardcoded: projektspezifischer Host, SFTP-Account und feste Pfade
23
+ - Ziel: host/user/remote_path/local_path als Tool-Argumente ODER Config-Profile
24
+ - Config-Profile für wiederkehrende Deploys: `[profiles.umbruch]`, `[profiles.other]`
25
+
26
+ ### Mail-Kern extrahieren (cli.py)
27
+ - GUI-Code (gui.py, 2935 LoC) bleibt draußen
28
+ - CLI-Kern (~516 LoC) als Basis: IMAP-Verbindung, SMTP-Versand, FTS5-Cache
29
+ - Credential-Handling: Config-Datei mit verschlüsseltem Passwort oder Env-Variable
30
+
31
+ ### Kein Hardcoding von Credentials
32
+ - Alle Secrets via Config oder Env-Variablen
33
+ - Referenz: `$SFTP_PASSWORD`, `$IMAP_PASSWORD` etc.
34
+
35
+ ## Architektur
36
+
37
+ ```
38
+ ellmos-servercommander-mcp/
39
+ ├── src/servercommander/
40
+ │ ├── server.py # MCP-Server Entry Point
41
+ │ ├── config.py # Konfiguration
42
+ │ ├── deploy.py # sc_deploy, sc_deploy_status
43
+ │ ├── mail.py # sc_mail_list/read/send/search
44
+ │ ├── logs.py # sc_logs_analyze
45
+ │ └── health.py # sc_health_check
46
+ ├── config/
47
+ │ └── servercommander.example.toml
48
+ ├── tests/
49
+ └── pyproject.toml
50
+ ```
51
+
52
+ ## Konfigurationsbeispiel
53
+
54
+ ```toml
55
+ [server]
56
+ name = "ellmos-servercommander"
57
+
58
+ [deploy.profiles.example_site]
59
+ host = "sftp.example.com"
60
+ user = "deploy-user"
61
+ remote_path = "/var/www/example"
62
+ local_path = "./dist"
63
+ protocol = "sftp"
64
+
65
+ [mail]
66
+ imap_host = "imap.example.com"
67
+ imap_port = 993
68
+ smtp_host = "smtp.example.com"
69
+ smtp_port = 587
70
+ username = "$MAIL_USER"
71
+ password = "$MAIL_PASSWORD"
72
+ cache_db = "~/.servercommander/mail_cache.db"
73
+
74
+ [logs]
75
+ default_format = "apache"
76
+
77
+ [health]
78
+ timeout = 5
79
+ endpoints = [
80
+ "https://example.com/health",
81
+ ]
82
+ ```
83
+
84
+ ## Quellmodule
85
+
86
+ | Modul | Quellpfad | LoC | Dependencies |
87
+ |---|---|---|---|
88
+ | deploy.py | `.TOPICS/.UMBRUCH/deploy.py` | ~221 | paramiko (SFTP) |
89
+ | cli.py (Mail) | `.TOPICS/.UMBRUCH/cli.py` | ~516 | stdlib (imaplib, smtplib) |
90
+ | traffic_analyzer.py | `.TOPICS/.UMBRUCH/traffic_analyzer.py` | ~300 | stdlib |
91
+ | health.py | neu | ~50 | stdlib (urllib) |
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # ellmos-servercommander-mcp
2
+
3
+ Alpha-MCP-Server für Server-Operationen: Deployment-Planung, Mail-Status, Log-Analyse und HTTP-Health-Checks.
4
+
5
+ ## Status
6
+
7
+ - Transport: stdio über das Python-MCP-SDK
8
+ - Paketstatus: Alpha-Paket, GitHub-Repo unter `ellmos-ai` vorgesehen
9
+ - Aktiver Kern: MCP-Tool-Liste, MCP-Tool-Dispatch, Config-Lader, `sc_logs_analyze`, `sc_health_check`
10
+ - Sichere Alpha-Handler: `sc_deploy` erstellt lokale SHA256-Manifeste im Dry-run, `sc_mail_*` führt noch keine IMAP/SMTP-Aktionen aus
11
+
12
+ ## Installation für lokale Tests
13
+
14
+ ```powershell
15
+ cd "C:\Users\User\OneDrive\.TOPICS\.AI\.MCP\ellmos-servercommander-mcp"
16
+ $env:PYTHONIOENCODING = "utf-8"
17
+ python -m pip install -e ".[dev]"
18
+ python -m pytest -q
19
+ ```
20
+
21
+ Keine `.venv` im OneDrive-Ordner anlegen. Falls eine isolierte Umgebung gebraucht wird, außerhalb von OneDrive erstellen.
22
+
23
+ ## npm Alpha
24
+
25
+ Das npm-Paket enthält einen Node-Wrapper, der den Python-Server startet. Voraussetzung bleibt Python 3.10+ mit installiertem Python-Paket `mcp>=1.0.0`.
26
+
27
+ ```powershell
28
+ npm install -g ellmos-servercommander-mcp@alpha
29
+ ellmos-servercommander
30
+ ```
31
+
32
+ ## Start
33
+
34
+ ```powershell
35
+ ellmos-servercommander
36
+ ```
37
+
38
+ Oder direkt aus dem Quellbaum:
39
+
40
+ ```powershell
41
+ $env:PYTHONPATH = "src"
42
+ python -m servercommander.server
43
+ ```
44
+
45
+ ## Konfiguration
46
+
47
+ Beispiel: [config/servercommander.example.toml](config/servercommander.example.toml)
48
+
49
+ Standardpfade:
50
+
51
+ - `%USERPROFILE%\.servercommander\config.toml`
52
+ - `%USERPROFILE%\.config\servercommander\config.toml`
53
+ - Override per `SERVERCOMMANDER_CONFIG`
54
+
55
+ Secrets sollen als Umgebungsvariablen referenziert werden, zum Beispiel `$MAIL_PASSWORD` oder `$SFTP_PASSWORD`.
56
+
57
+ ## Tools
58
+
59
+ - `sc_health_check`: prüft HTTP-Endpunkte und meldet Status-Code plus Latenz
60
+ - `sc_logs_analyze`: analysiert Apache-/Nginx-Access-Logs aus Text oder Datei
61
+ - `sc_deploy`: erstellt im Alpha-Modus einen Deployment-Plan mit lokalem SHA256-Manifest, führt aber noch keinen Upload aus
62
+ - `sc_deploy_status`: zeigt konfigurierte Deploy-Profile und den noch fehlenden History-Status
63
+ - `sc_mail_list`, `sc_mail_read`, `sc_mail_send`, `sc_mail_search`: liefern aktuell sichere Alpha-Statusantworten ohne IMAP/SMTP-Verbindung
64
+
65
+ ## Entwicklung
66
+
67
+ ```powershell
68
+ $env:PYTHONIOENCODING = "utf-8"
69
+ python -m pytest -q
70
+ ```
71
+
72
+ Der nächste sinnvolle Schritt ist die Extraktion der echten SFTP-, IMAP/SMTP- und erweiterten Traffic-Module aus `.UMBRUCH` in credential-freie Adapter mit lokalen Tests.
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require("node:child_process");
4
+ const path = require("node:path");
5
+ const pkg = require("../package.json");
6
+
7
+ if (process.argv.includes("--version")) {
8
+ console.log(pkg.version);
9
+ process.exit(0);
10
+ }
11
+
12
+ const python = process.env.PYTHON || (process.platform === "win32" ? "python" : "python3");
13
+ const srcPath = path.resolve(__dirname, "..", "src");
14
+ const env = {
15
+ ...process.env,
16
+ PYTHONPATH: process.env.PYTHONPATH ? `${srcPath}${path.delimiter}${process.env.PYTHONPATH}` : srcPath,
17
+ };
18
+
19
+ const child = spawn(python, ["-m", "servercommander.server"], {
20
+ stdio: "inherit",
21
+ env,
22
+ });
23
+
24
+ child.on("error", (error) => {
25
+ console.error(`Failed to start Python runtime '${python}': ${error.message}`);
26
+ console.error("Install Python 3.10+ and the Python dependency 'mcp>=1.0.0'.");
27
+ process.exit(1);
28
+ });
29
+
30
+ child.on("exit", (code, signal) => {
31
+ if (signal) {
32
+ process.kill(process.pid, signal);
33
+ return;
34
+ }
35
+ process.exit(code ?? 0);
36
+ });
@@ -0,0 +1,38 @@
1
+ # ellmos-servercommander-mcp — Konfiguration
2
+ # Kopieren nach ~/.servercommander/config.toml und anpassen.
3
+
4
+ [server]
5
+ name = "ellmos-servercommander"
6
+
7
+ # --- Deploy-Profile ---
8
+ [deploy.profiles.example_site]
9
+ host = "sftp.example.com"
10
+ user = "deploy-user"
11
+ remote_path = "/var/www/example"
12
+ local_path = "./dist"
13
+ protocol = "sftp"
14
+ # password = "$SFTP_PASSWORD" # Env-Variable referenzieren
15
+
16
+ # [deploy.profiles.other]
17
+ # host = "..."
18
+
19
+ # --- Mail ---
20
+ [mail]
21
+ imap_host = "imap.example.com"
22
+ imap_port = 993
23
+ smtp_host = "smtp.example.com"
24
+ smtp_port = 587
25
+ username = "$MAIL_USER"
26
+ password = "$MAIL_PASSWORD"
27
+ cache_db = "~/.servercommander/mail_cache.db"
28
+
29
+ # --- Log-Analyse ---
30
+ [logs]
31
+ default_format = "apache" # apache | nginx
32
+
33
+ # --- Health-Checks ---
34
+ [health]
35
+ timeout = 5
36
+ endpoints = [
37
+ "https://example.com/health",
38
+ ]
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "ellmos-servercommander-mcp",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Alpha MCP server for server operations: deploy dry-runs, mail status, log analysis, and health checks.",
5
+ "type": "commonjs",
6
+ "license": "MIT",
7
+ "author": "Lukas Geiger",
8
+ "homepage": "https://github.com/ellmos-ai/ellmos-servercommander-mcp#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ellmos-ai/ellmos-servercommander-mcp.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/ellmos-ai/ellmos-servercommander-mcp/issues"
15
+ },
16
+ "bin": {
17
+ "ellmos-servercommander": "bin/ellmos-servercommander.js"
18
+ },
19
+ "files": [
20
+ "bin/",
21
+ "config/",
22
+ "src/",
23
+ "README.md",
24
+ "KONZEPT.md",
25
+ "pyproject.toml"
26
+ ],
27
+ "keywords": [
28
+ "mcp",
29
+ "server",
30
+ "deploy",
31
+ "logs",
32
+ "health-checks",
33
+ "ellmos"
34
+ ],
35
+ "scripts": {
36
+ "test": "python -m pytest -q",
37
+ "smoke": "node bin/ellmos-servercommander.js --version"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "tag": "alpha"
45
+ }
46
+ }
package/pyproject.toml ADDED
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ellmos-servercommander-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for server operations: deploy, mail, log analysis, health checks."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Lukas Geiger" }]
13
+ keywords = ["mcp", "deploy", "server-management", "ellmos"]
14
+ dependencies = [
15
+ "mcp>=1.0.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ sftp = ["paramiko>=3.0"]
20
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
21
+
22
+ [project.scripts]
23
+ ellmos-servercommander = "servercommander.server:main"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/servercommander"]
27
+
28
+ [tool.pytest.ini_options]
29
+ pythonpath = ["src"]
@@ -0,0 +1,3 @@
1
+ """ellmos-servercommander-mcp — Server operations via MCP."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,71 @@
1
+ """Configuration loader for ServerCommander MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ try:
11
+ import tomllib
12
+ except ImportError:
13
+ import tomli as tomllib
14
+
15
+
16
+ DEFAULT_CONFIG_PATHS = [
17
+ Path.home() / ".servercommander" / "config.toml",
18
+ Path.home() / ".config" / "servercommander" / "config.toml",
19
+ ]
20
+
21
+
22
+ @dataclass
23
+ class ServerCommanderConfig:
24
+ server_name: str = "ellmos-servercommander"
25
+ deploy_profiles: dict[str, dict[str, Any]] = field(default_factory=dict)
26
+ mail: dict[str, Any] = field(default_factory=dict)
27
+ logs: dict[str, Any] = field(default_factory=dict)
28
+ health: dict[str, Any] = field(default_factory=dict)
29
+ raw: dict[str, Any] = field(default_factory=dict)
30
+
31
+ def deploy_profile(self, name: str) -> dict[str, Any]:
32
+ return self.deploy_profiles.get(name, {})
33
+
34
+
35
+ def load_config(path: str | Path | None = None) -> ServerCommanderConfig:
36
+ """Load TOML config. Missing config is valid for alpha-safe defaults."""
37
+ if path is None:
38
+ env_path = os.environ.get("SERVERCOMMANDER_CONFIG")
39
+ if env_path:
40
+ path = Path(env_path)
41
+ else:
42
+ for candidate in DEFAULT_CONFIG_PATHS:
43
+ if candidate.exists():
44
+ path = candidate
45
+ break
46
+
47
+ if path is None or not Path(path).exists():
48
+ return ServerCommanderConfig()
49
+
50
+ with open(path, "rb") as f:
51
+ raw = tomllib.load(f)
52
+
53
+ deploy = raw.get("deploy", {})
54
+ profiles = deploy.get("profiles", {}) if isinstance(deploy, dict) else {}
55
+ server = raw.get("server", {})
56
+
57
+ return ServerCommanderConfig(
58
+ server_name=server.get("name", "ellmos-servercommander"),
59
+ deploy_profiles=profiles if isinstance(profiles, dict) else {},
60
+ mail=raw.get("mail", {}),
61
+ logs=raw.get("logs", {}),
62
+ health=raw.get("health", {}),
63
+ raw=raw,
64
+ )
65
+
66
+
67
+ def resolve_env_value(value: Any) -> Any:
68
+ """Resolve config values of the form '$ENV_NAME' without exposing secrets."""
69
+ if isinstance(value, str) and value.startswith("$") and len(value) > 1:
70
+ return os.environ.get(value[1:], "")
71
+ return value
@@ -0,0 +1,100 @@
1
+ """Alpha deploy handlers for ServerCommander.
2
+
3
+ Real SFTP deployment is intentionally not executed in this alpha layer.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from servercommander.config import ServerCommanderConfig
13
+
14
+ SKIP_DIRS = {".git", "__pycache__", ".pytest_cache", ".ruff_cache", ".mypy_cache", ".venv", "venv"}
15
+
16
+
17
+ async def sc_deploy(
18
+ config: ServerCommanderConfig,
19
+ profile: str | None = None,
20
+ local_path: str | None = None,
21
+ remote_path: str | None = None,
22
+ dry_run: bool = True,
23
+ ) -> dict[str, Any]:
24
+ """Return a deploy plan. Non-dry-run execution is not implemented yet."""
25
+ if not dry_run:
26
+ raise ValueError("sc_deploy execution is not implemented in the alpha server; use dry_run=true")
27
+
28
+ profile_config = config.deploy_profile(profile or "") if profile else {}
29
+ plan = {
30
+ "profile": profile,
31
+ "host": profile_config.get("host"),
32
+ "user": profile_config.get("user"),
33
+ "local_path": local_path or profile_config.get("local_path"),
34
+ "remote_path": remote_path or profile_config.get("remote_path"),
35
+ "protocol": profile_config.get("protocol", "sftp"),
36
+ }
37
+ missing = [key for key in ("host", "user", "local_path", "remote_path") if not plan.get(key)]
38
+ manifest = _build_manifest(plan["local_path"]) if plan.get("local_path") else None
39
+
40
+ return {
41
+ "status": "dry_run",
42
+ "ready": not missing,
43
+ "missing": missing,
44
+ "plan": plan,
45
+ "manifest": manifest,
46
+ }
47
+
48
+
49
+ async def sc_deploy_status(
50
+ config: ServerCommanderConfig,
51
+ profile: str | None = None,
52
+ ) -> dict[str, Any]:
53
+ """Report configured deploy profile status for the alpha server."""
54
+ profiles = sorted(config.deploy_profiles)
55
+ selected = config.deploy_profile(profile or "") if profile else None
56
+ return {
57
+ "status": "not_recorded",
58
+ "message": "Deployment history storage is not implemented yet.",
59
+ "profiles": profiles,
60
+ "selected_profile": selected,
61
+ }
62
+
63
+
64
+ def _build_manifest(local_path: str) -> dict[str, Any]:
65
+ root = Path(local_path).expanduser()
66
+ if not root.exists():
67
+ return {"status": "missing_local_path", "path": str(root), "files": [], "total_bytes": 0}
68
+ if root.is_file():
69
+ return {"status": "ok", "path": str(root), "files": [_file_entry(root, root.parent)], "total_bytes": root.stat().st_size}
70
+
71
+ files = []
72
+ total_bytes = 0
73
+ for path in sorted(root.rglob("*")):
74
+ if not path.is_file():
75
+ continue
76
+ if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
77
+ continue
78
+ entry = _file_entry(path, root)
79
+ files.append(entry)
80
+ total_bytes += entry["size"]
81
+
82
+ return {
83
+ "status": "ok",
84
+ "path": str(root),
85
+ "file_count": len(files),
86
+ "total_bytes": total_bytes,
87
+ "files": files,
88
+ }
89
+
90
+
91
+ def _file_entry(path: Path, root: Path) -> dict[str, Any]:
92
+ digest = hashlib.sha256()
93
+ with path.open("rb") as handle:
94
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
95
+ digest.update(chunk)
96
+ return {
97
+ "path": path.relative_to(root).as_posix(),
98
+ "size": path.stat().st_size,
99
+ "sha256": digest.hexdigest(),
100
+ }
@@ -0,0 +1,65 @@
1
+ """HTTP health checks for ServerCommander."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.request import Request, urlopen
9
+
10
+ from servercommander.config import ServerCommanderConfig
11
+
12
+
13
+ async def sc_health_check(
14
+ config: ServerCommanderConfig,
15
+ endpoints: list[str] | None = None,
16
+ timeout: float | None = None,
17
+ ) -> dict[str, Any]:
18
+ """Check HTTP endpoints and report status codes plus latency."""
19
+ configured_endpoints = config.health.get("endpoints", [])
20
+ selected_endpoints = endpoints if endpoints is not None else configured_endpoints
21
+ check_timeout = float(timeout if timeout is not None else config.health.get("timeout", 5))
22
+
23
+ if not selected_endpoints:
24
+ return {
25
+ "status": "no_endpoints",
26
+ "ok": False,
27
+ "timeout": check_timeout,
28
+ "results": [],
29
+ }
30
+
31
+ results = [_check_one(endpoint, check_timeout) for endpoint in selected_endpoints]
32
+ return {
33
+ "status": "ok" if all(result["ok"] for result in results) else "degraded",
34
+ "ok": all(result["ok"] for result in results),
35
+ "timeout": check_timeout,
36
+ "results": results,
37
+ }
38
+
39
+
40
+ def _check_one(endpoint: str, timeout: float) -> dict[str, Any]:
41
+ started = time.perf_counter()
42
+ request = Request(endpoint, method="GET", headers={"User-Agent": "ellmos-servercommander/0.1"})
43
+
44
+ try:
45
+ with urlopen(request, timeout=timeout) as response:
46
+ status_code = int(response.status)
47
+ ok = 200 <= status_code < 400
48
+ error = None
49
+ except HTTPError as exc:
50
+ status_code = int(exc.code)
51
+ ok = False
52
+ error = str(exc)
53
+ except (URLError, TimeoutError, OSError) as exc:
54
+ status_code = None
55
+ ok = False
56
+ error = str(exc)
57
+
58
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 2)
59
+ return {
60
+ "endpoint": endpoint,
61
+ "ok": ok,
62
+ "status_code": status_code,
63
+ "latency_ms": elapsed_ms,
64
+ "error": error,
65
+ }
@@ -0,0 +1,78 @@
1
+ """Apache/Nginx access-log analysis for ServerCommander."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from servercommander.config import ServerCommanderConfig
11
+
12
+
13
+ LOG_PATTERN = re.compile(
14
+ r'(?P<host>\S+)\s+\S+\s+\S+\s+\[(?P<time>[^\]]+)\]\s+'
15
+ r'"(?P<method>[A-Z]+)\s+(?P<path>\S+)(?:\s+HTTP/[0-9.]+)?"\s+'
16
+ r'(?P<status>\d{3})\s+(?P<size>\S+)'
17
+ r'(?:\s+"(?P<referer>[^"]*)"\s+"(?P<agent>[^"]*)")?'
18
+ )
19
+
20
+ BOT_MARKERS = ("bot", "crawler", "spider", "slurp", "bingpreview")
21
+
22
+
23
+ async def sc_logs_analyze(
24
+ config: ServerCommanderConfig,
25
+ log_text: str | None = None,
26
+ log_path: str | None = None,
27
+ top_paths: int = 10,
28
+ format: str | None = None,
29
+ ) -> dict[str, Any]:
30
+ """Analyze access logs from inline text or a local file path."""
31
+ _ = config, format
32
+ text = _load_log_text(log_text, log_path)
33
+ lines = [line for line in text.splitlines() if line.strip()]
34
+
35
+ status_counts: Counter[str] = Counter()
36
+ status_classes: Counter[str] = Counter()
37
+ method_counts: Counter[str] = Counter()
38
+ path_counts: Counter[str] = Counter()
39
+ agent_counts: Counter[str] = Counter()
40
+ bot_requests = 0
41
+ parsed_lines = 0
42
+
43
+ for line in lines:
44
+ match = LOG_PATTERN.search(line)
45
+ if not match:
46
+ continue
47
+ parsed_lines += 1
48
+ status = match.group("status")
49
+ status_counts[status] += 1
50
+ status_classes[f"{status[0]}xx"] += 1
51
+ method_counts[match.group("method")] += 1
52
+ path_counts[match.group("path")] += 1
53
+ agent = match.group("agent") or ""
54
+ if agent:
55
+ agent_counts[agent] += 1
56
+ if any(marker in agent.lower() for marker in BOT_MARKERS):
57
+ bot_requests += 1
58
+
59
+ return {
60
+ "status": "ok",
61
+ "total_lines": len(lines),
62
+ "parsed_lines": parsed_lines,
63
+ "unparsed_lines": len(lines) - parsed_lines,
64
+ "status_counts": dict(status_counts),
65
+ "status_classes": dict(status_classes),
66
+ "method_counts": dict(method_counts),
67
+ "top_paths": path_counts.most_common(int(top_paths)),
68
+ "top_agents": agent_counts.most_common(10),
69
+ "bot_requests": bot_requests,
70
+ }
71
+
72
+
73
+ def _load_log_text(log_text: str | None, log_path: str | None) -> str:
74
+ if log_text is not None:
75
+ return log_text
76
+ if log_path is not None:
77
+ return Path(log_path).expanduser().read_text(encoding="utf-8", errors="replace")
78
+ raise ValueError("Provide either log_text or log_path")
@@ -0,0 +1,50 @@
1
+ """Alpha mail handlers for ServerCommander.
2
+
3
+ These handlers expose safe configuration/status checks. Real IMAP/SMTP
4
+ operations will be added after credential handling is finalized.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from servercommander.config import ServerCommanderConfig, resolve_env_value
12
+
13
+
14
+ async def sc_mail_list(config: ServerCommanderConfig, folder: str = "INBOX", limit: int = 10) -> dict[str, Any]:
15
+ return _mail_alpha_response(config, "list", {"folder": folder, "limit": limit})
16
+
17
+
18
+ async def sc_mail_read(config: ServerCommanderConfig, message_id: str | None = None) -> dict[str, Any]:
19
+ return _mail_alpha_response(config, "read", {"message_id": message_id})
20
+
21
+
22
+ async def sc_mail_send(
23
+ config: ServerCommanderConfig,
24
+ to: str,
25
+ subject: str,
26
+ body: str,
27
+ ) -> dict[str, Any]:
28
+ return _mail_alpha_response(
29
+ config,
30
+ "send",
31
+ {"to": to, "subject": subject, "body_preview": body[:80]},
32
+ )
33
+
34
+
35
+ async def sc_mail_search(config: ServerCommanderConfig, query: str, limit: int = 10) -> dict[str, Any]:
36
+ return _mail_alpha_response(config, "search", {"query": query, "limit": limit})
37
+
38
+
39
+ def _mail_alpha_response(config: ServerCommanderConfig, action: str, request: dict[str, Any]) -> dict[str, Any]:
40
+ mail = config.mail
41
+ username = resolve_env_value(mail.get("username", ""))
42
+ password = resolve_env_value(mail.get("password", ""))
43
+ configured = bool(mail.get("imap_host") and mail.get("smtp_host") and username and password)
44
+ return {
45
+ "status": "not_implemented",
46
+ "action": action,
47
+ "configured": configured,
48
+ "request": request,
49
+ "message": "IMAP/SMTP execution is not implemented in the alpha server.",
50
+ }
@@ -0,0 +1,169 @@
1
+ """ServerCommander MCP Server - Entry Point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from functools import partial
7
+ import logging
8
+ import sys
9
+ from typing import Any
10
+
11
+ import mcp.types as types
12
+ from mcp.server import Server
13
+ from mcp.server.stdio import stdio_server
14
+
15
+ from servercommander.config import ServerCommanderConfig, load_config
16
+ from servercommander.deploy import sc_deploy, sc_deploy_status
17
+ from servercommander.health import sc_health_check
18
+ from servercommander.logs import sc_logs_analyze
19
+ from servercommander.mail import sc_mail_list, sc_mail_read, sc_mail_search, sc_mail_send
20
+ from servercommander.tooling import ToolDefinition
21
+
22
+ app = Server("ellmos-servercommander")
23
+ logger = logging.getLogger("servercommander")
24
+ _registry: "ServerCommanderRegistry | None" = None
25
+
26
+
27
+ class ServerCommanderRegistry:
28
+ """Owns ServerCommander tool definitions and dispatch."""
29
+
30
+ def __init__(self, config: ServerCommanderConfig):
31
+ self.config = config
32
+ self._tools = build_tools(config)
33
+ self._handlers = {tool.name: tool.handler for tool in self._tools}
34
+
35
+ def list_tools(self) -> list[types.Tool]:
36
+ return [tool.as_mcp_tool() for tool in self._tools]
37
+
38
+ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
39
+ handler = self._handlers.get(name)
40
+ if handler is None:
41
+ raise ValueError(f"Unknown ServerCommander tool: {name}")
42
+ return await handler(**(arguments or {}))
43
+
44
+
45
+ def get_registry() -> ServerCommanderRegistry:
46
+ global _registry
47
+ if _registry is None:
48
+ _registry = ServerCommanderRegistry(load_config())
49
+ return _registry
50
+
51
+
52
+ def object_schema(properties: dict[str, Any], required: list[str] | None = None) -> dict[str, Any]:
53
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
54
+ if required:
55
+ schema["required"] = required
56
+ return schema
57
+
58
+
59
+ def build_tools(config: ServerCommanderConfig) -> list[ToolDefinition]:
60
+ return [
61
+ ToolDefinition(
62
+ name="sc_deploy",
63
+ description="Build a safe deployment plan. Alpha only: execution requires dry_run=true.",
64
+ input_schema=object_schema(
65
+ {
66
+ "profile": {"type": "string"},
67
+ "local_path": {"type": "string"},
68
+ "remote_path": {"type": "string"},
69
+ "dry_run": {"type": "boolean", "default": True},
70
+ }
71
+ ),
72
+ handler=partial(sc_deploy, config),
73
+ ),
74
+ ToolDefinition(
75
+ name="sc_deploy_status",
76
+ description="Show configured deployment profiles and alpha deployment-history status.",
77
+ input_schema=object_schema({"profile": {"type": "string"}}),
78
+ handler=partial(sc_deploy_status, config),
79
+ ),
80
+ ToolDefinition(
81
+ name="sc_mail_list",
82
+ description="Alpha mail status endpoint for listing an IMAP folder.",
83
+ input_schema=object_schema(
84
+ {
85
+ "folder": {"type": "string", "default": "INBOX"},
86
+ "limit": {"type": "integer", "default": 10},
87
+ }
88
+ ),
89
+ handler=partial(sc_mail_list, config),
90
+ ),
91
+ ToolDefinition(
92
+ name="sc_mail_read",
93
+ description="Alpha mail status endpoint for reading a message.",
94
+ input_schema=object_schema({"message_id": {"type": "string"}}),
95
+ handler=partial(sc_mail_read, config),
96
+ ),
97
+ ToolDefinition(
98
+ name="sc_mail_send",
99
+ description="Alpha mail status endpoint for sending mail; does not send yet.",
100
+ input_schema=object_schema(
101
+ {
102
+ "to": {"type": "string"},
103
+ "subject": {"type": "string"},
104
+ "body": {"type": "string"},
105
+ },
106
+ ["to", "subject", "body"],
107
+ ),
108
+ handler=partial(sc_mail_send, config),
109
+ ),
110
+ ToolDefinition(
111
+ name="sc_mail_search",
112
+ description="Alpha mail status endpoint for searching mail.",
113
+ input_schema=object_schema(
114
+ {"query": {"type": "string"}, "limit": {"type": "integer", "default": 10}},
115
+ ["query"],
116
+ ),
117
+ handler=partial(sc_mail_search, config),
118
+ ),
119
+ ToolDefinition(
120
+ name="sc_logs_analyze",
121
+ description="Analyze Apache/Nginx access logs from inline text or a local file path.",
122
+ input_schema=object_schema(
123
+ {
124
+ "log_text": {"type": "string"},
125
+ "log_path": {"type": "string"},
126
+ "top_paths": {"type": "integer", "default": 10},
127
+ "format": {"type": "string", "enum": ["apache", "nginx"]},
128
+ }
129
+ ),
130
+ handler=partial(sc_logs_analyze, config),
131
+ ),
132
+ ToolDefinition(
133
+ name="sc_health_check",
134
+ description="Check HTTP endpoints and return status codes plus latency.",
135
+ input_schema=object_schema(
136
+ {
137
+ "endpoints": {"type": "array", "items": {"type": "string"}},
138
+ "timeout": {"type": "number", "default": 5},
139
+ }
140
+ ),
141
+ handler=partial(sc_health_check, config),
142
+ ),
143
+ ]
144
+
145
+
146
+ @app.list_tools()
147
+ async def list_tools() -> list[types.Tool]:
148
+ return get_registry().list_tools()
149
+
150
+
151
+ @app.call_tool()
152
+ async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
153
+ return await get_registry().call_tool(name, arguments)
154
+
155
+
156
+ async def serve() -> None:
157
+ logger.info("ServerCommander starting...")
158
+
159
+ async with stdio_server() as (read_stream, write_stream):
160
+ await app.run(read_stream, write_stream, app.create_initialization_options())
161
+
162
+
163
+ def main() -> None:
164
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr)
165
+ asyncio.run(serve())
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -0,0 +1,23 @@
1
+ """Small MCP tool-definition helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ import mcp.types as types
9
+
10
+
11
+ @dataclass
12
+ class ToolDefinition:
13
+ name: str
14
+ description: str
15
+ input_schema: dict[str, Any]
16
+ handler: Callable[..., Awaitable[dict[str, Any]]]
17
+
18
+ def as_mcp_tool(self) -> types.Tool:
19
+ return types.Tool(
20
+ name=self.name,
21
+ description=self.description,
22
+ inputSchema=self.input_schema,
23
+ )