Skip to content
Merged
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
1 change: 1 addition & 0 deletions azure/functions/decorators/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@
MYSQL = "mysql"
MYSQL_TRIGGER = "mysqlTrigger"
MCP_TOOL_TRIGGER = "mcpToolTrigger"
MCP_RESOURCE_TRIGGER = "mcpResourceTrigger"
69 changes: 67 additions & 2 deletions azure/functions/decorators/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
_AssistantQueryInput, _AssistantPostInput, InputType, _EmbeddingsInput, \
semantic_search_system_prompt, \
_SemanticSearchInput, _EmbeddingsStoreOutput
from .mcp import _MCPToolTrigger, build_property_metadata
from .mcp import _MCPToolTrigger, MCPResourceTrigger, build_property_metadata
from .retry_policy import RetryPolicy
from .function_name import FunctionName
from .warmup import WarmUpTrigger
Expand Down Expand Up @@ -1576,7 +1576,7 @@ def decorator():

return wrap

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

Expand All @@ -1585,6 +1585,8 @@ def mcp_tool(self):
- Extracts docstrings as description
- Extracts parameters and types for tool properties
- Handles MCPToolContext injection

:param metadata: JSON-serialized metadata object for the tool.
"""
@self._configure_function_builder
def decorator(fb: FunctionBuilder) -> FunctionBuilder:
Expand Down Expand Up @@ -1649,6 +1651,7 @@ async def wrapper(context: str, *args, **kwargs):
tool_name=tool_name,
description=description,
tool_properties=tool_properties_json,
metadata=metadata
)
)
return fb
Expand Down Expand Up @@ -1689,6 +1692,68 @@ def decorator(func):
return func
return decorator

def mcp_resource_trigger(self,
arg_name: str,
uri: str,
resource_name: str,
title: Optional[str] = None,
description: Optional[str] = None,
mime_type: Optional[str] = None,
size: Optional[int] = None,
metadata: Optional[str] = None,
data_type: Optional[Union[DataType, str]] = None,
**kwargs) -> Callable[..., Any]:
"""The `mcp_resource_trigger` decorator adds :class:`MCPResourceTrigger` to the
:class:`FunctionBuilder` object for building a :class:`Function` object
used in the worker function indexing model.

This is equivalent to defining `MCPResourceTrigger` in the `function.json`,
which enables the function to be triggered when MCP resource requests are
received by the host.

All optional fields will be given default values by the function host when
they are parsed.

Ref: https://aka.ms/remote-mcp-functions-python

:param arg_name: The name of the trigger parameter in the function code.
:param uri: Unique URI identifier for the resource (must be absolute).
:param resource_name: Human-readable name of the resource.
:param title: Optional title for display purposes.
:param description: Optional description of the resource.
:param mime_type: Optional MIME type of the resource content.
:param size: Optional size of the resource in bytes.
:param metadata: Optional JSON-serialized metadata object.
:param data_type: Defines how the Functions runtime should treat the
parameter value.
:param kwargs: Keyword arguments for specifying additional binding
fields to include in the binding JSON.

