fix(#1283): respect plugin ownership of MCP servers (#1296)

This commit is contained in:
m57 authored and GitHub committed 2026-09-26 21:44:41 +03:00
1 parent ee487e0087
commit c34de55dd9
6 files changed
+341 -25

No files matched your search

@@ -0,0 +1,77 @@
package com.m57.hermescontrol.ui.mcp
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.mcp.components.ServerCard
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class McpOwnershipCardTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun pluginRowExplainsOwnershipAndDisablesMutations() {
showServer("plugin", "security")
composeRule.onNodeWithText("From plugin security · Read-only", useUnmergedTree = true).assertExists()
composeRule.onNode(isToggleable()).assertIsNotEnabled()
composeRule.onNodeWithContentDescription("Delete").assertIsNotEnabled()
composeRule.onNodeWithText("Authorize").assertIsNotEnabled()
composeRule.onNodeWithText("Test").assertIsEnabled()
composeRule.onNodeWithText("Env vars").performClick()
composeRule.onNodeWithContentDescription("Remove").assertIsNotEnabled()
composeRule.onNodeWithText("Add env var").assertIsNotEnabled()
composeRule.onNodeWithText("KEY", useUnmergedTree = true).assertExists()
}
@Test
fun configRowKeepsMutationsEnabled() {
showServer("config", null)
composeRule.onNode(isToggleable()).assertIsEnabled()
composeRule.onNodeWithContentDescription("Delete").assertIsEnabled()
composeRule.onNodeWithText("Authorize").assertIsEnabled()
composeRule.onNodeWithText("Env vars").performClick()
composeRule.onNodeWithContentDescription("Remove").assertIsEnabled()
composeRule.onNodeWithText("Add env var").assertIsEnabled()
}
private fun showServer(
source: String,
plugin: String?,
) {
val server =
McpServer(
name = "scanner",
enabled = true,
source = source,
plugin = plugin,
auth = "oauth",
env = mapOf("KEY" to "test-value"),
)
val viewModel = McpServersViewModel()
composeRule.setContent {
HermesControlTheme {
ServerCard(
server = server,
state = McpServersUiState(servers = listOf(server)),
viewModel = viewModel,
spacing = LocalSpacing.current,
onClick = {},
onOpenBrowser = {},
)
}
}
}
}
@@ -20,7 +20,12 @@ data class McpServer(
val error: String? = null, val error: String? = null,
val auth: String? = null, val auth: String? = null,
val tools: List<String>? = null, val tools: List<String>? = null,
) val source: String = "config",
val plugin: String? = null,
) {
// #1283: plugin-provided servers are managed by their owning plugin.
val isPluginOwned: Boolean get() = source == "plugin"
}
@Serializable @Serializable
data class McpServerTestResponse( data class McpServerTestResponse(
@@ -12,11 +12,13 @@ import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -66,8 +68,9 @@ data class McpServersUiState(
val activeOAuthFlow: McpOAuthFlowResponse? = null, val activeOAuthFlow: McpOAuthFlowResponse? = null,
) )
class McpServersViewModel : class McpServersViewModel(
ViewModel(), private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel(),
ToastHost { ToastHost {
private val _uiState = MutableStateFlow(McpServersUiState()) private val _uiState = MutableStateFlow(McpServersUiState())
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow() val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
@@ -131,6 +134,7 @@ class McpServersViewModel :
// ── Server toggle ──────────────────────────────────────── // ── Server toggle ────────────────────────────────────────
fun toggleServer(server: McpServer) { fun toggleServer(server: McpServer) {
if (server.isPluginOwned || isPluginOwned(server.name)) return
val originalEnabled = server.enabled val originalEnabled = server.enabled
val targetEnabled = !originalEnabled val targetEnabled = !originalEnabled
@@ -145,7 +149,7 @@ class McpServersViewModel :
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { safeApiCall {
ApiClient.hermesApi.toggleMcpServer( ApiClient.hermesApi.toggleMcpServer(
server.name, server.name,
@@ -163,7 +167,7 @@ class McpServersViewModel :
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}") revertToggle(server.name, originalEnabled, mutationError(result.error, "Failed to toggle server"))
} }
} }
} }
@@ -178,7 +182,7 @@ class McpServersViewModel :
) )
} }
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.testMcpServer(name) } safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
} }
when (result) { when (result) {
@@ -234,7 +238,7 @@ class McpServersViewModel :
val testJobs = val testJobs =
enabledServers.map { server -> enabledServers.map { server ->
async(Dispatchers.IO) { async(ioDispatcher) {
val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) } val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) }
server.name to res server.name to res
} }
@@ -271,9 +275,10 @@ class McpServersViewModel :
} }
fun deleteServer(name: String) { fun deleteServer(name: String) {
if (isPluginOwned(name)) return
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) } safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) }
} }
when (result) { when (result) {
@@ -283,7 +288,7 @@ class McpServersViewModel :
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") } _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to delete server")) }
} }
} }
} }
@@ -311,7 +316,7 @@ class McpServersViewModel :
viewModelScope.launch { viewModelScope.launch {
val importJobs = val importJobs =
requests.map { req -> requests.map { req ->
async(Dispatchers.IO) { async(ioDispatcher) {
val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) } val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) }
req to result req to result
} }
@@ -387,7 +392,7 @@ class McpServersViewModel :
viewModelScope.launch { viewModelScope.launch {
val request = AddServerRequestBuilder.build(state) val request = AddServerRequestBuilder.build(state)
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.addMcpServer(request) } safeApiCall { ApiClient.hermesApi.addMcpServer(request) }
} }
when (result) { when (result) {
@@ -412,7 +417,7 @@ class McpServersViewModel :
_uiState.update { _uiState.update {
it.copy( it.copy(
addingServer = false, addingServer = false,
toastMessage = "Failed to add server: ${result.error.message}", toastMessage = mutationError(result.error, "Failed to add server"),
) )
} }
} }
@@ -423,6 +428,7 @@ class McpServersViewModel :
// ── Env var editing ────────────────────────────────────── // ── Env var editing ──────────────────────────────────────
fun startEditingEnv(server: McpServer) { fun startEditingEnv(server: McpServer) {
if (server.isPluginOwned || isPluginOwned(server.name)) return
_uiState.update { it.copy(editingEnvFor = server.name, envKeyInput = "", envValueInput = "") } _uiState.update { it.copy(editingEnvFor = server.name, envKeyInput = "", envValueInput = "") }
} }
@@ -439,6 +445,7 @@ class McpServersViewModel :
} }
fun addEnvVar(serverName: String) { fun addEnvVar(serverName: String) {
if (isPluginOwned(serverName)) return
val state = _uiState.value val state = _uiState.value
val key = state.envKeyInput.trim() val key = state.envKeyInput.trim()
val value = state.envValueInput.trim() val value = state.envValueInput.trim()
@@ -450,7 +457,7 @@ class McpServersViewModel :
val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap() val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap()
val updatedEnv = existingEnv + (key to value) val updatedEnv = existingEnv + (key to value)
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) }
} }
when (result) { when (result) {
@@ -466,7 +473,7 @@ class McpServersViewModel :
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") } _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to add env var")) }
} }
} }
} }
@@ -476,12 +483,13 @@ class McpServersViewModel :
serverName: String, serverName: String,
key: String, key: String,
) { ) {
if (isPluginOwned(serverName)) return
viewModelScope.launch { viewModelScope.launch {
val state = _uiState.value val state = _uiState.value
val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap() val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap()
val updatedEnv = existingEnv - key val updatedEnv = existingEnv - key
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) }
} }
when (result) { when (result) {
@@ -491,7 +499,7 @@ class McpServersViewModel :
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") } _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to remove env var")) }
} }
} }
} }
@@ -503,7 +511,7 @@ class McpServersViewModel :
_uiState.update { it.copy(catalogLoading = true, catalogError = null) } _uiState.update { it.copy(catalogLoading = true, catalogError = null) }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getMcpCatalog() } safeApiCall { ApiClient.hermesApi.getMcpCatalog() }
} }
when (result) { when (result) {
@@ -542,7 +550,7 @@ class McpServersViewModel :
env = state.catalogInstallEnv.ifEmpty { null }, env = state.catalogInstallEnv.ifEmpty { null },
) )
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.installMcpCatalogEntry(request) } safeApiCall { ApiClient.hermesApi.installMcpCatalogEntry(request) }
} }
when (result) { when (result) {
@@ -584,11 +592,12 @@ class McpServersViewModel :
server: McpServer, server: McpServer,
onOpenBrowser: (String) -> Unit, onOpenBrowser: (String) -> Unit,
) { ) {
if (server.isPluginOwned || isPluginOwned(server.name)) return
oauthPollJob?.cancel() oauthPollJob?.cancel()
_uiState.update { it.copy(toastMessage = "Starting OAuth authorization…") } _uiState.update { it.copy(toastMessage = "Starting OAuth authorization…") }
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.authMcpServer(server.name) } safeApiCall { ApiClient.hermesApi.authMcpServer(server.name) }
} }
when (result) { when (result) {
@@ -608,7 +617,7 @@ class McpServersViewModel :
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") } _uiState.update { it.copy(toastMessage = mutationError(result.error, "Failed to start OAuth")) }
} }
} }
} }
@@ -623,7 +632,7 @@ class McpServersViewModel :
while (polling) { while (polling) {
kotlinx.coroutines.delay(2000) kotlinx.coroutines.delay(2000)
val result = val result =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getMcpOAuthFlowStatus(flowId) } safeApiCall { ApiClient.hermesApi.getMcpOAuthFlowStatus(flowId) }
} }
when (result) { when (result) {
@@ -688,6 +697,15 @@ class McpServersViewModel :
// ── Helpers ────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────
// #1283: re-check current ownership so stale row callbacks cannot mutate a plugin server.
private fun isPluginOwned(name: String): Boolean =
_uiState.value.servers.any { it.name == name && it.isPluginOwned }
private fun mutationError(
error: NetworkError,
prefix: String,
): String = if (error is NetworkError.Http && error.code == 409) error.message else "$prefix: ${error.message}"
private fun revertToggle( private fun revertToggle(
name: String, name: String,
originalEnabled: Boolean, originalEnabled: Boolean,
@@ -139,6 +139,16 @@ fun ServerCard(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
if (server.isPluginOwned) {
Text(
text =
server.plugin?.let { stringResource(R.string.mcp_servers_from_plugin, it) }
?: stringResource(R.string.mcp_servers_plugin_managed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Tool count & schema token overhead badge (issue #1029) // Tool count & schema token overhead badge (issue #1029)
val toolInfos = testResult?.tools val toolInfos = testResult?.tools
val toolCount = toolInfos?.size ?: server.tools?.size val toolCount = toolInfos?.size ?: server.tools?.size
@@ -173,6 +183,7 @@ fun ServerCard(
} }
Switch( Switch(
checked = server.enabled, checked = server.enabled,
enabled = !server.isPluginOwned,
onCheckedChange = { viewModel.toggleServer(server) }, onCheckedChange = { viewModel.toggleServer(server) },
) )
} }
@@ -218,6 +229,7 @@ fun ServerCard(
if (server.auth == "oauth") { if (server.auth == "oauth") {
FilledTonalButton( FilledTonalButton(
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) }, onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
enabled = !server.isPluginOwned,
modifier = Modifier.weight(1.2f), modifier = Modifier.weight(1.2f),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
) { ) {
@@ -245,7 +257,10 @@ fun ServerCard(
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
} }
} }
IconButton(onClick = { viewModel.deleteServer(server.name) }) { IconButton(
onClick = { viewModel.deleteServer(server.name) },
enabled = !server.isPluginOwned,
) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
} }
} }
@@ -275,7 +290,7 @@ fun EnvVarSection(
spacing: Spacing, spacing: Spacing,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isEditing = state.editingEnvFor == server.name val isEditing = !server.isPluginOwned && state.editingEnvFor == server.name
val env = server.env ?: emptyMap() val env = server.env ?: emptyMap()
Column(modifier = modifier) { Column(modifier = modifier) {
@@ -300,7 +315,10 @@ fun EnvVarSection(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) { IconButton(
onClick = { viewModel.removeEnvVar(server.name, key) },
enabled = !server.isPluginOwned,
) {
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
} }
} }
@@ -342,7 +360,10 @@ fun EnvVarSection(
} }
} }
} else { } else {
TextButton(onClick = { viewModel.startEditingEnv(server) }) { TextButton(
onClick = { viewModel.startEditingEnv(server) },
enabled = !server.isPluginOwned,
) {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp)) Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(spacing.xs)) Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_env_add)) Text(stringResource(R.string.mcp_servers_env_add))
+2
View File
@@ -1119,6 +1119,8 @@
<!-- McpServers Screen --> <!-- McpServers Screen -->
<string name="mcp_servers_label_transport">Transport: %1$s</string> <string name="mcp_servers_label_transport">Transport: %1$s</string>
<string name="mcp_servers_from_plugin">From plugin %1$s · Read-only</string>
<string name="mcp_servers_plugin_managed">Plugin-managed · Read-only</string>
<string name="mcp_servers_label_command">Command:</string> <string name="mcp_servers_label_command">Command:</string>
<string name="mcp_servers_action_test">Test</string> <string name="mcp_servers_action_test">Test</string>
<string name="mcp_servers_action_test_all">Test All</string> <string name="mcp_servers_action_test_all">Test All</string>
@@ -0,0 +1,193 @@
package com.m57.hermescontrol.ui.mcp
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.OkHttpProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class McpOwnershipTest {
private val dispatcher = StandardTestDispatcher()
private lateinit var api: HermesApiService
private val owned = McpServer(name = "scanner", enabled = true, source = "plugin", plugin = "security")
private val conflict = "Server 'scanner' is provided by plugin 'security' and cannot be modified"
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
api = mockk()
mockkObject(ApiClient, AuthManager)
every { ApiClient.hermesApi } returns api
every { AuthManager.currentDataScope() } throws IllegalStateException("No test scope")
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `ownership decodes and legacy servers remain editable`() {
val json = OkHttpProvider.json
val plugin =
json.decodeFromString<McpServer>(
"""{"name":"scanner","enabled":true,"source":"plugin","plugin":"security"}""",
)
assertEquals(owned, plugin)
assertTrue(plugin.isPluginOwned)
assertTrue(plugin.copy(plugin = null).isPluginOwned)
val legacy = json.decodeFromString<McpServer>("""{"name":"local","enabled":true}""")
assertFalse(legacy.isPluginOwned)
assertNull(legacy.plugin)
val config =
json.decodeFromString<McpServer>(
"""{"name":"local","enabled":true,"source":"config","plugin":null}""",
)
assertFalse(config.isPluginOwned)
}
@Test
fun `plugin mutations are blocked even for stale callbacks but testing remains available`() {
val vm = loaded(owned)
val stale = owned.copy(source = "config", plugin = null)
vm.toggleServer(stale)
vm.deleteServer(owned.name)
vm.startEditingEnv(stale)
vm.updateEnvKey("KEY")
vm.updateEnvValue("value")
vm.addEnvVar(owned.name)
vm.removeEnvVar(owned.name, "KEY")
vm.startMcpOAuthFlow(stale) { error("Must not open browser") }
dispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.servers
.single()
.enabled,
)
assertNull(vm.uiState.value.editingEnvFor)
coVerify(exactly = 0) { api.toggleMcpServer(any(), any()) }
coVerify(exactly = 0) { api.deleteMcpServer(any()) }
coVerify(exactly = 0) { api.updateMcpServer(any(), any()) }
coVerify(exactly = 0) { api.authMcpServer(any()) }
coEvery { api.testMcpServer(owned.name) } returns Response.success(McpServerTestResponse(ok = true))
vm.testServer(owned.name)
dispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.serverTestResults
.getValue(owned.name)
.ok,
)
}
@Test
fun `config server toggle and environment editing remain available`() {
val config = owned.copy(source = "config", plugin = null)
val vm = loaded(config)
coEvery { api.toggleMcpServer(config.name, any()) } returns Response.success(Unit)
vm.toggleServer(config)
dispatcher.scheduler.advanceUntilIdle()
assertFalse(
vm.uiState.value.servers
.single()
.enabled,
)
vm.startEditingEnv(config)
assertEquals(config.name, vm.uiState.value.editingEnvFor)
coEvery { api.updateMcpServer(config.name, any()) } returns Response.success(config)
vm.updateEnvKey("KEY")
vm.updateEnvValue("value")
vm.addEnvVar(config.name)
dispatcher.scheduler.advanceUntilIdle()
coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) }
vm.removeEnvVar(config.name, "KEY")
dispatcher.scheduler.advanceUntilIdle()
coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap<String, String>())) }
coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit)
vm.deleteServer(config.name)
dispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 1) { api.deleteMcpServer(config.name) }
}
@Test
fun `stale ownership conflicts surface detail verbatim and revert toggle`() {
val config = owned.copy(source = "config", plugin = null)
val vm = loaded(config)
coEvery { api.toggleMcpServer(any(), any()) } returns conflictResponse()
vm.toggleServer(config)
dispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.servers
.single()
.enabled,
)
assertEquals(conflict, vm.uiState.value.toastMessage)
coEvery { api.deleteMcpServer(any()) } returns conflictResponse()
vm.deleteServer(config.name)
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
coEvery { api.updateMcpServer(any(), any()) } coAnswers { conflictResponse() }
vm.updateEnvKey("KEY")
vm.addEnvVar(config.name)
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
vm.clearToast()
vm.removeEnvVar(config.name, "KEY")
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
coEvery { api.authMcpServer(any()) } returns conflictResponse()
vm.clearToast()
vm.startMcpOAuthFlow(config) { error("Must not open browser") }
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
}
@Test
fun `add collision surfaces plugin conflict verbatim`() {
coEvery { api.addMcpServer(any()) } returns conflictResponse()
val vm = McpServersViewModel(ioDispatcher = dispatcher)
vm.toggleAddForm()
vm.updateAddServerName(owned.name)
vm.updateAddServerUrl("https://example.com/mcp")
vm.submitAddServer()
dispatcher.scheduler.advanceUntilIdle()
assertEquals(conflict, vm.uiState.value.toastMessage)
assertFalse(vm.uiState.value.addingServer)
assertTrue(vm.uiState.value.showAddForm)
assertEquals(owned.name, vm.uiState.value.addServerName)
}
private fun loaded(server: McpServer): McpServersViewModel {
coEvery { api.getMcpServers() } returns Response.success(McpServersResponse(listOf(server)))
return McpServersViewModel(ioDispatcher = dispatcher).also {
it.loadServers()
dispatcher.scheduler.advanceUntilIdle()
assertEquals(listOf(server), it.uiState.value.servers)
}
}
private fun <T> conflictResponse(): Response<T> = Response.error(409, """{"detail":"$conflict"}""".toResponseBody())
}