Skip to content

Commit 9e650e7

Browse files
authored
Merge pull request #1105 from SteveL-MSFT/mcp-show-resource
Add `show_dsc_resource()` and enable `list_dsc_resource([adapter])` for MCP
2 parents 9c9bd9d + 0b9923f commit 9e650e7

File tree

7 files changed

+196
-91
lines changed

7 files changed

+196
-91
lines changed

dsc/locales/en-us.toml

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

69+
[mcp.list_dsc_resources]
70+
resourceNotAdapter = "The resource '%{adapter}' is not a valid adapter"
71+
adapterNotFound = "Adapter '%{adapter}' not found"
72+
73+
[mcp.show_dsc_resource]
74+
resourceNotFound = "Resource type '%{type_name}' not found"
75+
6976
[resolve]
7077
processingInclude = "Processing Include input"
7178
invalidInclude = "Failed to deserialize Include input"

dsc/src/mcp/list_adapted_resources.rs

Lines changed: 0 additions & 71 deletions
This file was deleted.

dsc/src/mcp/list_dsc_resources.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ use dsc_lib::{
88
discovery_trait::DiscoveryKind,
99
}, dscresources::resource_manifest::Kind, progress::ProgressFormat
1010
};
11-
use rmcp::{ErrorData as McpError, Json, tool, tool_router};
11+
use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters};
12+
use rust_i18n::t;
1213
use schemars::JsonSchema;
13-
use serde::Serialize;
14+
use serde::{Deserialize, Serialize};
1415
use std::collections::BTreeMap;
1516
use tokio::task;
1617

@@ -24,6 +25,14 @@ pub struct ResourceSummary {
2425
pub r#type: String,
2526
pub kind: Kind,
2627
pub description: Option<String>,
28+
#[serde(rename = "requireAdapter")]
29+
pub require_adapter: Option<String>,
30+
}
31+
32+
#[derive(Deserialize, JsonSchema)]
33+
pub struct ListResourcesRequest {
34+
#[schemars(description = "Filter adapted resources to only those requiring the specified adapter type. If not specified, all non-adapted resources are returned.")]
35+
pub adapter: Option<String>,
2736
}
2837

2938
#[tool_router(router = list_dsc_resources_router, vis = "pub")]
@@ -38,22 +47,36 @@ impl McpServer {
3847
open_world_hint = true,
3948
)
4049
)]
41-
pub async fn list_dsc_resources(&self) -> Result<Json<ResourceListResult>, McpError> {
50+
pub async fn list_dsc_resources(&self, Parameters(ListResourcesRequest { adapter }): Parameters<ListResourcesRequest>) -> Result<Json<ResourceListResult>, McpError> {
4251
let result = task::spawn_blocking(move || {
4352
let mut dsc = DscManager::new();
53+
let adapter_filter = match adapter {
54+
Some(adapter) => {
55+
if let Some(resource) = dsc.find_resource(&adapter, None) {
56+
if resource.kind != Kind::Adapter {
57+
return Err(McpError::invalid_params(t!("mcp.list_dsc_resources.resourceNotAdapter", adapter = adapter), None));
58+
}
59+
adapter
60+
} else {
61+
return Err(McpError::invalid_params(t!("mcp.list_dsc_resources.adapterNotFound", adapter = adapter), None));
62+
}
63+
},
64+
None => String::new(),
65+
};
4466
let mut resources = BTreeMap::<String, ResourceSummary>::new();
45-
for resource in dsc.list_available(&DiscoveryKind::Resource, "*", "", ProgressFormat::None) {
67+
for resource in dsc.list_available(&DiscoveryKind::Resource, "*", &adapter_filter, ProgressFormat::None) {
4668
if let Resource(resource) = resource {
4769
let summary = ResourceSummary {
4870
r#type: resource.type_name.clone(),
4971
kind: resource.kind.clone(),
5072
description: resource.description.clone(),
73+
require_adapter: resource.require_adapter.clone(),
5174
};
5275
resources.insert(resource.type_name.to_lowercase(), summary);
5376
}
5477
}
55-
ResourceListResult { resources: resources.into_values().collect() }
56-
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))?;
78+
Ok(ResourceListResult { resources: resources.into_values().collect() })
79+
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))??;
5780

5881
Ok(Json(result))
5982
}

dsc/src/mcp/mcp_server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ impl McpServer {
2020
#[must_use]
2121
pub fn new() -> Self {
2222
Self {
23-
tool_router: Self::list_adapted_resources_router() + Self::list_dsc_resources_router(),
23+
tool_router: Self::list_dsc_resources_router() + Self::show_dsc_resource_router(),
2424
}
2525
}
2626
}

dsc/src/mcp/mod.rs

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

