diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index f1e47d6ec..661cbcc53 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -66,6 +66,13 @@ serverStopped = "MCP server stopped" failedToCreateRuntime = "Failed to create async runtime: %{error}" serverWaitFailed = "Failed to wait for MCP server: %{error}" +[mcp.invoke_dsc_config] +invalidConfiguration = "Invalid configuration document" +invalidParameters = "Invalid parameters" +failedConvertJson = "Failed to convert to JSON" +failedSerialize = "Failed to serialize configuration" +failedSetParameters = "Failed to set parameters" + [mcp.invoke_dsc_resource] resourceNotFound = "Resource type '%{resource}' does not exist" diff --git a/dsc/src/mcp/invoke_dsc_config.rs b/dsc/src/mcp/invoke_dsc_config.rs new file mode 100644 index 000000000..9b0882b94 --- /dev/null +++ b/dsc/src/mcp/invoke_dsc_config.rs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mcp::mcp_server::McpServer; +use dsc_lib::{ + configure::{ + config_doc::Configuration, + config_result::{ + ConfigurationExportResult, ConfigurationGetResult, ConfigurationSetResult, + ConfigurationTestResult, + }, + Configurator, + }, + progress::ProgressFormat, +}; +use rmcp::{handler::server::wrapper::Parameters, tool, tool_router, ErrorData as McpError, Json}; +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tokio::task; + +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ConfigOperation { + Get, + Set, + Test, + Export, +} + +#[derive(Serialize, JsonSchema)] +#[serde(untagged)] +pub enum ConfigOperationResult { + GetResult(Box), + SetResult(Box), + TestResult(Box), + ExportResult(Box), +} + +#[derive(Serialize, JsonSchema)] +pub struct InvokeDscConfigResponse { + pub result: ConfigOperationResult, +} + +#[derive(Deserialize, JsonSchema)] +pub struct InvokeDscConfigRequest { + #[schemars(description = "The operation to perform on the DSC configuration")] + pub operation: ConfigOperation, + #[schemars(description = "The DSC configuration document as JSON or YAML string")] + pub configuration: String, + #[schemars( + description = "Optional parameters to pass to the configuration as JSON or YAML string" + )] + pub parameters: 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", + annotations( + title = "Invoke a DSC configuration operation (Get, Set, Test, Export) with optional parameters", + read_only_hint = false, + destructive_hint = true, + idempotent_hint = true, + open_world_hint = true, + ) + )] + pub async fn invoke_dsc_config( + &self, + Parameters(InvokeDscConfigRequest { + operation, + configuration, + parameters, + }): Parameters, + ) -> Result, McpError> { + let result = task::spawn_blocking(move || { + let config: Configuration = match serde_json::from_str(&configuration) { + Ok(config) => config, + Err(_) => { + match serde_yaml::from_str::(&configuration) { + Ok(yaml_value) => match serde_json::to_value(yaml_value) { + Ok(json_value) => match serde_json::from_value(json_value) { + Ok(config) => config, + Err(e) => { + return Err(McpError::invalid_request( + format!( + "{}: {e}", + t!("mcp.invoke_dsc_config.invalidConfiguration") + ), + None, + )) + } + }, + Err(e) => { + return Err(McpError::invalid_request( + format!( + "{}: {e}", + t!("mcp.invoke_dsc_config.failedConvertJson") + ), + None, + )) + } + }, + Err(e) => { + return Err(McpError::invalid_request( + format!( + "{}: {e}", + t!("mcp.invoke_dsc_config.invalidConfiguration") + ), + None, + )) + } + } + } + }; + + let config_json = match serde_json::to_string(&config) { + Ok(json) => json, + Err(e) => { + return Err(McpError::internal_error( + format!("{}: {e}", t!("mcp.invoke_dsc_config.failedSerialize")), + None, + )) + } + }; + + let mut configurator = match Configurator::new(&config_json, ProgressFormat::None) { + Ok(configurator) => configurator, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + + configurator.context.dsc_version = Some(env!("CARGO_PKG_VERSION").to_string()); + + let parameters_value: Option = if let Some(params_str) = parameters { + let params_json = match serde_json::from_str(¶ms_str) { + Ok(json) => json, + Err(_) => { + match serde_yaml::from_str::(¶ms_str) { + Ok(yaml) => match serde_json::to_value(yaml) { + Ok(json) => json, + Err(e) => { + return Err(McpError::invalid_request( + format!( + "{}: {e}", + t!("mcp.invoke_dsc_config.failedConvertJson") + ), + None, + )) + } + }, + Err(e) => { + return Err(McpError::invalid_request( + format!( + "{}: {e}", + t!("mcp.invoke_dsc_config.invalidParameters") + ), + None, + )) + } + } + } + }; + + // Wrap parameters in a "parameters" field for configurator.set_context() + Some(serde_json::json!({ + "parameters": params_json + })) + } else { + None + }; + + if let Err(e) = configurator.set_context(parameters_value.as_ref()) { + return Err(McpError::invalid_request( + format!("{}: {e}", t!("mcp.invoke_dsc_config.failedSetParameters")), + None, + )); + } + + match operation { + ConfigOperation::Get => { + let result = match configurator.invoke_get() { + Ok(res) => res, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + Ok(ConfigOperationResult::GetResult(Box::new(result))) + } + ConfigOperation::Set => { + let result = match configurator.invoke_set(false) { + Ok(res) => res, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + Ok(ConfigOperationResult::SetResult(Box::new(result))) + } + ConfigOperation::Test => { + let result = match configurator.invoke_test() { + Ok(res) => res, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + Ok(ConfigOperationResult::TestResult(Box::new(result))) + } + ConfigOperation::Export => { + let result = match configurator.invoke_export() { + Ok(res) => res, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + Ok(ConfigOperationResult::ExportResult(Box::new(result))) + } + } + }) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))??; + + Ok(Json(InvokeDscConfigResponse { result })) + } +} diff --git a/dsc/src/mcp/mcp_server.rs b/dsc/src/mcp/mcp_server.rs index 6f75af3a8..748355922 100644 --- a/dsc/src/mcp/mcp_server.rs +++ b/dsc/src/mcp/mcp_server.rs @@ -21,7 +21,8 @@ impl McpServer { pub fn new() -> Self { Self { tool_router: - Self::invoke_dsc_resource_router() + Self::invoke_dsc_config_router() + + Self::invoke_dsc_resource_router() + Self::list_dsc_functions_router() + Self::list_dsc_resources_router() + Self::show_dsc_resource_router() diff --git a/dsc/src/mcp/mod.rs b/dsc/src/mcp/mod.rs index 3cf1e3a8e..51c0084bf 100644 --- a/dsc/src/mcp/mod.rs +++ b/dsc/src/mcp/mod.rs @@ -9,6 +9,7 @@ use rmcp::{ }; use rust_i18n::t; +pub mod invoke_dsc_config; pub mod invoke_dsc_resource; pub mod list_dsc_functions; pub mod list_dsc_resources; diff --git a/dsc/tests/dsc_mcp.tests.ps1 b/dsc/tests/dsc_mcp.tests.ps1 index f3b7b25a4..336dcea3b 100644 --- a/dsc/tests/dsc_mcp.tests.ps1 +++ b/dsc/tests/dsc_mcp.tests.ps1 @@ -34,15 +34,15 @@ Describe 'Tests for MCP server' { It 'Initialization works' { $mcpRequest = @{ jsonrpc = "2.0" - id = 1 - method = "initialize" - params = @{ + id = 1 + method = "initialize" + params = @{ protocolVersion = "2024-11-05" - capabilities = @{ + capabilities = @{ tools = @{} } - clientInfo = @{ - name = "Test Client" + clientInfo = @{ + name = "Test Client" version = "1.0.0" } } @@ -56,7 +56,7 @@ Describe 'Tests for MCP server' { $notifyInitialized = @{ jsonrpc = "2.0" - method = "notifications/initialized" + method = "notifications/initialized" } Send-McpRequest -request $notifyInitialized -notify @@ -65,16 +65,17 @@ Describe 'Tests for MCP server' { It 'Tools/List works' { $mcpRequest = @{ jsonrpc = "2.0" - id = 2 - method = "tools/list" - params = @{} + id = 2 + method = "tools/list" + params = @{} } $tools = @{ + 'invoke_dsc_config' = $false 'invoke_dsc_resource' = $false - 'list_dsc_functions' = $false - 'list_dsc_resources' = $false - 'show_dsc_resource' = $false + 'list_dsc_functions' = $false + 'list_dsc_resources' = $false + 'show_dsc_resource' = $false } $response = Send-McpRequest -request $mcpRequest @@ -93,10 +94,10 @@ Describe 'Tests for MCP server' { It 'Calling list_dsc_resources works' { $mcpRequest = @{ jsonrpc = "2.0" - id = 3 - method = "tools/call" - params = @{ - name = "list_dsc_resources" + id = 3 + method = "tools/call" + params = @{ + name = "list_dsc_resources" arguments = @{} } } @@ -116,10 +117,10 @@ Describe 'Tests for MCP server' { It 'Calling list_dsc_resources with adapter works' { $mcpRequest = @{ jsonrpc = "2.0" - id = 4 - method = "tools/call" - params = @{ - name = "list_dsc_resources" + id = 4 + method = "tools/call" + params = @{ + name = "list_dsc_resources" arguments = @{ adapter = "Microsoft.DSC/PowerShell" } @@ -139,17 +140,17 @@ Describe 'Tests for MCP server' { } It 'Calling list_dsc_resources with returns error' -TestCases @( - @{"adapter" = "Non.Existent/Adapter"}, - @{"adapter" = "Microsoft.DSC.Debug/Echo"} + @{"adapter" = "Non.Existent/Adapter" }, + @{"adapter" = "Microsoft.DSC.Debug/Echo" } ) { param($adapter) $mcpRequest = @{ jsonrpc = "2.0" - id = 5 - method = "tools/call" - params = @{ - name = "list_dsc_resources" + id = 5 + method = "tools/call" + params = @{ + name = "list_dsc_resources" arguments = @{ adapter = $adapter } @@ -167,10 +168,10 @@ Describe 'Tests for MCP server' { $mcpRequest = @{ jsonrpc = "2.0" - id = 6 - method = "tools/call" - params = @{ - name = "show_dsc_resource" + id = 6 + method = "tools/call" + params = @{ + name = "show_dsc_resource" arguments = @{ type = $resource.type } @@ -195,10 +196,10 @@ Describe 'Tests for MCP server' { It 'Calling show_dsc_resource with non-existent resource returns error' { $mcpRequest = @{ jsonrpc = "2.0" - id = 7 - method = "tools/call" - params = @{ - name = "show_dsc_resource" + id = 7 + method = "tools/call" + params = @{ + name = "show_dsc_resource" arguments = @{ type = "Non.Existent/Resource" } @@ -214,10 +215,10 @@ Describe 'Tests for MCP server' { It 'Calling list_dsc_functions works' { $mcpRequest = @{ jsonrpc = "2.0" - id = 8 - method = "tools/call" - params = @{ - name = "list_dsc_functions" + id = 8 + method = "tools/call" + params = @{ + name = "list_dsc_functions" arguments = @{} } } @@ -241,10 +242,10 @@ Describe 'Tests for MCP server' { It 'Calling list_dsc_functions with function_filter filter works' { $mcpRequest = @{ jsonrpc = "2.0" - id = 9 - method = "tools/call" - params = @{ - name = "list_dsc_functions" + id = 9 + method = "tools/call" + params = @{ + name = "list_dsc_functions" arguments = @{ function_filter = "array" } @@ -261,10 +262,10 @@ Describe 'Tests for MCP server' { It 'Calling list_dsc_functions with wildcard pattern works' { $mcpRequest = @{ jsonrpc = "2.0" - id = 10 - method = "tools/call" - params = @{ - name = "list_dsc_functions" + id = 10 + method = "tools/call" + params = @{ + name = "list_dsc_functions" arguments = @{ function_filter = "*Array*" } @@ -284,10 +285,10 @@ Describe 'Tests for MCP server' { It 'Calling list_dsc_functions with invalid pattern returns empty result' { $mcpRequest = @{ jsonrpc = "2.0" - id = 11 - method = "tools/call" - params = @{ - name = "list_dsc_functions" + id = 11 + method = "tools/call" + params = @{ + name = "list_dsc_functions" arguments = @{ function_filter = "[invalid]" } @@ -310,18 +311,18 @@ Describe 'Tests for MCP server' { $mcpRequest = @{ jsonrpc = "2.0" - id = 12 - method = "tools/call" - params = @{ - name = "invoke_dsc_resource" + id = 12 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" arguments = @{ - type = 'Test/Operation' - operation = $operation - resource_type = 'Test/Operation' + type = 'Test/Operation' + operation = $operation + resource_type = 'Test/Operation' properties_json = (@{ - hello = "World" - action = $operation - } | ConvertTo-Json -Depth 20) + hello = "World" + action = $operation + } | ConvertTo-Json -Depth 20) } } } @@ -337,18 +338,18 @@ Describe 'Tests for MCP server' { It 'Calling invoke_dsc_resource for delete operation' { $mcpRequest = @{ jsonrpc = "2.0" - id = 12 - method = "tools/call" - params = @{ - name = "invoke_dsc_resource" + id = 12 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" arguments = @{ - type = 'Test/Operation' - operation = 'delete' - resource_type = 'Test/Operation' + type = 'Test/Operation' + operation = 'delete' + resource_type = 'Test/Operation' properties_json = (@{ - hello = "World" - action = 'delete' - } | ConvertTo-Json -Depth 20) + hello = "World" + action = 'delete' + } | ConvertTo-Json -Depth 20) } } } @@ -359,4 +360,255 @@ Describe 'Tests for MCP server' { ($response.result.structuredContent.psobject.properties | Measure-Object).Count | Should -Be 1 -Because $because $response.result.structuredContent.result.success | Should -Be $true -Because $because } + + It 'Calling invoke_dsc_config for operation: ' -TestCases @( + @{ operation = 'get' } + @{ operation = 'set' } + @{ operation = 'test' } + ) { + 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 = 13 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = $operation + configuration = ($config | ConvertTo-Json -Depth 20) + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 13 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.result.structuredContent.result.results | Should -Not -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.results.Count | Should -Be 1 -Because $because + $response.result.structuredContent.result.results[0].name | Should -Be 'TestOperation' -Because $because + } + + It 'Calling invoke_dsc_config for export operation' { + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + resources = @( + @{ + name = 'TestExport' + type = 'Test/Export' + properties = @{ + count = 2 + } + } + ) + } + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 13 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = 'export' + configuration = ($config | ConvertTo-Json -Depth 20) + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 13 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.result.structuredContent.result.result | Should -Not -BeNullOrEmpty -Because $because + $response.result.structuredContent.result.result.resources.Count | Should -Be 2 -Because $because + $response.result.structuredContent.result.result.resources[0].name | Should -Be 'TestName' -Because $because + } + + It 'Calling invoke_dsc_config with parameters works' { + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + parameters = @{ + message = @{ + type = 'string' + defaultValue = 'default message' + } + } + resources = @( + @{ + name = 'TestResource' + type = 'Test/Operation' + properties = @{ + hello = "[parameters('message')]" + action = 'get' + } + } + ) + } + + $parameters = @{ + message = 'custom message' + } + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 14 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = 'get' + configuration = ($config | ConvertTo-Json -Depth 20) + parameters = ($parameters | ConvertTo-Json -Depth 20) + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 14 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.result.structuredContent.result.results[0].result.actualState.hello | Should -Be 'custom message' -Because $because + } + + It 'Calling invoke_dsc_config with YAML configuration works' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: + - name: TestResource + type: Test/Operation + properties: + hello: Hello from YAML + action: get +'@ + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 15 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = 'get' + configuration = $configYaml + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 15 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.result.structuredContent.result.results[0].result.actualState.hello | Should -Be 'Hello from YAML' -Because $because + } + + It 'Calling invoke_dsc_config with YAML parameters works' { + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + parameters = @{ + greeting = @{ + type = 'string' + } + } + resources = @( + @{ + name = 'TestResource' + type = 'Test/Operation' + properties = @{ + hello = "[parameters('greeting')]" + action = 'get' + } + } + ) + } + + $parametersYaml = @' +greeting: Hello from YAML parameters +'@ + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 16 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = 'get' + configuration = ($config | ConvertTo-Json -Depth 20) + parameters = $parametersYaml + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 16 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + $response.result.structuredContent.result.results[0].result.actualState.hello | Should -Be 'Hello from YAML parameters' -Because $because + } + + It 'Calling invoke_dsc_config with invalid configuration returns error' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 17 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = 'get' + configuration = '{ invalid json }' + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 17 + $response.error.code | Should -Be -32600 + $response.error.message | Should -Match 'Invalid configuration' + } + + It 'Calling invoke_dsc_config with invalid parameters returns error' { + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + resources = @( + @{ + name = 'TestResource' + type = 'Test/Operation' + properties = @{ + hello = 'test' + action = 'get' + } + } + ) + } + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 18 + method = "tools/call" + params = @{ + name = "invoke_dsc_config" + arguments = @{ + operation = 'get' + configuration = ($config | ConvertTo-Json -Depth 20) + parameters = '{[invalid' + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 18 + $response.error.code | Should -Be -32600 + $response.error.message | Should -Match 'Invalid parameters' + } }