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.
Files changed (28) hide show
  1. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/PKG-INFO +40 -22
  2. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/README.md +37 -18
  3. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_loader.py +4 -9
  4. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_declarative_base.py +1 -7
  5. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_mcp.py +6 -47
  6. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_tools.py +26 -3
  7. agent_framework_declarative-1.5.0/agent_framework_declarative/_workflows/_mcp_handler.py +254 -0
  8. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/pyproject.toml +7 -5
  9. agent_framework_declarative-1.4.0/agent_framework_declarative/_workflows/_mcp_handler.py +0 -686
  10. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/LICENSE +0 -0
  11. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/__init__.py +0 -0
  12. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_feature_usage.py +0 -0
  13. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_models.py +0 -0
  14. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/__init__.py +0 -0
  15. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_declarative_builder.py +0 -0
  16. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_errors.py +0 -0
  17. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_agents.py +0 -0
  18. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_basic.py +0 -0
  19. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_control_flow.py +0 -0
  20. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_external_input.py +0 -0
  21. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_executors_http.py +0 -0
  22. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_factory.py +0 -0
  23. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_http_handler.py +0 -0
  24. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_powerfx_functions.py +0 -0
  25. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_powerfx_limits.py +0 -0
  26. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_state.py +0 -0
  27. {agent_framework_declarative-1.4.0 → agent_framework_declarative-1.5.0}/agent_framework_declarative/_workflows/_state_path.py +0 -0
  28. {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.4.0
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.10
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.23.0,<2
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.10–3.13 support. Python 3.14 and
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()` reuses MCP sessions through a bounded LRU cache.
104
- When configured with `client_provider`, it instead invokes the provider and
105
- creates a fresh MCP tool/session for every invocation, including `tools/list`.
106
- Sessions are never reused in this mode, even if the provider returns `None`
107
- or the same `httpx.AsyncClient` instance.
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 fallback HTTP clients are also closed; caller-supplied HTTP
111
- clients remain caller-owned and are never closed by the handler. Calling
112
- `aclose()` rejects new invocations and waits for active provider-backed
113
- invocations to finish cleaning up. Calling it from an active invocation's
114
- context (including provider callbacks, inherited child tasks, and cleanup)
115
- raises `RuntimeError` before changing handler state, rather than waiting on
116
- itself. Close the handler outside that context or after the invocation completes.
117
-
118
- This intentionally incurs connection and initialization overhead and does not
119
- retain server session state between provider-backed invocations. Applications
120
- that require shared session ownership must implement an explicitly scoped
121
- custom `MCPToolHandler`; there is no provider-backed session-cache opt-in.
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.10–3.13 support. Python 3.14 and
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()` reuses MCP sessions through a bounded LRU cache.
76
- When configured with `client_provider`, it instead invokes the provider and
77
- creates a fresh MCP tool/session for every invocation, including `tools/list`.
78
- Sessions are never reused in this mode, even if the provider returns `None`
79
- or the same `httpx.AsyncClient` instance.
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 fallback HTTP clients are also closed; caller-supplied HTTP
83
- clients remain caller-owned and are never closed by the handler. Calling
84
- `aclose()` rejects new invocations and waits for active provider-backed
85
- invocations to finish cleaning up. Calling it from an active invocation's
86
- context (including provider callbacks, inherited child tasks, and cleanup)
87
- raises `RuntimeError` before changing handler state, rather than waiting on
88
- itself. Close the handler outside that context or after the invocation completes.
89
-
90
- This intentionally incurs connection and initialization overhead and does not
91
- retain server session state between provider-backed invocations. Applications
92
- that require shared session ownership must implement an explicitly scoped
93
- custom `MCPToolHandler`; there is no provider-backed session-cache opt-in.
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
- if additional_properties:
747
- chat_options["additional_chat_options"] = additional_properties
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._invoke_with_narrow_catch(invocation)
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._invoke_with_narrow_catch(invocation)
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