Skip to content
Open
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
936 changes: 908 additions & 28 deletions dsc/Cargo.lock

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion dsc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@ crossterm = { version = "0.29" }
ctrlc = { version = "3.4" }
dsc_lib = { path = "../dsc_lib" }
indicatif = { version = "0.18" }
jsonschema = { version = "0.32", default-features = false }
jsonschema = { version = "0.33", default-features = false }
path-absolutize = { version = "3.1" }
regex = "1.11"
rmcp = { version = "0.6", features = [
"server",
"macros",
"transport-io",
"auth",
"elicitation",
"schemars",
] }
rust-i18n = { version = "3.1" }
schemars = { version = "1.0" }
semver = "1.0"
Expand All @@ -31,6 +39,8 @@ serde_yaml = { version = "0.9" }
syntect = { version = "5.0", features = ["default-fancy"], default-features = false }
sysinfo = { version = "0.37" }
thiserror = "2.0"
tokio = "1.47"
tokio-util = "0.7"
tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3", features = ["ansi", "env-filter", "json"] }
tracing-indicatif = { version = "0.3" }
10 changes: 10 additions & 0 deletions dsc/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ resource = "The name of the resource to invoke"
functionAbout = "Operations on DSC functions"
listFunctionAbout = "List or find functions"
version = "The version of the resource to invoke in semver format"
mcpAbout = "Use DSC as a MCP server"

[main]
ctrlCReceived = "Ctrl-C received"
Expand All @@ -55,6 +56,15 @@ storeMessage = """DSC.exe is a command-line tool and cannot be run directly from
Visit https://aka.ms/dscv3-docs for more information on how to use DSC.exe.

Press any key to close this window"""
failedToStartMcpServer = "Failed to start MCP server: %{error}"

[mcp.mod]
failedToInitialize = "Failed to initialize MCP server: %{error}"
failedToStart = "Failed to start MCP server: %{error}"
instructions = "This server provides tools that work with DSC (DesiredStateConfiguration) which enables users to manage and configure their systems declaratively."
serverStopped = "MCP server stopped"
failedToCreateRuntime = "Failed to create async runtime: %{error}"
serverWaitFailed = "Failed to wait for MCP server: %{error}"

