From 422bb9f5e639aec3bd2d61775d2b3eb0006643aa Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:30:51 +0300 Subject: [PATCH] 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 --- app/build.gradle.kts | 1 + .../m57/hermescontrol/data/model/McpServer.kt | 10 ++ .../data/remote/HermesApiService.kt | 11 +++ .../hermescontrol/ui/mcp/McpServersScreen.kt | 78 +++++++++++++++ .../ui/mcp/McpServersViewModel.kt | 97 +++++++++++++++++++ app/src/main/res/values-ko/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 6 ++ gradle/libs.versions.toml | 2 + 8 files changed, 211 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9a67287..cc03c1c2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index 980bee4a..cf351590 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -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? = null, ) + +@Serializable +data class McpOAuthFlowResponse( + val flowId: String, + val serverName: String, + val status: String, + val authorizationUrl: String? = null, + val error: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 75ed12f8..5886126c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + @POST("api/mcp/servers/{name}/auth") + suspend fun authMcpServer( + @Path("name") name: String, + ): Response + + @GET("api/mcp/oauth/flows/{flow_id}") + suspend fun getMcpOAuthFlowStatus( + @Path("flow_id") flowId: String, + ): Response + @GET("api/mcp/catalog") suspend fun getMcpCatalog(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 3f61ef3c..caba6a3d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -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(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), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 879fea57..d86d52d8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -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 = 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( diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 093107d9..cdf204ec 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -517,6 +517,12 @@ 설치 중… 출처: %1$s 필수 환경 변수 + 승인 + MCP OAuth 플로우 + 상태: %1$s + 오류: %1$s + 브라우저에 승인 페이지가 열렸습니다. 로그인을 완료한 후 여기로 돌아오세요. + 브라우저 열기 게이트웨이 실행 중 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc273fe0..f028a225 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -664,6 +664,12 @@ Header OAuth Bearer token + Authorize + MCP OAuth Flow + Status: %1$s + Error: %1$s + An authorization page has been opened in your browser. Complete the login, then return here. + Open browser GATEWAY RUNNING diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b55d661..7c3fb3e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"}