Add MCP server (v1 read-only): toggle, per-pane gate, panel UI
This commit is contained in:
parent
6068522ee3
commit
83d8932c98
15 changed files with 1235 additions and 7 deletions
16
README.md
16
README.md
|
|
@ -58,6 +58,22 @@ Font size persists per pane in `workspace.json`, so a zoomed pane stays zoomed a
|
||||||
|
|
||||||
Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\workspace.json` (debounced 500 ms).
|
Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\workspace.json` (debounced 500 ms).
|
||||||
|
|
||||||
|
### MCP server (Claude can drive the workspace)
|
||||||
|
|
||||||
|
The titlebar 🤖 button opens a small panel that starts an MCP (Model Context Protocol) server on `127.0.0.1`. A Claude session — running anywhere on the machine, including inside one of tiletopia's own panes — can connect to it, read scrollback, wait for commands to settle, and inspect the layout. v1 is **read-only**: no spawning, no keystroke injection, no host editing.
|
||||||
|
|
||||||
|
- **Off by default.** Click the button, hit **Server: ON** to start. The panel shows the bound URL + a randomly-generated bearer token and a ready-to-paste Claude config snippet.
|
||||||
|
- **Default-deny per pane.** Toggle the 🤖 chip in any pane's toolbar to allow MCP to see it. Panes without the chip on are invisible to the server.
|
||||||
|
- **Saved SSH passwords are never exposed** through the MCP surface.
|
||||||
|
- **WSL connectivity.** For Claude running inside WSL2 to reach the Windows-side server at `127.0.0.1`, set `networkingMode=mirrored` in `%UserProfile%\.wslconfig` (Win 11 22H2+):
|
||||||
|
|
||||||
|
```
|
||||||
|
[wsl2]
|
||||||
|
networkingMode=mirrored
|
||||||
|
```
|
||||||
|
|
||||||
|
Without mirrored mode you can still connect via the WSL gateway IP (default route).
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Tauri 2** (Rust backend, WebView2 frontend) — small bundle, native NSIS installer.
|
- **Tauri 2** (Rust backend, WebView2 frontend) — small bundle, native NSIS installer.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,22 @@ tauri-plugin-opener = "2"
|
||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
windows-native-keyring-store = "1"
|
windows-native-keyring-store = "1"
|
||||||
|
|
||||||
|
# Embedded MCP server: lets a Claude session drive the workspace
|
||||||
|
# (list panes, read scrollback, etc.). Streamable HTTP transport mounted
|
||||||
|
# on an Axum router so we can add a bearer-auth middleware in front.
|
||||||
|
rmcp = { version = "=1.7.0", features = [
|
||||||
|
"server",
|
||||||
|
"macros",
|
||||||
|
"schemars",
|
||||||
|
"transport-streamable-http-server",
|
||||||
|
] }
|
||||||
|
schemars = "1"
|
||||||
|
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
|
||||||
|
tower = "0.5"
|
||||||
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
|
rand = "0.9"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
//! Tauri command surface. Every JS-callable function lives here.
|
//! Tauri command surface. Every JS-callable function lives here.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::creds;
|
use crate::creds;
|
||||||
use crate::hosts::{self, SshHost, SshHostView};
|
use crate::hosts::{self, SshHost, SshHostView};
|
||||||
|
use crate::mcp::{self, McpMirror, McpServerHandle, McpState, RunningServer};
|
||||||
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
|
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
|
||||||
|
|
||||||
const WORKSPACE_FILE: &str = "workspace.json";
|
const WORKSPACE_FILE: &str = "workspace.json";
|
||||||
|
|
@ -17,7 +21,7 @@ pub async fn list_distros() -> Result<Vec<String>, String> {
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn spawn_pane(
|
pub async fn spawn_pane(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
manager: tauri::State<'_, PtyManager>,
|
manager: tauri::State<'_, Arc<PtyManager>>,
|
||||||
spec: SpawnSpec,
|
spec: SpawnSpec,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
|
|
@ -29,7 +33,7 @@ pub async fn spawn_pane(
|
||||||
/// strings; the frontend encodes before sending).
|
/// strings; the frontend encodes before sending).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn write_to_pane(
|
pub async fn write_to_pane(
|
||||||
manager: tauri::State<'_, PtyManager>,
|
manager: tauri::State<'_, Arc<PtyManager>>,
|
||||||
id: PaneId,
|
id: PaneId,
|
||||||
data_b64: String,
|
data_b64: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
|
@ -41,7 +45,7 @@ pub async fn write_to_pane(
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn resize_pane(
|
pub async fn resize_pane(
|
||||||
manager: tauri::State<'_, PtyManager>,
|
manager: tauri::State<'_, Arc<PtyManager>>,
|
||||||
id: PaneId,
|
id: PaneId,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
|
|
@ -51,7 +55,7 @@ pub async fn resize_pane(
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn kill_pane(
|
pub async fn kill_pane(
|
||||||
manager: tauri::State<'_, PtyManager>,
|
manager: tauri::State<'_, Arc<PtyManager>>,
|
||||||
id: PaneId,
|
id: PaneId,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
manager.kill(id).map_err(|e| e.to_string())
|
manager.kill(id).map_err(|e| e.to_string())
|
||||||
|
|
@ -137,3 +141,80 @@ pub async fn delete_host_password(host_id: String) -> Result<(), String> {
|
||||||
pub async fn has_host_password(host_id: String) -> Result<bool, String> {
|
pub async fn has_host_password(host_id: String) -> Result<bool, String> {
|
||||||
Ok(creds::has(&host_id))
|
Ok(creds::has(&host_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- MCP server lifecycle --------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct McpStatus {
|
||||||
|
pub running: bool,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_status(handle: &McpServerHandle) -> McpStatus {
|
||||||
|
let g = handle.0.lock();
|
||||||
|
match g.as_ref() {
|
||||||
|
Some(srv) => McpStatus {
|
||||||
|
running: true,
|
||||||
|
url: Some(format!("http://{}/mcp", srv.addr)),
|
||||||
|
token: Some(srv.token.clone()),
|
||||||
|
},
|
||||||
|
None => McpStatus {
|
||||||
|
running: false,
|
||||||
|
url: None,
|
||||||
|
token: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mcp_start(
|
||||||
|
ptys: tauri::State<'_, Arc<PtyManager>>,
|
||||||
|
state: tauri::State<'_, Arc<RwLock<McpState>>>,
|
||||||
|
handle: tauri::State<'_, McpServerHandle>,
|
||||||
|
) -> Result<McpStatus, String> {
|
||||||
|
{
|
||||||
|
let g = handle.0.lock();
|
||||||
|
if g.is_some() {
|
||||||
|
return Ok(server_status(&handle));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
|
||||||
|
let state_arc: Arc<RwLock<McpState>> = (*state).clone();
|
||||||
|
let running: RunningServer = mcp::start_server(ptys_arc, state_arc)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
{
|
||||||
|
let mut g = handle.0.lock();
|
||||||
|
*g = Some(running);
|
||||||
|
}
|
||||||
|
Ok(server_status(&handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mcp_stop(
|
||||||
|
handle: tauri::State<'_, McpServerHandle>,
|
||||||
|
) -> Result<McpStatus, String> {
|
||||||
|
mcp::stop_server(&handle);
|
||||||
|
Ok(server_status(&handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mcp_status(
|
||||||
|
handle: tauri::State<'_, McpServerHandle>,
|
||||||
|
) -> Result<McpStatus, String> {
|
||||||
|
Ok(server_status(&handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frontend pushes the gated mirror after every tree/host change. Backend
|
||||||
|
/// caches it for MCP responses — the MCP server only ever sees what the
|
||||||
|
/// frontend chose to mirror (default-deny per-leaf gate).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mcp_update_state(
|
||||||
|
state: tauri::State<'_, Arc<RwLock<McpState>>>,
|
||||||
|
mirror: McpMirror,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut g = state.write().await;
|
||||||
|
g.mirror = mirror;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@
|
||||||
mod commands;
|
mod commands;
|
||||||
mod creds;
|
mod creds;
|
||||||
mod hosts;
|
mod hosts;
|
||||||
|
mod mcp;
|
||||||
mod pty;
|
mod pty;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::mcp::{McpServerHandle, McpState};
|
||||||
use crate::pty::PtyManager;
|
use crate::pty::PtyManager;
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
|
@ -26,10 +30,19 @@ pub fn run() {
|
||||||
Err(e) => tracing::warn!("keyring store init failed: {e}"),
|
Err(e) => tracing::warn!("keyring store init failed: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PtyManager and McpState are shared with the MCP server, so register
|
||||||
|
// them as Arc<T> rather than the plain T. Tauri commands access them
|
||||||
|
// via `tauri::State<'_, Arc<T>>` and deref / clone as needed.
|
||||||
|
let ptys: Arc<PtyManager> = Arc::new(PtyManager::new());
|
||||||
|
let mcp_state: Arc<tokio::sync::RwLock<McpState>> =
|
||||||
|
Arc::new(tokio::sync::RwLock::new(McpState::default()));
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(PtyManager::new())
|
.manage(ptys)
|
||||||
|
.manage(mcp_state)
|
||||||
|
.manage(McpServerHandle::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::list_distros,
|
commands::list_distros,
|
||||||
commands::spawn_pane,
|
commands::spawn_pane,
|
||||||
|
|
@ -43,6 +56,10 @@ pub fn run() {
|
||||||
commands::set_host_password,
|
commands::set_host_password,
|
||||||
commands::delete_host_password,
|
commands::delete_host_password,
|
||||||
commands::has_host_password,
|
commands::has_host_password,
|
||||||
|
commands::mcp_start,
|
||||||
|
commands::mcp_stop,
|
||||||
|
commands::mcp_status,
|
||||||
|
commands::mcp_update_state,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
464
src-tauri/src/mcp.rs
Normal file
464
src-tauri/src/mcp.rs
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
//! Embedded MCP server. Lets a Claude session running anywhere on the
|
||||||
|
//! same machine — including inside one of tiletopia's own panes — inspect
|
||||||
|
//! the workspace via Model Context Protocol.
|
||||||
|
//!
|
||||||
|
//! V1 surface (read-only):
|
||||||
|
//! resources: tiletopia://layout, tiletopia://panes, tiletopia://hosts
|
||||||
|
//! tools: read_pane(leaf_id, last_lines?, after_seq?)
|
||||||
|
//! wait_for_idle(leaf_id, idle_ms?, timeout_ms?)
|
||||||
|
//!
|
||||||
|
//! 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::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::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::StreamableHttpService,
|
||||||
|
},
|
||||||
|
ErrorData as McpError, RoleServer, ServerHandler,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::{net::TcpListener, sync::RwLock, task::JoinHandle};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
use crate::pty::{PaneId, PtyManager};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// MCP service: tools + resources.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TileService {
|
||||||
|
ptys: Arc<PtyManager>,
|
||||||
|
state: Arc<RwLock<McpState>>,
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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>>) -> Self {
|
||||||
|
Self {
|
||||||
|
ptys,
|
||||||
|
state,
|
||||||
|
tool_router: Self::tool_router(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_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 (read-only v1). Resources: tiletopia://layout, \
|
||||||
|
tiletopia://panes, tiletopia://hosts. Tools: read_pane, \
|
||||||
|
wait_for_idle. Only panes the user has allow-listed are \
|
||||||
|
visible.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
contents: 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> {
|
||||||
|
let supplied = headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.strip_prefix("Bearer "));
|
||||||
|
let ok = supplied
|
||||||
|
.map(|t| constant_time_eq(t.as_bytes(), expected.as_bytes()))
|
||||||
|
.unwrap_or(false);
|
||||||
|
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(
|
||||||
|
ptys: Arc<PtyManager>,
|
||||||
|
state: Arc<RwLock<McpState>>,
|
||||||
|
) -> Result<RunningServer> {
|
||||||
|
// 256-bit bearer token, hex-encoded.
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut buf = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut buf);
|
||||||
|
let token = hex::encode(buf);
|
||||||
|
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 mcp_service = StreamableHttpService::new(
|
||||||
|
move || Ok(TileService::new(ptys_f.clone(), state_f.clone())),
|
||||||
|
LocalSessionManager::default().into(),
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.nest_service("/mcp", mcp_service)
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
Arc::new(token.clone()),
|
||||||
|
bearer_auth,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Port 0 → OS picks. Recover via local_addr() before serving.
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||||
|
let addr = listener.local_addr()?;
|
||||||
|
|
||||||
|
let cancel_inner = cancel.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let _ = axum::serve(listener, app)
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//! through portable-pty, reads its output on a background thread, and
|
//! through portable-pty, reads its output on a background thread, and
|
||||||
//! forwards chunks to the frontend as `pane://{id}/data` events.
|
//! forwards chunks to the frontend as `pane://{id}/data` events.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -52,6 +52,41 @@ pub enum SpawnSpec {
|
||||||
/// the SSH prompt.
|
/// the SSH prompt.
|
||||||
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
|
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
|
||||||
|
|
||||||
|
/// Per-pane scrollback ring exposed to the MCP server. Capped — we drop the
|
||||||
|
/// oldest bytes when full. `seq` is a monotonic byte counter that wraps at
|
||||||
|
/// u64; the MCP `read_pane` tool uses it for incremental polling and the
|
||||||
|
/// `wait_for_idle` tool uses it to detect silence.
|
||||||
|
pub const PANE_RING_CAPACITY: usize = 256 * 1024;
|
||||||
|
|
||||||
|
pub struct PaneRing {
|
||||||
|
buf: VecDeque<u8>,
|
||||||
|
seq: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaneRing {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
buf: VecDeque::with_capacity(PANE_RING_CAPACITY),
|
||||||
|
seq: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, bytes: &[u8]) {
|
||||||
|
for &b in bytes {
|
||||||
|
if self.buf.len() == PANE_RING_CAPACITY {
|
||||||
|
self.buf.pop_front();
|
||||||
|
}
|
||||||
|
self.buf.push_back(b);
|
||||||
|
}
|
||||||
|
self.seq = self.seq.wrapping_add(bytes.len() as u64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot: current contents (oldest-first) + the seq counter.
|
||||||
|
pub fn snapshot(&self) -> (Vec<u8>, u64) {
|
||||||
|
(self.buf.iter().copied().collect(), self.seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What we keep alive for each spawned PTY.
|
/// What we keep alive for each spawned PTY.
|
||||||
///
|
///
|
||||||
/// `master` stays in scope to keep the PTY alive; we never write through it
|
/// `master` stays in scope to keep the PTY alive; we never write through it
|
||||||
|
|
@ -63,6 +98,9 @@ struct PaneHandle {
|
||||||
writer: SharedWriter,
|
writer: SharedWriter,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||||
|
/// Same Arc the reader thread appends into; the MCP server reads via
|
||||||
|
/// {@link PtyManager::ring}.
|
||||||
|
ring: Arc<Mutex<PaneRing>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PtyManager {
|
pub struct PtyManager {
|
||||||
|
|
@ -127,6 +165,7 @@ impl PtyManager {
|
||||||
.take_writer()
|
.take_writer()
|
||||||
.context("take_writer failed")?;
|
.context("take_writer failed")?;
|
||||||
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
|
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
|
||||||
|
let ring: Arc<Mutex<PaneRing>> = Arc::new(Mutex::new(PaneRing::new()));
|
||||||
|
|
||||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
|
@ -136,14 +175,18 @@ impl PtyManager {
|
||||||
master: pair.master,
|
master: pair.master,
|
||||||
writer: writer.clone(),
|
writer: writer.clone(),
|
||||||
child,
|
child,
|
||||||
|
ring: ring.clone(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reader thread: pump bytes -> base64 -> emit. Also handles the
|
// Reader thread: pump bytes -> base64 -> emit. Also handles the
|
||||||
// password-prompt autotype state machine if `saved_password` is set.
|
// password-prompt autotype state machine if `saved_password` is set,
|
||||||
|
// and pushes raw bytes into the per-pane scrollback ring for the
|
||||||
|
// MCP server to read.
|
||||||
let app_for_reader = app.clone();
|
let app_for_reader = app.clone();
|
||||||
let event_name = format!("pane://{id}/data");
|
let event_name = format!("pane://{id}/data");
|
||||||
let writer_for_reader = writer.clone();
|
let writer_for_reader = writer.clone();
|
||||||
|
let ring_for_reader = ring.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut buf = [0u8; 8192];
|
let mut buf = [0u8; 8192];
|
||||||
let mut pw_state = PasswordState::from(saved_password);
|
let mut pw_state = PasswordState::from(saved_password);
|
||||||
|
|
@ -159,6 +202,9 @@ impl PtyManager {
|
||||||
// on the renderer; pw_state mutates here.
|
// on the renderer; pw_state mutates here.
|
||||||
pw_state.observe(&buf[..n], &writer_for_reader, id);
|
pw_state.observe(&buf[..n], &writer_for_reader, id);
|
||||||
|
|
||||||
|
// Mirror bytes into the scrollback ring (MCP source).
|
||||||
|
ring_for_reader.lock().push(&buf[..n]);
|
||||||
|
|
||||||
let chunk_b64 = B64.encode(&buf[..n]);
|
let chunk_b64 = B64.encode(&buf[..n]);
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
|
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
|
||||||
|
|
@ -217,6 +263,13 @@ impl PtyManager {
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrow the per-pane scrollback ring. Returns None if the pane has
|
||||||
|
/// been killed. The Arc lets callers hold the ring even after the
|
||||||
|
/// PaneHandle is dropped (reader thread will stop pushing into it).
|
||||||
|
pub fn ring(&self, id: PaneId) -> Option<Arc<Mutex<PaneRing>>> {
|
||||||
|
self.panes.lock().get(&id).map(|p| p.ring.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@
|
||||||
background: #2a2010;
|
background: #2a2010;
|
||||||
color: #c98a1f;
|
color: #c98a1f;
|
||||||
}
|
}
|
||||||
|
.palette-btn.mcp-btn.on {
|
||||||
|
background: #1a3a1a;
|
||||||
|
color: #80e080;
|
||||||
|
border-color: #2a6a2a;
|
||||||
|
}
|
||||||
.preset-btn {
|
.preset-btn {
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
123
src/App.tsx
123
src/App.tsx
|
|
@ -7,10 +7,18 @@ import {
|
||||||
saveSshHosts,
|
saveSshHosts,
|
||||||
setHostPassword,
|
setHostPassword,
|
||||||
deleteHostPassword,
|
deleteHostPassword,
|
||||||
|
mcpStart,
|
||||||
|
mcpStop,
|
||||||
|
mcpStatus as mcpStatusCmd,
|
||||||
|
mcpUpdateState,
|
||||||
writeToPane,
|
writeToPane,
|
||||||
killPane,
|
killPane,
|
||||||
type PaneId,
|
type PaneId,
|
||||||
type SshHost,
|
type SshHost,
|
||||||
|
type McpStatus,
|
||||||
|
type McpMirror,
|
||||||
|
type McpMirroredLeaf,
|
||||||
|
type McpMirroredHost,
|
||||||
} from "./ipc";
|
} from "./ipc";
|
||||||
import {
|
import {
|
||||||
type TreeNode,
|
type TreeNode,
|
||||||
|
|
@ -27,6 +35,7 @@ import {
|
||||||
setLeafShell,
|
setLeafShell,
|
||||||
changeLabel,
|
changeLabel,
|
||||||
toggleBroadcast as toggleBroadcastInTree,
|
toggleBroadcast as toggleBroadcastInTree,
|
||||||
|
toggleMcpAllow as toggleMcpAllowInTree,
|
||||||
setAllBroadcast,
|
setAllBroadcast,
|
||||||
adjustFontSize,
|
adjustFontSize,
|
||||||
adjustAllFontSizes,
|
adjustAllFontSizes,
|
||||||
|
|
@ -53,6 +62,7 @@ import Notifications, { type Toast } from "./components/Notifications";
|
||||||
import Palette from "./components/Palette";
|
import Palette from "./components/Palette";
|
||||||
import HostManager from "./components/HostManager";
|
import HostManager from "./components/HostManager";
|
||||||
import Help from "./components/Help";
|
import Help from "./components/Help";
|
||||||
|
import McpPanel from "./components/McpPanel";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import "./lib/layout/Gutter.css";
|
import "./lib/layout/Gutter.css";
|
||||||
|
|
||||||
|
|
@ -86,6 +96,12 @@ export default function App() {
|
||||||
const [hosts, setHosts] = useState<SshHost[]>([]);
|
const [hosts, setHosts] = useState<SshHost[]>([]);
|
||||||
const [hostManagerOpen, setHostManagerOpen] = useState(false);
|
const [hostManagerOpen, setHostManagerOpen] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
const [mcpStatus, setMcpStatus] = useState<McpStatus>({
|
||||||
|
running: false,
|
||||||
|
url: null,
|
||||||
|
token: null,
|
||||||
|
});
|
||||||
|
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [notifications, setNotifications] = useState<Toast[]>([]);
|
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
@ -261,6 +277,46 @@ export default function App() {
|
||||||
setTree((t) => toggleBroadcastInTree(t, leafId));
|
setTree((t) => toggleBroadcastInTree(t, leafId));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleMcpAllow = useCallback((leafId: NodeId) => {
|
||||||
|
setTree((t) => toggleMcpAllowInTree(t, leafId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- MCP server lifecycle ------------------------------------------------
|
||||||
|
const refreshMcpStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const st = await mcpStatusCmd();
|
||||||
|
setMcpStatus(st);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("mcpStatus failed:", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startMcp = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const st = await mcpStart();
|
||||||
|
setMcpStatus(st);
|
||||||
|
notify("MCP server started — see panel for URL + token");
|
||||||
|
} catch (e) {
|
||||||
|
notify(`MCP start failed: ${e}`);
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
const stopMcp = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const st = await mcpStop();
|
||||||
|
setMcpStatus(st);
|
||||||
|
notify("MCP server stopped");
|
||||||
|
} catch (e) {
|
||||||
|
notify(`MCP stop failed: ${e}`);
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
// On mount, sync our local mcpStatus with whatever's already running
|
||||||
|
// (the backend persists state across HMR reloads).
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshMcpStatus();
|
||||||
|
}, [refreshMcpStatus]);
|
||||||
|
|
||||||
// Ctrl+Shift+P: pop the active leaf out one level. The keyboard
|
// Ctrl+Shift+P: pop the active leaf out one level. The keyboard
|
||||||
// replacement for the (removed) drag-past-sibling gesture. No-op with a
|
// replacement for the (removed) drag-past-sibling gesture. No-op with a
|
||||||
// toast if the leaf is at the root or its parent shares orientation
|
// toast if the leaf is at the root or its parent shares orientation
|
||||||
|
|
@ -532,6 +588,7 @@ export default function App() {
|
||||||
setShell,
|
setShell,
|
||||||
setLabel,
|
setLabel,
|
||||||
toggleBroadcast,
|
toggleBroadcast,
|
||||||
|
toggleMcpAllow,
|
||||||
openHostManager,
|
openHostManager,
|
||||||
setActive,
|
setActive,
|
||||||
registerPaneId,
|
registerPaneId,
|
||||||
|
|
@ -553,6 +610,7 @@ export default function App() {
|
||||||
setShell,
|
setShell,
|
||||||
setLabel,
|
setLabel,
|
||||||
toggleBroadcast,
|
toggleBroadcast,
|
||||||
|
toggleMcpAllow,
|
||||||
openHostManager,
|
openHostManager,
|
||||||
setActive,
|
setActive,
|
||||||
registerPaneId,
|
registerPaneId,
|
||||||
|
|
@ -567,6 +625,47 @@ export default function App() {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- MCP mirror push -----------------------------------------------------
|
||||||
|
// Whenever the tree, hosts, or active selection change AND the MCP server
|
||||||
|
// is running, push a fresh mirror down to the backend. Per-leaf mcpAllow
|
||||||
|
// gates whether each leaf appears in the mirror (default-deny).
|
||||||
|
const allowedPaneCount = useMemo(
|
||||||
|
() => Array.from(walkLeaves(tree)).filter((l) => l.mcpAllow).length,
|
||||||
|
[tree],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mcpStatus.running) return;
|
||||||
|
const leaves: Record<string, McpMirroredLeaf> = {};
|
||||||
|
for (const leaf of walkLeaves(tree)) {
|
||||||
|
if (!leaf.mcpAllow) continue;
|
||||||
|
leaves[leaf.id] = {
|
||||||
|
paneId: paneIdByLeafRef.current.get(leaf.id) ?? null,
|
||||||
|
label: leaf.label,
|
||||||
|
shellKind: leaf.shellKind,
|
||||||
|
distro: leaf.distro,
|
||||||
|
sshHostId: leaf.sshHostId,
|
||||||
|
broadcast: !!leaf.broadcast,
|
||||||
|
active: activeLeafId === leaf.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const mirroredHosts: McpMirroredHost[] = hosts.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
label: h.label,
|
||||||
|
hostname: h.hostname,
|
||||||
|
user: h.user,
|
||||||
|
port: h.port,
|
||||||
|
hasPassword: !!h.hasPassword,
|
||||||
|
}));
|
||||||
|
const mirror: McpMirror = {
|
||||||
|
layoutJson: serialize(tree),
|
||||||
|
leaves,
|
||||||
|
hosts: mirroredHosts,
|
||||||
|
};
|
||||||
|
mcpUpdateState(mirror).catch((e) =>
|
||||||
|
console.warn("mcpUpdateState failed:", e),
|
||||||
|
);
|
||||||
|
}, [mcpStatus.running, tree, hosts, activeLeafId]);
|
||||||
|
|
||||||
const applyPreset = useCallback(
|
const applyPreset = useCallback(
|
||||||
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
||||||
const { tree: nextTree, dropped } = reshapeToPreset(
|
const { tree: nextTree, dropped } = reshapeToPreset(
|
||||||
|
|
@ -723,6 +822,19 @@ export default function App() {
|
||||||
>
|
>
|
||||||
🔔
|
🔔
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
|
||||||
|
onClick={() => setMcpPanelOpen(true)}
|
||||||
|
title={
|
||||||
|
mcpStatus.running
|
||||||
|
? `MCP server running (${allowedPaneCount} of ${leafCount(tree)} panes visible) — click for details`
|
||||||
|
: "MCP server is OFF — click to configure / start"
|
||||||
|
}
|
||||||
|
aria-label="MCP server"
|
||||||
|
aria-pressed={mcpStatus.running ? "true" : "false"}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="palette-btn"
|
className="palette-btn"
|
||||||
onClick={() => setHelpOpen(true)}
|
onClick={() => setHelpOpen(true)}
|
||||||
|
|
@ -794,6 +906,17 @@ export default function App() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{helpOpen && <Help onClose={() => setHelpOpen(false)} />}
|
{helpOpen && <Help onClose={() => setHelpOpen(false)} />}
|
||||||
|
|
||||||
|
{mcpPanelOpen && (
|
||||||
|
<McpPanel
|
||||||
|
status={mcpStatus}
|
||||||
|
onStart={startMcp}
|
||||||
|
onStop={stopMcp}
|
||||||
|
onClose={() => setMcpPanelOpen(false)}
|
||||||
|
allowedPaneCount={allowedPaneCount}
|
||||||
|
totalPaneCount={leafCount(tree)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
191
src/components/McpPanel.css
Normal file
191
src/components/McpPanel.css
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
.mcp-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 8vh;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(680px, 92vw);
|
||||||
|
max-height: 84vh;
|
||||||
|
background: #161616;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
.mcp-title { font-weight: 600; font-size: 13px; }
|
||||||
|
.mcp-close {
|
||||||
|
background: transparent; border: none; color: #888;
|
||||||
|
font-size: 18px; line-height: 1; padding: 2px 8px;
|
||||||
|
cursor: pointer; border-radius: 3px;
|
||||||
|
}
|
||||||
|
.mcp-close:hover { background: #2a2a2a; color: #ddd; }
|
||||||
|
|
||||||
|
.mcp-body {
|
||||||
|
padding: 14px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-blurb {
|
||||||
|
color: #aaa;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.mcp-toggle {
|
||||||
|
font: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #222;
|
||||||
|
color: #999;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mcp-toggle:hover:not(:disabled) { background: #2a2a2a; color: #ddd; }
|
||||||
|
.mcp-toggle:disabled { opacity: 0.5; cursor: progress; }
|
||||||
|
.mcp-toggle.on {
|
||||||
|
background: #1a3a1a;
|
||||||
|
color: #80e080;
|
||||||
|
border-color: #2a6a2a;
|
||||||
|
}
|
||||||
|
.mcp-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
.mcp-toggle.on .mcp-dot {
|
||||||
|
background: #80e080;
|
||||||
|
box-shadow: 0 0 6px rgba(128, 224, 128, 0.6);
|
||||||
|
}
|
||||||
|
.mcp-allow-count {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.mcp-allow-warn {
|
||||||
|
color: #d8a040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.mcp-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #777;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.mcp-field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.mcp-field input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #e6e6e6;
|
||||||
|
background: #0c0c0c;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.mcp-field button {
|
||||||
|
font: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #222;
|
||||||
|
color: #aac;
|
||||||
|
border: 1px solid #2a2a3a;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mcp-field button:hover {
|
||||||
|
background: #2a2a3a;
|
||||||
|
color: #ccd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-snippet {
|
||||||
|
font: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #0c0c0c;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #cce6ff;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tips {
|
||||||
|
background: #1a2030;
|
||||||
|
border: 1px solid #2a3040;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #aac;
|
||||||
|
font-size: 11px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.mcp-tips strong { color: #cce6ff; }
|
||||||
|
.mcp-tips code {
|
||||||
|
background: #0c0c0c;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.mcp-tips pre {
|
||||||
|
font: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #0c0c0c;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 4px 0;
|
||||||
|
color: #cce6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-off-hint {
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-security {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
color: #888;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.mcp-security strong { color: #d8a040; }
|
||||||
|
.mcp-security em { color: #d88; font-style: normal; }
|
||||||
191
src/components/McpPanel.tsx
Normal file
191
src/components/McpPanel.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
writeText as clipboardWriteText,
|
||||||
|
} from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import type { McpStatus } from "../ipc";
|
||||||
|
import "./McpPanel.css";
|
||||||
|
|
||||||
|
interface McpPanelProps {
|
||||||
|
status: McpStatus;
|
||||||
|
onStart: () => Promise<void>;
|
||||||
|
onStop: () => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Count of leaves with mcpAllow=true — shown so the user knows whether
|
||||||
|
* enabling the server will actually expose anything. */
|
||||||
|
allowedPaneCount: number;
|
||||||
|
/** Total pane count for context. */
|
||||||
|
totalPaneCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function McpPanel({
|
||||||
|
status,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onClose,
|
||||||
|
allowedPaneCount,
|
||||||
|
totalPaneCount,
|
||||||
|
}: McpPanelProps) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [revealToken, setRevealToken] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const toggle = useCallback(async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (status.running) await onStop();
|
||||||
|
else await onStart();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [busy, status.running, onStart, onStop]);
|
||||||
|
|
||||||
|
const copy = useCallback((s: string) => {
|
||||||
|
void clipboardWriteText(s).catch((e) =>
|
||||||
|
console.warn("clipboard write failed:", e),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="backdrop" onClick={onClose} aria-label="Close" />
|
||||||
|
<div className="mcp-panel" role="dialog" aria-label="MCP server">
|
||||||
|
<header className="mcp-header">
|
||||||
|
<span className="mcp-title">MCP server</span>
|
||||||
|
<button className="mcp-close" onClick={onClose} aria-label="Close">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mcp-body">
|
||||||
|
<p className="mcp-blurb">
|
||||||
|
Lets a Claude session on the same machine inspect this workspace
|
||||||
|
via Model Context Protocol — see which panes are running, read
|
||||||
|
their scrollback, wait for commands to settle. Read-only in v1;
|
||||||
|
Claude can't send keystrokes or reshape the layout yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mcp-toggle-row">
|
||||||
|
<button
|
||||||
|
className={`mcp-toggle${status.running ? " on" : ""}`}
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<span className="mcp-dot" />
|
||||||
|
{status.running ? "Server: ON" : "Server: OFF"}
|
||||||
|
</button>
|
||||||
|
<span className="mcp-allow-count">
|
||||||
|
{allowedPaneCount} of {totalPaneCount} pane
|
||||||
|
{totalPaneCount === 1 ? "" : "s"} allow-listed
|
||||||
|
{allowedPaneCount === 0 && status.running && (
|
||||||
|
<span className="mcp-allow-warn">
|
||||||
|
{" "}
|
||||||
|
— Claude will see nothing until you toggle 🤖 on at least
|
||||||
|
one pane.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.running && status.url && status.token && (
|
||||||
|
<>
|
||||||
|
<div className="mcp-field">
|
||||||
|
<label>URL</label>
|
||||||
|
<div className="mcp-field-row">
|
||||||
|
<input readOnly value={status.url} onFocus={(e) => e.currentTarget.select()} />
|
||||||
|
<button onClick={() => copy(status.url!)}>Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mcp-field">
|
||||||
|
<label>Bearer token</label>
|
||||||
|
<div className="mcp-field-row">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
type={revealToken ? "text" : "password"}
|
||||||
|
value={status.token}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
/>
|
||||||
|
<button onClick={() => setRevealToken((r) => !r)}>
|
||||||
|
{revealToken ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => copy(status.token!)}>Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mcp-field">
|
||||||
|
<label>Claude config snippet</label>
|
||||||
|
<pre className="mcp-snippet">
|
||||||
|
{`{
|
||||||
|
"mcpServers": {
|
||||||
|
"tiletopia": {
|
||||||
|
"url": "${status.url}",
|
||||||
|
"headers": { "Authorization": "Bearer ${status.token}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
copy(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
tiletopia: {
|
||||||
|
url: status.url,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${status.token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Copy config snippet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mcp-tips">
|
||||||
|
<strong>WSL connectivity:</strong> for Claude running inside
|
||||||
|
WSL to reach this server, enable mirrored networking in your
|
||||||
|
<code> %UserProfile%\.wslconfig</code> (Win11 22H2+):
|
||||||
|
<pre>{`[wsl2]
|
||||||
|
networkingMode=mirrored`}</pre>
|
||||||
|
Then <code>127.0.0.1</code> in WSL routes to this Windows
|
||||||
|
host. Without mirrored mode you'll need to use the WSL
|
||||||
|
gateway IP.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!status.running && (
|
||||||
|
<p className="mcp-off-hint">
|
||||||
|
Server is off — no port is open. Token is generated when you
|
||||||
|
start. Each pane needs the 🤖 chip toggled on for Claude to
|
||||||
|
see it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mcp-security">
|
||||||
|
<strong>Security:</strong> bound to 127.0.0.1 only. Anyone on
|
||||||
|
this machine running as you can read the bearer token if they
|
||||||
|
see it (e.g. via this UI or by guessing the localhost port).
|
||||||
|
Treat MCP access as equivalent to terminal access. Saved SSH
|
||||||
|
passwords are <em>never</em> exposed through MCP.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/ipc.ts
42
src/ipc.ts
|
|
@ -90,3 +90,45 @@ export const deleteHostPassword = (hostId: string): Promise<void> =>
|
||||||
|
|
||||||
export const hasHostPassword = (hostId: string): Promise<boolean> =>
|
export const hasHostPassword = (hostId: string): Promise<boolean> =>
|
||||||
invoke("has_host_password", { hostId });
|
invoke("has_host_password", { hostId });
|
||||||
|
|
||||||
|
// ---- MCP server -----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface McpStatus {
|
||||||
|
running: boolean;
|
||||||
|
url: string | null;
|
||||||
|
token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of the cached mirror we push to the backend on every workspace
|
||||||
|
* change. Mirrors src-tauri/src/mcp.rs `McpMirror`. */
|
||||||
|
export interface McpMirror {
|
||||||
|
layoutJson: string;
|
||||||
|
/** Only includes leaves with mcpAllow === true. */
|
||||||
|
leaves: Record<string, McpMirroredLeaf>;
|
||||||
|
hosts: McpMirroredHost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpMirroredLeaf {
|
||||||
|
paneId: number | null;
|
||||||
|
label?: string;
|
||||||
|
shellKind: "wsl" | "powershell" | "ssh";
|
||||||
|
distro?: string;
|
||||||
|
sshHostId?: string;
|
||||||
|
broadcast: boolean;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpMirroredHost {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
user?: string;
|
||||||
|
port?: number;
|
||||||
|
hasPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mcpStart = (): Promise<McpStatus> => invoke("mcp_start");
|
||||||
|
export const mcpStop = (): Promise<McpStatus> => invoke("mcp_stop");
|
||||||
|
export const mcpStatus = (): Promise<McpStatus> => invoke("mcp_status");
|
||||||
|
export const mcpUpdateState = (mirror: McpMirror): Promise<void> =>
|
||||||
|
invoke("mcp_update_state", { mirror });
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,12 @@
|
||||||
color: #f0c060;
|
color: #f0c060;
|
||||||
border-color: #c98a1f;
|
border-color: #c98a1f;
|
||||||
}
|
}
|
||||||
|
.bcast-chip.mcp-chip.on {
|
||||||
|
/* Green for MCP-allowed — clearly distinct from broadcast's orange. */
|
||||||
|
background: #1a3a1a;
|
||||||
|
color: #80e080;
|
||||||
|
border-color: #2a6a2a;
|
||||||
|
}
|
||||||
|
|
||||||
.distro-menu {
|
.distro-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
📡
|
📡
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`bcast-chip mcp-chip${leaf.mcpAllow ? " on" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
orch.toggleMcpAllow(leaf.id);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
leaf.mcpAllow
|
||||||
|
? "MCP can see this pane — click to revoke"
|
||||||
|
: "MCP cannot see this pane — click to allow (only matters when the MCP server is on)"
|
||||||
|
}
|
||||||
|
aria-pressed={leaf.mcpAllow ? "true" : "false"}
|
||||||
|
>
|
||||||
|
🤖
|
||||||
|
</button>
|
||||||
|
|
||||||
{isIdle && statusOk ? (
|
{isIdle && statusOk ? (
|
||||||
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
|
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
|
||||||
idle
|
idle
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ export interface Orchestration {
|
||||||
setShell: (leafId: NodeId, spec: LeafShellSpec) => void;
|
setShell: (leafId: NodeId, spec: LeafShellSpec) => void;
|
||||||
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||||
toggleBroadcast: (leafId: NodeId) => void;
|
toggleBroadcast: (leafId: NodeId) => void;
|
||||||
|
/** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane
|
||||||
|
* toolbar drives this. */
|
||||||
|
toggleMcpAllow: (leafId: NodeId) => void;
|
||||||
|
|
||||||
// SSH host management
|
// SSH host management
|
||||||
openHostManager: () => void;
|
openHostManager: () => void;
|
||||||
|
|
|
||||||
|
|
@ -108,4 +108,8 @@ export const TIPS: TipSpec[] = [
|
||||||
title: "Workspace persistence",
|
title: "Workspace persistence",
|
||||||
body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.",
|
body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "MCP server (let Claude drive the workspace)",
|
||||||
|
body: "Titlebar 🤖 opens the MCP control panel — start the server, copy the URL + bearer token into your Claude client config, and Claude can read scrollback / wait for commands to settle. Default-deny per pane: toggle 🤖 on each pane's toolbar to make it visible to MCP. Read-only in v1 (no spawn or write yet). For Claude inside WSL, enable mirrored networking in .wslconfig.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue