mirror of
https://github.com/carlrobertoh/ProxyAI.git
synced 2026-05-19 07:54:46 +00:00
feat: add MCP configuration form
This commit is contained in:
parent
1502cb5a4a
commit
4e126c14bc
12 changed files with 1675 additions and 0 deletions
|
|
@ -0,0 +1,154 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp
|
||||
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import ee.carlrobert.codegpt.mcp.McpCommandValidator
|
||||
import io.modelcontextprotocol.client.McpClient
|
||||
import io.modelcontextprotocol.client.McpSyncClient
|
||||
import io.modelcontextprotocol.client.transport.ServerParameters
|
||||
import io.modelcontextprotocol.client.transport.StdioClientTransport
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
@Service
|
||||
class McpClientManager {
|
||||
|
||||
private val logger = thisLogger()
|
||||
|
||||
fun createClient(serverDetails: McpServerDetailsState): McpSyncClient? {
|
||||
return try {
|
||||
val command = serverDetails.command ?: "npx"
|
||||
val resolvedCommand = McpCommandValidator.resolveCommand(command)
|
||||
?: throw IllegalStateException(McpCommandValidator.getCommandNotFoundMessage(command))
|
||||
|
||||
val connectionParams = ServerParameters.builder(resolvedCommand)
|
||||
.args(*serverDetails.arguments.toTypedArray())
|
||||
.env(serverDetails.environmentVariables)
|
||||
.build()
|
||||
|
||||
McpClient.sync(StdioClientTransport(connectionParams))
|
||||
.requestTimeout(Duration.ofSeconds(30))
|
||||
.loggingConsumer { notification ->
|
||||
logger.info("MCP Server '${serverDetails.name}': ${notification.data()}")
|
||||
}
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Failed to create MCP client for server '${serverDetails.name}'", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class ToolInfo(val name: String, val description: String)
|
||||
|
||||
data class ConnectionTestResult(
|
||||
val success: Boolean,
|
||||
val serverName: String? = null,
|
||||
val serverVersion: String? = null,
|
||||
val tools: List<ToolInfo> = emptyList(),
|
||||
val toolDescriptions: Map<String, String> = emptyMap(),
|
||||
val resources: List<String> = emptyList(),
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
|
||||
fun testConnection(serverDetails: McpServerDetailsState): ConnectionTestResult {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
return try {
|
||||
val command = serverDetails.command ?: "npx"
|
||||
val resolvedCommand = McpCommandValidator.resolveCommand(command)
|
||||
if (resolvedCommand == null) {
|
||||
logger.warn("Command not found for '${serverDetails.name}': $command")
|
||||
return ConnectionTestResult(
|
||||
success = false,
|
||||
errorMessage = McpCommandValidator.getCommandNotFoundMessage(command)
|
||||
)
|
||||
}
|
||||
|
||||
val client = createClient(serverDetails)
|
||||
if (client == null) {
|
||||
logger.warn("Failed to create client for '${serverDetails.name}'")
|
||||
return ConnectionTestResult(
|
||||
success = false,
|
||||
errorMessage = "Failed to create client - check command and arguments"
|
||||
)
|
||||
}
|
||||
|
||||
client.use { mcpClient ->
|
||||
logger.info("Client created, attempting to initialize connection...")
|
||||
|
||||
val initResult = mcpClient.initialize()
|
||||
val serverInfo = initResult.serverInfo
|
||||
logger.info("Connection initialized successfully")
|
||||
|
||||
val toolsResult = try {
|
||||
logger.debug("Listing tools...")
|
||||
mcpClient.listTools()
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Failed to list tools: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
val resourcesResult = try {
|
||||
logger.debug("Listing resources...")
|
||||
mcpClient.listResources()
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Failed to list resources: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
val tools = toolsResult?.tools?.map {
|
||||
ToolInfo(it.name, it.description ?: "No description available")
|
||||
} ?: emptyList()
|
||||
val toolDescriptions = toolsResult?.tools?.associate {
|
||||
it.name to (it.description ?: "No description available")
|
||||
} ?: emptyMap()
|
||||
val resources = resourcesResult?.resources?.map { it.name } ?: emptyList()
|
||||
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
logger.info("Connection test successful for '${serverDetails.name}': ${serverInfo.name} (took ${duration}ms)")
|
||||
|
||||
ConnectionTestResult(
|
||||
success = true,
|
||||
serverName = serverInfo.name,
|
||||
serverVersion = serverInfo.version,
|
||||
tools = tools,
|
||||
toolDescriptions = toolDescriptions,
|
||||
resources = resources
|
||||
)
|
||||
}
|
||||
} catch (e: TimeoutException) {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
logger.warn(
|
||||
"Connection test timed out for '${serverDetails.name}' after ${duration}ms",
|
||||
e
|
||||
)
|
||||
ConnectionTestResult(
|
||||
success = false,
|
||||
errorMessage = "Connection timed out after ${duration / 1000} seconds. The server may be unavailable or slow to respond."
|
||||
)
|
||||
} catch (e: java.io.IOException) {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
logger.warn(
|
||||
"IO error during connection test for '${serverDetails.name}' after ${duration}ms",
|
||||
e
|
||||
)
|
||||
ConnectionTestResult(
|
||||
success = false,
|
||||
errorMessage = "Network error: ${e.message}. Check if the server command is correct and accessible."
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
logger.warn("Connection test failed for '${serverDetails.name}' after ${duration}ms", e)
|
||||
ConnectionTestResult(
|
||||
success = false,
|
||||
errorMessage = when {
|
||||
e.message?.contains("No such file") == true -> "Command not found: '${serverDetails.command}'. Make sure it's installed and in PATH."
|
||||
e.message?.contains("Permission denied") == true -> "Permission denied. Check file permissions and execution rights."
|
||||
e.message?.contains("Connection refused") == true -> "Connection refused. The server may not be running or accessible."
|
||||
e.message?.contains("timeout") == true || e.message?.contains("timed out") == true -> "Connection timed out. The server may be slow to respond or unavailable."
|
||||
else -> e.message ?: "Unknown connection error"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp
|
||||
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import ee.carlrobert.codegpt.settings.mcp.form.McpForm
|
||||
import javax.swing.JComponent
|
||||
|
||||
class McpConfigurable : Configurable {
|
||||
|
||||
private lateinit var component: McpForm
|
||||
|
||||
override fun getDisplayName(): String {
|
||||
return "ProxyAI: MCP Servers"
|
||||
}
|
||||
|
||||
override fun createComponent(): JComponent {
|
||||
component = McpForm()
|
||||
return component.createPanel()
|
||||
}
|
||||
|
||||
override fun isModified(): Boolean = component.isModified()
|
||||
|
||||
override fun apply() {
|
||||
component.applyChanges()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
component.resetChanges()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp
|
||||
|
||||
import com.intellij.openapi.components.*
|
||||
import ee.carlrobert.codegpt.util.ApplicationUtil
|
||||
|
||||
@Service
|
||||
@State(
|
||||
name = "CodeGPT_McpSettings",
|
||||
storages = [Storage("CodeGPT_McpSettings.xml")]
|
||||
)
|
||||
class McpSettings : SimplePersistentStateComponent<McpSettingsState>(McpSettingsState())
|
||||
|
||||
class McpSettingsState : BaseState() {
|
||||
var servers by list<McpServerDetailsState>()
|
||||
|
||||
init {
|
||||
servers.add(McpServerDetailsState().apply {
|
||||
id = 1L
|
||||
name = "Everything Server"
|
||||
command = "npx"
|
||||
arguments = mutableListOf("-y", "@modelcontextprotocol/server-everything")
|
||||
})
|
||||
servers.add(McpServerDetailsState().apply {
|
||||
id = 2L
|
||||
name = "File System Server"
|
||||
command = "npx"
|
||||
arguments = mutableListOf(
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
ApplicationUtil.findCurrentProject()?.basePath ?: "/"
|
||||
)
|
||||
})
|
||||
servers.add(McpServerDetailsState().apply {
|
||||
id = 3L
|
||||
name = "Git Server"
|
||||
command = "npx"
|
||||
arguments = mutableListOf("-y", "@modelcontextprotocol/server-git")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class McpServerDetailsState : BaseState() {
|
||||
var id by property(1L)
|
||||
var name by string("New MCP Server")
|
||||
var command by string("npx")
|
||||
var arguments by list<String>()
|
||||
var environmentVariables by map<String, String>()
|
||||
}
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.ui.AnimatedIcon
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.ColoredListCellRenderer
|
||||
import com.intellij.ui.SimpleTextAttributes
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.dsl.builder.*
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.settings.mcp.McpClientManager
|
||||
import java.awt.Dimension
|
||||
import java.awt.Font
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JList
|
||||
import javax.swing.JProgressBar
|
||||
import javax.swing.ListSelectionModel
|
||||
|
||||
class McpConnectionTestResultDialog(
|
||||
private val serverName: String,
|
||||
private var result: McpClientManager.ConnectionTestResult?,
|
||||
private val serverCommand: String? = null
|
||||
) : DialogWrapper(true) {
|
||||
|
||||
private var centerPanel: JComponent? = null
|
||||
|
||||
init {
|
||||
title = "Testing MCP Connection"
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
centerPanel = when {
|
||||
result == null -> createLoadingPanel()
|
||||
result!!.success -> createSuccessPanel()
|
||||
else -> createErrorPanel()
|
||||
}
|
||||
return centerPanel!!
|
||||
}
|
||||
|
||||
fun updateResult(newResult: McpClientManager.ConnectionTestResult) {
|
||||
result = newResult
|
||||
title = if (newResult.success) "Connection Test Successful" else "Connection Test Failed"
|
||||
|
||||
val newPanel = if (newResult.success) {
|
||||
createSuccessPanel()
|
||||
} else {
|
||||
createErrorPanel()
|
||||
}
|
||||
|
||||
val contentPane = contentPanel
|
||||
contentPane.removeAll()
|
||||
contentPane.add(newPanel)
|
||||
contentPane.revalidate()
|
||||
contentPane.repaint()
|
||||
|
||||
pack()
|
||||
}
|
||||
|
||||
override fun createActions() = arrayOf(okAction)
|
||||
|
||||
private fun createLoadingPanel(): JComponent {
|
||||
return panel {
|
||||
row {
|
||||
icon(AnimatedIcon.Default()).gap(RightGap.SMALL)
|
||||
label("Testing connection to '${serverName}'...")
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.BOLD)
|
||||
}
|
||||
}.bottomGap(BottomGap.NONE)
|
||||
|
||||
serverInformation()
|
||||
|
||||
group("Connection Status") {
|
||||
row {
|
||||
val progressBar = JProgressBar().apply {
|
||||
isIndeterminate = true
|
||||
preferredSize = Dimension(400, 20)
|
||||
}
|
||||
cell(progressBar)
|
||||
.align(Align.FILL)
|
||||
}
|
||||
row {
|
||||
label("Initializing connection and discovering capabilities...")
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.ITALIC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row {
|
||||
icon(AllIcons.General.Information)
|
||||
cell(
|
||||
JBLabel(
|
||||
"""
|
||||
<html>
|
||||
This may take a few seconds while the server starts up and responds.
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label()
|
||||
}
|
||||
}.topGap(TopGap.SMALL)
|
||||
}.apply {
|
||||
preferredSize = Dimension(650, 300)
|
||||
minimumSize = Dimension(650, 250)
|
||||
maximumSize = Dimension(650, 350)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSuccessPanel(): JComponent {
|
||||
return panel {
|
||||
row {
|
||||
icon(AllIcons.General.InspectionsOK).gap(RightGap.SMALL)
|
||||
label("Connection test successful!")
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.BOLD)
|
||||
}
|
||||
}.bottomGap(BottomGap.SMALL)
|
||||
|
||||
serverInformation()
|
||||
|
||||
if (result!!.tools.isNotEmpty()) {
|
||||
group("Available Tools (${result!!.tools.size})") {
|
||||
row {
|
||||
val toolsWithDescriptions = result!!.tools.map { tool ->
|
||||
val description =
|
||||
result!!.toolDescriptions[tool.name] ?: "No description available"
|
||||
ToolInfo(tool.name, description)
|
||||
}
|
||||
|
||||
val toolsList = JBList(CollectionListModel(toolsWithDescriptions)).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
cellRenderer = object : ColoredListCellRenderer<ToolInfo>() {
|
||||
override fun customizeCellRenderer(
|
||||
list: JList<out ToolInfo>,
|
||||
value: ToolInfo,
|
||||
index: Int,
|
||||
selected: Boolean,
|
||||
hasFocus: Boolean
|
||||
) {
|
||||
icon = AllIcons.Nodes.Function
|
||||
append(value.name, SimpleTextAttributes.REGULAR_ATTRIBUTES)
|
||||
if (value.description.isNotBlank() && value.description != "No description available") {
|
||||
append(" - ", SimpleTextAttributes.GRAYED_ATTRIBUTES)
|
||||
val truncatedDesc = if (value.description.length > 80) {
|
||||
value.description.take(77) + "..."
|
||||
} else {
|
||||
value.description
|
||||
}
|
||||
append(
|
||||
truncatedDesc,
|
||||
SimpleTextAttributes.GRAYED_ATTRIBUTES
|
||||
)
|
||||
}
|
||||
|
||||
if (value.description.length > 80) {
|
||||
toolTipText =
|
||||
"<html><b>${value.name}</b><br/>${value.description}</html>"
|
||||
}
|
||||
}
|
||||
}
|
||||
visibleRowCount = minOf(6, result!!.tools.size)
|
||||
fixedCellWidth = 550
|
||||
}
|
||||
|
||||
cell(JBScrollPane(toolsList))
|
||||
.align(Align.FILL)
|
||||
.resizableColumn()
|
||||
}.resizableRow()
|
||||
.rowComment("These tools will be available for use with the AI assistant")
|
||||
}
|
||||
}
|
||||
|
||||
if (result!!.resources.isNotEmpty()) {
|
||||
group("Available Resources (${result!!.resources.size})") {
|
||||
row {
|
||||
val resourcesList = JBList(CollectionListModel(result!!.resources)).apply {
|
||||
selectionMode = ListSelectionModel.SINGLE_SELECTION
|
||||
cellRenderer = object : ColoredListCellRenderer<String>() {
|
||||
override fun customizeCellRenderer(
|
||||
list: JList<out String>,
|
||||
value: String,
|
||||
index: Int,
|
||||
selected: Boolean,
|
||||
hasFocus: Boolean
|
||||
) {
|
||||
icon = AllIcons.Nodes.DataTables
|
||||
append(value, SimpleTextAttributes.REGULAR_ATTRIBUTES)
|
||||
}
|
||||
}
|
||||
visibleRowCount = minOf(6, result!!.resources.size)
|
||||
}
|
||||
|
||||
cell(JBScrollPane(resourcesList))
|
||||
.align(Align.FILL)
|
||||
.resizableColumn()
|
||||
}.resizableRow()
|
||||
.rowComment("These resources can be accessed by the AI assistant")
|
||||
}
|
||||
}
|
||||
|
||||
if (result!!.tools.isEmpty() && result!!.resources.isEmpty()) {
|
||||
group("Server Capabilities") {
|
||||
row {
|
||||
icon(AllIcons.General.Information)
|
||||
label("No tools or resources were reported by this server.")
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.ITALIC)
|
||||
}
|
||||
}
|
||||
row {
|
||||
comment("The server may still provide other capabilities not listed here.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
preferredSize = Dimension(650, 450)
|
||||
minimumSize = Dimension(650, 400)
|
||||
maximumSize = Dimension(650, 600)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ToolInfo(val name: String, val description: String)
|
||||
|
||||
|
||||
private fun Panel.serverInformation() {
|
||||
group("Server Information") {
|
||||
row("Server Name:") {
|
||||
label(serverName)
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.BOLD)
|
||||
}
|
||||
}
|
||||
|
||||
result?.serverName?.let { name ->
|
||||
row("MCP Server:") {
|
||||
label("$name${result?.serverVersion?.let { " (v$it)" } ?: ""}")
|
||||
}
|
||||
}
|
||||
|
||||
serverCommand?.let { command ->
|
||||
row("Command:") {
|
||||
label(command)
|
||||
}
|
||||
}
|
||||
}.bottomGap(BottomGap.SMALL).topGap(TopGap.NONE)
|
||||
}
|
||||
|
||||
private fun createErrorPanel(): JComponent {
|
||||
return panel {
|
||||
row {
|
||||
icon(AllIcons.General.Error).gap(RightGap.SMALL)
|
||||
label("Connection test failed")
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.BOLD)
|
||||
}
|
||||
}.bottomGap(BottomGap.NONE)
|
||||
|
||||
serverInformation()
|
||||
|
||||
result!!.errorMessage?.let { error ->
|
||||
group("Error Details") {
|
||||
row {
|
||||
val errorTextArea = com.intellij.ui.components.JBTextArea(error).apply {
|
||||
isEditable = false
|
||||
rows = 3
|
||||
columns = 50
|
||||
lineWrap = true
|
||||
wrapStyleWord = true
|
||||
}
|
||||
|
||||
cell(JBScrollPane(errorTextArea))
|
||||
.align(Align.FILL)
|
||||
.resizableColumn()
|
||||
}.resizableRow()
|
||||
}
|
||||
}
|
||||
|
||||
group("Troubleshooting Tips") {
|
||||
val tips = listOf(
|
||||
"Verify the command exists and is accessible" + (serverCommand?.let { ": '$it'" }
|
||||
?: ""),
|
||||
"Check that all command arguments are correct",
|
||||
"Ensure required environment variables are properly set",
|
||||
"Try running the command manually in your terminal"
|
||||
)
|
||||
|
||||
tips.forEach { tip ->
|
||||
row {
|
||||
icon(AllIcons.General.BalloonInformation)
|
||||
label(tip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
row {
|
||||
icon(AllIcons.General.ContextHelp)
|
||||
cell(
|
||||
JBLabel(
|
||||
"""
|
||||
<html>
|
||||
<b>Need more help?</b> Check the MCP server documentation or test the command in your terminal.
|
||||
</html>
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label()
|
||||
}
|
||||
}.topGap(TopGap.SMALL)
|
||||
}.apply {
|
||||
preferredSize = Dimension(650, 400)
|
||||
minimumSize = Dimension(650, 350)
|
||||
maximumSize = Dimension(650, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import ee.carlrobert.codegpt.settings.mcp.McpSettingsState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
|
||||
class McpFileProvider {
|
||||
|
||||
private val objectMapper: ObjectMapper = ObjectMapper()
|
||||
.registerModule(Jdk8Module())
|
||||
.registerModule(JavaTimeModule())
|
||||
.registerModule(KotlinModule.Builder().build())
|
||||
.apply { configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) }
|
||||
|
||||
suspend fun writeSettings(path: String, data: McpSettingsState) = withContext(Dispatchers.IO) {
|
||||
val serializedData = objectMapper.writeValueAsString(data)
|
||||
FileWriter(path).use {
|
||||
it.write(serializedData)
|
||||
}
|
||||
}
|
||||
|
||||
fun readFromFile(path: String): McpSettingsState =
|
||||
objectMapper.readValue<McpSettingsState>(File(path))
|
||||
}
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.application.ReadAction
|
||||
import com.intellij.openapi.application.runInEdt
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.fileChooser.FileChooser
|
||||
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.ui.DialogBuilder
|
||||
import com.intellij.openapi.ui.DialogWrapper.OK_EXIT_CODE
|
||||
import com.intellij.openapi.ui.MessageType
|
||||
import com.intellij.openapi.ui.TextBrowseFolderListener
|
||||
import com.intellij.openapi.ui.TextFieldWithBrowseButton
|
||||
import com.intellij.ui.ToolbarDecorator
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.ui.dsl.builder.BottomGap
|
||||
import com.intellij.ui.render.LabelBasedRenderer
|
||||
import com.intellij.ui.treeStructure.SimpleTree
|
||||
import com.intellij.util.concurrency.AppExecutorUtil
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import ee.carlrobert.codegpt.settings.mcp.McpClientManager
|
||||
import ee.carlrobert.codegpt.settings.mcp.McpSettings
|
||||
import ee.carlrobert.codegpt.settings.mcp.McpSettingsState
|
||||
import ee.carlrobert.codegpt.settings.mcp.form.McpFormUtil.toState
|
||||
import ee.carlrobert.codegpt.settings.mcp.form.details.McpServerDetails
|
||||
import ee.carlrobert.codegpt.settings.mcp.form.details.McpServerDetailsPanel
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.Component
|
||||
import java.awt.Font
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JTree
|
||||
import javax.swing.tree.DefaultMutableTreeNode
|
||||
import javax.swing.tree.DefaultTreeModel
|
||||
import javax.swing.tree.TreePath
|
||||
import javax.swing.tree.TreeSelectionModel
|
||||
|
||||
class McpServerTreeNode(val details: McpServerDetails) : DefaultMutableTreeNode() {
|
||||
override fun toString(): String = details.name
|
||||
}
|
||||
|
||||
class McpForm {
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val serverDetailsPanel = McpServerDetailsPanel()
|
||||
private val serversNode = DefaultMutableTreeNode("MCP Servers")
|
||||
private val root = DefaultMutableTreeNode("Root").apply { add(serversNode) }
|
||||
private val treeModel = DefaultTreeModel(root)
|
||||
private val tree = SimpleTree(treeModel).apply {
|
||||
isRootVisible = false
|
||||
selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
|
||||
cellRenderer = McpTreeCellRenderer()
|
||||
|
||||
setupChildNodes()
|
||||
|
||||
addTreeSelectionListener { e ->
|
||||
val node = e.newLeadSelectionPath?.lastPathComponent as? McpServerTreeNode
|
||||
node?.let {
|
||||
serverDetailsPanel.updateData(it.details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val project = ProjectManager.getInstance().defaultProject
|
||||
private val mcpFileProvider = McpFileProvider()
|
||||
|
||||
init {
|
||||
runInEdt(ModalityState.any()) {
|
||||
expandAll()
|
||||
selectFirstServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHeaderPanel() = com.intellij.ui.dsl.builder.panel {
|
||||
row {
|
||||
label("MCP Servers")
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.BOLD, 16f)
|
||||
}
|
||||
.gap(com.intellij.ui.dsl.builder.RightGap.COLUMNS)
|
||||
|
||||
button("Import from JSON") { importFromJson() }
|
||||
.gap(com.intellij.ui.dsl.builder.RightGap.SMALL)
|
||||
button("Import from File") { importSettingsFromFile() }
|
||||
.gap(com.intellij.ui.dsl.builder.RightGap.SMALL)
|
||||
button("Export") { exportSettingsToFile() }
|
||||
}
|
||||
|
||||
row {
|
||||
text("Connect AI assistants to external tools and data sources")
|
||||
.applyToComponent {
|
||||
foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND
|
||||
font = JBUI.Fonts.smallFont()
|
||||
}
|
||||
}.bottomGap(BottomGap.MEDIUM)
|
||||
}
|
||||
|
||||
fun createPanel(): JComponent {
|
||||
return BorderLayoutPanel(8, 0)
|
||||
.addToTop(createHeaderPanel())
|
||||
.addToLeft(createToolbarDecorator().createPanel())
|
||||
.addToCenter(serverDetailsPanel.getPanel())
|
||||
}
|
||||
|
||||
fun isModified(): Boolean {
|
||||
val settings = service<McpSettings>().state
|
||||
val formServers = getFormServers()
|
||||
|
||||
if (settings.servers.size != formServers.size) return true
|
||||
|
||||
return !settings.servers.zip(formServers).all { (state, form) ->
|
||||
state.id == form.id &&
|
||||
state.name == form.name &&
|
||||
state.command == form.command &&
|
||||
state.arguments == form.arguments &&
|
||||
state.environmentVariables == form.environmentVariables
|
||||
}
|
||||
}
|
||||
|
||||
fun applyChanges() {
|
||||
val settings = service<McpSettings>().state
|
||||
settings.servers = getFormServers().map { it.toState() }.toMutableList()
|
||||
}
|
||||
|
||||
fun resetChanges() {
|
||||
removeAllChildNodes()
|
||||
setupChildNodes()
|
||||
reloadTreeView()
|
||||
}
|
||||
|
||||
private fun getFormServers(): List<McpServerDetails> {
|
||||
return serversNode.children().toList()
|
||||
.filterIsInstance<McpServerTreeNode>()
|
||||
.map { it.details }
|
||||
}
|
||||
|
||||
private fun setupChildNodes() {
|
||||
service<McpSettings>().state.servers.forEach {
|
||||
serversNode.add(McpServerTreeNode(McpServerDetails(it)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createToolbarDecorator(): ToolbarDecorator =
|
||||
ToolbarDecorator.createDecorator(tree)
|
||||
.setPreferredSize(java.awt.Dimension(300, 0))
|
||||
.setAddAction { handleAddAction() }
|
||||
.setRemoveAction { handleRemoveAction() }
|
||||
.setRemoveActionUpdater {
|
||||
tree.selectionPath?.lastPathComponent is McpServerTreeNode
|
||||
}
|
||||
.addExtraAction(object :
|
||||
AnAction("Test Connection", "Test server connection", AllIcons.Actions.Execute) {
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
val node = tree.selectionPath?.lastPathComponent as? McpServerTreeNode
|
||||
e.presentation.isEnabled = node != null
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
handleTestConnection()
|
||||
}
|
||||
})
|
||||
.addExtraAction(object : AnAction(
|
||||
"View JSON",
|
||||
"View configuration as JSON",
|
||||
AllIcons.FileTypes.Json
|
||||
) {
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabled =
|
||||
tree.selectionPath?.lastPathComponent is McpServerTreeNode
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
viewServerAsJson()
|
||||
}
|
||||
})
|
||||
.disableUpDownActions()
|
||||
|
||||
private fun handleAddAction() {
|
||||
val nextId = (getFormServers().maxOfOrNull { it.id } ?: 0) + 1
|
||||
val newServer = McpServerDetails(
|
||||
id = nextId,
|
||||
name = "New MCP Server",
|
||||
command = "npx",
|
||||
arguments = mutableListOf(),
|
||||
environmentVariables = mutableMapOf()
|
||||
)
|
||||
val newNode = McpServerTreeNode(newServer)
|
||||
insertAndSelectNode(newNode)
|
||||
}
|
||||
|
||||
private fun handleRemoveAction() {
|
||||
val selectedNode = tree.selectionPath?.lastPathComponent as? McpServerTreeNode ?: return
|
||||
treeModel.removeNodeFromParent(selectedNode)
|
||||
serverDetailsPanel.remove(selectedNode.details)
|
||||
}
|
||||
|
||||
private fun handleTestConnection() {
|
||||
val selectedNode = tree.selectionPath?.lastPathComponent as? McpServerTreeNode ?: return
|
||||
val server = selectedNode.details
|
||||
val dialog = McpConnectionTestResultDialog(server.name, null, server.command)
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val serverState = server.toState()
|
||||
val testResult = service<McpClientManager>().testConnection(serverState)
|
||||
|
||||
runInEdt(ModalityState.any()) { dialog.updateResult(testResult) }
|
||||
} catch (e: Exception) {
|
||||
val testResult = McpClientManager.ConnectionTestResult(
|
||||
success = false,
|
||||
errorMessage = e.message ?: "Unknown error occurred"
|
||||
)
|
||||
runInEdt(ModalityState.any()) {
|
||||
dialog.updateResult(testResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun insertAndSelectNode(newNode: McpServerTreeNode) {
|
||||
treeModel.insertNodeInto(newNode, serversNode, serversNode.childCount)
|
||||
tree.selectionPath = TreePath(newNode.path)
|
||||
}
|
||||
|
||||
private fun removeAllChildNodes() {
|
||||
serversNode.removeAllChildren()
|
||||
}
|
||||
|
||||
private fun reloadTreeView() {
|
||||
treeModel.reload()
|
||||
expandAll()
|
||||
selectFirstServer()
|
||||
}
|
||||
|
||||
private fun expandAll() {
|
||||
tree.expandPath(TreePath(serversNode.path))
|
||||
}
|
||||
|
||||
private fun selectFirstServer() {
|
||||
val firstServer = serversNode.getFirstChild() as? McpServerTreeNode
|
||||
firstServer?.let {
|
||||
tree.selectionPath = TreePath(it.path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportSettingsToFile() {
|
||||
val defaultFileName = "mcp-servers.json"
|
||||
val settings = service<McpSettings>().state
|
||||
|
||||
val fileNameTextField = JBTextField(defaultFileName).apply { columns = 20 }
|
||||
val fileChooserDescriptor =
|
||||
FileChooserDescriptorFactory.createSingleFolderDescriptor().apply {
|
||||
isForcedToUseIdeaFileChooser = true
|
||||
}
|
||||
val textFieldWithBrowseButton = TextFieldWithBrowseButton().apply {
|
||||
text = project.basePath ?: System.getProperty("user.home")
|
||||
addBrowseFolderListener(TextBrowseFolderListener(fileChooserDescriptor, project))
|
||||
}
|
||||
|
||||
val result = createExportDialog(fileNameTextField, textFieldWithBrowseButton).show()
|
||||
val fileName = fileNameTextField.text.ifEmpty { defaultFileName }
|
||||
val filePath = textFieldWithBrowseButton.text
|
||||
|
||||
if (result == OK_EXIT_CODE) {
|
||||
val fullFilePath = "$filePath/$fileName"
|
||||
coroutineScope.launch {
|
||||
runCatching {
|
||||
mcpFileProvider.writeSettings(fullFilePath, settings)
|
||||
}.onFailure {
|
||||
showExportErrorMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun importSettingsFromFile() {
|
||||
val fileChooserDescriptor = FileChooserDescriptorFactory
|
||||
.createSingleFileDescriptor("json")
|
||||
.apply { isForcedToUseIdeaFileChooser = true }
|
||||
|
||||
FileChooser.chooseFile(fileChooserDescriptor, project, null)?.let { file ->
|
||||
ReadAction.nonBlocking<McpSettingsState> {
|
||||
file.canonicalPath?.let { mcpFileProvider.readFromFile(it) }
|
||||
}
|
||||
.inSmartMode(project)
|
||||
.finishOnUiThread(ModalityState.defaultModalityState()) { settings ->
|
||||
insertServers(settings.servers)
|
||||
reloadTreeView()
|
||||
}
|
||||
.submit(AppExecutorUtil.getAppExecutorService())
|
||||
.onError { showImportErrorMessage() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertServers(servers: List<ee.carlrobert.codegpt.settings.mcp.McpServerDetailsState>) {
|
||||
serversNode.removeAllChildren()
|
||||
servers.forEachIndexed { index, server ->
|
||||
val node = McpServerTreeNode(McpServerDetails(server))
|
||||
treeModel.insertNodeInto(node, serversNode, index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createExportDialog(
|
||||
fileNameTextField: JBTextField,
|
||||
filePathButton: TextFieldWithBrowseButton
|
||||
): DialogBuilder {
|
||||
val form = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent("File name:", fileNameTextField)
|
||||
.addLabeledComponent("Save to:", filePathButton)
|
||||
.panel
|
||||
|
||||
return DialogBuilder().apply {
|
||||
setTitle("Export MCP Settings")
|
||||
centerPanel(form)
|
||||
addOkAction()
|
||||
addCancelAction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showExportErrorMessage() {
|
||||
OverlayUtil.showBalloon(
|
||||
"Failed to export MCP settings",
|
||||
MessageType.ERROR,
|
||||
tree
|
||||
)
|
||||
}
|
||||
|
||||
private fun showImportErrorMessage() {
|
||||
OverlayUtil.showBalloon(
|
||||
"Failed to import MCP settings",
|
||||
MessageType.ERROR,
|
||||
tree
|
||||
)
|
||||
}
|
||||
|
||||
private fun importFromJson() {
|
||||
val dialog = McpJsonImportDialog()
|
||||
if (dialog.showAndGet()) {
|
||||
val importedServers = dialog.importedServers
|
||||
if (importedServers.isNotEmpty()) {
|
||||
importedServers.forEach { server ->
|
||||
val newNode = McpServerTreeNode(server)
|
||||
insertAndSelectNode(newNode)
|
||||
}
|
||||
|
||||
val message = if (importedServers.size == 1) {
|
||||
"Successfully imported 1 MCP server"
|
||||
} else {
|
||||
"Successfully imported ${importedServers.size} MCP servers"
|
||||
}
|
||||
|
||||
OverlayUtil.showBalloon(
|
||||
message,
|
||||
MessageType.INFO,
|
||||
tree
|
||||
)
|
||||
|
||||
if (importedServers.isNotEmpty()) {
|
||||
val firstImportedNode = serversNode.children().toList()
|
||||
.filterIsInstance<McpServerTreeNode>()
|
||||
.find { it.details.id == importedServers.first().id }
|
||||
firstImportedNode?.let {
|
||||
tree.selectionPath = TreePath(it.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun viewServerAsJson() {
|
||||
val selectedNode = tree.selectionPath?.lastPathComponent as? McpServerTreeNode ?: return
|
||||
val server = selectedNode.details
|
||||
|
||||
val dialog = McpJsonViewDialog(server)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private class McpTreeCellRenderer : LabelBasedRenderer.Tree() {
|
||||
override fun getTreeCellRendererComponent(
|
||||
tree: JTree,
|
||||
value: Any?,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
focused: Boolean
|
||||
): Component {
|
||||
super.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, focused)
|
||||
|
||||
if (value is McpServerTreeNode) {
|
||||
icon = AllIcons.Nodes.Services
|
||||
iconTextGap = 6
|
||||
} else {
|
||||
icon = AllIcons.Nodes.Folder
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form
|
||||
|
||||
import ee.carlrobert.codegpt.settings.mcp.McpServerDetailsState
|
||||
import ee.carlrobert.codegpt.settings.mcp.form.details.McpServerDetails
|
||||
|
||||
object McpFormUtil {
|
||||
fun McpServerDetails.toState(): McpServerDetailsState {
|
||||
val state = McpServerDetailsState()
|
||||
state.id = this.id
|
||||
state.name = this.name
|
||||
state.command = this.command
|
||||
state.arguments = this.arguments.toMutableList()
|
||||
state.environmentVariables = this.environmentVariables.toMutableMap()
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import com.intellij.openapi.editor.ex.EditorEx
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.ui.ValidationInfo
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.dsl.builder.Align
|
||||
import com.intellij.ui.dsl.builder.BottomGap
|
||||
import com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import ee.carlrobert.codegpt.settings.mcp.form.details.McpServerDetails
|
||||
import java.awt.Dimension
|
||||
import java.awt.Font
|
||||
import javax.swing.JComponent
|
||||
|
||||
class McpJsonImportDialog : DialogWrapper(true) {
|
||||
|
||||
private val project = ProjectManager.getInstance().defaultProject
|
||||
private val jsonEditor = EditorTextField(
|
||||
"",
|
||||
project,
|
||||
FileTypeManager.getInstance().getFileTypeByExtension("json")
|
||||
).apply {
|
||||
setOneLineMode(false)
|
||||
preferredSize = Dimension(580, 250)
|
||||
|
||||
addSettingsProvider { editor ->
|
||||
val settings = editor.settings
|
||||
settings.isLineNumbersShown = true
|
||||
settings.isAutoCodeFoldingEnabled = true
|
||||
settings.isFoldingOutlineShown = true
|
||||
settings.isAllowSingleLogicalLineFolding = true
|
||||
settings.isRightMarginShown = false
|
||||
settings.isUseSoftWraps = false
|
||||
settings.isWhitespacesShown = false
|
||||
|
||||
editor.colorsScheme.apply {
|
||||
editorFontSize = JBUI.Fonts.label().size
|
||||
editorFontName = JBUI.Fonts.label().fontName
|
||||
}
|
||||
|
||||
if (editor is EditorEx) {
|
||||
editor.isViewer = false
|
||||
editor.setVerticalScrollbarVisible(true)
|
||||
editor.setHorizontalScrollbarVisible(true)
|
||||
editor.backgroundColor = editor.colorsScheme.defaultBackground
|
||||
}
|
||||
}
|
||||
|
||||
text = """
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/directory"]
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
addSettingsProvider { editor ->
|
||||
editor.caretModel.moveToOffset(0)
|
||||
editor.scrollingModel.scrollToCaret(com.intellij.openapi.editor.ScrollType.RELATIVE)
|
||||
}
|
||||
}
|
||||
|
||||
private val objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
|
||||
|
||||
var importedServers: List<McpServerDetails> = emptyList()
|
||||
private set
|
||||
|
||||
init {
|
||||
title = "Import MCP Servers from JSON"
|
||||
init()
|
||||
|
||||
|
||||
UIUtil.invokeLaterIfNeeded {
|
||||
jsonEditor.editor?.let { editor ->
|
||||
editor.caretModel.moveToOffset(0)
|
||||
editor.scrollingModel.scrollToCaret(com.intellij.openapi.editor.ScrollType.MAKE_VISIBLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
return panel {
|
||||
row {
|
||||
label("Paste your MCP server configuration:")
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.BOLD)
|
||||
}
|
||||
}.bottomGap(BottomGap.SMALL)
|
||||
|
||||
row {
|
||||
scrollCell(jsonEditor)
|
||||
.align(Align.FILL)
|
||||
.resizableColumn()
|
||||
}.resizableRow()
|
||||
}.apply {
|
||||
preferredSize = Dimension(600, 400)
|
||||
minimumSize = Dimension(550, 350)
|
||||
}
|
||||
}
|
||||
|
||||
override fun doValidate(): ValidationInfo? {
|
||||
val jsonText = jsonEditor.text.trim()
|
||||
if (jsonText.isEmpty()) {
|
||||
return ValidationInfo("JSON content cannot be empty", jsonEditor)
|
||||
}
|
||||
|
||||
return try {
|
||||
val parsedServers = parseJsonToServers(jsonText)
|
||||
if (parsedServers.isEmpty()) {
|
||||
ValidationInfo("No valid MCP servers found in the JSON", jsonEditor)
|
||||
} else {
|
||||
importedServers = parsedServers
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ValidationInfo("Invalid JSON format: ${e.message}", jsonEditor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonToServers(jsonText: String): List<McpServerDetails> {
|
||||
val jsonNode = objectMapper.readTree(jsonText)
|
||||
val servers = mutableListOf<McpServerDetails>()
|
||||
var nextId = System.currentTimeMillis()
|
||||
|
||||
when {
|
||||
jsonNode.has("mcpServers") -> {
|
||||
val mcpServers = jsonNode.get("mcpServers")
|
||||
mcpServers.fields().forEach { (name, config) ->
|
||||
servers.add(createServerFromConfig(nextId++, name, config))
|
||||
}
|
||||
}
|
||||
|
||||
jsonNode.has("command") -> {
|
||||
servers.add(createServerFromConfig(nextId++, "Imported Server", jsonNode))
|
||||
}
|
||||
|
||||
jsonNode.isArray -> {
|
||||
jsonNode.forEach { serverNode ->
|
||||
val name =
|
||||
serverNode.get("name")?.asText() ?: "Imported Server ${servers.size + 1}"
|
||||
servers.add(createServerFromConfig(nextId++, name, serverNode))
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
jsonNode.fields().forEach { (name, config) ->
|
||||
if (config.isObject) {
|
||||
servers.add(createServerFromConfig(nextId++, name, config))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
private fun createServerFromConfig(id: Long, name: String, config: JsonNode): McpServerDetails {
|
||||
val command = config.get("command")?.asText() ?: "npx"
|
||||
val args = config.get("args")?.map { it.asText() }?.toMutableList()
|
||||
?: config.get("arguments")?.map { it.asText() }?.toMutableList()
|
||||
?: mutableListOf()
|
||||
|
||||
val env = mutableMapOf<String, String>()
|
||||
config.get("env")?.fields()?.forEach { (key, value) ->
|
||||
env[key] = value.asText()
|
||||
}
|
||||
config.get("environment")?.fields()?.forEach { (key, value) ->
|
||||
env[key] = value.asText()
|
||||
}
|
||||
|
||||
return McpServerDetails(
|
||||
id = id,
|
||||
name = name,
|
||||
command = command,
|
||||
arguments = args,
|
||||
environmentVariables = env
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.openapi.ide.CopyPasteManager
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.ui.MessageType
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.dsl.builder.Align
|
||||
import com.intellij.ui.dsl.builder.BottomGap
|
||||
import com.intellij.ui.dsl.builder.TopGap
|
||||
import com.intellij.ui.dsl.builder.panel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground
|
||||
import ee.carlrobert.codegpt.settings.mcp.form.details.McpServerDetails
|
||||
import ee.carlrobert.codegpt.ui.OverlayUtil
|
||||
import java.awt.Dimension
|
||||
import java.awt.Font
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import javax.swing.JComponent
|
||||
|
||||
class McpJsonViewDialog(private val server: McpServerDetails) : DialogWrapper(true) {
|
||||
|
||||
private val objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
|
||||
private val project = ProjectManager.getInstance().defaultProject
|
||||
|
||||
private val jsonEditor = EditorTextField(
|
||||
"",
|
||||
project,
|
||||
FileTypeManager.getInstance().getFileTypeByExtension("json")
|
||||
).apply {
|
||||
setOneLineMode(false)
|
||||
preferredSize = Dimension(700, 450)
|
||||
|
||||
addSettingsProvider { editor ->
|
||||
val settings = editor.settings
|
||||
settings.isLineNumbersShown = true
|
||||
settings.isAutoCodeFoldingEnabled = true
|
||||
settings.isFoldingOutlineShown = true
|
||||
settings.isAllowSingleLogicalLineFolding = true
|
||||
settings.isRightMarginShown = false
|
||||
settings.isUseSoftWraps = false
|
||||
settings.isWhitespacesShown = false
|
||||
|
||||
editor.colorsScheme.apply {
|
||||
editorFontSize = JBUI.Fonts.label().size
|
||||
editorFontName = JBUI.Fonts.label().fontName
|
||||
}
|
||||
|
||||
editor.isViewer = true
|
||||
editor.backgroundColor = editor.colorsScheme.defaultBackground
|
||||
}
|
||||
|
||||
text = generateServerJson()
|
||||
}
|
||||
|
||||
init {
|
||||
title = "JSON Configuration - ${server.name}"
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
return panel {
|
||||
row {
|
||||
icon(AllIcons.FileTypes.Json)
|
||||
label("JSON configuration for '${server.name}':")
|
||||
.applyToComponent {
|
||||
font = JBUI.Fonts.label().deriveFont(Font.BOLD)
|
||||
}
|
||||
}.bottomGap(BottomGap.MEDIUM)
|
||||
|
||||
row {
|
||||
cell(com.intellij.ui.components.JBScrollPane(jsonEditor).apply {
|
||||
preferredSize = Dimension(700, 450)
|
||||
border = JBUI.Borders.customLine(separatorForeground())
|
||||
})
|
||||
.align(Align.FILL)
|
||||
.resizableColumn()
|
||||
}.resizableRow()
|
||||
|
||||
separator()
|
||||
|
||||
row {
|
||||
button("Copy to Clipboard") {
|
||||
CopyPasteManager.getInstance().setContents(StringSelection(jsonEditor.text))
|
||||
OverlayUtil.showBalloon(
|
||||
"JSON configuration copied to clipboard",
|
||||
MessageType.INFO,
|
||||
it.source as JComponent
|
||||
)
|
||||
}.applyToComponent {
|
||||
icon = AllIcons.Actions.Copy
|
||||
}
|
||||
}.topGap(TopGap.NONE)
|
||||
}.apply {
|
||||
preferredSize = Dimension(750, 600)
|
||||
minimumSize = Dimension(700, 500)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createActions() = arrayOf(okAction)
|
||||
|
||||
private fun generateServerJson(): String {
|
||||
val serverConfig = objectMapper.createObjectNode().apply {
|
||||
put("command", server.command)
|
||||
|
||||
if (server.arguments.isNotEmpty()) {
|
||||
val argsArray = putArray("args")
|
||||
server.arguments.forEach { argsArray.add(it) }
|
||||
}
|
||||
|
||||
if (server.environmentVariables.isNotEmpty()) {
|
||||
val envObject = putObject("env")
|
||||
server.environmentVariables.forEach { (key, value) ->
|
||||
envObject.put(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mcpServers = objectMapper.createObjectNode()
|
||||
mcpServers.set<ObjectNode>(server.name, serverConfig)
|
||||
|
||||
val rootObject = objectMapper.createObjectNode()
|
||||
rootObject.set<ObjectNode>("mcpServers", mcpServers)
|
||||
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootObject)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form.details
|
||||
|
||||
import com.intellij.openapi.ui.DialogWrapper
|
||||
import com.intellij.openapi.ui.ValidationInfo
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.ui.dsl.builder.*
|
||||
import javax.swing.JComponent
|
||||
|
||||
class SingleInputDialog(
|
||||
title: String,
|
||||
private val label: String,
|
||||
initialValue: String
|
||||
) : DialogWrapper(true) {
|
||||
|
||||
private val inputField = JBTextField(initialValue)
|
||||
|
||||
val inputValue: String
|
||||
get() = inputField.text
|
||||
|
||||
init {
|
||||
this.title = title
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
return panel {
|
||||
row(label) {
|
||||
cell(inputField)
|
||||
.columns(COLUMNS_LARGE)
|
||||
.focused()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doValidate(): ValidationInfo? {
|
||||
return if (inputField.text.trim().isEmpty()) {
|
||||
ValidationInfo("Value cannot be empty", inputField)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
class EnvironmentVariableDialog(
|
||||
title: String,
|
||||
initialName: String,
|
||||
initialValue: String
|
||||
) : DialogWrapper(true) {
|
||||
|
||||
private val nameField = JBTextField(initialName)
|
||||
private val valueField = JBTextField(initialValue)
|
||||
|
||||
val variableName: String
|
||||
get() = nameField.text
|
||||
|
||||
val variableValue: String
|
||||
get() = valueField.text
|
||||
|
||||
init {
|
||||
this.title = title
|
||||
init()
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
return panel {
|
||||
row("Variable name:") {
|
||||
cell(nameField)
|
||||
.columns(COLUMNS_MEDIUM)
|
||||
.focused()
|
||||
}
|
||||
row("Value:") {
|
||||
cell(valueField)
|
||||
.columns(COLUMNS_MEDIUM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doValidate(): ValidationInfo? {
|
||||
return when {
|
||||
nameField.text.trim().isEmpty() -> ValidationInfo("Variable name cannot be empty", nameField)
|
||||
nameField.text.contains('=') -> ValidationInfo("Variable name cannot contain '=' character", nameField)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form.details
|
||||
|
||||
import ee.carlrobert.codegpt.settings.mcp.McpServerDetailsState
|
||||
import javax.swing.JComponent
|
||||
|
||||
interface McpDetailsPanel {
|
||||
fun getPanel(): JComponent
|
||||
fun updateData(details: McpServerDetails)
|
||||
fun remove(details: McpServerDetails)
|
||||
}
|
||||
|
||||
data class McpServerDetails(
|
||||
val id: Long,
|
||||
var name: String,
|
||||
var command: String,
|
||||
var arguments: MutableList<String>,
|
||||
var environmentVariables: MutableMap<String, String>
|
||||
) {
|
||||
constructor(state: McpServerDetailsState) : this(
|
||||
id = state.id,
|
||||
name = state.name ?: "New MCP Server",
|
||||
command = state.command ?: "npx",
|
||||
arguments = state.arguments.toMutableList(),
|
||||
environmentVariables = state.environmentVariables.toMutableMap()
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
package ee.carlrobert.codegpt.settings.mcp.form.details
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.ui.DialogPanel
|
||||
import com.intellij.openapi.ui.ValidationInfo
|
||||
import com.intellij.ui.CardLayoutPanel
|
||||
import com.intellij.ui.CollectionListModel
|
||||
import com.intellij.ui.ToolbarDecorator
|
||||
import com.intellij.ui.components.JBList
|
||||
import com.intellij.ui.dsl.builder.*
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ee.carlrobert.codegpt.settings.mcp.McpSettings
|
||||
import java.awt.Component
|
||||
import javax.swing.DefaultListCellRenderer
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JList
|
||||
|
||||
class McpServerDetailsPanel : McpDetailsPanel {
|
||||
|
||||
private val cardLayoutPanel =
|
||||
object : CardLayoutPanel<McpServerDetails, McpServerDetails, JComponent>() {
|
||||
override fun prepare(key: McpServerDetails): McpServerDetails = key
|
||||
|
||||
override fun create(details: McpServerDetails): JComponent {
|
||||
return ServerEditorPanel(details).getPanel()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
service<McpSettings>().state.servers.forEach {
|
||||
cardLayoutPanel.select(McpServerDetails(it), true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPanel() = cardLayoutPanel
|
||||
|
||||
override fun updateData(details: McpServerDetails) {
|
||||
cardLayoutPanel.select(details, true)
|
||||
}
|
||||
|
||||
override fun remove(details: McpServerDetails) {
|
||||
cardLayoutPanel.remove(cardLayoutPanel.getValue(details, false))
|
||||
}
|
||||
|
||||
private class ServerEditorPanel(private val details: McpServerDetails) {
|
||||
|
||||
private val argumentsListModel = CollectionListModel(details.arguments)
|
||||
private val argumentsList = JBList(argumentsListModel).apply {
|
||||
cellRenderer = ServerEditorListCellRenderer(AllIcons.Nodes.Parameter)
|
||||
emptyText.text = "No arguments configured"
|
||||
}
|
||||
|
||||
private val envVarsListModel = CollectionListModel(
|
||||
details.environmentVariables.map { "${it.key}=${it.value}" }.toMutableList()
|
||||
)
|
||||
private val envVarsList = JBList(envVarsListModel).apply {
|
||||
cellRenderer = ServerEditorListCellRenderer(AllIcons.Nodes.Variable)
|
||||
emptyText.text = "No environment variables configured"
|
||||
}
|
||||
|
||||
fun getPanel(): DialogPanel = panel {
|
||||
row {
|
||||
label("Configure your MCP server settings below:")
|
||||
}.bottomGap(BottomGap.MEDIUM)
|
||||
|
||||
group("Basic Configuration") {
|
||||
row("Name:") {
|
||||
textField()
|
||||
.bindText({ details.name }, { details.name = it })
|
||||
.columns(COLUMNS_MEDIUM)
|
||||
.validationOnApply {
|
||||
if (it.text.isBlank()) ValidationInfo("Server name is required", it)
|
||||
else null
|
||||
}
|
||||
}.rowComment("A descriptive name for this MCP server")
|
||||
|
||||
row("Command:") {
|
||||
textField()
|
||||
.bindText({ details.command }, { details.command = it })
|
||||
.columns(COLUMNS_MEDIUM)
|
||||
.validationOnApply {
|
||||
if (it.text.isBlank()) ValidationInfo("Command is required", it)
|
||||
else null
|
||||
}
|
||||
}.rowComment("Executable command (e.g., npx, python, node)")
|
||||
}
|
||||
|
||||
group("Command Arguments") {
|
||||
row {
|
||||
cell(createArgumentsPanel()).align(Align.FILL)
|
||||
}.resizableRow()
|
||||
.rowComment("Arguments passed to the server command")
|
||||
}
|
||||
|
||||
group("Environment Variables") {
|
||||
row {
|
||||
cell(createEnvironmentPanel()).align(Align.FILL)
|
||||
}.resizableRow()
|
||||
.rowComment("Environment variables for the server process")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createArgumentsPanel(): JComponent {
|
||||
return ToolbarDecorator.createDecorator(argumentsList)
|
||||
.setAddAction { addArgument() }
|
||||
.setRemoveAction { removeSelectedArgument() }
|
||||
.setEditAction { editSelectedArgument() }
|
||||
.setAddActionName("Add Argument")
|
||||
.setRemoveActionName("Remove Argument")
|
||||
.setEditActionName("Edit Argument")
|
||||
.setPreferredSize(JBUI.size(400, 120))
|
||||
.createPanel()
|
||||
}
|
||||
|
||||
private fun createEnvironmentPanel(): JComponent {
|
||||
return ToolbarDecorator.createDecorator(envVarsList)
|
||||
.setAddAction { addEnvironmentVariable() }
|
||||
.setRemoveAction { removeSelectedEnvironmentVariable() }
|
||||
.setEditAction { editSelectedEnvironmentVariable() }
|
||||
.setAddActionName("Add Variable")
|
||||
.setRemoveActionName("Remove Variable")
|
||||
.setEditActionName("Edit Variable")
|
||||
.setPreferredSize(JBUI.size(400, 120))
|
||||
.createPanel()
|
||||
}
|
||||
|
||||
private fun addArgument() {
|
||||
val dialog = SingleInputDialog("Add Argument", "Argument:", "")
|
||||
if (dialog.showAndGet()) {
|
||||
val argument = dialog.inputValue.trim()
|
||||
if (argument.isNotEmpty()) {
|
||||
details.arguments.add(argument)
|
||||
argumentsListModel.add(argument)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSelectedArgument() {
|
||||
val selectedIndex = argumentsList.selectedIndex
|
||||
if (selectedIndex >= 0) {
|
||||
val removed = details.arguments.removeAt(selectedIndex)
|
||||
argumentsListModel.remove(removed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun editSelectedArgument() {
|
||||
val selectedIndex = argumentsList.selectedIndex
|
||||
if (selectedIndex >= 0) {
|
||||
val currentValue = details.arguments[selectedIndex]
|
||||
val dialog = SingleInputDialog("Edit Argument", "Argument:", currentValue)
|
||||
if (dialog.showAndGet()) {
|
||||
val newValue = dialog.inputValue.trim()
|
||||
if (newValue.isNotEmpty() && newValue != currentValue) {
|
||||
details.arguments[selectedIndex] = newValue
|
||||
argumentsListModel.setElementAt(newValue, selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addEnvironmentVariable() {
|
||||
val dialog = EnvironmentVariableDialog("Add Environment Variable", "", "")
|
||||
if (dialog.showAndGet()) {
|
||||
val name = dialog.variableName.trim()
|
||||
val value = dialog.variableValue.trim()
|
||||
if (name.isNotEmpty()) {
|
||||
details.environmentVariables[name] = value
|
||||
envVarsListModel.add("$name=$value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSelectedEnvironmentVariable() {
|
||||
val selectedIndex = envVarsList.selectedIndex
|
||||
if (selectedIndex >= 0) {
|
||||
val selectedItem = envVarsListModel.getElementAt(selectedIndex)
|
||||
val key = selectedItem.substringBefore('=')
|
||||
details.environmentVariables.remove(key)
|
||||
envVarsListModel.remove(selectedIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun editSelectedEnvironmentVariable() {
|
||||
val selectedIndex = envVarsList.selectedIndex
|
||||
if (selectedIndex >= 0) {
|
||||
val selectedItem = envVarsListModel.getElementAt(selectedIndex)
|
||||
val parts = selectedItem.split('=', limit = 2)
|
||||
val currentName = parts[0]
|
||||
val currentValue = if (parts.size > 1) parts[1] else ""
|
||||
|
||||
val dialog = EnvironmentVariableDialog(
|
||||
"Edit Environment Variable",
|
||||
currentName,
|
||||
currentValue
|
||||
)
|
||||
if (dialog.showAndGet()) {
|
||||
val newName = dialog.variableName.trim()
|
||||
val newValue = dialog.variableValue.trim()
|
||||
|
||||
if (newName.isNotEmpty()) {
|
||||
details.environmentVariables.remove(currentName)
|
||||
details.environmentVariables[newName] = newValue
|
||||
envVarsListModel.setElementAt("$newName=$newValue", selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ServerEditorListCellRenderer(private val cellIcon: Icon) : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus)
|
||||
icon = cellIcon
|
||||
text = value?.toString() ?: ""
|
||||
return this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue