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
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