|
2 | 2 | # Licensed under the MIT License. |
3 | 3 | import abc |
4 | 4 | import asyncio |
| 5 | +import functools |
| 6 | +import inspect |
5 | 7 | import json |
6 | 8 | import logging |
| 9 | +import textwrap |
| 10 | + |
7 | 11 | from abc import ABC |
8 | 12 | from datetime import time |
9 | 13 | from typing import Any, Callable, Dict, List, Optional, Union, \ |
10 | 14 | Iterable |
11 | 15 |
|
12 | 16 | from azure.functions.decorators.blob import BlobTrigger, BlobInput, BlobOutput |
13 | 17 | 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 |
15 | 20 | from azure.functions.decorators.cosmosdb import CosmosDBTrigger, \ |
16 | 21 | CosmosDBOutput, CosmosDBInput, CosmosDBTriggerV3, CosmosDBInputV3, \ |
17 | 22 | CosmosDBOutputV3 |
|
42 | 47 | AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ |
43 | 48 | semantic_search_system_prompt, \ |
44 | 49 | SemanticSearchInput, EmbeddingsStoreOutput |
45 | | -from .mcp import MCPToolTrigger |
| 50 | +from .mcp import MCPToolTrigger, build_property_metadata |
46 | 51 | from .retry_policy import RetryPolicy |
47 | 52 | from .function_name import FunctionName |
48 | 53 | from .warmup import WarmUpTrigger |
| 54 | +from ..mcp import MCPToolContext |
49 | 55 | from .._http_asgi import AsgiMiddleware |
50 | 56 | from .._http_wsgi import WsgiMiddleware, Context |
51 | 57 | from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \ |
@@ -1571,6 +1577,126 @@ def decorator(): |
1571 | 1577 |
|
1572 | 1578 | return wrap |
1573 | 1579 |
|
| 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 | + |
1574 | 1700 | def dapr_service_invocation_trigger(self, |
1575 | 1701 | arg_name: str, |
1576 | 1702 | method_name: str, |
@@ -4127,3 +4253,28 @@ def _add_http_app(self, |
4127 | 4253 | route="/{*route}") |
4128 | 4254 | def http_app_func(req: HttpRequest, context: Context): |
4129 | 4255 | 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 |
0 commit comments