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

@@ -20,7 +20,12 @@ data class McpServer(
val error: String? = null,
val auth: 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
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.McpServersResponse
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.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -66,8 +68,9 @@ data class McpServersUiState(
val activeOAuthFlow: McpOAuthFlowResponse? = null,
)
class McpServersViewModel :
ViewModel(),
class McpServersViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(McpServersUiState())
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
@@ -131,6 +134,7 @@ class McpServersViewModel :
// ── Server toggle ────────────────────────────────────────
fun toggleServer(server: McpServer) {
if (server.isPluginOwned || isPluginOwned(server.name)) return
val originalEnabled = server.enabled
val targetEnabled = !originalEnabled
@@ -145,7 +149,7 @@ class McpServersViewModel :
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall {
ApiClient.hermesApi.toggleMcpServer(
server.name,
@@ -163,7 +167,7 @@ class McpServersViewModel :
}
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 =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
}
when (result) {
@@ -234,7 +238,7 @@ class McpServersViewModel :
val testJobs =
enabledServers.map { server ->
async(Dispatchers.IO) {
async(ioDispatcher) {
val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) }
server.name to res
}
@@ -271,9 +275,10 @@ class McpServersViewModel :
}
fun deleteServer(name: String) {
if (isPluginOwned(name)) return
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) }
}
when (result) {
@@ -283,7 +288,7 @@ class McpServersViewModel :
}
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 {
val importJobs =
requests.map { req ->
async(Dispatchers.IO) {
async(ioDispatcher) {
val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) }
req to result
}
@@ -387,7 +392,7 @@ class McpServersViewModel :
viewModelScope.launch {
val request = AddServerRequestBuilder.build(state)
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.addMcpServer(request) }
}
when (result) {
@@ -412,7 +417,7 @@ class McpServersViewModel :
_uiState.update {
it.copy(
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 ──────────────────────────────────────
fun startEditingEnv(server: McpServer) {
if (server.isPluginOwned || isPluginOwned(server.name)) return
_uiState.update { it.copy(editingEnvFor = server.name, envKeyInput = "", envValueInput = "") }
}
@@ -439,6 +445,7 @@ class McpServersViewModel :
}
fun addEnvVar(serverName: String) {
if (isPluginOwned(serverName)) return
val state = _uiState.value
val key = state.envKeyInput.trim()
val value = state.envValueInput.trim()
@@ -450,7 +457,7 @@ class McpServersViewModel :
val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap()
val updatedEnv = existingEnv + (key to value)
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) }
}
when (result) {
@@ -466,7 +473,7 @@ class McpServersViewModel :
}
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,
key: String,
) {
if (isPluginOwned(serverName)) return
viewModelScope.launch {
val state = _uiState.value
val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap()
val updatedEnv = existingEnv - key
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) }
}
when (result) {
@@ -491,7 +499,7 @@ class McpServersViewModel :
}
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) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getMcpCatalog() }
}
when (result) {
@@ -542,7 +550,7 @@ class McpServersViewModel :
env = state.catalogInstallEnv.ifEmpty { null },
)
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.installMcpCatalogEntry(request) }
}
when (result) {
@@ -584,11 +592,12 @@ class McpServersViewModel :
server: McpServer,
onOpenBrowser: (String) -> Unit,
) {
if (server.isPluginOwned || isPluginOwned(server.name)) return
oauthPollJob?.cancel()
_uiState.update { it.copy(toastMessage = "Starting OAuth authorization…") }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.authMcpServer(server.name) }
}
when (result) {
@@ -608,7 +617,7 @@ class McpServersViewModel :
}
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) {
kotlinx.coroutines.delay(2000)
val result =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
safeApiCall { ApiClient.hermesApi.getMcpOAuthFlowStatus(flowId) }
}
when (result) {
@@ -688,6 +697,15 @@ class McpServersViewModel :
// ── 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(
name: String,
originalEnabled: Boolean,
@@ -139,6 +139,16 @@ fun ServerCard(
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)
val toolInfos = testResult?.tools
val toolCount = toolInfos?.size ?: server.tools?.size
@@ -173,6 +183,7 @@ fun ServerCard(
}
Switch(
checked = server.enabled,
enabled = !server.isPluginOwned,
onCheckedChange = { viewModel.toggleServer(server) },
)
}
@@ -218,6 +229,7 @@ fun ServerCard(
if (server.auth == "oauth") {
FilledTonalButton(
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
enabled = !server.isPluginOwned,
modifier = Modifier.weight(1.2f),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
) {
@@ -245,7 +257,10 @@ fun ServerCard(
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))
}
}
@@ -275,7 +290,7 @@ fun EnvVarSection(
spacing: Spacing,
modifier: Modifier = Modifier,
) {
val isEditing = state.editingEnvFor == server.name
val isEditing = !server.isPluginOwned && state.editingEnvFor == server.name
val env = server.env ?: emptyMap()
Column(modifier = modifier) {
@@ -300,7 +315,10 @@ fun EnvVarSection(
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))
}
}
@@ -342,7 +360,10 @@ fun EnvVarSection(
}
}
} 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))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_env_add))