Skip to content

Commit 22805b5

Browse files
authored
Merge pull request #1092 from SteveL-MSFT/dsc-mcp
Enable initial DSC MCP Server support
2 parents ec8fd50 + 73ed7f9 commit 22805b5

File tree

16 files changed

+1220
-91
lines changed

16 files changed

+1220
-91
lines changed

dsc/Cargo.lock

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

dsc/Cargo.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,17 @@ crossterm = { version = "0.29" }
1919
ctrlc = { version = "3.4" }
2020
dsc_lib = { path = "../dsc_lib" }
2121
indicatif = { version = "0.18" }
22-
jsonschema = { version = "0.32", default-features = false }
22+
jsonschema = { version = "0.33", default-features = false }
2323
path-absolutize = { version = "3.1" }
2424
regex = "1.11"
25+
rmcp = { version = "0.6", features = [
26+
"server",
27+
"macros",
28+
"transport-io",
29+
"auth",
30+
"elicitation",
31+
"schemars",
32+
] }
2533
rust-i18n = { version = "3.1" }
2634
schemars = { version = "1.0" }
2735
semver = "1.0"
@@ -31,6 +39,8 @@ serde_yaml = { version = "0.9" }
3139
syntect = { version = "5.0", features = ["default-fancy"], default-features = false }
3240
sysinfo = { version = "0.37" }
3341
thiserror = "2.0"
42+
tokio = "1.47"
43+
tokio-util = "0.7"
3444
tracing = { version = "0.1" }
3545
tracing-subscriber = { version = "0.3", features = ["ansi", "env-filter", "json"] }
3646
tracing-indicatif = { version = "0.3" }

dsc/locales/en-us.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ resource = "The name of the resource to invoke"
3535
functionAbout = "Operations on DSC functions"
3636
listFunctionAbout = "List or find functions"
3737
version = "The version of the resource to invoke in semver format"
38+
mcpAbout = "Use DSC as a MCP server"
3839

3940
[main]
4041
ctrlCReceived = "Ctrl-C received"
@@ -55,6 +56,15 @@ storeMessage = """DSC.exe is a command-line tool and cannot be run directly from
5556
Visit https://aka.ms/dscv3-docs for more information on how to use DSC.exe.
5657
5758
Press any key to close this window"""
59+
failedToStartMcpServer = "Failed to start MCP server: %{error}"
60+
61+
[mcp.mod]
62+
failedToInitialize = "Failed to initialize MCP server: %{error}"
63+
failedToStart = "Failed to start MCP server: %{error}"
64+
instructions = "This server provides tools that work with DSC (DesiredStateConfiguration) which enables users to manage and configure their systems declaratively."
65+
serverStopped = "MCP server stopped"
66+
failedToCreateRuntime = "Failed to create async runtime: %{error}"
67+
serverWaitFailed = "Failed to wait for MCP server: %{error}"
5868

5969
[resolve]
6070
processingInclude = "Processing Include input"

dsc/src/args.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ pub enum SubCommand {
9191
#[clap(subcommand)]
9292
subcommand: FunctionSubCommand,
9393
},
94+
#[clap(name = "mcp", about = t!("args.mcpAbout").to_string())]
95+
Mcp,
9496
#[clap(name = "resource", about = t!("args.resourceAbout").to_string())]
9597
Resource {
9698
#[clap(subcommand)]

dsc/src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use args::{Args, SubCommand};
55
use clap::{CommandFactory, Parser};
66
use clap_complete::generate;
7+
use mcp::start_mcp_server;
78
use rust_i18n::{i18n, t};
89
use std::{io, io::Read, process::exit};
910
use sysinfo::{Process, RefreshKind, System, get_current_pid, ProcessRefreshKind};
@@ -18,6 +19,7 @@ use crossterm::event;
1819
use std::env;
1920

2021
pub mod args;
22+
pub mod mcp;
2123
pub mod resolve;
2224
pub mod resource_command;
2325
pub mod subcommand;
@@ -95,6 +97,13 @@ fn main() {
9597
SubCommand::Function { subcommand } => {
9698
subcommand::function(&subcommand);
9799
},
100+
SubCommand::Mcp => {
101+
if let Err(err) = start_mcp_server() {
102+
error!("{}", t!("main.failedToStartMcpServer", error = err));
103+
exit(util::EXIT_MCP_FAILED);
104+
}
105+
exit(util::EXIT_SUCCESS);
106+
}
98107
SubCommand::Resource { subcommand } => {
99108
subcommand::resource(&subcommand, progress_format);
100109
},

dsc/src/mcp/list_dsc_resources.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use crate::mcp::McpServer;
5+
use dsc_lib::{
6+
DscManager, discovery::{
7+
command_discovery::ImportedManifest::Resource,
8+
discovery_trait::DiscoveryKind,
9+
}, dscresources::resource_manifest::Kind, progress::ProgressFormat
10+
};
11+
use rmcp::{ErrorData as McpError, Json, tool, tool_router};
12+
use schemars::JsonSchema;
13+
use serde::Serialize;
14+
use std::collections::BTreeMap;
15+
use tokio::task;
16+
17+
#[derive(Serialize, JsonSchema)]
18+
pub struct ResourceListResult {
19+
pub resources: Vec<ResourceSummary>,
20+
}
21+
22+
#[derive(Serialize, JsonSchema)]
23+
pub struct ResourceSummary {
24+
pub r#type: String,
25+
pub kind: Kind,
26+
pub description: Option<String>,
27+
}
28+
29+
#[tool_router]
30+
impl McpServer {
31+
#[must_use]
32+
pub fn new() -> Self {
33+
Self {
34+
tool_router: Self::tool_router()
35+
}
36+
}
37+
38+
#[tool(
39+
description = "List summary of all DSC resources available on the local machine",
40+
annotations(
41+
title = "Enumerate all available DSC resources on the local machine returning name, kind, and description.",
42+
read_only_hint = true,
43+
destructive_hint = false,
44+
idempotent_hint = true,
45+
open_world_hint = true,
46+
)
47+
)]
48+
async fn list_dsc_resources(&self) -> Result<Json<ResourceListResult>, McpError> {
49+
let result = task::spawn_blocking(move || {
50+
let mut dsc = DscManager::new();
51+
let mut resources = BTreeMap::<String, ResourceSummary>::new();
52+
for resource in dsc.list_available(&DiscoveryKind::Resource, "*", "", ProgressFormat::None) {
53+
if let Resource(resource) = resource {
54+
let summary = ResourceSummary {
55+
r#type: resource.type_name.clone(),
56+
kind: resource.kind.clone(),
57+
description: resource.description.clone(),
58+
};
59+
resources.insert(resource.type_name.to_lowercase(), summary);
60+
}
61+
}
62+
ResourceListResult { resources: resources.into_values().collect() }
63+
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))?;
64+
65+
Ok(Json(result))
66+
}
67+
}

