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))
|
||||
|
||||
Reference in new issue
Block a user