Skip to content

Commit 288336a

Browse files
authored
Merge pull request #1162 from SteveL-MSFT/mcp-invoke
Add `invoke_dsc_resource()` MCP tool
2 parents 635c8d5 + 00e81ce commit 288336a

File tree

12 files changed

+274
-22
lines changed

12 files changed

+274
-22
lines changed

dsc/Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dsc/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ indicatif = { version = "0.18" }
2222
jsonschema = { version = "0.33", default-features = false }
2323
path-absolutize = { version = "3.1" }
2424
regex = "1.11"
25-
rmcp = { version = "0.6", features = [
25+
rmcp = { version = "0.7", features = [
2626
"server",
2727
"macros",
2828
"transport-io",

dsc/locales/en-us.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,18 @@ serverStopped = "MCP server stopped"
6666
failedToCreateRuntime = "Failed to create async runtime: %{error}"
6767
serverWaitFailed = "Failed to wait for MCP server: %{error}"
6868

69+
[mcp.invoke_dsc_resource]
70+
resourceNotFound = "Resource type '%{resource}' does not exist"
71+
6972
[mcp.list_dsc_functions]
7073
invalidNamePattern = "Invalid function name pattern '%{pattern}'"
7174

7275
[mcp.list_dsc_resources]
7376
resourceNotAdapter = "The resource '%{adapter}' is not a valid adapter"
74-
adapterNotFound = "Adapter '%{adapter}' not found"
77+
adapterNotFound = "Adapter '%{adapter}' does not exist"
7578

7679
[mcp.show_dsc_resource]
77-
resourceNotFound = "Resource type '%{type_name}' not found"
80+
resourceNotFound = "Resource type '%{type_name}' does not exist"
7881

7982
[resolve]
8083
processingInclude = "Processing Include input"

dsc/src/mcp/invoke_dsc_resource.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use crate::mcp::mcp_server::McpServer;
5+
use dsc_lib::{
6+
configure::config_doc::ExecutionKind,
7+
dscresources::{
8+
dscresource::Invoke,
9+
invoke_result::{
10+
ExportResult,
11+
GetResult,
12+
SetResult,
13+
TestResult,
14+
},
15+
},
16+
DscManager,
17+
};
18+
use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters};
19+
use rust_i18n::t;
20+
use schemars::JsonSchema;
21+
use serde::{Deserialize, Serialize};
22+
use tokio::task;
23+
24+
#[derive(Deserialize, JsonSchema)]
25+
#[serde(rename_all = "lowercase")]
26+
pub enum DscOperation {
27+
Get,
28+
Set,
29+
Test,
30+
Export,
31+
}
32+
33+
#[derive(Serialize, JsonSchema)]
34+
#[serde(untagged)]
35+
pub enum ResourceOperationResult {
36+
GetResult(GetResult),
37+
SetResult(SetResult),
38+
TestResult(TestResult),
39+
ExportResult(ExportResult),
40+
}
41+
42+
#[derive(Serialize, JsonSchema)]
43+
pub struct InvokeDscResourceResponse {
44+
pub result: ResourceOperationResult,
45+
}
46+
47+
#[derive(Deserialize, JsonSchema)]
48+
pub struct InvokeDscResourceRequest {
49+
#[schemars(description = "The operation to perform on the DSC resource")]
50+
pub operation: DscOperation,
51+
#[schemars(description = "The type name of the DSC resource to invoke")]
52+
pub resource_type: String,
53+
#[schemars(description = "The properties to pass to the DSC resource as JSON. Must match the resource JSON schema from `show_dsc_resource` tool.")]
54+
pub properties_json: String,
55+
}
56+
57+
#[tool_router(router = invoke_dsc_resource_router, vis = "pub")]
58+
impl McpServer {
59+
#[tool(
60+
description = "Invoke a DSC resource operation (Get, Set, Test, Export) with specified properties in JSON format",
61+
annotations(
62+
title = "Invoke a DSC resource operation (Get, Set, Test, Export) with specified properties in JSON format",
63+
read_only_hint = false,
64+
destructive_hint = true,
65+
idempotent_hint = true,
66+
open_world_hint = true,
67+
)
68+
)]
69+
pub async fn invoke_dsc_resource(&self, Parameters(InvokeDscResourceRequest { operation, resource_type, properties_json }): Parameters<InvokeDscResourceRequest>) -> Result<Json<InvokeDscResourceResponse>, McpError> {
70+
let result = task::spawn_blocking(move || {
71+
let mut dsc = DscManager::new();
72+
let Some(resource) = dsc.find_resource(&resource_type, None) else {
73+
return Err(McpError::invalid_request(t!("mcp.invoke_dsc_resource.resourceNotFound", resource = resource_type), None));
74+
};
75+
match operation {
76+
DscOperation::Get => {
77+
let result = match resource.get(&properties_json) {
78+
Ok(res) => res,
79+
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
80+
};
81+
Ok(ResourceOperationResult::GetResult(result))
82+
},
83+
DscOperation::Set => {
84+
let result = match resource.set(&properties_json, false, &ExecutionKind::Actual) {
85+
Ok(res) => res,
86+
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
87+
};
88+
Ok(ResourceOperationResult::SetResult(result))
89+
},
90+
DscOperation::Test => {
91+
let result = match resource.test(&properties_json) {
92+
Ok(res) => res,
93+
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
94+
};
95+
Ok(ResourceOperationResult::TestResult(result))
96+
},
97+
DscOperation::Export => {
98+
let result = match resource.export(&properties_json) {
99+
Ok(res) => res,
100+
Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
101+
};
102+
Ok(ResourceOperationResult::ExportResult(result))
103+
}
104+
}
105+
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))??;
106+
107+
Ok(Json(InvokeDscResourceResponse { result }))
108+
}
109+
}

dsc/src/mcp/mcp_server.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ impl McpServer {
2020
#[must_use]
2121
pub fn new() -> Self {
2222
Self {
23-
tool_router: Self::list_dsc_resources_router() + Self::show_dsc_resource_router() + Self::list_dsc_functions_router(),
23+
tool_router:
24+
Self::invoke_dsc_resource_router()
25+
+ Self::list_dsc_functions_router()
26+
+ Self::list_dsc_resources_router()
27+
+ Self::show_dsc_resource_router()
2428
}
2529
}
2630
}

dsc/src/mcp/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use rmcp::{
99
};
1010
use rust_i18n::t;
1111

12+
pub mod invoke_dsc_resource;
1213
pub mod list_dsc_functions;
1314
pub mod list_dsc_resources;
1415
pub mod mcp_server;

dsc/tests/dsc_mcp.tests.ps1

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Describe 'Tests for MCP server' {
7171
}
7272

7373
$tools = @{
74+
'invoke_dsc_resource' = $false
7475
'list_dsc_functions' = $false
7576
'list_dsc_resources' = $false
7677
'show_dsc_resource' = $false
@@ -298,4 +299,38 @@ Describe 'Tests for MCP server' {
298299
$response.result.structuredContent.functions.Count | Should -Be 0
299300
$response.result.structuredContent.functions | Should -BeNullOrEmpty
300301
}
302+
303+
It 'Calling invoke_dsc_resource for operation: <operation>' -TestCases @(
304+
@{ operation = 'get'; property = 'actualState' }
305+
@{ operation = 'set'; property = 'beforeState' }
306+
@{ operation = 'test'; property = 'desiredState' }
307+
@{ operation = 'export'; property = 'actualState' }
308+
) {
309+
param($operation)
310+
311+
$mcpRequest = @{
312+
jsonrpc = "2.0"
313+
id = 12
314+
method = "tools/call"
315+
params = @{
316+
name = "invoke_dsc_resource"
317+
arguments = @{
318+
type = 'Test/Operation'
319+
operation = $operation
320+
resource_type = 'Test/Operation'
321+
properties_json = (@{
322+
hello = "World"
323+
action = $operation
324+
} | ConvertTo-Json -Depth 20)
325+
}
326+
}
327+
}
328+
329+
$response = Send-McpRequest -request $mcpRequest
330+
$response.id | Should -Be 12
331+
$because = ($response | ConvertTo-Json -Depth 20 | Out-String)
332+
($response.result.structuredContent.psobject.properties | Measure-Object).Count | Should -Be 1 -Because $because
333+
$response.result.structuredContent.result.$property.action | Should -BeExactly $operation -Because $because
334+
$response.result.structuredContent.result.$property.hello | Should -BeExactly "World" -Because $because
335+
}
301336
}

dsc_lib/src/dscresources/command_resource.rs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -745,22 +745,25 @@ async fn run_process_async(executable: &str, args: Option<Vec<String>>, input: O
745745
/// Will panic if tokio runtime can't be created.
746746
///
747747
#[allow(clippy::implicit_hasher)]
748-
#[tokio::main]
749-
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> {
750-
trace!("{}", t!("dscresources.commandResource.commandInvoke", executable = executable, args = args : {:?}));
751-
if let Some(cwd) = cwd {
752-
trace!("{}", t!("dscresources.commandResource.commandCwd", cwd = cwd));
753-
}
748+
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> {
749+
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(
750+
async {
751+
trace!("{}", t!("dscresources.commandResource.commandInvoke", executable = executable, args = args : {:?}));
752+
if let Some(cwd) = cwd {
753+
trace!("{}", t!("dscresources.commandResource.commandCwd", cwd = cwd));
754+
}
754755

755-
match run_process_async(executable, args, input, cwd, env, exit_codes).await {
756-
Ok((code, stdout, stderr)) => {
757-
Ok((code, stdout, stderr))
758-
},
759-
Err(err) => {
760-
error!("{}", t!("dscresources.commandResource.runProcessError", executable = executable, error = err));
761-
Err(err)
756+
match run_process_async(executable, args, input, cwd, env, exit_codes).await {
757+
Ok((code, stdout, stderr)) => {
758+
Ok((code, stdout, stderr))
759+
},
760+
Err(err) => {
761+
error!("{}", t!("dscresources.commandResource.runProcessError", executable = executable, error = err));
762+
Err(err)
763+
}
764+
}
762765
}
763-
}
766+
)
764767
}
765768

766769
/// Process the arguments for a command resource.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json",
3+
"type": "Test/Operation",
4+
"version": "0.1.0",
5+
"get": {
6+
"executable": "dsctest",
7+
"args": [
8+
"operation",
9+
"--operation",
10+
"get",
11+
{
12+
"jsonInputArg": "--input"
13+
}
14+
]
15+
},
16+
"set": {
17+
"executable": "dsctest",
18+
"args": [
19+
"operation",
20+
"--operation",
21+
"set",
22+
{
23+
"jsonInputArg": "--input"
24+
}
25+
]
26+
},
27+
"test": {
28+
"executable": "dsctest",
29+
"args": [
30+
"operation",
31+
"--operation",
32+
"trace",
33+
{
34+
"jsonInputArg": "--input"
35+
}
36+
]
37+
},
38+
"export": {
39+
"executable": "dsctest",
40+
"args": [
41+
"operation",
42+
"--operation",
43+
"export",
44+
{
45+
"jsonInputArg": "--input"
46+
}
47+
]
48+
},
49+
"schema": {
50+
"command": {
51+
"executable": "dsctest",
52+
"args": [
53+
"schema",
54+
"-s",
55+
"operation"
56+
]
57+
}
58+
}
59+
}

tools/dsctest/src/args.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub enum Schemas {
1414
Get,
1515
InDesiredState,
1616
Metadata,
17+
Operation,
1718
Sleep,
1819
Trace,
1920
Version,
@@ -100,6 +101,14 @@ pub enum SubCommand {
100101
export: bool,
101102
},
102103

104+
#[clap(name = "operation", about = "Perform an operation")]
105+
Operation {
106+
#[clap(name = "operation", short, long, help = "The name of the operation to perform")]
107+
operation: String,
108+
#[clap(name = "input", short, long, help = "The input to the operation command as JSON")]
109+
input: String,
110+
},
111+
103112
#[clap(name = "schema", about = "Get the JSON schema for a subcommand")]
104113
Schema {
105114
#[clap(name = "subcommand", short, long, help = "The subcommand to get the schema for")]

0 commit comments

Comments
 (0)