Skip to content

Commit f750d08

Browse files
authored
Merge branch 'dev' into fix/proxy-port-header
2 parents 24a9d0f + dea2ff1 commit f750d08

File tree

11 files changed

+746
-26
lines changed

11 files changed

+746
-26
lines changed

azure/functions/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
DecoratorApi, DataType, AuthLevel,
1111
Cardinality, AccessRights, HttpMethod,
1212
AsgiFunctionApp, WsgiFunctionApp,
13-
ExternalHttpFunctionApp, BlobSource)
13+
ExternalHttpFunctionApp, BlobSource, McpPropertyType)
1414
from ._durable_functions import OrchestrationContext, EntityContext
1515
from .decorators.function_app import (FunctionRegister, TriggerApi,
1616
BindingApi, SettingsApi)
@@ -19,6 +19,7 @@
1919
from ._http_wsgi import WsgiMiddleware
2020
from ._http_asgi import AsgiMiddleware
2121
from .kafka import KafkaEvent, KafkaConverter, KafkaTriggerConverter
22+
from .mcp import MCPToolContext
2223
from .meta import get_binding_registry
2324
from ._queue import QueueMessage
2425
from ._servicebus import ServiceBusMessage
@@ -32,6 +33,7 @@
3233
from . import eventhub # NoQA
3334
from . import http # NoQA
3435
from . import kafka # NoQA
36+
from . import mcp # NoQA
3537
from . import queue # NoQA
3638
from . import servicebus # NoQA
3739
from . import timer # NoQA
@@ -99,7 +101,9 @@
99101
'Cardinality',
100102
'AccessRights',
101103
'HttpMethod',
102-
'BlobSource'
104+
'BlobSource',
105+
'MCPToolContext',
106+
'McpPropertyType'
103107
)
104108

105109
__version__ = '1.25.0b1'

azure/functions/decorators/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from .function_app import FunctionApp, Function, DecoratorApi, DataType, \
55
AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \
66
WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \
7-
SettingsApi, BlobSource
7+
SettingsApi, BlobSource, McpPropertyType
88
from .http import HttpMethod
99

1010
__all__ = [
@@ -24,5 +24,6 @@
2424
'Cardinality',
2525
'AccessRights',
2626
'HttpMethod',
27-
'BlobSource'
27+
'BlobSource',
28+
'McpPropertyType'
2829
]

azure/functions/decorators/core.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ class BlobSource(StringifyEnum):
7373
"""Standard polling mechanism to detect changes in the container."""
7474

7575

