remote-admin-mcp 0.1.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.
- remote_admin_mcp-0.1.0.dist-info/METADATA +268 -0
- remote_admin_mcp-0.1.0.dist-info/RECORD +11 -0
- remote_admin_mcp-0.1.0.dist-info/WHEEL +4 -0
- remote_admin_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- remote_admin_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- ssh_mcp/__init__.py +3 -0
- ssh_mcp/audit.py +12 -0
- ssh_mcp/config.py +35 -0
- ssh_mcp/models.py +18 -0
- ssh_mcp/server.py +393 -0
- ssh_mcp/ssh_client.py +226 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remote-admin-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP Server for remote server administration via SSH — execute commands, manage files, edit configs, transfer files, control services. No agents needed on target servers.
|
|
5
|
+
Project-URL: Homepage, https://github.com/intershopper/remote-admin-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/intershopper/remote-admin-mcp
|
|
7
|
+
Author-email: Frank Schaellert <intershopper@gmx.de>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: mcp>=1.26.0
|
|
15
|
+
Requires-Dist: paramiko>=3.4.0
|
|
16
|
+
Requires-Dist: pyjwt[crypto]>=2.12.0
|
|
17
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
18
|
+
Requires-Dist: starlette>=0.38.0
|
|
19
|
+
Requires-Dist: uvicorn>=0.30.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.3.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# SSH MCP Server
|
|
27
|
+
|
|
28
|
+
MCP Server für SSH-basierte Server-Administration. Ermöglicht KI-Assistenten die Verwaltung von Remote-Servern via SSH.
|
|
29
|
+
|
|
30
|
+
## Supported MCP Clients
|
|
31
|
+
|
|
32
|
+
```mermaid
|
|
33
|
+
graph LR
|
|
34
|
+
subgraph Getestet
|
|
35
|
+
K[Kiro CLI]
|
|
36
|
+
C[Claude Desktop]
|
|
37
|
+
end
|
|
38
|
+
subgraph Kompatibel
|
|
39
|
+
A[Alle MCP-fähigen Clients<br/>stdio + StreamableHTTP]
|
|
40
|
+
end
|
|
41
|
+
K --> MCP[SSH MCP Server]
|
|
42
|
+
C --> MCP
|
|
43
|
+
A --> MCP
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Client | Transport | Status |
|
|
47
|
+
|--------|-----------|--------|
|
|
48
|
+
| [Kiro CLI](https://github.com/aws/kiro) | stdio | ✅ getestet |
|
|
49
|
+
| [Claude Desktop](https://claude.ai/download) | stdio | ✅ getestet |
|
|
50
|
+
| Jeder MCP-Client | stdio / StreamableHTTP | ✅ kompatibel |
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
Verwalte Linux-Server per SSH — direkt aus dem KI-Assistenten heraus. Keine Agents, Daemons oder Tools auf den Zielservern nötig. Nur ein SSH-Zugang reicht.
|
|
55
|
+
|
|
56
|
+
```mermaid
|
|
57
|
+
graph LR
|
|
58
|
+
AI[KI-Assistent] -->|MCP| S[SSH MCP Server]
|
|
59
|
+
S -->|SSH/SFTP| R1[Server 1]
|
|
60
|
+
S -->|SSH/SFTP| R2[Server 2]
|
|
61
|
+
S -->|SSH/SFTP| RN[Server N]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Agentless**: Kein Setup auf den Zielservern — funktioniert mit jedem SSH-Zugang
|
|
65
|
+
- **Remote Command Execution**: Einzelbefehle oder mehrzeilige Scripts ausführen
|
|
66
|
+
- **Sudo Support**: Befehle mit `sudo` ausführen, ohne interaktives Passwort
|
|
67
|
+
- **File Operations**: Dateien lesen (teilweise/komplett), schreiben, suchen, chirurgisch editieren
|
|
68
|
+
- **File Transfer**: Upload/Download via SFTP
|
|
69
|
+
- **Service Management**: Systemd Services starten, stoppen, restarten, Status prüfen
|
|
70
|
+
- **Code Navigation**: Funktions-/Klassen-Übersicht aus Quelldateien extrahieren (grep-basiert, kein Language Server nötig)
|
|
71
|
+
- **Multi-Server**: Beliebig viele Server parallel verwalten
|
|
72
|
+
- **Connection Pooling**: SSH-Verbindungen werden 5 Minuten wiederverwendet
|
|
73
|
+
- **User Approval**: Schreibende Operationen erfordern Bestätigung durch den Operator
|
|
74
|
+
- **Audit Log**: Alle Aktionen werden protokolliert (Pfad konfigurierbar)
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
### Von PyPI
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install remote-admin-mcp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Mit uv (empfohlen für Entwicklung)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# Virtuelle Umgebung erstellen und Abhängigkeiten installieren
|
|
88
|
+
uv venv
|
|
89
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
90
|
+
uv pip install -e ".[dev]"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Mit pip
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Virtuelle Umgebung erstellen
|
|
97
|
+
python -m venv .venv
|
|
98
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
99
|
+
|
|
100
|
+
# Abhängigkeiten installieren
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Konfiguration
|
|
105
|
+
|
|
106
|
+
1. Kopiere `.env.example` zu `.env`:
|
|
107
|
+
```bash
|
|
108
|
+
cp .env.example .env
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
2. Bearbeite `.env` mit deinen Server-Zugangsdaten:
|
|
112
|
+
```bash
|
|
113
|
+
SSH_SERVER_1_NAME=production
|
|
114
|
+
SSH_SERVER_1_HOST=prod.example.com
|
|
115
|
+
SSH_SERVER_1_PORT=22
|
|
116
|
+
SSH_SERVER_1_USER=admin
|
|
117
|
+
SSH_SERVER_1_PASSWORD=your-password
|
|
118
|
+
|
|
119
|
+
SSH_SERVER_2_NAME=staging
|
|
120
|
+
SSH_SERVER_2_HOST=staging.example.com
|
|
121
|
+
SSH_SERVER_2_PORT=22
|
|
122
|
+
SSH_SERVER_2_USER=deploy
|
|
123
|
+
SSH_SERVER_2_PASSWORD=your-password
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## MCP Tools
|
|
127
|
+
|
|
128
|
+
### list_servers
|
|
129
|
+
Liste alle konfigurierten Server auf.
|
|
130
|
+
|
|
131
|
+
### execute_command
|
|
132
|
+
Führe einen Befehl auf einem Remote-Server aus.
|
|
133
|
+
|
|
134
|
+
**Parameter:**
|
|
135
|
+
- `server` (string): Server-Name
|
|
136
|
+
- `command` (string): Auszuführender Befehl
|
|
137
|
+
- `use_sudo` (boolean): Mit sudo ausführen
|
|
138
|
+
|
|
139
|
+
### read_file
|
|
140
|
+
Lese eine Datei vom Remote-Server.
|
|
141
|
+
|
|
142
|
+
**Parameter:**
|
|
143
|
+
- `server` (string): Server-Name
|
|
144
|
+
- `path` (string): Dateipfad
|
|
145
|
+
|
|
146
|
+
### write_file
|
|
147
|
+
Schreibe eine Datei auf den Remote-Server.
|
|
148
|
+
|
|
149
|
+
**Parameter:**
|
|
150
|
+
- `server` (string): Server-Name
|
|
151
|
+
- `path` (string): Dateipfad
|
|
152
|
+
- `content` (string): Dateiinhalt
|
|
153
|
+
|
|
154
|
+
### get_service_status
|
|
155
|
+
Prüfe den Status eines systemd Services.
|
|
156
|
+
|
|
157
|
+
**Parameter:**
|
|
158
|
+
- `server` (string): Server-Name
|
|
159
|
+
- `service` (string): Service-Name
|
|
160
|
+
|
|
161
|
+
### manage_service
|
|
162
|
+
Verwalte einen systemd Service (start/stop/restart/reload).
|
|
163
|
+
|
|
164
|
+
**Parameter:**
|
|
165
|
+
- `server` (string): Server-Name
|
|
166
|
+
- `service` (string): Service-Name
|
|
167
|
+
- `action` (string): Aktion (start/stop/restart/reload)
|
|
168
|
+
|
|
169
|
+
## Verwendung mit KI-Assistenten
|
|
170
|
+
|
|
171
|
+
### Kiro CLI / Claude Desktop (uvx — empfohlen)
|
|
172
|
+
|
|
173
|
+
Kein manuelles Installieren nötig — `uvx` lädt und cached das Paket automatisch:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"mcpServers": {
|
|
178
|
+
"ssh": {
|
|
179
|
+
"command": "uvx",
|
|
180
|
+
"args": ["remote-admin-mcp"],
|
|
181
|
+
"env": {
|
|
182
|
+
"SSH_SERVER_1_NAME": "production",
|
|
183
|
+
"SSH_SERVER_1_HOST": "prod.example.com",
|
|
184
|
+
"SSH_SERVER_1_USER": "admin",
|
|
185
|
+
"SSH_SERVER_1_KEY_FILE": "~/.ssh/id_ed25519"
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Alternative: Mit .env-Datei
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"mcpServers": {
|
|
197
|
+
"ssh": {
|
|
198
|
+
"command": "uvx",
|
|
199
|
+
"args": ["--env-file", "/path/to/.env", "remote-admin-mcp"]
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Alternative: Lokale Installation
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{
|
|
209
|
+
"mcpServers": {
|
|
210
|
+
"ssh": {
|
|
211
|
+
"command": "/path/to/.venv/bin/ssh_mcp_server"
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Beispiele
|
|
218
|
+
|
|
219
|
+
**Log-Analyse:**
|
|
220
|
+
```
|
|
221
|
+
"Zeige mir die letzten 100 Zeilen vom nginx error log auf production"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Service Restart:**
|
|
225
|
+
```
|
|
226
|
+
"Starte den nginx Service auf staging neu"
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Config ändern:**
|
|
230
|
+
```
|
|
231
|
+
"Lies die nginx.conf auf production und erhöhe worker_processes auf 4"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Sicherheit
|
|
235
|
+
|
|
236
|
+
- Schreibende Tools erfordern **User-Approval** durch den MCP Client
|
|
237
|
+
- Lese-Tools (`list_servers`, `read_file`, `search_in_file`, `get_file_structure`) sind auto-approved
|
|
238
|
+
- SSH-Key-Authentifizierung empfohlen (Passwort-Auth möglich)
|
|
239
|
+
- `.env` sollte NICHT ins Git committed werden
|
|
240
|
+
|
|
241
|
+
### Audit Log
|
|
242
|
+
|
|
243
|
+
Alle Aktionen werden in ein Logfile geschrieben:
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
[2026-04-12 13:14:00] [production] execute: tail -100 /var/log/syslog
|
|
247
|
+
[2026-04-12 13:14:05] [production] write_file: /etc/nginx/nginx.conf — Config update
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Default: `~/.ssh-mcp-audit.log`. Konfigurierbar per Environment-Variable:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
SSH_MCP_AUDIT_LOG=/var/log/ssh-mcp-audit.log
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Entwicklung
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
# Tests ausführen
|
|
260
|
+
pytest -v
|
|
261
|
+
|
|
262
|
+
# Linting
|
|
263
|
+
ruff check src/
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Lizenz
|
|
267
|
+
|
|
268
|
+
MIT — siehe [LICENSE](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ssh_mcp/__init__.py,sha256=esBcfCRGD4JEgjfCONycLLcS4pam5g_0JItsSlxQMTs,84
|
|
2
|
+
ssh_mcp/audit.py,sha256=uayX6nMII6ScVkkb737_Brv65bwU7QCV9awQCnuXWbM,441
|
|
3
|
+
ssh_mcp/config.py,sha256=7ZP4HCYmMYzx9uqYN1_nZnu7OfoIdmfF_G5rVv5mkC8,1092
|
|
4
|
+
ssh_mcp/models.py,sha256=ur8fsk7Y6qDo9Yq-9xzrhd4i2mIVgwoaONrLlkQ3c1g,254
|
|
5
|
+
ssh_mcp/server.py,sha256=ANA7eUCc31WIHb7FlYbWfaOrsa1vU89Wdz237EsvfCc,18998
|
|
6
|
+
ssh_mcp/ssh_client.py,sha256=4NdQecel0w8bP_wTMlFcpn17YdqnhbUvHPTb8IquX74,10495
|
|
7
|
+
remote_admin_mcp-0.1.0.dist-info/METADATA,sha256=xMCYCOzpCjIkQq6LUvjl2kga2781SF4Jl-bOXfdav0M,6771
|
|
8
|
+
remote_admin_mcp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
remote_admin_mcp-0.1.0.dist-info/entry_points.txt,sha256=Td4Be1QgN7xt2ocfxYqCNAdjSbRoPeVGQqmTC1MF6c0,57
|
|
10
|
+
remote_admin_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=c4hjs6o_f9N3YnCBT35DQh-_RCkKQMEVznb8ycWQAJA,1073
|
|
11
|
+
remote_admin_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frank Schaellert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ssh_mcp/__init__.py
ADDED
ssh_mcp/audit.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import datetime
|
|
3
|
+
|
|
4
|
+
_log_path = os.getenv("SSH_MCP_AUDIT_LOG", os.path.join(os.path.expanduser("~"), ".ssh-mcp-audit.log"))
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def log(server: str, tool: str, detail: str, reason: str = "") -> None:
|
|
8
|
+
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
9
|
+
reason_str = f" — {reason}" if reason else ""
|
|
10
|
+
line = f"[{ts}] [{server}] {tool}: {detail}{reason_str}\n"
|
|
11
|
+
with open(_log_path, "a") as f:
|
|
12
|
+
f.write(line)
|
ssh_mcp/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dotenv import load_dotenv
|
|
3
|
+
from .models import ServerConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load_servers() -> dict[str, ServerConfig]:
|
|
7
|
+
"""Load server configurations from environment variables."""
|
|
8
|
+
load_dotenv()
|
|
9
|
+
|
|
10
|
+
servers = {}
|
|
11
|
+
i = 1
|
|
12
|
+
|
|
13
|
+
while True:
|
|
14
|
+
name = os.getenv(f"SSH_SERVER_{i}_NAME")
|
|
15
|
+
if not name:
|
|
16
|
+
break
|
|
17
|
+
|
|
18
|
+
host = os.getenv(f"SSH_SERVER_{i}_HOST")
|
|
19
|
+
port = int(os.getenv(f"SSH_SERVER_{i}_PORT", "22"))
|
|
20
|
+
user = os.getenv(f"SSH_SERVER_{i}_USER")
|
|
21
|
+
password = os.getenv(f"SSH_SERVER_{i}_PASSWORD", "")
|
|
22
|
+
key_file = os.getenv(f"SSH_SERVER_{i}_KEY_FILE", "")
|
|
23
|
+
if key_file:
|
|
24
|
+
key_file = os.path.expanduser(key_file)
|
|
25
|
+
|
|
26
|
+
if not all([host, user]) or not any([password, key_file]):
|
|
27
|
+
raise ValueError(f"Incomplete configuration for server {i} (need password or key_file)")
|
|
28
|
+
|
|
29
|
+
servers[name] = ServerConfig(
|
|
30
|
+
name=name, host=host, port=port, user=user,
|
|
31
|
+
password=password, key_file=key_file,
|
|
32
|
+
)
|
|
33
|
+
i += 1
|
|
34
|
+
|
|
35
|
+
return servers
|
ssh_mcp/models.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class ServerConfig:
|
|
6
|
+
name: str
|
|
7
|
+
host: str
|
|
8
|
+
port: int
|
|
9
|
+
user: str
|
|
10
|
+
password: str = ""
|
|
11
|
+
key_file: str = ""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CommandResult:
|
|
16
|
+
stdout: str
|
|
17
|
+
stderr: str
|
|
18
|
+
exit_code: int
|
ssh_mcp/server.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
from mcp.server import Server
|
|
2
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
3
|
+
from mcp.types import Tool, TextContent
|
|
4
|
+
from .config import load_servers
|
|
5
|
+
from .ssh_client import SSHClient
|
|
6
|
+
from . import audit
|
|
7
|
+
import atexit
|
|
8
|
+
|
|
9
|
+
server = Server("remote-admin-mcp")
|
|
10
|
+
servers = {}
|
|
11
|
+
ssh_client = None
|
|
12
|
+
|
|
13
|
+
_use_streaming = True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _validate(arguments: dict, *keys: str) -> str | None:
|
|
17
|
+
for k in keys:
|
|
18
|
+
if k in arguments and not str(arguments[k]).strip():
|
|
19
|
+
return f"ERROR: '{k}' must not be empty"
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _init():
|
|
24
|
+
global servers, ssh_client
|
|
25
|
+
if ssh_client is None:
|
|
26
|
+
servers = load_servers()
|
|
27
|
+
ssh_client = SSHClient(servers)
|
|
28
|
+
atexit.register(ssh_client.close_all)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _truncate_json_lines(text: str, max_json_len: int = 300) -> str:
|
|
32
|
+
"""Truncate lines that look like large JSON blobs."""
|
|
33
|
+
lines = text.split("\n")
|
|
34
|
+
out = []
|
|
35
|
+
for line in lines:
|
|
36
|
+
stripped = line.strip()
|
|
37
|
+
if (stripped.startswith("{") and stripped.endswith("}") and len(stripped) > max_json_len):
|
|
38
|
+
out.append(line[:max_json_len] + "... [JSON truncated]")
|
|
39
|
+
else:
|
|
40
|
+
out.append(line)
|
|
41
|
+
return "\n".join(out)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _format_result(result, server_name: str = "", command: str = "") -> str:
|
|
45
|
+
"""Output with server/command context header."""
|
|
46
|
+
header = f"[{server_name}] $ {command}\n" if server_name and command else ""
|
|
47
|
+
parts = []
|
|
48
|
+
if result.stdout.strip():
|
|
49
|
+
parts.append(_truncate_json_lines(result.stdout.rstrip()))
|
|
50
|
+
if result.stderr.strip():
|
|
51
|
+
parts.append(f"STDERR: {result.stderr.rstrip()}")
|
|
52
|
+
if result.exit_code != 0:
|
|
53
|
+
parts.append(f"Exit code: {result.exit_code}")
|
|
54
|
+
body = "\n".join(parts) if parts else "(no output)"
|
|
55
|
+
return f"{header}{body}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@server.list_tools()
|
|
59
|
+
async def list_tools() -> list[Tool]:
|
|
60
|
+
return [
|
|
61
|
+
Tool(
|
|
62
|
+
name="list_servers",
|
|
63
|
+
description="List all configured SSH servers with their connection details. Call this first to discover available server names.",
|
|
64
|
+
inputSchema={"type": "object", "properties": {}}
|
|
65
|
+
),
|
|
66
|
+
Tool(
|
|
67
|
+
name="execute",
|
|
68
|
+
description="Execute a command or multi-line script on a remote server via SSH. For single commands use `command`, for multi-line scripts use `script`. Requires approval.",
|
|
69
|
+
inputSchema={
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"server": {"type": "string", "description": "Server name"},
|
|
73
|
+
"command": {"type": "string", "description": "Single command to execute"},
|
|
74
|
+
"script": {"type": "string", "description": "Multi-line bash script (alternative to command)"},
|
|
75
|
+
"use_sudo": {"type": "boolean", "description": "Use sudo", "default": False},
|
|
76
|
+
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30, max: 300)", "default": 30},
|
|
77
|
+
"reason": {"type": "string", "description": "Human-readable explanation of what this command does and why (for audit log)"}
|
|
78
|
+
},
|
|
79
|
+
"required": ["server"]
|
|
80
|
+
}
|
|
81
|
+
),
|
|
82
|
+
Tool(
|
|
83
|
+
name="read_file",
|
|
84
|
+
description="Read a file or specific line range from a remote server. For large files (>200 lines), always use lines+offset to read only the relevant section. Use tail=true to read from end (e.g. log files).",
|
|
85
|
+
inputSchema={
|
|
86
|
+
"type": "object",
|
|
87
|
+
"properties": {
|
|
88
|
+
"server": {"type": "string", "description": "Server name"},
|
|
89
|
+
"path": {"type": "string", "description": "File path"},
|
|
90
|
+
"lines": {"type": "integer", "description": "Number of lines to read"},
|
|
91
|
+
"offset": {"type": "integer", "description": "Line offset (skip N lines from start)", "default": 0},
|
|
92
|
+
"tail": {"type": "boolean", "description": "Read from end of file", "default": False}
|
|
93
|
+
},
|
|
94
|
+
"required": ["server", "path"]
|
|
95
|
+
}
|
|
96
|
+
),
|
|
97
|
+
Tool(
|
|
98
|
+
name="write_file",
|
|
99
|
+
description="Overwrite an entire file on remote server via SFTP. WARNING: replaces full file content. For surgical edits on large files, use replace_in_file instead. Requires approval.",
|
|
100
|
+
inputSchema={
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"server": {"type": "string", "description": "Server name"},
|
|
104
|
+
"path": {"type": "string", "description": "File path"},
|
|
105
|
+
"content": {"type": "string", "description": "File content"},
|
|
106
|
+
"reason": {"type": "string", "description": "Human-readable explanation of what is being written and why (for audit log)"}
|
|
107
|
+
},
|
|
108
|
+
"required": ["server", "path", "content"]
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
Tool(
|
|
112
|
+
name="transfer_file",
|
|
113
|
+
description="Transfer a file between local machine and remote server via SFTP. Use direction=upload to send, direction=download to receive.",
|
|
114
|
+
inputSchema={
|
|
115
|
+
"type": "object",
|
|
116
|
+
"properties": {
|
|
117
|
+
"server": {"type": "string", "description": "Server name"},
|
|
118
|
+
"direction": {"type": "string", "enum": ["upload", "download"], "description": "Transfer direction"},
|
|
119
|
+
"local_path": {"type": "string", "description": "Local file path"},
|
|
120
|
+
"remote_path": {"type": "string", "description": "Remote file path"},
|
|
121
|
+
"reason": {"type": "string", "description": "Human-readable explanation of what is being transferred and why (for audit log)"}
|
|
122
|
+
},
|
|
123
|
+
"required": ["server", "direction", "local_path", "remote_path"]
|
|
124
|
+
}
|
|
125
|
+
),
|
|
126
|
+
Tool(
|
|
127
|
+
name="service",
|
|
128
|
+
description="Manage or query a systemd service. Use action=status to check, or start/stop/restart/reload to control. Actions other than status require sudo.",
|
|
129
|
+
inputSchema={
|
|
130
|
+
"type": "object",
|
|
131
|
+
"properties": {
|
|
132
|
+
"server": {"type": "string", "description": "Server name"},
|
|
133
|
+
"service": {"type": "string", "description": "Service name"},
|
|
134
|
+
"action": {"type": "string", "enum": ["status", "start", "stop", "restart", "reload"], "description": "Action to perform"},
|
|
135
|
+
"reason": {"type": "string", "description": "Human-readable explanation of why this service action is needed (for audit log)"}
|
|
136
|
+
},
|
|
137
|
+
"required": ["server", "service", "action"]
|
|
138
|
+
}
|
|
139
|
+
),
|
|
140
|
+
Tool(
|
|
141
|
+
name="search_in_file",
|
|
142
|
+
description="Search for a text pattern in a remote file using grep. Returns matching lines with line numbers and surrounding context. Use this to locate code sections before editing.",
|
|
143
|
+
inputSchema={
|
|
144
|
+
"type": "object",
|
|
145
|
+
"properties": {
|
|
146
|
+
"server": {"type": "string", "description": "Server name"},
|
|
147
|
+
"path": {"type": "string", "description": "File path"},
|
|
148
|
+
"pattern": {"type": "string", "description": "Grep regex pattern"},
|
|
149
|
+
"context_lines": {"type": "integer", "description": "Lines of context around each match", "default": 3},
|
|
150
|
+
"max_matches": {"type": "integer", "description": "Maximum number of matches", "default": 20}
|
|
151
|
+
},
|
|
152
|
+
"required": ["server", "path", "pattern"]
|
|
153
|
+
}
|
|
154
|
+
),
|
|
155
|
+
Tool(
|
|
156
|
+
name="replace_in_file",
|
|
157
|
+
description="Replace a specific text passage in a remote file without loading the full content. Preferred over write_file for editing large files. Returns error if old_text not found. Supports dry_run preview.",
|
|
158
|
+
inputSchema={
|
|
159
|
+
"type": "object",
|
|
160
|
+
"properties": {
|
|
161
|
+
"server": {"type": "string", "description": "Server name"},
|
|
162
|
+
"path": {"type": "string", "description": "File path"},
|
|
163
|
+
"old_text": {"type": "string", "description": "Exact text to find (multi-line supported)"},
|
|
164
|
+
"new_text": {"type": "string", "description": "Replacement text"},
|
|
165
|
+
"count": {"type": "integer", "description": "Max replacements (default: 1, 0 = all)", "default": 1},
|
|
166
|
+
"dry_run": {"type": "boolean", "description": "Preview changes without applying", "default": False},
|
|
167
|
+
"reason": {"type": "string", "description": "Human-readable explanation of what is being changed and why (for audit log)"}
|
|
168
|
+
},
|
|
169
|
+
"required": ["server", "path", "old_text", "new_text"]
|
|
170
|
+
}
|
|
171
|
+
),
|
|
172
|
+
Tool(
|
|
173
|
+
name="get_file_structure",
|
|
174
|
+
description="Get an overview of functions, classes and key definitions in a source file. Returns symbol names with line numbers. Use this to understand a large file before reading or editing specific sections.",
|
|
175
|
+
inputSchema={
|
|
176
|
+
"type": "object",
|
|
177
|
+
"properties": {
|
|
178
|
+
"server": {"type": "string", "description": "Server name"},
|
|
179
|
+
"path": {"type": "string", "description": "File path"},
|
|
180
|
+
"language": {"type": "string", "description": "Language hint (auto-detected from extension if omitted)"}
|
|
181
|
+
},
|
|
182
|
+
"required": ["server", "path"]
|
|
183
|
+
}
|
|
184
|
+
),
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@server.call_tool()
|
|
189
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
190
|
+
_init()
|
|
191
|
+
|
|
192
|
+
if name == "list_servers":
|
|
193
|
+
server_list = "\n".join(f" {n} {cfg.user}@{cfg.host}:{cfg.port}" for n, cfg in servers.items())
|
|
194
|
+
return [TextContent(type="text", text=server_list)]
|
|
195
|
+
|
|
196
|
+
elif name == "execute":
|
|
197
|
+
srv = arguments["server"]
|
|
198
|
+
command = arguments.get("command")
|
|
199
|
+
script = arguments.get("script")
|
|
200
|
+
use_sudo = arguments.get("use_sudo", False)
|
|
201
|
+
timeout = max(1, min(arguments.get("timeout", 30), 300))
|
|
202
|
+
|
|
203
|
+
if not command and not script:
|
|
204
|
+
return [TextContent(type="text", text="ERROR: Provide either 'command' or 'script'")]
|
|
205
|
+
|
|
206
|
+
# Script mode: write to temp file, execute, clean up
|
|
207
|
+
if script:
|
|
208
|
+
import uuid
|
|
209
|
+
tmp = f"/tmp/_mcp_{uuid.uuid4().hex[:8]}.sh"
|
|
210
|
+
ssh_client.write_file(srv, tmp, f"#!/bin/bash\n{script}\n")
|
|
211
|
+
try:
|
|
212
|
+
result = ssh_client.execute(srv, f"bash {tmp}", use_sudo, timeout)
|
|
213
|
+
audit.log(srv, "execute", f"script ({len(script)} chars)", arguments.get("reason", ""))
|
|
214
|
+
return [TextContent(type="text", text=_format_result(result, srv, "execute_script"))]
|
|
215
|
+
except TimeoutError:
|
|
216
|
+
return [TextContent(type="text", text=f"[{srv}] execute_script\nTIMEOUT after {timeout}s")]
|
|
217
|
+
finally:
|
|
218
|
+
ssh_client.execute(srv, f"rm -f {tmp}")
|
|
219
|
+
|
|
220
|
+
# Command mode with streaming
|
|
221
|
+
cmd = command
|
|
222
|
+
if _use_streaming:
|
|
223
|
+
try:
|
|
224
|
+
ctx = server.request_context
|
|
225
|
+
rid = ctx.request_id
|
|
226
|
+
log = ctx.session.send_log_message
|
|
227
|
+
output_lines, exit_code, stderr_data = [], 0, ""
|
|
228
|
+
async for line in ssh_client.execute_streaming(srv, cmd, use_sudo, timeout):
|
|
229
|
+
if line.startswith("\0EXIT:"):
|
|
230
|
+
parts = line[6:].split(":", 1)
|
|
231
|
+
exit_code = int(parts[0])
|
|
232
|
+
stderr_data = parts[1] if len(parts) > 1 else ""
|
|
233
|
+
else:
|
|
234
|
+
output_lines.append(line)
|
|
235
|
+
await log(level="info", data=line, related_request_id=rid)
|
|
236
|
+
header = f"[{srv}] $ {cmd}\n"
|
|
237
|
+
result_parts = []
|
|
238
|
+
stdout = "\n".join(output_lines)
|
|
239
|
+
if stdout.strip():
|
|
240
|
+
result_parts.append(_truncate_json_lines(stdout))
|
|
241
|
+
if stderr_data.strip():
|
|
242
|
+
result_parts.append(f"STDERR: {stderr_data.rstrip()}")
|
|
243
|
+
if exit_code == 124:
|
|
244
|
+
result_parts.append(f"TIMEOUT after {timeout}s")
|
|
245
|
+
elif exit_code != 0:
|
|
246
|
+
result_parts.append(f"Exit code: {exit_code}")
|
|
247
|
+
body = "\n".join(result_parts) if result_parts else "(no output)"
|
|
248
|
+
audit.log(srv, "execute", cmd, arguments.get("reason", ""))
|
|
249
|
+
return [TextContent(type="text", text=f"{header}{body}")]
|
|
250
|
+
except Exception as e:
|
|
251
|
+
import sys
|
|
252
|
+
print(f"[ssh-mcp] Streaming failed, falling back to non-streaming: {e}", file=sys.stderr)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
result = ssh_client.execute(srv, cmd, use_sudo, timeout)
|
|
256
|
+
audit.log(srv, "execute", cmd, arguments.get("reason", ""))
|
|
257
|
+
return [TextContent(type="text", text=_format_result(result, srv, cmd))]
|
|
258
|
+
except TimeoutError:
|
|
259
|
+
return [TextContent(type="text", text=f"[{srv}] $ {cmd}\nTIMEOUT after {timeout}s. Retry with higher timeout (max 300s).")]
|
|
260
|
+
|
|
261
|
+
elif name == "read_file":
|
|
262
|
+
err = _validate(arguments, "server", "path")
|
|
263
|
+
if err: return [TextContent(type="text", text=err)]
|
|
264
|
+
srv = arguments["server"]
|
|
265
|
+
path = arguments["path"]
|
|
266
|
+
lines = arguments.get("lines")
|
|
267
|
+
offset = arguments.get("offset", 0)
|
|
268
|
+
tail = arguments.get("tail", False)
|
|
269
|
+
content = ssh_client.read_file(srv, path, lines=lines, offset=offset, tail=tail)
|
|
270
|
+
return [TextContent(type="text", text=f"[{srv}] {path}\n{content.rstrip()}")]
|
|
271
|
+
|
|
272
|
+
elif name == "write_file":
|
|
273
|
+
err = _validate(arguments, "server", "path")
|
|
274
|
+
if err: return [TextContent(type="text", text=err)]
|
|
275
|
+
ssh_client.write_file(arguments["server"], arguments["path"], arguments["content"])
|
|
276
|
+
audit.log(arguments["server"], "write_file", arguments["path"], arguments.get("reason", ""))
|
|
277
|
+
return [TextContent(type="text", text=f"[{arguments['server']}] Written: {arguments['path']}")]
|
|
278
|
+
|
|
279
|
+
elif name == "transfer_file":
|
|
280
|
+
srv = arguments["server"]
|
|
281
|
+
direction = arguments["direction"]
|
|
282
|
+
local_path = arguments["local_path"]
|
|
283
|
+
remote_path = arguments["remote_path"]
|
|
284
|
+
if direction == "upload":
|
|
285
|
+
ssh_client.upload_file(srv, local_path, remote_path)
|
|
286
|
+
audit.log(srv, "transfer_file", f"upload {local_path} → {remote_path}", arguments.get("reason", ""))
|
|
287
|
+
return [TextContent(type="text", text=f"[{srv}] Uploaded: {local_path} → {remote_path}")]
|
|
288
|
+
else:
|
|
289
|
+
ssh_client.download_file(srv, remote_path, local_path)
|
|
290
|
+
return [TextContent(type="text", text=f"[{srv}] Downloaded: {remote_path} → {local_path}")]
|
|
291
|
+
|
|
292
|
+
elif name == "service":
|
|
293
|
+
import re
|
|
294
|
+
srv = arguments["server"]
|
|
295
|
+
svc = arguments["service"]
|
|
296
|
+
action = arguments["action"]
|
|
297
|
+
if not re.match(r'^[a-zA-Z0-9_.\-@]+$', svc):
|
|
298
|
+
return [TextContent(type="text", text=f"ERROR: Invalid service name: {svc}")]
|
|
299
|
+
use_sudo = action != "status"
|
|
300
|
+
result = ssh_client.execute(srv, f"systemctl {action} {svc}", use_sudo=use_sudo)
|
|
301
|
+
if action != "status" and result.exit_code == 0:
|
|
302
|
+
audit.log(srv, "service", f"{action} {svc}", arguments.get("reason", ""))
|
|
303
|
+
status = ssh_client.execute(srv, f"systemctl status {svc}", use_sudo=False)
|
|
304
|
+
return [TextContent(type="text", text=f"[{srv}] {svc} → {action} OK\n{status.stdout.rstrip()}")]
|
|
305
|
+
return [TextContent(type="text", text=_format_result(result, srv, f"systemctl {action} {svc}"))]
|
|
306
|
+
|
|
307
|
+
elif name == "search_in_file":
|
|
308
|
+
err = _validate(arguments, "server", "path", "pattern")
|
|
309
|
+
if err: return [TextContent(type="text", text=err)]
|
|
310
|
+
srv = arguments["server"]
|
|
311
|
+
path = arguments["path"]
|
|
312
|
+
pattern = arguments["pattern"]
|
|
313
|
+
ctx_lines = arguments.get("context_lines", 3)
|
|
314
|
+
max_matches = arguments.get("max_matches", 20)
|
|
315
|
+
result = ssh_client.search_in_file(srv, path, pattern, ctx_lines, max_matches)
|
|
316
|
+
return [TextContent(type="text", text=f"[{srv}] grep '{pattern}' {path}\n{result}")]
|
|
317
|
+
|
|
318
|
+
elif name == "replace_in_file":
|
|
319
|
+
err = _validate(arguments, "server", "path")
|
|
320
|
+
if err: return [TextContent(type="text", text=err)]
|
|
321
|
+
srv = arguments["server"]
|
|
322
|
+
path = arguments["path"]
|
|
323
|
+
old_text = arguments["old_text"]
|
|
324
|
+
new_text = arguments["new_text"]
|
|
325
|
+
count = arguments.get("count", 1)
|
|
326
|
+
dry_run = arguments.get("dry_run", False)
|
|
327
|
+
result = ssh_client.replace_in_file(srv, path, old_text, new_text, count, dry_run)
|
|
328
|
+
action = "Preview" if dry_run else "Replaced"
|
|
329
|
+
if not dry_run:
|
|
330
|
+
audit.log(srv, "replace_in_file", path, arguments.get("reason", ""))
|
|
331
|
+
return [TextContent(type="text", text=f"[{srv}] {action} in {path}\n{result}")]
|
|
332
|
+
|
|
333
|
+
elif name == "get_file_structure":
|
|
334
|
+
err = _validate(arguments, "server", "path")
|
|
335
|
+
if err: return [TextContent(type="text", text=err)]
|
|
336
|
+
srv = arguments["server"]
|
|
337
|
+
path = arguments["path"]
|
|
338
|
+
language = arguments.get("language")
|
|
339
|
+
result = ssh_client.get_file_structure(srv, path, language)
|
|
340
|
+
return [TextContent(type="text", text=f"[{srv}] {path}\n{result}")]
|
|
341
|
+
|
|
342
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def _async_main_stdio():
|
|
346
|
+
from mcp.server.stdio import stdio_server
|
|
347
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
348
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def _async_main_http(host: str, port: int):
|
|
352
|
+
from starlette.applications import Starlette
|
|
353
|
+
from starlette.routing import Mount
|
|
354
|
+
import uvicorn
|
|
355
|
+
import contextlib
|
|
356
|
+
|
|
357
|
+
session_manager = StreamableHTTPSessionManager(app=server, json_response=False)
|
|
358
|
+
|
|
359
|
+
async def handle_mcp(scope, receive, send):
|
|
360
|
+
await session_manager.handle_request(scope, receive, send)
|
|
361
|
+
|
|
362
|
+
@contextlib.asynccontextmanager
|
|
363
|
+
async def lifespan(app):
|
|
364
|
+
async with session_manager.run():
|
|
365
|
+
yield
|
|
366
|
+
|
|
367
|
+
app = Starlette(
|
|
368
|
+
routes=[Mount("/mcp", app=handle_mcp)],
|
|
369
|
+
lifespan=lifespan,
|
|
370
|
+
)
|
|
371
|
+
config = uvicorn.Config(app, host=host, port=port)
|
|
372
|
+
srv = uvicorn.Server(config)
|
|
373
|
+
await srv.serve()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main():
|
|
377
|
+
import argparse
|
|
378
|
+
import asyncio
|
|
379
|
+
|
|
380
|
+
parser = argparse.ArgumentParser(description="SSH MCP Server")
|
|
381
|
+
parser.add_argument("--transport", choices=["stdio", "http"], default="stdio")
|
|
382
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
383
|
+
parser.add_argument("--port", type=int, default=8080)
|
|
384
|
+
args = parser.parse_args()
|
|
385
|
+
|
|
386
|
+
if args.transport == "http":
|
|
387
|
+
asyncio.run(_async_main_http(args.host, args.port))
|
|
388
|
+
else:
|
|
389
|
+
asyncio.run(_async_main_stdio())
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
main()
|
ssh_mcp/ssh_client.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import shlex
|
|
3
|
+
import re
|
|
4
|
+
import paramiko
|
|
5
|
+
from typing import AsyncIterator
|
|
6
|
+
from .models import ServerConfig, CommandResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SSHClient:
|
|
10
|
+
CONNECT_TIMEOUT = 10
|
|
11
|
+
POOL_TTL = 300 # reuse connections for 5 min
|
|
12
|
+
|
|
13
|
+
def __init__(self, servers: dict[str, ServerConfig]):
|
|
14
|
+
self.servers = servers
|
|
15
|
+
self._pool: dict[str, tuple[paramiko.SSHClient, float]] = {}
|
|
16
|
+
|
|
17
|
+
def _get_connection(self, server_name: str) -> paramiko.SSHClient:
|
|
18
|
+
if server_name not in self.servers:
|
|
19
|
+
raise ValueError(f"Unknown server: {server_name}")
|
|
20
|
+
|
|
21
|
+
# Check pool
|
|
22
|
+
if server_name in self._pool:
|
|
23
|
+
client, ts = self._pool[server_name]
|
|
24
|
+
if time.time() - ts < self.POOL_TTL:
|
|
25
|
+
try:
|
|
26
|
+
transport = client.get_transport()
|
|
27
|
+
if transport and transport.is_active():
|
|
28
|
+
return client
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
# Stale — close and remove
|
|
32
|
+
try:
|
|
33
|
+
client.close()
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
del self._pool[server_name]
|
|
37
|
+
|
|
38
|
+
cfg = self.servers[server_name]
|
|
39
|
+
client = paramiko.SSHClient()
|
|
40
|
+
client.load_system_host_keys()
|
|
41
|
+
client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
|
42
|
+
connect_kwargs = dict(
|
|
43
|
+
hostname=cfg.host, port=cfg.port, username=cfg.user,
|
|
44
|
+
timeout=self.CONNECT_TIMEOUT, banner_timeout=self.CONNECT_TIMEOUT,
|
|
45
|
+
auth_timeout=self.CONNECT_TIMEOUT,
|
|
46
|
+
)
|
|
47
|
+
if cfg.key_file:
|
|
48
|
+
connect_kwargs["key_filename"] = cfg.key_file
|
|
49
|
+
if cfg.password:
|
|
50
|
+
connect_kwargs["password"] = cfg.password
|
|
51
|
+
client.connect(**connect_kwargs)
|
|
52
|
+
self._pool[server_name] = (client, time.time())
|
|
53
|
+
return client
|
|
54
|
+
|
|
55
|
+
def _exec(self, server_name: str, command: str, use_sudo: bool, timeout: int):
|
|
56
|
+
client = self._get_connection(server_name)
|
|
57
|
+
cmd = f"timeout {timeout} bash -c {shlex.quote(command)}"
|
|
58
|
+
if use_sudo:
|
|
59
|
+
cmd = f"sudo bash -c {shlex.quote(f'timeout {timeout} bash -c {shlex.quote(command)}')}"
|
|
60
|
+
channel = client.get_transport().open_session()
|
|
61
|
+
channel.exec_command(cmd)
|
|
62
|
+
return client, channel
|
|
63
|
+
|
|
64
|
+
def _drain(self, channel) -> tuple[str, str]:
|
|
65
|
+
out, err = [], []
|
|
66
|
+
while channel.recv_ready():
|
|
67
|
+
out.append(channel.recv(4096))
|
|
68
|
+
while channel.recv_stderr_ready():
|
|
69
|
+
err.append(channel.recv_stderr(4096))
|
|
70
|
+
return b"".join(out).decode(), b"".join(err).decode()
|
|
71
|
+
|
|
72
|
+
def execute(self, server_name: str, command: str, use_sudo: bool = False, timeout: int = 30) -> CommandResult:
|
|
73
|
+
client, channel = self._exec(server_name, command, use_sudo, timeout)
|
|
74
|
+
try:
|
|
75
|
+
out, err = [], []
|
|
76
|
+
while True:
|
|
77
|
+
if channel.recv_ready():
|
|
78
|
+
out.append(channel.recv(4096))
|
|
79
|
+
if channel.recv_stderr_ready():
|
|
80
|
+
err.append(channel.recv_stderr(4096))
|
|
81
|
+
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
|
|
82
|
+
break
|
|
83
|
+
extra_out, extra_err = self._drain(channel)
|
|
84
|
+
exit_code = channel.recv_exit_status()
|
|
85
|
+
stdout_data = b"".join(out).decode() + extra_out
|
|
86
|
+
stderr_data = b"".join(err).decode() + extra_err
|
|
87
|
+
if exit_code == 124:
|
|
88
|
+
raise TimeoutError(f"Command timed out after {timeout}s")
|
|
89
|
+
return CommandResult(stdout=stdout_data, stderr=stderr_data, exit_code=exit_code)
|
|
90
|
+
finally:
|
|
91
|
+
channel.close()
|
|
92
|
+
|
|
93
|
+
async def execute_streaming(self, server_name: str, command: str, use_sudo: bool = False, timeout: int = 30) -> AsyncIterator[str]:
|
|
94
|
+
import asyncio
|
|
95
|
+
client, channel = self._exec(server_name, command, use_sudo, timeout)
|
|
96
|
+
try:
|
|
97
|
+
buf, err_chunks = "", []
|
|
98
|
+
while True:
|
|
99
|
+
if channel.recv_stderr_ready():
|
|
100
|
+
err_chunks.append(channel.recv_stderr(4096))
|
|
101
|
+
if channel.recv_ready():
|
|
102
|
+
chunk = await asyncio.get_event_loop().run_in_executor(None, channel.recv, 4096)
|
|
103
|
+
buf += chunk.decode()
|
|
104
|
+
while "\n" in buf:
|
|
105
|
+
line, buf = buf.split("\n", 1)
|
|
106
|
+
yield line
|
|
107
|
+
elif channel.exit_status_ready():
|
|
108
|
+
break
|
|
109
|
+
else:
|
|
110
|
+
await asyncio.sleep(0.05)
|
|
111
|
+
while channel.recv_ready():
|
|
112
|
+
buf += channel.recv(4096).decode()
|
|
113
|
+
while channel.recv_stderr_ready():
|
|
114
|
+
err_chunks.append(channel.recv_stderr(4096))
|
|
115
|
+
if buf.strip():
|
|
116
|
+
yield buf.strip()
|
|
117
|
+
exit_code = channel.recv_exit_status()
|
|
118
|
+
yield f"\0EXIT:{exit_code}:{b''.join(err_chunks).decode()}"
|
|
119
|
+
finally:
|
|
120
|
+
channel.close()
|
|
121
|
+
|
|
122
|
+
def read_file(self, server_name: str, path: str, lines: int | None = None, offset: int = 0, tail: bool = False) -> str:
|
|
123
|
+
if lines and tail:
|
|
124
|
+
cmd = f"tail -n {lines} {shlex.quote(path)}"
|
|
125
|
+
elif lines:
|
|
126
|
+
start = offset + 1
|
|
127
|
+
end = offset + lines
|
|
128
|
+
cmd = f"sed -n '{start},{end}p' {shlex.quote(path)}"
|
|
129
|
+
else:
|
|
130
|
+
cmd = f"cat {shlex.quote(path)}"
|
|
131
|
+
result = self.execute(server_name, cmd)
|
|
132
|
+
if result.exit_code != 0:
|
|
133
|
+
raise RuntimeError(f"Failed to read file: {result.stderr}")
|
|
134
|
+
return result.stdout
|
|
135
|
+
|
|
136
|
+
def write_file(self, server_name: str, path: str, content: str) -> None:
|
|
137
|
+
client = self._get_connection(server_name)
|
|
138
|
+
sftp = client.open_sftp()
|
|
139
|
+
with sftp.file(path, 'w') as f:
|
|
140
|
+
f.write(content)
|
|
141
|
+
sftp.close()
|
|
142
|
+
|
|
143
|
+
def upload_file(self, server_name: str, local_path: str, remote_path: str) -> None:
|
|
144
|
+
client = self._get_connection(server_name)
|
|
145
|
+
sftp = client.open_sftp()
|
|
146
|
+
sftp.put(local_path, remote_path)
|
|
147
|
+
sftp.close()
|
|
148
|
+
|
|
149
|
+
def download_file(self, server_name: str, remote_path: str, local_path: str) -> None:
|
|
150
|
+
client = self._get_connection(server_name)
|
|
151
|
+
sftp = client.open_sftp()
|
|
152
|
+
sftp.get(remote_path, local_path)
|
|
153
|
+
sftp.close()
|
|
154
|
+
|
|
155
|
+
def search_in_file(self, server_name: str, path: str, pattern: str, context_lines: int = 3, max_matches: int = 20) -> str:
|
|
156
|
+
result = self.execute(server_name, f"test -f {shlex.quote(path)} && grep -n -C{context_lines} -m{max_matches} -- {shlex.quote(pattern)} {shlex.quote(path)}")
|
|
157
|
+
if result.exit_code == 1:
|
|
158
|
+
return "No matches found."
|
|
159
|
+
if result.exit_code != 0:
|
|
160
|
+
raise RuntimeError(f"Search failed: {result.stderr}")
|
|
161
|
+
return result.stdout
|
|
162
|
+
|
|
163
|
+
def replace_in_file(self, server_name: str, path: str, old_text: str, new_text: str, count: int = 1, dry_run: bool = False) -> str:
|
|
164
|
+
import base64
|
|
165
|
+
b64_old = base64.b64encode(old_text.encode()).decode()
|
|
166
|
+
b64_new = base64.b64encode(new_text.encode()).decode()
|
|
167
|
+
script = f"""import sys, base64, difflib
|
|
168
|
+
path, count, dry_run = sys.argv[1], int(sys.argv[2]), sys.argv[3] == "1"
|
|
169
|
+
old = base64.b64decode("{b64_old}").decode()
|
|
170
|
+
new = base64.b64decode("{b64_new}").decode()
|
|
171
|
+
content = open(path).read()
|
|
172
|
+
if old not in content:
|
|
173
|
+
print("ERROR: old_text not found in file", file=sys.stderr); sys.exit(1)
|
|
174
|
+
result = content.replace(old, new, count) if count > 0 else content.replace(old, new)
|
|
175
|
+
n = content.count(old) if count == 0 else min(count, content.count(old))
|
|
176
|
+
if dry_run:
|
|
177
|
+
diff = difflib.unified_diff(content.splitlines(), result.splitlines(), lineterm="", fromfile="before", tofile="after", n=3)
|
|
178
|
+
print("\\n".join(diff))
|
|
179
|
+
else:
|
|
180
|
+
open(path, "w").write(result)
|
|
181
|
+
print(f"OK: {{n}} replacement(s) applied")
|
|
182
|
+
"""
|
|
183
|
+
import uuid
|
|
184
|
+
tmp = f"/tmp/_mcp_replace_{uuid.uuid4().hex[:8]}.py"
|
|
185
|
+
try:
|
|
186
|
+
self.write_file(server_name, tmp, script)
|
|
187
|
+
result = self.execute(server_name, f"python3 {tmp} {shlex.quote(path)} {count} {'1' if dry_run else '0'}")
|
|
188
|
+
if result.exit_code != 0:
|
|
189
|
+
raise RuntimeError(result.stderr.strip())
|
|
190
|
+
return result.stdout.strip()
|
|
191
|
+
finally:
|
|
192
|
+
self.execute(server_name, f"rm -f {tmp}")
|
|
193
|
+
|
|
194
|
+
def get_file_structure(self, server_name: str, path: str, language: str | None = None) -> str:
|
|
195
|
+
if not language:
|
|
196
|
+
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
|
197
|
+
lang_map = {"js": "js", "ts": "js", "tsx": "js", "jsx": "js", "mjs": "js", "py": "py", "go": "go", "rs": "rs", "java": "java", "sh": "sh", "bash": "sh", "zsh": "sh", "c": "c", "h": "c", "cpp": "c", "hpp": "c", "cc": "c", "rb": "rb", "php": "php"}
|
|
198
|
+
language = lang_map.get(ext, "generic")
|
|
199
|
+
patterns = {
|
|
200
|
+
"js": r'^\s*(export\s+)?(async\s+)?function\s|^\s*(export\s+)?(const|let|var)\s+\w+\s*=\s*(async\s+)?\(|^\s*(export\s+)?class\s|^\s*(export\s+)?interface\s|^\s*(export\s+)?type\s|^\s*(export\s+)?enum\s',
|
|
201
|
+
"py": r'^\s*(class |def |async def )',
|
|
202
|
+
"go": r'^(func |type )',
|
|
203
|
+
"rs": r'^\s*(pub\s+)?(fn |struct |enum |impl |trait |mod )',
|
|
204
|
+
"java": r'^\s*(public|private|protected)?\s*(static\s+)?(class |interface |enum |.*\s+\w+\s*\()',
|
|
205
|
+
"sh": r'^\s*(\w+\s*\(\)|function\s+\w+)',
|
|
206
|
+
"c": r'^\s*(static\s+)?(inline\s+)?(void|int|char|float|double|long|unsigned|struct|enum|typedef|#define)\s|^\w+.*\w+\s*\(',
|
|
207
|
+
"rb": r'^\s*(class |module |def )',
|
|
208
|
+
"php": r'^\s*(public|private|protected|static)?\s*(function |class |interface |trait )',
|
|
209
|
+
"generic": r'^\s*(function|class|def|async def|interface|type|struct|impl|pub fn|fn|enum|trait|mod)\s',
|
|
210
|
+
}
|
|
211
|
+
pat = patterns.get(language, patterns["generic"])
|
|
212
|
+
result = self.execute(server_name, f"wc -l < {shlex.quote(path)} && grep -n -E {shlex.quote(pat)} {shlex.quote(path)}")
|
|
213
|
+
if result.exit_code != 0:
|
|
214
|
+
raise RuntimeError(f"Failed: {result.stderr}")
|
|
215
|
+
lines = result.stdout.strip().split("\n")
|
|
216
|
+
total = lines[0].strip()
|
|
217
|
+
symbols = "\n".join(lines[1:]) if len(lines) > 1 else "(no symbols found)"
|
|
218
|
+
return f"({total} lines)\n{symbols}"
|
|
219
|
+
|
|
220
|
+
def close_all(self):
|
|
221
|
+
for name, (client, _) in self._pool.items():
|
|
222
|
+
try:
|
|
223
|
+
client.close()
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
self._pool.clear()
|