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.
@@ -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)