76+
class McpPropertyType(StringifyEnum):
77+
"""MCP property types."""
78+
INTEGER = "integer"
79+
"""Integer type."""
80+
FLOAT = "float"
81+
"""Float type."""
82+
STRING = "string"
83+
"""String type."""
84+
BOOLEAN = "boolean"
85+
"""Boolean type."""
86+
OBJECT = "object"
87+
"""Object type."""
88+
DATETIME = "string"
89+
"""Datetime type represented as string."""
90+
91+
7692
class Binding(ABC):
7793
"""Abstract binding class which captures common attributes and
7894
functions. :meth:`get_dict_repr` can auto generate the function.json for

azure/functions/decorators/function_app.py

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@
22
# Licensed under the MIT License.
33
import abc
44
import asyncio
5+
import functools
6+
import inspect
57
import json
68
import logging
9+
import textwrap
10+
711
from abc import ABC
812
from datetime import time
913
from typing import Any, Callable, Dict, List, Optional, Union, \
1014
Iterable
1115

1216
from azure.functions.decorators.blob import BlobTrigger, BlobInput, BlobOutput
1317
from azure.functions.decorators.core import Binding, Trigger, DataType, \
14-
AuthLevel, SCRIPT_FILE_NAME, Cardinality, AccessRights, Setting, BlobSource
18+
AuthLevel, SCRIPT_FILE_NAME, Cardinality, AccessRights, Setting, BlobSource, \
19+
McpPropertyType
1520
from azure.functions.decorators.cosmosdb import CosmosDBTrigger, \
1621
CosmosDBOutput, CosmosDBInput, CosmosDBTriggerV3, CosmosDBInputV3, \
1722
CosmosDBOutputV3
@@ -42,10 +47,11 @@
4247
AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \
4348
semantic_search_system_prompt, \
4449
SemanticSearchInput, EmbeddingsStoreOutput
45-
from .mcp import MCPToolTrigger
50+
from .mcp import MCPToolTrigger, build_property_metadata
4651
from .retry_policy import RetryPolicy
4752
from .function_name import FunctionName
4853
from .warmup import WarmUpTrigger
54+
from ..mcp import MCPToolContext
4955
from .._http_asgi import AsgiMiddleware
5056
from .._http_wsgi import WsgiMiddleware, Context
5157
from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \
@@ -1571,6 +1577,126 @@ def decorator():
15711577

15721578
return wrap
15731579

1580+
def mcp_tool(self):
1581+
"""
1582+
Decorator to register an MCP tool function.
1583+
1584+
Automatically:
1585+
- Infers tool name from function name
1586+
- Extracts first line of docstring as description
1587+
- Extracts parameters and types for tool properties
1588+
- Handles MCPToolContext injection
1589+
"""
1590+
@self._configure_function_builder
1591+
def decorator(fb: FunctionBuilder) -> FunctionBuilder:
1592+
target_func = fb._function.get_user_function()
1593+
sig = inspect.signature(target_func)
1594+
1595+
# Pull any explicitly declared MCP tool properties
1596+
explicit_properties = getattr(target_func, "__mcp_tool_properties__", {})
1597+
1598+
# Parse tool name and description from function signature
1599+
tool_name = target_func.__name__
1600+
raw_doc = target_func.__doc__ or ""
1601+
description = textwrap.dedent(raw_doc).strip()
1602+
1603+
# Identify arguments that are already bound (bindings)
1604+
bound_param_names = {b.name for b in getattr(fb._function, "_bindings", [])}
1605+
skip_param_names = bound_param_names
1606+
1607+
# Build tool properties
1608+
tool_properties = build_property_metadata(sig=sig,
1609+
skip_param_names=skip_param_names,
1610+
explicit_properties=explicit_properties)
1611+
1612+
tool_properties_json = json.dumps(tool_properties)
1613+
1614+
bound_params = [
1615+
inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD)
1616+
for name in bound_param_names
1617+
]
1618+
# Build new signature for the wrapper function to pass worker indexing
1619+
wrapper_sig = inspect.Signature([
1620+
*bound_params,
1621+
inspect.Parameter("context", inspect.Parameter.POSITIONAL_OR_KEYWORD)
1622+
])
1623+
1624+
# Wrap the original function
1625+
@functools.wraps(target_func)
1626+
async def wrapper(context: str, *args, **kwargs):
1627+
content = json.loads(context)
1628+
arguments = content.get("arguments", {})
1629+
call_kwargs = {}
1630+
for param_name, param in sig.parameters.items():
1631+
param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa
1632+
actual_type = param_type_hint
1633+
if actual_type is MCPToolContext:
1634+
call_kwargs[param_name] = content
1635+
elif param_name in arguments:
1636+
call_kwargs[param_name] = arguments[param_name]
1637+
call_kwargs.update(kwargs)
1638+
result = target_func(**call_kwargs)
1639+
if asyncio.iscoroutine(result):
1640+
result = await result
1641+
return str(result)
1642+
1643+
wrapper.__signature__ = wrapper_sig
1644+
fb._function._func = wrapper
1645+
1646+
# Add the MCP trigger
1647+
fb.add_trigger(
1648+
trigger=MCPToolTrigger(
1649+
name="context",
1650+
tool_name=tool_name,
1651+
description=description,
1652+
tool_properties=tool_properties_json,
1653+
)
1654+
)
1655+
return fb
1656+
1657+
return decorator
1658+
1659+
def mcp_tool_property(self, arg_name: str,
1660+
description: Optional[str] = None,
1661+
property_type: Optional[McpPropertyType] = None,
1662+
is_required: Optional[bool] = True,
1663+
as_array: Optional[bool] = False):
1664+
"""
1665+
Decorator for defining explicit MCP tool property metadata for a specific argument.
1666+
1667+
:param arg_name: The name of the argument.
1668+
:param description: The description of the argument.
1669+
:param property_type: The type of the argument.
1670+
:param is_required: If the argument is required or not.
1671+
:param as_array: If the argument should be passed as an array or not.
1672+
1673+
:return: Decorator function.
1674+
1675+
Example:
1676+
@app.mcp_tool_property(
1677+
arg_name="snippetname",
1678+
description="The name of the snippet.",
1679+
property_type=func.McpPropertyType.STRING,
1680+
is_required=True,
1681+
as_array=False
1682+
)
1683+
"""
1684+
def decorator(func):
1685+
# If this function is already wrapped by FunctionBuilder or similar, unwrap it
1686+
target_func = getattr(func, "_function", func)
1687+
target_func = getattr(target_func, "_func", target_func)
1688+
1689+
existing = getattr(target_func, "__mcp_tool_properties__", {})
1690+
existing[arg_name] = {
1691+
"description": description,
1692+
"propertyType": property_type.value if property_type else None, # Get enum value
1693+
"isRequired": is_required,
1694+
"isArray": as_array,
1695+
}
1696+
setattr(target_func, "__mcp_tool_properties__", existing)
1697+
return func
1698+
return decorator
1699+
15741700
def dapr_service_invocation_trigger(self,
15751701
arg_name: str,
15761702
method_name: str,
@@ -4127,3 +4253,28 @@ def _add_http_app(self,
41274253
route="/{*route}")
41284254
def http_app_func(req: HttpRequest, context: Context):
41294255
return wsgi_middleware.handle(req, context)
4256+
4257+
4258+
def _get_user_function(target_func):
4259+
"""
4260+
Unwraps decorated or builder-wrapped functions to find the original
4261+
user-defined function (the one starting with 'def' or 'async def').
4262+
"""
4263+
# Case 1: It's a FunctionBuilder object
4264+
if isinstance(target_func, FunctionBuilder):
4265+
# Access the internal user function
4266+
try:
4267+
return target_func._function.get_user_function()
4268+
except AttributeError:
4269+
pass
4270+
4271+
# Case 2: It's already the user-defined function
4272+
if callable(target_func) and hasattr(target_func, "__name__"):
4273+
return target_func
4274+
4275+
# Case 3: It might be a partially wrapped callable
4276+
if hasattr(target_func, "__wrapped__"):
4277+
return _get_user_function(target_func.__wrapped__)
4278+
4279+
# Default fallback
4280+
return target_func

azure/functions/decorators/mcp.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
1-
from typing import Optional
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
import inspect
24

5+
from typing import List, Optional, Union, get_origin, get_args
6+
from datetime import datetime
7+
8+
from ..mcp import MCPToolContext
39
from azure.functions.decorators.constants import (
410
MCP_TOOL_TRIGGER
511
)
6-
from azure.functions.decorators.core import Trigger, DataType
12+
from azure.functions.decorators.core import Trigger, DataType, McpPropertyType
13+
14+
# Mapping Python types to MCP property types
15+
_TYPE_MAPPING = {
16+
int: "integer",
17+
float: "number",
18+
str: "string",
19+
bool: "boolean",
20+
object: "object",
21+
datetime: "string"
22+
}
723

824

925
class MCPToolTrigger(Trigger):
@@ -23,3 +39,91 @@ def __init__(self,
2339
self.description = description
2440
self.tool_properties = tool_properties
2541
super().__init__(name=name, data_type=data_type)
42+
43+
44+
def unwrap_optional(pytype: type):
45+
"""If Optional[T], return T; else return pytype unchanged."""
46+
origin = get_origin(pytype)
47+
args = get_args(pytype)
48+
if origin is Union and any(a is type(None) for a in args): # noqa
49+
non_none_args = [a for a in args if a is not type(None)] # noqa
50+
return non_none_args[0] if non_none_args else str
51+
return pytype
52+
53+
54+
def check_as_array(param_type_hint: type) -> bool:
55+
"""Return True if type is (possibly optional) list[...]"""
56+
unwrapped = unwrap_optional(param_type_hint)
57+
origin = get_origin(unwrapped)
58+
return origin in (list, List)
59+
60+
61+
def check_property_type(pytype: type, as_array: bool) -> str:
62+
"""Map Python type hints to MCP property types."""
63+
if isinstance(pytype, McpPropertyType):
64+
return pytype.value
65+
base_type = unwrap_optional(pytype)
66+
if as_array:
67+
args = get_args(base_type)
68+
inner_type = unwrap_optional(args[0]) if args else str
69+
return _TYPE_MAPPING.get(inner_type, "string")
70+
return _TYPE_MAPPING.get(base_type, "string")
71+
72+
73+
def check_is_required(param: type, param_type_hint: type) -> bool:
74+
"""
75+
Return True when param is required, False when optional.
76+
77+
Rules:
78+
- If param has an explicit default -> not required
79+
- If annotation is Optional[T] (Union[..., None]) -> not required
80+
- Otherwise -> required
81+
"""
82+
# 1) default value present => not required
83+
if param.default is not inspect.Parameter.empty:
84+
return False
85+
86+
# 2) Optional[T] => not required
87+
origin = get_origin(param_type_hint)
88+
args = get_args(param_type_hint)
89+
if origin is Union and any(a is type(None) for a in args): # noqa
90+
return False
91+
92+
# 3) It's required
93+
return True
94+
95+
96+
def build_property_metadata(sig,
97+
skip_param_names: List[str],
98+
explicit_properties: dict) -> List[dict]:
99+
tool_properties = []
100+
for param_name, param in sig.parameters.items():
101+
if param_name in skip_param_names:
102+
continue
103+
param_type_hint = param.annotation if param.annotation != inspect.Parameter.empty else str # noqa
104+
105+
if param_type_hint is MCPToolContext:
106+
continue
107+
108+
# Inferred defaults
109+
is_required = check_is_required(param, param_type_hint)
110+
as_array = check_as_array(param_type_hint)
111+
property_type = check_property_type(param_type_hint, as_array)
112+
113+
property_data = {
114+
"propertyName": param_name,
115+
"propertyType": property_type,
116+
"description": "",
117+
"isArray": as_array,
118+
"isRequired": is_required
119+
}
120+
121+
# Merge in any explicit overrides
122+
if param_name in explicit_properties:
123+
overrides = explicit_properties[param_name]
124+
for key, value in overrides.items():
125+
if value is not None:
126+
property_data[key] = value
127+
128+
tool_properties.append(property_data)
129+
return tool_properties

0 commit comments

Comments
 (0)