Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
37 changes: 36 additions & 1 deletion 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 @@ -162,7 +163,7 @@ Describe 'Tests for MCP server' {
}

It 'Calling show_dsc_resource works' {
$resource = (dsc resource list | Select-Object -First 1 | ConvertFrom-Json -Depth 20)
$resource = (dsc resource list | ConvertFrom-Json -Depth 20 | Select-Object -First 1)

$mcpRequest = @{
jsonrpc = "2.0"
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
Loading