diff --git a/lib/BUILD.bazel b/lib/BUILD.bazel index 4876c28fc476..17d453e33d42 100644 --- a/lib/BUILD.bazel +++ b/lib/BUILD.bazel @@ -16368,6 +16368,21 @@ jvm_import( source_jar = "@com_typesafe-config-1_4_3-sources_http//file" ) +copy_file( + name = "com.moandjiezana.toml/toml4j-0.7.2.jar_copy", + src = "@com_moandjiezana_toml-toml4j-0_7_2_http//file", + out = "com.moandjiezana.toml/toml4j-0.7.2.jar", + allow_symlink = True, + visibility = ["//visibility:public"] +) + +jvm_import( + name = "mcpserver-toml4j", + jar = "@com_moandjiezana_toml-toml4j-0_7_2_http//file", + source_jar = "@com_moandjiezana_toml-toml4j-0_7_2-sources_http//file", + visibility = ["//visibility:public"] +) + copy_file( name = "com.github.marschall/memoryfilesystem-2.8.2.jar_copy", src = "@com_github_marschall-memoryfilesystem-2_8_2_http//file", diff --git a/lib/MODULE.bazel b/lib/MODULE.bazel index e7912621b3cf..4dec81c1b5df 100644 --- a/lib/MODULE.bazel +++ b/lib/MODULE.bazel @@ -12998,6 +12998,20 @@ http_file( downloaded_file_path = "config-1.4.3-sources.jar" ) +http_file( + name = "com_moandjiezana_toml-toml4j-0_7_2_http", + url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/com/moandjiezana/toml/toml4j/0.7.2/toml4j-0.7.2.jar", + sha256 = "f5475e63e7e89e5db62223489aec7a56bd303543772077a17c2cb54c19ca3a20", + downloaded_file_path = "toml4j-0.7.2.jar" +) + +http_file( + name = "com_moandjiezana_toml-toml4j-0_7_2-sources_http", + url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/com/moandjiezana/toml/toml4j/0.7.2/toml4j-0.7.2-sources.jar", + sha256 = "fc9c72fb6a7abda2a65c037af622471da897f84e0a95643210b1c41328c83c5d", + downloaded_file_path = "toml4j-0.7.2-sources.jar" +) + http_file( name = "com_github_marschall-memoryfilesystem-2_8_2_http", url = "https://cache-redirector.jetbrains.com/repo1.maven.org/maven2/com/github/marschall/memoryfilesystem/2.8.2/memoryfilesystem-2.8.2.jar", diff --git a/platform/build-scripts/src/org/jetbrains/intellij/build/CommunityLibraryLicenses.kt b/platform/build-scripts/src/org/jetbrains/intellij/build/CommunityLibraryLicenses.kt index e650930b7acd..e740f3afef2c 100644 --- a/platform/build-scripts/src/org/jetbrains/intellij/build/CommunityLibraryLicenses.kt +++ b/platform/build-scripts/src/org/jetbrains/intellij/build/CommunityLibraryLicenses.kt @@ -1270,6 +1270,10 @@ object CommunityLibraryLicenses { LibraryLicense("thriftpy2", version = "0.4.13", attachedTo = "intellij.python", url = "https://github.com/Thriftpy/thriftpy2/") .mit("https://github.com/Thriftpy/thriftpy2/blob/master/LICENSE"), + LibraryLicense("toml4j", libraryName = "toml4j", url = "https://github.com/mwanji/toml4j") + .mit("https://github.com/mwanji/toml4j/blob/master/LICENSE") + .suppliedByPersons("Moandji Ezana"), + // for traceprocessor-proto module library in intellij.android.profilersAndroid LibraryLicense("Trang", libraryName = "trang-core.jar", version = LibraryLicense.CUSTOM_REVISION, url = "https://relaxng.org/jclark/trang.html") .newBsd("https://opensource.org/license/bsd-3-clause/"), diff --git a/plugins/mcp-server/BUILD.bazel b/plugins/mcp-server/BUILD.bazel index 2736a08e8250..70c661242067 100644 --- a/plugins/mcp-server/BUILD.bazel +++ b/plugins/mcp-server/BUILD.bazel @@ -14,6 +14,7 @@ jvm_library( srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True), resources = [":mcpserver_resources"], deps = [ + "@lib//:mcpserver-toml4j", "@lib//:jetbrains-annotations", "@lib//:kotlin-stdlib", "//libraries/kotlinx/coroutines/core", diff --git a/plugins/mcp-server/intellij.mcpserver.iml b/plugins/mcp-server/intellij.mcpserver.iml index f5e06ce19cc0..35d211766ed6 100644 --- a/plugins/mcp-server/intellij.mcpserver.iml +++ b/plugins/mcp-server/intellij.mcpserver.iml @@ -29,6 +29,24 @@ + + + + + + f5475e63e7e89e5db62223489aec7a56bd303543772077a17c2cb54c19ca3a20 + + + + + + + + + + + + diff --git a/plugins/mcp-server/plugin-content.yaml b/plugins/mcp-server/plugin-content.yaml index c7fac947152c..4e8a0eb55aa9 100644 --- a/plugins/mcp-server/plugin-content.yaml +++ b/plugins/mcp-server/plugin-content.yaml @@ -42,4 +42,9 @@ reason: <- intellij.mcpserver - name: lib/mcpserver.jar modules: - - name: intellij.mcpserver \ No newline at end of file + - name: intellij.mcpserver +- name: lib/toml4j.jar + library: toml4j + module: intellij.mcpserver + files: + - name: $MAVEN_REPOSITORY$/com/moandjiezana/toml/toml4j/0/toml4j-0.jar \ No newline at end of file diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/clients/impl/CodexClient.kt b/plugins/mcp-server/src/com/intellij/mcpserver/clients/impl/CodexClient.kt index daf0c6743817..6d3c71f63268 100644 --- a/plugins/mcp-server/src/com/intellij/mcpserver/clients/impl/CodexClient.kt +++ b/plugins/mcp-server/src/com/intellij/mcpserver/clients/impl/CodexClient.kt @@ -6,6 +6,8 @@ import com.intellij.mcpserver.clients.configs.CodexStreamableHttpConfig import com.intellij.mcpserver.clients.configs.ExistingConfig import com.intellij.mcpserver.clients.configs.ServerConfig import com.intellij.util.io.createParentDirectories +import com.moandjiezana.toml.Toml +import com.moandjiezana.toml.TomlWriter import java.nio.file.Path import kotlin.io.path.exists import kotlin.io.path.readText @@ -32,33 +34,47 @@ open class CodexClient(scope: McpClientInfo.Scope, configPath: Path) : McpClient val existingContent = if (configPath.exists()) configPath.readText() else "" val productServerKey = productSpecificServerKey() - var updatedContent = existingContent - LEGACY_SERVER_KEYS.forEach { legacyKey -> - if (legacyKey != productServerKey) { - updatedContent = removeCodexSection(updatedContent, legacyKey) - } - } - - updatedContent = updateCodexConfig(updatedContent, productServerKey, streamableHttpUrl) + val updatedContent = updateCodexConfig( + existing = existingContent, + productServerKey = productServerKey, + legacyKeys = LEGACY_SERVER_KEYS, + url = streamableHttpUrl + ) configPath.parent?.createParentDirectories() configPath.writeText(updatedContent) } companion object { - private val SERVER_SECTION_REGEX = Regex("(?mis)^\\s*\\[mcp_servers\\.([^]]+)]\\s*(.*?)(?=^\\s*\\[|\\z)") + private const val MCP_SERVERS = "mcp_servers" private fun parseCodexServers(content: String): Map { - val servers = mutableMapOf() - SERVER_SECTION_REGEX.findAll(content).forEach { matchResult -> - val serverName = matchResult.groupValues[1].trim() - val body = matchResult.groupValues[2] - val command = extractTomlString(body, "command") - val type = extractTomlString(body, "type") ?: extractTomlString(body, "transport") - val url = extractTomlString(body, "url") ?: extractTomlString(body, "serverUrl") - val args = extractTomlStringArray(body, "args") - val env = extractTomlInlineTable(body, "env") - servers[serverName] = ExistingConfig( + val root = Toml().read(content).toMap() + val serversAny = root[MCP_SERVERS] as? Map<*, *> ?: return emptyMap() + + val result = LinkedHashMap(serversAny.size) + for ((k, v) in serversAny) { + val serverName = (k as? String)?.trim().orEmpty() + if (serverName.isEmpty()) continue + + val table = v as? Map<*, *> ?: continue + + val command = table["command"] as? String + val type = (table["type"] as? String) ?: (table["transport"] as? String) + val url = (table["url"] as? String) ?: (table["serverUrl"] as? String) + + val args = (table["args"] as? List<*>)?.filterIsInstance()?.ifEmpty { null } + + val env = (table["env"] as? Map<*, *>)?.entries + ?.mapNotNull { (ek, ev) -> + val key = ek as? String ?: return@mapNotNull null + val value = ev as? String ?: return@mapNotNull null + key to value + } + ?.toMap() + ?.ifEmpty { null } + + result[serverName] = ExistingConfig( command = command, args = args, env = env, @@ -66,111 +82,34 @@ open class CodexClient(scope: McpClientInfo.Scope, configPath: Path) : McpClient type = type, ) } - return servers + return result } - private fun extractTomlString(body: String, key: String): String? { - val regex = Regex("(?mis)^\\s*${Regex.escape(key)}\\s*=\\s*\"((?:\\\\.|[^\"])*)\"") - val match = regex.find(body) ?: return null - return unescapeTomlString(match.groupValues[1]) - } + private fun updateCodexConfig( + existing: String, + productServerKey: String, + legacyKeys: Set, + url: String + ): String { + val root = Toml().read(existing).toMap() - private fun extractTomlStringArray(body: String, key: String): List? { - val regex = Regex("(?mis)^\\s*${Regex.escape(key)}\\s*=\\s*\\[(.*?)]") - val match = regex.find(body) ?: return null - val inner = match.groupValues[1] - val valueMatches = Regex("\"((?:\\\\.|[^\"])*)\"").findAll(inner) - val values = valueMatches.map { unescapeTomlString(it.groupValues[1]) }.toList() - return values.ifEmpty { null } - } + val existingServers = (root[MCP_SERVERS] as? Map<*, *>) ?: emptyMap() - private fun extractTomlInlineTable(body: String, key: String): Map? { - val regex = Regex("(?mis)^\\s*${Regex.escape(key)}\\s*=\\s*\\{(.*?)}") - val match = regex.find(body) ?: return null - val inner = match.groupValues[1] - val pairs = Regex("([A-Za-z0-9_.\\-]+)\\s*=\\s*\"((?:\\\\.|[^\"])*)\"").findAll(inner) - val map = pairs.associate { it.groupValues[1] to unescapeTomlString(it.groupValues[2]) } - return map.ifEmpty { null } - } - - private fun unescapeTomlString(value: String): String { - val result = StringBuilder() - var index = 0 - while (index < value.length) { - val ch = value[index] - if (ch == '\\' && index + 1 < value.length) { - val next = value[index + 1] - when (next) { - '\\' -> result.append('\\') - '"' -> result.append('"') - 'n' -> result.append('\n') - 'r' -> result.append('\r') - 't' -> result.append('\t') - 'b' -> result.append('\b') - else -> result.append(next) - } - index += 2 - } - else { - result.append(ch) - index++ - } - } - return result.toString() - } - - private fun escapeTomlString(value: String): String { - val result = StringBuilder() - value.forEach { ch -> - when (ch) { - '\\' -> result.append("\\\\") - '"' -> result.append("\\\"") - '\n' -> result.append("\\n") - '\r' -> result.append("\\r") - '\t' -> result.append("\\t") - '\b' -> result.append("\\b") - else -> result.append(ch) - } - } - return result.toString() - } - - private fun removeCodexSection(existing: String, serverKey: String): String { - val sectionRegex = codexSectionRegex(serverKey) - return sectionRegex.replace(existing, "") - } - - private fun updateCodexConfig(existing: String, serverKey: String, url: String): String { - val sectionRegex = codexSectionRegex(serverKey) - val newSection = buildCodexSection(serverKey, url) - if (sectionRegex.containsMatchIn(existing)) { - return sectionRegex.replace(existing, newSection) + val serversWithoutLegacy = existingServers.filterKeys { key -> + key !in legacyKeys || key == productServerKey } - if (existing.isBlank()) { - return newSection - } + val existingProductTable = (serversWithoutLegacy[productServerKey] as? Map<*, *>) ?: emptyMap() + val updatedProductTable = existingProductTable + ("url" to url) + + val updatedServers = serversWithoutLegacy + (productServerKey to updatedProductTable) - val builder = StringBuilder(existing) - val endsWithSingleNewline = existing.endsWith("\n") - val endsWithDoubleNewline = existing.endsWith("\n\n") || existing.endsWith("\r\n\r\n") - if (!endsWithSingleNewline) { - builder.append('\n') - } - if (!endsWithDoubleNewline) { - builder.append('\n') - } - builder.append(newSection) - return builder.toString() - } + val updatedRoot = root + (MCP_SERVERS to updatedServers) - private fun codexSectionRegex(serverKey: String): Regex { - val escapedHeader = Regex.escape("mcp_servers.$serverKey") - return Regex("(?mis)^\\s*\\[$escapedHeader]\\s*(.*?)(?=^\\s*\\[|\\z)") - } + val writer = TomlWriter() + val rendered = writer.write(updatedRoot) - private fun buildCodexSection(serverKey: String, url: String): String { - return "[mcp_servers.$serverKey]\nurl = \"${escapeTomlString(url)}\"\n\n" + return if (rendered.endsWith("\n")) rendered else "$rendered\n" } } } diff --git a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/clients/impl/CodexClientTest.kt b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/clients/impl/CodexClientTest.kt index b8bd7360bd5b..e0b362d10107 100644 --- a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/clients/impl/CodexClientTest.kt +++ b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/clients/impl/CodexClientTest.kt @@ -12,7 +12,6 @@ import kotlin.io.path.readText import kotlin.io.path.writeText class CodexClientTest { - @TempDir lateinit var tempDir: Path @@ -97,6 +96,99 @@ class CodexClientTest { val occurrences = result.split(expectedUrl).size - 1 assertTrue(1 == occurrences) } + + @Test + fun `isConfigured returns false when config file does not exist`() { + val configPath = tempDir.resolve("missing.toml") + val client = CodexClient(McpClientInfo.Scope.GLOBAL, configPath) + assertFalse(client.isConfigured() == true) + } + + @Test + fun `isConfigured returns false for non-stream url`() { + val configPath = tempDir.resolve("config.toml") + configPath.writeText( + """ + [mcp_servers.test] + url = "http://localhost:8123/api" + """.trimIndent() + ) + + val client = CodexClient(McpClientInfo.Scope.GLOBAL, configPath) + assertFalse(client.isConfigured() == true) + } + + @Test + fun `isConfigured returns false for malformed url`() { + val configPath = tempDir.resolve("config.toml") + configPath.writeText( + """ + [mcp_servers.test] + url = "not-a-url" + """.trimIndent() + ) + + val client = CodexClient(McpClientInfo.Scope.GLOBAL, configPath) + assertFalse(client.isConfigured() == true) + } + + @Test + fun `isConfigured returns false when mcp_servers section is missing`() { + val configPath = tempDir.resolve("config.toml") + configPath.writeText( + """ + [other_section] + url = "http://localhost:8123/stream" + """.trimIndent() + ) + + val client = CodexClient(McpClientInfo.Scope.GLOBAL, configPath) + assertFalse(client.isConfigured() == true) + } + + @Test + fun `configure overwrites existing product-specific section`() { + val configPath = tempDir.resolve("config.toml") + configPath.writeText( + """ + [mcp_servers.codextest] + url = "http://localhost:1111/stream" + """.trimIndent() + ) + + McpClient.overrideProductSpecificServerKeyForTests("codextest") + McpClient.overrideWriteLegacyForTests(false) + + val client = TestCodexClient(McpClientInfo.Scope.GLOBAL, configPath, "http://localhost:2222/stream") + client.configure() + + val result = configPath.readText() + assertTrue(result.contains("""url = "http://localhost:2222/stream"""")) + assertFalse(result.contains("""url = "http://localhost:1111/stream"""")) + } + + @Test + fun `configure preserves unrelated content`() { + val configPath = tempDir.resolve("config.toml") + configPath.writeText( + """ + # comment + [random.section] + value = 42 + """.trimIndent() + ) + + McpClient.overrideProductSpecificServerKeyForTests("codextest") + McpClient.overrideWriteLegacyForTests(false) + + val client = TestCodexClient(McpClientInfo.Scope.GLOBAL, configPath, "http://localhost:3333/stream") + client.configure() + + val result = configPath.readText() + assertTrue(result.contains("[random.section]")) + assertTrue(result.contains("value = 42")) + assertTrue(result.contains("[mcp_servers.codextest]")) + } } private class TestCodexClient(