agent-framework-declarative 1.0.0__py3-none-any.whl
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/__init__.py +71 -0
- agent_framework_declarative/_loader.py +868 -0
- agent_framework_declarative/_models.py +1154 -0
- agent_framework_declarative/_workflows/__init__.py +167 -0
- agent_framework_declarative/_workflows/_declarative_base.py +1226 -0
- agent_framework_declarative/_workflows/_declarative_builder.py +1057 -0
- agent_framework_declarative/_workflows/_errors.py +38 -0
- agent_framework_declarative/_workflows/_executors_agents.py +1025 -0
- agent_framework_declarative/_workflows/_executors_basic.py +574 -0
- agent_framework_declarative/_workflows/_executors_control_flow.py +461 -0
- agent_framework_declarative/_workflows/_executors_external_input.py +243 -0
- agent_framework_declarative/_workflows/_executors_http.py +417 -0
- agent_framework_declarative/_workflows/_executors_mcp.py +549 -0
- agent_framework_declarative/_workflows/_executors_tools.py +660 -0
- agent_framework_declarative/_workflows/_factory.py +808 -0
- agent_framework_declarative/_workflows/_http_handler.py +237 -0
- agent_framework_declarative/_workflows/_mcp_handler.py +581 -0
- agent_framework_declarative/_workflows/_powerfx_functions.py +498 -0
- agent_framework_declarative/_workflows/_state.py +650 -0
- agent_framework_declarative-1.0.0.dist-info/METADATA +49 -0
- agent_framework_declarative-1.0.0.dist-info/RECORD +23 -0
- agent_framework_declarative-1.0.0.dist-info/WHEEL +4 -0
- agent_framework_declarative-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""MCP tool handler abstraction for declarative workflows.
|
|
4
|
+
|
|
5
|
+
Mirrors the .NET ``IMcpToolHandler`` / ``DefaultMcpToolHandler`` pair from
|
|
6
|
+
``Microsoft.Agents.AI.Workflows.Declarative.Mcp``. Provides:
|
|
7
|
+
|
|
8
|
+
- :class:`MCPToolInvocation` — request input data passed from the executor.
|
|
9
|
+
- :class:`MCPToolResult` — response data returned to the executor.
|
|
10
|
+
- :class:`MCPToolHandler` — :class:`typing.Protocol` callers implement to plug
|
|
11
|
+
in custom transports (e.g. with allowlisting, Foundry connection resolution,
|
|
12
|
+
per-server auth, etc.).
|
|
13
|
+
- :class:`DefaultMCPToolHandler` — production-grade default backed by
|
|
14
|
+
:class:`agent_framework.MCPStreamableHTTPTool`.
|
|
15
|
+
|
|
16
|
+
Security note: :class:`DefaultMCPToolHandler` performs **no** URL filtering or
|
|
17
|
+
SSRF protection. Production deployments should supply a custom handler that
|
|
18
|
+
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
|
|
19
|
+
.NET design.
|
|
20
|
+
|
|
21
|
+
Prompt-injection note: MCP tool outputs flow back into agent conversations
|
|
22
|
+
(via ``conversationId`` and Tool-role messages emitted by the executor) so
|
|
23
|
+
they share the same risk surface as ``HttpRequestAction``. Workflow authors
|
|
24
|
+
must trust the MCP server they invoke.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from collections import OrderedDict
|
|
34
|
+
from collections.abc import Awaitable, Callable
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
|
|
37
|
+
|
|
38
|
+
import httpx
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from agent_framework import Content
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"ClientProvider",
|
|
45
|
+
"DefaultMCPToolHandler",
|
|
46
|
+
"MCPToolHandler",
|
|
47
|
+
"MCPToolInvocation",
|
|
48
|
+
"MCPToolResult",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
_DEFAULT_CACHE_MAX_SIZE = 32
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class MCPToolInvocation:
|
|
58
|
+
"""Description of an MCP tool call to be dispatched by a :class:`MCPToolHandler`.
|
|
59
|
+
|
|
60
|
+
Mirrors the input parameters of the .NET ``IMcpToolHandler.InvokeToolAsync``
|
|
61
|
+
method. Field semantics:
|
|
62
|
+
|
|
63
|
+
- ``server_url``: Absolute URL of the MCP server. Already evaluated from
|
|
64
|
+
the YAML expression.
|
|
65
|
+
- ``server_label``: Optional human-readable label used for diagnostics
|
|
66
|
+
and as the underlying ``MCPStreamableHTTPTool`` name.
|
|
67
|
+
- ``tool_name``: Name of the tool to invoke on the MCP server.
|
|
68
|
+
- ``arguments``: Tool arguments. Already evaluated; values may be any
|
|
69
|
+
JSON-serialisable Python object (str, int, bool, dict, list, None).
|
|
70
|
+
- ``headers``: Outbound HTTP headers (e.g. authentication). Empty values
|
|
71
|
+
are skipped by the executor before construction.
|
|
72
|
+
- ``connection_name``: Optional Foundry connection name forwarded for
|
|
73
|
+
handlers that resolve auth/credentials by connection. The default
|
|
74
|
+
handler does not consume this field.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
server_url: str
|
|
78
|
+
tool_name: str
|
|
79
|
+
server_label: str | None = None
|
|
80
|
+
arguments: dict[str, Any] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
|
81
|
+
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
|
82
|
+
connection_name: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _empty_outputs() -> list[Any]:
|
|
86
|
+
"""Default factory for ``MCPToolResult.outputs``.
|
|
87
|
+
|
|
88
|
+
Typed as ``list[Any]`` here to keep the dataclass field's runtime
|
|
89
|
+
factory simple; the public type on :class:`MCPToolResult` is
|
|
90
|
+
``list[Content]``.
|
|
91
|
+
"""
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class MCPToolResult:
|
|
97
|
+
"""Response returned by an :class:`MCPToolHandler`.
|
|
98
|
+
|
|
99
|
+
Mirrors the .NET ``McpServerToolResultContent`` shape. ``outputs`` is a
|
|
100
|
+
list of :class:`agent_framework.Content` items as parsed by the MCP
|
|
101
|
+
transport (TextContent / DataContent / UriContent / etc.).
|
|
102
|
+
|
|
103
|
+
On error, ``is_error`` is ``True``, ``error_message`` carries a human
|
|
104
|
+
readable description, and ``outputs`` typically contains a single
|
|
105
|
+
``Content.from_text("Error: ...")`` entry for downstream display.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
outputs: list[Content] = field(default_factory=_empty_outputs)
|
|
109
|
+
is_error: bool = False
|
|
110
|
+
error_message: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@runtime_checkable
|
|
114
|
+
class MCPToolHandler(Protocol):
|
|
115
|
+
"""Protocol for MCP tool handlers used by ``InvokeMcpTool``.
|
|
116
|
+
|
|
117
|
+
Mirrors :class:`HttpRequestHandler` — declares ONLY the invocation method.
|
|
118
|
+
Lifecycle methods (``aclose`` / ``__aenter__`` / ``__aexit__``) are NOT
|
|
119
|
+
part of the Protocol; concrete implementations may add them as
|
|
120
|
+
appropriate.
|
|
121
|
+
|
|
122
|
+
Implementations must be safe to call concurrently from multiple workflow
|
|
123
|
+
runs. Implementations are responsible for any URL allowlisting, SSRF
|
|
124
|
+
guards, retry policies, auth resolution, and other policies the workflow
|
|
125
|
+
author wants applied.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
129
|
+
"""Dispatch ``invocation`` and return the result.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
invocation: Description of the MCP tool call to perform.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The :class:`MCPToolResult` carrying the parsed outputs (or an
|
|
136
|
+
error flag if the tool raised). Implementations SHOULD return a
|
|
137
|
+
result with ``is_error=True`` rather than raising for transport
|
|
138
|
+
or tool-level failures, so the workflow can store the message in
|
|
139
|
+
``output.result`` (matching .NET ``AssignErrorAsync`` behaviour).
|
|
140
|
+
They MAY raise on unexpected programming errors — these will be
|
|
141
|
+
propagated unchanged by the executor so they fail loudly.
|
|
142
|
+
"""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
ClientProvider = Callable[[MCPToolInvocation], Awaitable["httpx.AsyncClient | None"]]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class _CacheEntry:
|
|
151
|
+
"""Internal record stored in the LRU cache."""
|
|
152
|
+
|
|
153
|
+
tool: Any # MCPStreamableHTTPTool — typed Any to avoid import at module load
|
|
154
|
+
owned_httpx_client: httpx.AsyncClient | None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class DefaultMCPToolHandler:
|
|
158
|
+
"""Default :class:`MCPToolHandler` backed by :class:`agent_framework.MCPStreamableHTTPTool`.
|
|
159
|
+
|
|
160
|
+
Caches one :class:`agent_framework.MCPStreamableHTTPTool` instance per
|
|
161
|
+
``(server_url, server_label, connection_name, headers_hash)`` in a
|
|
162
|
+
bounded LRU. The cache prevents re-establishing an MCP session for every
|
|
163
|
+
invocation while ensuring different header sets (auth tokens) cannot
|
|
164
|
+
share a session — matches the .NET design intent while bounding
|
|
165
|
+
cardinality. ``server_label`` and ``connection_name`` participate in
|
|
166
|
+
the key so that callers using ``client_provider`` to dispatch on those
|
|
167
|
+
fields receive a fresh client per logical connection (see below).
|
|
168
|
+
Header *names* are lower-cased inside the hash payload only — the
|
|
169
|
+
headers passed on the wire keep the caller's original casing — so two
|
|
170
|
+
YAML actions that spell ``Authorization`` differently still share a
|
|
171
|
+
cache entry.
|
|
172
|
+
|
|
173
|
+
Construction modes:
|
|
174
|
+
|
|
175
|
+
1. ``DefaultMCPToolHandler()`` — owns its own ``httpx.AsyncClient``
|
|
176
|
+
instances created lazily per cache entry. Closed by :meth:`aclose`.
|
|
177
|
+
2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-server client
|
|
178
|
+
lookup (parity with .NET ``httpClientProvider`` callback). The
|
|
179
|
+
callback receives the full :class:`MCPToolInvocation` so it can
|
|
180
|
+
dispatch on ``server_url`` / ``connection_name`` / ``server_label``.
|
|
181
|
+
Returning ``None`` falls back to an internally-created client. Caller
|
|
182
|
+
supplied clients are NOT closed by :meth:`aclose`.
|
|
183
|
+
|
|
184
|
+
.. warning::
|
|
185
|
+
|
|
186
|
+
This handler performs **no** URL filtering or SSRF protection. Wrap
|
|
187
|
+
or replace it with a custom handler in production deployments.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
client_provider: Optional per-server ``httpx.AsyncClient`` provider.
|
|
191
|
+
cache_max_size: Maximum number of cached MCP clients. When exceeded,
|
|
192
|
+
the least-recently-used entry is evicted and its client closed
|
|
193
|
+
(only owned clients are closed; caller-supplied ones are not).
|
|
194
|
+
Defaults to ``32``.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
|
|
198
|
+
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
|
|
199
|
+
to the MCP protocol ``tools/list`` discovery operation.
|
|
200
|
+
|
|
201
|
+
The constant matches the underlying MCP method name so a single
|
|
202
|
+
string travels unchanged through host code, YAML, and the protocol
|
|
203
|
+
wire. When this handler receives an invocation with this name it
|
|
204
|
+
pages through ``session.list_tools()`` and returns the catalog as a
|
|
205
|
+
single ``TextContent`` containing JSON of shape
|
|
206
|
+
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
|
207
|
+
Workflows can reference this name from an ``InvokeMcpTool`` declarative
|
|
208
|
+
action to introspect a server's tool surface without an extra round-trip
|
|
209
|
+
from host code.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
*,
|
|
215
|
+
client_provider: ClientProvider | None = None,
|
|
216
|
+
cache_max_size: int = _DEFAULT_CACHE_MAX_SIZE,
|
|
217
|
+
) -> None:
|
|
218
|
+
if cache_max_size <= 0:
|
|
219
|
+
raise ValueError(f"cache_max_size must be positive, got {cache_max_size}")
|
|
220
|
+
self._client_provider = client_provider
|
|
221
|
+
self._cache_max_size = cache_max_size
|
|
222
|
+
self._cache: OrderedDict[tuple[str, str, str, str], _CacheEntry] = OrderedDict()
|
|
223
|
+
# Outer lock guards the cache + in-flight-future map only — never
|
|
224
|
+
# held across network I/O.
|
|
225
|
+
self._cache_lock = asyncio.Lock()
|
|
226
|
+
# Per-key in-flight futures: while one task is connecting, other
|
|
227
|
+
# tasks awaiting the same key will await the same future and share
|
|
228
|
+
# the resulting cache entry.
|
|
229
|
+
self._inflight: dict[tuple[str, str, str, str], asyncio.Future[_CacheEntry]] = {}
|
|
230
|
+
# Set by ``aclose`` to prevent post-close cache insertions and to
|
|
231
|
+
# reject new ``invoke_tool`` calls. Once set, never cleared.
|
|
232
|
+
self._closed = False
|
|
233
|
+
|
|
234
|
+
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
235
|
+
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
|
|
236
|
+
|
|
237
|
+
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
|
|
238
|
+
intercepted client-side: instead of being forwarded as a tool call,
|
|
239
|
+
it is translated to an MCP ``session.list_tools()`` discovery
|
|
240
|
+
operation (paginated automatically) and returned as a single
|
|
241
|
+
``TextContent`` containing a JSON tool catalog.
|
|
242
|
+
"""
|
|
243
|
+
from agent_framework import Content
|
|
244
|
+
from agent_framework.exceptions import ToolExecutionException
|
|
245
|
+
|
|
246
|
+
# Reserved-name args validation runs before connect: rejecting bad
|
|
247
|
+
# input shouldn't require establishing an MCP session.
|
|
248
|
+
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
|
|
249
|
+
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
|
|
250
|
+
return MCPToolResult(
|
|
251
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
252
|
+
is_error=True,
|
|
253
|
+
error_message=message,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
entry = await self._get_or_create_entry(invocation)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
# Connect / cache lookup failures surface as tool errors so the
|
|
260
|
+
# workflow can store them at output.result without crashing.
|
|
261
|
+
logger.warning(
|
|
262
|
+
"DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s",
|
|
263
|
+
invocation.server_url,
|
|
264
|
+
invocation.tool_name,
|
|
265
|
+
exc,
|
|
266
|
+
)
|
|
267
|
+
message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ")
|
|
268
|
+
return MCPToolResult(
|
|
269
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
270
|
+
is_error=True,
|
|
271
|
+
error_message=message,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
|
|
276
|
+
return await self._invoke_list_tools(entry)
|
|
277
|
+
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
|
|
278
|
+
except ToolExecutionException as exc:
|
|
279
|
+
logger.info(
|
|
280
|
+
"DefaultMCPToolHandler: tool '%s' on '%s' raised ToolExecutionException",
|
|
281
|
+
invocation.tool_name,
|
|
282
|
+
invocation.server_url,
|
|
283
|
+
)
|
|
284
|
+
message = str(exc) or type(exc).__name__
|
|
285
|
+
return MCPToolResult(
|
|
286
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
287
|
+
is_error=True,
|
|
288
|
+
error_message=message,
|
|
289
|
+
)
|
|
290
|
+
except httpx.HTTPError as exc:
|
|
291
|
+
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
|
292
|
+
return MCPToolResult(
|
|
293
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
294
|
+
is_error=True,
|
|
295
|
+
error_message=message,
|
|
296
|
+
)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
# Be defensive about MCP errors that may bubble up without being
|
|
299
|
+
# wrapped in ToolExecutionException by custom parsers.
|
|
300
|
+
try:
|
|
301
|
+
from mcp.shared.exceptions import McpError
|
|
302
|
+
except ImportError: # pragma: no cover - mcp is a hard dep but stay defensive
|
|
303
|
+
raise
|
|
304
|
+
if isinstance(exc, McpError):
|
|
305
|
+
message = str(exc) or type(exc).__name__
|
|
306
|
+
return MCPToolResult(
|
|
307
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
308
|
+
is_error=True,
|
|
309
|
+
error_message=message,
|
|
310
|
+
)
|
|
311
|
+
raise
|
|
312
|
+
|
|
313
|
+
# Defensive normalisation: call_tool is typed ``str | list[Content]``.
|
|
314
|
+
# Default parser returns list, but custom parse_tool_results may return str.
|
|
315
|
+
if isinstance(raw, str):
|
|
316
|
+
outputs: list[Content] = [Content.from_text(raw)]
|
|
317
|
+
else:
|
|
318
|
+
outputs = list(raw)
|
|
319
|
+
return MCPToolResult(outputs=outputs)
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
|
|
323
|
+
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
|
|
324
|
+
|
|
325
|
+
Pages through ``session.list_tools()`` (mirroring the pagination loop
|
|
326
|
+
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
|
|
327
|
+
full catalog as a single ``TextContent`` containing JSON of shape
|
|
328
|
+
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
|
|
329
|
+
|
|
330
|
+
The output shape, property names, and property order are stable so
|
|
331
|
+
downstream PowerFx expressions can rely on the schema. ``indent=2``
|
|
332
|
+
produces human-readable JSON for the conversation log;
|
|
333
|
+
``allow_nan=False`` guards against producing non-conformant JSON
|
|
334
|
+
``NaN``/``Infinity`` tokens if a misbehaving server returns such
|
|
335
|
+
values in a schema.
|
|
336
|
+
"""
|
|
337
|
+
from agent_framework import Content
|
|
338
|
+
|
|
339
|
+
session = getattr(entry.tool, "session", None)
|
|
340
|
+
if session is None:
|
|
341
|
+
message = "MCP session is not connected; cannot list tools."
|
|
342
|
+
return MCPToolResult(
|
|
343
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
344
|
+
is_error=True,
|
|
345
|
+
error_message=message,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Lazy import keeps ``mcp`` types out of module import time.
|
|
349
|
+
from mcp import types as mcp_types
|
|
350
|
+
|
|
351
|
+
collected: list[Any] = []
|
|
352
|
+
params: mcp_types.PaginatedRequestParams | None = None
|
|
353
|
+
while True:
|
|
354
|
+
tool_list = await session.list_tools(params=params)
|
|
355
|
+
collected.extend(tool_list.tools)
|
|
356
|
+
next_cursor = getattr(tool_list, "nextCursor", None)
|
|
357
|
+
if not next_cursor:
|
|
358
|
+
break
|
|
359
|
+
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
|
|
360
|
+
|
|
361
|
+
payload = {
|
|
362
|
+
"tools": [
|
|
363
|
+
{
|
|
364
|
+
"name": tool.name,
|
|
365
|
+
"description": tool.description,
|
|
366
|
+
"inputSchema": tool.inputSchema,
|
|
367
|
+
"outputSchema": tool.outputSchema,
|
|
368
|
+
}
|
|
369
|
+
for tool in collected
|
|
370
|
+
],
|
|
371
|
+
}
|
|
372
|
+
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
|
|
373
|
+
|
|
374
|
+
async def aclose(self) -> None:
|
|
375
|
+
"""Close all cached MCP clients and the owned httpx clients.
|
|
376
|
+
|
|
377
|
+
Caller-supplied :class:`httpx.AsyncClient` instances (returned by the
|
|
378
|
+
``client_provider`` callback) are NOT closed.
|
|
379
|
+
|
|
380
|
+
Idempotent — a second call returns immediately. Drains any in-flight
|
|
381
|
+
``_create_entry`` tasks before returning so their resources are
|
|
382
|
+
cleaned up; the in-flight tasks see ``self._closed`` in phase 3 of
|
|
383
|
+
:meth:`_get_or_create_entry`, close their own entry, and resolve
|
|
384
|
+
their future with ``RuntimeError("DefaultMCPToolHandler is closed")``.
|
|
385
|
+
"""
|
|
386
|
+
async with self._cache_lock:
|
|
387
|
+
if self._closed:
|
|
388
|
+
return
|
|
389
|
+
self._closed = True
|
|
390
|
+
entries = list(self._cache.values())
|
|
391
|
+
self._cache.clear()
|
|
392
|
+
inflight_futures = list(self._inflight.values())
|
|
393
|
+
|
|
394
|
+
# Wait for in-flight creations to finish their self-cleanup. Each
|
|
395
|
+
# in-flight task self-closes its entry under the closed-flag branch
|
|
396
|
+
# in phase 3 and resolves its future with ``RuntimeError``; we
|
|
397
|
+
# swallow it here because the failure is expected at shutdown.
|
|
398
|
+
for fut in inflight_futures:
|
|
399
|
+
try:
|
|
400
|
+
await fut
|
|
401
|
+
except BaseException:
|
|
402
|
+
logger.debug("DefaultMCPToolHandler: in-flight future raised during aclose", exc_info=True)
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
for entry in entries:
|
|
406
|
+
await self._close_entry(entry)
|
|
407
|
+
|
|
408
|
+
async def __aenter__(self) -> DefaultMCPToolHandler:
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
412
|
+
await self.aclose()
|
|
413
|
+
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
# Internal helpers
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
async def _get_or_create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
|
|
419
|
+
"""Look up (or create) the cached MCP client for this invocation."""
|
|
420
|
+
key = self._cache_key(
|
|
421
|
+
invocation.server_url,
|
|
422
|
+
invocation.server_label,
|
|
423
|
+
invocation.connection_name,
|
|
424
|
+
invocation.headers,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Phase 1: check the cache and either claim creation or wait for an
|
|
428
|
+
# already in-flight creation.
|
|
429
|
+
creating = False
|
|
430
|
+
async with self._cache_lock:
|
|
431
|
+
if self._closed:
|
|
432
|
+
raise RuntimeError("DefaultMCPToolHandler is closed")
|
|
433
|
+
existing = self._cache.get(key)
|
|
434
|
+
if existing is not None:
|
|
435
|
+
self._cache.move_to_end(key)
|
|
436
|
+
return existing
|
|
437
|
+
inflight = self._inflight.get(key)
|
|
438
|
+
if inflight is None:
|
|
439
|
+
inflight = asyncio.get_running_loop().create_future()
|
|
440
|
+
self._inflight[key] = inflight
|
|
441
|
+
creating = True
|
|
442
|
+
|
|
443
|
+
if not creating:
|
|
444
|
+
return await inflight
|
|
445
|
+
|
|
446
|
+
# Phase 2: we own creation. Build the entry outside the lock.
|
|
447
|
+
try:
|
|
448
|
+
entry = await self._create_entry(invocation)
|
|
449
|
+
except BaseException as exc:
|
|
450
|
+
async with self._cache_lock:
|
|
451
|
+
self._inflight.pop(key, None)
|
|
452
|
+
if not inflight.done():
|
|
453
|
+
inflight.set_exception(exc if isinstance(exc, BaseException) else RuntimeError(str(exc)))
|
|
454
|
+
# Mark the exception retrieved to suppress noisy "Future exception
|
|
455
|
+
# was never retrieved" warnings when there are no other awaiters
|
|
456
|
+
# (other awaiters still see the exception through their ``await``).
|
|
457
|
+
inflight.exception()
|
|
458
|
+
raise
|
|
459
|
+
|
|
460
|
+
# Phase 3: insert with LRU eviction; resolve the in-flight future.
|
|
461
|
+
# If ``aclose`` ran while we were connecting, ``_closed`` is now
|
|
462
|
+
# True; don't insert into the cache (it has been drained), close
|
|
463
|
+
# the just-built entry, and surface the closed-handler error to
|
|
464
|
+
# all awaiters of the future.
|
|
465
|
+
evicted: _CacheEntry | None = None
|
|
466
|
+
duplicate: _CacheEntry | None = None
|
|
467
|
+
handler_closed = False
|
|
468
|
+
async with self._cache_lock:
|
|
469
|
+
self._inflight.pop(key, None)
|
|
470
|
+
if self._closed:
|
|
471
|
+
handler_closed = True
|
|
472
|
+
else:
|
|
473
|
+
existing = self._cache.get(key)
|
|
474
|
+
if existing is not None:
|
|
475
|
+
# Another writer beat us; prefer the existing entry and
|
|
476
|
+
# discard ours after the lock is released.
|
|
477
|
+
self._cache.move_to_end(key)
|
|
478
|
+
duplicate = entry
|
|
479
|
+
entry = existing
|
|
480
|
+
else:
|
|
481
|
+
self._cache[key] = entry
|
|
482
|
+
self._cache.move_to_end(key)
|
|
483
|
+
if len(self._cache) > self._cache_max_size:
|
|
484
|
+
_evicted_key, evicted = self._cache.popitem(last=False)
|
|
485
|
+
if not inflight.done():
|
|
486
|
+
inflight.set_result(entry)
|
|
487
|
+
|
|
488
|
+
if handler_closed:
|
|
489
|
+
# Close our orphaned entry; resolve the future with a clear
|
|
490
|
+
# error so the caller (and any other awaiters) surface a
|
|
491
|
+
# consistent "handler is closed" failure rather than receiving
|
|
492
|
+
# an entry we are about to close behind their back.
|
|
493
|
+
await self._close_entry(entry)
|
|
494
|
+
err = RuntimeError("DefaultMCPToolHandler is closed")
|
|
495
|
+
if not inflight.done():
|
|
496
|
+
inflight.set_exception(err)
|
|
497
|
+
inflight.exception()
|
|
498
|
+
raise err
|
|
499
|
+
if duplicate is not None:
|
|
500
|
+
await self._close_entry(duplicate)
|
|
501
|
+
if evicted is not None:
|
|
502
|
+
await self._close_entry(evicted)
|
|
503
|
+
return entry
|
|
504
|
+
|
|
505
|
+
async def _create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
|
|
506
|
+
"""Construct (and connect) a fresh MCP client for ``invocation``."""
|
|
507
|
+
from agent_framework import MCPStreamableHTTPTool
|
|
508
|
+
|
|
509
|
+
provided_client: httpx.AsyncClient | None = None
|
|
510
|
+
if self._client_provider is not None:
|
|
511
|
+
provided_client = await self._client_provider(invocation)
|
|
512
|
+
# Capture headers for this cache entry so the header_provider closure
|
|
513
|
+
# always returns the same set, regardless of the runtime kwargs.
|
|
514
|
+
captured_headers = dict(invocation.headers)
|
|
515
|
+
|
|
516
|
+
def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]:
|
|
517
|
+
return captured_headers
|
|
518
|
+
|
|
519
|
+
tool: Any = MCPStreamableHTTPTool(
|
|
520
|
+
name=invocation.server_label or "McpClient",
|
|
521
|
+
url=invocation.server_url,
|
|
522
|
+
load_prompts=False,
|
|
523
|
+
http_client=provided_client,
|
|
524
|
+
header_provider=_header_provider if captured_headers else None,
|
|
525
|
+
)
|
|
526
|
+
try:
|
|
527
|
+
await tool.connect()
|
|
528
|
+
except BaseException:
|
|
529
|
+
try:
|
|
530
|
+
await tool.close()
|
|
531
|
+
except Exception: # pragma: no cover - best effort
|
|
532
|
+
logger.debug("DefaultMCPToolHandler: error closing tool after failed connect", exc_info=True)
|
|
533
|
+
raise
|
|
534
|
+
|
|
535
|
+
# ``MCPStreamableHTTPTool.get_mcp_client`` lazily creates an
|
|
536
|
+
# ``httpx.AsyncClient`` when no caller client was provided AND a
|
|
537
|
+
# ``header_provider`` was set. We treat any client allocated this
|
|
538
|
+
# way as owned (closed by the handler). When the caller supplies
|
|
539
|
+
# one, we never close it.
|
|
540
|
+
owned_client: httpx.AsyncClient | None = None
|
|
541
|
+
if provided_client is None:
|
|
542
|
+
owned_client = cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None))
|
|
543
|
+
return _CacheEntry(tool=tool, owned_httpx_client=owned_client)
|
|
544
|
+
|
|
545
|
+
async def _close_entry(self, entry: _CacheEntry) -> None:
|
|
546
|
+
"""Close the MCP tool and any owned httpx client."""
|
|
547
|
+
try:
|
|
548
|
+
await entry.tool.close()
|
|
549
|
+
except Exception: # pragma: no cover - best effort
|
|
550
|
+
logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True)
|
|
551
|
+
if entry.owned_httpx_client is not None:
|
|
552
|
+
try:
|
|
553
|
+
await entry.owned_httpx_client.aclose()
|
|
554
|
+
except Exception: # pragma: no cover - best effort
|
|
555
|
+
logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True)
|
|
556
|
+
|
|
557
|
+
@staticmethod
|
|
558
|
+
def _cache_key(
|
|
559
|
+
server_url: str,
|
|
560
|
+
server_label: str | None,
|
|
561
|
+
connection_name: str | None,
|
|
562
|
+
headers: dict[str, str] | None,
|
|
563
|
+
) -> tuple[str, str, str, str]:
|
|
564
|
+
"""Build an order-independent cache key for the invocation identity.
|
|
565
|
+
|
|
566
|
+
The key includes ``server_label`` and ``connection_name`` so that
|
|
567
|
+
callers using ``client_provider`` to dispatch on those fields
|
|
568
|
+
receive a fresh client per logical connection (matches the
|
|
569
|
+
documented dispatch contract).
|
|
570
|
+
|
|
571
|
+
Header *names* are lower-cased inside the hash payload only so
|
|
572
|
+
that ``Authorization`` and ``authorization`` map to the same
|
|
573
|
+
cache entry. Header values remain case-sensitive (per RFC 7235).
|
|
574
|
+
"""
|
|
575
|
+
if not headers:
|
|
576
|
+
headers_hash = "0"
|
|
577
|
+
else:
|
|
578
|
+
normalized = sorted((k.lower(), v) for k, v in headers.items())
|
|
579
|
+
payload = json.dumps(normalized, ensure_ascii=False)
|
|
580
|
+
headers_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
581
|
+
return (server_url, server_label or "", connection_name or "", headers_hash)
|