diff --git a/app/src/main/java/com/secrm/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/secrm/ui/dashboard/DashboardScreen.kt index e24b08a..26d26da 100644 --- a/app/src/main/java/com/secrm/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/secrm/ui/dashboard/DashboardScreen.kt @@ -359,6 +359,7 @@ private fun ConnectionFormSheet( var fallback by remember { mutableStateOf(initial?.fallbackVia) } var canExec by remember { mutableStateOf(initial?.allowMcpExec ?: false) } var canRead by remember { mutableStateOf(initial?.allowMcpRead ?: false) } + var canRediscover by remember { mutableStateOf(initial?.allowMcpRediscover ?: false) } var forgetKey by remember { mutableStateOf(false) } val savedKeys = remember { app.keys.list() } @@ -489,6 +490,12 @@ private fun ConnectionFormSheet( trailingContent = { Switch(canRead, { canRead = it }) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) + ListItem( + headlineContent = { Text("Find host if its IP changed") }, + supportingContent = { Text("When Claude connects, search this phone's networks for the same host key") }, + trailingContent = { Switch(canRediscover, { canRediscover = it }, enabled = canExec || canRead) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) if (initial?.hostKey != null) { PillButton( @@ -518,7 +525,8 @@ private fun ConnectionFormSheet( // Kept only for the same address: a new address is a new host and needs its key approved again. hostKey = if (forgetKey || latest == null || latest.host != host.trim() || latest.port != (portNum ?: 22) || latest.via != via) null else latest.hostKey, allowMcpExec = canExec, - allowMcpRead = canRead + allowMcpRead = canRead, + allowMcpRediscover = canRediscover && (canExec || canRead), )) } } diff --git a/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/McpSessionPool.kt b/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/McpSessionPool.kt index aa1804a..6dcd396 100644 --- a/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/McpSessionPool.kt +++ b/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/McpSessionPool.kt @@ -78,13 +78,15 @@ class McpSessionPool( evict(e) } val client = connectNew(conn, auth) ?: return null - return Entry(conn, auth, client).also { it.users = 1; entries[conn.name] = it } + // A rediscovered host was saved under its new address; store that, so the next call reuses this session. + val current = saved.find(conn.name)?.takeIf { it.copy(host = conn.host, routeByNetwork = conn.routeByNetwork) == conn } ?: conn + return Entry(current, auth, client).also { it.users = 1; entries[conn.name] = it } } /** Caller holds [lock]. Connects a new client for [conn], or records why it could not. */ private suspend fun connectNew(conn: SavedConnection, auth: SshAuth): SshClientManager? { val client = newClient() - if (!client.connectSaved(conn, auth, saved, allowRediscover = false)) { + if (!client.connectSaved(conn, auth, saved, allowRediscover = conn.allowMcpRediscover)) { keyChanged[conn.name] = client.state.value is SshState.HostKeyChanged client.shutdown() return null diff --git a/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SavedConnections.kt b/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SavedConnections.kt index f831eb4..7ce5cfc 100644 --- a/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SavedConnections.kt +++ b/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SavedConnections.kt @@ -27,7 +27,9 @@ data class SavedConnection( val hostKey: String? = null, /** Opt-in: a connection is not reachable over MCP until its owner enables it. */ val allowMcpExec: Boolean = false, - val allowMcpRead: Boolean = false + val allowMcpRead: Boolean = false, + /** Opt-in: lets an MCP connect search the phone's networks for this host if its address changed (pinned key only). */ + val allowMcpRediscover: Boolean = false, ) interface SavedConnections { @@ -64,7 +66,7 @@ suspend fun SshClientManager.connectSaved( conn: SavedConnection, auth: SshAuth, saved: SavedConnections, - /** On for the owner's app and plugin; MCP passes false, so an agent never triggers a network probe. */ + /** On for the owner's app and plugin; MCP passes the connection's [SavedConnection.allowMcpRediscover], off by default. */ allowRediscover: Boolean = true, approvedKey: String? = null, ): Boolean { diff --git a/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SshTools.kt b/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SshTools.kt index cf0c5b1..ae4f866 100644 --- a/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SshTools.kt +++ b/core/ssh-tools/src/main/kotlin/com/secrm/core/ssh/tools/SshTools.kt @@ -467,7 +467,8 @@ object SshTools { } ?: if (pool.hostKeyChanged(conn.name)) ToolResult.error("host_key_changed", "The server's host key changed. The owner must trust the new key in the app before MCP can connect.", "Host key for '${conn.name}' has changed.") - else ToolResult.error("connect_failed", "Check host reachability and credentials.", + else ToolResult.error("connect_failed", if (conn.allowMcpRediscover) "Check host reachability and credentials." + else "Check host reachability and credentials. If its IP may have changed, the owner can turn on 'Find host if its IP changed' for this connection.", "SSH connect failed to ${conn.host}:${conn.port}") } catch (d: Denied) { d.result diff --git a/core/ssh-tools/src/test/kotlin/com/secrm/core/ssh/tools/McpToolsTest.kt b/core/ssh-tools/src/test/kotlin/com/secrm/core/ssh/tools/McpToolsTest.kt index e04330e..e740d09 100644 --- a/core/ssh-tools/src/test/kotlin/com/secrm/core/ssh/tools/McpToolsTest.kt +++ b/core/ssh-tools/src/test/kotlin/com/secrm/core/ssh/tools/McpToolsTest.kt @@ -129,4 +129,8 @@ class McpToolsTest { put("connection", "box") }).errorCode) } + + @Test fun mcpRediscoveryIsOffUnlessTheOwnerTurnsItOn() { + assertFalse(box(exec = true).allowMcpRediscover) + } }