agent-framework-declarative 1.4.0__tar.gz → 1.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/PKG-INFO +40 -22
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/README.md +37 -18
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_loader.py +4 -9
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_declarative_base.py +1 -7
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_mcp.py +6 -47
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_tools.py +26 -3
- agent_framework_declarative-1.5.0/agent_framework_declarative/_workflows/_mcp_handler.py +254 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/pyproject.toml +7 -5
- agent_framework_declarative-1.4.0/agent_framework_declarative/_workflows/_mcp_handler.py +0 -686
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/LICENSE +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/__init__.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_feature_usage.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_models.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_declarative_builder.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_agents.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_basic.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_factory.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_http_handler.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_powerfx_limits.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_state.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_state_path.py +0 -0
- {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/py.typed +0 -0
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agent-framework-declarative
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Declarative specification support for Microsoft Agent Framework.
|
|
5
5
|
Author-email: Microsoft <af-support@microsoft.com>
|
|
6
|
-
Requires-Python: >=3.
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Classifier: License :: OSI Approved :: MIT License
|
|
9
9
|
Classifier: Development Status :: 5 - Production/Stable
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
15
|
Classifier: Typing :: Typed
|
|
17
16
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: agent-framework-core>=1.
|
|
17
|
+
Requires-Dist: agent-framework-core>=1.25.0,<2
|
|
19
18
|
Requires-Dist: httpx>=0.27,<0.29
|
|
20
19
|
Requires-Dist: httpx2>=2.0.0,<3
|
|
21
20
|
Requires-Dist: powerfx>=0.0.32,<0.0.35; python_version < '3.14'
|
|
@@ -34,7 +33,7 @@ Please install this package via pip:
|
|
|
34
33
|
pip install agent-framework-declarative
|
|
35
34
|
```
|
|
36
35
|
|
|
37
|
-
The package currently advertises Python 3.
|
|
36
|
+
The package currently advertises Python 3.11–3.13 support. Python 3.14 and
|
|
38
37
|
3.15 can install the package, but Power Fx expressions are unavailable because
|
|
39
38
|
the `powerfx` dependency requires a `pythonnet` build that does not support
|
|
40
39
|
those Python versions.
|
|
@@ -100,23 +99,42 @@ on HTTPX internals. Compatibility tests cover HTTPX 0.27.0 and 0.28.1.
|
|
|
100
99
|
|
|
101
100
|
## MCP handler session lifetime
|
|
102
101
|
|
|
103
|
-
`DefaultMCPToolHandler
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
or
|
|
102
|
+
**Security fix:** `DefaultMCPToolHandler` creates and closes a fresh MCP
|
|
103
|
+
tool/session for every invocation, including `tools/list`. Independently
|
|
104
|
+
constructed workflows may safely share a handler without sharing MCP protocol
|
|
105
|
+
state, even with identical endpoint and header configuration. There is no
|
|
106
|
+
initialized-client or discovery cache.
|
|
108
107
|
|
|
109
108
|
Each invocation closes its tool/session on success, failure, or cancellation.
|
|
110
|
-
Internally created
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
109
|
+
Internally created HTTP clients are also closed. The optional `client_provider`
|
|
110
|
+
is called once per invocation and may return a reusable `httpx.AsyncClient`
|
|
111
|
+
to share an HTTP connection pool, not an MCP session. Returned clients are
|
|
112
|
+
borrowed and never closed by the handler. Returning `None` selects an internally
|
|
113
|
+
owned client.
|
|
114
|
+
|
|
115
|
+
Provider, connection, discovery, and transport failures raise exceptions and fail
|
|
116
|
+
the workflow action, including after approval. Remote tool error responses still
|
|
117
|
+
produce `MCPToolResult(is_error=True)` and populate `output.result` with an error
|
|
118
|
+
string. Custom handlers should return error results only for tool-level errors;
|
|
119
|
+
raised exceptions are not converted into workflow results.
|
|
120
|
+
|
|
121
|
+
The legacy `cache_max_size` parameter is accepted for compatibility, but is
|
|
122
|
+
deprecated and ignored. Passing any non-`None` value emits `DeprecationWarning`;
|
|
123
|
+
the value is not validated. Omitting it or passing `None` emits no warning.
|
|
124
|
+
It cannot restore session caching or sharing.
|
|
125
|
+
|
|
126
|
+
The handler no longer implements `aclose()` or an async context manager.
|
|
127
|
+
Replace `async with DefaultMCPToolHandler() as handler:`
|
|
128
|
+
with `handler = DefaultMCPToolHandler()` and remove handler shutdown calls.
|
|
129
|
+
Continue to manage caller-owned HTTP clients with their own context manager.
|
|
130
|
+
Hosts must cancel and await active invocations before closing borrowed clients;
|
|
131
|
+
admission limits and deadlines also belong to the host.
|
|
132
|
+
|
|
133
|
+
This intentionally adds connection and initialization overhead. Implicit
|
|
134
|
+
server-side state does not persist across tool calls, approval waits, or
|
|
135
|
+
checkpoint resumes. Workflow checkpoints and approval verification are
|
|
136
|
+
unchanged; no request-scope migration flag is required. Applications that need
|
|
137
|
+
MCP session continuity must provide an explicitly scoped custom `MCPToolHandler`
|
|
138
|
+
or pass explicit remote state handles as tool arguments. See the
|
|
139
|
+
[decision record](../../../docs/decisions/0053-python-invocation-owned-mcp-sessions.md).
|
|
122
140
|
|
|
@@ -6,7 +6,7 @@ Please install this package via pip:
|
|
|
6
6
|
pip install agent-framework-declarative
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
The package currently advertises Python 3.
|
|
9
|
+
The package currently advertises Python 3.11–3.13 support. Python 3.14 and
|
|
10
10
|
3.15 can install the package, but Power Fx expressions are unavailable because
|
|
11
11
|
the `powerfx` dependency requires a `pythonnet` build that does not support
|
|
12
12
|
those Python versions.
|
|
@@ -72,22 +72,41 @@ on HTTPX internals. Compatibility tests cover HTTPX 0.27.0 and 0.28.1.
|
|
|
72
72
|
|
|
73
73
|
## MCP handler session lifetime
|
|
74
74
|
|
|
75
|
-
`DefaultMCPToolHandler
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
or
|
|
75
|
+
**Security fix:** `DefaultMCPToolHandler` creates and closes a fresh MCP
|
|
76
|
+
tool/session for every invocation, including `tools/list`. Independently
|
|
77
|
+
constructed workflows may safely share a handler without sharing MCP protocol
|
|
78
|
+
state, even with identical endpoint and header configuration. There is no
|
|
79
|
+
initialized-client or discovery cache.
|
|
80
80
|
|
|
81
81
|
Each invocation closes its tool/session on success, failure, or cancellation.
|
|
82
|
-
Internally created
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
82
|
+
Internally created HTTP clients are also closed. The optional `client_provider`
|
|
83
|
+
is called once per invocation and may return a reusable `httpx.AsyncClient`
|
|
84
|
+
to share an HTTP connection pool, not an MCP session. Returned clients are
|
|
85
|
+
borrowed and never closed by the handler. Returning `None` selects an internally
|
|
86
|
+
owned client.
|
|
87
|
+
|
|
88
|
+
Provider, connection, discovery, and transport failures raise exceptions and fail
|
|
89
|
+
the workflow action, including after approval. Remote tool error responses still
|
|
90
|
+
produce `MCPToolResult(is_error=True)` and populate `output.result` with an error
|
|
91
|
+
string. Custom handlers should return error results only for tool-level errors;
|
|
92
|
+
raised exceptions are not converted into workflow results.
|
|
93
|
+
|
|
94
|
+
The legacy `cache_max_size` parameter is accepted for compatibility, but is
|
|
95
|
+
deprecated and ignored. Passing any non-`None` value emits `DeprecationWarning`;
|
|
96
|
+
the value is not validated. Omitting it or passing `None` emits no warning.
|
|
97
|
+
It cannot restore session caching or sharing.
|
|
98
|
+
|
|
99
|
+
The handler no longer implements `aclose()` or an async context manager.
|
|
100
|
+
Replace `async with DefaultMCPToolHandler() as handler:`
|
|
101
|
+
with `handler = DefaultMCPToolHandler()` and remove handler shutdown calls.
|
|
102
|
+
Continue to manage caller-owned HTTP clients with their own context manager.
|
|
103
|
+
Hosts must cancel and await active invocations before closing borrowed clients;
|
|
104
|
+
admission limits and deadlines also belong to the host.
|
|
105
|
+
|
|
106
|
+
This intentionally adds connection and initialization overhead. Implicit
|
|
107
|
+
server-side state does not persist across tool calls, approval waits, or
|
|
108
|
+
checkpoint resumes. Workflow checkpoints and approval verification are
|
|
109
|
+
unchanged; no request-scope migration flag is required. Applications that need
|
|
110
|
+
MCP session continuity must provide an explicitly scoped custom `MCPToolHandler`
|
|
111
|
+
or pass explicit remote state handles as tool arguments. See the
|
|
112
|
+
[decision record](../../../docs/decisions/0053-python-invocation-owned-mcp-sessions.md).
|
|
@@ -3,10 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import sys
|
|
7
6
|
from collections.abc import Callable, Mapping
|
|
8
7
|
from pathlib import Path
|
|
9
|
-
from typing import Any, cast
|
|
8
|
+
from typing import Any, TypedDict, cast
|
|
10
9
|
|
|
11
10
|
import yaml
|
|
12
11
|
from agent_framework import (
|
|
@@ -44,11 +43,6 @@ from ._models import (
|
|
|
44
43
|
agent_schema_dispatch,
|
|
45
44
|
)
|
|
46
45
|
|
|
47
|
-
if sys.version_info >= (3, 11):
|
|
48
|
-
from typing import TypedDict # pragma: no cover
|
|
49
|
-
else:
|
|
50
|
-
from typing_extensions import TypedDict # pragma: no cover
|
|
51
|
-
|
|
52
46
|
|
|
53
47
|
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
|
|
54
48
|
class ProviderTypeMapping(TypedDict, total=True):
|
|
@@ -743,8 +737,9 @@ class AgentFactory:
|
|
|
743
737
|
additional_properties = options.additionalProperties.copy()
|
|
744
738
|
if (chat_tool_mode := additional_properties.pop("chatToolMode", None)) is not None:
|
|
745
739
|
chat_options["tool_choice"] = chat_tool_mode
|
|
746
|
-
|
|
747
|
-
|
|
740
|
+
# Pass the rest through as regular options; the typed fields above win on conflicts.
|
|
741
|
+
for key, value in additional_properties.items():
|
|
742
|
+
chat_options.setdefault(key, value)
|
|
748
743
|
return chat_options
|
|
749
744
|
|
|
750
745
|
def _parse_tools(self, tools: list[Tool] | None) -> list[AFFunctionTool | dict[str, Any]] | None:
|
|
@@ -29,14 +29,13 @@ import locale
|
|
|
29
29
|
import logging
|
|
30
30
|
import os
|
|
31
31
|
import re
|
|
32
|
-
import sys
|
|
33
32
|
import uuid
|
|
34
33
|
from collections.abc import Iterator, Mapping
|
|
35
34
|
from dataclasses import dataclass, field
|
|
36
35
|
from decimal import Decimal as _Decimal
|
|
37
36
|
from enum import Enum
|
|
38
37
|
from types import MappingProxyType
|
|
39
|
-
from typing import Any, Literal, cast
|
|
38
|
+
from typing import Any, Literal, TypedDict, cast
|
|
40
39
|
|
|
41
40
|
from agent_framework import (
|
|
42
41
|
Executor,
|
|
@@ -60,11 +59,6 @@ except (ImportError, RuntimeError):
|
|
|
60
59
|
# RuntimeError: .NET runtime not available or misconfigured
|
|
61
60
|
Engine = None
|
|
62
61
|
|
|
63
|
-
if sys.version_info >= (3, 11):
|
|
64
|
-
from typing import TypedDict # pragma: no cover
|
|
65
|
-
else:
|
|
66
|
-
from typing_extensions import TypedDict # pragma: no cover
|
|
67
|
-
|
|
68
62
|
|
|
69
63
|
logger = logging.getLogger(__name__)
|
|
70
64
|
|
|
@@ -23,6 +23,8 @@ Security notes:
|
|
|
23
23
|
authors must trust the MCP server they invoke.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
26
28
|
import hmac
|
|
27
29
|
import json
|
|
28
30
|
import logging
|
|
@@ -32,7 +34,6 @@ from collections.abc import Mapping
|
|
|
32
34
|
from dataclasses import dataclass, field
|
|
33
35
|
from typing import Any
|
|
34
36
|
|
|
35
|
-
import httpx2 as httpx
|
|
36
37
|
from agent_framework import (
|
|
37
38
|
Content,
|
|
38
39
|
Message,
|
|
@@ -40,7 +41,6 @@ from agent_framework import (
|
|
|
40
41
|
handler,
|
|
41
42
|
response_handler,
|
|
42
43
|
)
|
|
43
|
-
from agent_framework.exceptions import ToolExecutionException
|
|
44
44
|
|
|
45
45
|
from ._declarative_base import (
|
|
46
46
|
ActionComplete,
|
|
@@ -177,6 +177,8 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
177
177
|
- On error returned by the handler (``is_error=True``): assigns
|
|
178
178
|
``"Error: <message>"`` to ``output.result`` and completes normally
|
|
179
179
|
(parity with .NET ``AssignErrorAsync``).
|
|
180
|
+
- Setup, transport, and protocol exceptions propagate and fail the action,
|
|
181
|
+
including when resuming an approved invocation.
|
|
180
182
|
|
|
181
183
|
.. note::
|
|
182
184
|
|
|
@@ -243,7 +245,7 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
243
245
|
)
|
|
244
246
|
return
|
|
245
247
|
|
|
246
|
-
result = await self.
|
|
248
|
+
result = await self._mcp_tool_handler.invoke_tool(invocation)
|
|
247
249
|
await self._process_result(
|
|
248
250
|
ctx=ctx,
|
|
249
251
|
state=state,
|
|
@@ -357,7 +359,7 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
357
359
|
)
|
|
358
360
|
await self._request_approval(invocation, dict(metadata), ctx)
|
|
359
361
|
return
|
|
360
|
-
result = await self.
|
|
362
|
+
result = await self._mcp_tool_handler.invoke_tool(invocation)
|
|
361
363
|
await self._process_result(
|
|
362
364
|
ctx=ctx,
|
|
363
365
|
state=state,
|
|
@@ -472,49 +474,6 @@ class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
|
472
474
|
return evaluated.strip().lower() in {"true", "1", "yes"}
|
|
473
475
|
return bool(evaluated)
|
|
474
476
|
|
|
475
|
-
# ----- Invocation + error handling ----------------------------------------
|
|
476
|
-
|
|
477
|
-
async def _invoke_with_narrow_catch(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
478
|
-
"""Invoke the handler with a narrow exception catch.
|
|
479
|
-
|
|
480
|
-
Only known transport / tool exceptions are normalised to an error
|
|
481
|
-
result. Programmer bugs (TypeError, ValueError from misuse, etc.)
|
|
482
|
-
propagate so they fail loudly.
|
|
483
|
-
|
|
484
|
-
``asyncio.CancelledError`` is a ``BaseException``, not ``Exception``,
|
|
485
|
-
so it is not caught here and propagates unchanged for workflow
|
|
486
|
-
cancellation.
|
|
487
|
-
"""
|
|
488
|
-
try:
|
|
489
|
-
return await self._mcp_tool_handler.invoke_tool(invocation)
|
|
490
|
-
except ToolExecutionException as exc:
|
|
491
|
-
message = str(exc) or type(exc).__name__
|
|
492
|
-
return MCPToolResult(
|
|
493
|
-
outputs=[Content.from_text(f"Error: {message}")],
|
|
494
|
-
is_error=True,
|
|
495
|
-
error_message=message,
|
|
496
|
-
)
|
|
497
|
-
except httpx.HTTPError as exc:
|
|
498
|
-
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
|
499
|
-
return MCPToolResult(
|
|
500
|
-
outputs=[Content.from_text(f"Error: {message}")],
|
|
501
|
-
is_error=True,
|
|
502
|
-
error_message=message,
|
|
503
|
-
)
|
|
504
|
-
except Exception as exc:
|
|
505
|
-
try:
|
|
506
|
-
from mcp.shared.exceptions import MCPError
|
|
507
|
-
except ImportError: # pragma: no cover - mcp is a hard dep
|
|
508
|
-
raise
|
|
509
|
-
if isinstance(exc, MCPError):
|
|
510
|
-
message = str(exc) or type(exc).__name__
|
|
511
|
-
return MCPToolResult(
|
|
512
|
-
outputs=[Content.from_text(f"Error: {message}")],
|
|
513
|
-
is_error=True,
|
|
514
|
-
error_message=message,
|
|
515
|
-
)
|
|
516
|
-
raise
|
|
517
|
-
|
|
518
477
|
# ----- Result handling -----------------------------------------------------
|
|
519
478
|
|
|
520
479
|
async def _process_result(
|
|
@@ -243,6 +243,27 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
243
243
|
|
|
244
244
|
return None
|
|
245
245
|
|
|
246
|
+
def _registered_tool_requires_approval(
|
|
247
|
+
self,
|
|
248
|
+
function_name: str,
|
|
249
|
+
ctx: WorkflowContext[Any, Any],
|
|
250
|
+
) -> bool:
|
|
251
|
+
"""Check whether the registered tool requires approval on every invocation.
|
|
252
|
+
|
|
253
|
+
A tool registered with ``approval_mode="always_require"`` must not run
|
|
254
|
+
without approval, even when the action omits ``requireApproval`` or sets
|
|
255
|
+
it to false.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
function_name: Name of the function
|
|
259
|
+
ctx: Workflow context
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
True if the registered tool requires approval, False otherwise
|
|
263
|
+
"""
|
|
264
|
+
tool = self._get_tool(function_name, ctx)
|
|
265
|
+
return getattr(tool, "approval_mode", None) == "always_require"
|
|
266
|
+
|
|
246
267
|
def _get_output_config(self) -> tuple[str | None, str | None, Any]:
|
|
247
268
|
"""Parse output bindings and the unevaluated autoSend setting.
|
|
248
269
|
|
|
@@ -433,7 +454,8 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
433
454
|
) -> None:
|
|
434
455
|
"""Handle the tool invocation with optional approval flow.
|
|
435
456
|
|
|
436
|
-
When requireApproval=true
|
|
457
|
+
When requireApproval=true, or the registered tool has
|
|
458
|
+
approval_mode="always_require":
|
|
437
459
|
1. Saves invocation state to State (keyed by executor ID)
|
|
438
460
|
2. Emits ToolApprovalRequest via ctx.request_info()
|
|
439
461
|
3. Workflow yields (returns without ActionComplete)
|
|
@@ -477,10 +499,11 @@ class BaseToolExecutor(DeclarativeActionExecutor):
|
|
|
477
499
|
for key, value in arguments_def.items(): # type: ignore[reportUnknownVariableType]
|
|
478
500
|
arguments[key] = state.eval_if_expression(value)
|
|
479
501
|
|
|
480
|
-
# Check if approval is required
|
|
502
|
+
# Check if approval is required. The action's requireApproval cannot
|
|
503
|
+
# override a registered tool's approval_mode="always_require".
|
|
481
504
|
require_approval = self._action_def.get("requireApproval", False)
|
|
482
505
|
|
|
483
|
-
if require_approval:
|
|
506
|
+
if require_approval or self._registered_tool_requires_approval(function_name, ctx):
|
|
484
507
|
# Emit approval request - the request payload is the source of
|
|
485
508
|
# truth for resumed invocation; no side-channel state is written.
|
|
486
509
|
request_id = str(uuid.uuid4())
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""MCP tool handlers for declarative workflows.
|
|
4
|
+
|
|
5
|
+
The default handler owns a separate MCP protocol session for each invocation.
|
|
6
|
+
Custom handlers may implement explicitly scoped session continuity or additional
|
|
7
|
+
transport policy. Neither the default handler nor this protocol filters URLs:
|
|
8
|
+
hosts must enforce their own destination allowlists and trust the MCP servers
|
|
9
|
+
whose outputs enter workflow conversations.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import warnings
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
import httpx2 as httpx
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from agent_framework import Content, MCPStreamableHTTPTool
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ClientProvider",
|
|
29
|
+
"DefaultMCPToolHandler",
|
|
30
|
+
"MCPToolHandler",
|
|
31
|
+
"MCPToolInvocation",
|
|
32
|
+
"MCPToolResult",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class MCPToolInvocation:
|
|
40
|
+
"""Evaluated input for an MCP tool call.
|
|
41
|
+
|
|
42
|
+
``server_url`` identifies the server and ``server_label`` names its client.
|
|
43
|
+
``tool_name`` is either a remote tool or the reserved ``tools/list`` operation.
|
|
44
|
+
``arguments`` contains JSON-serialisable tool arguments; ``headers`` contains
|
|
45
|
+
evaluated outbound headers. ``connection_name`` is available to custom
|
|
46
|
+
handlers and client providers that resolve host-owned connections.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
server_url: str
|
|
50
|
+
tool_name: str
|
|
51
|
+
server_label: str | None = None
|
|
52
|
+
arguments: dict[str, Any] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
|
53
|
+
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
|
54
|
+
connection_name: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class MCPToolResult:
|
|
59
|
+
"""Parsed MCP output, or a remote tool error the workflow can store in its result."""
|
|
60
|
+
|
|
61
|
+
outputs: list[Content] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
62
|
+
is_error: bool = False
|
|
63
|
+
error_message: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@runtime_checkable
|
|
67
|
+
class MCPToolHandler(Protocol):
|
|
68
|
+
"""Transport contract for declarative ``InvokeMcpTool`` actions.
|
|
69
|
+
|
|
70
|
+
Implementations must support concurrent calls from independent workflows.
|
|
71
|
+
They own destination validation, authentication, retries, and any explicitly
|
|
72
|
+
scoped protocol-session continuity. Lifecycle methods are not part of this
|
|
73
|
+
protocol.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
77
|
+
"""Return tool output or a remote tool error; propagate setup and transport failures."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
ClientProvider = Callable[[MCPToolInvocation], Awaitable["httpx.AsyncClient | None"]]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DefaultMCPToolHandler:
|
|
85
|
+
"""Invoke MCP tools with a separate protocol client for every call.
|
|
86
|
+
|
|
87
|
+
Every invocation, including ``tools/list``, connects and closes its own
|
|
88
|
+
:class:`agent_framework.MCPStreamableHTTPTool`. No initialized clients,
|
|
89
|
+
session state, credentials, or discovery inventories are cached here.
|
|
90
|
+
|
|
91
|
+
The optional ``client_provider`` is called for every invocation. Its HTTP
|
|
92
|
+
client is borrowed and never closed by this handler, so the host may reuse
|
|
93
|
+
HTTP connection pools without sharing MCP sessions. Returning ``None`` uses
|
|
94
|
+
an internally owned HTTP client that is closed with the invocation.
|
|
95
|
+
|
|
96
|
+
The handler owns no shared disposable resources and has no shutdown method.
|
|
97
|
+
Hosts must cancel and await active invocations before closing borrowed HTTP
|
|
98
|
+
clients. Admission limits and invocation deadlines are also host-owned.
|
|
99
|
+
Implicit server-side state does not survive later calls, approval waits, or
|
|
100
|
+
checkpoint resumes. Use a custom scoped handler or explicit remote state
|
|
101
|
+
handles when continuity is required.
|
|
102
|
+
|
|
103
|
+
This handler performs no URL filtering or SSRF protection.
|
|
104
|
+
|
|
105
|
+
Keyword Args:
|
|
106
|
+
client_provider: Optional per-invocation provider of a borrowed HTTP client.
|
|
107
|
+
cache_max_size: Deprecated compatibility parameter. Non-``None`` values
|
|
108
|
+
emit a warning but are ignored; sessions are never cached.
|
|
109
|
+
max_discovery_pages: Maximum pages accepted per tool catalog fetch.
|
|
110
|
+
Defaults to 100; ``None`` disables the limit.
|
|
111
|
+
max_discovery_items: Maximum tools accepted per tool catalog fetch.
|
|
112
|
+
Defaults to 1,000; ``None`` disables the limit.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
|
|
116
|
+
"""Reserved operation returning a JSON catalog with a ``tools`` array.
|
|
117
|
+
|
|
118
|
+
Each item contains ``name``, ``description``, ``inputSchema``, and
|
|
119
|
+
``outputSchema`` in that order. The catalog is paginated within one
|
|
120
|
+
invocation, not retained for subsequent calls.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
client_provider: ClientProvider | None = None,
|
|
127
|
+
cache_max_size: int | None = None,
|
|
128
|
+
max_discovery_pages: int | None = 100,
|
|
129
|
+
max_discovery_items: int | None = 1_000,
|
|
130
|
+
) -> None:
|
|
131
|
+
if cache_max_size is not None:
|
|
132
|
+
warnings.warn(
|
|
133
|
+
"cache_max_size is deprecated and has no effect. Each invocation uses a new MCP session.",
|
|
134
|
+
DeprecationWarning,
|
|
135
|
+
stacklevel=2,
|
|
136
|
+
)
|
|
137
|
+
if max_discovery_pages is not None and max_discovery_pages <= 0:
|
|
138
|
+
raise ValueError("max_discovery_pages must be positive or None.")
|
|
139
|
+
if max_discovery_items is not None and max_discovery_items <= 0:
|
|
140
|
+
raise ValueError("max_discovery_items must be positive or None.")
|
|
141
|
+
self._client_provider = client_provider
|
|
142
|
+
self._max_discovery_pages = max_discovery_pages
|
|
143
|
+
self._max_discovery_items = max_discovery_items
|
|
144
|
+
|
|
145
|
+
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
146
|
+
"""Connect, dispatch, and finish protocol cleanup before returning."""
|
|
147
|
+
from agent_framework import MCPStreamableHTTPTool
|
|
148
|
+
|
|
149
|
+
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
|
|
150
|
+
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
|
|
151
|
+
raise ValueError(message)
|
|
152
|
+
|
|
153
|
+
tool: MCPStreamableHTTPTool | None = None
|
|
154
|
+
try:
|
|
155
|
+
client = await self._client_provider(invocation) if self._client_provider is not None else None
|
|
156
|
+
tool = MCPStreamableHTTPTool(
|
|
157
|
+
name=invocation.server_label or "McpClient",
|
|
158
|
+
url=invocation.server_url,
|
|
159
|
+
load_prompts=False,
|
|
160
|
+
http_client=client,
|
|
161
|
+
static_headers=dict(invocation.headers),
|
|
162
|
+
max_discovery_pages=self._max_discovery_pages,
|
|
163
|
+
max_discovery_items=self._max_discovery_items,
|
|
164
|
+
)
|
|
165
|
+
await tool.connect()
|
|
166
|
+
return await self._invoke_connected_tool(tool, invocation)
|
|
167
|
+
finally:
|
|
168
|
+
if tool is not None:
|
|
169
|
+
await self._close_tool(tool)
|
|
170
|
+
|
|
171
|
+
async def _invoke_connected_tool(self, tool: MCPStreamableHTTPTool, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
172
|
+
from agent_framework import Content
|
|
173
|
+
from agent_framework.exceptions import ToolExecutionException
|
|
174
|
+
|
|
175
|
+
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
|
|
176
|
+
return await self._invoke_list_tools(tool)
|
|
177
|
+
try:
|
|
178
|
+
raw = await tool.call_tool(invocation.tool_name, **invocation.arguments)
|
|
179
|
+
except ToolExecutionException as exc:
|
|
180
|
+
# Core chains protocol/transport failures, unlike remote is_error responses.
|
|
181
|
+
if exc.__cause__ is not None:
|
|
182
|
+
raise
|
|
183
|
+
logger.info(
|
|
184
|
+
"DefaultMCPToolHandler: tool '%s' on '%s' raised ToolExecutionException",
|
|
185
|
+
invocation.tool_name,
|
|
186
|
+
invocation.server_url,
|
|
187
|
+
)
|
|
188
|
+
message = str(exc) or type(exc).__name__
|
|
189
|
+
return MCPToolResult(outputs=[Content.from_text(f"Error: {message}")], is_error=True, error_message=message)
|
|
190
|
+
|
|
191
|
+
outputs = [Content.from_text(raw)] if isinstance(raw, str) else list(raw)
|
|
192
|
+
return MCPToolResult(outputs=outputs)
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
async def _invoke_list_tools(tool: MCPStreamableHTTPTool) -> MCPToolResult:
|
|
196
|
+
"""Preserve the catalog shape and pagination used by workflow expressions."""
|
|
197
|
+
from agent_framework import Content
|
|
198
|
+
from mcp import types as mcp_types
|
|
199
|
+
|
|
200
|
+
require_connection: Any = getattr(tool, "_require_connection", None)
|
|
201
|
+
connection: Any | None = None
|
|
202
|
+
session: Any | None = None
|
|
203
|
+
if callable(require_connection):
|
|
204
|
+
connection = require_connection()
|
|
205
|
+
else:
|
|
206
|
+
session = getattr(tool, "session", None)
|
|
207
|
+
|
|
208
|
+
if connection is None and session is None:
|
|
209
|
+
raise RuntimeError("MCP session is not connected; cannot list tools.")
|
|
210
|
+
|
|
211
|
+
collected: list[Any] = []
|
|
212
|
+
params: mcp_types.PaginatedRequestParams | None = None
|
|
213
|
+
while True:
|
|
214
|
+
if connection is not None:
|
|
215
|
+
tool_list = await connection.list_tools_page(params)
|
|
216
|
+
elif session is not None:
|
|
217
|
+
tool_list = await session.list_tools(params=params)
|
|
218
|
+
else: # pragma: no cover - guarded before entering the loop
|
|
219
|
+
raise RuntimeError("MCP connection became unavailable while listing tools.")
|
|
220
|
+
collected.extend(tool_list.tools)
|
|
221
|
+
next_cursor = getattr(tool_list, "next_cursor", getattr(tool_list, "nextCursor", None))
|
|
222
|
+
if not next_cursor:
|
|
223
|
+
break
|
|
224
|
+
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
|
|
225
|
+
|
|
226
|
+
payload = {
|
|
227
|
+
"tools": [
|
|
228
|
+
{
|
|
229
|
+
"name": item.name,
|
|
230
|
+
"description": item.description,
|
|
231
|
+
"inputSchema": getattr(item, "input_schema", getattr(item, "inputSchema", None)),
|
|
232
|
+
"outputSchema": getattr(item, "output_schema", getattr(item, "outputSchema", None)),
|
|
233
|
+
}
|
|
234
|
+
for item in collected
|
|
235
|
+
]
|
|
236
|
+
}
|
|
237
|
+
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
async def _close_tool(tool: MCPStreamableHTTPTool) -> None:
|
|
241
|
+
# The tool owns its SDK lifecycle task and any internally allocated HTTP client.
|
|
242
|
+
cleanup = asyncio.create_task(tool.close())
|
|
243
|
+
cancelled = False
|
|
244
|
+
try:
|
|
245
|
+
while not cleanup.done():
|
|
246
|
+
try:
|
|
247
|
+
await asyncio.shield(cleanup)
|
|
248
|
+
except asyncio.CancelledError:
|
|
249
|
+
cancelled = True
|
|
250
|
+
cleanup.result()
|
|
251
|
+
except Exception:
|
|
252
|
+
logger.warning("DefaultMCPToolHandler: error closing invocation MCP client", exc_info=True)
|
|
253
|
+
if cancelled:
|
|
254
|
+
raise asyncio.CancelledError
|