diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index f5a4dc19..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) @@ -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 @@ -99,7 +101,9 @@ 'Cardinality', 'AccessRights', 'HttpMethod', - 'BlobSource' + 'BlobSource', + '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 516e9603..16482aa3 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -2,8 +2,12 @@ # Licensed under the MIT License. import abc import asyncio +import functools +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, \ @@ -11,7 +15,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 @@ -42,10 +47,11 @@ AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ semantic_search_system_prompt, \ SemanticSearchInput, EmbeddingsStoreOutput -from .mcp import MCPToolTrigger +from .mcp import MCPToolTrigger, build_property_metadata 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, \ @@ -1571,6 +1577,126 @@ 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) + + # Pull any explicitly declared MCP tool properties + explicit_properties = getattr(target_func, "__mcp_tool_properties__", {}) + + # Parse tool name and description from function signature + tool_name = target_func.__name__ + 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 = build_property_metadata(sig=sig, + skip_param_names=skip_param_names, + explicit_properties=explicit_properties) + + 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 = 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 mcp_tool_property(self, arg_name: str, + description: Optional[str] = None, + property_type: Optional[McpPropertyType] = None, + is_required: Optional[bool] = True, + as_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 as_array: If the argument should be passed as an array or not. + + :return: Decorator function. + + Example: + @app.mcp_tool_property( + arg_name="snippetname", + description="The name of the snippet.", + property_type=func.McpPropertyType.STRING, + is_required=True, + as_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, + "propertyType": property_type.value if property_type else None, # Get enum value + "isRequired": is_required, + "isArray": as_array, + } + setattr(target_func, "__mcp_tool_properties__", existing) + return func + return decorator + def dapr_service_invocation_trigger(self, arg_name: str, method_name: str, @@ -4127,3 +4253,28 @@ 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 diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 7657975d..91a51777 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -1,9 +1,25 @@ -from typing import Optional +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import inspect +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 ) -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 = { + int: "integer", + float: "number", + str: "string", + bool: "boolean", + object: "object", + datetime: "string" +} class MCPToolTrigger(Trigger): @@ -23,3 +39,91 @@ 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): # 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 + + +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, 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 as_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): # noqa + return False + + # 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/azure/functions/mcp.py b/azure/functions/mcp.py index 839d94ef..5e09ec18 100644 --- a/azure/functions/mcp.py +++ b/azure/functions/mcp.py @@ -1,14 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. import typing 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: 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', diff --git a/tests/decorators/test_mcp.py b/tests/decorators/test_mcp.py index 044be213..a142810f 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,370 @@ 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": "", ' + '"isArray": false, ' + '"isRequired": true}, ' + '{"propertyName": "b", ' + '"propertyType": "integer", ' + '"description": "", ' + '"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): + 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[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": "snippetname", ' + '"propertyType": "string", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}, ' + '{"propertyName": "snippet", ' + '"propertyType": "string", ' + '"description": "", ' + '"isArray": false, ' + '"isRequired": true}]') + + 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": "", ' + '"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_is_required(self): + @self.app.mcp_tool() + 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 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_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_as_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_as_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, + as_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": "", ' + '"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}]') 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)