MCP polish + SSH host manager Connect button
Three small things bundled from PR-3 verification:
1. Split SSH out of mcp.spawn_pane schema. New McpSpawnSpec enum
(Wsl | Powershell only) used for SpawnPaneArgs, so Claude's
spawn_pane tool description and JSON schema show only the local
shells. SSH must go through connect_host. The internal
pty::SpawnSpec is unchanged — the frontend's manual spawn path
via XtermPane still supports all three variants. Previously
spawn_pane(kind=ssh) was a half-broken path that required `host`
as a separate mandatory field even when hostId was given;
serde-rejected the natural "spawn to a saved host" call shape.
2. Refresh the MCP server's `with_instructions` text and the
module-level header comment. Both still claimed "read-only v1"
long after the v2 write surface landed, which was making Claude
refuse to attempt tools on first contact ("the server has
flagged itself as read-only..."). The instructions now describe
the actual tool set, the SSH-via-connect_host convention, and
the policy/safeguards gates so Claude doesn't have to infer.
3. Add a "Connect" button to the SSH hosts manager. Previously
the dialog only had Edit — users (rightly) expected clicking a
saved host to spawn an SSH pane to it. New onConnect callback
does the splitLeaf + smart-orient dance and closes the manager.
Buttons wrapped in a flex container so the row's
space-between layout doesn't strand the new button mid-row.
This commit is contained in:
parent
bf2810a433
commit
6da7523993
4 changed files with 144 additions and 52 deletions
|
|
@ -1,11 +1,21 @@
|
||||||
//! Embedded MCP server. Lets a Claude session running anywhere on the
|
//! Embedded MCP server. Lets a Claude session running anywhere on the
|
||||||
//! same machine — including inside one of tiletopia's own panes — inspect
|
//! same machine — including inside one of tiletopia's own panes — inspect
|
||||||
//! the workspace via Model Context Protocol.
|
//! and drive the workspace via Model Context Protocol.
|
||||||
//!
|
//!
|
||||||
//! V1 surface (read-only):
|
//! Resources (read-only):
|
||||||
//! resources: tiletopia://layout, tiletopia://panes, tiletopia://hosts
|
//! tiletopia://layout, tiletopia://panes, tiletopia://hosts
|
||||||
//! tools: read_pane(leaf_id, last_lines?, after_seq?)
|
//!
|
||||||
//! wait_for_idle(leaf_id, idle_ms?, timeout_ms?)
|
//! Read tools:
|
||||||
|
//! read_pane(leaf_id, last_lines?, after_seq?)
|
||||||
|
//! wait_for_idle(leaf_id, idle_ms?, timeout_ms?)
|
||||||
|
//!
|
||||||
|
//! Write tools (all go through dispatch_action → user policy → confirm
|
||||||
|
//! modal → audit):
|
||||||
|
//! set_label, close_pane, swap_panes, promote_pane, apply_preset
|
||||||
|
//! spawn_pane (local WSL / PowerShell only)
|
||||||
|
//! connect_host (SSH to a saved host id — the only SSH path)
|
||||||
|
//! write_pane (rate-limited per pane; matched against a non-overridable
|
||||||
|
//! hard-deny list before user policy)
|
||||||
//!
|
//!
|
||||||
//! Per-pane `mcpAllow` gate (default-deny) lives in the frontend tree;
|
//! Per-pane `mcpAllow` gate (default-deny) lives in the frontend tree;
|
||||||
//! the frontend mirrors the gated subset into {@link McpState} via the
|
//! the frontend mirrors the gated subset into {@link McpState} via the
|
||||||
|
|
@ -366,11 +376,27 @@ pub struct ApplyPresetArgs {
|
||||||
pub allow_drops: bool,
|
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)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub struct SpawnPaneArgs {
|
pub struct SpawnPaneArgs {
|
||||||
/// What shell to run in the new pane: WSL distro, PowerShell, or a
|
/// What local shell to run in the new pane: WSL distro or PowerShell.
|
||||||
/// saved SSH host (or arbitrary ssh args).
|
/// For SSH connections to saved hosts, use the connect_host tool
|
||||||
pub spec: crate::pty::SpawnSpec,
|
/// 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
|
/// 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
|
/// workspace root if no pane is active. The parent leaf must be
|
||||||
/// MCP-allowed for Claude to target it.
|
/// MCP-allowed for Claude to target it.
|
||||||
|
|
@ -801,30 +827,18 @@ impl TileService {
|
||||||
Ok(CallToolResult::success(vec![Content::text("ok")]))
|
Ok(CallToolResult::success(vec![Content::text("ok")]))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Spawn a new pane next to an existing one, running \
|
#[tool(description = "Spawn a new local-shell pane next to an existing \
|
||||||
the requested shell (WSL distro / PowerShell / SSH). The new pane is \
|
one — WSL distro or PowerShell. The new pane is auto-allowed for \
|
||||||
auto-allowed for MCP so Claude can immediately read its scrollback \
|
MCP so Claude can immediately read its scrollback and send it \
|
||||||
and send it keystrokes (subject to policy). Returns {leafId, paneId} \
|
keystrokes (subject to policy). Returns {leafId, paneId} for the \
|
||||||
for the newly created pane. Times out after ~15 seconds if the \
|
newly created pane. Times out after ~15 seconds if the PTY can't \
|
||||||
PTY can't be spawned (covers a cold WSL distro start).")]
|
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(
|
async fn spawn_pane(
|
||||||
&self,
|
&self,
|
||||||
Parameters(args): Parameters<SpawnPaneArgs>,
|
Parameters(args): Parameters<SpawnPaneArgs>,
|
||||||
) -> Result<CallToolResult, McpError> {
|
) -> Result<CallToolResult, McpError> {
|
||||||
// SSH safeguard: refuse before any other work if the user hasn't
|
|
||||||
// opted in to MCP-initiated SSH connections.
|
|
||||||
if matches!(args.spec, crate::pty::SpawnSpec::Ssh { .. })
|
|
||||||
&& !self.policy_ssh_open_allowed().await
|
|
||||||
{
|
|
||||||
return Err(McpError::invalid_params(
|
|
||||||
"ssh-disabled: Claude is not allowed to open SSH connections \
|
|
||||||
(Policy tab → SSH safeguards → 'Allow Claude to open SSH \
|
|
||||||
connections'). Open the SSH session manually via the \
|
|
||||||
titlebar 🔑 picker first, then ask Claude to interact with \
|
|
||||||
it.",
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// If a parent is named, it must be MCP-allowed. (If omitted, the
|
// If a parent is named, it must be MCP-allowed. (If omitted, the
|
||||||
// frontend picks the active pane or root — no explicit check here
|
// frontend picks the active pane or root — no explicit check here
|
||||||
// since the user already approved an MCP-server-running state.)
|
// since the user already approved an MCP-server-running state.)
|
||||||
|
|
@ -835,14 +849,13 @@ impl TileService {
|
||||||
// reconstruct it without us doing the per-kind dispatch here.
|
// reconstruct it without us doing the per-kind dispatch here.
|
||||||
let spec_json = serde_json::to_value(&args.spec).map_err(|e| {
|
let spec_json = serde_json::to_value(&args.spec).map_err(|e| {
|
||||||
McpError::internal_error(
|
McpError::internal_error(
|
||||||
format!("serialize SpawnSpec: {e}"),
|
format!("serialize spec: {e}"),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let kind_str = match &args.spec {
|
let kind_str = match &args.spec {
|
||||||
crate::pty::SpawnSpec::Wsl { .. } => "wsl",
|
McpSpawnSpec::Wsl { .. } => "wsl",
|
||||||
crate::pty::SpawnSpec::Powershell => "powershell",
|
McpSpawnSpec::Powershell => "powershell",
|
||||||
crate::pty::SpawnSpec::Ssh { .. } => "ssh",
|
|
||||||
};
|
};
|
||||||
let args_repr = format!(
|
let args_repr = format!(
|
||||||
"shell={} parent={} orientation={}",
|
"shell={} parent={} orientation={}",
|
||||||
|
|
@ -1083,10 +1096,27 @@ impl ServerHandler for TileService {
|
||||||
.with_server_info(Implementation::from_build_env())
|
.with_server_info(Implementation::from_build_env())
|
||||||
.with_protocol_version(ProtocolVersion::V_2024_11_05)
|
.with_protocol_version(ProtocolVersion::V_2024_11_05)
|
||||||
.with_instructions(
|
.with_instructions(
|
||||||
"Tiletopia MCP (read-only v1). Resources: tiletopia://layout, \
|
"Tiletopia MCP — drive a multi-pane terminal workspace.\n\
|
||||||
tiletopia://panes, tiletopia://hosts. Tools: read_pane, \
|
\n\
|
||||||
wait_for_idle. Only panes the user has allow-listed are \
|
Resources (read): tiletopia://layout, tiletopia://panes, \
|
||||||
visible.",
|
tiletopia://hosts.\n\
|
||||||
|
\n\
|
||||||
|
Read tools: read_pane (scrollback), wait_for_idle (block \
|
||||||
|
until a pane goes quiet).\n\
|
||||||
|
\n\
|
||||||
|
Write tools (subject to user policy + confirm modal):\n\
|
||||||
|
- set_label, close_pane, swap_panes, promote_pane, \
|
||||||
|
apply_preset — tree shape and metadata.\n\
|
||||||
|
- spawn_pane (local WSL/PowerShell), connect_host (SSH to a \
|
||||||
|
saved host — use this for SSH, not spawn_pane).\n\
|
||||||
|
- write_pane (send keystrokes; rate-limited; matched against \
|
||||||
|
user policy + a non-overridable hard-deny list for the \
|
||||||
|
worst-of-the-worst patterns).\n\
|
||||||
|
\n\
|
||||||
|
Only panes the user has allow-listed (🤖 chip on) are \
|
||||||
|
visible. SSH spawns are gated by an extra Policy-tab switch \
|
||||||
|
that's off by default — if you see 'ssh-disabled' errors, \
|
||||||
|
the user has not enabled MCP-initiated SSH.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
53
src/App.tsx
53
src/App.tsx
|
|
@ -308,6 +308,42 @@ export default function App() {
|
||||||
);
|
);
|
||||||
}, [activeLeafId, defaultShell, notify]);
|
}, [activeLeafId, defaultShell, notify]);
|
||||||
|
|
||||||
|
// From the SSH host manager's "Connect" button — splits off the active
|
||||||
|
// pane and opens an SSH session to the picked host. Same smart-orient as
|
||||||
|
// the titlebar "+" path.
|
||||||
|
const connectToHost = useCallback(
|
||||||
|
(hostId: string) => {
|
||||||
|
const container = paneWrapRef.current;
|
||||||
|
const layout = flattenLayout(treeRef.current);
|
||||||
|
const targetId = activeLeafId ?? layout.leaves[0]?.leaf.id ?? null;
|
||||||
|
if (!targetId || !container) {
|
||||||
|
notify("No pane to split off — open a pane first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slot = layout.leaves.find((s) => s.leaf.id === targetId);
|
||||||
|
if (!slot) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const paneW = slot.box.width * rect.width;
|
||||||
|
const paneH = slot.box.height * rect.height;
|
||||||
|
const orientation: Orientation = paneW >= paneH ? "h" : "v";
|
||||||
|
const childW = orientation === "h" ? paneW / 2 : paneW;
|
||||||
|
const childH = orientation === "v" ? paneH / 2 : paneH;
|
||||||
|
if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) {
|
||||||
|
notify(
|
||||||
|
`Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTree((t) =>
|
||||||
|
splitLeaf(t, targetId, orientation, {
|
||||||
|
shellKind: "ssh",
|
||||||
|
sshHostId: hostId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[activeLeafId, notify],
|
||||||
|
);
|
||||||
|
|
||||||
const close = useCallback(
|
const close = useCallback(
|
||||||
(leafId: NodeId) => {
|
(leafId: NodeId) => {
|
||||||
const paneId = paneIdByLeafRef.current.get(leafId);
|
const paneId = paneIdByLeafRef.current.get(leafId);
|
||||||
|
|
@ -990,6 +1026,10 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "spawn_pane": {
|
case "spawn_pane": {
|
||||||
|
// Backend McpSpawnSpec only contains Wsl / Powershell — SSH is
|
||||||
|
// routed through connect_host instead. The cast tolerates the
|
||||||
|
// wider frontend SpawnSpec union; the kind discriminant is what
|
||||||
|
// matters at runtime.
|
||||||
const a = args as {
|
const a = args as {
|
||||||
spec?: SpawnSpec;
|
spec?: SpawnSpec;
|
||||||
parentLeafId?: string;
|
parentLeafId?: string;
|
||||||
|
|
@ -998,15 +1038,6 @@ export default function App() {
|
||||||
if (!a.spec || typeof a.spec !== "object") {
|
if (!a.spec || typeof a.spec !== "object") {
|
||||||
throw new Error("missing spec");
|
throw new Error("missing spec");
|
||||||
}
|
}
|
||||||
// For SSH, our LeafNode only persists sshHostId — we don't store
|
|
||||||
// inline host/user/port on the leaf. Refuse ad-hoc SSH spawns;
|
|
||||||
// use connect_host with a saved host_id instead.
|
|
||||||
if (a.spec.kind === "ssh" && !a.spec.hostId) {
|
|
||||||
throw new Error(
|
|
||||||
"spawn_pane with kind=ssh requires hostId (a saved host). " +
|
|
||||||
"Use connect_host(host_id) or add the host via the SSH host manager first.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const newLeafId = await spawnNewLeafFromSpec(
|
const newLeafId = await spawnNewLeafFromSpec(
|
||||||
a.spec,
|
a.spec,
|
||||||
a.parentLeafId,
|
a.parentLeafId,
|
||||||
|
|
@ -1593,6 +1624,10 @@ export default function App() {
|
||||||
onSave={saveHosts}
|
onSave={saveHosts}
|
||||||
onSavePassword={savePassword}
|
onSavePassword={savePassword}
|
||||||
onClearPassword={clearPassword}
|
onClearPassword={clearPassword}
|
||||||
|
onConnect={(hostId) => {
|
||||||
|
connectToHost(hostId);
|
||||||
|
closeHostManager();
|
||||||
|
}}
|
||||||
onClose={closeHostManager}
|
onClose={closeHostManager}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,13 @@
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
.host-edit-btn {
|
.host-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.host-edit-btn,
|
||||||
|
.host-connect-btn {
|
||||||
background: #222;
|
background: #222;
|
||||||
color: #aac;
|
color: #aac;
|
||||||
border: 1px solid #2a2a3a;
|
border: 1px solid #2a2a3a;
|
||||||
|
|
@ -106,6 +112,15 @@
|
||||||
background: #2a2a3a;
|
background: #2a2a3a;
|
||||||
color: #cce;
|
color: #cce;
|
||||||
}
|
}
|
||||||
|
.host-connect-btn {
|
||||||
|
background: #1a2a1a;
|
||||||
|
color: #80c080;
|
||||||
|
border-color: #2a4a2a;
|
||||||
|
}
|
||||||
|
.host-connect-btn:hover {
|
||||||
|
background: #2a4a2a;
|
||||||
|
color: #a0e0a0;
|
||||||
|
}
|
||||||
|
|
||||||
.host-form {
|
.host-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ interface HostManagerProps {
|
||||||
/** Delete the keyring entry for this host id. Called when the user
|
/** Delete the keyring entry for this host id. Called when the user
|
||||||
* clicked "Remove password" before Save. */
|
* clicked "Remove password" before Save. */
|
||||||
onClearPassword: (hostId: string) => void;
|
onClearPassword: (hostId: string) => void;
|
||||||
|
/** Open a new pane connected to this host (and close the manager). */
|
||||||
|
onConnect: (hostId: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +50,7 @@ export default function HostManager({
|
||||||
onSave,
|
onSave,
|
||||||
onSavePassword,
|
onSavePassword,
|
||||||
onClearPassword,
|
onClearPassword,
|
||||||
|
onConnect,
|
||||||
onClose,
|
onClose,
|
||||||
}: HostManagerProps) {
|
}: HostManagerProps) {
|
||||||
// Local editable copy. Any save / delete acts on this and pushes the
|
// Local editable copy. Any save / delete acts on this and pushes the
|
||||||
|
|
@ -377,12 +380,21 @@ export default function HostManager({
|
||||||
{h.jumpHost ? ` via ${h.jumpHost}` : ""}
|
{h.jumpHost ? ` via ${h.jumpHost}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="host-actions">
|
||||||
className="host-edit-btn"
|
<button
|
||||||
onClick={() => startEdit(h.id)}
|
className="host-connect-btn"
|
||||||
>
|
onClick={() => onConnect(h.id)}
|
||||||
Edit
|
title={`Open a new pane connected to ${h.label}`}
|
||||||
</button>
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="host-edit-btn"
|
||||||
|
onClick={() => startEdit(h.id)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue