From b5cc190b2c98f8be5792ca0268320fbecfda563b Mon Sep 17 00:00:00 2001 From: "G.Reijn" <26114636+Gijsreyn@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:44:11 +0200 Subject: [PATCH] feat: Add --what-if in MCP server tools --- CHANGELOG.md | 9 + docs/concepts/dsc-mcp-server.md | 29 ++- dsc/locales/en-us.toml | 2 + dsc/src/server/invoke_dsc_config.rs | 22 ++- dsc/src/server/invoke_dsc_resource.rs | 28 ++- dsc/tests/dsc_server.tests.ps1 | 270 ++++++++++++++++++++++++++ 6 files changed, 347 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c9fb8a0..4ae6e6f41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,15 @@ changes since the last release, see the [diff on GitHub][unreleased]. for the `v3.0.0.0-alpha.5` release. Leave the release links under the release section. --> +### Added + +- Added what-if support to the MCP server tools. The `invoke_dsc_config` tool now accepts a + `what_if` option for the `set` operation, and the `invoke_dsc_resource` tool accepts `what_if` + for the `set` and `delete` operations. This mirrors the `--what-if` flag on the `dsc config set`, + `dsc resource set`, and `dsc resource delete` commands, enabling AI agents to preview changes + before applying them. Passing `what_if` with an operation that doesn't support it returns an + invalid parameters error. + ## [v3.2.2][release-v3.2.2] - 2026-06-16 This section includes a summary of changes for the `3.2.2` release. For the full list of changes diff --git a/docs/concepts/dsc-mcp-server.md b/docs/concepts/dsc-mcp-server.md index ca4f2c172..72cae22d8 100644 --- a/docs/concepts/dsc-mcp-server.md +++ b/docs/concepts/dsc-mcp-server.md @@ -20,10 +20,12 @@ This enhances the overall authoring experience by providing contextual informati local DSC environment directly to AI-powered tools. > [!IMPORTANT] -> The DSC MCP server is focused on discovery and information retrieval. It does not -> directly perform any configuration changes or resource modifications unless requested to do so. -> The information it provides to AI agents can be used to generate configurations and commands -> that, when executed, will impact your system. Always review and validate any generated +> The DSC MCP server is primarily focused on discovery and information retrieval. It only +> performs configuration changes or resource modifications when an agent explicitly invokes the +> `invoke_dsc_config` or `invoke_dsc_resource` tools with the `set` or `delete` operation. Those +> tools support a `what_if` option so agents can preview a change before applying it. The +> information the server provides to AI agents can be used to generate configurations and +> commands that, when executed, will impact your system. Always review and validate any generated > content before execution. ## What is Model Context Protocol (MCP)? @@ -74,6 +76,24 @@ to help solve your specific needs: This helps agents suggest the most suitable approach using your available DSC capabilities. +### Previewing changes with what-if + +Before an agent applies a configuration or resource change, it can simulate the change to show +you what would happen without modifying your system. The `invoke_dsc_config` tool accepts a +`what_if` option for the `set` operation, and the `invoke_dsc_resource` tool accepts `what_if` +for the `set` and `delete` operations. This is the same behavior as the `--what-if` flag on the +`dsc config set`, `dsc resource set`, and `dsc resource delete` commands: + +- **You ask**: "Show me what would change if I applied this configuration" +- **Agent invokes**: `invoke_dsc_config` with `operation: set` and `what_if: true` +- **Agent provides**: The projected before and after state for each resource, with the result + metadata reporting `executionType` as `whatIf` + +Resources that natively support what-if run their simulation directly. For resources that don't, +DSC generates a synthetic what-if result from the resource's `test` operation. When `what_if` is +requested with an operation that doesn't support it, such as `get`, the tool returns an error +instead of silently ignoring the option. + > [!NOTE] > Additional MCP tools will become available in future releases to expand the capabilities > of the DSC MCP server integration. For the latest updates and feature announcements, @@ -194,6 +214,7 @@ Example prompts that work well with DSC MCP integration: - "What DSC resources are available on this machine?" - "Show me the schema for the Microsoft.Windows/Registry resource" - "List all available DSC functions I can use in expressions" +- "Preview what this configuration would change before applying it" :::image type="complex" source="media/dsc-mcp-server/dsc-mcp-usage-example.png" alt-text="Screenshot showing DSC MCP usage example in VS Code"::: This screenshot demonstrates the DSC MCP integration in action, showing how AI agents use the MCP tools to provide contextual assistance with DSC-related tasks in VS Code. diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 4fc3d250e..b2126d48d 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -102,6 +102,7 @@ invalidParameters = "Invalid parameters" failedConvertJson = "Failed to convert to JSON" failedSerialize = "Failed to serialize configuration" failedSetParameters = "Failed to set parameters" +whatIfOnlySet = "what_if is only supported for the 'set' operation" [server.invoke_dsc_expression] parserInitializationFailed = "Failed to initialize parser: %{error}" @@ -113,6 +114,7 @@ functionInvocationFailed = "Function '%{function}' invocation failed: %{error}" [server.invoke_dsc_resource] resourceNotFound = "Resource type '%{resource}' does not exist" +whatIfNotSupported = "what_if is only supported for the 'set' and 'delete' operations" [server.list_dsc_functions] invalidNamePattern = "Invalid function name pattern '%{pattern}'" diff --git a/dsc/src/server/invoke_dsc_config.rs b/dsc/src/server/invoke_dsc_config.rs index 3b2a29970..c38453a20 100644 --- a/dsc/src/server/invoke_dsc_config.rs +++ b/dsc/src/server/invoke_dsc_config.rs @@ -4,7 +4,7 @@ use crate::server::mcp_server::McpServer; use dsc_lib::{ configure::{ - config_doc::Configuration, + config_doc::{Configuration, ExecutionKind}, config_result::{ ConfigurationExportResult, ConfigurationGetResult, ConfigurationSetResult, ConfigurationTestResult, @@ -52,14 +52,19 @@ pub struct InvokeDscConfigRequest { description = "Optional parameters to pass to the configuration as a YAML string" )] pub parameters: Option, + #[schemars( + description = "When true and operation is 'set', simulate the change (what-if / dry-run) instead of applying it. The result includes 'metadata.Microsoft.DSC.executionType' = 'whatIf'. Only valid with the 'set' operation." + )] + #[serde(default)] + pub what_if: Option, } #[tool_router(router = invoke_dsc_config_router, vis = "pub")] impl McpServer { #[tool( - description = "Invoke a DSC configuration operation (Get, Set, Test, Export) with optional parameters", + description = "Invoke a DSC configuration operation (Get, Set, Test, Export) with optional parameters. Set 'what_if' to true to preview a Set without applying changes.", annotations( - title = "Invoke a DSC configuration operation (Get, Set, Test, Export) with optional parameters", + title = "Invoke a DSC configuration operation (Get, Set, Test, Export) with optional parameters and what-if support", read_only_hint = false, destructive_hint = true, idempotent_hint = true, @@ -72,6 +77,7 @@ impl McpServer { operation, configuration, parameters, + what_if, }): Parameters, ) -> Result, McpError> { let result = task::spawn_blocking(move || { @@ -127,6 +133,16 @@ impl McpServer { configurator.context.dsc_version = Some(env!("CARGO_PKG_VERSION").to_string()); + if what_if.unwrap_or(false) { + if !matches!(operation, ConfigOperation::Set) { + return Err(McpError::invalid_params( + t!("server.invoke_dsc_config.whatIfOnlySet"), + None, + )); + } + configurator.context.execution_type = ExecutionKind::WhatIf; + } + let parameters_value: Option = if let Some(params_str) = parameters { let params_json = match serde_yaml::from_str::(¶ms_str) { Ok(yaml) => match serde_json::to_value(yaml) { diff --git a/dsc/src/server/invoke_dsc_resource.rs b/dsc/src/server/invoke_dsc_resource.rs index a48e6fb8c..6006b53b3 100644 --- a/dsc/src/server/invoke_dsc_resource.rs +++ b/dsc/src/server/invoke_dsc_resource.rs @@ -8,6 +8,8 @@ use dsc_lib::{ dscresources::{ dscresource::Invoke, invoke_result::{ + DeleteResult, + DeleteResultKind, ExportResult, GetResult, SetResult, @@ -39,6 +41,7 @@ pub enum ResourceOperationResult { TestResult(TestResult), ExportResult(ExportResult), DeleteResult { success: bool }, + DeleteWhatIfResult(DeleteResult), } #[derive(Serialize, JsonSchema)] @@ -54,22 +57,33 @@ pub struct InvokeDscResourceRequest { pub resource_type: FullyQualifiedTypeName, #[schemars(description = "The properties to pass to the DSC resource as JSON. Must match the resource JSON schema from `show_dsc_resource` tool.")] pub properties_json: String, + #[schemars(description = "When true and operation is 'set' or 'delete', simulate the change (what-if / dry-run) instead of applying it. Resources without native what-if support return a synthetic result derived from 'test'. Only valid with the 'set' and 'delete' operations.")] + #[serde(default)] + pub what_if: Option, } #[tool_router(router = invoke_dsc_resource_router, vis = "pub")] impl McpServer { #[tool( - description = "Invoke a DSC resource operation (Get, Set, Test, Export, Delete) with specified properties in JSON format", + description = "Invoke a DSC resource operation (Get, Set, Test, Export, Delete) with specified properties in JSON format. Set 'what_if' to true to preview a Set or Delete without applying changes.", annotations( - title = "Invoke a DSC resource operation (Get, Set, Test, Export, Delete) with specified properties in JSON format", + title = "Invoke a DSC resource operation (Get, Set, Test, Export, Delete) with specified properties in JSON format and what-if support", read_only_hint = false, destructive_hint = true, idempotent_hint = true, open_world_hint = true, ) )] - pub async fn invoke_dsc_resource(&self, Parameters(InvokeDscResourceRequest { operation, resource_type, properties_json }): Parameters) -> Result, McpError> { + pub async fn invoke_dsc_resource(&self, Parameters(InvokeDscResourceRequest { operation, resource_type, properties_json, what_if }): Parameters) -> Result, McpError> { let result = task::spawn_blocking(move || { + let execution_kind = if what_if.unwrap_or(false) { + if !matches!(operation, DscOperation::Set | DscOperation::Delete) { + return Err(McpError::invalid_params(t!("server.invoke_dsc_resource.whatIfNotSupported"), None)); + } + ExecutionKind::WhatIf + } else { + ExecutionKind::Actual + }; let mut dsc = DscManager::new(); let Some(resource) = dsc.find_resource(&DiscoveryFilter::new(&resource_type, None, None)).unwrap_or(None) else { return Err(McpError::invalid_request(t!("server.invoke_dsc_resource.resourceNotFound", resource = resource_type), None)); @@ -83,7 +97,7 @@ impl McpServer { Ok(ResourceOperationResult::GetResult(result)) }, DscOperation::Set => { - let result = match resource.set(&properties_json, false, &ExecutionKind::Actual) { + let result = match resource.set(&properties_json, false, &execution_kind) { Ok(res) => res, Err(e) => return Err(McpError::internal_error(e.to_string(), None)), }; @@ -97,8 +111,10 @@ impl McpServer { Ok(ResourceOperationResult::TestResult(result)) }, DscOperation::Delete => { - match resource.delete(&properties_json, &ExecutionKind::Actual) { - Ok(_) => Ok(ResourceOperationResult::DeleteResult { success: true }), + match resource.delete(&properties_json, &execution_kind) { + Ok(DeleteResultKind::ResourceActual) => Ok(ResourceOperationResult::DeleteResult { success: true }), + Ok(DeleteResultKind::ResourceWhatIf(delete_result)) => Ok(ResourceOperationResult::DeleteWhatIfResult(delete_result)), + Ok(DeleteResultKind::SyntheticWhatIf(test_result)) => Ok(ResourceOperationResult::TestResult(test_result)), Err(e) => Err(McpError::internal_error(e.to_string(), None)), } }, diff --git a/dsc/tests/dsc_server.tests.ps1 b/dsc/tests/dsc_server.tests.ps1 index 4ba43c872..dcc8e6711 100644 --- a/dsc/tests/dsc_server.tests.ps1 +++ b/dsc/tests/dsc_server.tests.ps1 @@ -429,6 +429,153 @@ Describe 'Tests for DSC server' { $response.result.structuredContent.result.success | Should -Be $true -Because $because } + It 'Calling invoke_dsc_resource for set operation with what_if uses native what-if' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 30 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" + arguments = @{ + operation = 'set' + resource_type = 'Test/WhatIfArgKind' + properties_json = (@{ executionType = 'Actual' } | ConvertTo-Json -Depth 20) + what_if = $true + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 30 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.afterState.executionType | Should -BeExactly 'WhatIf' -Because $because + } + + It 'Calling invoke_dsc_resource for set operation without what_if performs actual execution' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 31 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" + arguments = @{ + operation = 'set' + resource_type = 'Test/WhatIfArgKind' + properties_json = (@{ executionType = 'Actual' } | ConvertTo-Json -Depth 20) + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 31 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.afterState.executionType | Should -BeExactly 'Actual' -Because $because + } + + It 'Calling invoke_dsc_resource for set operation with what_if uses synthetic what-if' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 32 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" + arguments = @{ + operation = 'set' + resource_type = 'Microsoft.DSC.Debug/Echo' + properties_json = (@{ output = 'what-if test' } | ConvertTo-Json -Depth 20) + what_if = $true + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 32 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.beforeState | Should -Not -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.afterState.output | Should -BeExactly 'what-if test' -Because $because + } + + It 'Calling invoke_dsc_resource for delete operation with what_if returns native what-if metadata' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 33 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" + arguments = @{ + operation = 'delete' + resource_type = 'Test/WhatIfDelete' + properties_json = (@{ _exist = $false } | ConvertTo-Json -Depth 20) + what_if = $true + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 33 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.success | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result._metadata.whatIf | Should -Contain 'Delete what-if message 1' -Because $because + $response.result.structuredContent.result._metadata.whatIf | Should -Contain 'Delete what-if message 2' -Because $because + } + + It 'Calling invoke_dsc_resource for delete operation with what_if returns synthetic what-if test result' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 34 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" + arguments = @{ + operation = 'delete' + resource_type = 'Test/Delete' + properties_json = (@{ _exist = $false } | ConvertTo-Json -Depth 20) + what_if = $true + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 34 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.success | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.desiredState._exist | Should -BeFalse -Because $because + $response.result.structuredContent.result.actualState | Should -Not -BeNullOrEmpty -Because $because + } + + It 'Calling invoke_dsc_resource with what_if for unsupported operation: ' -TestCases @( + @{ operation = 'get' } + @{ operation = 'test' } + @{ operation = 'export' } + ) { + param($operation) + $mcpRequest = @{ + jsonrpc = "2.0" + id = 35 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" + arguments = @{ + operation = $operation + resource_type = 'Test/Operation' + properties_json = (@{ hello = 'World'; action = $operation } | ConvertTo-Json -Depth 20) + what_if = $true + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 35 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error.code | Should -Be -32602 -Because $because + $response.error.message | Should -Match 'what_if' -Because $because + } + It 'Calling invoke_dsc_config for operation: ' -TestCases @( @{ operation = 'get' } @{ operation = 'set' } @@ -471,6 +618,129 @@ Describe 'Tests for DSC server' { $response.result.structuredContent.result.results[0].name | Should -Be 'TestOperation' -Because $because } + It 'Calling invoke_dsc_config for set operation with what_if: ' -TestCases @( + @{ what_if = $true; expectedExecutionType = 'whatIf'; expectedState = 'WhatIf' } + @{ what_if = $false; expectedExecutionType = 'actual'; expectedState = 'Actual' } + ) { + param($what_if, $expectedExecutionType, $expectedState) + + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + resources = @( + @{ + name = 'WhatIf' + type = 'Test/WhatIf' + properties = @{ + executionType = 'Actual' + } + } + ) + } + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 36 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = 'set' + configuration = ($config | ConvertTo-Json -Depth 20) + what_if = $what_if + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 36 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.hadErrors | Should -BeFalse -Because $because + $response.result.structuredContent.result.metadata.'Microsoft.DSC'.executionType | Should -BeExactly $expectedExecutionType -Because $because + $response.result.structuredContent.result.results.Count | Should -Be 1 -Because $because + $response.result.structuredContent.result.results[0].result.afterState.executionType | Should -BeExactly $expectedState -Because $because + } + + It 'Calling invoke_dsc_config for set operation with what_if surfaces delete what-if metadata' { + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + resources = @( + @{ + name = 'WhatIfDelete' + type = 'Test/WhatIfDelete' + properties = @{ + _exist = $false + } + } + ) + } + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 37 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = 'set' + configuration = ($config | ConvertTo-Json -Depth 20) + what_if = $true + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 37 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error | Should -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.hadErrors | Should -BeFalse -Because $because + $response.result.structuredContent.result.metadata.'Microsoft.DSC'.executionType | Should -BeExactly 'whatIf' -Because $because + $response.result.structuredContent.result.results[0].metadata.whatIf[0] | Should -BeExactly 'Delete what-if message 1' -Because $because + $response.result.structuredContent.result.results[0].executionInformation.whatIf[1] | Should -BeExactly 'Delete what-if message 2' -Because $because + } + + It 'Calling invoke_dsc_config with what_if for unsupported operation: ' -TestCases @( + @{ operation = 'get' } + @{ operation = 'test' } + @{ operation = 'export' } + ) { + param($operation) + + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + resources = @( + @{ + name = 'TestOperation' + type = 'Test/Operation' + properties = @{ + hello = 'Hello from config' + action = $operation + } + } + ) + } + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 38 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = $operation + configuration = ($config | ConvertTo-Json -Depth 20) + what_if = $true + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 38 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.error.code | Should -Be -32602 -Because $because + $response.error.message | Should -Match 'what_if' -Because $because + } + It 'Calling invoke_dsc_config for export operation' { $config = @{ '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json'