dsc/src/mcp/mod.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use rmcp::{
5+
ErrorData as McpError,
6+
handler::server::tool::ToolRouter,
7+
model::{InitializeResult, InitializeRequestParam, ServerCapabilities, ServerInfo},
8+
service::{RequestContext, RoleServer},
9+
ServerHandler,
10+
ServiceExt,
11+
tool_handler,
12+
transport::stdio,
13+
};
14+
use rust_i18n::t;
15+
16+
pub mod list_dsc_resources;
17+
18+
#[derive(Debug, Clone)]
19+
pub struct McpServer {
20+
tool_router: ToolRouter<Self>
21+
}
22+
23+
impl Default for McpServer {
24+
fn default() -> Self {
25+
Self::new()
26+
}
27+
}
28+
29+
#[tool_handler]
30+
impl ServerHandler for McpServer {
31+
fn get_info(&self) -> ServerInfo {
32+
ServerInfo {
33+
capabilities: ServerCapabilities::builder()
34+
.enable_tools()
35+
.build(),
36+
instructions: Some(t!("mcp.mod.instructions").to_string()),
37+
..Default::default()
38+
}
39+
}
40+
41+
async fn initialize(&self, _request: InitializeRequestParam, _context: RequestContext<RoleServer>) -> Result<InitializeResult, McpError> {
42+
Ok(self.get_info())
43+
}
44+
}
45+
46+
/// This function initializes and starts the MCP server, handling any errors that may occur.
47+
///
48+
/// # Errors
49+
///
50+
/// This function will return an error if the MCP server fails to start.
51+
pub async fn start_mcp_server_async() -> Result<(), McpError> {
52+
// Initialize the MCP server
53+
let server = McpServer::new();
54+
55+
// Try to create the service with proper error handling
56+
let service = server.serve(stdio()).await
57+
.map_err(|err| McpError::internal_error(t!("mcp.mod.failedToInitialize", error = err.to_string()), None))?;
58+
59+
// Wait for the service to complete with proper error handling
60+
service.waiting().await
61+
.map_err(|err| McpError::internal_error(t!("mcp.mod.serverWaitFailed", error = err.to_string()), None))?;
62+
63+
tracing::info!("{}", t!("mcp.mod.serverStopped"));
64+
Ok(())
65+
}
66+
67+
/// Synchronous wrapper to start the MCP server
68+
///
69+
/// # Errors
70+
///
71+
/// This function will return an error if the MCP server fails to start or if the tokio runtime cannot be created.
72+
pub fn start_mcp_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
73+
let rt = tokio::runtime::Runtime::new()
74+
.map_err(|e| McpError::internal_error(t!("mcp.mod.failedToCreateRuntime", error = e.to_string()), None))?;
75+
76+
rt.block_on(start_mcp_server_async())
77+
.map_err(|e| McpError::internal_error(t!("mcp.mod.failedToStart", error = e.to_string()), None))?;
78+
Ok(())
79+
}

dsc/src/subcommand.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String>
744744
}
745745
}
746746

