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 +91 -0
- package/README.md +72 -0
- package/bin/ellmos-servercommander.js +36 -0
- package/config/servercommander.example.toml +38 -0
- package/package.json +46 -0
- package/pyproject.toml +29 -0
- package/src/servercommander/__init__.py +3 -0
- package/src/servercommander/config.py +71 -0
- package/src/servercommander/deploy.py +100 -0
- package/src/servercommander/health.py +65 -0
- package/src/servercommander/logs.py +78 -0
- package/src/servercommander/mail.py +50 -0
- package/src/servercommander/server.py +169 -0
- package/src/servercommander/tooling.py +23 -0
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,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
|
+
)
|