use super::TerminalMcpService;
use crate::protocol::params::{ControlParams, MoveCursorParams, OutputParams, ResizeParams, SendKeysParams, SendParams, SnapshotParams, TagParams, WaitForParams};
use crate::{audit_extra, config};
use rmcp::{handler::server::wrapper::Parameters, tool, tool_router};
use shell_engine::util::strip_ansi_codes;
use std::time::Duration;
use shell_engine::shell::Key;
use crate::security::audit;
#[tool_router(router = io_tool_router, vis = "pub(crate)")]
impl TerminalMcpService {
#[tool(description = "Send content to the specified interactive shell (without appending a newline)")]
async fn shell_send(&self, Parameters(SendParams { input, tag }): Parameters<SendParams>) -> String {
let audit_tag = tag.clone();
let audit_input = input.clone();
audit::with_audit(
"shell_send",
audit_extra!(audit_tag, audit_input),
|| async move {
let shell = self.registry.get(&tag)?;
shell.lock().await.send(&input).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!("sent"))
},
)
.await
}
#[tool(description = "Send content to the specified interactive shell and append a newline (equivalent to pressing Enter)")]
async fn shell_send_line(&self, Parameters(SendParams { input, tag }): Parameters<SendParams>) -> String {
let audit_tag = tag.clone();
let audit_input = input.clone();
audit::with_audit(
"shell_send_line",
audit_extra!(audit_tag, audit_input),
|| async move {
let shell = self.registry.get(&tag)?;
shell.lock().await.send_line(&input).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!("sent"))
},
)
.await
}
#[tool(description = "Gets the output of the specified interactive shell (including stdout and stderr. This consumes the buffer and does not return what was once returned).")]
async fn shell_output(
&self,
Parameters(OutputParams { tag, idle_ms }): Parameters<OutputParams>,
) -> String {
let audit_tag = tag.clone();
audit::with_audit("shell_output", audit_extra!(audit_tag), || async move {
let shell = self.registry.get(&tag)?;
let idle = Some(Duration::from_millis(idle_ms.unwrap_or(config::OUTPUT_IDLE_MS)));
let mut guard = shell.lock().await;
let result = guard.output(idle, None).await;
drop(guard);
let stdout = strip_ansi_codes(&result.stdout);
let stderr = strip_ansi_codes(&result.stderr);
Ok(serde_json::json!({ "stdout": stdout, "stderr": stderr }))
})
.await
}
#[tool(description = "Block and wait until `pattern` appears in stdout/stderr of the specified session, \
or until `timeout_ms` elapses (default 5000), then return everything collected so far. \
Suitable for commands with uncertain completion time (gdb continue/run hitting a breakpoint, \
yes/password prompts during ssh login, long-running task completion markers, etc.). Compared to \
repeatedly calling shell_output(idle_ms=...) and manually guessing the wait time, this significantly \
reduces the number of interaction turns.(This consumes the buffer and does not return what was once returned)")]
async fn shell_wait_for(
&self,
Parameters(WaitForParams { tag, substring, timeout_ms }): Parameters<WaitForParams>,
) -> String {
let audit_tag = tag.clone();
let audit_substring = substring.clone();
audit::with_audit(
"shell_wait_for",
audit_extra!(audit_tag, audit_substring),
|| async move {
let shell = self.registry.get(&tag)?;
let timeout = Duration::from_millis(timeout_ms.unwrap_or(config::WAIT_FOR_TIMEOUT_MS));
let mut guard = shell.lock().await;
let result = guard.output_until(substring.clone(), Some(timeout)).await;
drop(guard);
let stdout = strip_ansi_codes(&result.stdout);
let stderr = strip_ansi_codes(&result.stderr);
let matched = stdout.contains(&substring) || stderr.contains(&substring);
Ok(serde_json::json!({
"stdout": stdout,
"stderr": stderr,
"matched": matched,
}))
},
)
.await
}
}
#[tool_router(router = pty_tool_router, vis = "pub(crate)")]
impl TerminalMcpService {
#[tool(description = "Send a standard terminal control character to the specified interactive shell \
(e.g. key=\"C\" for Ctrl+C/interrupt, \"D\" for Ctrl+D/EOF, \"Z\" for Ctrl+Z/suspend, \"?\" for DEL). \
Clearer and safer than embedding raw control bytes or \"^C\"-style strings inside shell_send/shell_send_line. \
In pty mode this is translated to the corresponding standard control byte; in pipe (non-pty) mode only \
two special semantics are preserved: R = reset the session (equivalent to shell_reset), D = send EOF \
(close stdin).")]
async fn shell_send_control(
&self,
Parameters(ControlParams { tag, key }): Parameters<ControlParams>,
) -> String {
let audit_tag = tag.clone();
let audit_key = key.clone();
audit::with_audit(
"shell_send_control",
audit_extra!(audit_tag, audit_key),
|| async move {
let ch = key
.trim()
.chars()
.next()
.ok_or_else(|| "key must not be empty".to_string())?;
let shell = self.registry.get(&tag)?;
shell.lock().await.send_control_char(ch).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!("sent"))
},
)
.await
}
#[tool(description = "Send an ordered sequence of literal text and/or special keys \
(arrow keys, Home/End, PageUp/PageDown, Insert/Delete, Tab/BackTab, Enter/Escape/Backspace, \
F1-F12) to the specified session as a single burst. Use this instead of embedding raw ANSI \
escape bytes in shell_send when you need to: recall shell history (Up/Down), move within / edit \
the current input line (Left/Right/Home/End/Delete/Backspace), trigger tab-completion (Tab), \
answer arrow-key-driven menus/wizards (whiptail/dialog-style), or drive a full-screen TUI \
program (vim/htop/less/menuconfig, etc.) together with shell_snapshot — see guide://shell/tui \
for the required workflow. Unknown bracket-tagged keys (e.g. a typo like \"[Upp]\") return an \
explicit error instead of being silently sent as literal text. After sending, ALWAYS use \
shell_snapshot (pty mode) or shell_output (pipe mode) to confirm the result before deciding the \
next step — never chain many key-sends assuming you already know what the screen will look like \
several steps ahead.")]
async fn shell_send_keys(
&self,
Parameters(SendKeysParams { tag, keys }): Parameters<SendKeysParams>,
) -> String {
let audit_tag = tag.clone();
let audit_input = keys.join(" ");
audit::with_audit(
"shell_send_keys",
audit_extra!(audit_tag, audit_input),
|| async move {
let shell = self.registry.get(&tag)?;
let seq = keys.into_iter().map(Key::StringChar).collect();
shell.lock().await.send_keys(seq).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!("sent"))
},
)
.await
}
#[tool(description = "Gets a snapshot of the rendered virtual terminal screen along with the current cursor position for the specified session. Returns `{ \"screen\": \"...\", \"cursor\": {\"row\":.., \"col\":..} }` after interpreting the cursor movement/screen clear/color control sequence, rather than the raw byte stream. Suitable for use with progress bars, selection menus and tui interfaces. If shell_output|shell_wait_for has no output, this method can be used to confirm the terminal status.")]
async fn shell_snapshot(
&self,
Parameters(SnapshotParams { tag, wait_ms }): Parameters<SnapshotParams>,
) -> String {
let audit_tag = tag.clone();
audit::with_audit("shell_snapshot", audit_extra!(audit_tag), || async move {
let shell = self.registry.get(&tag)?;
let wait = Some(Duration::from_millis(wait_ms.unwrap_or(config::SNAPSHOT_WAIT_MS)));
let mut guard = shell.lock().await;
let screen = guard.output_snapshot(None, wait).await.map_err(|e| e.to_string())?;
let cursor = guard
.cursor_position()
.ok()
.map(|(row, col)| serde_json::json!({ "row": row, "col": col }));
drop(guard);
Ok(serde_json::json!({ "screen": screen, "cursor": cursor }))
})
.await
}
#[tool(description = "Get the current cursor position (row, col; 0-based, vt100 convention) on \
the rendered virtual terminal screen of the specified session \
Cheaper than shell_snapshot when you only need to know where the input caret / menu selection \
indicator currently sits, without pulling the full screen text.")]
async fn shell_cursor_position(
&self,
Parameters(TagParams { tag }): Parameters<TagParams>,
) -> String {
let audit_tag = tag.clone();
audit::with_audit("shell_cursor_position", audit_extra!(audit_tag), || async move {
let shell = self.registry.get(&tag)?;
let guard = shell.lock().await;
let (row, col) = guard.cursor_position().map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "row": row, "col": col }))
})
.await
}
#[tool(description = "Move the terminal cursor of the specified session to an absolute (row, col) \
position via a standard ANSI CUP escape sequence. Coordinates \
are 1-based (note: this differs from shell_cursor_position/shell_snapshot's 0-based cursor output \
— add 1 to reuse those values here). Only affects where subsequently sent characters land; it does \
not by itself trigger program behavior unless the running program itself reads cursor-addressed \
input (some full-screen TUI programs do).")]
async fn shell_move_cursor(
&self,
Parameters(MoveCursorParams { tag, row, col }): Parameters<MoveCursorParams>,
) -> String {
let audit_tag = tag.clone();
audit::with_audit("shell_move_cursor", audit_extra!(audit_tag, row, col), || async move {
let shell = self.registry.get(&tag)?;
let mut guard = shell.lock().await;
guard.move_cursor_to(row, col).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!("moved"))
})
.await
}
#[tool(description = "Dynamically resize the PTY window of an already-running session \
without losing session state (no need to re-spawn). Use this when a \
column/row-sensitive program (pagers, progress bars, table renderers, full-screen TUI programs) \
needs a different terminal size mid-session. cols/rows must be >= 1.")]
async fn shell_resize(
&self,
Parameters(ResizeParams { tag, cols, rows }): Parameters<ResizeParams>,
) -> String {
let audit_tag = tag.clone();
audit::with_audit("shell_resize", audit_extra!(audit_tag, cols, rows), || async move {
if cols == 0 || rows == 0 {
return Err("cols and rows must be >= 1".to_string());
}
let shell = self.registry.get(&tag)?;
let mut guard = shell.lock().await;
guard.resize(cols, rows).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "cols": cols, "rows": rows }))
})
.await
}
}