mcp-deploy-server 0.3.0__tar.gz
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.
- mcp_deploy_server-0.3.0/.gitignore +8 -0
- mcp_deploy_server-0.3.0/PKG-INFO +13 -0
- mcp_deploy_server-0.3.0/README.md +1 -0
- mcp_deploy_server-0.3.0/pyproject.toml +23 -0
- mcp_deploy_server-0.3.0/src/mcp_deploy_server/__init__.py +2 -0
- mcp_deploy_server-0.3.0/src/mcp_deploy_server/main.py +320 -0
- mcp_deploy_server-0.3.0/src/mcp_deploy_server/models.py +106 -0
- mcp_deploy_server-0.3.0/src/mcp_deploy_server/ssh.py +108 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcp-deploy-server
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: MCP deployment server for AI agents to execute commands on remote servers
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ai-agents,deployment,mcp,ssh
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: asyncssh>=2.14.0
|
|
9
|
+
Requires-Dist: mcp>=1.0.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# serverASmcp
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# serverASmcp
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-deploy-server"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "MCP deployment server for AI agents to execute commands on remote servers"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
keywords = ["mcp", "deployment", "ssh", "ai-agents"]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"mcp>=1.0.0",
|
|
11
|
+
"asyncssh>=2.14.0",
|
|
12
|
+
"pydantic>=2.0.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
mcp-deploy-server = "mcp_deploy_server.main:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/mcp_deploy_server"]
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""MCP Deploy Server - main entry point."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mcp.server import Server
|
|
9
|
+
from mcp.server.stdio import stdio_server
|
|
10
|
+
from mcp.types import Tool, TextContent
|
|
11
|
+
|
|
12
|
+
from .models import (
|
|
13
|
+
ServerEntry,
|
|
14
|
+
load_servers,
|
|
15
|
+
find_server,
|
|
16
|
+
find_server_by_name,
|
|
17
|
+
add_server,
|
|
18
|
+
remove_server,
|
|
19
|
+
append_audit,
|
|
20
|
+
get_config_dir,
|
|
21
|
+
)
|
|
22
|
+
from .ssh import (
|
|
23
|
+
get_connection,
|
|
24
|
+
close_connection,
|
|
25
|
+
close_all_connections,
|
|
26
|
+
exec_command,
|
|
27
|
+
upload_content,
|
|
28
|
+
upload_file,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
app = Server("mcp-deploy-server")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_server(id_or_name: str) -> ServerEntry | None:
|
|
35
|
+
return find_server(id_or_name) or find_server_by_name(id_or_name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sanitize_args(args: dict[str, Any]) -> str:
|
|
39
|
+
sanitized = dict(args)
|
|
40
|
+
if "password" in sanitized:
|
|
41
|
+
sanitized["password"] = "***"
|
|
42
|
+
if "content" in sanitized and isinstance(sanitized["content"], str) and len(sanitized["content"]) > 200:
|
|
43
|
+
sanitized["content"] = f"[{len(sanitized['content'])} chars]"
|
|
44
|
+
return json.dumps(sanitized)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
TOOLS = [
|
|
48
|
+
Tool(
|
|
49
|
+
name="add_server",
|
|
50
|
+
description="Add a target server. Call multiple times to add many servers.",
|
|
51
|
+
inputSchema={
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"name": {"type": "string", "description": "Unique label (e.g. web-1)"},
|
|
55
|
+
"host": {"type": "string", "description": "IP or hostname"},
|
|
56
|
+
"port": {"type": "integer", "default": 22, "description": "SSH port"},
|
|
57
|
+
"username": {"type": "string", "default": "root", "description": "SSH user"},
|
|
58
|
+
"authMethod": {"type": "string", "enum": ["password", "private_key"]},
|
|
59
|
+
"password": {"type": "string", "description": "SSH password"},
|
|
60
|
+
"privateKeyPath": {"type": "string", "description": "Path to SSH key"},
|
|
61
|
+
"privateKey": {"type": "string", "description": "SSH key content"},
|
|
62
|
+
},
|
|
63
|
+
"required": ["name", "host", "authMethod"],
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
Tool(
|
|
67
|
+
name="list_servers",
|
|
68
|
+
description="List all registered servers",
|
|
69
|
+
inputSchema={"type": "object", "properties": {}},
|
|
70
|
+
),
|
|
71
|
+
Tool(
|
|
72
|
+
name="remove_server",
|
|
73
|
+
description="Remove a server by name",
|
|
74
|
+
inputSchema={
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {"server": {"type": "string", "description": "Server name"}},
|
|
77
|
+
"required": ["server"],
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
Tool(
|
|
81
|
+
name="run_command",
|
|
82
|
+
description="Execute any shell command as root on a specific server. No restrictions.",
|
|
83
|
+
inputSchema={
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"server": {"type": "string", "description": "Server name or ID"},
|
|
87
|
+
"command": {"type": "string", "description": "Shell command"},
|
|
88
|
+
"timeoutSec": {"type": "integer", "default": 30},
|
|
89
|
+
},
|
|
90
|
+
"required": ["server", "command"],
|
|
91
|
+
},
|
|
92
|
+
),
|
|
93
|
+
Tool(
|
|
94
|
+
name="run_all",
|
|
95
|
+
description="Execute the same command on ALL servers",
|
|
96
|
+
inputSchema={
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"command": {"type": "string"},
|
|
100
|
+
"timeoutSec": {"type": "integer", "default": 30},
|
|
101
|
+
},
|
|
102
|
+
"required": ["command"],
|
|
103
|
+
},
|
|
104
|
+
),
|
|
105
|
+
Tool(
|
|
106
|
+
name="deploy_file",
|
|
107
|
+
description="Upload files and run commands on a specific server",
|
|
108
|
+
inputSchema={
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"server": {"type": "string", "description": "Server name or ID"},
|
|
112
|
+
"files": {
|
|
113
|
+
"type": "array",
|
|
114
|
+
"items": {
|
|
115
|
+
"type": "object",
|
|
116
|
+
"properties": {
|
|
117
|
+
"localPath": {"type": "string"},
|
|
118
|
+
"content": {"type": "string"},
|
|
119
|
+
"remotePath": {"type": "string"},
|
|
120
|
+
},
|
|
121
|
+
"required": ["remotePath"],
|
|
122
|
+
},
|
|
123
|
+
"minItems": 1,
|
|
124
|
+
},
|
|
125
|
+
"remoteDir": {"type": "string", "default": "/tmp"},
|
|
126
|
+
"commands": {"type": "array", "items": {"type": "string"}, "default": []},
|
|
127
|
+
},
|
|
128
|
+
"required": ["server", "files"],
|
|
129
|
+
},
|
|
130
|
+
),
|
|
131
|
+
Tool(
|
|
132
|
+
name="check_status",
|
|
133
|
+
description="Test SSH connectivity to a server",
|
|
134
|
+
inputSchema={
|
|
135
|
+
"type": "object",
|
|
136
|
+
"properties": {"server": {"type": "string"}},
|
|
137
|
+
"required": ["server"],
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.list_tools()
|
|
144
|
+
async def list_tools() -> list[Tool]:
|
|
145
|
+
return TOOLS
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.call_tool()
|
|
149
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
150
|
+
start = time.monotonic()
|
|
151
|
+
|
|
152
|
+
if name == "add_server":
|
|
153
|
+
result = await _add_server(arguments)
|
|
154
|
+
elif name == "list_servers":
|
|
155
|
+
result = await _list_servers()
|
|
156
|
+
elif name == "remove_server":
|
|
157
|
+
result = await _remove_server(arguments)
|
|
158
|
+
elif name == "run_command":
|
|
159
|
+
result = await _run_command(arguments)
|
|
160
|
+
elif name == "run_all":
|
|
161
|
+
result = await _run_all(arguments)
|
|
162
|
+
elif name == "deploy_file":
|
|
163
|
+
result = await _deploy_file(arguments)
|
|
164
|
+
elif name == "check_status":
|
|
165
|
+
result = await _check_status(arguments)
|
|
166
|
+
else:
|
|
167
|
+
result = f"Unknown tool: {name}"
|
|
168
|
+
|
|
169
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
170
|
+
server_name = arguments.get("server", arguments.get("name", "*"))
|
|
171
|
+
append_audit(name, str(server_name), _sanitize_args(arguments), result[:200], duration_ms)
|
|
172
|
+
return [TextContent(type="text", text=result)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def _add_server(args: dict[str, Any]) -> str:
|
|
176
|
+
name = args.get("name", "")
|
|
177
|
+
if find_server_by_name(name):
|
|
178
|
+
return f"Server name '{name}' already exists. Use a different name."
|
|
179
|
+
|
|
180
|
+
auth = args.get("authMethod", "password")
|
|
181
|
+
if auth == "password" and not args.get("password"):
|
|
182
|
+
return "Password is required when authMethod is 'password'."
|
|
183
|
+
if auth == "private_key" and not args.get("privateKeyPath") and not args.get("privateKey"):
|
|
184
|
+
return "privateKeyPath or privateKey is required when authMethod is 'private_key'."
|
|
185
|
+
|
|
186
|
+
entry = ServerEntry(
|
|
187
|
+
id=str(uuid.uuid4()),
|
|
188
|
+
name=name,
|
|
189
|
+
host=args.get("host", ""),
|
|
190
|
+
port=args.get("port", 22),
|
|
191
|
+
username=args.get("username", "root"),
|
|
192
|
+
auth_method=auth,
|
|
193
|
+
password=args.get("password"),
|
|
194
|
+
private_key_path=args.get("privateKeyPath"),
|
|
195
|
+
private_key=args.get("privateKey"),
|
|
196
|
+
)
|
|
197
|
+
add_server(entry)
|
|
198
|
+
return f"Server '{name}' added ({entry.username}@{entry.host}:{entry.port}). ID: {entry.id}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _list_servers() -> str:
|
|
202
|
+
servers = load_servers()
|
|
203
|
+
if not servers:
|
|
204
|
+
return "No servers registered. Use add_server to add one."
|
|
205
|
+
lines = []
|
|
206
|
+
for s in servers:
|
|
207
|
+
auth = "password" if s.auth_method == "password" else f"key: {s.private_key_path or 'inline'}"
|
|
208
|
+
lines.append(f"- {s.name} → {s.username}@{s.host}:{s.port} [{auth}] (id: {s.id})")
|
|
209
|
+
return "\n".join(lines)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def _remove_server(args: dict[str, Any]) -> str:
|
|
213
|
+
entry = _resolve_server(args.get("server", ""))
|
|
214
|
+
if not entry:
|
|
215
|
+
return f"Server '{args.get('server')}' not found."
|
|
216
|
+
close_connection(entry.id)
|
|
217
|
+
removed = remove_server(entry.id)
|
|
218
|
+
return f"Server '{args.get('server')}' removed." if removed else "Failed to remove."
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _run_command(args: dict[str, Any]) -> str:
|
|
222
|
+
entry = _resolve_server(args.get("server", ""))
|
|
223
|
+
if not entry:
|
|
224
|
+
return f"Server '{args.get('server')}' not found. Use list_servers."
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
conn = await get_connection(entry)
|
|
228
|
+
result = await exec_command(conn, args["command"], args.get("timeoutSec", 30))
|
|
229
|
+
output = [
|
|
230
|
+
f"Server: {entry.name} ({entry.host})",
|
|
231
|
+
f"Command: {args['command']}",
|
|
232
|
+
f"Exit code: {result['exit_code']}",
|
|
233
|
+
]
|
|
234
|
+
if result["stdout"]:
|
|
235
|
+
output.append(f"\nSTDOUT:\n{result['stdout'].strip()}")
|
|
236
|
+
if result["stderr"]:
|
|
237
|
+
output.append(f"\nSTDERR:\n{result['stderr'].strip()}")
|
|
238
|
+
return "\n".join(output)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
return f"Error: {e}"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def _run_all(args: dict[str, Any]) -> str:
|
|
244
|
+
servers = load_servers()
|
|
245
|
+
if not servers:
|
|
246
|
+
return "No servers registered."
|
|
247
|
+
|
|
248
|
+
results = [f"Command on all {len(servers)} server(s): {args['command']}"]
|
|
249
|
+
for entry in servers:
|
|
250
|
+
try:
|
|
251
|
+
conn = await get_connection(entry)
|
|
252
|
+
result = await exec_command(conn, args["command"], args.get("timeoutSec", 30))
|
|
253
|
+
status = "OK" if result["exit_code"] == 0 else f"FAILED ({result['exit_code']})"
|
|
254
|
+
results.append(f"{entry.name}: {status}")
|
|
255
|
+
if result["stdout"]:
|
|
256
|
+
results.append(f" stdout: {result['stdout'].strip()[:200]}")
|
|
257
|
+
except Exception as e:
|
|
258
|
+
results.append(f"{entry.name}: ERROR — {e}")
|
|
259
|
+
return "\n".join(results)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _deploy_file(args: dict[str, Any]) -> str:
|
|
263
|
+
entry = _resolve_server(args.get("server", ""))
|
|
264
|
+
if not entry:
|
|
265
|
+
return f"Server '{args.get('server')}' not found."
|
|
266
|
+
|
|
267
|
+
results = [f"Server: {entry.name}"]
|
|
268
|
+
try:
|
|
269
|
+
conn = await get_connection(entry)
|
|
270
|
+
for f in args.get("files", []):
|
|
271
|
+
if "localPath" in f:
|
|
272
|
+
await upload_file(conn, f["localPath"], f["remotePath"])
|
|
273
|
+
results.append(f"Uploaded {f['localPath']} → {f['remotePath']}")
|
|
274
|
+
elif "content" in f:
|
|
275
|
+
await upload_content(conn, f["content"], f["remotePath"])
|
|
276
|
+
results.append(f"Wrote {f['remotePath']} ({len(f['content'])} chars)")
|
|
277
|
+
|
|
278
|
+
remote_dir = args.get("remoteDir", "/tmp")
|
|
279
|
+
for cmd in args.get("commands", []):
|
|
280
|
+
try:
|
|
281
|
+
result = await exec_command(conn, f"cd {remote_dir} && {cmd}", 60)
|
|
282
|
+
status = "OK" if result["exit_code"] == 0 else f"FAILED ({result['exit_code']})"
|
|
283
|
+
results.append(f"$ {cmd} → {status}")
|
|
284
|
+
if result["stderr"]:
|
|
285
|
+
results.append(f" stderr: {result['stderr'].strip()[:500]}")
|
|
286
|
+
except Exception as e:
|
|
287
|
+
results.append(f"$ {cmd} → ERROR: {e}")
|
|
288
|
+
except Exception as e:
|
|
289
|
+
results.append(f"Error: {e}")
|
|
290
|
+
|
|
291
|
+
return "\n".join(results)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def _check_status(args: dict[str, Any]) -> str:
|
|
295
|
+
entry = _resolve_server(args.get("server", ""))
|
|
296
|
+
if not entry:
|
|
297
|
+
return f"Server '{args.get('server')}' not found."
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
conn = await get_connection(entry)
|
|
301
|
+
result = await exec_command(conn, "echo ok && uname -a && whoami", 5)
|
|
302
|
+
return f"{entry.name}: connected\n{result['stdout'].strip()}"
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return f"{args.get('server')}: connection failed — {e}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def main() -> None:
|
|
308
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
309
|
+
servers = load_servers()
|
|
310
|
+
import sys
|
|
311
|
+
print(
|
|
312
|
+
f"MCP Deploy Server v0.3.0 (stdio) | {len(servers)} server(s) registered | config: {get_config_dir()}",
|
|
313
|
+
file=sys.stderr,
|
|
314
|
+
)
|
|
315
|
+
await app.run(read_stream, write_stream, app.create_initialization_options())
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
import asyncio
|
|
320
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Data models for server configuration and audit entries."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ServerEntry(BaseModel):
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
host: str
|
|
15
|
+
port: int = 22
|
|
16
|
+
username: str = "root"
|
|
17
|
+
auth_method: str = Field(..., alias="authMethod", pattern="^(password|private_key)$")
|
|
18
|
+
password: Optional[str] = None
|
|
19
|
+
private_key_path: Optional[str] = Field(None, alias="privateKeyPath")
|
|
20
|
+
private_key: Optional[str] = Field(None, alias="privateKey")
|
|
21
|
+
|
|
22
|
+
model_config = {"populate_by_name": True}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuditEntry(BaseModel):
|
|
26
|
+
timestamp: str
|
|
27
|
+
tool_name: str
|
|
28
|
+
server_name: str
|
|
29
|
+
args_summary: str
|
|
30
|
+
result_summary: str
|
|
31
|
+
duration_ms: Optional[int] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
CONFIG_DIR = Path.home() / ".mcp-deploy"
|
|
35
|
+
SERVERS_FILE = CONFIG_DIR / "servers.json"
|
|
36
|
+
AUDIT_FILE = CONFIG_DIR / "audit.log"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_config_dir() -> None:
|
|
40
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_servers() -> list[ServerEntry]:
|
|
44
|
+
ensure_config_dir()
|
|
45
|
+
if not SERVERS_FILE.exists():
|
|
46
|
+
return []
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(SERVERS_FILE.read_text("utf-8"))
|
|
49
|
+
return [ServerEntry(**s) for s in data.get("servers", [])]
|
|
50
|
+
except Exception:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save_servers(servers: list[ServerEntry]) -> None:
|
|
55
|
+
ensure_config_dir()
|
|
56
|
+
SERVERS_FILE.write_text(
|
|
57
|
+
json.dumps({"servers": [s.model_dump(by_alias=True, exclude_none=True) for s in servers]}, indent=2),
|
|
58
|
+
"utf-8",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_server(server_id: str) -> Optional[ServerEntry]:
|
|
63
|
+
return next((s for s in load_servers() if s.id == server_id), None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_server_by_name(name: str) -> Optional[ServerEntry]:
|
|
67
|
+
return next((s for s in load_servers() if s.name == name), None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def add_server(entry: ServerEntry) -> None:
|
|
71
|
+
servers = load_servers()
|
|
72
|
+
servers.append(entry)
|
|
73
|
+
save_servers(servers)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def remove_server(server_id: str) -> bool:
|
|
77
|
+
servers = load_servers()
|
|
78
|
+
filtered = [s for s in servers if s.id != server_id]
|
|
79
|
+
if len(filtered) == len(servers):
|
|
80
|
+
return False
|
|
81
|
+
save_servers(filtered)
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_config_dir() -> str:
|
|
86
|
+
return str(CONFIG_DIR)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def append_audit(
|
|
90
|
+
tool_name: str,
|
|
91
|
+
server_name: str,
|
|
92
|
+
args_summary: str,
|
|
93
|
+
result_summary: str,
|
|
94
|
+
duration_ms: int | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
ensure_config_dir()
|
|
97
|
+
entry = AuditEntry(
|
|
98
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
99
|
+
tool_name=tool_name,
|
|
100
|
+
server_name=server_name,
|
|
101
|
+
args_summary=args_summary,
|
|
102
|
+
result_summary=result_summary,
|
|
103
|
+
duration_ms=duration_ms,
|
|
104
|
+
)
|
|
105
|
+
with open(AUDIT_FILE, "a") as f:
|
|
106
|
+
f.write(entry.model_dump_json(exclude_none=True) + "\n")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Async SSH connection manager with connection pooling per server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import asyncssh
|
|
8
|
+
|
|
9
|
+
from .models import ServerEntry
|
|
10
|
+
|
|
11
|
+
_connections: dict[str, asyncssh.SSHClientConnection] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _build_connect_kwargs(server: ServerEntry) -> dict[str, Any]:
|
|
15
|
+
kwargs: dict[str, Any] = {
|
|
16
|
+
"host": server.host,
|
|
17
|
+
"port": server.port,
|
|
18
|
+
"username": server.username,
|
|
19
|
+
"known_hosts": None,
|
|
20
|
+
"connect_timeout": 10,
|
|
21
|
+
}
|
|
22
|
+
if server.auth_method == "password" and server.password:
|
|
23
|
+
kwargs["password"] = server.password
|
|
24
|
+
elif server.auth_method == "private_key":
|
|
25
|
+
if server.private_key_path:
|
|
26
|
+
kwargs["client_keys"] = [server.private_key_path]
|
|
27
|
+
elif server.private_key:
|
|
28
|
+
kwargs["client_keys"] = [(asyncssh.import_private_key(server.private_key))]
|
|
29
|
+
return kwargs
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def get_connection(server: ServerEntry) -> asyncssh.SSHClientConnection:
|
|
33
|
+
"""Get or create a pooled SSH connection for a server."""
|
|
34
|
+
conn = _connections.get(server.id)
|
|
35
|
+
if conn is not None:
|
|
36
|
+
try:
|
|
37
|
+
# Liveness check
|
|
38
|
+
result = await conn.run("true", check=False)
|
|
39
|
+
if result.exit_code == 0:
|
|
40
|
+
return conn
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
# Stale connection, remove
|
|
44
|
+
_connections.pop(server.id, None)
|
|
45
|
+
try:
|
|
46
|
+
conn.close()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
kwargs = _build_connect_kwargs(server)
|
|
51
|
+
conn = await asyncssh.connect(**kwargs)
|
|
52
|
+
_connections[server.id] = conn
|
|
53
|
+
return conn
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def close_connection(server_id: str) -> None:
|
|
57
|
+
"""Close a specific server connection."""
|
|
58
|
+
conn = _connections.pop(server_id, None)
|
|
59
|
+
if conn:
|
|
60
|
+
conn.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def close_all_connections() -> None:
|
|
64
|
+
"""Close all pooled connections."""
|
|
65
|
+
for conn in _connections.values():
|
|
66
|
+
conn.close()
|
|
67
|
+
_connections.clear()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def exec_command(
|
|
71
|
+
conn: asyncssh.SSHClientConnection,
|
|
72
|
+
command: str,
|
|
73
|
+
timeout_sec: int = 30,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""Execute a command and return exit_code, stdout, stderr."""
|
|
76
|
+
try:
|
|
77
|
+
result = await asyncio.wait_for(
|
|
78
|
+
conn.run(command, check=False),
|
|
79
|
+
timeout=timeout_sec,
|
|
80
|
+
)
|
|
81
|
+
return {
|
|
82
|
+
"exit_code": result.exit_code or 0,
|
|
83
|
+
"stdout": result.stdout or "",
|
|
84
|
+
"stderr": result.stderr or "",
|
|
85
|
+
}
|
|
86
|
+
except asyncio.TimeoutError:
|
|
87
|
+
raise TimeoutError(f"Command timed out after {timeout_sec}s")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def upload_content(
|
|
91
|
+
conn: asyncssh.SSHClientConnection,
|
|
92
|
+
content: str,
|
|
93
|
+
remote_path: str,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Write content to a remote file via SFTP."""
|
|
96
|
+
async with conn.start_sftp_client() as sftp:
|
|
97
|
+
async with sftp.open(remote_path, "w") as f:
|
|
98
|
+
await f.write(content)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def upload_file(
|
|
102
|
+
conn: asyncssh.SSHClientConnection,
|
|
103
|
+
local_path: str,
|
|
104
|
+
remote_path: str,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Upload a local file to a remote path via SFTP."""
|
|
107
|
+
async with conn.start_sftp_client() as sftp:
|
|
108
|
+
await sftp.put(local_path, remote_path)
|