MCP polish + SSH host manager Connect button

Three small things bundled from PR-3 verification:

1. Split SSH out of mcp.spawn_pane schema. New McpSpawnSpec enum
   (Wsl | Powershell only) used for SpawnPaneArgs, so Claude's
   spawn_pane tool description and JSON schema show only the local
   shells. SSH must go through connect_host. The internal
   pty::SpawnSpec is unchanged — the frontend's manual spawn path
   via XtermPane still supports all three variants. Previously
   spawn_pane(kind=ssh) was a half-broken path that required `host`
   as a separate mandatory field even when hostId was given;
   serde-rejected the natural "spawn to a saved host" call shape.

2. Refresh the MCP server's `with_instructions` text and the
   module-level header comment. Both still claimed "read-only v1"
   long after the v2 write surface landed, which was making Claude
   refuse to attempt tools on first contact ("the server has
   flagged itself as read-only..."). The instructions now describe
   the actual tool set, the SSH-via-connect_host convention, and
   the policy/safeguards gates so Claude doesn't have to infer.

3. Add a "Connect" button to the SSH hosts manager. Previously
   the dialog only had Edit — users (rightly) expected clicking a
   saved host to spawn an SSH pane to it. New onConnect callback
   does the splitLeaf + smart-orient dance and closes the manager.
   Buttons wrapped in a flex container so the row's
   space-between layout doesn't strand the new button mid-row.
This commit is contained in:
megaproxy 2026-05-26 15:20:22 +01:00
parent bf2810a433
commit 6da7523993
4 changed files with 144 additions and 52 deletions

View file

@ -1,11 +1,21 @@
//! Embedded MCP server. Lets a Claude session running anywhere on the
//! same machine — including inside one of tiletopia's own panes — inspect
//! the workspace via Model Context Protocol.
//! and drive the workspace via Model Context Protocol.
//!
//! V1 surface (read-only):
//! resources: tiletopia://layout, tiletopia://panes, tiletopia://hosts
//! tools: read_pane(leaf_id, last_lines?, after_seq?)
//! wait_for_idle(leaf_id, idle_ms?, timeout_ms?)
//! Resources (read-only):
//! tiletopia://layout, tiletopia://panes, tiletopia://hosts
//!
//! Read tools:
//! read_pane(leaf_id, last_lines?, after_seq?)
//! wait_for_idle(leaf_id, idle_ms?, timeout_ms?)
//!
//! Write tools (all go through dispatch_action → user policy → confirm
//! modal → audit):
//! set_label, close_pane, swap_panes, promote_pane, apply_preset
//! spawn_pane (local WSL / PowerShell only)
//! connect_host (SSH to a saved host id — the only SSH path)
//! write_pane (rate-limited per pane; matched against a non-overridable
//! hard-deny list before user policy)
//!
//! Per-pane `mcpAllow` gate (default-deny) lives in the frontend tree;
//! the frontend mirrors the gated subset into {@link McpState} via the
@ -366,11 +376,27 @@ pub struct ApplyPresetArgs {
pub allow_drops: bool,
}
/// MCP-facing spawn spec — same shape as pty::SpawnSpec but without the
/// Ssh variant. Claude's spawn_pane only opens local shells; SSH goes
/// through the dedicated connect_host tool which takes a host_id and
/// handles the lookup. Two clearly-scoped tools beats one tool with a
/// half-broken SSH path.
#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum McpSpawnSpec {
Wsl {
distro: Option<String>,
cwd: Option<String>,
},
Powershell,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SpawnPaneArgs {
/// What shell to run in the new pane: WSL distro, PowerShell, or a
/// saved SSH host (or arbitrary ssh args).
pub spec: crate::pty::SpawnSpec,
/// What local shell to run in the new pane: WSL distro or PowerShell.
/// For SSH connections to saved hosts, use the connect_host tool
/// instead — it takes a host_id and looks the rest up for you.
pub spec: McpSpawnSpec,
/// Where to insert the new pane. Defaults to the active pane, or the
/// workspace root if no pane is active. The parent leaf must be
/// MCP-allowed for Claude to target it.
@ -801,30 +827,18 @@ impl TileService {
Ok(CallToolResult::success(vec![Content::text("ok")]))
}
#[tool(description = "Spawn a new pane next to an existing one, running \
the requested shell (WSL distro / PowerShell / SSH). The new pane is \
auto-allowed for MCP so Claude can immediately read its scrollback \
and send it keystrokes (subject to policy). Returns {leafId, paneId} \
for the newly created pane. Times out after ~15 seconds if the \
PTY can't be spawned (covers a cold WSL distro start).")]
#[tool(description = "Spawn a new local-shell pane next to an existing \
one WSL distro or PowerShell. The new pane is auto-allowed for \
MCP so Claude can immediately read its scrollback and send it \
keystrokes (subject to policy). Returns {leafId, paneId} for the \
newly created pane. Times out after ~15 seconds if the PTY can't \
be spawned (covers a cold WSL distro start). \
FOR SSH: use connect_host(host_id) instead it looks the host up \
from the saved-hosts list for you.")]
async fn spawn_pane(
&self,
Parameters(args): Parameters<SpawnPaneArgs>,
) -> Result<CallToolResult, McpError> {
// SSH safeguard: refuse before any other work if the user hasn't
// opted in to MCP-initiated SSH connections.
if matches!(args.spec, crate::pty::SpawnSpec::Ssh { .. })
&& !self.policy_ssh_open_allowed().await
{
return Err(McpError::invalid_params(
"ssh-disabled: Claude is not allowed to open SSH connections \
(Policy tab SSH safeguards 'Allow Claude to open SSH \
connections'). Open the SSH session manually via the \
titlebar 🔑 picker first, then ask Claude to interact with \
it.",
None,
));
}
// If a parent is named, it must be MCP-allowed. (If omitted, the
// frontend picks the active pane or root — no explicit check here
// since the user already approved an MCP-server-running state.)
@ -835,14 +849,13 @@ impl TileService {
// reconstruct it without us doing the per-kind dispatch here.
let spec_json = serde_json::to_value(&args.spec).map_err(|e| {
McpError::internal_error(
format!("serialize SpawnSpec: {e}"),
format!("serialize spec: {e}"),
None,
)
})?;
let kind_str = match &args.spec {
crate::pty::SpawnSpec::Wsl { .. } => "wsl",
crate::pty::SpawnSpec::Powershell => "powershell",
crate::pty::SpawnSpec::Ssh { .. } => "ssh",
McpSpawnSpec::Wsl { .. } => "wsl",
McpSpawnSpec::Powershell => "powershell",
};
let args_repr = format!(
"shell={} parent={} orientation={}",
@ -1083,10 +1096,27 @@ impl ServerHandler for TileService {
.with_server_info(Implementation::from_build_env())
.with_protocol_version(ProtocolVersion::V_2024_11_05)
.with_instructions(
"Tiletopia MCP (read-only v1). Resources: tiletopia://layout, \
tiletopia://panes, tiletopia://hosts. Tools: read_pane, \
wait_for_idle. Only panes the user has allow-listed are \
visible.",
"Tiletopia MCP — drive a multi-pane terminal workspace.\n\
\n\
Resources (read): tiletopia://layout, tiletopia://panes, \
tiletopia://hosts.\n\
\n\
Read tools: read_pane (scrollback), wait_for_idle (block \
until a pane goes quiet).\n\
\n\
Write tools (subject to user policy + confirm modal):\n\
- set_label, close_pane, swap_panes, promote_pane, \
apply_preset tree shape and metadata.\n\
- spawn_pane (local WSL/PowerShell), connect_host (SSH to a \
saved host use this for SSH, not spawn_pane).\n\
- write_pane (send keystrokes; rate-limited; matched against \
user policy + a non-overridable hard-deny list for the \
worst-of-the-worst patterns).\n\
\n\
Only panes the user has allow-listed (🤖 chip on) are \
visible. SSH spawns are gated by an extra Policy-tab switch \
that's off by default if you see 'ssh-disabled' errors, \
the user has not enabled MCP-initiated SSH.",
)
}