diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index e3af68d3f..5d109ca1d 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -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" diff --git a/dsc/src/mcp/list_dsc_functions.rs b/dsc/src/mcp/list_dsc_functions.rs new file mode 100644 index 000000000..52fe87d38 --- /dev/null +++ b/dsc/src/mcp/list_dsc_functions.rs @@ -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, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ListFunctionsRequest { + #[schemars(description = "Optional function name to filter the list. Supports wildcard patterns (*, ?)")] + pub function_filter: Option, +} + +#[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) -> Result, 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(®ex_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)) + } +} diff --git a/dsc/src/mcp/mcp_server.rs b/dsc/src/mcp/mcp_server.rs index 7073f980f..aeb1697be 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_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(), } } } diff --git a/dsc/src/mcp/mod.rs b/dsc/src/mcp/mod.rs index 11d75ab5b..1cb41ee48 100644 --- a/dsc/src/mcp/mod.rs +++ b/dsc/src/mcp/mod.rs @@ -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; diff --git a/dsc/tests/dsc_mcp.tests.ps1 b/dsc/tests/dsc_mcp.tests.ps1 index 4a643e4be..c72261469 100644 --- a/dsc/tests/dsc_mcp.tests.ps1 +++ b/dsc/tests/dsc_mcp.tests.ps1 @@ -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 @@ -70,6 +71,7 @@ Describe 'Tests for MCP server' { } $tools = @{ + 'list_dsc_functions' = $false 'list_dsc_resources' = $false 'show_dsc_resource' = $false } @@ -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 + } }