Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions azure/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -99,7 +101,9 @@
'Cardinality',
'AccessRights',
'HttpMethod',
'BlobSource'
'BlobSource',
'MCPToolContext',
'McpPropertyType'
)

__version__ = '1.25.0b1'
5 changes: 3 additions & 2 deletions azure/functions/decorators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -24,5 +24,6 @@
'Cardinality',
'AccessRights',
'HttpMethod',
'BlobSource'
'BlobSource',
'McpPropertyType'
]
16 changes: 16 additions & 0 deletions azure/functions/decorators/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 153 additions & 2 deletions azure/functions/decorators/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
# 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, \
Iterable

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
Expand Down Expand Up @@ -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, \
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
108 changes: 106 additions & 2 deletions azure/functions/decorators/mcp.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Loading