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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ remote-admin-mcp = ssh_mcp.server:main
@@ -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
@@ -0,0 +1,3 @@
1
+ """SSH MCP Server - Remote server administration via MCP."""
2
+
3
+ __version__ = "0.1.0"
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()