From 6da7523993d16ec98b35924ba9633fbb4fe72282 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 26 May 2026 15:20:22 +0100 Subject: [PATCH] MCP polish + SSH host manager Connect button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/mcp.rs | 102 +++++++++++++++++++++------------ src/App.tsx | 53 ++++++++++++++--- src/components/HostManager.css | 17 +++++- src/components/HostManager.tsx | 24 ++++++-- 4 files changed, 144 insertions(+), 52 deletions(-) diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 31b739f..07095d8 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -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, + cwd: Option, + }, + 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, ) -> Result { - // 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.", ) } diff --git a/src/App.tsx b/src/App.tsx index d45aee0..724e4a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -308,6 +308,42 @@ export default function App() { ); }, [activeLeafId, defaultShell, notify]); + // From the SSH host manager's "Connect" button — splits off the active + // pane and opens an SSH session to the picked host. Same smart-orient as + // the titlebar "+" path. + const connectToHost = useCallback( + (hostId: string) => { + const container = paneWrapRef.current; + const layout = flattenLayout(treeRef.current); + const targetId = activeLeafId ?? layout.leaves[0]?.leaf.id ?? null; + if (!targetId || !container) { + notify("No pane to split off — open a pane first"); + return; + } + const slot = layout.leaves.find((s) => s.leaf.id === targetId); + if (!slot) return; + const rect = container.getBoundingClientRect(); + const paneW = slot.box.width * rect.width; + const paneH = slot.box.height * rect.height; + const orientation: Orientation = paneW >= paneH ? "h" : "v"; + const childW = orientation === "h" ? paneW / 2 : paneW; + const childH = orientation === "v" ? paneH / 2 : paneH; + if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) { + notify( + `Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`, + ); + return; + } + setTree((t) => + splitLeaf(t, targetId, orientation, { + shellKind: "ssh", + sshHostId: hostId, + }), + ); + }, + [activeLeafId, notify], + ); + const close = useCallback( (leafId: NodeId) => { const paneId = paneIdByLeafRef.current.get(leafId); @@ -990,6 +1026,10 @@ export default function App() { }; } case "spawn_pane": { + // Backend McpSpawnSpec only contains Wsl / Powershell — SSH is + // routed through connect_host instead. The cast tolerates the + // wider frontend SpawnSpec union; the kind discriminant is what + // matters at runtime. const a = args as { spec?: SpawnSpec; parentLeafId?: string; @@ -998,15 +1038,6 @@ export default function App() { if (!a.spec || typeof a.spec !== "object") { throw new Error("missing spec"); } - // For SSH, our LeafNode only persists sshHostId — we don't store - // inline host/user/port on the leaf. Refuse ad-hoc SSH spawns; - // use connect_host with a saved host_id instead. - if (a.spec.kind === "ssh" && !a.spec.hostId) { - throw new Error( - "spawn_pane with kind=ssh requires hostId (a saved host). " + - "Use connect_host(host_id) or add the host via the SSH host manager first.", - ); - } const newLeafId = await spawnNewLeafFromSpec( a.spec, a.parentLeafId, @@ -1593,6 +1624,10 @@ export default function App() { onSave={saveHosts} onSavePassword={savePassword} onClearPassword={clearPassword} + onConnect={(hostId) => { + connectToHost(hostId); + closeHostManager(); + }} onClose={closeHostManager} /> )} diff --git a/src/components/HostManager.css b/src/components/HostManager.css index b4436d4..43b1381 100644 --- a/src/components/HostManager.css +++ b/src/components/HostManager.css @@ -92,7 +92,13 @@ font-size: 11px; margin-top: 1px; } -.host-edit-btn { +.host-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} +.host-edit-btn, +.host-connect-btn { background: #222; color: #aac; border: 1px solid #2a2a3a; @@ -106,6 +112,15 @@ background: #2a2a3a; color: #cce; } +.host-connect-btn { + background: #1a2a1a; + color: #80c080; + border-color: #2a4a2a; +} +.host-connect-btn:hover { + background: #2a4a2a; + color: #a0e0a0; +} .host-form { display: flex; diff --git a/src/components/HostManager.tsx b/src/components/HostManager.tsx index a87dd7d..226907b 100644 --- a/src/components/HostManager.tsx +++ b/src/components/HostManager.tsx @@ -40,6 +40,8 @@ interface HostManagerProps { /** Delete the keyring entry for this host id. Called when the user * clicked "Remove password" before Save. */ onClearPassword: (hostId: string) => void; + /** Open a new pane connected to this host (and close the manager). */ + onConnect: (hostId: string) => void; onClose: () => void; } @@ -48,6 +50,7 @@ export default function HostManager({ onSave, onSavePassword, onClearPassword, + onConnect, onClose, }: HostManagerProps) { // Local editable copy. Any save / delete acts on this and pushes the @@ -377,12 +380,21 @@ export default function HostManager({ {h.jumpHost ? ` via ${h.jumpHost}` : ""} - +
+ + +
)}