Skip to content

Commit 3d3067e

Browse files
authored
feat: add mcp_resource_trigger (#312)
* Add mcp_resource_trigger * lint * fix pipeline * add metadata property to mcp_tool
1 parent e73c6aa commit 3d3067e

6 files changed

Lines changed: 239 additions & 8 deletions

File tree

azure/functions/decorators/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,4 @@
4646
MYSQL = "mysql"
4747
MYSQL_TRIGGER = "mysqlTrigger"
4848
MCP_TOOL_TRIGGER = "mcpToolTrigger"
49+
MCP_RESOURCE_TRIGGER = "mcpResourceTrigger"

azure/functions/decorators/function_app.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
_AssistantQueryInput, _AssistantPostInput, InputType, _EmbeddingsInput, \
4848
semantic_search_system_prompt, \
4949
_SemanticSearchInput, _EmbeddingsStoreOutput
50-
from .mcp import _MCPToolTrigger, build_property_metadata
50+
from .mcp import _MCPToolTrigger, MCPResourceTrigger, build_property_metadata
5151
from .retry_policy import RetryPolicy
5252
from .function_name import FunctionName
5353
from .warmup import WarmUpTrigger
@@ -1576,7 +1576,7 @@ def decorator():
15761576

15771577
return wrap
15781578

1579-
def mcp_tool(self):
1579+
def mcp_tool(self, metadata: Optional[str] = None):
15801580
"""Decorator to register an MCP tool function.
15811581
Ref: https://aka.ms/remote-mcp-functions-python
15821582
@@ -1585,6 +1585,8 @@ def mcp_tool(self):
15851585
- Extracts docstrings as description
15861586
- Extracts parameters and types for tool properties
15871587
- Handles MCPToolContext injection
1588+
1589+
:param metadata: JSON-serialized metadata object for the tool.
15881590
"""
15891591
@self._configure_function_builder
15901592
def decorator(fb: FunctionBuilder) -> FunctionBuilder:
@@ -1649,6 +1651,7 @@ async def wrapper(context: str, *args, **kwargs):
16491651
tool_name=tool_name,
16501652
description=description,
16511653
tool_properties=tool_properties_json,
1654+
metadata=metadata
16521655
)
16531656
)
16541657
return fb
@@ -1689,6 +1692,68 @@ def decorator(func):
16891692
return func
16901693
return decorator
16911694

1695+
def mcp_resource_trigger(self,
1696+
arg_name: str,
1697+
uri: str,
1698+
resource_name: str,
1699+
title: Optional[str] = None,
1700+
description: Optional[str] = None,
1701+
mime_type: Optional[str] = None,
1702+
size: Optional[int] = None,
1703+
metadata: Optional[str] = None,
1704+
data_type: Optional[Union[DataType, str]] = None,
1705+
**kwargs) -> Callable[..., Any]:
1706+
"""The `mcp_resource_trigger` decorator adds :class:`MCPResourceTrigger` to the
1707+
:class:`FunctionBuilder` object for building a :class:`Function` object
1708+
used in the worker function indexing model.
1709+
1710+
This is equivalent to defining `MCPResourceTrigger` in the `function.json`,
1711+
which enables the function to be triggered when MCP resource requests are
1712+
received by the host.
1713+
1714+
All optional fields will be given default values by the function host when
1715+
they are parsed.
1716+
1717+
Ref: https://aka.ms/remote-mcp-functions-python
1718+
1719+
:param arg_name: The name of the trigger parameter in the function code.
1720+
:param uri: Unique URI identifier for the resource (must be absolute).
1721+
:param resource_name: Human-readable name of the resource.
1722+
:param title: Optional title for display purposes.
1723+
:param description: Optional description of the resource.
1724+
:param mime_type: Optional MIME type of the resource content.
1725+
:param size: Optional size of the resource in bytes.
1726+
:param metadata: Optional JSON-serialized metadata object.
1727+
:param data_type: Defines how the Functions runtime should treat the
1728+
parameter value.
1729+
:param kwargs: Keyword arguments for specifying additional binding
1730+
fields to include in the binding JSON.
1731+
1732+
:return: Decorator function.
1733+
"""
1734+
1735+
@self._configure_function_builder
1736+
def wrap(fb):
1737+
def decorator():
1738+
fb.add_trigger(
1739+
trigger=MCPResourceTrigger(
1740+
name=arg_name,
1741+
uri=uri,
1742+
resource_name=resource_name,
1743+
title=title,
1744+
description=description,
1745+
mime_type=mime_type,
1746+
size=size,
1747+
metadata=metadata,
1748+
data_type=parse_singular_param_to_enum(data_type,
1749+
DataType),
1750+
**kwargs))
1751+
return fb
1752+
1753+
return decorator()
1754+
1755+
return wrap
1756+
16921757
def dapr_service_invocation_trigger(self,
16931758
arg_name: str,
16941759
method_name: str,

azure/functions/decorators/mcp.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from ..mcp import MCPToolContext
99
from azure.functions.decorators.constants import (
10-
MCP_TOOL_TRIGGER
10+
MCP_TOOL_TRIGGER, MCP_RESOURCE_TRIGGER
1111
)
1212
from azure.functions.decorators.core import Trigger, DataType, McpPropertyType
1313

@@ -22,6 +22,33 @@
2222
}
2323

