* 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
@@ -140,6 +140,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.browser)
|
||||||
|
|
||||||
// Arch Components
|
// Arch Components
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ data class McpServer(
|
|||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
val auth: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -64,3 +65,12 @@ data class McpCatalogInstallRequest(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val env: Map<String, String>? = null,
|
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.ManagedFilesListResponse
|
||||||
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
||||||
import com.m57.hermescontrol.data.model.McpCatalogResponse
|
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.McpServer
|
||||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||||
@@ -426,6 +427,16 @@ interface HermesApiService {
|
|||||||
@Path("name") name: String,
|
@Path("name") name: String,
|
||||||
): Response<Unit>
|
): 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")
|
@GET("api/mcp/catalog")
|
||||||
suspend fun getMcpCatalog(): Response<McpCatalogResponse>
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.RestartAlt
|
||||||
import androidx.compose.material.icons.filled.Science
|
import androidx.compose.material.icons.filled.Science
|
||||||
import androidx.compose.material.icons.filled.Storage
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -88,6 +90,7 @@ fun McpServersScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
|
val context = LocalContext.current
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
var showDetail by remember { mutableStateOf<McpServer?>(null) }
|
var showDetail by remember { mutableStateOf<McpServer?>(null) }
|
||||||
|
|
||||||
@@ -183,6 +186,17 @@ fun McpServersScreen(
|
|||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
spacing = spacing,
|
spacing = spacing,
|
||||||
onClick = { showDetail = server },
|
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 },
|
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
|
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
|
||||||
@@ -395,6 +461,7 @@ private fun ServerCard(
|
|||||||
viewModel: McpServersViewModel,
|
viewModel: McpServersViewModel,
|
||||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onOpenBrowser: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var showEnv by remember { mutableStateOf(false) }
|
var showEnv by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -477,6 +544,17 @@ private fun ServerCard(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
|
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(
|
FilledTonalButton(
|
||||||
onClick = { viewModel.testServer(server.name) },
|
onClick = { viewModel.testServer(server.name) },
|
||||||
modifier = Modifier.weight(1f),
|
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.AddMcpServerRequest
|
||||||
import com.m57.hermescontrol.data.model.McpCatalogEntry
|
import com.m57.hermescontrol.data.model.McpCatalogEntry
|
||||||
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
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.McpServer
|
||||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
@@ -48,6 +49,7 @@ data class McpServersUiState(
|
|||||||
val catalogError: String? = null,
|
val catalogError: String? = null,
|
||||||
val installingCatalogEntry: String? = null,
|
val installingCatalogEntry: String? = null,
|
||||||
val catalogInstallEnv: Map<String, String> = emptyMap(),
|
val catalogInstallEnv: Map<String, String> = emptyMap(),
|
||||||
|
val activeOAuthFlow: McpOAuthFlowResponse? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class McpServersViewModel :
|
class McpServersViewModel :
|
||||||
@@ -444,6 +446,101 @@ class McpServersViewModel :
|
|||||||
_uiState.update { it.copy(catalogInstallEnv = it.catalogInstallEnv + (key to value)) }
|
_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 ──────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
private fun revertToggle(
|
private fun revertToggle(
|
||||||
|
|||||||
@@ -517,6 +517,12 @@
|
|||||||
<string name="mcp_servers_catalog_installing">설치 중…</string>
|
<string name="mcp_servers_catalog_installing">설치 중…</string>
|
||||||
<string name="mcp_servers_catalog_source">출처: %1$s</string>
|
<string name="mcp_servers_catalog_source">출처: %1$s</string>
|
||||||
<string name="mcp_servers_catalog_required_env">필수 환경 변수</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 -->
|
<!-- Gateway Screen -->
|
||||||
<string name="gateway_status_running">게이트웨이 실행 중</string>
|
<string name="gateway_status_running">게이트웨이 실행 중</string>
|
||||||
|
|||||||
@@ -664,6 +664,12 @@
|
|||||||
<string name="mcp_servers_auth_header">Header</string>
|
<string name="mcp_servers_auth_header">Header</string>
|
||||||
<string name="mcp_servers_auth_oauth">OAuth</string>
|
<string name="mcp_servers_auth_oauth">OAuth</string>
|
||||||
<string name="mcp_servers_field_bearer_token">Bearer token</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 -->
|
<!-- Gateway Screen -->
|
||||||
<string name="gateway_status_running">GATEWAY RUNNING</string>
|
<string name="gateway_status_running">GATEWAY RUNNING</string>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
androidGradlePlugin = "9.0.1"
|
androidGradlePlugin = "9.0.1"
|
||||||
|
androidxBrowser = "1.8.0"
|
||||||
androidxCore = "1.18.0"
|
androidxCore = "1.18.0"
|
||||||
androidxLifecycle = "2.10.0"
|
androidxLifecycle = "2.10.0"
|
||||||
androidxActivity = "1.13.0"
|
androidxActivity = "1.13.0"
|
||||||
@@ -31,6 +32,7 @@ datastore = "1.1.2"
|
|||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
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-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"}
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"}
|
||||||
|
|||||||
Reference in new issue
Block a user