IJPL-238874 Execution toolset should allow adjustments for parameters, env vars, working dir

(cherry picked from commit 48b5c2c7ca109993c9fa57cd8541ea8b22688119)

GitOrigin-RevId: 5d18bdea2f8c1c4cd7f1b9efa373050a1372e5e8
This commit is contained in:
Artem.Bukhonov
2026-03-13 01:13:29 +00:00
committed by intellij-monorepo-bot
parent 1b1917e342
commit 6cb0043247
3 changed files with 241 additions and 8 deletions
@@ -4,7 +4,9 @@
package com.intellij.mcpserver.toolsets.general
import com.intellij.execution.CommonProgramRunConfigurationParameters
import com.intellij.execution.Executor
import com.intellij.execution.RunManager
import com.intellij.execution.configurations.RunConfiguration
import com.intellij.execution.executors.DefaultRunExecutor
import com.intellij.execution.process.ProcessEvent
import com.intellij.execution.process.ProcessListener
@@ -22,6 +24,7 @@ import com.intellij.mcpserver.reportToolActivity
import com.intellij.mcpserver.toolsets.Constants
import com.intellij.mcpserver.util.TruncateMode
import com.intellij.mcpserver.util.checkUserConfirmationIfNeeded
import com.intellij.mcpserver.util.prepareRunConfigurationForExecution
import com.intellij.mcpserver.util.truncateText
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.PathManager
@@ -50,7 +53,8 @@ class ExecutionToolset : McpToolset {
|Returns a list of run configurations for the current project.
|Run configurations are usually used to define user the way how to run a user application, task or test suite from sources.
|
|This tool provides additional info like command line, working directory, and environment variables if they are available.
|This tool provides additional info like command line, working directory, environment variables,
|and whether the configuration supports dynamic launch overrides (`programArguments`, `workingDirectory`, `envs`).
|
|Use this tool to query the list of available run configurations in the current project.
""")
@@ -61,11 +65,13 @@ class ExecutionToolset : McpToolset {
val configurations = readAction {
runManager.allSettings.map { configurationSettings ->
val runConfigurationParameters = configurationSettings.configuration as? CommonProgramRunConfigurationParameters
val configuration = configurationSettings.configuration
val runConfigurationParameters = configuration as? CommonProgramRunConfigurationParameters
// TODO: render other details of other types of run configurations
RunConfigurationInfo(
name = configurationSettings.name,
description = (configurationSettings.type.configurationTypeDescription ?: configurationSettings.type.displayName).ifBlank { null },
supportsDynamicLaunchOverrides = runConfigurationParameters != null,
commandLine = runConfigurationParameters?.programParameters?.ifBlank { null },
workingDirectory = runConfigurationParameters?.workingDirectory?.ifBlank { null },
environment = runConfigurationParameters?.envs?.ifEmpty { null },
@@ -79,6 +85,12 @@ class ExecutionToolset : McpToolset {
@McpDescription("""
|Run a specific run configuration in the current project and wait up to specified timeout for it to finish.
|Use this tool to run a run configuration that you have found from the "get_run_configurations" tool.
|
|Optional launch overrides (`programArguments`, `workingDirectory`, `envs`) are applied only for this run and are not persisted.
|Do not pass these override parameters unless you explicitly need to change the configured launch values for this run.
|Missing/null override parameters keep existing run configuration values unchanged.
|For string overrides (`programArguments`, `workingDirectory`), only explicit empty string (`""`) clears an existing value.
|
|Returns the execution result including exit code, output, and success status.
""")
suspend fun execute_run_configuration(
@@ -90,14 +102,28 @@ class ExecutionToolset : McpToolset {
maxLinesCount: Int = Constants.MAX_LINES_COUNT_VALUE,
@McpDescription(Constants.TRUNCATE_MODE_DESCRIPTION)
truncateMode: TruncateMode = Constants.TRUCATE_MODE_VALUE,
@McpDescription("Optional program arguments override for this launch only. Missing/null keeps existing value; explicit empty string clears it.")
programArguments: String? = null,
@McpDescription("Optional working directory override for this launch only. Missing/null keeps existing value; explicit empty string clears it.")
workingDirectory: String? = null,
@McpDescription("Optional environment variable overrides for this launch only. Missing/null keeps existing env unchanged; when provided, values are merged over existing env.")
envs: Map<String, String>? = null,
): RunConfigurationResult {
currentCoroutineContext().reportToolActivity(McpServerBundle.message("tool.activity.executing.run.configuration", configurationName))
val project = currentCoroutineContext().project
val runManager = RunManager.getInstance(project)
val runnerAndConfigurationSettings = readAction { runManager.allSettings.find { it.name == configurationName } } ?: mcpFail("Run configuration with name '$configurationName' not found.")
val runConfiguration = prepareRunConfigurationForExecution(
configurationName = configurationName,
configuration = runnerAndConfigurationSettings.configuration,
programArguments = programArguments,
workingDirectory = workingDirectory,
envs = envs,
)
val useOriginalSettings = runConfiguration === runnerAndConfigurationSettings.configuration
val runConfigurationParameters = (runnerAndConfigurationSettings.configuration as? CommonProgramRunConfigurationParameters)?.programParameters
val runConfigurationParameters = (runConfiguration as? CommonProgramRunConfigurationParameters)?.programParameters
val notificationText = if (runConfigurationParameters != null) {
McpServerBundle.message("label.do.you.want.to.execute.run.configuration.with.command", configurationName)
}
@@ -111,7 +137,7 @@ class ExecutionToolset : McpToolset {
val outputBuilder = StringBuilder()
withContext(Dispatchers.EDT) {
val runner: ProgramRunner<*>? = ProgramRunner.getRunner(executor.id, runnerAndConfigurationSettings.configuration)
val runner: ProgramRunner<*>? = ProgramRunner.getRunner(executor.id, runConfiguration)
if (runner == null) mcpFail("No suitable runner found for configuration '${runnerAndConfigurationSettings.name}'")
val callback = object : ProgramRunner.Callback {
@@ -144,7 +170,13 @@ class ExecutionToolset : McpToolset {
}
}
val environment = ExecutionEnvironmentBuilder.create(project, executor, runnerAndConfigurationSettings.configuration).build()
val environment = createExecutionEnvironment(
project = project,
executor = executor,
runConfiguration = runConfiguration,
useOriginalSettings = useOriginalSettings,
runnerAndConfigurationSettings = runnerAndConfigurationSettings,
)
environment.callback = callback
runner.execute(environment)
}
@@ -184,6 +216,19 @@ class ExecutionToolset : McpToolset {
}
}
private fun createExecutionEnvironment(
project: com.intellij.openapi.project.Project,
executor: Executor,
runConfiguration: RunConfiguration,
useOriginalSettings: Boolean,
runnerAndConfigurationSettings: com.intellij.execution.RunnerAndConfigurationSettings,
) = if (useOriginalSettings) {
ExecutionEnvironmentBuilder.create(executor, runnerAndConfigurationSettings).build()
}
else {
ExecutionEnvironmentBuilder.create(project, executor, runConfiguration).build()
}
@Serializable
data class RunConfigurationsList(
val configurations: List<RunConfigurationInfo>,
@@ -194,6 +239,8 @@ class ExecutionToolset : McpToolset {
val name: String,
@EncodeDefault(mode = EncodeDefault.Mode.NEVER)
val description: String? = null,
@property:McpDescription("Whether this run configuration supports one-time dynamic launch overrides for programArguments, workingDirectory, and envs.")
val supportsDynamicLaunchOverrides: Boolean,
@EncodeDefault(mode = EncodeDefault.Mode.NEVER)
val commandLine: String? = null,
@EncodeDefault(mode = EncodeDefault.Mode.NEVER)
@@ -1,9 +1,12 @@
package com.intellij.mcpserver.util
import com.intellij.execution.CommonProgramRunConfigurationParameters
import com.intellij.execution.configurations.RunConfiguration
import com.intellij.mcpserver.McpExpectedError
import com.intellij.mcpserver.McpServerBundle
import com.intellij.mcpserver.impl.McpServerService
import com.intellij.mcpserver.mcpCallInfo
import com.intellij.mcpserver.mcpFail
import com.intellij.mcpserver.settings.McpServerSettings
import com.intellij.openapi.application.EDT
import com.intellij.openapi.editor.colors.EditorFontType
@@ -82,3 +85,36 @@ suspend fun askConfirmation(project: Project, @NlsContexts.Label notificationTex
return@withContext confirmationDialog.isOK
}
}
fun prepareRunConfigurationForExecution(
configurationName: String,
configuration: RunConfiguration,
programArguments: String?,
workingDirectory: String?,
envs: Map<String, String>?,
): RunConfiguration {
if (programArguments == null && workingDirectory == null && envs == null) {
return configuration
}
val copiedConfiguration = configuration.clone()
val configurable = copiedConfiguration as? CommonProgramRunConfigurationParameters
?: mcpFail(
"Run configuration '$configurationName' of type '${configuration.type.displayName}' doesn't support dynamic launch overrides " +
"(programArguments, workingDirectory, envs)."
)
if (programArguments != null) {
configurable.programParameters = programArguments.ifEmpty { null }
}
if (workingDirectory != null) {
configurable.workingDirectory = workingDirectory.ifEmpty { null }
}
if (envs != null) {
val mergedEnvs = LinkedHashMap(configurable.envs)
mergedEnvs.putAll(envs)
configurable.envs = mergedEnvs
}
return copiedConfiguration
}
@@ -4,19 +4,57 @@ package com.intellij.mcpserver.toolsets
import com.intellij.mcpserver.McpToolsetTestBase
import com.intellij.mcpserver.toolsets.general.ExecutionToolset
import com.intellij.openapi.application.runWriteAction
import io.kotest.common.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Proxy
class ExecutionToolsetTest : McpToolsetTestBase() {
@Test
fun get_run_configurations() = runBlocking {
val runManager = getRunManager()
val editableSettings = createEditableConfiguration(runManager)
val editableConfiguration = getConfiguration(editableSettings)
setDynamicLaunchOverrides(editableConfiguration)
val nonEditableSettings = createNonEditableConfiguration(runManager)
runWriteAction {
addConfiguration(runManager, editableSettings)
addConfiguration(runManager, nonEditableSettings)
}
testMcpTool(
ExecutionToolset::get_run_configurations.name,
buildJsonObject {},
"""{"configurations":[]}"""
)
) { result ->
val configurations = Json.parseToJsonElement(result.textContent.text)
.jsonObject
.getValue("configurations")
.jsonArray
.associateBy { it.jsonObject.getValue("name").jsonPrimitive.content }
val editable = configurations.getValue("editable-config").jsonObject
assertTrue(editable.getValue("supportsDynamicLaunchOverrides").jsonPrimitive.content.toBoolean())
assertEquals("--sample", editable.getValue("commandLine").jsonPrimitive.content)
assertEquals(project.basePath, editable.getValue("workingDirectory").jsonPrimitive.content)
assertEquals("bar", editable.getValue("environment").jsonObject.getValue("FOO").jsonPrimitive.content)
val nonEditable = configurations.getValue("compound-config").jsonObject
assertFalse(nonEditable.getValue("supportsDynamicLaunchOverrides").jsonPrimitive.content.toBoolean())
assertFalse("commandLine" in nonEditable)
assertFalse("workingDirectory" in nonEditable)
assertFalse("environment" in nonEditable)
}
}
@Test
@@ -29,4 +67,116 @@ class ExecutionToolsetTest : McpToolsetTestBase() {
"Run configuration with name 'test-config' not found."
)
}
}
@Test
fun execute_run_configuration_with_dynamic_launch_overrides_on_unsupported_configuration() = runBlocking {
val runManager = getRunManager()
val nonEditableSettings = createNonEditableConfiguration(runManager)
runWriteAction {
addConfiguration(runManager, nonEditableSettings)
}
testMcpTool(
ExecutionToolset::execute_run_configuration.name,
buildJsonObject {
put("configurationName", JsonPrimitive("compound-config"))
put("programArguments", JsonPrimitive("--sample"))
},
"Run configuration 'compound-config' of type 'Compound' doesn't support dynamic launch overrides (programArguments, workingDirectory, envs)."
)
}
private fun createEditableConfiguration(runManager: Any): Any {
val name = "editable-config"
val baseSettings = createNonEditableConfiguration(runManager)
val baseConfiguration = getConfiguration(baseSettings)
val factory = baseConfiguration.javaClass.getMethod("getFactory").invoke(baseConfiguration)
val editableConfiguration = createEditableConfigurationProxy(baseConfiguration, name)
return runManager.javaClass.methods
.first { it.name == "createConfiguration" && it.parameterCount == 2 && it.parameterTypes[0].name == "com.intellij.execution.configurations.RunConfiguration" }
.invoke(runManager, editableConfiguration, factory)
}
private fun getRunManager(): Any {
val projectClass = Class.forName("com.intellij.openapi.project.Project")
val runManagerClass = Class.forName("com.intellij.execution.RunManager")
return runManagerClass.getMethod("getInstance", projectClass).invoke(null, project)
}
private fun createNonEditableConfiguration(runManager: Any): Any {
val name = "compound-config"
val configurationTypeClassName = "com.intellij.execution.compound.CompoundRunConfigurationType"
val createConfigurationMethod = runManager.javaClass.methods.first {
it.name == "createConfiguration" &&
it.parameterCount == 2 &&
it.parameterTypes[0] == String::class.java &&
it.parameterTypes[1] == Class::class.java
}
return createConfigurationMethod.invoke(runManager, name, Class.forName(configurationTypeClassName))
}
private fun getConfiguration(settings: Any): Any = settings.javaClass.getMethod("getConfiguration").invoke(settings)
private fun setDynamicLaunchOverrides(configuration: Any) {
val parametersClass = Class.forName("com.intellij.execution.CommonProgramRunConfigurationParameters")
parametersClass.getMethod("setProgramParameters", String::class.java).invoke(configuration, "--sample")
parametersClass.getMethod("setWorkingDirectory", String::class.java).invoke(configuration, project.basePath)
parametersClass.getMethod("setEnvs", Map::class.java).invoke(configuration, mapOf("FOO" to "bar"))
}
private fun addConfiguration(runManager: Any, settings: Any) {
runManager.javaClass.methods
.first { it.name == "addConfiguration" && it.parameterCount == 1 }
.invoke(runManager, settings)
}
private fun createEditableConfigurationProxy(baseConfiguration: Any, name: String): Any {
val runConfigurationClass = Class.forName("com.intellij.execution.configurations.RunConfiguration")
val parametersClass = Class.forName("com.intellij.execution.CommonProgramRunConfigurationParameters")
val envMap = linkedMapOf<String, String>()
var programParameters: String? = null
var workingDirectory: String? = null
var passParentEnvs = true
val invocationHandler = InvocationHandler { _, method, args ->
when (method.name) {
"setProgramParameters" -> {
programParameters = args?.firstOrNull() as String?
null
}
"getProgramParameters" -> programParameters
"setWorkingDirectory" -> {
workingDirectory = args?.firstOrNull() as String?
null
}
"getWorkingDirectory" -> workingDirectory
"setEnvs" -> {
envMap.clear()
@Suppress("UNCHECKED_CAST")
envMap.putAll(args?.firstOrNull() as Map<String, String>)
null
}
"getEnvs" -> LinkedHashMap(envMap)
"setPassParentEnvs" -> {
passParentEnvs = args?.firstOrNull() as Boolean
null
}
"isPassParentEnvs" -> passParentEnvs
"getName" -> name
"setName" -> null
"clone" -> createEditableConfigurationProxy(baseConfiguration, name)
"equals" -> args?.firstOrNull() === baseConfiguration
"hashCode" -> System.identityHashCode(baseConfiguration)
"toString" -> "EditableTestRunConfigurationProxy($name)"
else -> method.invoke(baseConfiguration, *(args ?: emptyArray()))
}
}
return Proxy.newProxyInstance(
javaClass.classLoader,
arrayOf(runConfigurationClass, parametersClass),
invocationHandler,
)
}
}