Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/kotlin/net/portswigger/mcp/ExtensionBase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import burp.api.montoya.MontoyaApi
import net.portswigger.mcp.config.ConfigUi
import net.portswigger.mcp.config.McpConfig
import net.portswigger.mcp.providers.ClaudeDesktopProvider
import net.portswigger.mcp.providers.CodexCliProvider
import net.portswigger.mcp.providers.ManualProxyInstallerProvider
import net.portswigger.mcp.providers.ProxyJarManager

Expand All @@ -22,6 +23,7 @@ class ExtensionBase : BurpExtension {
val configUi = ConfigUi(
config = config, providers = listOf(
ClaudeDesktopProvider(api.logging(), proxyJarManager),
CodexCliProvider(api.logging(), proxyJarManager),
ManualProxyInstallerProvider(api.logging(), proxyJarManager),
)
)
Expand Down Expand Up @@ -54,4 +56,4 @@ class ExtensionBase : BurpExtension {
}
}
}
}
}
88 changes: 87 additions & 1 deletion src/main/kotlin/net/portswigger/mcp/providers/Provider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import javax.swing.JFileChooser
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText
Expand All @@ -19,6 +20,91 @@ interface Provider {
fun install(config: McpConfig): String?
}

class CodexCliProvider(
private val logging: Logging,
private val proxyJarManager: ProxyJarManager,
private val userHome: Path = Path.of(System.getProperty("user.home"))
) : Provider {

private val serverName = "burp"
private val configFileName = "config.toml"

override val name = "Codex CLI"
override val installButtonText = "Install to $name"
override val confirmationText =
"Install to $name?\nThis will create or update $name's MCP configuration ($configFileName)."

override fun install(config: McpConfig): String {
val proxyJarPath = proxyJarManager.getProxyJar()
val path = configFilePath()
path.parent.createDirectories()

val burpBlock = buildString {
appendLine("[mcp_servers.$serverName]")
appendLine("command = \"java\"")
appendLine(
"args = [\"-jar\", \"${escapeTomlString(proxyJarPath.toString())}\", \"--sse-url\", \"http://${config.host}:${config.port}\"]"
)
}.trimEnd()

val updatedContent = upsertTomlTable(
existingContent = if (path.exists()) path.readText() else "",
tableHeader = "[mcp_servers.$serverName]",
newBlock = burpBlock
)

path.writeText(updatedContent)
logging.logToOutput("Installed Burp MCP Server to Codex CLI config")

return "Installation successful. Please restart $name if it is currently running."
}

private fun configFilePath(): Path {
return userHome.resolve(".codex").resolve(configFileName)
}

internal fun upsertTomlTable(existingContent: String, tableHeader: String, newBlock: String): String {
if (existingContent.isBlank()) {
return "$newBlock\n"
}

val headerRegex = Regex("(?m)^\\[.*]\\s*$")
val match = Regex("(?m)^${Regex.escape(tableHeader)}\\s*$").find(existingContent)

if (match == null) {
val separator = if (existingContent.endsWith("\n\n") || existingContent.endsWith("\r\n\r\n")) "" else "\n\n"
return existingContent.trimEnd() + separator + newBlock + "\n"
}

val startIndex = match.range.first
val nextHeader = headerRegex.find(existingContent, match.range.last + 1)
val endIndex = nextHeader?.range?.first ?: existingContent.length

val prefix = existingContent.substring(0, startIndex).trimEnd()
val suffix = existingContent.substring(endIndex).trimStart('\n', '\r')

return buildString {
if (prefix.isNotEmpty()) {
append(prefix)
append("\n\n")
}
append(newBlock)
append("\n")
if (suffix.isNotBlank()) {
append("\n")
append(suffix)
if (!suffix.endsWith("\n")) {
append("\n")
}
}
}
}

private fun escapeTomlString(value: String): String {
return value.replace("\\", "\\\\").replace("\"", "\\\"")
}
}

class ClaudeDesktopProvider(private val logging: Logging, private val proxyJarManager: ProxyJarManager) : Provider {

private val claudeConfigFileName = "claude_desktop_config.json"
Expand Down Expand Up @@ -146,4 +232,4 @@ class ManualProxyInstallerProvider(private val logging: Logging, private val pro

return "Extracted proxy jar to $destinationFile"
}
}
}
141 changes: 141 additions & 0 deletions src/test/kotlin/net/portswigger/mcp/providers/CodexCliProviderTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package net.portswigger.mcp.providers

