From bc050a1e376dfb358ac4df2bb8a0f602c43fcf97 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 16 Oct 2025 14:31:53 -0500 Subject: [PATCH 01/13] simplified mcp decorator --- azure/functions/__init__.py | 5 +- azure/functions/decorators/__init__.py | 5 +- azure/functions/decorators/function_app.py | 75 +++++++++++++++++++++- azure/functions/decorators/mcp.py | 23 +++++++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index f5a4dc19..b24c2571 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -10,7 +10,7 @@ DecoratorApi, DataType, AuthLevel, Cardinality, AccessRights, HttpMethod, AsgiFunctionApp, WsgiFunctionApp, - ExternalHttpFunctionApp, BlobSource) + ExternalHttpFunctionApp, BlobSource, MCPToolContext) from ._durable_functions import OrchestrationContext, EntityContext from .decorators.function_app import (FunctionRegister, TriggerApi, BindingApi, SettingsApi) @@ -99,7 +99,8 @@ 'Cardinality', 'AccessRights', 'HttpMethod', - 'BlobSource' + 'BlobSource', + 'MCPToolContext' ) __version__ = '1.25.0b1' diff --git a/azure/functions/decorators/__init__.py b/azure/functions/decorators/__init__.py index be7ff99f..26b637b1 100644 --- a/azure/functions/decorators/__init__.py +++ b/azure/functions/decorators/__init__.py @@ -4,7 +4,7 @@ from .function_app import FunctionApp, Function, DecoratorApi, DataType, \ AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \ WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \ - SettingsApi, BlobSource + SettingsApi, BlobSource, MCPToolContext from .http import HttpMethod __all__ = [ @@ -24,5 +24,6 @@ 'Cardinality', 'AccessRights', 'HttpMethod', - 'BlobSource' + 'BlobSource', + 'MCPToolContext' ] diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 20961621..1c78d2d1 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import abc import asyncio +import inspect import json import logging from abc import ABC @@ -42,7 +43,7 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger +from .mcp import MCPToolTrigger, MCPToolContext, _TYPE_MAPPING, _extract_type_and_description from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger @@ -462,6 +463,78 @@ def auth_level(self) -> AuthLevel: class TriggerApi(DecoratorApi, ABC): """Interface to extend for using existing trigger decorator functions.""" + def mcp_tool(self) -> Callable[[Callable], Callable]: + """ + Decorator to register an MCP tool function. + + Automatically: + - Infers tool name from function name + - Extracts first line of docstring as description + - Extracts parameters and types for tool properties + - Handles MCPToolContext injection + """ + def decorator(target_func: Callable) -> Callable: + sig = inspect.signature(target_func) + tool_name = target_func.__name__ + description = (target_func.__doc__ or "").strip().split("\n")[0] + + # Build tool properties metadata + tool_properties = [] + for param_name, param in sig.parameters.items(): + param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str + actual_type, param_description = _extract_type_and_description(param_name, param_type_hint) + if actual_type is MCPToolContext: + continue + property_type = _TYPE_MAPPING.get(actual_type, "string") + tool_properties.append({ + "propertyName": param_name, + "propertyType": property_type, + "description": param_description, + }) + + tool_properties_json = json.dumps(tool_properties) + + # Wrapper function for MCP trigger + def wrapper(context: str) -> str: + try: + content = json.loads(context) + arguments = content.get("arguments", {}) + kwargs = {} + + for param_name, param in sig.parameters.items(): + param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str + actual_type, _ = _extract_type_and_description(param_name, param_type_hint) + + if actual_type is MCPToolContext: + kwargs[param_name] = content + elif param_name in arguments: + kwargs[param_name] = arguments[param_name] + else: + return f"Error: Missing required parameter '{param_name}' for '{tool_name}'" + + result = target_func(**kwargs) + return str(result) + + except Exception as e: + return f"Error executing function '{tool_name}': {str(e)}" + + wrapper.__name__ = target_func.__name__ + wrapper.__doc__ = target_func.__doc__ + + # Use the existing FunctionRegister mechanism to add the trigger + fb = self._configure_function_builder(lambda fb: fb)(wrapper) + fb.add_trigger( + trigger=MCPToolTrigger( + name="context", + tool_name=tool_name, + description=description, + tool_properties=tool_properties_json + ) + ) + + return fb + + return decorator def route(self, route: Optional[str] = None, diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 7657975d..c4d7d06f 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -1,10 +1,18 @@ from typing import Optional +from typing import Any, Dict, Tuple, get_args, get_origin, Annotated from azure.functions.decorators.constants import ( MCP_TOOL_TRIGGER ) from azure.functions.decorators.core import Trigger, DataType +# Mapping Python types to MCP property types +_TYPE_MAPPING = { + int: "integer", + float: "number", + str: "string", + bool: "boolean", +} class MCPToolTrigger(Trigger): @@ -23,3 +31,18 @@ def __init__(self, self.description = description self.tool_properties = tool_properties super().__init__(name=name, data_type=data_type) + +# MCP-specific context object +class MCPToolContext(Dict[str, Any]): + """Injected context object for MCP tool triggers.""" + pass + +# Helper to extract actual type and description from Annotated types +def _extract_type_and_description(param_name: str, type_hint: Any) -> Tuple[Any, str]: + if get_origin(type_hint) is Annotated: + args = get_args(type_hint) + actual_type = args[0] + # Use first string annotation as description if present + param_description = next((a for a in args[1:] if isinstance(a, str)), f"The {param_name} parameter.") + return actual_type, param_description + return type_hint, f"The {param_name} parameter." From 82212b6044f536103432e4a64724da28b28df12f Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 17 Oct 2025 15:10:51 -0500 Subject: [PATCH 02/13] misc testing --- azure/functions/decorators/function_app.py | 5 ++-- azure/functions/decorators/mcp.py | 27 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 1c78d2d1..481b5a04 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -43,7 +43,7 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger, MCPToolContext, _TYPE_MAPPING, _extract_type_and_description +from .mcp import MCPToolTrigger, MCPToolContext, _TYPE_MAPPING, _extract_type_and_description, _get_user_function from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger @@ -473,7 +473,8 @@ def mcp_tool(self) -> Callable[[Callable], Callable]: - Extracts parameters and types for tool properties - Handles MCPToolContext injection """ - def decorator(target_func: Callable) -> Callable: + def decorator(user_func: Callable) -> Callable: + target_func = _get_user_function(user_func) sig = inspect.signature(target_func) tool_name = target_func.__name__ description = (target_func.__doc__ or "").strip().split("\n")[0] diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index c4d7d06f..6b66cdb4 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -1,10 +1,12 @@ from typing import Optional from typing import Any, Dict, Tuple, get_args, get_origin, Annotated +import logging from azure.functions.decorators.constants import ( MCP_TOOL_TRIGGER ) from azure.functions.decorators.core import Trigger, DataType +from azure.functions.decorators.function_app import FunctionBuilder # Mapping Python types to MCP property types _TYPE_MAPPING = { @@ -46,3 +48,28 @@ def _extract_type_and_description(param_name: str, type_hint: Any) -> Tuple[Any, param_description = next((a for a in args[1:] if isinstance(a, str)), f"The {param_name} parameter.") return actual_type, param_description return type_hint, f"The {param_name} parameter." + +def _get_user_function(target_func): + """ + Unwraps decorated or builder-wrapped functions to find the original + user-defined function (the one starting with 'def' or 'async def'). + """ + logging.info("HELLO FROM THE SDK") + # Case 1: It's a FunctionBuilder object + if isinstance(target_func, FunctionBuilder): + # Access the internal user function + try: + return target_func._function.get_user_function() + except AttributeError: + pass + + # Case 2: It's already the user-defined function + if callable(target_func) and hasattr(target_func, "__name__"): + return target_func + + # Case 3: It might be a partially wrapped callable + if hasattr(target_func, "__wrapped__"): + return _get_user_function(target_func.__wrapped__) + + # Default fallback + return target_func From 3ba01e8886211f495f469f686f12155ed5d07bbd Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 20 Oct 2025 16:51:09 -0500 Subject: [PATCH 03/13] works with bindings - no plain context yet --- azure/functions/decorators/function_app.py | 126 ++++++++++++++------- 1 file changed, 85 insertions(+), 41 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 481b5a04..47e3ce3c 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -43,7 +43,7 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger, MCPToolContext, _TYPE_MAPPING, _extract_type_and_description, _get_user_function +from .mcp import MCPToolTrigger, MCPToolContext, _TYPE_MAPPING, _extract_type_and_description from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger @@ -52,6 +52,7 @@ from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \ MySqlTrigger +_logger = logging.getLogger('azure.functions.AsgiMiddleware') class Function(object): """ @@ -273,6 +274,7 @@ def _validate_function(self, trigger = self._function.get_trigger() if trigger is None: raise ValueError( + f"This is the function: {self._function}" f"Function {function_name} does not have a trigger. A valid " f"function must have one and only one trigger registered.") @@ -463,8 +465,7 @@ def auth_level(self) -> AuthLevel: class TriggerApi(DecoratorApi, ABC): """Interface to extend for using existing trigger decorator functions.""" - def mcp_tool(self) -> Callable[[Callable], Callable]: - """ + """ Decorator to register an MCP tool function. Automatically: @@ -473,70 +474,86 @@ def mcp_tool(self) -> Callable[[Callable], Callable]: - Extracts parameters and types for tool properties - Handles MCPToolContext injection """ - def decorator(user_func: Callable) -> Callable: - target_func = _get_user_function(user_func) + def mcp_tool(self): + @self._configure_function_builder + def decorator(fb: FunctionBuilder) -> FunctionBuilder: + target_func = fb._function.get_user_function() sig = inspect.signature(target_func) tool_name = target_func.__name__ description = (target_func.__doc__ or "").strip().split("\n")[0] - # Build tool properties metadata + bound_param_names = {b.name for b in getattr(fb._function, "_bindings", [])} + skip_param_names = bound_param_names + _logger.info("Bound param names for %s: %s", tool_name, skip_param_names) + + # Build tool properties tool_properties = [] for param_name, param in sig.parameters.items(): + if param_name in skip_param_names: + continue param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str - actual_type, param_description = _extract_type_and_description(param_name, param_type_hint) + actual_type, param_desc = _extract_type_and_description(param_name, param_type_hint) if actual_type is MCPToolContext: continue property_type = _TYPE_MAPPING.get(actual_type, "string") tool_properties.append({ "propertyName": param_name, "propertyType": property_type, - "description": param_description, + "description": param_desc, }) - tool_properties_json = json.dumps(tool_properties) - - # Wrapper function for MCP trigger - def wrapper(context: str) -> str: - try: - content = json.loads(context) - arguments = content.get("arguments", {}) - kwargs = {} - - for param_name, param in sig.parameters.items(): - param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str - actual_type, _ = _extract_type_and_description(param_name, param_type_hint) - - if actual_type is MCPToolContext: - kwargs[param_name] = content - elif param_name in arguments: - kwargs[param_name] = arguments[param_name] - else: - return f"Error: Missing required parameter '{param_name}' for '{tool_name}'" - - result = target_func(**kwargs) - return str(result) - - except Exception as e: - return f"Error executing function '{tool_name}': {str(e)}" - - wrapper.__name__ = target_func.__name__ - wrapper.__doc__ = target_func.__doc__ - - # Use the existing FunctionRegister mechanism to add the trigger - fb = self._configure_function_builder(lambda fb: fb)(wrapper) + tool_properties_json = json.dumps(tool_properties)\ + + bound_params = [ + inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for name in bound_param_names + ] + wrapper_sig = inspect.Signature([ + *bound_params, + inspect.Parameter("context", inspect.Parameter.POSITIONAL_OR_KEYWORD) + ]) + + # Wrap the original function + import functools + @functools.wraps(target_func) + async def wrapper(context: str, *args, **kwargs): + _logger.info(f"Invoking MCP tool function '{tool_name}' with context: {context}") + content = json.loads(context) + arguments = content.get("arguments", {}) + call_kwargs = {} + for param_name, param in sig.parameters.items(): + param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str + actual_type, _ = _extract_type_and_description(param_name, param_type_hint) + if actual_type is MCPToolContext: + call_kwargs[param_name] = content + elif param_name in arguments: + call_kwargs[param_name] = arguments[param_name] + call_kwargs.update(kwargs) + result = target_func(**call_kwargs) + if asyncio.iscoroutine(result): + result = await result + return str(result) + + wrapper.__signature__ = wrapper_sig + fb._function._func = wrapper + _logger.info(f"Registered MCP tool function '{tool_name}' with description: {description} and properties: {tool_properties_json}") + + # Add the MCP trigger fb.add_trigger( trigger=MCPToolTrigger( name="context", tool_name=tool_name, description=description, - tool_properties=tool_properties_json + tool_properties=tool_properties_json, ) ) - return fb return decorator + + + def route(self, route: Optional[str] = None, trigger_arg_name: str = 'req', @@ -3971,6 +3988,9 @@ def get_functions(self) -> List[Function]: :return: A list of :class:`Function` objects defined in the app. """ + for function_builder in self._function_builders: + _logger.info("Function builder functions: %s", + function_builder._function) functions = [function_builder.build(self.auth_level) for function_builder in self._function_builders] @@ -4193,3 +4213,27 @@ def _add_http_app(self, route="/{*route}") def http_app_func(req: HttpRequest, context: Context): return wsgi_middleware.handle(req, context) + +def _get_user_function(target_func): + """ + Unwraps decorated or builder-wrapped functions to find the original + user-defined function (the one starting with 'def' or 'async def'). + """ + # Case 1: It's a FunctionBuilder object + if isinstance(target_func, FunctionBuilder): + # Access the internal user function + try: + return target_func._function.get_user_function() + except AttributeError: + pass + + # Case 2: It's already the user-defined function + if callable(target_func) and hasattr(target_func, "__name__"): + return target_func + + # Case 3: It might be a partially wrapped callable + if hasattr(target_func, "__wrapped__"): + return _get_user_function(target_func.__wrapped__) + + # Default fallback + return target_func \ No newline at end of file From 4b95af5c4193bcbf782919429a55516e1319f53d Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 21 Oct 2025 10:43:57 -0500 Subject: [PATCH 04/13] create MCPToolContext class --- azure/functions/__init__.py | 4 ++- azure/functions/decorators/__init__.py | 5 ++-- azure/functions/decorators/function_app.py | 5 ++-- azure/functions/decorators/mcp.py | 30 ---------------------- azure/functions/mcp.py | 8 +++++- 5 files changed, 15 insertions(+), 37 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index b24c2571..e8adeb82 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -10,7 +10,7 @@ DecoratorApi, DataType, AuthLevel, Cardinality, AccessRights, HttpMethod, AsgiFunctionApp, WsgiFunctionApp, - ExternalHttpFunctionApp, BlobSource, MCPToolContext) + ExternalHttpFunctionApp, BlobSource) from ._durable_functions import OrchestrationContext, EntityContext from .decorators.function_app import (FunctionRegister, TriggerApi, BindingApi, SettingsApi) @@ -19,6 +19,7 @@ from ._http_wsgi import WsgiMiddleware from ._http_asgi import AsgiMiddleware from .kafka import KafkaEvent, KafkaConverter, KafkaTriggerConverter +from .mcp import MCPToolContext from .meta import get_binding_registry from ._queue import QueueMessage from ._servicebus import ServiceBusMessage @@ -32,6 +33,7 @@ from . import eventhub # NoQA from . import http # NoQA from . import kafka # NoQA +from . import mcp # NoQA from . import queue # NoQA from . import servicebus # NoQA from . import timer # NoQA diff --git a/azure/functions/decorators/__init__.py b/azure/functions/decorators/__init__.py index 26b637b1..be7ff99f 100644 --- a/azure/functions/decorators/__init__.py +++ b/azure/functions/decorators/__init__.py @@ -4,7 +4,7 @@ from .function_app import FunctionApp, Function, DecoratorApi, DataType, \ AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \ WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \ - SettingsApi, BlobSource, MCPToolContext + SettingsApi, BlobSource from .http import HttpMethod __all__ = [ @@ -24,6 +24,5 @@ 'Cardinality', 'AccessRights', 'HttpMethod', - 'BlobSource', - 'MCPToolContext' + 'BlobSource' ] diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 47e3ce3c..50c5fdd6 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -43,10 +43,11 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger, MCPToolContext, _TYPE_MAPPING, _extract_type_and_description +from .mcp import MCPToolTrigger, _TYPE_MAPPING, _extract_type_and_description from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger +from ..mcp import MCPToolContext from .._http_asgi import AsgiMiddleware from .._http_wsgi import WsgiMiddleware, Context from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \ @@ -502,7 +503,7 @@ def decorator(fb: FunctionBuilder) -> FunctionBuilder: "description": param_desc, }) - tool_properties_json = json.dumps(tool_properties)\ + tool_properties_json = json.dumps(tool_properties) bound_params = [ inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 6b66cdb4..08cbe2d4 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -6,7 +6,6 @@ MCP_TOOL_TRIGGER ) from azure.functions.decorators.core import Trigger, DataType -from azure.functions.decorators.function_app import FunctionBuilder # Mapping Python types to MCP property types _TYPE_MAPPING = { @@ -34,10 +33,6 @@ def __init__(self, self.tool_properties = tool_properties super().__init__(name=name, data_type=data_type) -# MCP-specific context object -class MCPToolContext(Dict[str, Any]): - """Injected context object for MCP tool triggers.""" - pass # Helper to extract actual type and description from Annotated types def _extract_type_and_description(param_name: str, type_hint: Any) -> Tuple[Any, str]: @@ -48,28 +43,3 @@ def _extract_type_and_description(param_name: str, type_hint: Any) -> Tuple[Any, param_description = next((a for a in args[1:] if isinstance(a, str)), f"The {param_name} parameter.") return actual_type, param_description return type_hint, f"The {param_name} parameter." - -def _get_user_function(target_func): - """ - Unwraps decorated or builder-wrapped functions to find the original - user-defined function (the one starting with 'def' or 'async def'). - """ - logging.info("HELLO FROM THE SDK") - # Case 1: It's a FunctionBuilder object - if isinstance(target_func, FunctionBuilder): - # Access the internal user function - try: - return target_func._function.get_user_function() - except AttributeError: - pass - - # Case 2: It's already the user-defined function - if callable(target_func) and hasattr(target_func, "__name__"): - return target_func - - # Case 3: It might be a partially wrapped callable - if hasattr(target_func, "__wrapped__"): - return _get_user_function(target_func.__wrapped__) - - # Default fallback - return target_func diff --git a/azure/functions/mcp.py b/azure/functions/mcp.py index 839d94ef..98ca25f3 100644 --- a/azure/functions/mcp.py +++ b/azure/functions/mcp.py @@ -3,12 +3,18 @@ from . import meta +# MCP-specific context object +class MCPToolContext(typing.Dict[str, typing.Any]): + """Injected context object for MCP tool triggers.""" + pass + + class MCPToolTriggerConverter(meta.InConverter, binding='mcpToolTrigger', trigger=True): @classmethod def check_input_type_annotation(cls, pytype: type) -> bool: - return issubclass(pytype, (str, dict, bytes)) + return issubclass(pytype, (str, dict, bytes, MCPToolContext)) @classmethod def has_implicit_output(cls) -> bool: From 8476f186606e509e0b58e3264036a9f5302ef15e Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 21 Oct 2025 10:53:02 -0500 Subject: [PATCH 05/13] lint --- azure/functions/decorators/function_app.py | 183 ++++++++++----------- azure/functions/decorators/mcp.py | 6 +- azure/functions/mcp.py | 1 + 3 files changed, 94 insertions(+), 96 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 50c5fdd6..333bcba3 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import abc import asyncio +import functools import inspect import json import logging @@ -53,7 +54,6 @@ from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \ MySqlTrigger -_logger = logging.getLogger('azure.functions.AsgiMiddleware') class Function(object): """ @@ -466,94 +466,6 @@ def auth_level(self) -> AuthLevel: class TriggerApi(DecoratorApi, ABC): """Interface to extend for using existing trigger decorator functions.""" - """ - Decorator to register an MCP tool function. - - Automatically: - - Infers tool name from function name - - Extracts first line of docstring as description - - Extracts parameters and types for tool properties - - Handles MCPToolContext injection - """ - def mcp_tool(self): - @self._configure_function_builder - def decorator(fb: FunctionBuilder) -> FunctionBuilder: - target_func = fb._function.get_user_function() - sig = inspect.signature(target_func) - tool_name = target_func.__name__ - description = (target_func.__doc__ or "").strip().split("\n")[0] - - bound_param_names = {b.name for b in getattr(fb._function, "_bindings", [])} - skip_param_names = bound_param_names - _logger.info("Bound param names for %s: %s", tool_name, skip_param_names) - - # Build tool properties - tool_properties = [] - for param_name, param in sig.parameters.items(): - if param_name in skip_param_names: - continue - param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str - actual_type, param_desc = _extract_type_and_description(param_name, param_type_hint) - if actual_type is MCPToolContext: - continue - property_type = _TYPE_MAPPING.get(actual_type, "string") - tool_properties.append({ - "propertyName": param_name, - "propertyType": property_type, - "description": param_desc, - }) - - tool_properties_json = json.dumps(tool_properties) - - bound_params = [ - inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) - for name in bound_param_names - ] - wrapper_sig = inspect.Signature([ - *bound_params, - inspect.Parameter("context", inspect.Parameter.POSITIONAL_OR_KEYWORD) - ]) - - # Wrap the original function - import functools - @functools.wraps(target_func) - async def wrapper(context: str, *args, **kwargs): - _logger.info(f"Invoking MCP tool function '{tool_name}' with context: {context}") - content = json.loads(context) - arguments = content.get("arguments", {}) - call_kwargs = {} - for param_name, param in sig.parameters.items(): - param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str - actual_type, _ = _extract_type_and_description(param_name, param_type_hint) - if actual_type is MCPToolContext: - call_kwargs[param_name] = content - elif param_name in arguments: - call_kwargs[param_name] = arguments[param_name] - call_kwargs.update(kwargs) - result = target_func(**call_kwargs) - if asyncio.iscoroutine(result): - result = await result - return str(result) - - wrapper.__signature__ = wrapper_sig - fb._function._func = wrapper - _logger.info(f"Registered MCP tool function '{tool_name}' with description: {description} and properties: {tool_properties_json}") - - # Add the MCP trigger - fb.add_trigger( - trigger=MCPToolTrigger( - name="context", - tool_name=tool_name, - description=description, - tool_properties=tool_properties_json, - ) - ) - return fb - - return decorator - - - def route(self, route: Optional[str] = None, @@ -1655,6 +1567,93 @@ def decorator(): return wrap + def mcp_tool(self): + """ + Decorator to register an MCP tool function. + + Automatically: + - Infers tool name from function name + - Extracts first line of docstring as description + - Extracts parameters and types for tool properties + - Handles MCPToolContext injection + """ + @self._configure_function_builder + def decorator(fb: FunctionBuilder) -> FunctionBuilder: + target_func = fb._function.get_user_function() + sig = inspect.signature(target_func) + # Parse tool name and description from function signature + tool_name = target_func.__name__ + description = (target_func.__doc__ or "").strip().split("\n")[0] + + # Identify arguments that are already bound (bindings) + bound_param_names = {b.name for b in getattr(fb._function, "_bindings", [])} + skip_param_names = bound_param_names + + # Build tool properties + tool_properties = [] + for param_name, param in sig.parameters.items(): + if param_name in skip_param_names: + continue + param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa + # Parse type and description from type hint + actual_type, param_desc = _extract_type_and_description( + param_name, param_type_hint) + if actual_type is MCPToolContext: + continue + property_type = _TYPE_MAPPING.get(actual_type, "string") + tool_properties.append({ + "propertyName": param_name, + "propertyType": property_type, + "description": param_desc, + }) + + tool_properties_json = json.dumps(tool_properties) + + bound_params = [ + inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD) + for name in bound_param_names + ] + # Build new signature for the wrapper function to pass worker indexing + wrapper_sig = inspect.Signature([ + *bound_params, + inspect.Parameter("context", inspect.Parameter.POSITIONAL_OR_KEYWORD) + ]) + + # Wrap the original function + @functools.wraps(target_func) + async def wrapper(context: str, *args, **kwargs): + content = json.loads(context) + arguments = content.get("arguments", {}) + call_kwargs = {} + for param_name, param in sig.parameters.items(): + param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa + actual_type, _ = _extract_type_and_description(param_name, param_type_hint) + if actual_type is MCPToolContext: + call_kwargs[param_name] = content + elif param_name in arguments: + call_kwargs[param_name] = arguments[param_name] + call_kwargs.update(kwargs) + result = target_func(**call_kwargs) + if asyncio.iscoroutine(result): + result = await result + return str(result) + + wrapper.__signature__ = wrapper_sig + fb._function._func = wrapper + + # Add the MCP trigger + fb.add_trigger( + trigger=MCPToolTrigger( + name="context", + tool_name=tool_name, + description=description, + tool_properties=tool_properties_json, + ) + ) + return fb + + return decorator + def dapr_service_invocation_trigger(self, arg_name: str, method_name: str, @@ -3989,9 +3988,6 @@ def get_functions(self) -> List[Function]: :return: A list of :class:`Function` objects defined in the app. """ - for function_builder in self._function_builders: - _logger.info("Function builder functions: %s", - function_builder._function) functions = [function_builder.build(self.auth_level) for function_builder in self._function_builders] @@ -4215,6 +4211,7 @@ def _add_http_app(self, def http_app_func(req: HttpRequest, context: Context): return wsgi_middleware.handle(req, context) + def _get_user_function(target_func): """ Unwraps decorated or builder-wrapped functions to find the original @@ -4237,4 +4234,4 @@ def _get_user_function(target_func): return _get_user_function(target_func.__wrapped__) # Default fallback - return target_func \ No newline at end of file + return target_func diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 08cbe2d4..010ce8ec 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -1,6 +1,5 @@ from typing import Optional -from typing import Any, Dict, Tuple, get_args, get_origin, Annotated -import logging +from typing import Any, Tuple, get_args, get_origin, Annotated from azure.functions.decorators.constants import ( MCP_TOOL_TRIGGER @@ -15,6 +14,7 @@ bool: "boolean", } + class MCPToolTrigger(Trigger): @staticmethod @@ -40,6 +40,6 @@ def _extract_type_and_description(param_name: str, type_hint: Any) -> Tuple[Any, args = get_args(type_hint) actual_type = args[0] # Use first string annotation as description if present - param_description = next((a for a in args[1:] if isinstance(a, str)), f"The {param_name} parameter.") + param_description = next((a for a in args[1:] if isinstance(a, str)), f"The {param_name} parameter.") # noqa return actual_type, param_description return type_hint, f"The {param_name} parameter." diff --git a/azure/functions/mcp.py b/azure/functions/mcp.py index 98ca25f3..4c64e5d2 100644 --- a/azure/functions/mcp.py +++ b/azure/functions/mcp.py @@ -6,6 +6,7 @@ # MCP-specific context object class MCPToolContext(typing.Dict[str, typing.Any]): """Injected context object for MCP tool triggers.""" + pass From 857ff632e51c8d4198220326f23e7deaa757cb44 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 21 Oct 2025 11:45:48 -0500 Subject: [PATCH 06/13] add tests --- azure/functions/decorators/function_app.py | 1 - azure/functions/decorators/mcp.py | 2 + azure/functions/mcp.py | 2 + tests/decorators/test_mcp.py | 88 +++++++++++++++++++++- tests/test_mcp.py | 76 +++++++++++++++++++ 5 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/test_mcp.py diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 333bcba3..a9233956 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -275,7 +275,6 @@ def _validate_function(self, trigger = self._function.get_trigger() if trigger is None: raise ValueError( - f"This is the function: {self._function}" f"Function {function_name} does not have a trigger. A valid " f"function must have one and only one trigger registered.") diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 010ce8ec..584280d6 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. from typing import Optional from typing import Any, Tuple, get_args, get_origin, Annotated diff --git a/azure/functions/mcp.py b/azure/functions/mcp.py index 4c64e5d2..5e09ec18 100644 --- a/azure/functions/mcp.py +++ b/azure/functions/mcp.py @@ -1,3 +1,5 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import typing from . import meta diff --git a/tests/decorators/test_mcp.py b/tests/decorators/test_mcp.py index 044be213..381acda7 100644 --- a/tests/decorators/test_mcp.py +++ b/tests/decorators/test_mcp.py @@ -1,6 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import typing import unittest -from azure.functions import DataType +import azure.functions as func +from azure.functions import DataType, MCPToolContext from azure.functions.decorators.core import BindingDirection from azure.functions.decorators.mcp import MCPToolTrigger from azure.functions.mcp import MCPToolTriggerConverter @@ -44,3 +48,85 @@ def test_trigger_converter(self): result_json = MCPToolTriggerConverter.decode(datum_json, trigger_metadata={}) self.assertEqual(result_json, {"arguments": {}}) self.assertIsInstance(result_json, dict) + + +class TestMcpToolDecorator(unittest.TestCase): + def setUp(self): + self.app = func.FunctionApp() + + def tearDown(self): + self.app = None + + def test_simple_signature(self): + @self.app.mcp_tool() + def add_numbers(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "The a parameter."}, ' + '{"propertyName": "b", "propertyType": "integer", ' + '"description": "The b parameter."}]') + + def test_with_binding_argument(self): + @self.app.mcp_tool() + def save_snippet(file, snippetname: str, snippet: str): + """Save snippet.""" + return f"Saved {snippetname}" + + trigger = save_snippet._function._bindings[0] + self.assertEqual(trigger.description, "Save snippet.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "save_snippet") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "file", ' + '"propertyType": "string", ' + '"description": "The file parameter."}, ' + '{"propertyName": "snippetname", ' + '"propertyType": "string", ' + '"description": "The snippetname parameter."}, ' + '{"propertyName": "snippet", ' + '"propertyType": "string", ' + '"description": "The snippet parameter."}]') + + def test_with_context_argument(self): + @self.app.mcp_tool() + def process_data(data: str, context: MCPToolContext): + """Process data with context.""" + return f"Processed {data}" + + trigger = process_data._function._bindings[0] + self.assertEqual(trigger.description, "Process data with context.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "process_data") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "data", ' + '"propertyType": "string", ' + '"description": "The data parameter."}]') + + def test_with_annotated(self): + @self.app.mcp_tool() + def add_numbers( + a: typing.Annotated[int, "First number"], + b: typing.Annotated[int, "Second number"] + ) -> str: + """Add two integers.""" + return str(a + b) + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two integers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "First number"}, ' + '{"propertyName": "b", ' + '"propertyType": "integer", ' + '"description": "Second number"}]') diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 00000000..e322c25e --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest +import azure.functions as func +from azure.functions.meta import Datum +from azure.functions.mcp import MCPToolTriggerConverter + + +class TestMCPToolTriggerConverter(unittest.TestCase): + """Unit tests for MCPToolTriggerConverter""" + + def test_check_input_type_annotation_valid_types(self): + self.assertTrue(MCPToolTriggerConverter.check_input_type_annotation(str)) + self.assertTrue(MCPToolTriggerConverter.check_input_type_annotation(dict)) + self.assertTrue(MCPToolTriggerConverter.check_input_type_annotation(bytes)) + self.assertTrue(MCPToolTriggerConverter.check_input_type_annotation(func.MCPToolContext)) + + def test_check_input_type_annotation_invalid_type(self): + with self.assertRaises(TypeError): + MCPToolTriggerConverter.check_input_type_annotation(123) # not a type + + class Dummy: + pass + self.assertFalse(MCPToolTriggerConverter.check_input_type_annotation(Dummy)) + + def test_has_implicit_output(self): + self.assertTrue(MCPToolTriggerConverter.has_implicit_output()) + + def test_decode_json(self): + data = Datum(type='json', value={'foo': 'bar'}) + result = MCPToolTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, {'foo': 'bar'}) + + def test_decode_string(self): + data = Datum(type='string', value='hello') + result = MCPToolTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, 'hello') + + def test_decode_bytes(self): + data = Datum(type='bytes', value=b'data') + result = MCPToolTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, b'data') + + def test_decode_other_without_python_value(self): + data = Datum(type='other', value='fallback') + result = MCPToolTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, 'fallback') + + def test_encode_none(self): + result = MCPToolTriggerConverter.encode(None) + self.assertEqual(result.type, 'string') + self.assertEqual(result.value, '') + + def test_encode_string(self): + result = MCPToolTriggerConverter.encode('hello') + self.assertEqual(result.type, 'string') + self.assertEqual(result.value, 'hello') + + def test_encode_bytes(self): + result = MCPToolTriggerConverter.encode(b'\x00\x01') + self.assertEqual(result.type, 'bytes') + self.assertEqual(result.value, b'\x00\x01') + + def test_encode_bytearray(self): + result = MCPToolTriggerConverter.encode(bytearray(b'\x01\x02')) + self.assertEqual(result.type, 'bytes') + self.assertEqual(result.value, b'\x01\x02') + + def test_encode_other_type(self): + result = MCPToolTriggerConverter.encode(42) + self.assertEqual(result.type, 'string') + self.assertEqual(result.value, '42') + + result = MCPToolTriggerConverter.encode({'a': 1}) + self.assertEqual(result.type, 'string') + self.assertIn("'a'", result.value) From 76831970ef2520a3a25cf662aa4c8cd26933329b Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 3 Nov 2025 15:21:35 -0600 Subject: [PATCH 07/13] Remove Annotated --- azure/functions/decorators/function_app.py | 9 ++++----- azure/functions/decorators/mcp.py | 15 +++------------ 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 53af7e53..edd7bab9 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -44,7 +44,7 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger, _TYPE_MAPPING, _extract_type_and_description +from .mcp import MCPToolTrigger, _TYPE_MAPPING from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger @@ -1603,15 +1603,14 @@ def decorator(fb: FunctionBuilder) -> FunctionBuilder: continue param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa # Parse type and description from type hint - actual_type, param_desc = _extract_type_and_description( - param_name, param_type_hint) + actual_type = param_type_hint if actual_type is MCPToolContext: continue property_type = _TYPE_MAPPING.get(actual_type, "string") tool_properties.append({ "propertyName": param_name, "propertyType": property_type, - "description": param_desc, + "description": "", }) tool_properties_json = json.dumps(tool_properties) @@ -1634,7 +1633,7 @@ async def wrapper(context: str, *args, **kwargs): call_kwargs = {} for param_name, param in sig.parameters.items(): param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa - actual_type, _ = _extract_type_and_description(param_name, param_type_hint) + actual_type = param_type_hint if actual_type is MCPToolContext: call_kwargs[param_name] = content elif param_name in arguments: diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 584280d6..227946f5 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. from typing import Optional -from typing import Any, Tuple, get_args, get_origin, Annotated +from datetime import datetime from azure.functions.decorators.constants import ( MCP_TOOL_TRIGGER @@ -14,6 +14,8 @@ float: "number", str: "string", bool: "boolean", + object: "object", + datetime: "string" } @@ -34,14 +36,3 @@ def __init__(self, self.description = description self.tool_properties = tool_properties super().__init__(name=name, data_type=data_type) - - -# Helper to extract actual type and description from Annotated types -def _extract_type_and_description(param_name: str, type_hint: Any) -> Tuple[Any, str]: - if get_origin(type_hint) is Annotated: - args = get_args(type_hint) - actual_type = args[0] - # Use first string annotation as description if present - param_description = next((a for a in args[1:] if isinstance(a, str)), f"The {param_name} parameter.") # noqa - return actual_type, param_description - return type_hint, f"The {param_name} parameter." From fd4e269ab4a9930547a8c5baf989df21b6b98bea Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 4 Nov 2025 15:51:00 -0600 Subject: [PATCH 08/13] Add support for isRequired, isArray, McpToolProperty input --- azure/functions/decorators/function_app.py | 65 ++++++++++++++++++++-- azure/functions/decorators/mcp.py | 52 ++++++++++++++++- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index edd7bab9..3697755b 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -44,7 +44,7 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger, _TYPE_MAPPING +from .mcp import MCPToolTrigger, _TYPE_MAPPING, check_property_type, check_is_array, check_is_required from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger @@ -55,6 +55,8 @@ MySqlTrigger +logger = logging.getLogger('azure.functions.WsgiMiddleware') + class Function(object): """ The function object represents a function in Function App. It @@ -1588,6 +1590,11 @@ def mcp_tool(self): def decorator(fb: FunctionBuilder) -> FunctionBuilder: target_func = fb._function.get_user_function() sig = inspect.signature(target_func) + + # Pull any explicitly declared MCP tool properties + explicit_properties = getattr(target_func, "__mcp_tool_properties__", {}) + logger.info(f"Explicit MCP tool properties: {explicit_properties}") + # Parse tool name and description from function signature tool_name = target_func.__name__ description = (target_func.__doc__ or "").strip().split("\n")[0] @@ -1602,15 +1609,29 @@ def decorator(fb: FunctionBuilder) -> FunctionBuilder: if param_name in skip_param_names: continue param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa - # Parse type and description from type hint - actual_type = param_type_hint - if actual_type is MCPToolContext: + + if param_type_hint is MCPToolContext: continue - property_type = _TYPE_MAPPING.get(actual_type, "string") + + # Check if explicit metadata exists for this param + if param_name in explicit_properties: + logger.info(f"Using explicit MCP tool property for param: {param_name}") # noqa + prop = explicit_properties[param_name].copy() + prop["propertyName"] = param_name + tool_properties.append(prop) + continue + + # Otherwise infer it + is_required = check_is_required(param, param_type_hint) + is_array = check_is_array(param_type_hint) + property_type = check_property_type(param_type_hint, is_array) + tool_properties.append({ "propertyName": param_name, "propertyType": property_type, "description": "", + "isArray": is_array, + "isRequired": is_required }) tool_properties_json = json.dumps(tool_properties) @@ -1660,6 +1681,40 @@ async def wrapper(context: str, *args, **kwargs): return decorator + def mcp_tool_property(self, arg_name: str, + description: Optional[str] = "", + property_type: Optional[str] = None, + is_required: Optional[bool] = True, + is_array: Optional[bool] = False): + """ + Decorator for defining explicit MCP tool property metadata for a specific argument. + + Example: + @app.mcp_tool_property( + arg_name="snippetname", + description="The name of the snippet.", + property_type="string", + is_required=True, + is_array=False + ) + """ + def decorator(func): + # If this function is already wrapped by FunctionBuilder or similar, unwrap it + target_func = getattr(func, "_function", func) + target_func = getattr(target_func, "_func", target_func) + + existing = getattr(target_func, "__mcp_tool_properties__", {}) + existing[arg_name] = { + "description": description or "", + "propertyType": property_type or "string", + "isRequired": is_required, + "isArray": is_array, + } + setattr(target_func, "__mcp_tool_properties__", existing) + return func + return decorator + + def dapr_service_invocation_trigger(self, arg_name: str, method_name: str, diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 227946f5..61e0858d 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Optional +import inspect + +from typing import List, Optional, Union, get_origin, get_args from datetime import datetime from azure.functions.decorators.constants import ( @@ -36,3 +38,51 @@ def __init__(self, self.description = description self.tool_properties = tool_properties super().__init__(name=name, data_type=data_type) + +def unwrap_optional(pytype: type): + """If Optional[T], return T; else return pytype unchanged.""" + origin = get_origin(pytype) + args = get_args(pytype) + if origin is Union and any(a is type(None) for a in args): + non_none_args = [a for a in args if a is not type(None)] + return non_none_args[0] if non_none_args else str + return pytype + + +def check_is_array(param_type_hint: type) -> bool: + """Return True if type is (possibly optional) list[...]""" + unwrapped = unwrap_optional(param_type_hint) + origin = get_origin(unwrapped) + return origin in (list, List) + + +def check_property_type(pytype: type, is_array: bool) -> str: + """Map Python type hints to MCP property types.""" + base_type = unwrap_optional(pytype) + if is_array: + args = get_args(base_type) + inner_type = unwrap_optional(args[0]) if args else str + return _TYPE_MAPPING.get(inner_type, "string") + return _TYPE_MAPPING.get(base_type, "string") + +def check_is_required(param: type, param_type_hint: type) -> bool: + """ + Return True when param is required, False when optional. + + Rules: + - If param has an explicit default -> not required + - If annotation is Optional[T] (Union[..., None]) -> not required + - Otherwise -> required + """ + # 1) default value present => not required + if param.default is not inspect.Parameter.empty: + return False + + # 2) Optional[T] => not required + origin = get_origin(param_type_hint) + args = get_args(param_type_hint) + if origin is Union and any(a is type(None) for a in args): + return False + + # 3) It's required + return True From 63f80c3756fe0b24de662c91cd1af45598022758 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 5 Nov 2025 11:45:05 -0600 Subject: [PATCH 09/13] Final changes, tests --- azure/functions/__init__.py | 5 +- azure/functions/decorators/__init__.py | 5 +- azure/functions/decorators/core.py | 16 ++ azure/functions/decorators/function_app.py | 56 ++-- azure/functions/decorators/mcp.py | 6 +- tests/decorators/test_mcp.py | 289 +++++++++++++++++++-- 6 files changed, 326 insertions(+), 51 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index e8adeb82..76898cc8 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -10,7 +10,7 @@ DecoratorApi, DataType, AuthLevel, Cardinality, AccessRights, HttpMethod, AsgiFunctionApp, WsgiFunctionApp, - ExternalHttpFunctionApp, BlobSource) + ExternalHttpFunctionApp, BlobSource, McpPropertyType) from ._durable_functions import OrchestrationContext, EntityContext from .decorators.function_app import (FunctionRegister, TriggerApi, BindingApi, SettingsApi) @@ -102,7 +102,8 @@ 'AccessRights', 'HttpMethod', 'BlobSource', - 'MCPToolContext' + 'MCPToolContext', + 'McpPropertyType' ) __version__ = '1.25.0b1' diff --git a/azure/functions/decorators/__init__.py b/azure/functions/decorators/__init__.py index be7ff99f..f39a860f 100644 --- a/azure/functions/decorators/__init__.py +++ b/azure/functions/decorators/__init__.py @@ -4,7 +4,7 @@ from .function_app import FunctionApp, Function, DecoratorApi, DataType, \ AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \ WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \ - SettingsApi, BlobSource + SettingsApi, BlobSource, McpPropertyType from .http import HttpMethod __all__ = [ @@ -24,5 +24,6 @@ 'Cardinality', 'AccessRights', 'HttpMethod', - 'BlobSource' + 'BlobSource', + 'McpPropertyType' ] diff --git a/azure/functions/decorators/core.py b/azure/functions/decorators/core.py index 7aa9d128..1e1ef4cd 100644 --- a/azure/functions/decorators/core.py +++ b/azure/functions/decorators/core.py @@ -73,6 +73,22 @@ class BlobSource(StringifyEnum): """Standard polling mechanism to detect changes in the container.""" +class McpPropertyType(StringifyEnum): + """MCP property types.""" + INTEGER = "integer" + """Integer type.""" + FLOAT = "float" + """Float type.""" + STRING = "string" + """String type.""" + BOOLEAN = "boolean" + """Boolean type.""" + OBJECT = "object" + """Object type.""" + DATETIME = "string" + """Datetime type represented as string.""" + + class Binding(ABC): """Abstract binding class which captures common attributes and functions. :meth:`get_dict_repr` can auto generate the function.json for diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 3697755b..ac00db92 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -13,7 +13,8 @@ from azure.functions.decorators.blob import BlobTrigger, BlobInput, BlobOutput from azure.functions.decorators.core import Binding, Trigger, DataType, \ - AuthLevel, SCRIPT_FILE_NAME, Cardinality, AccessRights, Setting, BlobSource + AuthLevel, SCRIPT_FILE_NAME, Cardinality, AccessRights, Setting, BlobSource, \ + McpPropertyType from azure.functions.decorators.cosmosdb import CosmosDBTrigger, \ CosmosDBOutput, CosmosDBInput, CosmosDBTriggerV3, CosmosDBInputV3, \ CosmosDBOutputV3 @@ -44,7 +45,7 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger, _TYPE_MAPPING, check_property_type, check_is_array, check_is_required +from .mcp import MCPToolTrigger, check_property_type, check_is_array, check_is_required from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger @@ -55,8 +56,6 @@ MySqlTrigger -logger = logging.getLogger('azure.functions.WsgiMiddleware') - class Function(object): """ The function object represents a function in Function App. It @@ -1593,7 +1592,6 @@ def decorator(fb: FunctionBuilder) -> FunctionBuilder: # Pull any explicitly declared MCP tool properties explicit_properties = getattr(target_func, "__mcp_tool_properties__", {}) - logger.info(f"Explicit MCP tool properties: {explicit_properties}") # Parse tool name and description from function signature tool_name = target_func.__name__ @@ -1613,26 +1611,27 @@ def decorator(fb: FunctionBuilder) -> FunctionBuilder: if param_type_hint is MCPToolContext: continue - # Check if explicit metadata exists for this param - if param_name in explicit_properties: - logger.info(f"Using explicit MCP tool property for param: {param_name}") # noqa - prop = explicit_properties[param_name].copy() - prop["propertyName"] = param_name - tool_properties.append(prop) - continue - - # Otherwise infer it + # Inferred defaults is_required = check_is_required(param, param_type_hint) is_array = check_is_array(param_type_hint) property_type = check_property_type(param_type_hint, is_array) - tool_properties.append({ + property_data = { "propertyName": param_name, "propertyType": property_type, "description": "", "isArray": is_array, "isRequired": is_required - }) + } + + # Merge in any explicit overrides + if param_name in explicit_properties: + overrides = explicit_properties[param_name] + for key, value in overrides.items(): + if value is not None: + property_data[key] = value + + tool_properties.append(property_data) tool_properties_json = json.dumps(tool_properties) @@ -1682,31 +1681,39 @@ async def wrapper(context: str, *args, **kwargs): return decorator def mcp_tool_property(self, arg_name: str, - description: Optional[str] = "", - property_type: Optional[str] = None, - is_required: Optional[bool] = True, - is_array: Optional[bool] = False): + description: Optional[str] = None, + property_type: Optional[McpPropertyType] = None, + is_required: Optional[bool] = True, + is_array: Optional[bool] = False): """ Decorator for defining explicit MCP tool property metadata for a specific argument. + :param arg_name: The name of the argument. + :param description: The description of the argument. + :param property_type: The type of the argument. + :param is_required: If the argument is required or not. + :param is_array: If the argument is array or not. + + :return: Decorator function. + Example: @app.mcp_tool_property( arg_name="snippetname", description="The name of the snippet.", - property_type="string", + property_type=func.McpPropertyType.STRING, is_required=True, is_array=False ) """ def decorator(func): - # If this function is already wrapped by FunctionBuilder or similar, unwrap it + # If this function is already wrapped by FunctionBuilder or similar, unwrap it target_func = getattr(func, "_function", func) target_func = getattr(target_func, "_func", target_func) existing = getattr(target_func, "__mcp_tool_properties__", {}) existing[arg_name] = { - "description": description or "", - "propertyType": property_type or "string", + "description": description, + "propertyType": property_type.value if property_type else None, # Get enum value "isRequired": is_required, "isArray": is_array, } @@ -1714,7 +1721,6 @@ def decorator(func): return func return decorator - def dapr_service_invocation_trigger(self, arg_name: str, method_name: str, diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 61e0858d..d729a67d 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -8,7 +8,7 @@ from azure.functions.decorators.constants import ( MCP_TOOL_TRIGGER ) -from azure.functions.decorators.core import Trigger, DataType +from azure.functions.decorators.core import Trigger, DataType, McpPropertyType # Mapping Python types to MCP property types _TYPE_MAPPING = { @@ -39,6 +39,7 @@ def __init__(self, self.tool_properties = tool_properties super().__init__(name=name, data_type=data_type) + def unwrap_optional(pytype: type): """If Optional[T], return T; else return pytype unchanged.""" origin = get_origin(pytype) @@ -58,6 +59,8 @@ def check_is_array(param_type_hint: type) -> bool: def check_property_type(pytype: type, is_array: bool) -> str: """Map Python type hints to MCP property types.""" + if isinstance(pytype, McpPropertyType): + return pytype.value base_type = unwrap_optional(pytype) if is_array: args = get_args(base_type) @@ -65,6 +68,7 @@ def check_property_type(pytype: type, is_array: bool) -> str: return _TYPE_MAPPING.get(inner_type, "string") return _TYPE_MAPPING.get(base_type, "string") + def check_is_required(param: type, param_type_hint: type) -> bool: """ Return True when param is required, False when optional. diff --git a/tests/decorators/test_mcp.py b/tests/decorators/test_mcp.py index 381acda7..ce6227ec 100644 --- a/tests/decorators/test_mcp.py +++ b/tests/decorators/test_mcp.py @@ -70,30 +70,58 @@ def add_numbers(a: int, b: int) -> int: self.assertEqual(trigger.tool_properties, '[{"propertyName": "a", ' '"propertyType": "integer", ' - '"description": "The a parameter."}, ' - '{"propertyName": "b", "propertyType": "integer", ' - '"description": "The b parameter."}]') + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}, ' + '{"propertyName": "b", ' + '"propertyType": "integer", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + def test_simple_signature_defaults(self): + @self.app.mcp_tool() + def add_numbers(a, b): + return a + b + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "string", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}, ' + '{"propertyName": "b", ' + '"propertyType": "string", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') def test_with_binding_argument(self): @self.app.mcp_tool() + @self.app.blob_input(arg_name="file", path="", connection="Test") def save_snippet(file, snippetname: str, snippet: str): """Save snippet.""" return f"Saved {snippetname}" - trigger = save_snippet._function._bindings[0] + trigger = save_snippet._function._bindings[1] self.assertEqual(trigger.description, "Save snippet.") self.assertEqual(trigger.name, "context") self.assertEqual(trigger.tool_name, "save_snippet") self.assertEqual(trigger.tool_properties, - '[{"propertyName": "file", ' - '"propertyType": "string", ' - '"description": "The file parameter."}, ' - '{"propertyName": "snippetname", ' + '[{"propertyName": "snippetname", ' '"propertyType": "string", ' - '"description": "The snippetname parameter."}, ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}, ' '{"propertyName": "snippet", ' '"propertyType": "string", ' - '"description": "The snippet parameter."}]') + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') def test_with_context_argument(self): @self.app.mcp_tool() @@ -108,25 +136,244 @@ def process_data(data: str, context: MCPToolContext): self.assertEqual(trigger.tool_properties, '[{"propertyName": "data", ' '"propertyType": "string", ' - '"description": "The data parameter."}]') + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + def test_with_only_context(self): + @self.app.mcp_tool() + def process_data(context: MCPToolContext): + """Process data with context.""" + return f"Processed {context}" + + trigger = process_data._function._bindings[0] + self.assertEqual(trigger.description, "Process data with context.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "process_data") + self.assertEqual(trigger.tool_properties, + '[]') - def test_with_annotated(self): + def test_is_required(self): @self.app.mcp_tool() - def add_numbers( - a: typing.Annotated[int, "First number"], - b: typing.Annotated[int, "Second number"] - ) -> str: - """Add two integers.""" - return str(a + b) + def add_numbers(a: typing.Optional[int] = 0) -> int: + """Add two numbers.""" + return a trigger = add_numbers._function._bindings[0] - self.assertEqual(trigger.description, "Add two integers.") + self.assertEqual(trigger.description, "Add two numbers.") self.assertEqual(trigger.name, "context") self.assertEqual(trigger.tool_name, "add_numbers") self.assertEqual(trigger.tool_properties, '[{"propertyName": "a", ' '"propertyType": "integer", ' - '"description": "First number"}, ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": false}]') + + def test_is_required_default_value(self): + @self.app.mcp_tool() + def add_numbers(a: int = 0) -> int: + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": false}]') + + def test_is_array(self): + @self.app.mcp_tool() + def add_numbers(a: typing.List[int]) -> typing.List[int]: + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "", ' + '"isArray": true, ' + '"isRequired": true}]') + + def test_is_array_pep(self): + @self.app.mcp_tool() + def add_numbers(a: list[int]) -> list[int]: + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "", ' + '"isArray": true, ' + '"isRequired": true}]') + + def test_is_optional_array(self): + @self.app.mcp_tool() + def add_numbers(a: typing.Optional[typing.List[int]]): + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "", ' + '"isArray": true, ' + '"isRequired": false}]') + + def test_mcp_property_input_all_props(self): + @self.app.mcp_tool() + @self.app.mcp_tool_property(arg_name="a", + description="The first number", + property_type=func.McpPropertyType.INTEGER, + is_required=False, + is_array=True) + def add_numbers(a, b: int) -> int: + """Add two numbers.""" + return a + b + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "The first number", ' + '"isArray": true, ' + '"isRequired": false}, ' '{"propertyName": "b", ' '"propertyType": "integer", ' - '"description": "Second number"}]') + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + def test_mcp_property_input_one_prop(self): + @self.app.mcp_tool() + @self.app.mcp_tool_property(arg_name="a", description="The first number") + def add_numbers(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "The first number", ' + '"isArray": false, ' + '"isRequired": true}, ' + '{"propertyName": "b", ' + '"propertyType": "integer", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + def test_mcp_property_input_enum_float(self): + @self.app.mcp_tool() + @self.app.mcp_tool_property(arg_name="a", property_type=func.McpPropertyType.FLOAT) + def add_numbers(a) -> int: + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "float", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + def test_mcp_property_input_enum_string(self): + @self.app.mcp_tool() + @self.app.mcp_tool_property(arg_name="a", property_type=func.McpPropertyType.STRING) + def add_numbers(a) -> int: + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "string", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + def test_mcp_property_input_enum_bool(self): + @self.app.mcp_tool() + @self.app.mcp_tool_property(arg_name="a", property_type=func.McpPropertyType.BOOLEAN) + def add_numbers(a) -> int: + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "boolean", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + def test_mcp_property_input_enum_object(self): + @self.app.mcp_tool() + @self.app.mcp_tool_property(arg_name="a", property_type=func.McpPropertyType.OBJECT) + def add_numbers(a) -> int: + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "object", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + def test_mcp_property_input_enum_datetime(self): + @self.app.mcp_tool() + @self.app.mcp_tool_property(arg_name="a", property_type=func.McpPropertyType.DATETIME) + def add_numbers(a) -> int: + """Add two numbers.""" + return a + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, "Add two numbers.") + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "string", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') From ef394ed0efecfff1d851cffc45bb4da03970d29b Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 5 Nov 2025 12:14:09 -0600 Subject: [PATCH 10/13] lint --- azure/functions/decorators/mcp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index d729a67d..e901e2eb 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -44,8 +44,8 @@ def unwrap_optional(pytype: type): """If Optional[T], return T; else return pytype unchanged.""" origin = get_origin(pytype) args = get_args(pytype) - if origin is Union and any(a is type(None) for a in args): - non_none_args = [a for a in args if a is not type(None)] + if isinstance(origin, Union) and any(isinstance(a, type(None)) for a in args): + non_none_args = [a for a in args if not isinstance(a, type(None))] return non_none_args[0] if non_none_args else str return pytype @@ -85,7 +85,7 @@ def check_is_required(param: type, param_type_hint: type) -> bool: # 2) Optional[T] => not required origin = get_origin(param_type_hint) args = get_args(param_type_hint) - if origin is Union and any(a is type(None) for a in args): + if isinstance(origin, Union) and any(isinstance(a, type(None)) for a in args): return False # 3) It's required From 68ddad8e86898bd42a3d3bee5663efb5e5f13242 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 5 Nov 2025 13:10:49 -0600 Subject: [PATCH 11/13] lint --- azure/functions/decorators/mcp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index e901e2eb..fa8409fd 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -44,8 +44,8 @@ def unwrap_optional(pytype: type): """If Optional[T], return T; else return pytype unchanged.""" origin = get_origin(pytype) args = get_args(pytype) - if isinstance(origin, Union) and any(isinstance(a, type(None)) for a in args): - non_none_args = [a for a in args if not isinstance(a, type(None))] + if origin is Union and any(a is type(None) for a in args): # noqa + non_none_args = [a for a in args if a is not type(None)] # noqa return non_none_args[0] if non_none_args else str return pytype @@ -85,7 +85,7 @@ def check_is_required(param: type, param_type_hint: type) -> bool: # 2) Optional[T] => not required origin = get_origin(param_type_hint) args = get_args(param_type_hint) - if isinstance(origin, Union) and any(isinstance(a, type(None)) for a in args): + if origin is Union and any(a is type(None) for a in args): # noqa return False # 3) It's required From 7f29e50545d8c324a8ee9fc480186d2625c89062 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 6 Nov 2025 11:57:20 -0600 Subject: [PATCH 12/13] feedback --- azure/functions/decorators/function_app.py | 48 ++++++---------------- azure/functions/decorators/mcp.py | 43 +++++++++++++++++-- tests/decorators/test_mcp.py | 44 ++++++++++++++++++-- 3 files changed, 93 insertions(+), 42 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index ac00db92..16482aa3 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -6,6 +6,8 @@ import inspect import json import logging +import textwrap + from abc import ABC from datetime import time from typing import Any, Callable, Dict, List, Optional, Union, \ @@ -45,7 +47,7 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger, check_property_type, check_is_array, check_is_required +from .mcp import MCPToolTrigger, build_property_metadata from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger @@ -1595,43 +1597,17 @@ def decorator(fb: FunctionBuilder) -> FunctionBuilder: # Parse tool name and description from function signature tool_name = target_func.__name__ - description = (target_func.__doc__ or "").strip().split("\n")[0] + raw_doc = target_func.__doc__ or "" + description = textwrap.dedent(raw_doc).strip() # Identify arguments that are already bound (bindings) bound_param_names = {b.name for b in getattr(fb._function, "_bindings", [])} skip_param_names = bound_param_names # Build tool properties - tool_properties = [] - for param_name, param in sig.parameters.items(): - if param_name in skip_param_names: - continue - param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa - - if param_type_hint is MCPToolContext: - continue - - # Inferred defaults - is_required = check_is_required(param, param_type_hint) - is_array = check_is_array(param_type_hint) - property_type = check_property_type(param_type_hint, is_array) - - property_data = { - "propertyName": param_name, - "propertyType": property_type, - "description": "", - "isArray": is_array, - "isRequired": is_required - } - - # Merge in any explicit overrides - if param_name in explicit_properties: - overrides = explicit_properties[param_name] - for key, value in overrides.items(): - if value is not None: - property_data[key] = value - - tool_properties.append(property_data) + tool_properties = build_property_metadata(sig=sig, + skip_param_names=skip_param_names, + explicit_properties=explicit_properties) tool_properties_json = json.dumps(tool_properties) @@ -1684,7 +1660,7 @@ def mcp_tool_property(self, arg_name: str, description: Optional[str] = None, property_type: Optional[McpPropertyType] = None, is_required: Optional[bool] = True, - is_array: Optional[bool] = False): + as_array: Optional[bool] = False): """ Decorator for defining explicit MCP tool property metadata for a specific argument. @@ -1692,7 +1668,7 @@ def mcp_tool_property(self, arg_name: str, :param description: The description of the argument. :param property_type: The type of the argument. :param is_required: If the argument is required or not. - :param is_array: If the argument is array or not. + :param as_array: If the argument should be passed as an array or not. :return: Decorator function. @@ -1702,7 +1678,7 @@ def mcp_tool_property(self, arg_name: str, description="The name of the snippet.", property_type=func.McpPropertyType.STRING, is_required=True, - is_array=False + as_array=False ) """ def decorator(func): @@ -1715,7 +1691,7 @@ def decorator(func): "description": description, "propertyType": property_type.value if property_type else None, # Get enum value "isRequired": is_required, - "isArray": is_array, + "isArray": as_array, } setattr(target_func, "__mcp_tool_properties__", existing) return func diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index fa8409fd..91a51777 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -5,6 +5,7 @@ from typing import List, Optional, Union, get_origin, get_args from datetime import datetime +from ..mcp import MCPToolContext from azure.functions.decorators.constants import ( MCP_TOOL_TRIGGER ) @@ -50,19 +51,19 @@ def unwrap_optional(pytype: type): return pytype -def check_is_array(param_type_hint: type) -> bool: +def check_as_array(param_type_hint: type) -> bool: """Return True if type is (possibly optional) list[...]""" unwrapped = unwrap_optional(param_type_hint) origin = get_origin(unwrapped) return origin in (list, List) -def check_property_type(pytype: type, is_array: bool) -> str: +def check_property_type(pytype: type, as_array: bool) -> str: """Map Python type hints to MCP property types.""" if isinstance(pytype, McpPropertyType): return pytype.value base_type = unwrap_optional(pytype) - if is_array: + if as_array: args = get_args(base_type) inner_type = unwrap_optional(args[0]) if args else str return _TYPE_MAPPING.get(inner_type, "string") @@ -90,3 +91,39 @@ def check_is_required(param: type, param_type_hint: type) -> bool: # 3) It's required return True + + +def build_property_metadata(sig, + skip_param_names: List[str], + explicit_properties: dict) -> List[dict]: + tool_properties = [] + for param_name, param in sig.parameters.items(): + if param_name in skip_param_names: + continue + param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa + + if param_type_hint is MCPToolContext: + continue + + # Inferred defaults + is_required = check_is_required(param, param_type_hint) + as_array = check_as_array(param_type_hint) + property_type = check_property_type(param_type_hint, as_array) + + property_data = { + "propertyName": param_name, + "propertyType": property_type, + "description": "", + "isArray": as_array, + "isRequired": is_required + } + + # Merge in any explicit overrides + if param_name in explicit_properties: + overrides = explicit_properties[param_name] + for key, value in overrides.items(): + if value is not None: + property_data[key] = value + + tool_properties.append(property_data) + return tool_properties diff --git a/tests/decorators/test_mcp.py b/tests/decorators/test_mcp.py index ce6227ec..a142810f 100644 --- a/tests/decorators/test_mcp.py +++ b/tests/decorators/test_mcp.py @@ -79,6 +79,44 @@ def add_numbers(a: int, b: int) -> int: '"isArray": false, ' '"isRequired": true}]') + def test_long_pydocs(self): + @self.app.mcp_tool() + def add_numbers(a: int, b: int) -> int: + """ + Add two numbers. + + Args: + a (int): The first number to add. + b (int): The second number to add. + + Returns: + int: The sum of the two numbers. + """ + return a + b + + trigger = add_numbers._function._bindings[0] + self.assertEqual(trigger.description, '''Add two numbers. + +Args: + a (int): The first number to add. + b (int): The second number to add. + +Returns: + int: The sum of the two numbers.''') + self.assertEqual(trigger.name, "context") + self.assertEqual(trigger.tool_name, "add_numbers") + self.assertEqual(trigger.tool_properties, + '[{"propertyName": "a", ' + '"propertyType": "integer", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}, ' + '{"propertyName": "b", ' + '"propertyType": "integer", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + def test_simple_signature_defaults(self): @self.app.mcp_tool() def add_numbers(a, b): @@ -187,7 +225,7 @@ def add_numbers(a: int = 0) -> int: '"isArray": false, ' '"isRequired": false}]') - def test_is_array(self): + def test_as_array(self): @self.app.mcp_tool() def add_numbers(a: typing.List[int]) -> typing.List[int]: """Add two numbers.""" @@ -204,7 +242,7 @@ def add_numbers(a: typing.List[int]) -> typing.List[int]: '"isArray": true, ' '"isRequired": true}]') - def test_is_array_pep(self): + def test_as_array_pep(self): @self.app.mcp_tool() def add_numbers(a: list[int]) -> list[int]: """Add two numbers.""" @@ -244,7 +282,7 @@ def test_mcp_property_input_all_props(self): description="The first number", property_type=func.McpPropertyType.INTEGER, is_required=False, - is_array=True) + as_array=True) def add_numbers(a, b: int) -> int: """Add two numbers.""" return a + b From ef220db3ac7e0251afc16e41c9de1911a362e26d Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 6 Nov 2025 14:01:52 -0600 Subject: [PATCH 13/13] bump python_requires to 3.10 --- eng/templates/jobs/build.yml | 6 ------ eng/templates/jobs/ci-tests.yml | 6 ------ pyproject.toml | 8 ++++---- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml index c4b7d0b1..a9838d6a 100644 --- a/eng/templates/jobs/build.yml +++ b/eng/templates/jobs/build.yml @@ -4,12 +4,6 @@ jobs: strategy: matrix: - Python37: - PYTHON_VERSION: '3.7' - Python38: - PYTHON_VERSION: '3.8' - Python39: - PYTHON_VERSION: '3.9' Python310: PYTHON_VERSION: '3.10' Python311: diff --git a/eng/templates/jobs/ci-tests.yml b/eng/templates/jobs/ci-tests.yml index 9f1ddedb..ff1b85e6 100644 --- a/eng/templates/jobs/ci-tests.yml +++ b/eng/templates/jobs/ci-tests.yml @@ -4,12 +4,6 @@ jobs: strategy: matrix: - python-37: - PYTHON_VERSION: '3.7' - python-38: - PYTHON_VERSION: '3.8' - python-39: - PYTHON_VERSION: '3.9' python-310: PYTHON_VERSION: '3.10' python-311: diff --git a/pyproject.toml b/pyproject.toml index 0fc37607..a10c308d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "azure-functions" dynamic = ["version"] -requires-python = ">=3.7" +requires-python = ">=3.10" authors = [{ name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com" }] description = "Python library for Azure Functions." readme = "README.md" @@ -14,11 +14,11 @@ classifiers = [ 'License :: OSI Approved :: MIT License', 'Intended Audience :: Developers', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', 'Operating System :: Microsoft :: Windows', 'Operating System :: POSIX', 'Operating System :: MacOS :: MacOS X',