//! Embedded MCP server. Lets a Claude session running anywhere on the //! same machine — including inside one of tiletopia's own panes — inspect //! and drive the workspace via Model Context Protocol. //! //! 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 spawn path) //! add_host, delete_host (mutate saved-hosts list; gated by an extra //! 'allow_add_host' safeguard; add_host sanitises extraArgs to reject //! ProxyCommand / LocalCommand / KnownHostsCommand / PermitLocalCommand //! =yes — CVE-2023-51385 class local-RCE primitives) //! 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 //! `mcp_update_state` Tauri command. The MCP server only sees what the //! mirror exposes — no peeking around it. use std::collections::HashMap; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use axum::{ body::Body, http::{HeaderMap, HeaderValue, Request, StatusCode}, middleware::{self, Next}, response::Response, Router, }; use parking_lot::Mutex as PlMutex; use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::*, schemars, tool, tool_handler, tool_router, service::RequestContext, transport::streamable_http_server::{ session::local::LocalSessionManager, tower::{StreamableHttpServerConfig, StreamableHttpService}, }, ErrorData as McpError, RoleServer, ServerHandler, }; use serde::{Deserialize, Serialize}; use serde_json::json; use tauri::{AppHandle, Emitter, Manager}; use tokio::{net::TcpListener, sync::RwLock, task::JoinHandle}; use tokio_util::sync::CancellationToken; use crate::pty::{PaneId, PtyManager}; /// Default port for the MCP server. Picked from the IANA-unassigned /// 47000-range so it's unlikely to collide with anything else on a dev box. /// Override by editing `port` in `%APPDATA%\com.megaproxy.tiletopia\mcp.json`. pub const DEFAULT_PORT: u16 = 47821; const MCP_CONFIG_FILE: &str = "mcp.json"; /// Persisted across restarts so the firewall rule + Claude config snippet /// don't need re-pasting every launch. Generated on first start. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpPersistedConfig { pub port: u16, pub token: String, } impl McpPersistedConfig { fn new_default() -> Self { Self { port: DEFAULT_PORT, token: generate_token(), } } } fn generate_token() -> String { use rand::RngCore; let mut buf = [0u8; 32]; rand::rng().fill_bytes(&mut buf); hex::encode(buf) } fn config_path(app: &AppHandle) -> Result { let dir = app .path() .app_config_dir() .map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?; Ok(dir.join(MCP_CONFIG_FILE)) } /// Load saved config, or generate-and-save a fresh one on first run. pub fn load_or_init_config(app: &AppHandle) -> Result { let path = config_path(app)?; if path.exists() { let raw = std::fs::read_to_string(&path).context("read mcp.json")?; let cfg: McpPersistedConfig = serde_json::from_str(&raw).context("parse mcp.json")?; return Ok(cfg); } let cfg = McpPersistedConfig::new_default(); save_config(app, &cfg)?; Ok(cfg) } pub fn save_config(app: &AppHandle, cfg: &McpPersistedConfig) -> Result<()> { let path = config_path(app)?; if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).context("create_dir_all")?; } let tmp = path.with_extension("json.tmp"); let json = serde_json::to_string_pretty(cfg).context("serialize mcp cfg")?; std::fs::write(&tmp, json.as_bytes()).context("write tmp mcp.json")?; // Atomic on Unix; MoveFileEx with REPLACE_EXISTING on Windows. std::fs::rename(&tmp, &path).context("rename mcp.json")?; Ok(()) } // ---------------------------------------------------------------------------- // Shared state mirrored from the frontend. // ---------------------------------------------------------------------------- pub type LeafId = String; /// Cached snapshot the frontend pushes via `mcp_update_state` whenever the /// tree or hosts change. Source of truth for everything except scrollback, /// which the backend collects directly via {@link PtyManager}. #[derive(Debug, Default, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpMirror { /// Serialised layout tree (full structure, post-filtering happens /// per-resource — see read_resource). #[serde(default)] pub layout_json: String, /// Map of leaf id → pane metadata. Includes only leaves with /// `mcpAllow === true` (frontend gates before mirroring). #[serde(default)] pub leaves: HashMap, /// Saved SSH hosts, password fields stripped. #[serde(default)] pub hosts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MirroredLeaf { pub pane_id: Option, pub label: Option, pub shell_kind: String, pub distro: Option, pub ssh_host_id: Option, #[serde(default)] pub broadcast: bool, #[serde(default)] pub active: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MirroredHost { pub id: String, pub label: String, pub hostname: String, pub user: Option, pub port: Option, #[serde(default)] pub has_password: bool, } #[derive(Default)] pub struct McpState { pub bearer_token: String, pub mirror: McpMirror, } // ---------------------------------------------------------------------------- // Action reply registry. // ---------------------------------------------------------------------------- /// Registry of pending frontend action requests. Each entry maps a `requestId` /// to a oneshot sender that the `mcp_action_reply` Tauri command will fire /// once the frontend resolves or rejects the action. /// /// Owned as separate managed state (Arc) so Tauri commands can /// grab it via `tauri::State<'_, Arc>` without needing to lock /// the entire McpState or pass TileService around. pub struct PendingActions( pub PlMutex>>>, ); impl Default for PendingActions { fn default() -> Self { Self(PlMutex::new(HashMap::new())) } } // ---------------------------------------------------------------------------- // Audit / request event payload types. // ---------------------------------------------------------------------------- #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct McpActionRequest { request_id: String, tool: &'static str, args: serde_json::Value, needs_confirm: bool, reason: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase", tag = "kind")] enum McpAuditResult { Ok, Denied { reason: String, hard: bool }, Failed { msg: String }, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct McpAuditEntry { ts_ms: u64, tool: &'static str, args_summary: String, result: McpAuditResult, duration_ms: u64, } fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 } fn truncate_summary(s: &str) -> String { if s.len() > 80 { format!("{}...", &s[..80]) } else { s.to_string() } } // ---------------------------------------------------------------------------- // Write rate limiter (per OWASP LLM06 "Excessive Agency" + MCP spec MUST). // Token bucket per leaf_id, only applied to write_pane. // ---------------------------------------------------------------------------- const WRITE_RATE_CAPACITY: f64 = 30.0; /// Tokens added per second. 30 / 10s = 3.0 → burst of 30, sustained 3/s. const WRITE_RATE_REFILL_PER_SEC: f64 = 3.0; #[derive(Default)] pub struct WriteRateLimiter { /// leaf_id → (last_refill, tokens). Naive HashMap that never evicts; for /// a long-running session with many transient panes this could grow, but /// LeafIds are uuid-shaped and a few hundred entries is fine. buckets: PlMutex>, } impl WriteRateLimiter { /// Try to consume a token for this leaf. Returns Ok on success, Err with /// the milliseconds to wait until the next token will be available. pub fn try_consume(&self, leaf_id: &str) -> Result<(), u64> { let mut buckets = self.buckets.lock(); let now = Instant::now(); let entry = buckets .entry(leaf_id.to_string()) .or_insert((now, WRITE_RATE_CAPACITY)); let elapsed = now.saturating_duration_since(entry.0).as_secs_f64(); entry.1 = (entry.1 + elapsed * WRITE_RATE_REFILL_PER_SEC).min(WRITE_RATE_CAPACITY); entry.0 = now; if entry.1 >= 1.0 { entry.1 -= 1.0; Ok(()) } else { let wait_secs = (1.0 - entry.1) / WRITE_RATE_REFILL_PER_SEC; Err((wait_secs * 1000.0).ceil() as u64) } } } // ---------------------------------------------------------------------------- // MCP service: tools + resources. // ---------------------------------------------------------------------------- #[derive(Clone)] pub struct TileService { ptys: Arc, state: Arc>, pending: Arc, rate_limiter: Arc, app: AppHandle, // Used by the code rmcp's `#[tool_router]` macro generates; rustc's // dead-code pass can't see through the macro, hence the explicit // suppression rather than a redesign. #[allow(dead_code)] tool_router: ToolRouter, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct ReadPaneArgs { /// Stable leaf id from the tree (uuid-shaped). Must belong to a pane /// the user has allow-listed for MCP access. pub leaf_id: LeafId, /// Return only the last N lines (default 200, hard cap 3000). #[serde(default)] pub last_lines: Option, /// Only return bytes whose seq > this. Pair with the `__seq__` value /// returned in a prior call for incremental polling. #[serde(default)] pub after_seq: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct WaitForIdleArgs { pub leaf_id: LeafId, /// Required quiet window before declaring idle (default 500 ms). #[serde(default)] pub idle_ms: Option, /// Hard timeout in ms; returns timeout=true after this (default 30s, /// hard cap 5 min). #[serde(default)] pub timeout_ms: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct CloseLeafArgs { /// Stable leaf id from the tree (uuid-shaped). Must belong to a pane /// the user has allow-listed for MCP access. Closing the last leaf in /// the workspace replaces it with a fresh default-shell pane. pub leaf_id: LeafId, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct SwapPanesArgs { /// First leaf to swap. Both leaves must be MCP-allowed. pub leaf_a: LeafId, /// Second leaf to swap. pub leaf_b: LeafId, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct PromotePaneArgs { /// Leaf to promote one level (i.e. swap it with its parent's sibling). /// No-op if the parent shares orientation with the grandparent — /// frontend returns a descriptive error in that case. pub leaf_id: LeafId, } #[derive(Debug, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PresetName { /// Replace the workspace with a single full-window pane. Single, /// Two columns side-by-side. TwoColumns, /// Three columns side-by-side. ThreeColumns, /// Two stacked rows. TwoRows, /// 2x2 grid. TwoByTwo, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct ApplyPresetArgs { pub name: PresetName, /// Pre-acknowledge that some existing panes may be killed if the /// preset has fewer slots than the current layout. Required for any /// non-additive reshape — frontend rejects with the dropped count /// otherwise. #[serde(default)] 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 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. #[serde(default)] pub parent_leaf_id: Option, /// "h" → new pane to the right; "v" → new pane below. If omitted, /// picks based on the parent's current aspect ratio (wider → "h", /// taller → "v"), matching the titlebar "+" button's behaviour. #[serde(default)] pub orientation: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct ConnectHostArgs { /// Stable id of a host saved via the SSH host manager. The new pane /// inherits the host's user/port/identity-file/etc. and reuses the /// keyring-stored password (if any) for auto-fill at the SSH prompt. pub host_id: String, /// Same semantics as spawn_pane.parent_leaf_id. #[serde(default)] pub parent_leaf_id: Option, /// Same semantics as spawn_pane.orientation. #[serde(default)] pub orientation: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct AddHostArgs { /// Human-readable name for the host (shown in the picker / palette). /// If omitted, defaults to `hostname` server-side. #[serde(default)] pub label: Option, /// Hostname or IP. Required. Rejected if it starts with '-' or contains /// control characters (CVE-2023-51385 / smuggled-flag class). pub hostname: String, /// SSH login user. Same validation as hostname. #[serde(default)] pub user: Option, /// TCP port. Defaults to 22 if omitted. #[serde(default)] pub port: Option, /// Path to a private key. Passed to ssh as `-i`. #[serde(default, rename = "identityFile")] pub identity_file: Option, /// `user@host[:port]` jump host. Same validation as hostname. #[serde(default, rename = "jumpHost")] pub jump_host: Option, /// Extra ssh args (e.g. `-o ServerAliveInterval=30`). Sanitised to /// reject command-execution `-o` options: ProxyCommand, LocalCommand, /// KnownHostsCommand, PermitLocalCommand=yes. The user's manually-added /// hosts are unrestricted; only this MCP path is gated. #[serde(default, rename = "extraArgs")] pub extra_args: Option>, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct DeleteHostArgs { /// Stable id of a host returned by tiletopia://hosts or a prior /// add_host call. Deleting also sweeps any saved password for the /// host from the OS keyring. pub host_id: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct WritePaneArgs { /// Stable leaf id from the tree (uuid-shaped). Must belong to a pane /// the user has allow-listed for MCP access. pub leaf_id: LeafId, /// Bytes to send to the pane's PTY. Use "\n" for Enter. Each call sends /// one chunk; partial commands are fine but block the shell until you /// send a newline. This is the highest-risk MCP tool — Claude can send /// arbitrary keystrokes including destructive commands. The policy /// engine + hard-deny list are evaluated against this text directly. /// Rate-limited to 30 calls / 10s per pane. pub text: String, } #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct SetLabelArgs { /// Stable leaf id from the tree (uuid-shaped). Must belong to a pane /// the user has allow-listed for MCP access. pub leaf_id: LeafId, /// New human-readable label. Pass an empty string to clear the label. pub label: String, } const READ_PANE_HARD_CAP_LINES: usize = 3000; const WAIT_TIMEOUT_HARD_CAP_MS: u64 = 5 * 60 * 1000; #[tool_router] impl TileService { pub fn new( ptys: Arc, state: Arc>, pending: Arc, rate_limiter: Arc, app: AppHandle, ) -> Self { Self { ptys, state, pending, rate_limiter, app, tool_router: Self::tool_router(), } } /// Dispatch an action that the frontend must execute. Generates a uuid, /// registers a oneshot, emits the "mcp://request" event with the args, /// awaits the reply with a 30s timeout. Also emits an "mcp://audit" event /// after the call resolves (regardless of outcome). async fn dispatch_action( &self, tool: &'static str, args: serde_json::Value, args_repr: String, ) -> Result { let start_ms = now_ms(); let args_summary = truncate_summary(&args_repr); tracing::debug!(tool, args_repr = %args_repr, "dispatch_action: start"); // 1. Load user policy. let policy = crate::mcp_policy::load_or_init(&self.app) .map_err(|e| McpError::internal_error(e.to_string(), None))?; // 2. Hard-deny check (for any tool — is_hard_denied checks for shell // catastrophe patterns; for non-write_pane tools the patterns are // unlikely to match args_repr but the check is cheap and safe). if let Some(label) = crate::mcp_policy::is_hard_denied(&args_repr) { let duration_ms = now_ms() - start_ms; let audit = McpAuditEntry { ts_ms: start_ms, tool, args_summary: args_summary.clone(), result: McpAuditResult::Denied { reason: label.to_string(), hard: true, }, duration_ms, }; tracing::debug!(tool, reason = label, hard = true, "dispatch_action: hard-denied"); let _ = self.app.emit("mcp://audit", &audit); return Err(McpError::invalid_params( format!("hard-denied: {label}"), None, )); } // 3. Evaluate user-policy decision. let decision = crate::mcp_policy::evaluate(&policy, tool, &args_repr); tracing::debug!(tool, ?decision, "dispatch_action: policy decision"); // 4. Handle Deny. let (needs_confirm, ask_reason) = match &decision { crate::mcp_policy::PolicyDecision::Allow => (false, None), crate::mcp_policy::PolicyDecision::Ask { reason } => { (true, Some(reason.clone())) } crate::mcp_policy::PolicyDecision::Deny { reason, hard } => { let duration_ms = now_ms() - start_ms; let audit = McpAuditEntry { ts_ms: start_ms, tool, args_summary: args_summary.clone(), result: McpAuditResult::Denied { reason: reason.clone(), hard: *hard, }, duration_ms, }; tracing::debug!(tool, reason = %reason, hard, "dispatch_action: denied by policy"); let _ = self.app.emit("mcp://audit", &audit); return Err(McpError::invalid_params( format!("denied: {reason}"), None, )); } }; // 5. Generate a unique request id, register oneshot, emit mcp://request. // uuid crate is not in Cargo.toml; generate via rand (already a dep). // TODO: if uuid (v4 feature) is added to Cargo.toml, replace with: // let request_id = uuid::Uuid::new_v4().to_string(); let request_id = { use rand::RngCore; let mut bytes = [0u8; 16]; rand::rng().fill_bytes(&mut bytes); // Format as a RFC-4122-style UUID v4 string for frontend interop. bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; format!( "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15], ) }; let (tx, rx) = tokio::sync::oneshot::channel(); { self.pending.0.lock().insert(request_id.clone(), tx); } let payload = McpActionRequest { request_id: request_id.clone(), tool, args, needs_confirm, reason: ask_reason, }; tracing::debug!(tool, request_id = %request_id, needs_confirm, "dispatch_action: emitting mcp://request"); let _ = self.app.emit("mcp://request", &payload); // 6. Await reply with 30s timeout. // 60s is the outer cap. Per-tool inner timeouts (waitForPaneRegistration // in the frontend handler) are tighter for fast ops and looser for // SSH/spawn — this just keeps a misbehaving frontend from leaking a // request id forever. let result = tokio::time::timeout(Duration::from_secs(60), rx).await; let duration_ms = now_ms() - start_ms; match result { Err(_elapsed) => { // Timeout — remove stale sender from registry. self.pending.0.lock().remove(&request_id); let audit = McpAuditEntry { ts_ms: start_ms, tool, args_summary, result: McpAuditResult::Failed { msg: "timeout".into(), }, duration_ms, }; tracing::debug!(tool, request_id = %request_id, "dispatch_action: timed out"); let _ = self.app.emit("mcp://audit", &audit); Err(McpError::internal_error( "action timed out waiting for frontend response", Some(json!({ "requestId": request_id })), )) } Ok(Err(_recv_err)) => { // Sender was dropped (shouldn't happen normally). let audit = McpAuditEntry { ts_ms: start_ms, tool, args_summary, result: McpAuditResult::Failed { msg: "channel closed".into(), }, duration_ms, }; tracing::debug!(tool, request_id = %request_id, "dispatch_action: channel closed"); let _ = self.app.emit("mcp://audit", &audit); Err(McpError::internal_error( "action channel closed unexpectedly", Some(json!({ "requestId": request_id })), )) } Ok(Ok(reply)) => { // 7. On reply: emit audit, propagate. Destructure with // ownership so the success payload and the error string // move out cleanly (avoids borrow-then-move on `reply`). let (audit_result, err, ok_payload) = match reply { Ok(v) => { tracing::debug!(tool, request_id = %request_id, "dispatch_action: reply ok"); (McpAuditResult::Ok, None, Some(v)) } Err(msg) => { tracing::debug!(tool, request_id = %request_id, error = %msg, "dispatch_action: reply error"); ( McpAuditResult::Failed { msg: msg.clone() }, Some(McpError::internal_error(msg, None)), None, ) } }; let audit = McpAuditEntry { ts_ms: start_ms, tool, args_summary, result: audit_result, duration_ms, }; let _ = self.app.emit("mcp://audit", &audit); match err { Some(e) => Err(e), None => Ok(ok_payload.expect("ok branch always sets ok_payload")), } } } } /// Look up a leaf_id → pane_id under the MCP-allow gate. async fn resolve_pane(&self, leaf_id: &str) -> Result { let st = self.state.read().await; let leaf = st.mirror.leaves.get(leaf_id).ok_or_else(|| { McpError::invalid_params( "unknown leaf_id (not visible to MCP; user may need to allow it)", Some(json!({ "leaf_id": leaf_id })), ) })?; leaf.pane_id.ok_or_else(|| { McpError::invalid_params( "leaf has no live pane", Some(json!({ "leaf_id": leaf_id })), ) }) } #[tool(description = "Read the recent scrollback of a terminal pane. \ Returns text plus a __seq__=N marker that can be passed back as \ after_seq for incremental polling.")] async fn read_pane( &self, Parameters(args): Parameters, ) -> Result { let pane_id = self.resolve_pane(&args.leaf_id).await?; let ring = self.ptys.ring(pane_id).ok_or_else(|| { McpError::internal_error( "pane ring missing (pane may have just exited)", Some(json!({ "leaf_id": args.leaf_id })), ) })?; let (bytes, seq) = { let g = ring.lock(); g.snapshot() }; // Trim by after_seq if provided: bytes in the ring beyond // `after_seq` is `seq - after_seq`, clamped against ring size. let start = match args.after_seq { Some(prev) if seq > prev => { let new_bytes = (seq - prev) as usize; bytes.len().saturating_sub(new_bytes) } Some(_) => bytes.len(), None => 0, }; let tail = &bytes[start..]; let text = String::from_utf8_lossy(tail); let cap = args .last_lines .map(|n| n.min(READ_PANE_HARD_CAP_LINES)) .unwrap_or(200); let limited: String = if cap == 0 { String::new() } else { let lines: Vec<&str> = text.lines().collect(); let start_line = lines.len().saturating_sub(cap); lines[start_line..].join("\n") }; Ok(CallToolResult::success(vec![ Content::text(limited), Content::text(format!("__seq__={seq}")), ])) } #[tool(description = "Block until a pane has been quiet (no output) \ for idle_ms, or timeout_ms elapses. Useful for command-completion \ synchronisation. Returns {idle:bool, seq:u64, elapsed_ms:u64}.")] async fn wait_for_idle( &self, Parameters(args): Parameters, ) -> Result { let pane_id = self.resolve_pane(&args.leaf_id).await?; let ring = self.ptys.ring(pane_id).ok_or_else(|| { McpError::internal_error("pane ring missing", None) })?; let idle_target = Duration::from_millis(args.idle_ms.unwrap_or(500)); let timeout = Duration::from_millis( args.timeout_ms .unwrap_or(30_000) .min(WAIT_TIMEOUT_HARD_CAP_MS), ); let start = Instant::now(); let mut last_seq = ring.lock().snapshot().1; let mut last_change = Instant::now(); loop { // Sleep in small slices so we notice both incoming data and // the overall timeout promptly. tokio::time::sleep(Duration::from_millis(50)).await; let now_seq = ring.lock().snapshot().1; if now_seq != last_seq { last_seq = now_seq; last_change = Instant::now(); } if last_change.elapsed() >= idle_target { return Ok(CallToolResult::success(vec![Content::text( json!({ "idle": true, "seq": last_seq, "elapsed_ms": start.elapsed().as_millis() as u64, }) .to_string(), )])); } if start.elapsed() >= timeout { return Ok(CallToolResult::success(vec![Content::text( json!({ "idle": false, "seq": last_seq, "elapsed_ms": start.elapsed().as_millis() as u64, }) .to_string(), )])); } } } #[tool(description = "Send keystrokes (text) to a pane's PTY. The leaf \ must be MCP-allowed. Use \"\\n\" for Enter. Rate-limited per pane: \ 30 calls per 10 seconds. The user policy is evaluated against the \ sent text (so rules like write_pane(git push *) match by content), \ and a compiled-in hard-deny list always catches rm -rf /, fork \ bombs, mkfs on devices, etc. — those are blocked regardless of \ policy.")] async fn write_pane( &self, Parameters(args): Parameters, ) -> Result { let _leaf = self.require_visible_leaf(&args.leaf_id).await?; // Rate limit BEFORE dispatch — we don't want a misbehaving client // to spam the user with confirm modals or burn audit-log capacity. if let Err(retry_after_ms) = self.rate_limiter.try_consume(&args.leaf_id) { tracing::warn!( leaf_id = %args.leaf_id, retry_after_ms, "write_pane: rate limited" ); return Err(McpError::invalid_params( format!("rate limited; retry after {retry_after_ms}ms"), Some(json!({ "leaf_id": &args.leaf_id, "retry_after_ms": retry_after_ms, })), )); } // args_repr IS the text — that's what hard-deny and the user's // policy globs pattern-match against. (For other tools we use a // stable summary string; for write_pane the text is the surface.) let args_repr = args.text.clone(); let args_json = json!({ "leafId": &args.leaf_id, "text": &args.text }); tracing::debug!( leaf_id = %args.leaf_id, bytes = args.text.len(), "write_pane: dispatching" ); let _ = self .dispatch_action("write_pane", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text("ok")])) } #[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 { // 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.) if let Some(ref parent) = args.parent_leaf_id { let _ = self.require_visible_leaf(parent).await?; } // Serialise the spec back to JSON so the frontend handler can // 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 spec: {e}"), None, ) })?; let kind_str = match &args.spec { McpSpawnSpec::Wsl { .. } => "wsl", McpSpawnSpec::Powershell => "powershell", }; let args_repr = format!( "shell={} parent={} orientation={}", kind_str, args.parent_leaf_id.as_deref().unwrap_or("(active)"), args.orientation.as_deref().unwrap_or("(auto)"), ); let args_json = json!({ "spec": spec_json, "parentLeafId": args.parent_leaf_id, "orientation": args.orientation, }); tracing::debug!(shell = kind_str, "spawn_pane: dispatching"); let result = self .dispatch_action("spawn_pane", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text( result.to_string(), )])) } #[tool(description = "Open a new pane connected to a saved SSH host. \ Thin wrapper around spawn_pane that resolves host_id against the \ saved-hosts list (use the tiletopia://hosts resource to list them). \ Returns {leafId, paneId} for the new pane. The user's saved password \ for the host (if any) is auto-typed at the prompt — passwords are \ never exposed through the MCP surface.")] async fn connect_host( &self, Parameters(args): Parameters, ) -> Result { if !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 let Some(ref parent) = args.parent_leaf_id { let _ = self.require_visible_leaf(parent).await?; } // Verify host_id is in the mirror (so Claude can't probe unknown ids). let host_known = { let st = self.state.read().await; st.mirror.hosts.iter().any(|h| h.id == args.host_id) }; if !host_known { return Err(McpError::invalid_params( "unknown host_id (use tiletopia://hosts to list saved hosts)", Some(json!({ "host_id": &args.host_id })), )); } let args_repr = format!( "host={} parent={} orientation={}", &args.host_id, args.parent_leaf_id.as_deref().unwrap_or("(active)"), args.orientation.as_deref().unwrap_or("(auto)"), ); let args_json = json!({ "hostId": &args.host_id, "parentLeafId": args.parent_leaf_id, "orientation": args.orientation, }); tracing::debug!(host_id = %args.host_id, "connect_host: dispatching"); let result = self .dispatch_action("connect_host", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text( result.to_string(), )])) } #[tool(description = "Register a new SSH host in the saved-hosts list \ (the same store the titlebar 🔑 picker manages). Validates hostname/\ user/jumpHost the same way an SSH spawn would (rejects '-' prefixes \ and control characters, CVE-2023-51385 class) and sanitises extraArgs \ to reject ProxyCommand / LocalCommand / KnownHostsCommand / \ PermitLocalCommand=yes (local-RCE primitives). Gated by the \ 'Allow Claude to save or delete SSH hosts' switch in the Policy tab; \ refuses with 'add-host-disabled' when off. Returns {hostId} for the \ newly-saved host — pass to connect_host to open it.")] async fn add_host( &self, Parameters(args): Parameters, ) -> Result { if !self.policy_ssh_add_host_allowed().await { return Err(McpError::invalid_params( "add-host-disabled: Claude is not allowed to save SSH hosts \ (Policy tab → SSH safeguards → 'Allow Claude to save or \ delete SSH hosts'). Ask the user to add the host manually \ via the titlebar 🔑 picker.", None, )); } // Same token validation ssh.exe would do at spawn time — reject up // front so we don't persist a host that can never be opened. crate::pty::validate_ssh_token("hostname", &args.hostname) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; if let Some(u) = args.user.as_deref() { crate::pty::validate_ssh_token("user", u) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; } if let Some(jh) = args.jump_host.as_deref() { crate::pty::validate_ssh_token("jump host", jh) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; } if let Some(extra) = args.extra_args.as_deref() { crate::hosts::sanitize_extra_args(extra) .map_err(|reason| McpError::invalid_params(reason, None))?; } let label = args .label .as_deref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| args.hostname.trim()) .to_string(); let args_repr = format!( "label={} hostname={} user={} port={}", label, &args.hostname, args.user.as_deref().unwrap_or("(default)"), args.port .map(|p| p.to_string()) .unwrap_or_else(|| "(default)".into()), ); let args_json = json!({ "label": label, "hostname": &args.hostname, "user": args.user, "port": args.port, "identityFile": args.identity_file, "jumpHost": args.jump_host, "extraArgs": args.extra_args, }); tracing::debug!(hostname = %args.hostname, "add_host: dispatching"); let result = self .dispatch_action("add_host", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text( result.to_string(), )])) } #[tool(description = "Delete a saved SSH host by id. Sweeps any saved \ password for the host from the OS keyring as a side effect. Gated by \ the same 'Allow Claude to save or delete SSH hosts' switch as \ add_host; refuses with 'add-host-disabled' when off.")] async fn delete_host( &self, Parameters(args): Parameters, ) -> Result { if !self.policy_ssh_add_host_allowed().await { return Err(McpError::invalid_params( "add-host-disabled: Claude is not allowed to delete SSH hosts \ (Policy tab → SSH safeguards → 'Allow Claude to save or \ delete SSH hosts').", None, )); } // Verify the host_id is in the mirror so Claude can't probe arbitrary // ids. The mirror is the authoritative view of what Claude can see. let host_label = { let st = self.state.read().await; st.mirror .hosts .iter() .find(|h| h.id == args.host_id) .map(|h| h.label.clone()) }; let label = host_label.ok_or_else(|| { McpError::invalid_params( "unknown host_id (use tiletopia://hosts to list saved hosts)", Some(json!({ "host_id": &args.host_id })), ) })?; let args_repr = format!("hostId={} label={}", &args.host_id, &label); let args_json = json!({ "hostId": &args.host_id }); tracing::debug!(host_id = %args.host_id, "delete_host: dispatching"); let _ = self .dispatch_action("delete_host", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text("ok")])) } #[tool(description = "Set or clear the human-readable label on a pane. \ Pass empty string to clear. The leaf must be MCP-allowed.")] async fn set_label( &self, Parameters(args): Parameters, ) -> Result { // Validate leaf exists in mirror + is visible to MCP (mcpAllow=true // is enforced by the frontend before mirroring, so presence here // implies the user has allowed it). let _leaf = self .state .read() .await .mirror .leaves .get(&args.leaf_id) .cloned() .ok_or_else(|| { McpError::invalid_params( "unknown leaf_id (not visible to MCP; user may need to allow it)", Some(json!({ "leaf_id": &args.leaf_id })), ) })?; let args_repr = format!("leafId={} label={}", &args.leaf_id, &args.label); let args_json = json!({ "leafId": &args.leaf_id, "label": &args.label }); tracing::debug!(leaf_id = %args.leaf_id, label = %args.label, "set_label: dispatching"); let _ = self .dispatch_action("set_label", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text("ok")])) } #[tool(description = "Close a pane and kill its PTY. The leaf must be \ MCP-allowed. Closing the only leaf in the workspace replaces it \ with a fresh default-shell pane (the workspace can never be empty).")] async fn close_pane( &self, Parameters(args): Parameters, ) -> Result { let _leaf = self.require_visible_leaf(&args.leaf_id).await?; let args_repr = format!("leafId={}", &args.leaf_id); let args_json = json!({ "leafId": &args.leaf_id }); tracing::debug!(leaf_id = %args.leaf_id, "close_pane: dispatching"); let _ = self .dispatch_action("close_pane", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text("ok")])) } #[tool(description = "Swap two panes in the layout tree (preserves \ both PTYs and their labels). Both leaves must be MCP-allowed.")] async fn swap_panes( &self, Parameters(args): Parameters, ) -> Result { let _a = self.require_visible_leaf(&args.leaf_a).await?; let _b = self.require_visible_leaf(&args.leaf_b).await?; let args_repr = format!("leafA={} leafB={}", &args.leaf_a, &args.leaf_b); let args_json = json!({ "leafA": &args.leaf_a, "leafB": &args.leaf_b }); tracing::debug!(leaf_a = %args.leaf_a, leaf_b = %args.leaf_b, "swap_panes: dispatching"); let _ = self .dispatch_action("swap_panes", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text("ok")])) } #[tool(description = "Promote a pane up one level — swaps it with its \ parent split's sibling subtree. Useful for un-nesting a pane that \ ended up deeper than intended. No-op (errors) if the pane's parent \ shares orientation with its grandparent — no perpendicular promote \ target exists.")] async fn promote_pane( &self, Parameters(args): Parameters, ) -> Result { let _leaf = self.require_visible_leaf(&args.leaf_id).await?; let args_repr = format!("leafId={}", &args.leaf_id); let args_json = json!({ "leafId": &args.leaf_id }); tracing::debug!(leaf_id = %args.leaf_id, "promote_pane: dispatching"); let _ = self .dispatch_action("promote_pane", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text("ok")])) } #[tool(description = "Reshape the workspace to a preset layout. \ Existing panes are slotted into the new shape in order (ids + PTYs \ preserved where possible); extra slots spawn fresh shells. If the \ preset has fewer slots than the current pane count, set \ allow_drops=true to acknowledge that those overflow panes will be \ killed — otherwise the call fails with the dropped count so you \ can decide.")] async fn apply_preset( &self, Parameters(args): Parameters, ) -> Result { // Convert the typed enum back to a stable wire-form string the // frontend dispatcher matches against. Matching the snake_case of // PresetName's serde rename_all so JSON round-trip stays clean. let name = match args.name { PresetName::Single => "single", PresetName::TwoColumns => "two_columns", PresetName::ThreeColumns => "three_columns", PresetName::TwoRows => "two_rows", PresetName::TwoByTwo => "two_by_two", }; let args_repr = format!("preset={} allowDrops={}", name, args.allow_drops); let args_json = json!({ "name": name, "allowDrops": args.allow_drops }); tracing::debug!(preset = name, allow_drops = args.allow_drops, "apply_preset: dispatching"); let _ = self .dispatch_action("apply_preset", args_json, args_repr) .await?; Ok(CallToolResult::success(vec![Content::text("ok")])) } /// Read the persisted SSH-safeguard switch. Fresh-read every call so a /// user editing the policy in the panel takes effect on the next MCP /// call without a server restart. Errors fall back to the safe default /// (refuse). async fn policy_ssh_open_allowed(&self) -> bool { match crate::mcp_policy::load_or_init(&self.app) { Ok(p) => p.ssh_safeguards.allow_open_ssh, Err(e) => { tracing::warn!(error = %e, "policy_ssh_open_allowed: load failed, defaulting to false"); false } } } /// Mirror of policy_ssh_open_allowed for the add_host/delete_host pair. async fn policy_ssh_add_host_allowed(&self) -> bool { match crate::mcp_policy::load_or_init(&self.app) { Ok(p) => p.ssh_safeguards.allow_add_host, Err(e) => { tracing::warn!(error = %e, "policy_ssh_add_host_allowed: load failed, defaulting to false"); false } } } /// Shared validation for tools that target an existing leaf — confirms /// the leaf is in the mirror (which means the user has it allow-listed /// for MCP) and returns its metadata. Factored out of the 4+ tools that /// need this exact check. async fn require_visible_leaf(&self, leaf_id: &str) -> Result { self.state .read() .await .mirror .leaves .get(leaf_id) .cloned() .ok_or_else(|| { McpError::invalid_params( "unknown leaf_id (not visible to MCP; user may need to allow it)", Some(json!({ "leaf_id": leaf_id })), ) }) } } #[tool_handler] impl ServerHandler for TileService { fn get_info(&self) -> ServerInfo { ServerInfo::new( ServerCapabilities::builder() .enable_tools() .enable_resources() .build(), ) .with_server_info(Implementation::from_build_env()) .with_protocol_version(ProtocolVersion::V_2024_11_05) .with_instructions( "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\ - add_host, delete_host (mutate the saved-hosts list; \ add_host's extraArgs are sanitised — ProxyCommand and \ friends are refused).\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. add_host / \ delete_host are similarly gated by an 'allow_add_host' \ switch — 'add-host-disabled' means the user manages SSH \ hosts manually via the titlebar 🔑 picker.", ) } async fn list_resources( &self, _r: Option, _: RequestContext, ) -> Result { Ok(ListResourcesResult { resources: vec![ RawResource::new("tiletopia://layout", "layout").no_annotation(), RawResource::new("tiletopia://panes", "panes").no_annotation(), RawResource::new("tiletopia://hosts", "hosts").no_annotation(), ], next_cursor: None, meta: None, }) } async fn read_resource( &self, req: ReadResourceRequestParams, _: RequestContext, ) -> Result { let state = self.state.read().await; let body = match req.uri.as_str() { "tiletopia://layout" => state.mirror.layout_json.clone(), "tiletopia://panes" => { serde_json::to_string(&state.mirror.leaves).unwrap_or_default() } "tiletopia://hosts" => { serde_json::to_string(&state.mirror.hosts).unwrap_or_default() } other => { return Err(McpError::resource_not_found( "resource_not_found", Some(json!({ "uri": other })), )); } }; Ok(ReadResourceResult::new(vec![ResourceContents::text( body, req.uri, )])) } async fn list_resource_templates( &self, _r: Option, _: RequestContext, ) -> Result { Ok(ListResourceTemplatesResult { resource_templates: vec![], next_cursor: None, meta: None, }) } } // ---------------------------------------------------------------------------- // HTTP wiring + bearer auth. // ---------------------------------------------------------------------------- async fn bearer_auth( axum::extract::State(expected): axum::extract::State>, headers: HeaderMap, req: Request, next: Next, ) -> Result { // OAuth-discovery clients probe /.well-known/* and /register before any // /mcp request. Letting those fall through to axum's default 404 keeps // us out of the OAuth challenge/response game — bearer enforcement only // applies to the real MCP surface. if !req.uri().path().starts_with("/mcp") { return Ok(next.run(req).await); } let auth_header = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()); let supplied = auth_header.and_then(|s| s.strip_prefix("Bearer ")); let ok = supplied .map(|t| constant_time_eq(t.as_bytes(), expected.as_bytes())) .unwrap_or(false); tracing::debug!( method = %req.method(), path = %req.uri().path(), auth_present = auth_header.is_some(), bearer_present = supplied.is_some(), token_match = ok, "MCP request" ); if ok { return Ok(next.run(req).await); } let mut resp = Response::builder() .status(StatusCode::UNAUTHORIZED) .body(Body::empty()) .unwrap(); resp.headers_mut().insert( axum::http::header::WWW_AUTHENTICATE, HeaderValue::from_static(r#"Bearer realm="tiletopia""#), ); Err(resp) } fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut d = 0u8; for (x, y) in a.iter().zip(b) { d |= x ^ y; } d == 0 } // ---------------------------------------------------------------------------- // Lifecycle. // ---------------------------------------------------------------------------- pub struct RunningServer { pub addr: SocketAddr, pub token: String, pub cancel: CancellationToken, pub task: JoinHandle<()>, } #[derive(Default)] pub struct McpServerHandle(pub PlMutex>); pub async fn start_server( app_handle: AppHandle, ptys: Arc, state: Arc>, pending: Arc, ) -> Result { let cfg = load_or_init_config(&app_handle)?; let token = cfg.token.clone(); state.write().await.bearer_token = token.clone(); let cancel = CancellationToken::new(); // Fresh service per session; cheap because we share state via Arcs. let ptys_f = ptys.clone(); let state_f = state.clone(); let pending_f = pending.clone(); // Single shared rate limiter for the lifetime of this server — token // buckets are per-leaf-id, but the registry itself is one piece of state. let rate_limiter: Arc = Arc::new(WriteRateLimiter::default()); // Clone AppHandle before the move closure so we can pass it into each // TileService instance. AppHandle is cheap to clone (it's an Arc inside). let app_handle_for_service = app_handle.clone(); // Disable rmcp's DNS-rebinding host allowlist. The default only permits // localhost / 127.0.0.1 / ::1; legitimate WSL clients connect via the // dynamic WSL gateway IP (172.x.x.1) which can't be in any static list. // Bearer-token auth on /mcp is the real gatekeeper, and we're not // running in a browser context where DNS rebinding is a concern. let mcp_service = StreamableHttpService::new( move || { Ok(TileService::new( ptys_f.clone(), state_f.clone(), pending_f.clone(), rate_limiter.clone(), app_handle_for_service.clone(), )) }, LocalSessionManager::default().into(), StreamableHttpServerConfig::default().disable_allowed_hosts(), ); let router = Router::new() .nest_service("/mcp", mcp_service) .layer(middleware::from_fn_with_state( Arc::new(token.clone()), bearer_auth, )); // Bind to all interfaces so WSL distros in NAT mode can reach the server // via the Windows host's WSL-side adapter IP. Auth is bearer-token only. // We report 127.0.0.1 in the URL since that's the canonical Windows-side // hostname (WSL clients swap in the gateway IP). // // Try the saved port first so the user's firewall rule + Claude config // survive restarts. If it's taken, fall back to an OS-picked port and // leave the saved port alone — the conflict may clear later. let listener = match TcpListener::bind(("0.0.0.0", cfg.port)).await { Ok(l) => l, Err(e) => { tracing::warn!( "MCP saved port {} unavailable ({}); falling back to OS-picked port", cfg.port, e ); TcpListener::bind("0.0.0.0:0").await? } }; let port = listener.local_addr()?.port(); let addr = SocketAddr::from(([127, 0, 0, 1], port)); let cancel_inner = cancel.clone(); let task = tokio::spawn(async move { let _ = axum::serve(listener, router) .with_graceful_shutdown(async move { cancel_inner.cancelled().await; }) .await; }); tracing::info!("MCP server listening on http://{addr}/mcp"); Ok(RunningServer { addr, token, cancel, task, }) } pub fn stop_server(handle: &McpServerHandle) { if let Some(srv) = handle.0.lock().take() { srv.cancel.cancel(); srv.task.abort(); tracing::info!("MCP server stopped"); } } /// Mint a new bearer token, persist it, and return the new value. Caller is /// responsible for restarting the server if it was running — the live auth /// middleware captures the token by value at start time. pub fn regenerate_token(app: &AppHandle) -> Result { let mut cfg = load_or_init_config(app)?; cfg.token = generate_token(); save_config(app, &cfg)?; Ok(cfg.token) }