747-
fn list_resources(dsc: &mut DscManager, resource_name: Option<&String>, adapter_name: Option<&String>, description: Option<&String>, tags: Option<&Vec<String>>, format: Option<&ListOutputFormat>, progress_format: ProgressFormat) {
747+
pub fn list_resources(dsc: &mut DscManager, resource_name: Option<&String>, adapter_name: Option<&String>, description: Option<&String>, tags: Option<&Vec<String>>, format: Option<&ListOutputFormat>, progress_format: ProgressFormat) {
748748
let mut write_table = false;
749749
let mut table = Table::new(&[
750750
t!("subcommand.tableHeader_type").to_string().as_ref(),

dsc/src/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ pub const EXIT_VALIDATION_FAILED: i32 = 5;
6969
pub const EXIT_CTRL_C: i32 = 6;
7070
pub const EXIT_DSC_RESOURCE_NOT_FOUND: i32 = 7;
7171
pub const EXIT_DSC_ASSERTION_FAILED: i32 = 8;
72+
pub const EXIT_MCP_FAILED: i32 = 9;
7273

7374
pub const DSC_CONFIG_ROOT: &str = "DSC_CONFIG_ROOT";
7475
pub const DSC_TRACE_LEVEL: &str = "DSC_TRACE_LEVEL";

dsc/tests/dsc_mcp.tests.ps1

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
Describe 'Tests for MCP server' {
5+
BeforeAll {
6+
$processStartInfo = [System.Diagnostics.ProcessStartInfo]::new()
7+
$processStartInfo.FileName = "dsc"
8+
$processStartInfo.Arguments = "--trace-format plaintext mcp"
9+
$processStartInfo.RedirectStandardError = $true
10+
$processStartInfo.RedirectStandardOutput = $true
11+
$processStartInfo.RedirectStandardInput = $true
12+
$mcp = [System.Diagnostics.Process]::Start($processStartInfo)
13+
14+
function Send-McpRequest($request, [switch]$notify) {
15+
$request = $request | ConvertTo-Json -Compress -Depth 10
16+
$mcp.StandardInput.WriteLine($request)
17+
$mcp.StandardInput.Flush()
18+
if (!$notify) {
19+
while ($mcp.StandardOutput.Peek() -eq -1) {
20+
Start-Sleep -Milliseconds 100
21+
}
22+
$stdout = $mcp.StandardOutput.ReadLine()
23+
return ($stdout | ConvertFrom-Json -Depth 30)
24+
}
25+
}
26+
}
27+
28+
AfterAll {
29+
$mcp.StandardInput.Close()
30+
$mcp.WaitForExit()
31+
}
32+
33+
It 'Initialization works' {
34+
$mcpRequest = @{
35+
jsonrpc = "2.0"
36+
id = 1
37+
method = "initialize"
38+
params = @{
39+
protocolVersion = "2024-11-05"
40+
capabilities = @{
41+
tools = @{}
42+
}
43+
clientInfo = @{
44+
name = "Test Client"
45+
version = "1.0.0"
46+
}
47+
}
48+
}
49+
50+
$response = Send-McpRequest -request $mcpRequest
51+
52+
$response.id | Should -Be 1
53+
$response.result.capabilities.tools | Should -Not -Be $null
54+
$response.result.instructions | Should -Not -BeNullOrEmpty
55+
56+
$notifyInitialized = @{
57+
jsonrpc = "2.0"
58+
method = "notifications/initialized"
59+
}
60+
61+
Send-McpRequest -request $notifyInitialized -notify
62+
}
63+
64+
It 'Tools/List works' {
65+
$mcpRequest = @{
66+
jsonrpc = "2.0"
67+
id = 2
68+
method = "tools/list"
69+
params = @{}
70+
}
71+
72+
$response = Send-McpRequest -request $mcpRequest
73+
74+
$response.id | Should -Be 2
75+
$response.result.tools.Count | Should -Be 1
76+
$response.result.tools[0].name | Should -BeExactly 'list_dsc_resources'
77+
}
78+
79+
It 'Calling list_dsc_resources works' {
80+
$mcpRequest = @{
81+
jsonrpc = "2.0"
82+
id = 3
83+
method = "tools/call"
84+
params = @{
85+
name = "list_dsc_resources"
86+
arguments = @{}
87+
}
88+
}
89+
90+
$response = Send-McpRequest -request $mcpRequest
91+
$response.id | Should -Be 3
92+
$resources = dsc resource list | ConvertFrom-Json -Depth 20 | Select-Object type, kind, description -Unique
93+
$response.result.structuredContent.resources.Count | Should -Be $resources.Count
94+
for ($i = 0; $i -lt $resources.Count; $i++) {
95+
($response.result.structuredContent.resources[$i].psobject.properties | Measure-Object).Count | Should -Be 3
96+
$response.result.structuredContent.resources[$i].type | Should -BeExactly $resources[$i].type -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
97+
$response.result.structuredContent.resources[$i].kind | Should -BeExactly $resources[$i].kind -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
98+
$response.result.structuredContent.resources[$i].description | Should -BeExactly $resources[$i].description -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)