From 78a495c58af5fc528a49a7b16bd1dfb0d8225071 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 2 Feb 2026 11:52:37 -0600 Subject: [PATCH 1/4] Add mcp_resource_trigger --- azure/functions/decorators/constants.py | 1 + azure/functions/decorators/function_app.py | 64 +++++++++++++++++++- azure/functions/decorators/mcp.py | 29 ++++++++- azure/functions/mcp.py | 47 +++++++++++++++ tests/decorators/test_mcp.py | 69 +++++++++++++++++++++- 5 files changed, 206 insertions(+), 4 deletions(-) diff --git a/azure/functions/decorators/constants.py b/azure/functions/decorators/constants.py index c9091128..aeeac1c9 100644 --- a/azure/functions/decorators/constants.py +++ b/azure/functions/decorators/constants.py @@ -46,3 +46,4 @@ MYSQL = "mysql" MYSQL_TRIGGER = "mysqlTrigger" MCP_TOOL_TRIGGER = "mcpToolTrigger" +MCP_RESOURCE_TRIGGER = "mcpResourceTrigger" diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index b1972fb9..a35b2053 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -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 @@ -1689,6 +1689,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, diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index 4f92dae8..a6fc4631 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -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 @@ -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 diff --git a/azure/functions/mcp.py b/azure/functions/mcp.py index 97a9c923..e6e49162 100644 --- a/azure/functions/mcp.py +++ b/azure/functions/mcp.py @@ -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)) diff --git a/tests/decorators/test_mcp.py b/tests/decorators/test_mcp.py index bf9a8fe0..0e1544be 100644 --- a/tests/decorators/test_mcp.py +++ b/tests/decorators/test_mcp.py @@ -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 @@ -415,3 +415,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) \ No newline at end of file From 205c1feeee235f04b575ed0cf698e255fb42fb5b Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 2 Feb 2026 13:01:19 -0600 Subject: [PATCH 2/4] lint --- tests/decorators/test_mcp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/decorators/test_mcp.py b/tests/decorators/test_mcp.py index 0e1544be..5f1ba49a 100644 --- a/tests/decorators/test_mcp.py +++ b/tests/decorators/test_mcp.py @@ -449,7 +449,7 @@ def test_mcp_resource_trigger_valid_creation(self): "direction": BindingDirection.IN, }, ) - + def test_mcp_resource_trigger_only_required_args_creation(self): trigger = MCPResourceTrigger( name="context", @@ -479,4 +479,4 @@ def test_trigger_converter(self): 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) \ No newline at end of file + self.assertIsInstance(result_json, dict) From 9b80b853ec7051309bdc0dd998a1b302d3bc0513 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 3 Feb 2026 11:00:45 -0600 Subject: [PATCH 3/4] fix pipeline --- eng/templates/official/jobs/publish-release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eng/templates/official/jobs/publish-release.yml b/eng/templates/official/jobs/publish-release.yml index 16fa8312..12638d00 100644 --- a/eng/templates/official/jobs/publish-release.yml +++ b/eng/templates/official/jobs/publish-release.yml @@ -80,7 +80,7 @@ jobs: exit -1 } displayName: 'Tag and push x.y.z' - - powershell: | + - pwsh: | $githubUser = "$(GithubUser)" $githubToken = "$(GithubPat)" $newLibraryVersion = "$(NewLibraryVersion)" @@ -123,7 +123,7 @@ jobs: dependsOn: ['CheckGitHubRelease'] displayName: 'Test with Worker' steps: - - powershell: | + - pwsh: | $githubUser = "$(GithubUser)" $githubToken = "$(GithubPat)" $newLibraryVersion = "$(NewLibraryVersion)" @@ -203,7 +203,7 @@ jobs: displayName: 'Use Python 3.11' inputs: versionSpec: 3.11 - - powershell: | + - pwsh: | $newLibraryVersion = "$(NewLibraryVersion)" $pypiToken = "$(PypiToken)" From a3514b60790bca7939fbff5e467aea78b5e1bb1d Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 5 Feb 2026 15:11:37 -0600 Subject: [PATCH 4/4] add metadata property to mcp_tool --- azure/functions/decorators/function_app.py | 5 ++++- azure/functions/decorators/mcp.py | 2 ++ tests/decorators/test_mcp.py | 24 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index a35b2053..c562c438 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -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 @@ -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: @@ -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 diff --git a/azure/functions/decorators/mcp.py b/azure/functions/decorators/mcp.py index a6fc4631..435b8864 100644 --- a/azure/functions/decorators/mcp.py +++ b/azure/functions/decorators/mcp.py @@ -60,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) diff --git a/tests/decorators/test_mcp.py b/tests/decorators/test_mcp.py index 5f1ba49a..2cc315f3 100644 --- a/tests/decorators/test_mcp.py +++ b/tests/decorators/test_mcp.py @@ -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", ) @@ -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, }, ) @@ -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")