Skip to content
Merged
3 changes: 3 additions & 0 deletions dsc/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ serverStopped = "MCP server stopped"
failedToCreateRuntime = "Failed to create async runtime: %{error}"
serverWaitFailed = "Failed to wait for MCP server: %{error}"

[mcp.list_dsc_functions]
invalidNamePattern = "Invalid function name pattern '%{pattern}'"

[mcp.list_dsc_resources]
resourceNotAdapter = "The resource '%{adapter}' is not a valid adapter"
adapterNotFound = "Adapter '%{adapter}' not found"
Expand Down
62 changes: 62 additions & 0 deletions dsc/src/mcp/list_dsc_functions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use crate::mcp::mcp_server::McpServer;
use dsc_lib::functions::{FunctionDispatcher, FunctionDefinition};
use dsc_lib::util::convert_wildcard_to_regex;
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 regex::RegexBuilder;
use tokio::task;

#[derive(Serialize, JsonSchema)]
pub struct FunctionListResult {
pub functions: Vec<FunctionDefinition>,
}

#[derive(Deserialize, JsonSchema)]
pub struct ListFunctionsRequest {
#[schemars(description = "Optional function name to filter the list. Supports wildcard patterns (*, ?)")]
pub function_filter: Option<String>,
}

#[tool_router(router = list_dsc_functions_router, vis = "pub")]
impl McpServer {
#[tool(
description = "List available DSC functions to be used in expressions with optional filtering by name pattern",
annotations(
title = "Enumerate all available DSC functions on the local machine returning name, category, description, and metadata.",
read_only_hint = true,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = true,
)
)]
pub async fn list_dsc_functions(&self, Parameters(ListFunctionsRequest { function_filter }): Parameters<ListFunctionsRequest>) -> Result<Json<FunctionListResult>, McpError> {
let result = task::spawn_blocking(move || {
let function_dispatcher = FunctionDispatcher::new();
let mut functions = function_dispatcher.list();

// apply filtering if function_filter is provided
if let Some(name_pattern) = function_filter {
let regex_str = convert_wildcard_to_regex(&name_pattern);
let mut regex_builder = RegexBuilder::new(&regex_str);
regex_builder.case_insensitive(true);

let regex = regex_builder.build()
.map_err(|_| McpError::invalid_params(
t!("mcp.list_dsc_functions.invalidNamePattern", pattern = name_pattern),
None
))?;

functions.retain(|func| regex.is_match(&func.name));
}

Ok(FunctionListResult { functions })
}).await.map_err(|e| McpError::internal_error(e.to_string(), None))??;

Ok(Json(result))
}
}
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_dsc_resources_router() + Self::show_dsc_resource_router(),
tool_router: Self::list_dsc_resources_router() + Self::show_dsc_resource_router() + Self::list_dsc_functions_router(),
}
}
}
Expand Down
1 change: 1 addition & 0 deletions dsc/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use rmcp::{
};
use rust_i18n::t;

pub mod list_dsc_functions;
pub mod list_dsc_resources;
pub mod mcp_server;
pub mod show_dsc_resource;
Expand Down
91 changes: 91 additions & 0 deletions dsc/tests/dsc_mcp.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Describe 'Tests for MCP server' {
$processStartInfo = [System.Diagnostics.ProcessStartInfo]::new()
$processStartInfo.FileName = "dsc"
$processStartInfo.Arguments = "--trace-format plaintext mcp"
$processStartInfo.UseShellExecute = $false
$processStartInfo.RedirectStandardError = $true
$processStartInfo.RedirectStandardOutput = $true
$processStartInfo.RedirectStandardInput = $true
Expand Down Expand Up @@ -70,6 +71,7 @@ Describe 'Tests for MCP server' {
}

$tools = @{
'list_dsc_functions' = $false
'list_dsc_resources' = $false
'show_dsc_resource' = $false
}
Expand Down Expand Up @@ -207,4 +209,93 @@ Describe 'Tests for MCP server' {
$response.error.code | Should -Be -32602
$response.error.message | Should -Not -BeNullOrEmpty
}

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

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 8
$functions = dsc function list --output-format json | ConvertFrom-Json
$response.result.structuredContent.functions.Count | Should -Be $functions.Count

$mcpFunctions = $response.result.structuredContent.functions | Sort-Object name
$dscFunctions = $functions | Sort-Object name

for ($i = 0; $i -lt $dscFunctions.Count; $i++) {
($mcpFunctions[$i].psobject.properties | Measure-Object).Count | Should -BeGreaterOrEqual 8
$mcpFunctions[$i].name | Should -BeExactly $dscFunctions[$i].name -Because ($response.result.structuredContent | ConvertTo-Json -Depth 10 | Out-String)
$mcpFunctions[$i].category | Should -BeExactly $dscFunctions[$i].category -Because ($response.result.structuredContent | ConvertTo-Json -Depth 10 | Out-String)
$mcpFunctions[$i].description | Should -BeExactly $dscFunctions[$i].description -Because ($response.result.structuredContent | ConvertTo-Json -Depth 10 | Out-String)
}
}

It 'Calling list_dsc_functions with function_filter filter works' {
$mcpRequest = @{
jsonrpc = "2.0"
id = 9
method = "tools/call"
params = @{
name = "list_dsc_functions"
arguments = @{
function_filter = "array"
}
}
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 9
$response.result.structuredContent.functions.Count | Should -Be 1
$response.result.structuredContent.functions[0].name | Should -BeExactly "array"
$response.result.structuredContent.functions[0].category | Should -BeExactly "Array"
}

It 'Calling list_dsc_functions with wildcard pattern works' {
$mcpRequest = @{
jsonrpc = "2.0"
id = 10
method = "tools/call"
params = @{
name = "list_dsc_functions"
arguments = @{
function_filter = "*Array*"
}
}
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 10
$arrayFunctions = dsc function list --output-format json | ConvertFrom-Json -Depth 20 | Where-Object { $_.name -like "*Array*" }
$response.result.structuredContent.functions.Count | Should -Be $arrayFunctions.Count
foreach ($func in $response.result.structuredContent.functions) {
$func.name | Should -Match "Array" -Because "Function name should contain 'Array'"
}
}

# dont check for error as dsc function list returns empty list for invalid patterns
It 'Calling list_dsc_functions with invalid pattern returns empty result' {
$mcpRequest = @{
jsonrpc = "2.0"
id = 11
method = "tools/call"
params = @{
name = "list_dsc_functions"
arguments = @{
function_filter = "[invalid]"
}
}
}

$response = Send-McpRequest -request $mcpRequest
$response.id | Should -Be 11
$response.result.structuredContent.functions.Count | Should -Be 0
$response.result.structuredContent.functions | Should -BeNullOrEmpty
}
}
Loading