12-
pub mod list_adapted_resources;
1312
pub mod list_dsc_resources;
1413
pub mod mcp_server;
14+
pub mod show_dsc_resource;
1515

1616
/// This function initializes and starts the MCP server, handling any errors that may occur.
1717
///

dsc/src/mcp/show_dsc_resource.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
use crate::mcp::mcp_server::McpServer;
5+
use dsc_lib::{
6+
DscManager,
7+
dscresources::{
8+
dscresource::{Capability, Invoke},
9+
resource_manifest::Kind
10+
},
11+
};
12+
use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters};
13+
use rust_i18n::t;
14+
use schemars::JsonSchema;
15+
use serde::{Deserialize, Serialize};
16+
use serde_json::Value;
17+
use tokio::task;
18+
19+
#[derive(Serialize, JsonSchema)]
20+
pub struct DscResource {
21+
/// The namespaced name of the resource.
22+
#[serde(rename="type")]
23+
pub type_name: String,
24+
/// The kind of resource.
25+
pub kind: Kind,
26+
/// The version of the resource.
27+
pub version: String,
28+
/// The capabilities of the resource.
29+
pub capabilities: Vec<Capability>,
30+
/// The description of the resource.
31+
#[serde(skip_serializing_if = "Option::is_none")]
32+
pub description: Option<String>,
33+
/// The author of the resource.
34+
#[serde(skip_serializing_if = "Option::is_none")]
35+
pub author: Option<String>,
36+
#[serde(skip_serializing_if = "Option::is_none")]
37+
pub schema: Option<Value>,
38+
}
39+
40+
#[derive(Deserialize, JsonSchema)]
41+
pub struct ShowResourceRequest {
42+
#[schemars(description = "The type name of the resource to get detailed information.")]
43+
pub r#type: String,
44+
}
45+
46+
#[tool_router(router = show_dsc_resource_router, vis = "pub")]
47+
impl McpServer {
48+
#[tool(
49+
description = "Get detailed information including the schema for a specific DSC resource",
50+
annotations(
51+
title = "Get detailed information including the schema for a specific DSC resource",
52+
read_only_hint = true,
53+
destructive_hint = false,
54+
idempotent_hint = true,
55+
open_world_hint = true,
56+
)
57+
)]
58+
pub async fn show_dsc_resource(&self, Parameters(ShowResourceRequest { r#type }): Parameters<ShowResourceRequest>) -> Result<Json<DscResource>, McpError> {
59+
let result = task::spawn_blocking(move || {
60+
let mut dsc = DscManager::new();
61+
let Some(resource) = dsc.find_resource(&r#type, None) else {
62+
return Err(McpError::invalid_params(t!("mcp.show_dsc_resource.resourceNotFound", type_name = r#type), None))
63+
};
64+
let schema = match resource.schema() {
65+
Ok(schema_str) => serde_json::from_str(&schema_str).ok(),
66+
Err(_) => None,
67+
};
68+
Ok(DscResource {
69+
type_name: resource.type_name.clone(),
70+
kind: resource.kind.clone(),
71+
version: resource.version.clone(),
72+
capabilities: resource.capabilities.clone(),
73+
description: resource.description.clone(),
74+
author: resource.author.clone(),
75+
schema,
76+
})
77+
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))??;
78+
79+
Ok(Json(result))
80+
}
81+
}

dsc/tests/dsc_mcp.tests.ps1

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,22 @@ Describe 'Tests for MCP server' {
6969
params = @{}
7070
}
7171

72-
$response = Send-McpRequest -request $mcpRequest
72+
$tools = @{
73+
'list_dsc_resources' = $false
74+
'show_dsc_resource' = $false
75+
}
7376

77+
$response = Send-McpRequest -request $mcpRequest
7478
$response.id | Should -Be 2
75-
$response.result.tools.Count | Should -Be 2
76-
$response.result.tools[0].name | Should -BeIn @('list_adapted_resources', 'list_dsc_resources')
77-
$response.result.tools[1].name | Should -BeIn @('list_adapted_resources', 'list_dsc_resources')
79+
$response.result.tools.Count | Should -Be $tools.Count
80+
foreach ($tool in $response.result.tools) {
81+
$tools.ContainsKey($tool.name) | Should -Be $true
82+
$tools[$tool.name] = $true
83+
$tool.description | Should -Not -BeNullOrEmpty
84+
}
85+
foreach ($tool in $tools.GetEnumerator()) {
86+
$tool.Value | Should -Be $true -Because "Tool '$($tool.Key)' was not found in the list of tools"
87+
}
7888
}
7989

8090
It 'Calling list_dsc_resources works' {
@@ -89,24 +99,24 @@ Describe 'Tests for MCP server' {
8999
}
90100

91101
$response = Send-McpRequest -request $mcpRequest
92-
$response.id | Should -Be 3
102+
$response.id | Should -BeGreaterOrEqual 3
93103
$resources = dsc resource list | ConvertFrom-Json -Depth 20 | Select-Object type, kind, description -Unique
94104
$response.result.structuredContent.resources.Count | Should -Be $resources.Count
95105
for ($i = 0; $i -lt $resources.Count; $i++) {
96-
($response.result.structuredContent.resources[$i].psobject.properties | Measure-Object).Count | Should -Be 3
106+
($response.result.structuredContent.resources[$i].psobject.properties | Measure-Object).Count | Should -BeGreaterOrEqual 3
97107
$response.result.structuredContent.resources[$i].type | Should -BeExactly $resources[$i].type -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
98108
$response.result.structuredContent.resources[$i].kind | Should -BeExactly $resources[$i].kind -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
99109
$response.result.structuredContent.resources[$i].description | Should -BeExactly $resources[$i].description -Because ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
100110
}
101111
}
102112

103-
It 'Calling list_adapted_resources works' {
113+
It 'Calling list_dsc_resources with adapter works' {
104114
$mcpRequest = @{
105115
jsonrpc = "2.0"
106116
id = 4
107117
method = "tools/call"
108118
params = @{
109-
name = "list_adapted_resources"
119+
name = "list_dsc_resources"
110120
arguments = @{
111121
adapter = "Microsoft.DSC/PowerShell"
112122
}
@@ -125,21 +135,76 @@ Describe 'Tests for MCP server' {
125135
}
126136
}
127137

128-
It 'Calling list_adapted_resources with no matches works' {
138+
It 'Calling list_dsc_resources with <adapter> returns error' -TestCases @(
139+
@{"adapter" = "Non.Existent/Adapter"},
140+
@{"adapter" = "Microsoft.DSC.Debug/Echo"}
141+
) {
142+
param($adapter)
143+
129144
$mcpRequest = @{
130145
jsonrpc = "2.0"
131146
id = 5
132147
method = "tools/call"
133148
params = @{
134-
name = "list_adapted_resources"
149+
name = "list_dsc_resources"
135150
arguments = @{
136-
adapter = "Non.Existent/Adapter"
151+
adapter = $adapter
137152
}
138153
}
139154
}
140155

141156
$response = Send-McpRequest -request $mcpRequest
142157
$response.id | Should -Be 5
143-
$response.result.structuredContent.resources.Count | Should -Be 0
158+
$response.error.code | Should -Be -32602
159+
$response.error.message | Should -Not -BeNullOrEmpty
160+
}
161+
162+
It 'Calling show_dsc_resource works' {
163+
$resource = (dsc resource list | Select-Object -First 1 | ConvertFrom-Json -Depth 20)
164+
165+
$mcpRequest = @{
166+
jsonrpc = "2.0"
167+
id = 6
168+
method = "tools/call"
169+
params = @{
170+
name = "show_dsc_resource"
171+
arguments = @{
172+
type = $resource.type
173+
}
174+
}
175+
}
176+
177+
$response = Send-McpRequest -request $mcpRequest
178+
$response.id | Should -Be 6
179+
($response.result.structuredContent.psobject.properties | Measure-Object).Count | Should -BeGreaterOrEqual 4
180+
$because = ($response.result.structuredContent | ConvertTo-Json -Depth 20 | Out-String)
181+
$response.result.structuredContent.type | Should -BeExactly $resource.type -Because $because
182+
$response.result.structuredContent.kind | Should -BeExactly $resource.kind -Because $because
183+
$response.result.structuredContent.version | Should -Be $resource.version -Because $because
184+
$response.result.structuredContent.capabilities | Should -Be $resource.capabilities -Because $because
185+
$response.result.structuredContent.description | Should -Be $resource.description -Because $because
186+
$schema = (dsc resource schema --resource $resource.type | ConvertFrom-Json -Depth 20)
187+
$response.result.structuredContent.schema.'$id' | Should -Be $schema.'$id' -Because $because
188+
$response.result.structuredContent.schema.type | Should -Be $schema.type -Because $because
189+
$response.result.structuredContent.schema.properties.keys | Should -Be $schema.properties.keys -Because $because
190+
}
191+
192+
It 'Calling show_dsc_resource with non-existent resource returns error' {
193+
$mcpRequest = @{
194+
jsonrpc = "2.0"
195+
id = 7
196+
method = "tools/call"
197+
params = @{
198+
name = "show_dsc_resource"
199+
arguments = @{
200+
type = "Non.Existent/Resource"
201+
}
202+
}
203+
}
204+
205+
$response = Send-McpRequest -request $mcpRequest
206+
$response.id | Should -Be 7
207+
$response.error.code | Should -Be -32602
208+
$response.error.message | Should -Not -BeNullOrEmpty
144209
}
145210
}

0 commit comments

Comments
 (0)