refactor(mcp): decompose McpServersScreen into components

Extract McpSectionHeader, AddServerSection, ServerCard,
CatalogSection, and McpDialogs into ui/mcp/components.
This commit is contained in:
Hy4ri committed 2026-09-11 19:53:16 +03:00
1 parent d2e5e8e1aa
commit f4904653d1
6 files changed
+1071 -837

No files matched your search

@@ -87,6 +87,10 @@ import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.mcp.components.AddServerSection
import com.m57.hermescontrol.ui.mcp.components.CatalogSection
import com.m57.hermescontrol.ui.mcp.components.McpDialogs
import com.m57.hermescontrol.ui.mcp.components.ServerCard
@Composable
fun McpServersScreen(
@@ -247,842 +251,13 @@ fun McpServersScreen(
}
}
showDetail?.let { server ->
DetailDialog(
title = server.name,
rows = server.toDetailRows(),
onDismiss = { showDetail = null },
)
}
state.activeOAuthFlow?.let { flow ->
androidx.compose.material3.AlertDialog(
onDismissRequest = { viewModel.dismissOAuthFlow() },
confirmButton = {
Button(
onClick = {
flow.authorizationUrl?.let { url ->
try {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
),
)
} catch (_: Exception) {
}
}
},
) {
Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser))
}
},
dismissButton = {
OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) {
Text(stringResource(R.string.action_cancel))
}
},
title = {
Text(stringResource(R.string.mcp_servers_oauth_dialog_title))
},
text = {
Column {
Text(stringResource(R.string.mcp_servers_oauth_dialog_desc))
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
)
flow.error?.let { err ->
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = stringResource(R.string.mcp_servers_oauth_dialog_error, err),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
)
}
if (state.showImportDialog) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { viewModel.toggleImportDialog() },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.UploadFile,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(spacing.sm))
Text(stringResource(R.string.mcp_servers_import_dialog_title))
}
},
text = {
Column {
Text(
text = stringResource(R.string.mcp_servers_import_dialog_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
val clip = clipboardManager.getText()?.text
if (!clip.isNullOrBlank()) {
viewModel.updateImportJsonInput(clip)
}
},
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
) {
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = stringResource(R.string.mcp_servers_import_dialog_paste),
style = MaterialTheme.typography.labelSmall,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
OutlinedTextField(
value = state.importJsonInput,
onValueChange = viewModel::updateImportJsonInput,
placeholder = {
Text(
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
},
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier =
Modifier
.fillMaxWidth()
.height(180.dp),
maxLines = 10,
)
}
},
confirmButton = {
Button(
onClick = { viewModel.submitImportJson() },
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
) {
if (state.isImportingJson) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
} else {
Text(stringResource(R.string.mcp_servers_action_submit))
}
}
},
dismissButton = {
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
// as a header, not a button) ──────────────────────────────────────────────
@Composable
private fun McpSectionHeader(
icon: ImageVector,
title: String,
trailing: (@Composable () -> Unit)? = null,
) {
val spacing = LocalSpacing.current
Row(
modifier =
Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(12.dp),
).padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(spacing.sm))
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
}
trailing?.invoke()
}
}
// ── Sections ──────────────────────────────────────────────────────
@Composable
private fun AddServerSection(
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: com.m57.hermescontrol.theme.Spacing,
) {
McpSectionHeader(
icon = Icons.Filled.Add,
title = stringResource(R.string.mcp_servers_add_server),
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
TextButton(
onClick = { viewModel.toggleImportDialog() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_import_json))
}
TextButton(
onClick = { viewModel.toggleAddForm() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(if (state.showAddForm) "Hide" else "New")
}
}
},
McpDialogs(
state = state,
viewModel = viewModel,
spacing = spacing,
selectedServerDetail = showDetail,
onDismissDetail = { showDetail = null },
context = context,
clipboardManager = clipboardManager,
)
AnimatedVisibility(visible = state.showAddForm) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column(modifier = Modifier.padding(spacing.md)) {
SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) {
Tab(
selected = state.addMode == AddServerMode.HTTP,
onClick = { viewModel.setAddMode(AddServerMode.HTTP) },
text = { Text(stringResource(R.string.mcp_servers_add_http)) },
)
Tab(
selected = state.addMode == AddServerMode.Stdio,
onClick = { viewModel.setAddMode(AddServerMode.Stdio) },
text = { Text(stringResource(R.string.mcp_servers_add_stdio)) },
)
}
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField(
value = state.addServerName,
onValueChange = viewModel::updateAddServerName,
label = { Text(stringResource(R.string.mcp_servers_field_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(spacing.sm))
if (state.addMode == AddServerMode.HTTP) {
OutlinedTextField(
value = state.addServerUrl,
onValueChange = viewModel::updateAddServerUrl,
label = { Text(stringResource(R.string.mcp_servers_field_url)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.mcp_servers_auth_mode),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(spacing.xs))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
FilterChip(
selected = state.addServerAuth == "none",
onClick = { viewModel.updateAddServerAuth("none") },
label = { Text(stringResource(R.string.mcp_servers_auth_none)) },
)
FilterChip(
selected = state.addServerAuth == "header",
onClick = { viewModel.updateAddServerAuth("header") },
label = { Text(stringResource(R.string.mcp_servers_auth_header)) },
)
FilterChip(
selected = state.addServerAuth == "oauth",
onClick = { viewModel.updateAddServerAuth("oauth") },
label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) },
)
}
if (state.addServerAuth == "header") {
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField(
value = state.addServerBearerToken,
onValueChange = viewModel::updateAddServerBearerToken,
label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) },
singleLine = true,
visualTransformation = remember { PasswordVisualTransformation() },
modifier = Modifier.fillMaxWidth(),
)
}
} else {
OutlinedTextField(
value = state.addServerCommand,
onValueChange = viewModel::updateAddServerCommand,
label = { Text(stringResource(R.string.mcp_servers_field_command)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField(
value = state.addServerArgs,
onValueChange = viewModel::updateAddServerArgs,
label = { Text(stringResource(R.string.mcp_servers_field_args)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.height(spacing.md))
Button(
onClick = { viewModel.submitAddServer() },
enabled = !state.addingServer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (state.addingServer) {
stringResource(R.string.mcp_servers_action_adding)
} else {
stringResource(R.string.mcp_servers_action_submit)
},
)
}
}
}
}
}
@Composable
private fun ServerCard(
server: McpServer,
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: com.m57.hermescontrol.theme.Spacing,
onClick: () -> Unit,
onOpenBrowser: (String) -> Unit,
) {
var showEnv by remember { mutableStateOf(false) }
val isTesting = server.name in state.testingServers
val testResult = state.serverTestResults[server.name]
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor =
if (server.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) {
// Header row: name + status + toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = server.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.width(spacing.sm))
when (healthStatus) {
McpHealthStatus.HEALTHY -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_healthy),
status = StatusBadgeType.SUCCESS,
)
}
McpHealthStatus.AUTH_REQUIRED -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_needs_auth),
status = StatusBadgeType.WARNING,
)
}
McpHealthStatus.ERROR -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_error),
status = StatusBadgeType.ERROR,
)
}
McpHealthStatus.TESTING -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_testing),
status = StatusBadgeType.INFO,
)
}
McpHealthStatus.UNKNOWN -> {
ServerStatusBadge(server.status)
}
}
}
Text(
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
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
if (toolCount != null && toolCount > 0) {
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
Spacer(modifier = Modifier.height(spacing.xs))
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
shape = RoundedCornerShape(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
) {
Icon(
imageVector = Icons.Filled.Build,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.Medium,
)
}
}
}
}
Switch(
checked = server.enabled,
onCheckedChange = { viewModel.toggleServer(server) },
)
}
// URL or Command
if (server.url != null) {
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = server.url,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
)
} else if (server.command != null) {
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.mcp_servers_label_command),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Text(
text = "${server.command} ${server.args.orEmpty().joinToString(" ")}",
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
)
}
// Error diagnostics
server.error?.let { error ->
if (error.isNotBlank()) {
Spacer(modifier = Modifier.height(spacing.sm))
StatusBadge(
text = error,
status = StatusBadgeType.ERROR,
)
}
}
Spacer(modifier = Modifier.height(spacing.sm))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
if (server.auth == "oauth") {
FilledTonalButton(
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
modifier = Modifier.weight(1.2f),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
) {
Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1)
}
}
FilledTonalButton(
onClick = { viewModel.testServer(server.name) },
enabled = !isTesting,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
} else {
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
}
}
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
}
}
// Env vars toggle
TextButton(
onClick = { showEnv = !showEnv },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_env_vars))
}
AnimatedVisibility(visible = showEnv) {
EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing)
}
}
}
}
@Composable
private fun EnvVarSection(
server: McpServer,
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: com.m57.hermescontrol.theme.Spacing,
) {
val isEditing = state.editingEnvFor == server.name
val env = server.env ?: emptyMap()
if (env.isEmpty() && !isEditing) {
Text(
text = stringResource(R.string.mcp_servers_env_no_vars),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = spacing.xs),
)
} else {
env.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
Text(
text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) {
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
}
}
}
}
if (isEditing) {
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = state.envKeyInput,
onValueChange = viewModel::updateEnvKey,
label = { Text(stringResource(R.string.mcp_servers_env_key)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = state.envValueInput,
onValueChange = viewModel::updateEnvValue,
label = { Text(stringResource(R.string.mcp_servers_env_value)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Button(onClick = { viewModel.addEnvVar(server.name) }) {
Text(stringResource(R.string.mcp_servers_env_add))
}
OutlinedButton(onClick = { viewModel.stopEditingEnv() }) {
Text(stringResource(R.string.action_cancel))
}
}
} else {
TextButton(onClick = { viewModel.startEditingEnv(server) }) {
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))
}
}
}
@Composable
private fun ServerStatusBadge(status: String?) {
if (status == null) return
val badgeType =
when (status.lowercase()) {
"running", "ok", "connected" -> StatusBadgeType.SUCCESS
"error", "failed" -> StatusBadgeType.ERROR
"stopped" -> StatusBadgeType.NEUTRAL
else -> StatusBadgeType.NEUTRAL
}
StatusBadge(text = status, status = badgeType)
}
// ── Catalog ─────────────────────────────────────────────────────
@Composable
private fun CatalogSection(
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: com.m57.hermescontrol.theme.Spacing,
filteredCatalog: List<McpCatalogEntry>,
) {
val catalogExpanded = remember { mutableStateOf(false) }
McpSectionHeader(
icon = Icons.Filled.Storage,
title = stringResource(R.string.mcp_servers_section_catalog),
trailing = {
TextButton(
onClick = {
catalogExpanded.value = !catalogExpanded.value
if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) {
viewModel.loadCatalog()
}
},
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(if (catalogExpanded.value) "Hide" else "Browse")
}
},
)
AnimatedVisibility(visible = catalogExpanded.value) {
Column(modifier = Modifier.fillMaxWidth()) {
// Search
OutlinedTextField(
value = state.catalogQuery,
onValueChange = viewModel::updateCatalogQuery,
label = { Text(stringResource(R.string.mcp_servers_catalog_search)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm),
)
// Loading / error / content
when {
state.catalogLoading -> {
Spacer(modifier = Modifier.height(spacing.sm))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.height(spacing.sm))
}
state.catalogError != null -> {
ErrorState(
message = state.catalogError,
onRetry = { viewModel.loadCatalog() },
)
}
filteredCatalog.isEmpty() -> {
Text(
text = stringResource(R.string.mcp_servers_catalog_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = spacing.md),
)
}
else -> {
filteredCatalog.forEach { entry ->
CatalogEntryCard(
entry = entry,
state = state,
viewModel = viewModel,
spacing = spacing,
)
Spacer(modifier = Modifier.height(spacing.sm))
}
}
}
}
}
}
@Composable
private fun CatalogEntryCard(
entry: McpCatalogEntry,
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: com.m57.hermescontrol.theme.Spacing,
) {
var showInstallForm by remember { mutableStateOf(false) }
val isInstalling = state.installingCatalogEntry == entry.name
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column(modifier = Modifier.padding(spacing.md)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
entry.source?.let {
Text(
text = stringResource(R.string.mcp_servers_catalog_source, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Button(
onClick = {
if (entry.env?.isNotEmpty() == true) {
showInstallForm = !showInstallForm
} else {
viewModel.installCatalogEntry(entry)
}
},
enabled = !isInstalling,
) {
Text(
if (isInstalling) {
stringResource(R.string.mcp_servers_catalog_installing)
} else {
stringResource(R.string.mcp_servers_catalog_install)
},
)
}
}
entry.description?.let {
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Install form with env vars
AnimatedVisibility(visible = showInstallForm) {
Column(modifier = Modifier.padding(top = spacing.sm)) {
entry.env?.let { envVars ->
if (envVars.isNotEmpty()) {
Text(
text = stringResource(R.string.mcp_servers_catalog_required_env),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(spacing.sm))
envVars.forEach { envVar ->
val label = envVar.label ?: envVar.key
val currentValue = state.catalogInstallEnv[envVar.key] ?: ""
OutlinedTextField(
value = currentValue,
onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) },
label = { Text(label) },
placeholder = envVar.description?.let { { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(spacing.sm))
}
}
}
Button(
onClick = { viewModel.installCatalogEntry(entry) },
enabled = !isInstalling,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (isInstalling) {
stringResource(R.string.mcp_servers_catalog_installing)
} else {
stringResource(R.string.mcp_servers_catalog_install)
},
)
}
}
}
}
}
}
@@ -0,0 +1,198 @@
package com.m57.hermescontrol.ui.mcp.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.mcp.AddServerMode
import com.m57.hermescontrol.ui.mcp.McpServersUiState
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AddServerSection(
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: Spacing,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
McpSectionHeader(
icon = Icons.Filled.Add,
title = stringResource(R.string.mcp_servers_add_server),
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
TextButton(
onClick = { viewModel.toggleImportDialog() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
),
) {
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_import_json))
}
TextButton(
onClick = { viewModel.toggleAddForm() },
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(if (state.showAddForm) "Hide" else "New")
}
}
},
)
AnimatedVisibility(visible = state.showAddForm) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column(modifier = Modifier.padding(spacing.md)) {
SecondaryTabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) {
Tab(
selected = state.addMode == AddServerMode.HTTP,
onClick = { viewModel.setAddMode(AddServerMode.HTTP) },
text = { Text(stringResource(R.string.mcp_servers_add_http)) },
)
Tab(
selected = state.addMode == AddServerMode.Stdio,
onClick = { viewModel.setAddMode(AddServerMode.Stdio) },
text = { Text(stringResource(R.string.mcp_servers_add_stdio)) },
)
}
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField(
value = state.addServerName,
onValueChange = viewModel::updateAddServerName,
label = { Text(stringResource(R.string.mcp_servers_field_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(spacing.sm))
if (state.addMode == AddServerMode.HTTP) {
OutlinedTextField(
value = state.addServerUrl,
onValueChange = viewModel::updateAddServerUrl,
label = { Text(stringResource(R.string.mcp_servers_field_url)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.mcp_servers_auth_mode),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(spacing.xs))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
FilterChip(
selected = state.addServerAuth == "none",
onClick = { viewModel.updateAddServerAuth("none") },
label = { Text(stringResource(R.string.mcp_servers_auth_none)) },
)
FilterChip(
selected = state.addServerAuth == "header",
onClick = { viewModel.updateAddServerAuth("header") },
label = { Text(stringResource(R.string.mcp_servers_auth_header)) },
)
FilterChip(
selected = state.addServerAuth == "oauth",
onClick = { viewModel.updateAddServerAuth("oauth") },
label = { Text(stringResource(R.string.mcp_servers_auth_oauth)) },
)
}
if (state.addServerAuth == "header") {
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField(
value = state.addServerBearerToken,
onValueChange = viewModel::updateAddServerBearerToken,
label = { Text(stringResource(R.string.mcp_servers_field_bearer_token)) },
singleLine = true,
visualTransformation = remember { PasswordVisualTransformation() },
modifier = Modifier.fillMaxWidth(),
)
}
} else {
OutlinedTextField(
value = state.addServerCommand,
onValueChange = viewModel::updateAddServerCommand,
label = { Text(stringResource(R.string.mcp_servers_field_command)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedTextField(
value = state.addServerArgs,
onValueChange = viewModel::updateAddServerArgs,
label = { Text(stringResource(R.string.mcp_servers_field_args)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.height(spacing.md))
Button(
onClick = { viewModel.submitAddServer() },
enabled = !state.addingServer,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (state.addingServer) {
stringResource(R.string.mcp_servers_action_adding)
} else {
stringResource(R.string.mcp_servers_action_submit)
},
)
}
}
}
}
}
}
@@ -0,0 +1,235 @@
package com.m57.hermescontrol.ui.mcp.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.mcp.McpServersUiState
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
@Composable
fun CatalogSection(
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: Spacing,
filteredCatalog: List<McpCatalogEntry>,
modifier: Modifier = Modifier,
) {
val catalogExpanded = remember { mutableStateOf(false) }
Column(modifier = modifier) {
McpSectionHeader(
icon = Icons.Filled.Storage,
title = stringResource(R.string.mcp_servers_section_catalog),
trailing = {
TextButton(
onClick = {
catalogExpanded.value = !catalogExpanded.value
if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) {
viewModel.loadCatalog()
}
},
colors =
ButtonDefaults.textButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
),
) {
Text(if (catalogExpanded.value) "Hide" else "Browse")
}
},
)
AnimatedVisibility(visible = catalogExpanded.value) {
Column(modifier = Modifier.fillMaxWidth()) {
// Search
OutlinedTextField(
value = state.catalogQuery,
onValueChange = viewModel::updateCatalogQuery,
label = { Text(stringResource(R.string.mcp_servers_catalog_search)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm),
)
// Loading / error / content
when {
state.catalogLoading -> {
Spacer(modifier = Modifier.height(spacing.sm))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.height(spacing.sm))
}
state.catalogError != null -> {
ErrorState(
message = state.catalogError,
onRetry = { viewModel.loadCatalog() },
)
}
filteredCatalog.isEmpty() -> {
Text(
text = stringResource(R.string.mcp_servers_catalog_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = spacing.md),
)
}
else -> {
filteredCatalog.forEach { entry ->
CatalogEntryCard(
entry = entry,
state = state,
viewModel = viewModel,
spacing = spacing,
)
Spacer(modifier = Modifier.height(spacing.sm))
}
}
}
}
}
}
}
@Composable
fun CatalogEntryCard(
entry: McpCatalogEntry,
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: Spacing,
modifier: Modifier = Modifier,
) {
var showInstallForm by remember { mutableStateOf(false) }
val isInstalling = state.installingCatalogEntry == entry.name
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column(modifier = Modifier.padding(spacing.md)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
entry.source?.let {
Text(
text = stringResource(R.string.mcp_servers_catalog_source, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Button(
onClick = {
if (entry.env?.isNotEmpty() == true) {
showInstallForm = !showInstallForm
} else {
viewModel.installCatalogEntry(entry)
}
},
enabled = !isInstalling,
) {
Text(
if (isInstalling) {
stringResource(R.string.mcp_servers_catalog_installing)
} else {
stringResource(R.string.mcp_servers_catalog_install)
},
)
}
}
entry.description?.let {
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Install form with env vars
AnimatedVisibility(visible = showInstallForm) {
Column(modifier = Modifier.padding(top = spacing.sm)) {
entry.env?.let { envVars ->
if (envVars.isNotEmpty()) {
Text(
text = stringResource(R.string.mcp_servers_catalog_required_env),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(spacing.sm))
envVars.forEach { envVar ->
val label = envVar.label ?: envVar.key
val currentValue = state.catalogInstallEnv[envVar.key] ?: ""
OutlinedTextField(
value = currentValue,
onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) },
label = { Text(label) },
placeholder = envVar.description?.let { { Text(it) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(spacing.sm))
}
}
}
Button(
onClick = { viewModel.installCatalogEntry(entry) },
enabled = !isInstalling,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (isInstalling) {
stringResource(R.string.mcp_servers_catalog_installing)
} else {
stringResource(R.string.mcp_servers_catalog_install)
},
)
}
}
}
}
}
}
@@ -0,0 +1,202 @@
package com.m57.hermescontrol.ui.mcp.components
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.DetailDialog
import com.m57.hermescontrol.ui.common.toDetailRows
import com.m57.hermescontrol.ui.mcp.McpServersUiState
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
@Composable
fun McpDialogs(
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: Spacing,
selectedServerDetail: McpServer?,
onDismissDetail: () -> Unit,
context: Context = LocalContext.current,
clipboardManager: ClipboardManager = LocalClipboardManager.current,
) {
selectedServerDetail?.let { server ->
DetailDialog(
title = server.name,
rows = server.toDetailRows(),
onDismiss = onDismissDetail,
)
}
state.activeOAuthFlow?.let { flow ->
AlertDialog(
onDismissRequest = { viewModel.dismissOAuthFlow() },
confirmButton = {
Button(
onClick = {
flow.authorizationUrl?.let { url ->
try {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(url),
),
)
} catch (_: Exception) {
}
}
},
) {
Text(stringResource(R.string.mcp_servers_oauth_dialog_open_browser))
}
},
dismissButton = {
OutlinedButton(onClick = { viewModel.dismissOAuthFlow() }) {
Text(stringResource(R.string.action_cancel))
}
},
title = {
Text(stringResource(R.string.mcp_servers_oauth_dialog_title))
},
text = {
Column {
Text(stringResource(R.string.mcp_servers_oauth_dialog_desc))
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.mcp_servers_oauth_dialog_status, flow.status),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
)
flow.error?.let { err ->
Spacer(modifier = Modifier.height(spacing.xs))
Text(
text = stringResource(R.string.mcp_servers_oauth_dialog_error, err),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
)
}
if (state.showImportDialog) {
AlertDialog(
onDismissRequest = { viewModel.toggleImportDialog() },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.UploadFile,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(spacing.sm))
Text(stringResource(R.string.mcp_servers_import_dialog_title))
}
},
text = {
Column {
Text(
text = stringResource(R.string.mcp_servers_import_dialog_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
OutlinedButton(
onClick = {
val clip = clipboardManager.getText()?.text
if (!clip.isNullOrBlank()) {
viewModel.updateImportJsonInput(clip)
}
},
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
) {
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = stringResource(R.string.mcp_servers_import_dialog_paste),
style = MaterialTheme.typography.labelSmall,
)
}
}
Spacer(modifier = Modifier.height(spacing.xs))
OutlinedTextField(
value = state.importJsonInput,
onValueChange = viewModel::updateImportJsonInput,
placeholder = {
Text(
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
},
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier =
Modifier
.fillMaxWidth()
.height(180.dp),
maxLines = 10,
)
}
},
confirmButton = {
Button(
onClick = { viewModel.submitImportJson() },
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
) {
if (state.isImportingJson) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
} else {
Text(stringResource(R.string.mcp_servers_action_submit))
}
}
},
dismissButton = {
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
@@ -0,0 +1,59 @@
package com.m57.hermescontrol.ui.mcp.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.LocalSpacing
@Composable
fun McpSectionHeader(
icon: ImageVector,
title: String,
modifier: Modifier = Modifier,
trailing: (@Composable () -> Unit)? = null,
) {
val spacing = LocalSpacing.current
Row(
modifier =
modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(12.dp),
).padding(horizontal = spacing.md, vertical = spacing.sm),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(spacing.sm))
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
}
trailing?.invoke()
}
}
@@ -0,0 +1,365 @@
package com.m57.hermescontrol.ui.mcp.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.mcp.McpHealthStatus
import com.m57.hermescontrol.ui.mcp.McpServersUiState
import com.m57.hermescontrol.ui.mcp.McpServersViewModel
import com.m57.hermescontrol.ui.mcp.McpTokenEstimator
@Composable
fun ServerCard(
server: McpServer,
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: Spacing,
onClick: () -> Unit,
onOpenBrowser: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var showEnv by remember { mutableStateOf(false) }
val isTesting = server.name in state.testingServers
val testResult = state.serverTestResults[server.name]
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
Card(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
colors =
CardDefaults.cardColors(
containerColor =
if (server.enabled) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) {
// Header row: name + status + toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = server.name.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.width(spacing.sm))
when (healthStatus) {
McpHealthStatus.HEALTHY -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_healthy),
status = StatusBadgeType.SUCCESS,
)
}
McpHealthStatus.AUTH_REQUIRED -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_needs_auth),
status = StatusBadgeType.WARNING,
)
}
McpHealthStatus.ERROR -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_error),
status = StatusBadgeType.ERROR,
)
}
McpHealthStatus.TESTING -> {
StatusBadge(
text = stringResource(R.string.mcp_servers_status_testing),
status = StatusBadgeType.INFO,
)
}
McpHealthStatus.UNKNOWN -> {
ServerStatusBadge(server.status)
}
}
}
Text(
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
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
if (toolCount != null && toolCount > 0) {
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
Spacer(modifier = Modifier.height(spacing.xs))
Surface(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
shape = RoundedCornerShape(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
) {
Icon(
imageVector = Icons.Filled.Build,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.Medium,
)
}
}
}
}
Switch(
checked = server.enabled,
onCheckedChange = { viewModel.toggleServer(server) },
)
}
// URL or Command
if (server.url != null) {
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = server.url,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
)
} else if (server.command != null) {
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.mcp_servers_label_command),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
)
Text(
text = "${server.command} ${server.args.orEmpty().joinToString(" ")}",
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
)
}
// Error diagnostics
server.error?.let { error ->
if (error.isNotBlank()) {
Spacer(modifier = Modifier.height(spacing.sm))
StatusBadge(
text = error,
status = StatusBadgeType.ERROR,
)
}
}
Spacer(modifier = Modifier.height(spacing.sm))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
if (server.auth == "oauth") {
FilledTonalButton(
onClick = { viewModel.startMcpOAuthFlow(server, onOpenBrowser) },
modifier = Modifier.weight(1.2f),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
) {
Icon(Icons.Filled.Key, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_authorize), maxLines = 1)
}
}
FilledTonalButton(
onClick = { viewModel.testServer(server.name) },
enabled = !isTesting,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
) {
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
} else {
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
}
}
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
}
}
// Env vars toggle
TextButton(
onClick = { showEnv = !showEnv },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_env_vars))
}
AnimatedVisibility(visible = showEnv) {
EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing)
}
}
}
}
@Composable
fun EnvVarSection(
server: McpServer,
state: McpServersUiState,
viewModel: McpServersViewModel,
spacing: Spacing,
modifier: Modifier = Modifier,
) {
val isEditing = state.editingEnvFor == server.name
val env = server.env ?: emptyMap()
Column(modifier = modifier) {
if (env.isEmpty() && !isEditing) {
Text(
text = stringResource(R.string.mcp_servers_env_no_vars),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = spacing.xs),
)
} else {
env.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold)
Text(
text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) {
Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp))
}
}
}
}
if (isEditing) {
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = state.envKeyInput,
onValueChange = viewModel::updateEnvKey,
label = { Text(stringResource(R.string.mcp_servers_env_key)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
value = state.envValueInput,
onValueChange = viewModel::updateEnvValue,
label = { Text(stringResource(R.string.mcp_servers_env_value)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Button(onClick = { viewModel.addEnvVar(server.name) }) {
Text(stringResource(R.string.mcp_servers_env_add))
}
OutlinedButton(onClick = { viewModel.stopEditingEnv() }) {
Text(stringResource(R.string.action_cancel))
}
}
} else {
TextButton(onClick = { viewModel.startEditingEnv(server) }) {
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))
}
}
}
}
@Composable
fun ServerStatusBadge(status: String?) {
if (status == null) return
val badgeType =
when (status.lowercase()) {
"running", "ok", "connected" -> StatusBadgeType.SUCCESS
"error", "failed" -> StatusBadgeType.ERROR
"stopped" -> StatusBadgeType.NEUTRAL
else -> StatusBadgeType.NEUTRAL
}
StatusBadge(text = status, status = badgeType)
}