diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 56cdd82ee..e3af68d3f 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -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" diff --git a/dsc/src/mcp/list_adapted_resources.rs b/dsc/src/mcp/list_adapted_resources.rs deleted file mode 100644 index d7f44fc1b..000000000 --- a/dsc/src/mcp/list_adapted_resources.rs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -use crate::mcp::mcp_server::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, handler::server::wrapper::Parameters}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use tokio::task; - -#[derive(Serialize, JsonSchema)] -pub struct AdaptedResourceListResult { - pub resources: Vec, -} - -#[derive(Serialize, JsonSchema)] -pub struct AdaptedResourceSummary { - pub r#type: String, - pub kind: Kind, - pub description: Option, - #[serde(rename = "requiresAdapter")] - pub require_adapter: String, -} - -#[derive(Deserialize, JsonSchema)] -pub struct ListAdaptersRequest { - #[schemars(description = "Filter adapted resources to only those requiring the specified adapter type.")] - pub adapter: String, -} - -#[tool_router(router = list_adapted_resources_router, vis = "pub")] -impl McpServer { - #[tool( - description = "List summary of all adapted DSC resources available on the local machine. Adapted resources require an adapter to run.", - annotations( - title = "Enumerate all available adapted DSC resources on the local machine returning name, kind, description, and required adapter.", - read_only_hint = true, - destructive_hint = false, - idempotent_hint = true, - open_world_hint = true, - ) - )] - pub async fn list_adapted_resources(&self, Parameters(ListAdaptersRequest { adapter }): Parameters) -> Result, McpError> { - let result = task::spawn_blocking(move || { - let mut dsc = DscManager::new(); - let mut resources = BTreeMap::::new(); - for resource in dsc.list_available(&DiscoveryKind::Resource, "*", &adapter, ProgressFormat::None) { - if let Resource(resource) = resource { - if let Some(require_adapter) = resource.require_adapter.as_ref() { - let summary = AdaptedResourceSummary { - r#type: resource.type_name.clone(), - kind: resource.kind.clone(), - description: resource.description.clone(), - require_adapter: require_adapter.clone(), - }; - resources.insert(resource.type_name.to_lowercase(), summary); - } - } - } - AdaptedResourceListResult { resources: resources.into_values().collect() } - }).await.map_err(|e| McpError::internal_error(e.to_string(), None))?; - - Ok(Json(result)) - } -} diff --git a/dsc/src/mcp/list_dsc_resources.rs b/dsc/src/mcp/list_dsc_resources.rs index 32646e66f..2f347fb87 100644 --- a/dsc/src/mcp/list_dsc_resources.rs +++ b/dsc/src/mcp/list_dsc_resources.rs @@ -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; @@ -24,6 +25,14 @@ pub struct ResourceSummary { pub r#type: String, pub kind: Kind, pub description: Option, + #[serde(rename = "requireAdapter")] + pub require_adapter: Option, +} + +#[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, } #[tool_router(router = list_dsc_resources_router, vis = "pub")] @@ -38,22 +47,36 @@ impl McpServer { open_world_hint = true, ) )] - pub async fn list_dsc_resources(&self) -> Result, McpError> { + pub async fn list_dsc_resources(&self, Parameters(ListResourcesRequest { adapter }): Parameters) -> Result, 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::::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)) } diff --git a/dsc/src/mcp/mcp_server.rs b/dsc/src/mcp/mcp_server.rs index 8b86e870a..7073f980f 100644 --- a/dsc/src/mcp/mcp_server.rs +++ b/dsc/src/mcp/mcp_server.rs @@ -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(), } } } diff --git a/dsc/src/mcp/mod.rs b/dsc/src/mcp/mod.rs index 15ddd5194..11d75ab5b 100644 --- a/dsc/src/mcp/mod.rs +++ b/dsc/src/mcp/mod.rs @@ -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. /// diff --git a/dsc/src/mcp/show_dsc_resource.rs b/dsc/src/mcp/show_dsc_resource.rs new file mode 100644 index 000000000..660fbf312 --- /dev/null +++ b/dsc/src/mcp/show_dsc_resource.rs @@ -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, + /// The description of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The author of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub schema: Option, +} + +#[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) -> Result, 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)) + } +} diff --git a/dsc/tests/dsc_mcp.tests.ps1 b/dsc/tests/dsc_mcp.tests.ps1 index 805d37ce6..4a643e4be 100644 --- a/dsc/tests/dsc_mcp.tests.ps1 +++ b/dsc/tests/dsc_mcp.tests.ps1 @@ -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' { @@ -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" } @@ -125,21 +135,76 @@ Describe 'Tests for MCP server' { } } - It 'Calling list_adapted_resources with no matches works' { + It 'Calling list_dsc_resources with 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 } }