Three of the four dead-code warnings (`ClassifierHint`, `PolicyClassifier`, `NoopClassifier`) were the v2.1 classifier scaffold sitting unused since PR-1. Deleted — being unused for weeks was a stronger "no concrete plan" signal than its presence was a "TODO" signal. Trivial to re-add when we actually do the classifier (v0.4.0 candidate). Fourth warning was rmcp's `#[tool_router]` macro generating internal references to a `tool_router` field on TileService that rustc's dead-code pass can't see through. Added `#[allow(dead_code)]` with a brief comment on why. `cargo build` is now clean of the four standing dead-code warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1536 lines
59 KiB
Rust
1536 lines
59 KiB
Rust
//! 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<PathBuf> {
|
|
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<McpPersistedConfig> {
|
|
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<LeafId, MirroredLeaf>,
|
|
/// Saved SSH hosts, password fields stripped.
|
|
#[serde(default)]
|
|
pub hosts: Vec<MirroredHost>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MirroredLeaf {
|
|
pub pane_id: Option<PaneId>,
|
|
pub label: Option<String>,
|
|
pub shell_kind: String,
|
|
pub distro: Option<String>,
|
|
pub ssh_host_id: Option<String>,
|
|
#[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<String>,
|
|
pub port: Option<u16>,
|
|
#[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<PendingActions>) so Tauri commands can
|
|
/// grab it via `tauri::State<'_, Arc<PendingActions>>` without needing to lock
|
|
/// the entire McpState or pass TileService around.
|
|
pub struct PendingActions(
|
|
pub PlMutex<HashMap<String, tokio::sync::oneshot::Sender<Result<serde_json::Value, String>>>>,
|
|
);
|
|
|
|
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<String>,
|
|
}
|
|
|
|
#[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<HashMap<String, (Instant, f64)>>,
|
|
}
|
|
|
|
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<PtyManager>,
|
|
state: Arc<RwLock<McpState>>,
|
|
pending: Arc<PendingActions>,
|
|
rate_limiter: Arc<WriteRateLimiter>,
|
|
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<Self>,
|
|
}
|
|
|
|
#[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<usize>,
|
|
/// 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<u64>,
|
|
}
|
|
|
|
#[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<u64>,
|
|
/// Hard timeout in ms; returns timeout=true after this (default 30s,
|
|
/// hard cap 5 min).
|
|
#[serde(default)]
|
|
pub timeout_ms: Option<u64>,
|
|
}
|
|
|
|
#[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<String>,
|
|
cwd: Option<String>,
|
|
},
|
|
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<LeafId>,
|
|
/// "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<String>,
|
|
}
|
|
|
|
#[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<LeafId>,
|
|
/// Same semantics as spawn_pane.orientation.
|
|
#[serde(default)]
|
|
pub orientation: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// 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<String>,
|
|
/// TCP port. Defaults to 22 if omitted.
|
|
#[serde(default)]
|
|
pub port: Option<u16>,
|
|
/// Path to a private key. Passed to ssh as `-i`.
|
|
#[serde(default, rename = "identityFile")]
|
|
pub identity_file: Option<String>,
|
|
/// `user@host[:port]` jump host. Same validation as hostname.
|
|
#[serde(default, rename = "jumpHost")]
|
|
pub jump_host: Option<String>,
|
|
/// 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<Vec<String>>,
|
|
}
|
|
|
|
#[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<PtyManager>,
|
|
state: Arc<RwLock<McpState>>,
|
|
pending: Arc<PendingActions>,
|
|
rate_limiter: Arc<WriteRateLimiter>,
|
|
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<serde_json::Value, McpError> {
|
|
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<PaneId, McpError> {
|
|
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<ReadPaneArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<WaitForIdleArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<WritePaneArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<SpawnPaneArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
// 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<ConnectHostArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<AddHostArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<DeleteHostArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<SetLabelArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
// 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<CloseLeafArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<SwapPanesArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<PromotePaneArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
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<ApplyPresetArgs>,
|
|
) -> Result<CallToolResult, McpError> {
|
|
// 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<MirroredLeaf, McpError> {
|
|
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<PaginatedRequestParams>,
|
|
_: RequestContext<RoleServer>,
|
|
) -> Result<ListResourcesResult, McpError> {
|
|
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<RoleServer>,
|
|
) -> Result<ReadResourceResult, McpError> {
|
|
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<PaginatedRequestParams>,
|
|
_: RequestContext<RoleServer>,
|
|
) -> Result<ListResourceTemplatesResult, McpError> {
|
|
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<Arc<String>>,
|
|
headers: HeaderMap,
|
|
req: Request<Body>,
|
|
next: Next,
|
|
) -> Result<Response, Response> {
|
|
// 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<Option<RunningServer>>);
|
|
|
|
pub async fn start_server(
|
|
app_handle: AppHandle,
|
|
ptys: Arc<PtyManager>,
|
|
state: Arc<RwLock<McpState>>,
|
|
pending: Arc<PendingActions>,
|
|
) -> Result<RunningServer> {
|
|
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<WriteRateLimiter> = 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<String> {
|
|
let mut cfg = load_or_init_config(app)?;
|
|
cfg.token = generate_token();
|
|
save_config(app, &cfg)?;
|
|
Ok(cfg.token)
|
|
}
|
|
|