-
Notifications
You must be signed in to change notification settings - Fork 71
feat: add support for structured content for MCP #318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
42f0c26
874cac6
402a2a0
b25ec59
835e09e
c727493
8624e87
4d9ed9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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 | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.. |
||
| 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__", {}) | ||
|
|
||
|
|
@@ -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 = {} | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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 | ||
|
|
||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
||
|
|
||
|
|
@@ -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) | ||
|
|
||
|
|
||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add a check for this ? |
||
|
|
||
| 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)): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment.
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?