[resolve]
processingInclude = "Processing Include input"
Expand Down
2 changes: 2 additions & 0 deletions dsc/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ pub enum SubCommand {
#[clap(subcommand)]
subcommand: FunctionSubCommand,
},
#[clap(name = "mcp", about = t!("args.mcpAbout").to_string())]
Mcp,
#[clap(name = "resource", about = t!("args.resourceAbout").to_string())]
Resource {
#[clap(subcommand)]
Expand Down
9 changes: 9 additions & 0 deletions dsc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use args::{Args, SubCommand};
use clap::{CommandFactory, Parser};
use clap_complete::generate;
use mcp::start_mcp_server;
use rust_i18n::{i18n, t};
use std::{io, io::Read, process::exit};
use sysinfo::{Process, RefreshKind, System, get_current_pid, ProcessRefreshKind};
Expand All @@ -18,6 +19,7 @@ use crossterm::event;
use std::env;

pub mod args;
pub mod mcp;
pub mod resolve;
pub mod resource_command;
pub mod subcommand;
Expand Down Expand Up @@ -95,6 +97,13 @@ fn main() {
SubCommand::Function { subcommand } => {
subcommand::function(&subcommand);
},
SubCommand::Mcp => {
if let Err(err) = start_mcp_server() {
error!("{}", t!("main.failedToStartMcpServer", error = err));
exit(util::EXIT_MCP_FAILED);
}
exit(util::EXIT_SUCCESS);
}
SubCommand::Resource { subcommand } => {
subcommand::resource(&subcommand, progress_format);
},
Expand Down
67 changes: 67 additions & 0 deletions dsc/src/mcp/list_dsc_resources.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use crate::mcp::McpServer;
use dsc_lib::{
DscManager, discovery::{
command_discovery::ImportedManifest::Resource,
discovery_trait::DiscoveryKind,
}, dscresources::resource_manifest::Kind, progress::ProgressFormat
};
use rmcp::{ErrorData as McpError, Json, tool, tool_router};
use schemars::JsonSchema;
use serde::Serialize;
use std::collections::BTreeMap;
use tokio::task;

#[derive(Serialize, JsonSchema)]
pub struct ResourceListResult {
pub resources: Vec<ResourceSummary>,
}

#[derive(Serialize, JsonSchema)]
pub struct ResourceSummary {
pub r#type: String,
pub kind: Kind,
pub description: Option<String>,
}

#[tool_router]
impl McpServer {
#[must_use]
pub fn new() -> Self {
Self {
tool_router: Self::tool_router()
}
}

#[tool(
description = "List summary of all DSC resources available on the local machine",
annotations(
title = "Enumerate all available DSC resources on the local machine returning name, kind, and description.",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true,
)
)]
async fn list_dsc_resources(&self) -> Result<Json<ResourceListResult>, McpError> {
let result = task::spawn_blocking(move || {
let mut dsc = DscManager::new();
let mut resources = BTreeMap::<String, ResourceSummary>::new();
for resource in dsc.list_available(&DiscoveryKind::Resource, "*", "", ProgressFormat::None) {
if let Resource(resource) = resource {
let summary = ResourceSummary {
r#type: resource.type_name.clone(),
kind: resource.kind.clone(),
description: resource.description.clone(),
};
resources.insert(resource.type_name.to_lowercase(), summary);
}
}
ResourceListResult { resources: resources.into_values().collect() }
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))?;

Ok(Json(result))
}
}
79 changes: 79 additions & 0 deletions dsc/src/mcp/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use rmcp::{
ErrorData as McpError,
handler::server::tool::ToolRouter,
model::{InitializeResult, InitializeRequestParam, ServerCapabilities, ServerInfo},
service::{RequestContext, RoleServer},
ServerHandler,
ServiceExt,
tool_handler,
transport::stdio,
};
use rust_i18n::t;

pub mod list_dsc_resources;

#[derive(Debug, Clone)]
pub struct McpServer {
tool_router: ToolRouter<Self>
}

impl Default for McpServer {
fn default() -> Self {
Self::new()
}
}

#[tool_handler]
impl ServerHandler for McpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
instructions: Some(t!("mcp.mod.instructions").to_string()),
..Default::default()
}
}

async fn initialize(&self, _request: InitializeRequestParam, _context: RequestContext<RoleServer>) -> Result<InitializeResult, McpError> {
Ok(self.get_info())
}
}

/// This function initializes and starts the MCP server, handling any errors that may occur.
///
/// # Errors
///
/// This function will return an error if the MCP server fails to start.
pub async fn start_mcp_server_async() -> Result<(), McpError> {
// Initialize the MCP server
let server = McpServer::new();

// Try to create the service with proper error handling
let service = server.serve(stdio()).await
.map_err(|err| McpError::internal_error(t!("mcp.mod.failedToInitialize", error = err.to_string()), None))?;

// Wait for the service to complete with proper error handling
service.waiting().await
.map_err(|err| McpError::internal_error(t!("mcp.mod.serverWaitFailed", error = err.to_string()), None))?;

tracing::info!("{}", t!("mcp.mod.serverStopped"));
Ok(())
}

/// Synchronous wrapper to start the MCP server
///
/// # Errors
///
/// This function will return an error if the MCP server fails to start or if the tokio runtime cannot be created.
pub fn start_mcp_server() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let rt = tokio::runtime::Runtime::new()
.map_err(|e| McpError::internal_error(t!("mcp.mod.failedToCreateRuntime", error = e.to_string()), None))?;

