diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index 49e9b50dd..24e8e1906 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -34,6 +34,8 @@ getAll = "Get all instances of the resource" resource = "The name of the resource to invoke" functionAbout = "Operations on DSC functions" listFunctionAbout = "List or find functions" +functionCategory = "Category to filter functions on; specify multiple times to require all categories" +functionDescription = "Description to search for in the function description, accepts wildcards" version = "The version of the resource to invoke in semver format" serverAbout = "Use DSC as a server over JSON-RPC (useful as MCP server)" bicepAbout = "Use DSC as a Bicep server over gRPC" @@ -113,6 +115,7 @@ resourceNotFound = "Resource type '%{resource}' does not exist" [server.list_dsc_functions] invalidNamePattern = "Invalid function name pattern '%{pattern}'" +invalidDescriptionPattern = "Invalid function description pattern '%{pattern}'" [server.list_dsc_resources] resourceNotAdapter = "The resource '%{adapter}' is not a valid adapter" @@ -152,6 +155,7 @@ tableHeader_functionName = "Function" tableHeader_functionCategory = "Category" tableHeader_syntax = "Syntax" invalidFunctionFilter = "Invalid function filter" +invalidFunctionDescriptionFilter = "Invalid function description filter" maxInt = "maxInt" invalidManifest = "Error in manifest for" jsonArrayNotSupported = "JSON array output format is only supported for `--all'" diff --git a/dsc/src/args.rs b/dsc/src/args.rs index 4d4622287..3108c6317 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -4,6 +4,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use dsc_lib::dscresources::command_resource::TraceLevel; +use dsc_lib::functions::FunctionCategory; use dsc_lib::progress::ProgressFormat; use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use rust_i18n::t; @@ -193,6 +194,10 @@ pub enum FunctionSubCommand { List { /// Optional function name to filter the list function_name: Option, + #[clap(short = 'c', long = "category", help = t!("args.functionCategory").to_string())] + category: Vec, + #[clap(short, long, help = t!("args.functionDescription").to_string())] + description: Option, #[clap(short = 'o', long, help = t!("args.outputFormat").to_string())] output_format: Option, }, diff --git a/dsc/src/server/list_dsc_functions.rs b/dsc/src/server/list_dsc_functions.rs index 441cfc638..9da95ef88 100644 --- a/dsc/src/server/list_dsc_functions.rs +++ b/dsc/src/server/list_dsc_functions.rs @@ -2,13 +2,13 @@ // Licensed under the MIT License. use crate::server::mcp_server::McpServer; -use dsc_lib::functions::{FunctionDispatcher, FunctionDefinition}; +use dsc_lib::functions::{FunctionCategory, FunctionDefinition, FunctionDispatcher}; use dsc_lib::util::convert_wildcard_to_regex; -use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; +use regex::RegexBuilder; +use rmcp::{ErrorData as McpError, Json, handler::server::wrapper::Parameters, tool, tool_router}; use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use regex::RegexBuilder; use tokio::task; #[derive(Serialize, JsonSchema)] @@ -18,14 +18,24 @@ pub struct FunctionListResult { #[derive(Deserialize, JsonSchema)] pub struct ListFunctionsRequest { - #[schemars(description = "Optional function name to filter the list. Supports wildcard patterns (*, ?)")] + #[schemars( + description = "Optional function name to filter the list. Supports wildcard patterns (*, ?)" + )] pub function_filter: Option, + #[schemars( + description = "Optional function categories to filter the list. Returned functions must belong to every specified category." + )] + pub category_filter: Option>, + #[schemars( + description = "Optional function description to filter the list. Supports case-insensitive wildcard patterns (*, ?) and matches within descriptions." + )] + pub description_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", + description = "List available DSC functions to be used in expressions with optional filtering by name, category, and description", annotations( title = "Enumerate all available DSC functions on the local machine returning name, category, description, and metadata.", read_only_hint = true, @@ -34,7 +44,14 @@ impl McpServer { open_world_hint = true, ) )] - pub async fn list_dsc_functions(&self, Parameters(ListFunctionsRequest { function_filter }): Parameters) -> Result, McpError> { + pub async fn list_dsc_functions( + &self, + Parameters(ListFunctionsRequest { + function_filter, + category_filter, + description_filter, + }): Parameters, + ) -> Result, McpError> { let result = task::spawn_blocking(move || { let function_dispatcher = FunctionDispatcher::new(); let mut functions = function_dispatcher.list(); @@ -45,17 +62,50 @@ impl McpServer { let mut regex_builder = RegexBuilder::new(®ex_str); regex_builder.case_insensitive(true); - let regex = regex_builder.build() - .map_err(|_| McpError::invalid_params( - t!("server.list_dsc_functions.invalidNamePattern", pattern = name_pattern), - None - ))?; + let regex = regex_builder.build().map_err(|_| { + McpError::invalid_params( + t!( + "server.list_dsc_functions.invalidNamePattern", + pattern = name_pattern + ), + None, + ) + })?; functions.retain(|func| regex.is_match(&func.name)); } + if let Some(categories) = category_filter { + functions.retain(|func| { + categories + .iter() + .all(|category| func.category.contains(category)) + }); + } + + if let Some(description_pattern) = description_filter { + let regex_str = convert_wildcard_to_regex(&description_pattern); + let regex_str = ®ex_str[1..regex_str.len() - 1]; + let mut regex_builder = RegexBuilder::new(regex_str); + regex_builder.case_insensitive(true); + + let regex = regex_builder.build().map_err(|_| { + McpError::invalid_params( + t!( + "server.list_dsc_functions.invalidDescriptionPattern", + pattern = description_pattern + ), + None, + ) + })?; + + functions.retain(|func| regex.is_match(&func.description)); + } + Ok(FunctionListResult { functions }) - }).await.map_err(|e| McpError::internal_error(e.to_string(), None))??; + }) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))??; Ok(Json(result)) } diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 1c86d9ae5..180346e9a 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -28,7 +28,7 @@ use dsc_lib::{ }, dscresources::dscresource::{Capability, ImplementedAs, validate_json, validate_properties}, extensions::dscextension::Capability as ExtensionCapability, - functions::FunctionDispatcher, + functions::{FunctionCategory, FunctionDispatcher}, progress::ProgressFormat, util::convert_wildcard_to_regex, }; @@ -541,8 +541,8 @@ pub fn extension(subcommand: &ExtensionSubCommand, progress_format: ProgressForm pub fn function(subcommand: &FunctionSubCommand) { let functions = FunctionDispatcher::new(); match subcommand { - FunctionSubCommand::List { function_name, output_format } => { - list_functions(&functions, function_name.as_ref(), output_format.as_ref()); + FunctionSubCommand::List { function_name, category, description, output_format } => { + list_functions(&functions, function_name.as_ref(), category, description.as_ref(), output_format.as_ref()); }, } } @@ -689,7 +689,7 @@ fn list_extensions(dsc: &mut DscManager, extension_name: &TypeNameFilter, format } } -fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String>, output_format: Option<&ListOutputFormat>) { +fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String>, category: &[FunctionCategory], description: Option<&String>, output_format: Option<&ListOutputFormat>) { let write_table = should_write_table(output_format); let mut table = Table::new(&[ t!("subcommand.tableHeader_functionCategory").to_string().as_ref(), @@ -709,6 +709,19 @@ fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String> exit(EXIT_INVALID_ARGS); }; + let description_regex = description.map(|description| { + let regex_str = convert_wildcard_to_regex(description); + // strip the `^` and `$` anchors so the filter searches within the description text + let regex_str = ®ex_str[1..regex_str.len() - 1]; + let mut regex_builder = RegexBuilder::new(regex_str); + regex_builder.case_insensitive(true); + let Ok(regex) = regex_builder.build() else { + error!("{}: {}", t!("subcommand.invalidFunctionDescriptionFilter"), regex_str); + exit(EXIT_INVALID_ARGS); + }; + regex + }); + let mut functions_list = functions.list(); functions_list.sort(); for function in functions_list { @@ -716,6 +729,16 @@ fn list_functions(functions: &FunctionDispatcher, function_name: Option<&String> continue; } + // all specified categories must be present on the function (AND filter) + if !category.iter().all(|c| function.category.contains(c)) { + continue; + } + + if let Some(ref description_regex) = description_regex + && !description_regex.is_match(&function.description) { + continue; + } + if write_table { table.add_row(vec![ function.category.iter().map(std::string::ToString::to_string).collect::>().join(", "), diff --git a/dsc/tests/dsc_function_list.tests.ps1 b/dsc/tests/dsc_function_list.tests.ps1 index f3b8b9ae7..43bf1d7c1 100644 --- a/dsc/tests/dsc_function_list.tests.ps1 +++ b/dsc/tests/dsc_function_list.tests.ps1 @@ -42,4 +42,75 @@ Describe 'Tests for function list subcommand' { } $foundWideLine | Should -BeTrue } + + Context 'Category filter' { + It 'Should filter by a single category' { + $out = dsc function list --category resource | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + foreach ($function in $out) { + $function.category | Should -Contain 'resource' + } + $out.name | Should -Contain 'resourceId' + } + + It 'Should treat multiple categories as an AND filter' { + $out = dsc function list --category array --category object | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + foreach ($function in $out) { + $function.category | Should -Contain 'array' + $function.category | Should -Contain 'object' + } + $out.name | Should -Contain 'tryGet' + } + + It 'Should match category case-insensitively' { + $out = dsc function list --category Array | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + foreach ($function in $out) { + $function.category | Should -Contain 'array' + } + } + + It 'Should error on an invalid category' { + $out = dsc function list --category notARealCategory 2>&1 + $LASTEXITCODE | Should -Not -Be 0 + ($out | Out-String) | Should -Match 'Invalid function category' + } + } + + Context 'Description filter' { + It 'Should filter on description text' { + $out = dsc function list --description base64 | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -Not -BeNullOrEmpty + foreach ($function in $out) { + $function.description | Should -Match 'base64' + } + $out.name | Should -Contain 'base64' + $out.name | Should -Contain 'base64ToString' + } + + It 'Should support wildcards in the description filter' { + $out = dsc function list --description 'encodes*base64' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.name | Should -Contain 'base64' + } + + It 'Should return nothing when the description does not match' { + $out = dsc function list --description 'zzzNoSuchDescriptionzzz' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out | Should -BeNullOrEmpty + } + } + + Context 'Combined filters' { + It 'Should combine name, category, and description filters' { + $out = dsc function list 'try*' --category array --category object --description 'retrieve' | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.name | Should -BeExactly 'tryGet' + } + } } diff --git a/dsc/tests/dsc_server.tests.ps1 b/dsc/tests/dsc_server.tests.ps1 index a31242ba4..6583bd180 100644 --- a/dsc/tests/dsc_server.tests.ps1 +++ b/dsc/tests/dsc_server.tests.ps1 @@ -284,6 +284,71 @@ Describe 'Tests for DSC server' { } } + It 'Calling list_dsc_functions with category_filter works' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 11 + method = "tools/call" + params = @{ + name = "list_dsc_functions" + arguments = @{ + category_filter = @("string") + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 11 + $expectedFunctions = dsc function list --category String --output-format json | ConvertFrom-Json + $response.result.structuredContent.functions.Count | Should -Be $expectedFunctions.Count + foreach ($func in $response.result.structuredContent.functions) { + $func.category | Should -Contain "String" + } + } + + It 'Calling list_dsc_functions with description_filter works' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 12 + method = "tools/call" + params = @{ + name = "list_dsc_functions" + arguments = @{ + description_filter = "*base64*" + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 12 + $expectedFunctions = dsc function list --description "*base64*" --output-format json | ConvertFrom-Json + $response.result.structuredContent.functions.Count | Should -Be $expectedFunctions.Count + foreach ($func in $response.result.structuredContent.functions) { + $func.description | Should -Match "(?i)base64" + } + } + + It 'Calling list_dsc_functions combines all filters' { + $mcpRequest = @{ + jsonrpc = "2.0" + id = 13 + method = "tools/call" + params = @{ + name = "list_dsc_functions" + arguments = @{ + function_filter = "base64" + category_filter = @("string") + description_filter = "*base64*" + } + } + } + + $response = Send-McpRequest -request $mcpRequest + $response.id | Should -Be 13 + $response.result.structuredContent.functions.Count | Should -Be 1 + $response.result.structuredContent.functions[0].name | Should -BeExactly "base64" + } + # 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 = @{ diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index f5ad7c555..88f9218c2 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -284,6 +284,7 @@ noObjectArgs = "Function '%{name}' does not accept object arguments, accepted ty noStringArgs = "Function '%{name}' does not accept string arguments, accepted types are: %{accepted_args_string}" lambdaNotFound = "Function '%{name}' could not find lambda with ID '%{id}'" lambdaTooManyParams = "Function '%{name}' requires lambda with 1 or 2 parameters (element and optional index)" +invalidCategory = "Invalid function category '%{category}', valid categories are: %{valid_categories}" [functions.add] description = "Adds two or more numbers together" diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index 344c421e2..d6331a6e8 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -9,7 +9,7 @@ use crate::functions::user_function::invoke_user_function; use crate::schemas::dsc_repo::DscRepoSchema; use rust_i18n::t; use schemars::JsonSchema; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fmt::Display; @@ -392,7 +392,7 @@ pub struct FunctionDefinition { pub return_types: Vec, } -#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, JsonSchema, DscRepoSchema)] +#[derive(Clone, Debug, Deserialize, Ord, PartialOrd, Eq, PartialEq, Serialize, JsonSchema, DscRepoSchema)] #[serde(deny_unknown_fields, rename_all = "camelCase")] #[dsc_repo_schema(base_name = "category", folder_path = "definitions/functions/builtin")] pub enum FunctionCategory { @@ -428,3 +428,46 @@ impl Display for FunctionCategory { } } } + +impl FunctionCategory { + /// All defined function categories. + pub const ALL: [FunctionCategory; 12] = [ + FunctionCategory::Array, + FunctionCategory::Cidr, + FunctionCategory::Comparison, + FunctionCategory::Date, + FunctionCategory::Deployment, + FunctionCategory::Lambda, + FunctionCategory::Logical, + FunctionCategory::Numeric, + FunctionCategory::Object, + FunctionCategory::Resource, + FunctionCategory::String, + FunctionCategory::System, + ]; +} + +impl std::str::FromStr for FunctionCategory { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "array" => Ok(FunctionCategory::Array), + "cidr" => Ok(FunctionCategory::Cidr), + "comparison" => Ok(FunctionCategory::Comparison), + "date" => Ok(FunctionCategory::Date), + "deployment" => Ok(FunctionCategory::Deployment), + "lambda" => Ok(FunctionCategory::Lambda), + "logical" => Ok(FunctionCategory::Logical), + "numeric" => Ok(FunctionCategory::Numeric), + "object" => Ok(FunctionCategory::Object), + "resource" => Ok(FunctionCategory::Resource), + "string" => Ok(FunctionCategory::String), + "system" => Ok(FunctionCategory::System), + _ => { + let valid = Self::ALL.iter().map(std::string::ToString::to_string).collect::>().join(", "); + Err(t!("functions.invalidCategory", category = s, valid_categories = valid).to_string()) + }, + } + } +} diff --git a/lib/dsc-lib/src/util.rs b/lib/dsc-lib/src/util.rs index 9ced77575..c25a862b7 100644 --- a/lib/dsc-lib/src/util.rs +++ b/lib/dsc-lib/src/util.rs @@ -72,8 +72,15 @@ pub fn parse_input_to_json(value: &str) -> Result { /// A string that holds the regex pattern. #[must_use] pub fn convert_wildcard_to_regex(wildcard: &str) -> String { - let mut regex = wildcard.to_string().replace('.', "\\.").replace('?', ".").replace('*', ".*?"); - regex.insert(0, '^'); + let mut regex = String::with_capacity(wildcard.len() + 2); + regex.push('^'); + for c in wildcard.chars() { + match c { + '*' => regex.push_str(".*?"), + '?' => regex.push('.'), + _ => regex.push_str(®ex::escape(&c.to_string())), + } + } regex.push('$'); regex } @@ -95,6 +102,10 @@ mod tests { let wildcard = "r*"; let regex = convert_wildcard_to_regex(wildcard); assert_eq!(regex, "^r.*?$"); + + let wildcard = "a+b(c)[d]^e$f|g\\h"; + let regex = convert_wildcard_to_regex(wildcard); + assert_eq!(regex, r"^a\+b\(c\)\[d\]\^e\$f\|g\\h$"); } #[cfg(not(target_os = "windows"))]