Skip to content

Commit 48152b6

Browse files
committed
feat: add MCP server management
1 parent b61a050 commit 48152b6

15 files changed

Lines changed: 613 additions & 3 deletions

opencode.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
"jetbrains_execute_terminal_command": "ask",
1212
"jetbrains_execute_run_configuration": "allow"
1313
},
14-
"keybinds": {
15-
"app_exit": "none"
14+
"agent": {
15+
"explore": {
16+
"mode": "subagent",
17+
"model": "anthropic/claude-haiku-4-5"
18+
}
1619
}
1720
}

src/main/kotlin/com/ashotn/opencode/companion/actions/ActionStrings.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,10 @@ enum class ActionStrings(
6666
description = "Disconnect from the server, reset all plugin state, and reconnect",
6767
disabledDescription = "Reset is only available when OpenCode is running or starting",
6868
),
69+
MCP_SERVERS(
70+
text = "MCP Servers",
71+
disabledText = "MCP Servers (OpenCode must be running)",
72+
description = "View and toggle MCP server connections",
73+
disabledDescription = "OpenCode must be running to manage MCP servers",
74+
),
6975
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.ashotn.opencode.companion.actions
2+
3+
import com.ashotn.opencode.companion.OpenCodePlugin
4+
import com.ashotn.opencode.companion.settings.OpenCodeSettings
5+
import com.ashotn.opencode.companion.util.applyStrings
6+
import com.intellij.icons.AllIcons
7+
import com.intellij.openapi.actionSystem.ActionUpdateThread
8+
import com.intellij.openapi.actionSystem.AnAction
9+
import com.intellij.openapi.actionSystem.AnActionEvent
10+
import com.intellij.openapi.project.Project
11+
import com.intellij.openapi.ui.popup.JBPopupFactory
12+
import com.intellij.ui.awt.RelativePoint
13+
import java.awt.Point
14+
15+
class McpServersAction(private val project: Project) : AnAction() {
16+
17+
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
18+
19+
override fun update(e: AnActionEvent) {
20+
val running = OpenCodePlugin.getInstance(project).isRunning
21+
e.presentation.icon = AllIcons.Nodes.Plugin
22+
e.applyStrings(ActionStrings.MCP_SERVERS, running)
23+
}
24+
25+
override fun actionPerformed(e: AnActionEvent) {
26+
val port = OpenCodeSettings.getInstance(project).serverPort
27+
val content = McpServersPopupPanel(port)
28+
29+
val popup = JBPopupFactory.getInstance()
30+
.createComponentPopupBuilder(content, content)
31+
.setTitle("MCP Servers")
32+
.setMovable(true)
33+
.setResizable(true)
34+
.setRequestFocus(true)
35+
.setFocusable(true)
36+
.createPopup()
37+
38+
// Reload the list whenever mcp.tools.changed fires, for as long as the popup is open.
39+
content.subscribeToMcpChanges(project, popup)
40+
41+
val component = e.inputEvent?.component
42+
if (component != null) {
43+
// Show below the toolbar icon that was clicked
44+
popup.show(RelativePoint(component, Point(0, component.height)))
45+
} else {
46+
popup.showInFocusCenter()
47+
}
48+
}
49+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package com.ashotn.opencode.companion.actions
2+
3+
import com.ashotn.opencode.companion.api.config.ConfigApiClient.McpServerConfig
4+
import com.ashotn.opencode.companion.api.mcp.McpApiClient.McpConnectionStatus
5+
import com.ashotn.opencode.companion.api.mcp.McpEntry
6+
import com.ashotn.opencode.companion.api.mcp.McpService
7+
import com.ashotn.opencode.companion.api.transport.ApiResult
8+
import com.ashotn.opencode.companion.ipc.McpChangedListener
9+
import com.intellij.icons.AllIcons
10+
import com.intellij.openapi.Disposable
11+
import com.intellij.openapi.application.ApplicationManager
12+
import com.intellij.openapi.project.Project
13+
import com.intellij.ui.AnimatedIcon
14+
import com.intellij.ui.JBColor
15+
import com.intellij.ui.components.JBLabel
16+
import com.intellij.ui.components.JBScrollPane
17+
import com.intellij.util.ui.JBUI
18+
import com.intellij.util.ui.UIUtil
19+
import java.awt.BorderLayout
20+
import java.awt.Dimension
21+
import java.awt.GridBagConstraints
22+
import java.awt.GridBagLayout
23+
import javax.swing.Box
24+
import javax.swing.JButton
25+
import javax.swing.JPanel
26+
import javax.swing.SwingConstants
27+
28+
/**
29+
* The content panel rendered inside the MCP servers popup.
30+
* Owns its own load/reload logic so the popup just needs to embed it.
31+
*
32+
* Call [subscribeToMcpChanges] after the popup is created, passing the popup's
33+
* [Disposable] so the subscription is cleaned up when the popup closes.
34+
*/
35+
class McpServersPopupPanel(
36+
private val port: Int,
37+
private val mcpService: McpService = McpService(),
38+
) : JPanel(BorderLayout()) {
39+
40+
private val listPanel = JPanel(GridBagLayout()).apply { isOpaque = false }
41+
private val statusLabel = JBLabel("Loading…", SwingConstants.CENTER).apply {
42+
border = JBUI.Borders.empty(8)
43+
foreground = JBUI.CurrentTheme.Label.disabledForeground()
44+
}
45+
private val scrollPane = JBScrollPane(listPanel).apply {
46+
border = null
47+
isOpaque = false
48+
viewport.isOpaque = false
49+
preferredSize = Dimension(JBUI.scale(360), JBUI.scale(180))
50+
}
51+
52+
init {
53+
isOpaque = false
54+
border = JBUI.Borders.empty(4)
55+
add(statusLabel, BorderLayout.NORTH)
56+
add(scrollPane, BorderLayout.CENTER)
57+
loadEntries()
58+
}
59+
60+
/**
61+
* Subscribes to [McpChangedListener] on the project message bus and reloads
62+
* the list whenever an `mcp.tools.changed` SSE event arrives.
63+
* The subscription is tied to [popupDisposable] and cleaned up automatically.
64+
*/
65+
fun subscribeToMcpChanges(project: Project, popupDisposable: Disposable) {
66+
project.messageBus.connect(popupDisposable).subscribe(
67+
McpChangedListener.TOPIC,
68+
McpChangedListener { loadEntries() },
69+
)
70+
}
71+
72+
private fun loadEntries() {
73+
statusLabel.text = "Loading…"
74+
statusLabel.foreground = JBUI.CurrentTheme.Label.disabledForeground()
75+
statusLabel.isVisible = true
76+
listPanel.removeAll()
77+
listPanel.revalidate()
78+
listPanel.repaint()
79+
80+
ApplicationManager.getApplication().executeOnPooledThread {
81+
val result = mcpService.listMcpEntries(port)
82+
ApplicationManager.getApplication().invokeLater {
83+
when (result) {
84+
is ApiResult.Success -> renderEntries(result.value)
85+
is ApiResult.Failure -> showError("Could not load MCP servers: ${result.error}")
86+
}
87+
}
88+
}
89+
}
90+
91+
private fun renderEntries(entries: List<McpEntry>) {
92+
listPanel.removeAll()
93+
94+
if (entries.isEmpty()) {
95+
statusLabel.text = "No MCP servers configured."
96+
statusLabel.isVisible = true
97+
return
98+
}
99+
100+
statusLabel.isVisible = false
101+
102+
val gbc = GridBagConstraints().apply {
103+
gridx = 0
104+
fill = GridBagConstraints.HORIZONTAL
105+
weightx = 1.0
106+
insets = JBUI.insets(1, 0)
107+
}
108+
109+
entries.forEachIndexed { index, entry ->
110+
gbc.gridy = index
111+
listPanel.add(buildRow(entry), gbc)
112+
}
113+
114+
// Push rows to the top
115+
gbc.gridy = entries.size
116+
gbc.weighty = 1.0
117+
gbc.fill = GridBagConstraints.BOTH
118+
listPanel.add(Box.createVerticalGlue(), gbc)
119+
120+
listPanel.revalidate()
121+
listPanel.repaint()
122+
}
123+
124+
private fun buildRow(entry: McpEntry): JPanel {
125+
val row = JPanel(BorderLayout(JBUI.scale(8), 0)).apply {
126+
isOpaque = false
127+
border = JBUI.Borders.empty(3, 6)
128+
}
129+
130+
// Left: icon + name + type tag (+ optional error line)
131+
val statusIcon = JBLabel(statusIconFor(entry)).apply {
132+
toolTipText = statusTooltipFor(entry)
133+
}
134+
135+
val nameLabel = JBLabel(entry.name)
136+
137+
val typeTag = JBLabel(typeTagFor(entry.config)).apply {
138+
font = UIUtil.getLabelFont(UIUtil.FontSize.SMALL)
139+
foreground = JBUI.CurrentTheme.Label.disabledForeground()
140+
}
141+
142+
val nameLine = JPanel(BorderLayout(JBUI.scale(4), 0)).apply {
143+
isOpaque = false
144+
add(nameLabel, BorderLayout.WEST)
145+
add(typeTag, BorderLayout.CENTER)
146+
}
147+
148+
val centerStack = if (entry.connectionError != null) {
149+
val errorLabel = JBLabel(entry.connectionError).apply {
150+
font = UIUtil.getLabelFont(UIUtil.FontSize.SMALL)
151+
foreground = JBColor.RED
152+
}
153+
JPanel(BorderLayout(0, JBUI.scale(1))).apply {
154+
isOpaque = false
155+
add(nameLine, BorderLayout.NORTH)
156+
add(errorLabel, BorderLayout.SOUTH)
157+
}
158+
} else {
159+
nameLine
160+
}
161+
162+
val left = JPanel(BorderLayout(JBUI.scale(6), 0)).apply {
163+
isOpaque = false
164+
add(statusIcon, BorderLayout.WEST)
165+
add(centerStack, BorderLayout.CENTER)
166+
}
167+
168+
row.add(left, BorderLayout.CENTER)
169+
row.add(buildToggleButton(entry), BorderLayout.EAST)
170+
return row
171+
}
172+
173+
private fun buildToggleButton(entry: McpEntry): JButton {
174+
val connected = entry.isConnected
175+
return JButton(if (connected) "Disconnect" else "Connect").apply {
176+
font = UIUtil.getLabelFont(UIUtil.FontSize.SMALL)
177+
isEnabled = !entry.isLoading
178+
if (entry.isLoading) icon = AnimatedIcon.Default.INSTANCE
179+
180+
addActionListener {
181+
isEnabled = false
182+
icon = AnimatedIcon.Default.INSTANCE
183+
184+
ApplicationManager.getApplication().executeOnPooledThread {
185+
val result = if (connected) mcpService.disconnect(port, entry.name)
186+
else mcpService.connect(port, entry.name)
187+
188+
ApplicationManager.getApplication().invokeLater {
189+
when (result) {
190+
is ApiResult.Success -> renderEntries(result.value)
191+
// On failure, reload the real server state so the button re-enables
192+
// and the entry's status icon/tooltip reflects what actually happened.
193+
is ApiResult.Failure -> loadEntries()
194+
}
195+
}
196+
}
197+
}
198+
}
199+
}
200+
201+
private fun showError(message: String) {
202+
statusLabel.text = message
203+
statusLabel.foreground = JBColor.RED
204+
statusLabel.isVisible = true
205+
}
206+
207+
private fun typeTagFor(config: McpServerConfig) = when (config) {
208+
is McpServerConfig.Local -> "(local)"
209+
is McpServerConfig.Remote -> "(remote)"
210+
}
211+
212+
private fun statusIconFor(entry: McpEntry) = when {
213+
entry.isLoading -> AnimatedIcon.Default.INSTANCE
214+
entry.isConnected -> AllIcons.RunConfigurations.TestPassed
215+
entry.connectionStatus == McpConnectionStatus.DISABLED -> AllIcons.Actions.Pause
216+
entry.hasIssue -> AllIcons.RunConfigurations.TestFailed
217+
else -> AllIcons.RunConfigurations.TestIgnored
218+
}
219+
220+
private fun statusTooltipFor(entry: McpEntry) = when {
221+
entry.isLoading -> "Updating…"
222+
entry.isConnected -> "Connected"
223+
entry.connectionStatus == McpConnectionStatus.DISABLED -> "Disabled"
224+
entry.connectionStatus == McpConnectionStatus.FAILED ->
225+
"Failed: ${entry.connectionError ?: "unknown error"}"
226+
227+
entry.connectionStatus == McpConnectionStatus.NEEDS_AUTH -> "Needs authentication"
228+
entry.connectionStatus == McpConnectionStatus.NEEDS_CLIENT_REGISTRATION ->
229+
"Needs client registration"
230+
231+
entry.connectionStatus == null -> "Not yet connected"
232+
else -> entry.connectionStatus.name.lowercase().replace('_', ' ')
233+
}
234+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package com.ashotn.opencode.companion.api.config
2+
3+
import com.ashotn.opencode.companion.api.transport.ApiResult
4+
import com.ashotn.opencode.companion.api.transport.OpenCodeHttpTransport
5+
import com.ashotn.opencode.companion.api.transport.mapJsonObjectResponse
6+
import com.ashotn.opencode.companion.api.transport.withParseContext
7+
import com.ashotn.opencode.companion.util.getObjectOrNull
8+
import com.ashotn.opencode.companion.util.getStringOrNull
9+
import com.google.gson.JsonObject
10+
11+
class ConfigApiClient(
12+
private val transport: OpenCodeHttpTransport = OpenCodeHttpTransport(),
13+
) {
14+
sealed class McpServerConfig {
15+
abstract val name: String
16+
abstract val enabled: Boolean
17+
18+
data class Local(
19+
override val name: String,
20+
val command: List<String>,
21+
val environment: Map<String, String>,
22+
override val enabled: Boolean,
23+
) : McpServerConfig()
24+
25+
data class Remote(
26+
override val name: String,
27+
val url: String,
28+
override val enabled: Boolean,
29+
) : McpServerConfig()
30+
}
31+
32+
fun getMcpServers(port: Int): ApiResult<List<McpServerConfig>> {
33+
val endpoint = ConfigEndpoints.get()
34+
val response = transport.get(port = port, path = endpoint.path)
35+
return transport.mapJsonObjectResponse(response) { root: JsonObject ->
36+
val mcpObj = root.getObjectOrNull("mcp") ?: return@mapJsonObjectResponse ApiResult.Success(emptyList())
37+
val servers = mcpObj.entrySet().mapNotNull { entry ->
38+
val name = entry.key
39+
val element = entry.value
40+
if (!element.isJsonObject) return@mapNotNull null
41+
val obj = element.asJsonObject
42+
val enabled = obj.get("enabled")
43+
?.takeIf { it.isJsonPrimitive }
44+
?.asBoolean
45+
?: true
46+
47+
when (obj.getStringOrNull("type")) {
48+
"local" -> {
49+
val command = obj.get("command")
50+
?.takeIf { it.isJsonArray }
51+
?.asJsonArray
52+
?.mapNotNull { e -> e.takeIf { it.isJsonPrimitive }?.asString }
53+
?: emptyList()
54+
val environment = obj.getObjectOrNull("environment")
55+
?.entrySet()
56+
?.associate { e -> e.key to e.value.asString }
57+
?: emptyMap()
58+
McpServerConfig.Local(
59+
name = name,
60+
command = command,
61+
environment = environment,
62+
enabled = enabled,
63+
)
64+
}
65+
66+
"remote" -> {
67+
val url = obj.getStringOrNull("url") ?: return@mapNotNull null
68+
McpServerConfig.Remote(
69+
name = name,
70+
url = url,
71+
enabled = enabled,
72+
)
73+
}
74+
75+
else -> null
76+
}
77+
}
78+
ApiResult.Success(servers)
79+
}.withParseContext(endpoint)
80+
}
81+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.ashotn.opencode.companion.api.config
2+
3+
import com.ashotn.opencode.companion.api.transport.ApiEndpoint
4+
import com.ashotn.opencode.companion.api.transport.HttpMethod
5+
6+
internal object ConfigEndpoints {
7+
fun get(): ApiEndpoint = ApiEndpoint(method = HttpMethod.GET, path = "/config")
8+
}

0 commit comments

Comments
 (0)