import burp.api.montoya.logging.Logging
import burp.api.montoya.persistence.PersistedObject
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import net.portswigger.mcp.config.McpConfig
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path

class CodexCliProviderTest {

@TempDir
lateinit var tempDir: Path

private lateinit var logging: Logging
private lateinit var config: McpConfig

@BeforeEach
fun setup() {
val storage = mutableMapOf<String, Any>()

val persistedObject = mockk<PersistedObject>().apply {
every { getBoolean(any()) } answers {
val key = firstArg<String>()
storage[key] as? Boolean ?: when (key) {
"enabled" -> true
else -> false
}
}
every { getString(any()) } answers {
val key = firstArg<String>()
storage[key] as? String ?: when (key) {
"host" -> "127.0.0.1"
else -> ""
}
}
every { getInteger(any()) } answers {
val key = firstArg<String>()
storage[key] as? Int ?: when (key) {
"port" -> 9876
else -> 0
}
}
every { setBoolean(any(), any()) } answers {
storage[firstArg()] = secondArg<Boolean>()
}
every { setString(any(), any()) } answers {
storage[firstArg()] = secondArg<String>()
}
every { setInteger(any(), any()) } answers {
storage[firstArg()] = secondArg<Int>()
}
}

logging = mockk<Logging>().apply {
every { logToOutput(any<String>()) } returns Unit
every { logToError(any<String>()) } returns Unit
}

config = McpConfig(persistedObject, logging)
}

@Test
fun `install should create Codex config when missing`() {
val proxyJarManager = mockk<ProxyJarManager>().apply {
every { getProxyJar() } returns Path.of("/tmp/mcp-proxy-all.jar")
}
val provider = CodexCliProvider(logging, proxyJarManager, tempDir)

val result = provider.install(config)

val configPath = tempDir.resolve(".codex").resolve("config.toml")
val content = configPath.toFile().readText()

assertTrue(configPath.toFile().exists())
assertEquals("Installation successful. Please restart Codex CLI if it is currently running.", result)
assertTrue(content.contains("[mcp_servers.burp]"))
assertTrue(content.contains("command = \"java\""))
assertTrue(content.contains("/tmp/mcp-proxy-all.jar"))
assertTrue(content.contains("http://127.0.0.1:9876"))
verify { logging.logToOutput("Installed Burp MCP Server to Codex CLI config") }
}

@Test
fun `install should replace existing burp table and preserve other config`() {
val configPath = tempDir.resolve(".codex").resolve("config.toml")
configPath.parent.toFile().mkdirs()
configPath.toFile().writeText(
"""
model = "gpt-5-codex"

[features]
multi_agent = true

[mcp_servers.burp]
command = "java"
args = ["-jar", "/tmp/old.jar", "--sse-url", "http://localhost:9999"]

[mcp_servers.other]
command = "uvx"
args = ["context7"]
""".trimIndent()
)

val proxyJarManager = mockk<ProxyJarManager>().apply {
every { getProxyJar() } returns Path.of("/tmp/mcp-proxy-all.jar")
}
val provider = CodexCliProvider(logging, proxyJarManager, tempDir)

provider.install(config)

val content = configPath.toFile().readText()
assertTrue(content.contains("model = \"gpt-5-codex\""))
assertTrue(content.contains("[features]"))
assertTrue(content.contains("[mcp_servers.other]"))
assertTrue(content.contains("/tmp/mcp-proxy-all.jar"))
assertTrue(!content.contains("/tmp/old.jar"))
}

@Test
fun `upsertTomlTable should append missing table`() {
val provider = CodexCliProvider(logging, mockk(relaxed = true), tempDir)

val updated = provider.upsertTomlTable(
existingContent = "model = \"gpt-5-codex\"\n",
tableHeader = "[mcp_servers.burp]",
newBlock = "[mcp_servers.burp]\ncommand = \"java\""
)

assertEquals(
"model = \"gpt-5-codex\"\n\n[mcp_servers.burp]\ncommand = \"java\"\n",
updated
)
}
}