1package soy.iko.opencode.ui.mcp
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
5import kotlinx.coroutines.ExperimentalCoroutinesApi
6import kotlinx.coroutines.Dispatchers
7import kotlinx.coroutines.flow.MutableSharedFlow
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.SharedFlow
10import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.asSharedFlow
12import kotlinx.coroutines.flow.asStateFlow
13import kotlinx.coroutines.flow.collectLatest
14import kotlinx.coroutines.flow.merge
15import kotlinx.coroutines.launch
16import kotlinx.coroutines.withContext
17import kotlinx.serialization.json.JsonObject
18import kotlinx.serialization.json.JsonPrimitive
19import kotlinx.serialization.json.add
20import kotlinx.serialization.json.buildJsonObject
21import kotlinx.serialization.json.put
22import kotlinx.serialization.json.putJsonArray
23import kotlinx.serialization.json.putJsonObject
24import soy.iko.opencode.di.AppContainer
25import soy.iko.opencode.util.runCatchingCancellable
27/** The shape of an MCP server the user is registering from the Add dialog. */
28enum class McpKind { LOCAL, REMOTE }
30/** User-facing outcomes of an Add-server attempt; the screen maps these to localized strings. */
31enum class McpMessage { ADDED, ADD_FAILED, NOT_CONNECTED }
33/** Loads the configured MCP servers from the active connection's `config`, enriched with live
34 * status from `GET /mcp`, and supports dynamically registering a new server via `POST /mcp`. */
35@OptIn(ExperimentalCoroutinesApi::class)
36class McpViewModel(private val container: AppContainer) : ViewModel() {
38 sealed interface State {
39 data object Loading : State
40 data object Disconnected : State
41 /** MCP servers failed to load. [message] carries a localized, human-friendly summary
42 * produced by [AppContainer.friendlyError] (auth vs network vs timeout vs …) so the
43 * user can tell failure modes apart. Matches every other screen's error handling. */
44 data class Error(val message: String? = null) : State
45 data class Ready(val servers: List<McpServerInfo>) : State
48 private val _state = MutableStateFlow<State>(State.Loading)
49 val state: StateFlow<State> = _state.asStateFlow()
51 /** True while a reload runs on top of already-loaded data, so the list stays visible and the
52 * pull-to-refresh spinner (not the full-screen one) shows. */
53 private val _refreshing = MutableStateFlow(false)
54 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
56 /** True while an Add-server POST is in flight, so the dialog can show a spinner and block a
57 * second concurrent submit. */
58 private val _adding = MutableStateFlow(false)
59 val adding: StateFlow<Boolean> = _adding.asStateFlow()
61 /** One-shot user-facing messages (add success/failure) for the snackbar host. */
62 private val _messages = MutableSharedFlow<McpMessage>(
63 extraBufferCapacity = 1,
64 onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
66 val messages: SharedFlow<McpMessage> = _messages.asSharedFlow()
68 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
69 private val _reload = MutableStateFlow(0)
72 // Observe the active connection so the servers load (or reload) once a connection is
73 // available — including when the screen opens during a reconnect window where
74 // activeConnection.value is momentarily null (else it would latch Disconnected until a
75 // manual refresh). collectLatest cancels an in-flight load when the connection changes or
76 // a manual reload arrives.
77 viewModelScope.launch {
78 merge(container.activeConnection, _reload).collectLatest { doLoad() }
82 fun load() { _reload.value++ }
84 private suspend fun doLoad() {
85 val conn = container.activeConnection.value ?: run {
86 _state.value = State.Disconnected
89 // Keep already-loaded servers on screen during a reload; only show the full-screen
90 // spinner on the very first load.
91 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
93 runCatchingCancellable {
94 val config = conn.api.config()
95 // Fetch live status separately so a missing/unstable /mcp endpoint (older server
96 // builds, transient error) never blocks the config-driven view — it just omits the
97 // connected/error indicators for that load.
98 val status = runCatchingCancellable { conn.api.mcpStatus() }.getOrNull()
99 withContext(Dispatchers.Default) {
100 mergeMcpStatus(parseMcpServers(config), status)
103 .onSuccess { _state.value = State.Ready(it) }
104 .onFailure { _state.value = State.Error(container.friendlyError(it)) }
106 // Reset in finally so a collectLatest cancellation (which rethrows
107 // CancellationException out of runCatchingCancellable, skipping the lines above)
108 // can't leave the pull-to-refresh spinner latched true.
109 _refreshing.value = false
113 /** Register a new MCP server via `POST /mcp`. Builds the server config object from the
114 * kind/target the user entered. A name collision is left for the server to reject (it
115 * overwrites/validates server-side); the result surfaces as a snackbar. */
116 fun addServer(name: String, kind: McpKind, target: String, env: String) {
117 if (_adding.value) return
118 val conn = container.activeConnection.value ?: run {
119 viewModelScope.launch { _messages.tryEmit(McpMessage.NOT_CONNECTED) }
122 val cleanName = name.trim()
123 val cleanTarget = target.trim()
124 if (cleanName.isEmpty() || cleanTarget.isEmpty()) return
125 val config = buildMcpConfig(kind, cleanTarget, env)
126 viewModelScope.launch {
128 val ok = runCatchingCancellable { conn.api.addMcp(cleanName, config) }.isSuccess
129 _adding.value = false
131 _messages.tryEmit(McpMessage.ADDED)
134 _messages.tryEmit(McpMessage.ADD_FAILED)
140/** Build a `config.mcp`-shaped object for a new server. Local servers carry a `command` token
141 * array; remote servers carry a `url`. Env vars (one KEY=VALUE per line) are parsed tolerantly. */
142private fun buildMcpConfig(kind: McpKind, target: String, env: String): JsonObject = buildJsonObject {
146 putJsonArray("command") {
147 // Split the command line into argv the way a shell roughly would: on whitespace.
148 // This intentionally doesn't handle quoting — MCP command fields are normally a
149 // simple program + args, and over-engineering a parser here would surprise more
150 // than it helps. Users needing quotes can edit config.mcp directly.
151 target.split(Regex("\\s+")).filter { it.isNotEmpty() }.forEach { add(it) }
155 put("type", "remote")
159 val envPairs = env.lineSequence()
160 .mapNotNull { line ->
161 val eq = line.indexOf('=')
162 if (eq <= 0) null else line.substring(0, eq).trim() to line.substring(eq + 1).trim()
164 .filter { it.first.isNotEmpty() }
166 if (envPairs.isNotEmpty()) {
167 putJsonObject("env") { envPairs.forEach { (k, v) -> put(k, JsonPrimitive(v)) } }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list