feat: support hosted OAuth for authenticated HTTP MCP servers (#751) (#752)

* feat: support hosted OAuth for authenticated HTTP MCP servers (#751)

* fix(mcp): import McpOAuthFlowResponse in McpServersViewModel

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-31 05:30:51 +03:00
1 parent 86e1e66ea9
commit 422bb9f5e6
8 files changed
+211

No files matched your search

@@ -24,6 +24,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.RestartAlt
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Storage
@@ -54,6 +55,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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
@@ -88,6 +90,7 @@ fun McpServersScreen(
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current
val context = LocalContext.current
var query by remember { mutableStateOf("") }
var showDetail by remember { mutableStateOf<McpServer?>(null) }
@@ -183,6 +186,17 @@ fun McpServersScreen(
viewModel = viewModel,
spacing = spacing,
onClick = { showDetail = server },
onOpenBrowser = { url ->
try {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
),
)
} catch (_: Exception) {
}
},
)
}
@@ -207,6 +221,58 @@ fun McpServersScreen(
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,
)
}
}
},
)
}
}
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
@@ -395,6 +461,7 @@ private fun ServerCard(
viewModel: McpServersViewModel,
spacing: com.m57.hermescontrol.theme.Spacing,
onClick: () -> Unit,
onOpenBrowser: (String) -> Unit,
) {
var showEnv by remember { mutableStateOf(false) }
@@ -477,6 +544,17 @@ private fun ServerCard(
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) },
modifier = Modifier.weight(1f),
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.AddMcpServerRequest
import com.m57.hermescontrol.data.model.McpCatalogEntry
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient
@@ -48,6 +49,7 @@ data class McpServersUiState(
val catalogError: String? = null,
val installingCatalogEntry: String? = null,
val catalogInstallEnv: Map<String, String> = emptyMap(),
val activeOAuthFlow: McpOAuthFlowResponse? = null,
)
class McpServersViewModel :
@@ -444,6 +446,101 @@ class McpServersViewModel :
_uiState.update { it.copy(catalogInstallEnv = it.catalogInstallEnv + (key to value)) }
}
// ── OAuth ─────────────────────────────────────────────────
private var oauthPollJob: kotlinx.coroutines.Job? = null
fun startMcpOAuthFlow(
server: McpServer,
onOpenBrowser: (String) -> Unit,
) {
oauthPollJob?.cancel()
_uiState.update { it.copy(toastMessage = "Starting OAuth authorization…") }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.authMcpServer(server.name) }
}
when (result) {
is NetworkResult.Success -> {
val flow = result.data
_uiState.update { it.copy(activeOAuthFlow = flow) }
flow.authorizationUrl?.let { url ->
onOpenBrowser(url)
startPollingOAuthFlow(flow.flowId)
} ?: run {
_uiState.update {
it.copy(
toastMessage = "Failed to start OAuth: No authorization URL returned",
)
}
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to start OAuth: ${result.error.message}") }
}
}
}
}
private fun startPollingOAuthFlow(flowId: String) {
oauthPollJob?.cancel()
oauthPollJob =
viewModelScope.launch {
var polling = true
while (polling) {
kotlinx.coroutines.delay(2000)
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getMcpOAuthFlowStatus(flowId) }
}
when (result) {
is NetworkResult.Success -> {
val flow = result.data
_uiState.update { it.copy(activeOAuthFlow = flow) }
when (flow.status) {
"approved", "completed" -> {
polling = false
_uiState.update {
it.copy(
activeOAuthFlow = null,
toastMessage = "OAuth authorization successful!",
)
}
loadServers()
}
"error" -> {
polling = false
_uiState.update {
it.copy(
activeOAuthFlow = null,
toastMessage = "OAuth failed: ${flow.error ?: "Unknown error"}",
)
}
}
"authorization_required" -> {
// Keep polling
}
else -> {
// Check if worker is done or expired
// Continue polling until status transitions
}
}
}
is NetworkResult.Failure -> {
// Keep polling or stop after too many failures? Let's just log/toast n retry a few times
}
}
}
}
}
fun dismissOAuthFlow() {
oauthPollJob?.cancel()
oauthPollJob = null
_uiState.update { it.copy(activeOAuthFlow = null) }
}
// ── Helpers ──────────────────────────────────────────────
private fun revertToggle(