:return: Decorator function.
"""

@self._configure_function_builder
def wrap(fb):
def decorator():
fb.add_trigger(
trigger=MCPResourceTrigger(
name=arg_name,
uri=uri,
resource_name=resource_name,
title=title,
description=description,
mime_type=mime_type,
size=size,
metadata=metadata,
data_type=parse_singular_param_to_enum(data_type,
DataType),
**kwargs))
return fb

return decorator()

return wrap

def dapr_service_invocation_trigger(self,
arg_name: str,
method_name: str,
Expand Down
31 changes: 30 additions & 1 deletion azure/functions/decorators/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from ..mcp import MCPToolContext
from azure.functions.decorators.constants import (
MCP_TOOL_TRIGGER
MCP_TOOL_TRIGGER, MCP_RESOURCE_TRIGGER
)
from azure.functions.decorators.core import Trigger, DataType, McpPropertyType

Expand All @@ -22,6 +22,33 @@
}


class MCPResourceTrigger(Trigger):

@staticmethod
def get_binding_name() -> str:
return MCP_RESOURCE_TRIGGER

def __init__(self,
name: str,
uri: str,
resource_name: str,
title: Optional[str] = None,
description: Optional[str] = None,
mime_type: Optional[str] = None,
size: Optional[int] = None,
metadata: Optional[str] = None,
data_type: Optional[DataType] = None,
**kwargs):
self.uri = uri
self.resourceName = resource_name
self.title = title
self.description = description
self.mimeType = mime_type
self.size = size
self.metadata = metadata
super().__init__(name=name, data_type=data_type)


class _MCPToolTrigger(Trigger):

@staticmethod
Expand All @@ -33,11 +60,13 @@ def __init__(self,
tool_name: str,
description: Optional[str] = None,
tool_properties: Optional[str] = None,
metadata: Optional[str] = None,
data_type: Optional[DataType] = None,
**kwargs):
self.tool_name = tool_name
self.description = description
self.tool_properties = tool_properties
self.metadata = metadata
super().__init__(name=name, data_type=data_type)


Expand Down
47 changes: 47 additions & 0 deletions azure/functions/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,50 @@ def encode(cls, obj: typing.Any, *, expected_type: typing.Optional[type] = None)
else:
# Convert other types to string
return meta.Datum(type='string', value=str(obj))


class MCPResourceTriggerConverter(meta.InConverter, binding='mcpResourceTrigger',
trigger=True):

@classmethod
def check_input_type_annotation(cls, pytype: type) -> bool:
return issubclass(pytype, (str, dict, bytes))

@classmethod
def has_implicit_output(cls) -> bool:
return True

@classmethod
def decode(cls, data: meta.Datum, *, trigger_metadata):
"""
Decode incoming MCP resource request data.
Returns the raw data in its native format (string, dict, bytes).
"""
# Handle different data types appropriately
if data.type == 'json':
# If it's already parsed JSON, use the value directly
return data.value
elif data.type == 'string':
# If it's a string, use it as-is
return data.value
elif data.type == 'bytes':
return data.value
else:
# Fallback to python_value for other types
return data.python_value if hasattr(data, 'python_value') else data.value

@classmethod
def encode(cls, obj: typing.Any, *, expected_type: typing.Optional[type] = None):
"""
Encode the return value from MCP resource functions.
MCP resources typically return string responses.
"""
if obj is None:
return meta.Datum(type='string', value='')
elif isinstance(obj, str):
return meta.Datum(type='string', value=obj)
elif isinstance(obj, (bytes, bytearray)):
return meta.Datum(type='bytes', value=bytes(obj))
else:
# Convert other types to string
return meta.Datum(type='string', value=str(obj))
6 changes: 3 additions & 3 deletions eng/templates/official/jobs/publish-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
exit -1
}
displayName: 'Tag and push x.y.z'
- powershell: |
- pwsh: |
$githubUser = "$(GithubUser)"
$githubToken = "$(GithubPat)"
$newLibraryVersion = "$(NewLibraryVersion)"
Expand Down Expand Up @@ -123,7 +123,7 @@ jobs:
dependsOn: ['CheckGitHubRelease']
displayName: 'Test with Worker'
steps:
- powershell: |
- pwsh: |
$githubUser = "$(GithubUser)"
$githubToken = "$(GithubPat)"
$newLibraryVersion = "$(NewLibraryVersion)"
Expand Down Expand Up @@ -203,7 +203,7 @@ jobs:
displayName: 'Use Python 3.11'
inputs:
versionSpec: 3.11
- powershell: |
- pwsh: |
$newLibraryVersion = "$(NewLibraryVersion)"
$pypiToken = "$(PypiToken)"

Expand Down
93 changes: 91 additions & 2 deletions tests/decorators/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import azure.functions as func
from azure.functions import DataType, MCPToolContext
from azure.functions.decorators.core import BindingDirection
from azure.functions.decorators.mcp import _MCPToolTrigger
from azure.functions.mcp import _MCPToolTriggerConverter
from azure.functions.decorators.mcp import _MCPToolTrigger, MCPResourceTrigger
from azure.functions.mcp import _MCPToolTriggerConverter, MCPResourceTriggerConverter
from azure.functions.meta import Datum


Expand All @@ -18,6 +18,7 @@ def test_mcp_tool_trigger_valid_creation(self):
tool_name="hello",
description="Hello world.",
tool_properties="[]",
metadata='{"key": "value"}',
data_type=DataType.UNDEFINED,
dummy_field="dummy",
)
Expand All @@ -32,6 +33,7 @@ def test_mcp_tool_trigger_valid_creation(self):
"type": "mcpToolTrigger",
"dataType": DataType.UNDEFINED,
"dummyField": "dummy",
"metadata": '{"key": "value"}',
"direction": BindingDirection.IN,
},
)
Expand Down Expand Up @@ -138,6 +140,28 @@ def add_numbers(a, b):
'"isArray": false, '
'"isRequired": true}]')

def test_simple_signature_defaults_metadata(self):
@self.app.mcp_tool(metadata='{"key": "value"}')
def add_numbers(a, b):
return a + b

trigger = add_numbers._function._bindings[0]
self.assertEqual(trigger.description, "")
self.assertEqual(trigger.name, "context")
self.assertEqual(trigger.tool_name, "add_numbers")
self.assertEqual(trigger.metadata, '{"key": "value"}')
self.assertEqual(trigger.tool_properties,
'[{"propertyName": "a", '
'"propertyType": "string", '
'"description": "", '
'"isArray": false, '
'"isRequired": true}, '
'{"propertyName": "b", '
'"propertyType": "string", '
'"description": "", '
'"isArray": false, '
'"isRequired": true}]')

def test_with_binding_argument(self):
@self.app.mcp_tool()
@self.app.blob_input(arg_name="file", path="", connection="Test")
Expand Down Expand Up @@ -415,3 +439,68 @@ def add_numbers(a) -> int:
'"description": "", '
'"isArray": false, '
'"isRequired": true}]')


class TestMCPResourceTrigger(unittest.TestCase):
def test_mcp_resource_trigger_valid_creation(self):
trigger = MCPResourceTrigger(
name="context",
uri="file://readme.md",
resource_name="myresource",
title="my title",
description="my resource description",
mime_type="Text/Markdown",
size=1024,
metadata="",
data_type=DataType.UNDEFINED,
dummy_field="dummy",
)
self.assertEqual(trigger.get_binding_name(), "mcpResourceTrigger")
self.assertEqual(
trigger.get_dict_repr(),
{
"name": "context",
"uri": "file://readme.md",
"resourceName": "myresource",
"title": "my title",
"description": "my resource description",
"mimeType": "Text/Markdown",
"size": 1024,
"metadata": "",
"type": "mcpResourceTrigger",
"dataType": DataType.UNDEFINED,
"dummyField": "dummy",
"direction": BindingDirection.IN,
},
)

def test_mcp_resource_trigger_only_required_args_creation(self):
trigger = MCPResourceTrigger(
name="context",
uri="file://readme.md",
resource_name="myresource"
)
self.assertEqual(trigger.get_binding_name(), "mcpResourceTrigger")
self.assertEqual(
trigger.get_dict_repr(),
{
"name": "context",
"uri": "file://readme.md",
"resourceName": "myresource",
"type": "mcpResourceTrigger",
"direction": BindingDirection.IN,
},
)

def test_trigger_converter(self):
# Test with string data
datum = Datum(value='{"arguments":{}}', type='string')
result = MCPResourceTriggerConverter.decode(datum, trigger_metadata={})
self.assertEqual(result, '{"arguments":{}}')
self.assertIsInstance(result, str)

# Test with json data
datum_json = Datum(value={"arguments": {}}, type='json')
result_json = MCPResourceTriggerConverter.decode(datum_json, trigger_metadata={})
self.assertEqual(result_json, {"arguments": {}})
self.assertIsInstance(result_json, dict)