From 6cb0043247400d99512eb656b425d004d8525d7c Mon Sep 17 00:00:00 2001 From: "Artem.Bukhonov" Date: Wed, 11 Mar 2026 19:42:21 +0100 Subject: [PATCH] IJPL-238874 Execution toolset should allow adjustments for parameters, env vars, working dir (cherry picked from commit 48b5c2c7ca109993c9fa57cd8541ea8b22688119) GitOrigin-RevId: 5d18bdea2f8c1c4cd7f1b9efa373050a1372e5e8 --- .../toolsets/general/ExecutionToolset.kt | 57 ++++++- .../intellij/mcpserver/util/execution.util.kt | 36 ++++ .../toolsets/ExecutionToolsetTest.kt | 156 +++++++++++++++++- 3 files changed, 241 insertions(+), 8 deletions(-) diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/toolsets/general/ExecutionToolset.kt b/plugins/mcp-server/src/com/intellij/mcpserver/toolsets/general/ExecutionToolset.kt index 6a9452fe511b..c245c6fe99d0 100644 --- a/plugins/mcp-server/src/com/intellij/mcpserver/toolsets/general/ExecutionToolset.kt +++ b/plugins/mcp-server/src/com/intellij/mcpserver/toolsets/general/ExecutionToolset.kt @@ -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? = 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, @@ -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) diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/util/execution.util.kt b/plugins/mcp-server/src/com/intellij/mcpserver/util/execution.util.kt index f370db13433f..d9631d6aa8fe 100644 --- a/plugins/mcp-server/src/com/intellij/mcpserver/util/execution.util.kt +++ b/plugins/mcp-server/src/com/intellij/mcpserver/util/execution.util.kt @@ -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?, +): 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 +} diff --git a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/toolsets/ExecutionToolsetTest.kt b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/toolsets/ExecutionToolsetTest.kt index a31320edda6e..18ff789b4929 100644 --- a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/toolsets/ExecutionToolsetTest.kt +++ b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/toolsets/ExecutionToolsetTest.kt @@ -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." ) } -} \ No newline at end of file + + @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() + 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) + 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, + ) + } +}