* 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:
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(
|
||||
|
||||
Reference in new issue
Block a user