Skip to content
Draft
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'
66 changes: 64 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,6 +1615,7 @@ 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:
Expand Down Expand Up @@ -1653,6 +1654,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 +1673,61 @@ 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)

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

# 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)
})

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

# Handle structured content generation when use_result_schema is True
if 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 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 +1740,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=use_result_schema
)
)
return fb
Expand Down
65 changes: 65 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,63 @@ 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.
"""
if obj is None:
return False
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)

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)):
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
88 changes: 88 additions & 0 deletions azure/functions/mcp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import typing
from dataclasses import dataclass, field, asdict
from typing import Optional, List, Any

from . import meta

Expand All @@ -12,6 +14,92 @@ class MCPToolContext(typing.Dict[str, typing.Any]):
pass


# ContentBlock types for MCP responses
@dataclass
class ContentBlock:
"""Base class for MCP content blocks."""
type: str = field(init=False)

def to_dict(self) -> dict:
"""Convert the content block to a dictionary for JSON serialization."""
return asdict(self)


@dataclass
class TextContentBlock(ContentBlock):
"""Text content block for MCP responses."""
text: str
type: str = field(default="text", init=False)


@dataclass
class ImageContentBlock(ContentBlock):
"""Image content block for MCP responses."""
data: str # base64-encoded image data
mime_type: str
type: str = field(default="image", init=False)

def to_dict(self) -> dict:
"""Convert to dict with correct JSON property names."""
return {
"type": self.type,
"data": self.data,
"mimeType": self.mime_type
}


@dataclass
class ResourceLinkBlock(ContentBlock):
"""Resource link content block for MCP responses."""
uri: str
name: Optional[str] = None
description: Optional[str] = None
mime_type: Optional[str] = None
type: str = field(default="resource", init=False)

def to_dict(self) -> dict:
"""Convert to dict with correct JSON property names."""
result = {
"type": self.type,
"uri": self.uri
}
if self.name is not None:
result["name"] = self.name
if self.description is not None:
result["description"] = self.description
if self.mime_type is not None:
result["mimeType"] = self.mime_type
return result


@dataclass
class CallToolResult:
"""
Result type for MCP tool calls that allows manual construction
of content blocks and structured content.

Example:
return CallToolResult(
content=[
TextContentBlock(text="Here's the data"),
ImageContentBlock(data=base64_data, mime_type="image/png")
],
structured_content={"key": "value"}
)
"""
content: List[ContentBlock]
structured_content: Optional[Any] = None

def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
result = {
"content": [block.to_dict() for block in self.content]
}
if self.structured_content is not None:
result["structuredContent"] = self.structured_content
return result


class _MCPToolTriggerConverter(meta.InConverter, binding='mcpToolTrigger',
trigger=True):

Expand Down
Loading
Loading