2424

25+
class MCPResourceTrigger(Trigger):
26+
27+
@staticmethod
28+
def get_binding_name() -> str:
29+
return MCP_RESOURCE_TRIGGER
30+
31+
def __init__(self,
32+
name: str,
33+
uri: str,
34+
resource_name: str,
35+
title: Optional[str] = None,
36+
description: Optional[str] = None,
37+
mime_type: Optional[str] = None,
38+
size: Optional[int] = None,
39+
metadata: Optional[str] = None,
40+
data_type: Optional[DataType] = None,
41+
**kwargs):
42+
self.uri = uri
43+
self.resourceName = resource_name
44+
self.title = title
45+
self.description = description
46+
self.mimeType = mime_type
47+
self.size = size
48+
self.metadata = metadata
49+
super().__init__(name=name, data_type=data_type)
50+
51+
2552
class _MCPToolTrigger(Trigger):
2653

2754
@staticmethod
@@ -33,11 +60,13 @@ def __init__(self,
3360
tool_name: str,
3461
description: Optional[str] = None,
3562
tool_properties: Optional[str] = None,
63+
metadata: Optional[str] = None,
3664
data_type: Optional[DataType] = None,
3765
**kwargs):
3866
self.tool_name = tool_name
3967
self.description = description
4068
self.tool_properties = tool_properties
69+
self.metadata = metadata
4170
super().__init__(name=name, data_type=data_type)
4271

4372

azure/functions/mcp.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,50 @@ def encode(cls, obj: typing.Any, *, expected_type: typing.Optional[type] = None)
5757
else:
5858
# Convert other types to string
5959
return meta.Datum(type='string', value=str(obj))
60+
61+
62+
class MCPResourceTriggerConverter(meta.InConverter, binding='mcpResourceTrigger',
63+
trigger=True):
64+
65+
@classmethod
66+
def check_input_type_annotation(cls, pytype: type) -> bool:
67+
return issubclass(pytype, (str, dict, bytes))
68+
69+
@classmethod
70+
def has_implicit_output(cls) -> bool:
71+
return True
72+
73+
@classmethod
74+
def decode(cls, data: meta.Datum, *, trigger_metadata):
75+
"""
76+
Decode incoming MCP resource request data.
77+
Returns the raw data in its native format (string, dict, bytes).
78+
"""
79+
# Handle different data types appropriately
80+
if data.type == 'json':
81+
# If it's already parsed JSON, use the value directly
82+
return data.value
83+
elif data.type == 'string':
84+
# If it's a string, use it as-is
85+
return data.value
86+
elif data.type == 'bytes':
87+
return data.value
88+
else:
89+
# Fallback to python_value for other types
90+
return data.python_value if hasattr(data, 'python_value') else data.value
91+
92+
@classmethod
93+
def encode(cls, obj: typing.Any, *, expected_type: typing.Optional[type] = None):
94+
"""
95+
Encode the return value from MCP resource functions.
96+
MCP resources typically return string responses.
97+
"""
98+
if obj is None:
99+
return meta.Datum(type='string', value='')
100+
elif isinstance(obj, str):
101+
return meta.Datum(type='string', value=obj)
102+
elif isinstance(obj, (bytes, bytearray)):
103+
return meta.Datum(type='bytes', value=bytes(obj))
104+
else:
105+
# Convert other types to string
106+
return meta.Datum(type='string', value=str(obj))

eng/templates/official/jobs/publish-release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ jobs:
8080
exit -1
8181
}
8282
displayName: 'Tag and push x.y.z'
83-
- powershell: |
83+
- pwsh: |
8484
$githubUser = "$(GithubUser)"
8585
$githubToken = "$(GithubPat)"
8686
$newLibraryVersion = "$(NewLibraryVersion)"
@@ -123,7 +123,7 @@ jobs:
123123
dependsOn: ['CheckGitHubRelease']
124124
displayName: 'Test with Worker'
125125
steps:
126-
- powershell: |
126+
- pwsh: |
127127
$githubUser = "$(GithubUser)"
128128
$githubToken = "$(GithubPat)"
129129
$newLibraryVersion = "$(NewLibraryVersion)"
@@ -203,7 +203,7 @@ jobs:
203203
displayName: 'Use Python 3.11'
204204
inputs:
205205
versionSpec: 3.11
206-
- powershell: |
206+
- pwsh: |
207207
$newLibraryVersion = "$(NewLibraryVersion)"
208208
$pypiToken = "$(PypiToken)"
209209

