diff --git a/dsc/Cargo.lock b/dsc/Cargo.lock index 34ca22ba8..8dae23f8f 100644 --- a/dsc/Cargo.lock +++ b/dsc/Cargo.lock @@ -2116,9 +2116,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.6.4" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ab0892f4938752b34ae47cb53910b1b0921e55e77ddb6e44df666cab17939f" +checksum = "534fd1cd0601e798ac30545ff2b7f4a62c6f14edd4aaed1cc5eb1e85f69f09af" dependencies = [ "base64", "chrono", @@ -2140,9 +2140,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.6.4" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1827cd98dab34cade0513243c6fe0351f0f0b2c9d6825460bcf45b42804bdda0" +checksum = "9ba777eb0e5f53a757e36f0e287441da0ab766564ba7201600eeb92a4753022e" dependencies = [ "darling 0.21.3", "proc-macro2", diff --git a/dsc/Cargo.toml b/dsc/Cargo.toml index 514566ac9..d8d7eac4a 100644 --- a/dsc/Cargo.toml +++ b/dsc/Cargo.toml @@ -22,7 +22,7 @@ indicatif = { version = "0.18" } jsonschema = { version = "0.33", default-features = false } path-absolutize = { version = "3.1" } regex = "1.11" -rmcp = { version = "0.6", features = [ +rmcp = { version = "0.7", features = [ "server", "macros", "transport-io", diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 5d109ca1d..f1e47d6ec 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -66,15 +66,18 @@ serverStopped = "MCP server stopped" failedToCreateRuntime = "Failed to create async runtime: %{error}" serverWaitFailed = "Failed to wait for MCP server: %{error}" +[mcp.invoke_dsc_resource] +resourceNotFound = "Resource type '%{resource}' does not exist" + [mcp.list_dsc_functions] invalidNamePattern = "Invalid function name pattern '%{pattern}'" [mcp.list_dsc_resources] resourceNotAdapter = "The resource '%{adapter}' is not a valid adapter" -adapterNotFound = "Adapter '%{adapter}' not found" +adapterNotFound = "Adapter '%{adapter}' does not exist" [mcp.show_dsc_resource] -resourceNotFound = "Resource type '%{type_name}' not found" +resourceNotFound = "Resource type '%{type_name}' does not exist" [resolve] processingInclude = "Processing Include input" diff --git a/dsc/src/mcp/invoke_dsc_resource.rs b/dsc/src/mcp/invoke_dsc_resource.rs new file mode 100644 index 000000000..e060e4ca7 --- /dev/null +++ b/dsc/src/mcp/invoke_dsc_resource.rs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mcp::mcp_server::McpServer; +use dsc_lib::{ + configure::config_doc::ExecutionKind, + dscresources::{ + dscresource::Invoke, + invoke_result::{ + ExportResult, + GetResult, + SetResult, + TestResult, + }, + }, + DscManager, +}; +use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tokio::task; + +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum DscOperation { + Get, + Set, + Test, + Export, +} + +#[derive(Serialize, JsonSchema)] +#[serde(untagged)] +pub enum ResourceOperationResult { + GetResult(GetResult), + SetResult(SetResult), + TestResult(TestResult), + ExportResult(ExportResult), +} + +#[derive(Serialize, JsonSchema)] +pub struct InvokeDscResourceResponse { + pub result: ResourceOperationResult, +} + +#[derive(Deserialize, JsonSchema)] +pub struct InvokeDscResourceRequest { + #[schemars(description = "The operation to perform on the DSC resource")] + pub operation: DscOperation, + #[schemars(description = "The type name of the DSC resource to invoke")] + pub resource_type: String, + #[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, +} + +#[tool_router(router = invoke_dsc_resource_router, vis = "pub")] +impl McpServer { + #[tool( + description = "Invoke a DSC resource operation (Get, Set, Test, Export) with specified properties in JSON format", + annotations( + title = "Invoke a DSC resource operation (Get, Set, Test, Export) with specified properties in JSON format", + 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> { + let result = task::spawn_blocking(move || { + let mut dsc = DscManager::new(); + let Some(resource) = dsc.find_resource(&resource_type, None) else { + return Err(McpError::invalid_request(t!("mcp.invoke_dsc_resource.resourceNotFound", resource = resource_type), None)); + }; + match operation { + DscOperation::Get => { + let result = match resource.get(&properties_json) { + Ok(res) => res, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + Ok(ResourceOperationResult::GetResult(result)) + }, + DscOperation::Set => { + let result = match resource.set(&properties_json, false, &ExecutionKind::Actual) { + Ok(res) => res, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + Ok(ResourceOperationResult::SetResult(result)) + }, + DscOperation::Test => { + let result = match resource.test(&properties_json) { + Ok(res) => res, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + Ok(ResourceOperationResult::TestResult(result)) + }, + DscOperation::Export => { + let result = match resource.export(&properties_json) { + Ok(res) => res, + Err(e) => return Err(McpError::internal_error(e.to_string(), None)), + }; + Ok(ResourceOperationResult::ExportResult(result)) + } + } + }).await.map_err(|e| McpError::internal_error(e.to_string(), None))??; + + Ok(Json(InvokeDscResourceResponse { result })) + } +} diff --git a/dsc/src/mcp/mcp_server.rs b/dsc/src/mcp/mcp_server.rs index aeb1697be..6f75af3a8 100644 --- a/dsc/src/mcp/mcp_server.rs +++ b/dsc/src/mcp/mcp_server.rs @@ -20,7 +20,11 @@ impl McpServer { #[must_use] pub fn new() -> Self { Self { - tool_router: Self::list_dsc_resources_router() + Self::show_dsc_resource_router() + Self::list_dsc_functions_router(), + tool_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 1cb41ee48..3cf1e3a8e 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_resource; pub mod list_dsc_functions; pub mod list_dsc_resources; pub mod mcp_server; diff --git a/dsc/tests/dsc_mcp.tests.ps1 b/dsc/tests/dsc_mcp.tests.ps1 index c72261469..f7c52ffea 100644 --- a/dsc/tests/dsc_mcp.tests.ps1 +++ b/dsc/tests/dsc_mcp.tests.ps1 @@ -71,6 +71,7 @@ Describe 'Tests for MCP server' { } $tools = @{ + 'invoke_dsc_resource' = $false 'list_dsc_functions' = $false 'list_dsc_resources' = $false 'show_dsc_resource' = $false @@ -298,4 +299,38 @@ Describe 'Tests for MCP server' { $response.result.structuredContent.functions.Count | Should -Be 0 $response.result.structuredContent.functions | Should -BeNullOrEmpty } + + It 'Calling invoke_dsc_resource for operation: ' -TestCases @( + @{ operation = 'get'; property = 'actualState' } + @{ operation = 'set'; property = 'beforeState' } + @{ operation = 'test'; property = 'desiredState' } + @{ operation = 'export'; property = 'actualState' } + ) { + param($operation) + + $mcpRequest = @{ + jsonrpc = "2.0" + id = 12 + method = "tools/call" + params = @{ + name = "invoke_dsc_resource" + arguments = @{ + type = 'Test/Operation' + operation = $operation + resource_type = 'Test/Operation' + properties_json = (@{ + hello = "World" + action = $operation + } | ConvertTo-Json -Depth 20) + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 12 + $because = ($response | ConvertTo-Json -Depth 20 | Out-String) + ($response.result.structuredContent.psobject.properties | Measure-Object).Count | Should -Be 1 -Because $because + $response.result.structuredContent.result.$property.action | Should -BeExactly $operation -Because $because + $response.result.structuredContent.result.$property.hello | Should -BeExactly "World" -Because $because + } } diff --git a/dsc_lib/src/dscresources/command_resource.rs b/dsc_lib/src/dscresources/command_resource.rs index 68bf4484a..b5220fed7 100644 --- a/dsc_lib/src/dscresources/command_resource.rs +++ b/dsc_lib/src/dscresources/command_resource.rs @@ -745,22 +745,25 @@ async fn run_process_async(executable: &str, args: Option>, input: O /// Will panic if tokio runtime can't be created. /// #[allow(clippy::implicit_hasher)] -#[tokio::main] -pub async fn invoke_command(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&str>, env: Option>, exit_codes: Option<&HashMap>) -> Result<(i32, String, String), DscError> { - trace!("{}", t!("dscresources.commandResource.commandInvoke", executable = executable, args = args : {:?})); - if let Some(cwd) = cwd { - trace!("{}", t!("dscresources.commandResource.commandCwd", cwd = cwd)); - } +pub fn invoke_command(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&str>, env: Option>, exit_codes: Option<&HashMap>) -> Result<(i32, String, String), DscError> { + tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on( + async { + trace!("{}", t!("dscresources.commandResource.commandInvoke", executable = executable, args = args : {:?})); + if let Some(cwd) = cwd { + trace!("{}", t!("dscresources.commandResource.commandCwd", cwd = cwd)); + } - match run_process_async(executable, args, input, cwd, env, exit_codes).await { - Ok((code, stdout, stderr)) => { - Ok((code, stdout, stderr)) - }, - Err(err) => { - error!("{}", t!("dscresources.commandResource.runProcessError", executable = executable, error = err)); - Err(err) + match run_process_async(executable, args, input, cwd, env, exit_codes).await { + Ok((code, stdout, stderr)) => { + Ok((code, stdout, stderr)) + }, + Err(err) => { + error!("{}", t!("dscresources.commandResource.runProcessError", executable = executable, error = err)); + Err(err) + } + } } - } + ) } /// Process the arguments for a command resource. diff --git a/tools/dsctest/dscoperation.dsc.resource.json b/tools/dsctest/dscoperation.dsc.resource.json new file mode 100644 index 000000000..3b15b0820 --- /dev/null +++ b/tools/dsctest/dscoperation.dsc.resource.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/Operation", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "get", + { + "jsonInputArg": "--input" + } + ] + }, + "set": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "set", + { + "jsonInputArg": "--input" + } + ] + }, + "test": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "trace", + { + "jsonInputArg": "--input" + } + ] + }, + "export": { + "executable": "dsctest", + "args": [ + "operation", + "--operation", + "export", + { + "jsonInputArg": "--input" + } + ] + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "operation" + ] + } + } +} diff --git a/tools/dsctest/src/args.rs b/tools/dsctest/src/args.rs index f2054d99b..ed896dbc8 100644 --- a/tools/dsctest/src/args.rs +++ b/tools/dsctest/src/args.rs @@ -14,6 +14,7 @@ pub enum Schemas { Get, InDesiredState, Metadata, + Operation, Sleep, Trace, Version, @@ -100,6 +101,14 @@ pub enum SubCommand { export: bool, }, + #[clap(name = "operation", about = "Perform an operation")] + Operation { + #[clap(name = "operation", short, long, help = "The name of the operation to perform")] + operation: String, + #[clap(name = "input", short, long, help = "The input to the operation command as JSON")] + input: String, + }, + #[clap(name = "schema", about = "Get the JSON schema for a subcommand")] Schema { #[clap(name = "subcommand", short, long, help = "The subcommand to get the schema for")] diff --git a/tools/dsctest/src/main.rs b/tools/dsctest/src/main.rs index 8d96d9de1..04e2abd81 100644 --- a/tools/dsctest/src/main.rs +++ b/tools/dsctest/src/main.rs @@ -10,6 +10,7 @@ mod exporter; mod get; mod in_desired_state; mod metadata; +mod operation; mod adapter; mod sleep; mod trace; @@ -28,6 +29,7 @@ use crate::exporter::{Exporter, Resource}; use crate::get::Get; use crate::in_desired_state::InDesiredState; use crate::metadata::Metadata; +use crate::operation::Operation; use crate::sleep::Sleep; use crate::trace::Trace; use crate::version::Version; @@ -209,6 +211,17 @@ fn main() { } String::new() }, + SubCommand::Operation { operation, input } => { + let mut operation_result = match serde_json::from_str::(&input) { + Ok(op) => op, + Err(err) => { + eprintln!("Error JSON does not match schema: {err}"); + std::process::exit(1); + } + }; + operation_result.operation = Some(operation.to_lowercase()); + serde_json::to_string(&operation_result).unwrap() + }, SubCommand::Schema { subcommand } => { let schema = match subcommand { Schemas::Adapter => { @@ -238,6 +251,9 @@ fn main() { Schemas::Metadata => { schema_for!(Metadata) }, + Schemas::Operation => { + schema_for!(Operation) + }, Schemas::Sleep => { schema_for!(Sleep) }, diff --git a/tools/dsctest/src/operation.rs b/tools/dsctest/src/operation.rs new file mode 100644 index 000000000..70e12b4de --- /dev/null +++ b/tools/dsctest/src/operation.rs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct Operation { + pub operation: Option, + #[serde(flatten)] + pub params: Map, +}