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

+1
View File
@@ -140,6 +140,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.browser)
// Arch Components
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -17,6 +17,7 @@ data class McpServer(
val enabled: Boolean,
val status: String? = null,
val error: String? = null,
val auth: String? = null,
)
@Serializable
@@ -64,3 +65,12 @@ data class McpCatalogInstallRequest(
val name: String,
val env: Map<String, String>? = null,
)
@Serializable
data class McpOAuthFlowResponse(
val flowId: String,
val serverName: String,
val status: String,
val authorizationUrl: String? = null,
val error: String? = null,
)
@@ -44,6 +44,7 @@ import com.m57.hermescontrol.data.model.ManagedFileUpload
import com.m57.hermescontrol.data.model.ManagedFilesListResponse
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
import com.m57.hermescontrol.data.model.McpCatalogResponse
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.model.McpServersResponse
@@ -426,6 +427,16 @@ interface HermesApiService {
@Path("name") name: String,
): Response<Unit>
@POST("api/mcp/servers/{name}/auth")
suspend fun authMcpServer(
@Path("name") name: String,
): Response<McpOAuthFlowResponse>
@GET("api/mcp/oauth/flows/{flow_id}")
suspend fun getMcpOAuthFlowStatus(
@Path("flow_id") flowId: String,
): Response<McpOAuthFlowResponse>
@GET("api/mcp/catalog")
suspend fun getMcpCatalog(): Response<McpCatalogResponse>
@@ -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(
+6
View File
@@ -517,6 +517,12 @@
<string name="mcp_servers_catalog_installing">설치 중…</string>
<string name="mcp_servers_catalog_source">출처: %1$s</string>
<string name="mcp_servers_catalog_required_env">필수 환경 변수</string>
<string name="mcp_servers_action_authorize">승인</string>
<string name="mcp_servers_oauth_dialog_title">MCP OAuth 플로우</string>
<string name="mcp_servers_oauth_dialog_status">상태: %1$s</string>
<string name="mcp_servers_oauth_dialog_error">오류: %1$s</string>
<string name="mcp_servers_oauth_dialog_desc">브라우저에 승인 페이지가 열렸습니다. 로그인을 완료한 후 여기로 돌아오세요.</string>
<string name="mcp_servers_oauth_dialog_open_browser">브라우저 열기</string>
<!-- Gateway Screen -->
<string name="gateway_status_running">게이트웨이 실행 중</string>
+6
View File
@@ -664,6 +664,12 @@
<string name="mcp_servers_auth_header">Header</string>
<string name="mcp_servers_auth_oauth">OAuth</string>
<string name="mcp_servers_field_bearer_token">Bearer token</string>
<string name="mcp_servers_action_authorize">Authorize</string>
<string name="mcp_servers_oauth_dialog_title">MCP OAuth Flow</string>
<string name="mcp_servers_oauth_dialog_status">Status: %1$s</string>
<string name="mcp_servers_oauth_dialog_error">Error: %1$s</string>
<string name="mcp_servers_oauth_dialog_desc">An authorization page has been opened in your browser. Complete the login, then return here.</string>
<string name="mcp_servers_oauth_dialog_open_browser">Open browser</string>
<!-- Gateway Screen -->
<string name="gateway_status_running">GATEWAY RUNNING</string>
+2
View File
@@ -1,5 +1,6 @@
[versions]
androidGradlePlugin = "9.0.1"
androidxBrowser = "1.8.0"
androidxCore = "1.18.0"
androidxLifecycle = "2.10.0"
androidxActivity = "1.13.0"
@@ -31,6 +32,7 @@ datastore = "1.1.2"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"}