From 4e126c14bc37e3c26ea1bb1d839c3df43b19be2e Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Tue, 18 Nov 2025 19:47:35 +0000 Subject: [PATCH] feat: add MCP configuration form --- .../codegpt/settings/mcp/McpClientManager.kt | 154 +++++++ .../codegpt/settings/mcp/McpConfigurable.kt | 29 ++ .../codegpt/settings/mcp/McpSettings.kt | 48 ++ .../mcp/form/McpConnectionTestResultDialog.kt | 323 ++++++++++++++ .../settings/mcp/form/McpFileProvider.kt | 32 ++ .../codegpt/settings/mcp/form/McpForm.kt | 416 ++++++++++++++++++ .../codegpt/settings/mcp/form/McpFormUtil.kt | 16 + .../settings/mcp/form/McpJsonImportDialog.kt | 189 ++++++++ .../settings/mcp/form/McpJsonViewDialog.kt | 133 ++++++ .../mcp/form/details/McpDialogHelpers.kt | 83 ++++ .../mcp/form/details/McpFormDetails.kt | 26 ++ .../mcp/form/details/McpServerDetailsPanel.kt | 226 ++++++++++ 12 files changed, 1675 insertions(+) create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpClientManager.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpConfigurable.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpSettings.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpConnectionTestResultDialog.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpFileProvider.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpForm.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpFormUtil.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpJsonImportDialog.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpJsonViewDialog.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpDialogHelpers.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpFormDetails.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpServerDetailsPanel.kt diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpClientManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpClientManager.kt new file mode 100644 index 00000000..e4b387e9 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpClientManager.kt @@ -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 = emptyList(), + val toolDescriptions: Map = emptyMap(), + val resources: List = 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" + } + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpConfigurable.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpConfigurable.kt new file mode 100644 index 00000000..dc5034e3 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpConfigurable.kt @@ -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() + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpSettings.kt new file mode 100644 index 00000000..76b55e87 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpSettings.kt @@ -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()) + +class McpSettingsState : BaseState() { + var servers by list() + + 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() + var environmentVariables by map() +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpConnectionTestResultDialog.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpConnectionTestResultDialog.kt new file mode 100644 index 00000000..0b435cfc --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpConnectionTestResultDialog.kt @@ -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( + """ + + This may take a few seconds while the server starts up and responds. + + """.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() { + override fun customizeCellRenderer( + list: JList, + 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 = + "${value.name}
${value.description}" + } + } + } + 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() { + override fun customizeCellRenderer( + list: JList, + 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( + """ + + Need more help? Check the MCP server documentation or test the command in your terminal. + + """.trimIndent() + ) + ) + .applyToComponent { + font = JBUI.Fonts.label() + } + }.topGap(TopGap.SMALL) + }.apply { + preferredSize = Dimension(650, 400) + minimumSize = Dimension(650, 350) + maximumSize = Dimension(650, 500) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpFileProvider.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpFileProvider.kt new file mode 100644 index 00000000..07d824c1 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpFileProvider.kt @@ -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(File(path)) +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpForm.kt new file mode 100644 index 00000000..5f6acb1b --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpForm.kt @@ -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().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().state + settings.servers = getFormServers().map { it.toState() }.toMutableList() + } + + fun resetChanges() { + removeAllChildNodes() + setupChildNodes() + reloadTreeView() + } + + private fun getFormServers(): List { + return serversNode.children().toList() + .filterIsInstance() + .map { it.details } + } + + private fun setupChildNodes() { + service().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().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().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 { + 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) { + 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() + .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 + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpFormUtil.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpFormUtil.kt new file mode 100644 index 00000000..454d9ccc --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpFormUtil.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpJsonImportDialog.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpJsonImportDialog.kt new file mode 100644 index 00000000..307c5be2 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpJsonImportDialog.kt @@ -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 = 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 { + val jsonNode = objectMapper.readTree(jsonText) + val servers = mutableListOf() + 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() + 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 + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpJsonViewDialog.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpJsonViewDialog.kt new file mode 100644 index 00000000..588632a8 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/McpJsonViewDialog.kt @@ -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(server.name, serverConfig) + + val rootObject = objectMapper.createObjectNode() + rootObject.set("mcpServers", mcpServers) + + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootObject) + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpDialogHelpers.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpDialogHelpers.kt new file mode 100644 index 00000000..4836ac27 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpDialogHelpers.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpFormDetails.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpFormDetails.kt new file mode 100644 index 00000000..f5a52e69 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpFormDetails.kt @@ -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, + var environmentVariables: MutableMap +) { + 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() + ) +} \ No newline at end of file diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpServerDetailsPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpServerDetailsPanel.kt new file mode 100644 index 00000000..77a7ebd3 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/form/details/McpServerDetailsPanel.kt @@ -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() { + override fun prepare(key: McpServerDetails): McpServerDetails = key + + override fun create(details: McpServerDetails): JComponent { + return ServerEditorPanel(details).getPanel() + } + } + + init { + service().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 + } + } + } +} \ No newline at end of file