Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions dsc/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dsc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions dsc/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
109 changes: 109 additions & 0 deletions dsc/src/mcp/invoke_dsc_resource.rs
Original file line number Diff line number Diff line change
@@ -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<InvokeDscResourceRequest>) -> Result<Json<InvokeDscResourceResponse>, 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 }))
}
}
6 changes: 5 additions & 1 deletion dsc/src/mcp/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down
1 change: 1 addition & 0 deletions dsc/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions dsc/tests/dsc_mcp.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <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
}
}
31 changes: 17 additions & 14 deletions dsc_lib/src/dscresources/command_resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -745,22 +745,25 @@ async fn run_process_async(executable: &str, args: Option<Vec<String>>, 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<Vec<String>>, input: Option<&str>, cwd: Option<&str>, env: Option<HashMap<String, String>>, exit_codes: Option<&HashMap<i32, String>>) -> 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<Vec<String>>, input: Option<&str>, cwd: Option<&str>, env: Option<HashMap<String, String>>, exit_codes: Option<&HashMap<i32, String>>) -> 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.
Expand Down
59 changes: 59 additions & 0 deletions tools/dsctest/dscoperation.dsc.resource.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
9 changes: 9 additions & 0 deletions tools/dsctest/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub enum Schemas {
Get,
InDesiredState,
Metadata,
Operation,
Sleep,
Trace,
Version,
Expand Down Expand Up @@ -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")]
Expand Down
16 changes: 16 additions & 0 deletions tools/dsctest/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod exporter;
mod get;
mod in_desired_state;
mod metadata;
mod operation;
mod adapter;
mod sleep;
mod trace;
Expand All @@ -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;
Expand Down Expand Up @@ -209,6 +211,17 @@ fn main() {
}
String::new()
},
SubCommand::Operation { operation, input } => {
let mut operation_result = match serde_json::from_str::<Operation>(&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 => {
Expand Down Expand Up @@ -238,6 +251,9 @@ fn main() {
Schemas::Metadata => {
schema_for!(Metadata)
},
Schemas::Operation => {
schema_for!(Operation)
},
Schemas::Sleep => {
schema_for!(Sleep)
},
Expand Down
Loading