rt.block_on(start_mcp_server_async())
.map_err(|e| McpError::internal_error(t!("mcp.mod.failedToStart", error = e.to_string()), None))?;
Ok(())
}
2 changes: 1 addition & 1 deletion dsc/src/subcommand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String>
}
}

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) {
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) {
let mut write_table = false;
let mut table = Table::new(&[
t!("subcommand.tableHeader_type").to_string().as_ref(),
Expand Down
1 change: 1 addition & 0 deletions dsc/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub const EXIT_VALIDATION_FAILED: i32 = 5;
pub const EXIT_CTRL_C: i32 = 6;
pub const EXIT_DSC_RESOURCE_NOT_FOUND: i32 = 7;
pub const EXIT_DSC_ASSERTION_FAILED: i32 = 8;
pub const EXIT_MCP_FAILED: i32 = 9;

pub const DSC_CONFIG_ROOT: &str = "DSC_CONFIG_ROOT";
pub const DSC_TRACE_LEVEL: &str = "DSC_TRACE_LEVEL";
Expand Down
101 changes: 101 additions & 0 deletions dsc/tests/dsc_mcp.tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

Describe 'Tests for MCP server' {
BeforeAll {
$processStartInfo = [System.Diagnostics.ProcessStartInfo]::new()
$processStartInfo.FileName = "dsc"
$processStartInfo.Arguments = "--trace-format plaintext mcp"
$processStartInfo.RedirectStandardError = $true
$processStartInfo.RedirectStandardOutput = $true
$processStartInfo.RedirectStandardInput = $true
$mcp = [System.Diagnostics.Process]::Start($processStartInfo)

function Send-McpRequest($request, [switch]$notify) {
$request = $request | ConvertTo-Json -Compress -Depth 10
$mcp.StandardInput.WriteLine($request)
$mcp.StandardInput.Flush()
if (!$notify) {
while ($mcp.StandardOutput.Peek() -eq -1) {
Start-Sleep -Milliseconds 100
}
$stdout = $mcp.StandardOutput.ReadLine()
return ($stdout | ConvertFrom-Json -Depth 30)
}
}
}

AfterAll {
$mcp.StandardInput.Close()
$mcp.WaitForExit()
}

It 'Initialization works' {
$mcpRequest = @{
jsonrpc = "2.0"
id = 1
method = "initialize"
params = @{
protocolVersion = "2024-11-05"
capabilities = @{
tools = @{}
}
clientInfo = @{
name = "Test Client"
version = "1.0.0"
}
}
}

$response = Send-McpRequest -request $mcpRequest

$response.id | Should -Be 1
$response.result.capabilities.tools | Should -Not -Be $null
$response.result.instructions | Should -Not -BeNullOrEmpty

$notifyInitialized = @{
jsonrpc = "2.0"
method = "notifications/initialized"
}

Send-McpRequest -request $notifyInitialized -notify
}

It 'Tools/List works' {
$mcpRequest = @{
jsonrpc = "2.0"
id = 2
method = "tools/list"
params = @{}
}

$response = Send-McpRequest -request $mcpRequest

$response.id | Should -Be 2
$response.result.tools.Count | Should -Be 1
$response.result.tools[0].name | Should -BeExactly 'list_dsc_resources'
}

It 'Calling list_dsc_resources works' {
$mcpRequest = @{
jsonrpc = "2.0"
id = 3
method = "tools/call"
params = @{
name = "list_dsc_resources"
arguments = @{}
}
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 3
$resources = dsc resource list | ConvertFrom-Json -Depth 20 | Select-Object type, kind, description -Unique
$response.result.structuredContent.resources.Count | Should -Be $resources.Count
for ($i = 0; $i -lt $resources.Count; $i++) {
($response.result.structuredContent.resources[$i].psobject.properties | Measure-Object).Count | Should -Be 3
$response.result.structuredContent.resources[$i].type | Should -BeExactly $resources[$i].type -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
$response.result.structuredContent.resources[$i].kind | Should -BeExactly $resources[$i].kind -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
$response.result.structuredContent.resources[$i].description | Should -BeExactly $resources[$i].description -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
}
}
}
Loading
Loading