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(