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
2 changes: 1 addition & 1 deletion azure-functions-maven-plugin/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<groupId>com.microsoft.azure</groupId>
<artifactId>azure-functions-maven-plugin</artifactId>
<version>1.39.0</version>
<version>1.40.0</version>
<packaging>maven-plugin</packaging>
<name>Maven Plugin for Azure Functions</name>
<description>Maven Plugin for Azure Functions</description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.FIXED_DELAY_RETRY;
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.FUNCTION_NAME;
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.STORAGE_ACCOUNT;
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.SYSTEM_RETURN_BINDING_NAME;

@Slf4j
abstract class AzureFunctionPackagerBase {
private static final List<String> CUSTOM_BINDING_RESERVED_PROPERTIES = Arrays.asList("type", "name", "direction");
private static final String MULTI_RETRY_ANNOTATION = "Fixed delay retry and exponential backoff retry are not compatible, " +
"please use either of them for one trigger";

private static final String HTTP_OUTPUT_DEFAULT_NAME = "$return";
private static final Map<BindingEnum, List<String>> REQUIRED_ATTRIBUTE_MAP = new HashMap<>();

static {
Expand Down Expand Up @@ -96,7 +96,7 @@ private void processMethodAnnotations(final FunctionMethod method, final List<Bi
bindings.addAll(parseAnnotations(method.getAnnotations(), this::parseMethodAnnotation));

if (bindings.stream().anyMatch(b -> b.getBindingEnum() == BindingEnum.HttpTrigger) &&
bindings.stream().noneMatch(b -> b.getName().equalsIgnoreCase("$return"))) {
bindings.stream().noneMatch(b -> b.getName().equalsIgnoreCase(SYSTEM_RETURN_BINDING_NAME))) {
bindings.add(getHTTPOutBinding());
}
}
Expand All @@ -105,7 +105,7 @@ private void processMethodAnnotations(final FunctionMethod method, final List<Bi
private Binding parseMethodAnnotation(final FunctionAnnotation annotation) {
final Binding ret = parseParameterAnnotation(annotation);
if (ret != null) {
ret.setName("$return");
ret.setName(SYSTEM_RETURN_BINDING_NAME);
}
return ret;
}
Expand Down Expand Up @@ -194,7 +194,7 @@ private Binding createCustomBinding(Map<String, Object> map1, Map<String, Object

private Binding getHTTPOutBinding() {
final Binding result = new Binding(BindingEnum.HttpOutput);
result.setName(HTTP_OUTPUT_DEFAULT_NAME);
result.setName(SYSTEM_RETURN_BINDING_NAME);
return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ public class AzureFunctionsAnnotationConstants {
public static final String FUNCTION_NAME = "com.microsoft.azure.functions.annotation.FunctionName";
public static final String STORAGE_ACCOUNT = "com.microsoft.azure.functions.annotation.StorageAccount";
public static final String CUSTOM_BINDING = "com.microsoft.azure.functions.annotation.CustomBinding";
public static final String MCP_TOOL_TRIGGER = "com.microsoft.azure.functions.annotation.McpToolTrigger";
public static final String MCP_TOOL_PROPERTY = "com.microsoft.azure.functions.annotation.McpToolProperty";
public static final String FIXED_DELAY_RETRY = "com.microsoft.azure.functions.annotation.FixedDelayRetry";
public static final String EXPONENTIAL_BACKOFF_RETRY = "com.microsoft.azure.functions.annotation.ExponentialBackoffRetry";

// AuthorizationLevel
public static final String ANONYMOUS = "ANONYMOUS";
public static final String FUNCTION = "FUNCTION";
public static final String ADMIN = "ADMIN";

// System-reserved binding names (allowed to have duplicates)
public static final String SYSTEM_RETURN_BINDING_NAME = "$return";
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public class Binding {
//initialize required attributes, which will be saved to function.json even if it equals to its default value
requiredAttributeMap.put(BindingEnum.EventHubTrigger, Collections.singletonList("cardinality"));
requiredAttributeMap.put(BindingEnum.HttpTrigger, Collections.singletonList("authLevel"));
requiredAttributeMap.put(BindingEnum.McpToolProperty, Arrays.asList("isRequired", "description"));
requiredAttributeMap.put(BindingEnum.McpToolTrigger, Collections.singletonList("description"));
}

public Binding(BindingEnum bindingEnum) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public enum BindingEnum {
HttpOutput("http", Direction.OUT),
KafkaTrigger("kafkaTrigger", Direction.IN),
KafkaOutput("kafka", Direction.OUT),
McpToolTrigger("mcpToolTrigger", Direction.IN),
McpToolProperty("mcpToolProperty", Direction.IN),
QueueTrigger("queueTrigger", Direction.IN, true),
QueueOutput("queue", Direction.OUT, true),
SendGridOutput("sendGrid", Direction.OUT),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
import java.util.Locale;

import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.CUSTOM_BINDING;
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.SYSTEM_RETURN_BINDING_NAME;

@Deprecated
public class BindingFactory {
private static final String HTTP_OUTPUT_DEFAULT_NAME = "$return";

public static Binding getBinding(final Annotation annotation) {
final BindingEnum annotationEnum = Arrays.stream(BindingEnum.values())
Expand All @@ -35,7 +35,7 @@ public static Binding getUserDefinedBinding(final Annotation annotation) {

public static Binding getHTTPOutBinding() {
final Binding result = new Binding(BindingEnum.HttpOutput);
result.setName(HTTP_OUTPUT_DEFAULT_NAME);
result.setName(SYSTEM_RETURN_BINDING_NAME);
return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.FIXED_DELAY_RETRY;
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.FUNCTION_NAME;
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.STORAGE_ACCOUNT;
import static com.microsoft.azure.toolkit.lib.appservice.function.core.AzureFunctionsAnnotationConstants.SYSTEM_RETURN_BINDING_NAME;

@Slf4j
@Deprecated
Expand Down Expand Up @@ -104,8 +105,14 @@ public FunctionConfiguration generateConfiguration(final Method method) throws A

processMethodAnnotations(method, bindings);

// Process MCP annotations (McpToolTrigger and McpToolProperty)
McpAnnotationProcessor.processMcpAnnotations(bindings);

patchStorageBinding(method, bindings);

// Validate all bindings for duplicate names after all processing is complete
validateBindingNames(bindings);

config.setRetry(getRetryConfigurationFromMethod(method));
config.setEntryPoint(method.getDeclaringClass().getCanonicalName() + "." + method.getName());
return config;
Expand Down Expand Up @@ -152,7 +159,7 @@ protected void processMethodAnnotations(final Method method, final List<Binding>
bindings.addAll(parseAnnotations(method::getAnnotations, this::parseMethodAnnotation));

if (bindings.stream().anyMatch(b -> b.getBindingEnum() == BindingEnum.HttpTrigger) &&
bindings.stream().noneMatch(b -> b.getName().equalsIgnoreCase("$return"))) {
bindings.stream().noneMatch(b -> b.getName().equalsIgnoreCase(SYSTEM_RETURN_BINDING_NAME))) {
bindings.add(BindingFactory.getHTTPOutBinding());
}
}
Expand Down Expand Up @@ -180,7 +187,7 @@ protected Binding parseParameterAnnotation(final Annotation annotation) {
protected Binding parseMethodAnnotation(final Annotation annotation) {
final Binding ret = parseParameterAnnotation(annotation);
if (ret != null) {
ret.setName("$return");
ret.setName(SYSTEM_RETURN_BINDING_NAME);
}
return ret;
}
Expand All @@ -200,4 +207,39 @@ protected void patchStorageBinding(final Method method, final List<Binding> bind
log.debug("No StorageAccount annotation found.");
}
}

/**
* Validates that all bindings in a function have unique names, excluding system-reserved names.
* This validation applies to user-defined binding names to prevent conflicts.
* System names (defined in AzureFunctionsAnnotationConstants) are allowed to have duplicates.
*
* @param bindings the list of bindings to validate
* @throws AzureExecutionException if duplicate user-defined binding names are found
*/
protected void validateBindingNames(final List<Binding> bindings) throws AzureExecutionException {
final Set<String> seenNames = new HashSet<>();

for (final Binding binding : bindings) {
final String bindingName = binding.getName();
// Skip validation for system-reserved names and null/empty names
if (bindingName != null && !bindingName.isEmpty() && !isSystemReservedName(bindingName)) {
if (!seenNames.add(bindingName)) {
throw new AzureExecutionException(
String.format("Duplicate binding name found: '%s'. All bindings within a function must have unique names.",
bindingName));
}
}
}
}

/**
* Checks if a binding name is reserved by the system and allowed to have duplicates.
*
* @param bindingName the name to check
* @return true if the name is system-reserved
*/
private boolean isSystemReservedName(final String bindingName) {
// System-reserved names that are allowed to have duplicates
return SYSTEM_RETURN_BINDING_NAME.equals(bindingName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*/

package com.microsoft.azure.toolkit.lib.legacy.function.handlers;

import com.microsoft.azure.toolkit.lib.common.utils.JsonUtils;
import com.microsoft.azure.toolkit.lib.legacy.function.bindings.Binding;
import com.microsoft.azure.toolkit.lib.legacy.function.bindings.BindingEnum;
import org.apache.commons.lang3.StringUtils;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* Processor for handling MCP (Model Context Protocol) annotations in Azure Functions.
* This class is responsible for processing McpToolTrigger and McpToolProperty annotations
* and generating the appropriate binding configurations for function.json.
*
* McpToolTrigger annotations define tool invocation triggers with a toolName.
* McpToolProperty annotations define tool properties that are aggregated into toolProperties JSON.
*/
public class McpAnnotationProcessor {

/**
* Private constructor to prevent instantiation of utility class.
*/
private McpAnnotationProcessor() {
// Utility class - no instances allowed
}

/**
* Processes all MCP-related annotations and updates the bindings accordingly.
* This performs patching of individual bindings and generation of toolProperties in a single pass
* for optimal performance.
*
* Note: Duplicate name validation is handled at the function level by AnnotationHandlerImpl.
*
* Assumes each method has at most one McpToolTrigger that receives all McpToolProperty data.
*
* @param bindings the list of bindings to update
*/
public static void processMcpAnnotations(final List<Binding> bindings) {
if (bindings == null || bindings.isEmpty()) {
return;
}

final List<Map<String, Object>> allProperties = new ArrayList<>();
final List<Binding> mcpTriggers = new ArrayList<>();

// Single pass: Process all bindings and categorize them
for (final Binding binding : bindings) {
final BindingEnum bindingType = binding.getBindingEnum();

if (bindingType == BindingEnum.McpToolProperty) {
processPropertyBinding(binding, allProperties);
} else if (bindingType == BindingEnum.McpToolTrigger) {
patchMcpToolTrigger(binding);
mcpTriggers.add(binding);
}
}

// Apply toolProperties to all triggers (only generate JSON once if needed)
if (!allProperties.isEmpty() && !mcpTriggers.isEmpty()) {
final String toolPropertiesJson = JsonUtils.toJson(allProperties);
for (final Binding trigger : mcpTriggers) {
trigger.setAttribute("toolProperties", toolPropertiesJson);
}
}
}

/**
* Extracts the 'name' attribute from an McpToolTrigger binding and sets it as 'toolName'
* on the binding for function.json generation.
*
* @param binding the binding to update
*/
private static void patchMcpToolTrigger(final Binding binding) {
final String name = (String) binding.getAttribute("name");
if (StringUtils.isNotEmpty(name)) {
binding.setAttribute("toolName", name);
}
}

/**
* Extracts the 'name' attribute from an McpToolProperty binding and sets it as 'propertyName'
* on the binding. Note: Does NOT set 'toolName' on property bindings.
*
* @param binding the binding to update
*/
private static void patchMcpToolProperty(final Binding binding) {
final String name = (String) binding.getAttribute("name");
if (StringUtils.isNotEmpty(name)) {
binding.setAttribute("propertyName", name);
}
}

/**
* Processes a single McpToolProperty binding: patches it and adds its attributes
* to the properties collection.
*
* @param binding the property binding to process
* @param allProperties the collection to add processed attributes to
*/
private static void processPropertyBinding(final Binding binding,
final List<Map<String, Object>> allProperties) {
patchMcpToolProperty(binding);

// Create filtered attributes map (excluding 'name' for toolProperties)
final Map<String, Object> propertyAttributes = createFilteredAttributesMap(binding);
allProperties.add(propertyAttributes);
}

/**
* Creates a filtered map of binding attributes, excluding the 'name' attribute.
*
* @param binding the binding to extract attributes from
* @return a new map with all attributes except 'name'
*/
private static Map<String, Object> createFilteredAttributesMap(final Binding binding) {
final Map<String, Object> propertyAttributes = new HashMap<>();
final Map<String, Object> bindingAttributes = binding.getBindingAttributes();

for (final Map.Entry<String, Object> entry : bindingAttributes.entrySet()) {
if (!"name".equals(entry.getKey())) {
propertyAttributes.put(entry.getKey(), entry.getValue());
}
}

return propertyAttributes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,30 @@
]
}
]
},
{
"type": "mcpToolTrigger",
"displayName": "$mcp_tool_trigger_displayName",
"direction": "trigger",
"enabledInTryMode": true,
"settings": [
{
"name": "toolName",
"value": "string",
"defaultValue": "MyMcpTool",
"required": true,
"label": "$mcp_tool_trigger_toolName_label",
"help": "$mcp_tool_trigger_toolName_help"
},
{
"name": "toolDescription",
"value": "string",
"defaultValue": "A simple MCP Tool that logs the message that is provided.",
"required": true,
"label": "$mcp_tool_trigger_toolDescription_label",
"help": "$mcp_tool_trigger_toolDescription_help"
}
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@
"textCompletion_topP_help": "The top_p probability mass to consider.",
"textCompletion_name_label": "Trigger binding name",
"textCompletion_name_help": "Name used to identify trigger in code",
"textCompletion_name_errorText": "The name must start with a letter, and it can contain only letters and numbers. The name must be 1 to 127 characters."
"textCompletion_name_errorText": "The name must start with a letter, and it can contain only letters and numbers. The name must be 1 to 127 characters.",

"McpToolTrigger_description": "A function that creates an MCP Tool exposed through a MCP Server managed by the platform and triggers when an MCP Client calls the tool.",
"mcp_tool_trigger_displayName": "MCP Tool Trigger",
"mcp_tool_trigger_toolName_label": "MCP Tool Name",
"mcp_tool_trigger_toolName_help": "The name of the MCP Tool.",
"mcp_tool_trigger_toolDescription_label": "MCP Tool Description",
"mcp_tool_trigger_toolDescription_help": "A description of the MCP Tool's functionality."
}
}
Loading
Loading