Skip to content
Open
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
4 changes: 4 additions & 0 deletions dsc/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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'"
Expand Down
5 changes: 5 additions & 0 deletions dsc/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -193,6 +194,10 @@ pub enum FunctionSubCommand {
List {
/// Optional function name to filter the list
function_name: Option<String>,
#[clap(short = 'c', long = "category", help = t!("args.functionCategory").to_string())]
category: Vec<FunctionCategory>,
#[clap(short, long, help = t!("args.functionDescription").to_string())]
description: Option<String>,
#[clap(short = 'o', long, help = t!("args.outputFormat").to_string())]
output_format: Option<ListOutputFormat>,
},
Expand Down
74 changes: 62 additions & 12 deletions dsc/src/server/list_dsc_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<String>,
#[schemars(
description = "Optional function categories to filter the list. Returned functions must belong to every specified category."
)]
pub category_filter: Option<Vec<FunctionCategory>>,
#[schemars(
description = "Optional function description to filter the list. Supports case-insensitive wildcard patterns (*, ?) and matches within descriptions."
)]
pub description_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",
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,
Expand All @@ -34,7 +44,14 @@ impl McpServer {
open_world_hint = true,
)
)]
pub async fn list_dsc_functions(&self, Parameters(ListFunctionsRequest { function_filter }): Parameters<ListFunctionsRequest>) -> Result<Json<FunctionListResult>, McpError> {
pub async fn list_dsc_functions(
&self,
Parameters(ListFunctionsRequest {
function_filter,
category_filter,
description_filter,
}): Parameters<ListFunctionsRequest>,
) -> Result<Json<FunctionListResult>, McpError> {
let result = task::spawn_blocking(move || {
let function_dispatcher = FunctionDispatcher::new();
let mut functions = function_dispatcher.list();
Expand All @@ -45,17 +62,50 @@ impl McpServer {
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.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 = &regex_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))
}
Expand Down
31 changes: 27 additions & 4 deletions dsc/src/subcommand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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());
},
}
}
Expand Down Expand Up @@ -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(),
Expand All @@ -709,13 +709,36 @@ 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 = &regex_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 {
if !regex.is_match(&function.name) {
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::<Vec<String>>().join(", "),
Expand Down
71 changes: 71 additions & 0 deletions dsc/tests/dsc_function_list.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
}
65 changes: 65 additions & 0 deletions dsc/tests/dsc_server.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = @{
Expand Down
1 change: 1 addition & 0 deletions lib/dsc-lib/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading