Skip to content
Open
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
14 changes: 12 additions & 2 deletions azure/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Cardinality, AccessRights, HttpMethod,
AsgiFunctionApp, WsgiFunctionApp,
ExternalHttpFunctionApp, BlobSource, McpPropertyType)
from .decorators.mcp import mcp_content
from ._durable_functions import OrchestrationContext, EntityContext
from .decorators.function_app import (FunctionRegister, TriggerApi,
BindingApi, SettingsApi)
Expand All @@ -19,7 +20,8 @@
from ._http_wsgi import WsgiMiddleware
from ._http_asgi import AsgiMiddleware
from .kafka import KafkaEvent, KafkaConverter, KafkaTriggerConverter
from .mcp import MCPToolContext
from .mcp import (MCPToolContext, ContentBlock, TextContentBlock,
ImageContentBlock, ResourceLinkBlock, CallToolResult)
from .meta import get_binding_registry
from ._queue import QueueMessage
from ._servicebus import ServiceBusMessage
Expand Down Expand Up @@ -104,7 +106,15 @@
'HttpMethod',
'BlobSource',
'MCPToolContext',
'McpPropertyType'
'McpPropertyType',
'mcp_content',

# MCP ContentBlock types
'ContentBlock',
'TextContentBlock',
'ImageContentBlock',
'ResourceLinkBlock',
'CallToolResult'
)

__version__ = '1.25.0b4'
132 changes: 130 additions & 2 deletions azure/functions/decorators/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1604,7 +1604,7 @@ def decorator():

return wrap

def mcp_tool(self, metadata: Optional[str] = None):
def mcp_tool(self, metadata: Optional[str] = None, use_result_schema: Optional[bool] = False):
"""Decorator to register an MCP tool function.
Ref: https://aka.ms/remote-mcp-functions-python

Expand All @@ -1615,12 +1615,74 @@ def mcp_tool(self, metadata: Optional[str] = None):
- Handles MCPToolContext injection

:param metadata: JSON-serialized metadata object for the tool.
:param use_result_schema: Whether the result schema should be
provided by the worker instead of being generated by the host
extension.
"""
@self._configure_function_builder
def decorator(fb: FunctionBuilder) -> FunctionBuilder:
target_func = fb._function.get_user_function()
sig = inspect.signature(target_func)

# Auto-detect MCP return types and set use_result_schema=True
# Use a separate variable to avoid UnboundLocalError
auto_use_result_schema = use_result_schema
return_annotation = sig.return_annotation
if return_annotation != inspect.Signature.empty and not auto_use_result_schema:
from azure.functions.mcp import ContentBlock, CallToolResult
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These imports will happen at every call. Can we move this to the top?

from azure.functions.decorators.mcp import has_mcp_content_marker

# Check if return type is a ContentBlock subclass
is_content_block = False
is_call_tool_result = False
is_mcp_content = False

try:
# Handle direct ContentBlock or CallToolResult
if isinstance(return_annotation, type):
if issubclass(return_annotation, ContentBlock):
is_content_block = True
elif issubclass(return_annotation, CallToolResult):
is_call_tool_result = True
elif has_mcp_content_marker(return_annotation):
is_mcp_content = True
except TypeError:
pass

# Handle List[ContentBlock] and other generic types
if hasattr(return_annotation, '__origin__'):
import typing
origin = typing.get_origin(return_annotation)
args = typing.get_args(return_annotation)

# Check for List[ContentBlock] or list[ContentBlock]
if origin in (list, List) and args:
try:
if issubclass(args[0], ContentBlock):
is_content_block = True
except TypeError:
pass

# Check for Optional[T] where T is an MCP type
if origin is Union:
for arg in args:
if isinstance(arg, type(None)):
continue
try:
if isinstance(arg, type):
if issubclass(arg, (ContentBlock, CallToolResult)):
is_content_block = True
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here if arg is CallToolResult, you're setting is_content_block = True instead of is_call_tool_result = True..
Lets break this into 2 if conditions
if issubclass(arg, ContentBlock):
...
elif issubclass(arg, CallToolResult):
...

break
elif has_mcp_content_marker(arg):
is_mcp_content = True
break
except TypeError:
pass

# Auto-enable use_result_schema for MCP types
if is_content_block or is_call_tool_result or is_mcp_content:
auto_use_result_schema = True

# Pull any explicitly declared MCP tool properties
explicit_properties = getattr(target_func, "__mcp_tool_properties__", {})

Expand Down Expand Up @@ -1653,6 +1715,11 @@ def decorator(fb: FunctionBuilder) -> FunctionBuilder:
# Wrap the original function
@functools.wraps(target_func)
async def wrapper(context: str, *args, **kwargs):
from azure.functions.mcp import (
ContentBlock, CallToolResult)
from azure.functions.decorators.mcp import should_create_structured_content
import dataclasses

content = json.loads(context)
arguments = content.get("arguments", {})
call_kwargs = {}
Expand All @@ -1667,6 +1734,66 @@ async def wrapper(context: str, *args, **kwargs):
result = target_func(**call_kwargs)
if asyncio.iscoroutine(result):
result = await result

if result is None:
return str(result)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will return "None" . Maybe return ""


# Handle CallToolResult - manual construction by user
if isinstance(result, CallToolResult):
result_dict = result.to_dict()
structured = (json.dumps(result.structured_content)
if result.structured_content else None)
return json.dumps({
"type": "call_tool_result",
"content": json.dumps(result_dict),
"structuredContent": structured
})

# Handle List[ContentBlock] - multiple content blocks
if isinstance(result, list) and all(
isinstance(item, ContentBlock) for item in result):
content_blocks = [block.to_dict() for block in result]
return json.dumps({
"type": "multi_content_result",
"content": json.dumps(content_blocks),
"structuredContent": json.dumps(content_blocks)
})

# Handle single ContentBlock
if isinstance(result, ContentBlock):
block_dict = result.to_dict()
return str(json.dumps({
"type": result.type,
"content": json.dumps(block_dict),
"structuredContent": json.dumps(block_dict)
}))

# Handle structured content generation when
# auto_use_result_schema is True
if auto_use_result_schema:
# Check if we should create structured content
if should_create_structured_content(result):
# Serialize result as JSON for structured content
# Handle dataclasses properly
if dataclasses.is_dataclass(result):
result_json = json.dumps(
dataclasses.asdict(result))
elif hasattr(result, '__dict__'):
# For regular classes with __dict__
result_json = json.dumps(result.__dict__)
else:
# Fallback to str conversion
result_json = json.dumps(
result) if not isinstance(
result, str) else result

# Return McpToolResult format with both text and structured content
return str(json.dumps({
"type": "text",
"content": json.dumps({"type": "text", "text": result_json}),
"structuredContent": result_json
}))

return str(result)

wrapper.__signature__ = wrapper_sig
Expand All @@ -1679,7 +1806,8 @@ async def wrapper(context: str, *args, **kwargs):
tool_name=tool_name,
description=description,
tool_properties=tool_properties_json,
metadata=metadata
metadata=metadata,
use_result_schema=auto_use_result_schema
)
)
return fb
Expand Down
72 changes: 72 additions & 0 deletions azure/functions/decorators/mcp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import inspect
import typing

from typing import List, Optional, Union, get_origin, get_args
from datetime import datetime
Expand Down Expand Up @@ -37,6 +38,7 @@ def __init__(self,
mime_type: Optional[str] = None,
size: Optional[int] = None,
metadata: Optional[str] = None,
use_result_schema: Optional[bool] = False,
data_type: Optional[DataType] = None,
**kwargs):
self.uri = uri
Expand All @@ -46,6 +48,7 @@ def __init__(self,
self.mimeType = mime_type
self.size = size
self.metadata = metadata
self.useResultSchema = use_result_schema
super().__init__(name=name, data_type=data_type)


Expand All @@ -61,12 +64,14 @@ def __init__(self,
description: Optional[str] = None,
tool_properties: Optional[str] = None,
metadata: Optional[str] = None,
use_result_schema: Optional[bool] = False,
data_type: Optional[DataType] = None,
**kwargs):
self.tool_name = tool_name
self.description = description
self.tool_properties = tool_properties
self.metadata = metadata
self.use_result_schema = use_result_schema
super().__init__(name=name, data_type=data_type)


Expand Down Expand Up @@ -156,3 +161,70 @@ def build_property_metadata(sig,

tool_properties.append(property_data)
return tool_properties


def has_mcp_content_marker(obj: typing.Any) -> bool:
"""
Check if an object or its type is marked for structured content generation.
Returns True if the object's class has '__mcp_content__' attribute set to True.
Handles both class types and instances.
"""
if obj is None:
return False

# If obj is already a class type, check it directly
if isinstance(obj, type):
return getattr(obj, '__mcp_content__', False) is True

# Otherwise, get the type and check
obj_type = type(obj)
return getattr(obj_type, '__mcp_content__', False) is True


def should_create_structured_content(obj: typing.Any) -> bool:
"""
Determines whether structured content should be created for the given object.

Returns True if:
- The object's class is decorated with a marker that sets __mcp_content__ = True
- The object is not a primitive type (str, int, float, bool, None)
- The object is not a dict or list (unless explicitly marked)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a check for this ?
if isinstance(obj, (dict, list)):
return False


This mimics the .NET implementation's McpContentAttribute checking.
"""
if obj is None:
return False

# Primitive types don't generate structured content unless explicitly marked
if isinstance(obj, (str, int, float, bool)):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: bool is a subclass of int so its just a redundant check. (str, int, float) should be enough

return False

# Check for the marker attribute
return has_mcp_content_marker(obj)


def mcp_content(cls):
"""
Decorator to mark a class as an MCP result type that should be serialized
as structured content.

When a function returns an object of a type decorated with this decorator,
the result will be serialized as both text content (for backwards compatibility)
and structured content (for clients that support it).

This is the Python equivalent of C#'s [McpContent] attribute.

Example:
@mcp_content
class ImageMetadata:
def __init__(self, image_id: str, format: str, tags: list):
self.image_id = image_id
self.format = format
self.tags = tags

@app.mcp_tool(use_result_schema=True)
def get_image_info():
return ImageMetadata("logo", "png", ["functions"])
"""
cls.__mcp_content__ = True
return cls
Loading
Loading