feat: 31 MCP tools — ssh_connect for autonomous AI operation
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 4m12s

The AI can now open its own SSH sessions without the Commander
pre-opening them:

- ssh_connect(hostname, username, password?, private_key_path?, port?)
  Returns session_id for use with all other tools
  Supports password auth and SSH key file auth

Also added app_handle and error_watcher to MCP server state so
new sessions get full scrollback, monitoring, and CWD tracking.

This completes the autonomy loop: the AI discovers what's available
(list_sessions), connects to what it needs (ssh_connect), operates
(terminal_execute, docker_ps, sftp_read), and disconnects when done.

Total MCP tools: 31.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-26 17:04:53 -04:00
parent 5aaedbe4a5
commit f578c434df
3 changed files with 63 additions and 2 deletions

View File

@ -279,6 +279,17 @@ fn handle_tools_list(id: Value) -> JsonRpcResponse {
"description": "Set the clipboard content on a remote RDP session",
"inputSchema": { "type": "object", "properties": { "session_id": { "type": "string" }, "text": { "type": "string" } }, "required": ["session_id", "text"] }
},
{
"name": "ssh_connect",
"description": "Open a new SSH connection through Wraith. Returns the session ID for use with other tools.",
"inputSchema": { "type": "object", "properties": {
"hostname": { "type": "string" },
"port": { "type": "number", "description": "Default: 22" },
"username": { "type": "string" },
"password": { "type": "string", "description": "Password (for password auth)" },
"private_key_path": { "type": "string", "description": "Path to SSH private key file on the local machine" }
}, "required": ["hostname", "username"] }
},
{
"name": "list_sessions",
"description": "List all active Wraith sessions (SSH, RDP, PTY) with connection details",
@ -347,6 +358,7 @@ fn handle_tool_call(id: Value, port: u16, tool_name: &str, args: &Value) -> Json
"rdp_click" => call_wraith(port, "/mcp/rdp/click", args.clone()),
"rdp_type" => call_wraith(port, "/mcp/rdp/type", args.clone()),
"rdp_clipboard" => call_wraith(port, "/mcp/rdp/clipboard", args.clone()),
"ssh_connect" => call_wraith(port, "/mcp/ssh/connect", args.clone()),
"terminal_screenshot" => {
let result = call_wraith(port, "/mcp/screenshot", args.clone());
// Screenshot returns base64 PNG — wrap as image content for multimodal AI

View File

@ -155,7 +155,9 @@ pub fn run() {
let _ = write_log(&log_file, "Setup: cloned services OK");
// Error watcher — std::thread, no tokio needed
let watcher_for_mcp = watcher.clone();
let app_handle = app.handle().clone();
let app_handle_for_mcp = app.handle().clone();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mcp::error_watcher::start_error_watcher(watcher, scrollback.clone(), app_handle);
}));
@ -165,7 +167,7 @@ pub fn run() {
let log_file2 = log_file.clone();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
tauri::async_runtime::spawn(async move {
match mcp::server::start_mcp_server(ssh, rdp, sftp, scrollback).await {
match mcp::server::start_mcp_server(ssh, rdp, sftp, scrollback, app_handle_for_mcp, watcher_for_mcp).await {
Ok(port) => { let _ = write_log(&log_file2, &format!("MCP server started on localhost:{}", port)); }
Err(e) => { let _ = write_log(&log_file2, &format!("MCP server FAILED: {}", e)); }
}

View File

@ -20,6 +20,8 @@ pub struct McpServerState {
pub rdp: RdpService,
pub sftp: SftpService,
pub scrollback: ScrollbackRegistry,
pub app_handle: tauri::AppHandle,
pub error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
}
#[derive(Deserialize)]
@ -432,6 +434,48 @@ async fn handle_git_log(AxumState(state): AxumState<Arc<McpServerState>>, Json(r
match tool_exec(&session.handle, &format!("cd {} && git log --oneline -20 2>&1", req.path)).await { Ok(o) => ok_response(o), Err(e) => err_response(e) }
}
// ── Session creation handlers ────────────────────────────────────────────────
#[derive(Deserialize)]
struct SshConnectRequest {
hostname: String,
port: Option<u16>,
username: String,
password: Option<String>,
private_key_path: Option<String>,
}
async fn handle_ssh_connect(AxumState(state): AxumState<Arc<McpServerState>>, Json(req): Json<SshConnectRequest>) -> Json<McpResponse<String>> {
use crate::ssh::session::AuthMethod;
let port = req.port.unwrap_or(22);
let auth = if let Some(key_path) = req.private_key_path {
// Read key file
let pem = match std::fs::read_to_string(&key_path) {
Ok(p) => p,
Err(e) => return err_response(format!("Failed to read key file {}: {}", key_path, e)),
};
AuthMethod::Key { private_key_pem: pem, passphrase: req.password }
} else {
AuthMethod::Password(req.password.unwrap_or_default())
};
match state.ssh.connect(
state.app_handle.clone(),
&req.hostname,
port,
&req.username,
auth,
120, 40,
&state.sftp,
&state.scrollback,
&state.error_watcher,
).await {
Ok(session_id) => ok_response(session_id),
Err(e) => err_response(e),
}
}
// ── RDP interaction handlers ─────────────────────────────────────────────────
#[derive(Deserialize)]
@ -476,8 +520,10 @@ pub async fn start_mcp_server(
rdp: RdpService,
sftp: SftpService,
scrollback: ScrollbackRegistry,
app_handle: tauri::AppHandle,
error_watcher: std::sync::Arc<crate::mcp::error_watcher::ErrorWatcher>,
) -> Result<u16, String> {
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback });
let state = Arc::new(McpServerState { ssh, rdp, sftp, scrollback, app_handle, error_watcher });
let app = Router::new()
.route("/mcp/sessions", post(handle_list_sessions))
@ -510,6 +556,7 @@ pub async fn start_mcp_server(
.route("/mcp/rdp/click", post(handle_rdp_click))
.route("/mcp/rdp/type", post(handle_rdp_type))
.route("/mcp/rdp/clipboard", post(handle_rdp_clipboard))
.route("/mcp/ssh/connect", post(handle_ssh_connect))
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await