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
7 changes: 7 additions & 0 deletions dsc/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ serverStopped = "MCP server stopped"
failedToCreateRuntime = "Failed to create async runtime: %{error}"
serverWaitFailed = "Failed to wait for MCP server: %{error}"

[mcp.list_dsc_resources]
resourceNotAdapter = "The resource '%{adapter}' is not a valid adapter"
adapterNotFound = "Adapter '%{adapter}' not found"

[mcp.show_dsc_resource]
resourceNotFound = "Resource type '%{type_name}' not found"

[resolve]
processingInclude = "Processing Include input"
invalidInclude = "Failed to deserialize Include input"
Expand Down
71 changes: 0 additions & 71 deletions dsc/src/mcp/list_adapted_resources.rs

This file was deleted.

35 changes: 29 additions & 6 deletions dsc/src/mcp/list_dsc_resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ use dsc_lib::{
discovery_trait::DiscoveryKind,
}, dscresources::resource_manifest::Kind, progress::ProgressFormat
};
use rmcp::{ErrorData as McpError, Json, tool, tool_router};
use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters};
use rust_i18n::t;
use schemars::JsonSchema;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use tokio::task;

Expand All @@ -24,6 +25,14 @@ pub struct ResourceSummary {
pub r#type: String,
pub kind: Kind,
pub description: Option<String>,
#[serde(rename = "requireAdapter")]
pub require_adapter: Option<String>,
}

#[derive(Deserialize, JsonSchema)]
pub struct ListResourcesRequest {
#[schemars(description = "Filter adapted resources to only those requiring the specified adapter type. If not specified, all non-adapted resources are returned.")]
pub adapter: Option<String>,
}

