mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
[mcp server] IJPL-230584 refactor codex support to avoid toml regexp parsing
GitOrigin-RevId: f7d1b2dd6fef5e83607c5f2b0953711be0469e7f
This commit is contained in:
committed by
intellij-monorepo-bot
parent
5dcb960f09
commit
7cd16cea1d
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -29,6 +29,24 @@
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="module-library">
|
||||
<library name="toml4j" type="repository">
|
||||
<properties include-transitive-deps="false" maven-id="com.moandjiezana.toml:toml4j:0.7.2">
|
||||
<verification>
|
||||
<artifact url="file://$MAVEN_REPOSITORY$/com/moandjiezana/toml/toml4j/0.7.2/toml4j-0.7.2.jar">
|
||||
<sha256sum>f5475e63e7e89e5db62223489aec7a56bd303543772077a17c2cb54c19ca3a20</sha256sum>
|
||||
</artifact>
|
||||
</verification>
|
||||
</properties>
|
||||
<CLASSES>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/moandjiezana/toml/toml4j/0.7.2/toml4j-0.7.2.jar!/" />
|
||||
</CLASSES>
|
||||
<JAVADOC />
|
||||
<SOURCES>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/moandjiezana/toml/toml4j/0.7.2/toml4j-0.7.2-sources.jar!/" />
|
||||
</SOURCES>
|
||||
</library>
|
||||
</orderEntry>
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="jetbrains-annotations" level="project" />
|
||||
<orderEntry type="library" name="kotlin-stdlib" level="project" />
|
||||
|
||||
@@ -42,4 +42,9 @@
|
||||
reason: <- intellij.mcpserver
|
||||
- name: lib/mcpserver.jar
|
||||
modules:
|
||||
- name: intellij.mcpserver
|
||||
- name: intellij.mcpserver
|
||||
- name: lib/toml4j.jar
|
||||
library: toml4j
|
||||
module: intellij.mcpserver
|
||||
files:
|
||||
- name: $MAVEN_REPOSITORY$/com/moandjiezana/toml/toml4j/0/toml4j-0.jar
|
||||
@@ -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<String, ExistingConfig> {
|
||||
val servers = mutableMapOf<String, ExistingConfig>()
|
||||
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<String, ExistingConfig>(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<String>()?.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<String>,
|
||||
url: String
|
||||
): String {
|
||||
val root = Toml().read(existing).toMap()
|
||||
|
||||
private fun extractTomlStringArray(body: String, key: String): List<String>? {
|
||||
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<Any, Any>()
|
||||
|
||||
private fun extractTomlInlineTable(body: String, key: String): Map<String, String>? {
|
||||
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<Any, Any>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+93
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user