tests/decorators/test_mcp.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import azure.functions as func
77
from azure.functions import DataType, MCPToolContext
88
from azure.functions.decorators.core import BindingDirection
9-
from azure.functions.decorators.mcp import _MCPToolTrigger
10-
from azure.functions.mcp import _MCPToolTriggerConverter
9+
from azure.functions.decorators.mcp import _MCPToolTrigger, MCPResourceTrigger
10+
from azure.functions.mcp import _MCPToolTriggerConverter, MCPResourceTriggerConverter
1111
from azure.functions.meta import Datum
1212

1313

@@ -18,6 +18,7 @@ def test_mcp_tool_trigger_valid_creation(self):
1818
tool_name="hello",
1919
description="Hello world.",
2020
tool_properties="[]",
21+
metadata='{"key": "value"}',
2122
data_type=DataType.UNDEFINED,
2223
dummy_field="dummy",
2324
)
@@ -32,6 +33,7 @@ def test_mcp_tool_trigger_valid_creation(self):
3233
"type": "mcpToolTrigger",
3334
"dataType": DataType.UNDEFINED,
3435
"dummyField": "dummy",
36+
"metadata": '{"key": "value"}',
3537
"direction": BindingDirection.IN,
3638
},
3739
)
@@ -138,6 +140,28 @@ def add_numbers(a, b):
138140
'"isArray": false, '
139141
'"isRequired": true}]')
140142

143+
def test_simple_signature_defaults_metadata(self):
144+
@self.app.mcp_tool(metadata='{"key": "value"}')
145+
def add_numbers(a, b):
146+
return a + b
147+
148+
trigger = add_numbers._function._bindings[0]
149+
self.assertEqual(trigger.description, "")
150+
self.assertEqual(trigger.name, "context")
151+
self.assertEqual(trigger.tool_name, "add_numbers")
152+
self.assertEqual(trigger.metadata, '{"key": "value"}')
153+
self.assertEqual(trigger.tool_properties,
154+
'[{"propertyName": "a", '
155+
'"propertyType": "string", '
156+
'"description": "", '
157+
'"isArray": false, '
158+
'"isRequired": true}, '
159+
'{"propertyName": "b", '
160+
'"propertyType": "string", '
161+
'"description": "", '
162+
'"isArray": false, '
163+
'"isRequired": true}]')
164+
141165
def test_with_binding_argument(self):
142166
@self.app.mcp_tool()
143167
@self.app.blob_input(arg_name="file", path="", connection="Test")
@@ -415,3 +439,68 @@ def add_numbers(a) -> int:
415439
'"description": "", '
416440
'"isArray": false, '
417441
'"isRequired": true}]')
442+
443+
444+
class TestMCPResourceTrigger(unittest.TestCase):
445+
def test_mcp_resource_trigger_valid_creation(self):
446+
trigger = MCPResourceTrigger(
447+
name="context",
448+
uri="file://readme.md",
449+
resource_name="myresource",
450+
title="my title",
451+
description="my resource description",
452+
mime_type="Text/Markdown",
453+
size=1024,
454+
metadata="",
455+
data_type=DataType.UNDEFINED,
456+
dummy_field="dummy",
457+
)
458+
self.assertEqual(trigger.get_binding_name(), "mcpResourceTrigger")
459+
self.assertEqual(
460+
trigger.get_dict_repr(),
461+
{
462+
"name": "context",
463+
"uri": "file://readme.md",
464+
"resourceName": "myresource",
465+
"title": "my title",
466+
"description": "my resource description",
467+
"mimeType": "Text/Markdown",
468+
"size": 1024,
469+
"metadata": "",
470+
"type": "mcpResourceTrigger",
471+
"dataType": DataType.UNDEFINED,
472+
"dummyField": "dummy",
473+
"direction": BindingDirection.IN,
474+
},
475+
)
476+
477+
def test_mcp_resource_trigger_only_required_args_creation(self):
478+
trigger = MCPResourceTrigger(
479+
name="context",
480+
uri="file://readme.md",
481+
resource_name="myresource"
482+
)
483+
self.assertEqual(trigger.get_binding_name(), "mcpResourceTrigger")
484+
self.assertEqual(
485+
trigger.get_dict_repr(),
486+
{
487+
"name": "context",
488+
"uri": "file://readme.md",
489+
"resourceName": "myresource",
490+
"type": "mcpResourceTrigger",
491+
"direction": BindingDirection.IN,
492+
},
493+
)
494+
495+
def test_trigger_converter(self):
496+
# Test with string data
497+
datum = Datum(value='{"arguments":{}}', type='string')
498+
result = MCPResourceTriggerConverter.decode(datum, trigger_metadata={})
499+
self.assertEqual(result, '{"arguments":{}}')
500+
self.assertIsInstance(result, str)
501+
502+
# Test with json data
503+
datum_json = Datum(value={"arguments": {}}, type='json')
504+
result_json = MCPResourceTriggerConverter.decode(datum_json, trigger_metadata={})
505+
self.assertEqual(result_json, {"arguments": {}})
506+
self.assertIsInstance(result_json, dict)

0 commit comments

Comments
 (0)