#[tool_router(router = list_dsc_resources_router, vis = "pub")]
Expand All @@ -38,22 +47,36 @@ impl McpServer {
open_world_hint = true,
)
)]
pub async fn list_dsc_resources(&self) -> Result<Json<ResourceListResult>, McpError> {
pub async fn list_dsc_resources(&self, Parameters(ListResourcesRequest { adapter }): Parameters<ListResourcesRequest>) -> Result<Json<ResourceListResult>, McpError> {
let result = task::spawn_blocking(move || {
let mut dsc = DscManager::new();
let adapter_filter = match adapter {
Some(adapter) => {
if let Some(resource) = dsc.find_resource(&adapter, None) {
if resource.kind != Kind::Adapter {
return Err(McpError::invalid_params(t!("mcp.list_dsc_resources.resourceNotAdapter", adapter = adapter), None));
}
adapter
} else {
return Err(McpError::invalid_params(t!("mcp.list_dsc_resources.adapterNotFound", adapter = adapter), None));
}
},
None => String::new(),
};
let mut resources = BTreeMap::<String, ResourceSummary>::new();
for resource in dsc.list_available(&DiscoveryKind::Resource, "*", "", ProgressFormat::None) {
for resource in dsc.list_available(&DiscoveryKind::Resource, "*", &adapter_filter, ProgressFormat::None) {
if let Resource(resource) = resource {
let summary = ResourceSummary {
r#type: resource.type_name.clone(),
kind: resource.kind.clone(),
description: resource.description.clone(),
require_adapter: resource.require_adapter.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(ResourceListResult { resources: resources.into_values().collect() })
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))??;

Ok(Json(result))
}
Expand Down
2 changes: 1 addition & 1 deletion dsc/src/mcp/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ impl McpServer {
#[must_use]
pub fn new() -> Self {
Self {
tool_router: Self::list_adapted_resources_router() + Self::list_dsc_resources_router(),
tool_router: Self::list_dsc_resources_router() + Self::show_dsc_resource_router(),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion dsc/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ use rmcp::{
};
use rust_i18n::t;

pub mod list_adapted_resources;
pub mod list_dsc_resources;
pub mod mcp_server;
pub mod show_dsc_resource;

/// This function initializes and starts the MCP server, handling any errors that may occur.
///
Expand Down
81 changes: 81 additions & 0 deletions dsc/src/mcp/show_dsc_resource.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use crate::mcp::mcp_server::McpServer;
use dsc_lib::{
DscManager,
dscresources::{
dscresource::{Capability, Invoke},
resource_manifest::Kind
},
};
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 serde_json::Value;
use tokio::task;

#[derive(Serialize, JsonSchema)]
pub struct DscResource {
/// The namespaced name of the resource.
#[serde(rename="type")]
pub type_name: String,
/// The kind of resource.
pub kind: Kind,
/// The version of the resource.
pub version: String,
/// The capabilities of the resource.
pub capabilities: Vec<Capability>,
/// The description of the resource.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// The author of the resource.
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
}

#[derive(Deserialize, JsonSchema)]
pub struct ShowResourceRequest {
#[schemars(description = "The type name of the resource to get detailed information.")]
pub r#type: String,
}

#[tool_router(router = show_dsc_resource_router, vis = "pub")]
impl McpServer {
#[tool(
description = "Get detailed information including the schema for a specific DSC resource",
annotations(
title = "Get detailed information including the schema for a specific DSC resource",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true,
)
)]
pub async fn show_dsc_resource(&self, Parameters(ShowResourceRequest { r#type }): Parameters<ShowResourceRequest>) -> Result<Json<DscResource>, McpError> {
let result = task::spawn_blocking(move || {
let mut dsc = DscManager::new();
let Some(resource) = dsc.find_resource(&r#type, None) else {
return Err(McpError::invalid_params(t!("mcp.show_dsc_resource.resourceNotFound", type_name = r#type), None))
};
let schema = match resource.schema() {
Ok(schema_str) => serde_json::from_str(&schema_str).ok(),
Err(_) => None,
};
Ok(DscResource {
type_name: resource.type_name.clone(),
kind: resource.kind.clone(),
version: resource.version.clone(),
capabilities: resource.capabilities.clone(),
description: resource.description.clone(),
author: resource.author.clone(),
schema,
})
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))??;

Ok(Json(result))
}
}
89 changes: 77 additions & 12 deletions dsc/tests/dsc_mcp.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,22 @@ Describe 'Tests for MCP server' {
params = @{}
}

$response = Send-McpRequest -request $mcpRequest
$tools = @{
'list_dsc_resources' = $false
'show_dsc_resource' = $false
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 2
$response.result.tools.Count | Should -Be 2
$response.result.tools[0].name | Should -BeIn @('list_adapted_resources', 'list_dsc_resources')
$response.result.tools[1].name | Should -BeIn @('list_adapted_resources', 'list_dsc_resources')
$response.result.tools.Count | Should -Be $tools.Count
foreach ($tool in $response.result.tools) {
$tools.ContainsKey($tool.name) | Should -Be $true
$tools[$tool.name] = $true
$tool.description | Should -Not -BeNullOrEmpty
}
foreach ($tool in $tools.GetEnumerator()) {
$tool.Value | Should -Be $true -Because "Tool '$($tool.Key)' was not found in the list of tools"
}
}

It 'Calling list_dsc_resources works' {
Expand All @@ -89,24 +99,24 @@ Describe 'Tests for MCP server' {
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 3
$response.id | Should -BeGreaterOrEqual 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].psobject.properties | Measure-Object).Count | Should -BeGreaterOrEqual 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)
}
}

It 'Calling list_adapted_resources works' {
It 'Calling list_dsc_resources with adapter works' {
$mcpRequest = @{
jsonrpc = "2.0"
id = 4
method = "tools/call"
params = @{
name = "list_adapted_resources"
name = "list_dsc_resources"
arguments = @{
adapter = "Microsoft.DSC/PowerShell"
}
Expand All @@ -125,21 +135,76 @@ Describe 'Tests for MCP server' {
}
}

It 'Calling list_adapted_resources with no matches works' {
It 'Calling list_dsc_resources with <adapter> returns error' -TestCases @(
@{"adapter" = "Non.Existent/Adapter"},
@{"adapter" = "Microsoft.DSC.Debug/Echo"}
) {
param($adapter)

$mcpRequest = @{
jsonrpc = "2.0"
id = 5
method = "tools/call"
params = @{
name = "list_adapted_resources"
name = "list_dsc_resources"
arguments = @{
adapter = "Non.Existent/Adapter"
adapter = $adapter
}
}
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 5
$response.result.structuredContent.resources.Count | Should -Be 0
$response.error.code | Should -Be -32602
$response.error.message | Should -Not -BeNullOrEmpty
}

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

$mcpRequest = @{
jsonrpc = "2.0"
id = 6
method = "tools/call"
params = @{
name = "show_dsc_resource"
arguments = @{
type = $resource.type
}
}
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 6
($response.result.structuredContent.psobject.properties | Measure-Object).Count | Should -BeGreaterOrEqual 4
$because = ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
$response.result.structuredContent.type | Should -BeExactly $resource.type -Because $because
$response.result.structuredContent.kind | Should -BeExactly $resource.kind -Because $because
$response.result.structuredContent.version | Should -Be $resource.version -Because $because
$response.result.structuredContent.capabilities | Should -Be $resource.capabilities -Because $because
$response.result.structuredContent.description | Should -Be $resource.description -Because $because
$schema = (dsc resource schema --resource $resource.type | ConvertFrom-Json -Depth 20)
$response.result.structuredContent.schema.'$id' | Should -Be $schema.'$id' -Because $because
$response.result.structuredContent.schema.type | Should -Be $schema.type -Because $because
$response.result.structuredContent.schema.properties.keys | Should -Be $schema.properties.keys -Because $because
}

It 'Calling show_dsc_resource with non-existent resource returns error' {
$mcpRequest = @{
jsonrpc = "2.0"
id = 7
method = "tools/call"
params = @{
name = "show_dsc_resource"
arguments = @{
type = "Non.Existent/Resource"
}
}
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 7
$response.error.code | Should -Be -32602
$response.error.message | Should -Not -BeNullOrEmpty
}
}
Loading