This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new f82542a453a Name the caller's own MCP client and durable setup in 
Common AI errors (#76495)
f82542a453a is described below

commit f82542a453a63d1b5f5b3fd73f99b2cf7ca9dae4
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Nov 5 14:55:23 2026 +0000

    Name the caller's own MCP client and durable setup in Common AI errors 
(#76495)
    
    Handing MCPToolset to a framework adapter's AirflowTools told every user
    about Strands' MCPClient and ADK's McpToolset, and called the adapters
    "the framework-neutral tools". The error is now MCPToolsetNotSupportedError,
    still a NotImplementedError, and each adapter this provider ships names its
    own MCP route: mcp_servers with MCPServerStreamableHttp for the OpenAI
    Agents SDK, async_mcp_tool() with AsyncAnthropic for the Anthropic tool
    runner, build_options(mcp_servers=...) plus allowed_tools for the Claude
    Agent SDK, MCPClient for Strands and McpToolset for ADK. The public
    collect_tools() signature is unchanged; the adapters pass their hint
    through a private _collect_tools().
    
    The missing durable_cache_path error on Airflow before 3.3 said
    "durable=True requires ...", which run_durably() callers never pass. It now
    talks about durable execution, and so does the option's description.
---
 providers/common/ai/docs/durable_execution.rst     |  2 +-
 providers/common/ai/docs/frameworks/anthropic.rst  |  4 +-
 .../common/ai/docs/frameworks/claude_agent_sdk.rst |  4 +-
 .../common/ai/docs/frameworks/openai_agents.rst    |  7 ++--
 providers/common/ai/docs/troubleshooting.rst       |  2 +-
 providers/common/ai/provider.yaml                  |  7 ++--
 .../airflow/providers/common/ai/durable/storage.py |  3 +-
 .../src/airflow/providers/common/ai/exceptions.py  | 10 +++++
 .../providers/common/ai/get_provider_info.py       |  2 +-
 .../airflow/providers/common/ai/tools/__init__.py  | 28 +++++++++++++-
 .../src/airflow/providers/common/ai/tools/adk.py   |  8 +++-
 .../airflow/providers/common/ai/tools/anthropic.py | 14 +++++--
 .../providers/common/ai/tools/claude_agent_sdk.py  |  9 ++++-
 .../providers/common/ai/tools/openai_agents.py     |  9 ++++-
 .../airflow/providers/common/ai/tools/strands.py   |  6 ++-
 .../airflow/providers/common/ai/toolsets/mcp.py    | 14 +++----
 .../tests/unit/common/ai/durable/test_storage.py   |  9 ++++-
 .../ai/tests/unit/common/ai/tools/test_adk.py      |  6 +++
 .../tests/unit/common/ai/tools/test_anthropic.py   | 13 ++++++-
 .../unit/common/ai/tools/test_claude_agent_sdk.py  | 10 +++++
 .../unit/common/ai/tools/test_openai_agents.py     |  6 +++
 .../ai/tests/unit/common/ai/tools/test_strands.py  |  6 +++
 .../ai/tests/unit/common/ai/tools/test_tools.py    | 43 +++++++++++++++++++++-
 .../ai/tests/unit/common/ai/toolsets/test_mcp.py   |  8 +++-
 24 files changed, 193 insertions(+), 37 deletions(-)

diff --git a/providers/common/ai/docs/durable_execution.rst 
b/providers/common/ai/docs/durable_execution.rst
index b6004ed0e69..87bffbbed7a 100644
--- a/providers/common/ai/docs/durable_execution.rst
+++ b/providers/common/ai/docs/durable_execution.rst
@@ -122,7 +122,7 @@ Without the option, the task fails with:
 
 .. code-block:: text
 
-    ValueError: durable=True requires [common.ai] durable_cache_path to be 
set. Example: durable_cache_path = file:///tmp/airflow_durable_cache
+    ValueError: Durable execution on Airflow before 3.3 needs [common.ai] 
durable_cache_path to be set, to say where its journal is kept. Example: 
durable_cache_path = file:///tmp/airflow_durable_cache
 
 How a retry replays
 -------------------
diff --git a/providers/common/ai/docs/frameworks/anthropic.rst 
b/providers/common/ai/docs/frameworks/anthropic.rst
index 3124206709c..1d83e75d1d3 100644
--- a/providers/common/ai/docs/frameworks/anthropic.rst
+++ b/providers/common/ai/docs/frameworks/anthropic.rst
@@ -84,7 +84,9 @@ What each tool does
 ``AirflowTools`` accepts any toolset that implements
 :class:`~airflow.providers.common.ai.tools.ToolProvider`, and individual
 :class:`~airflow.providers.common.ai.tools.AirflowTool` objects. 
``MCPToolset`` is the
-exception; give the runner the MCP server through the SDK instead. Each tool:
+exception; run the agent with ``AsyncAnthropic`` and
+:class:`~airflow.providers.common.ai.tools.anthropic.AsyncAirflowTools`, and 
convert the MCP
+server's tools with the SDK's ``anthropic.lib.tools.mcp.async_mcp_tool()`` 
instead. Each tool:
 
 - Keeps the toolset tool's name, description and argument schema.
 - Runs through the toolset, so connection resolution, SQL validation,
diff --git a/providers/common/ai/docs/frameworks/claude_agent_sdk.rst 
b/providers/common/ai/docs/frameworks/claude_agent_sdk.rst
index 6b27e4f8dbe..54910ca5047 100644
--- a/providers/common/ai/docs/frameworks/claude_agent_sdk.rst
+++ b/providers/common/ai/docs/frameworks/claude_agent_sdk.rst
@@ -94,7 +94,9 @@ What each tool does
 ``AirflowTools`` accepts any toolset that implements
 :class:`~airflow.providers.common.ai.tools.ToolProvider`, and individual
 :class:`~airflow.providers.common.ai.tools.AirflowTool` objects. 
``MCPToolset`` is the
-exception; give the SDK the MCP server directly instead. Each tool:
+exception; give the SDK the MCP server directly instead, with
+``build_options(mcp_servers=...)``, and list its tools (``mcp__<name>__*``) in
+``allowed_tools`` so the agent may call them. Each tool:
 
 - Keeps the toolset tool's name, description and argument schema, served as an 
MCP tool
   named ``mcp__<server_name>__<tool name>``.
diff --git a/providers/common/ai/docs/frameworks/openai_agents.rst 
b/providers/common/ai/docs/frameworks/openai_agents.rst
index 23f3d6908fa..8b55cdd03c0 100644
--- a/providers/common/ai/docs/frameworks/openai_agents.rst
+++ b/providers/common/ai/docs/frameworks/openai_agents.rst
@@ -468,7 +468,7 @@ classes, such as ``MCPServerStreamableHttp``. 
``MCPToolset`` does not work throu
 
 .. code-block:: text
 
-    NotImplementedError: MCPToolset works in Pydantic AI agents and through 
the LangChain bridge, not through the framework-neutral tools. Connect your 
framework's own MCP client to the server instead, such as Strands' MCPClient or 
ADK's McpToolset.
+    airflow.providers.common.ai.exceptions.MCPToolsetNotSupportedError: 
MCPToolset works in Pydantic AI agents and through the LangChain bridge, not 
through AirflowTools. Pass the server to the agent's mcp_servers with one of 
the SDK's MCPServer classes, such as MCPServerStreamableHttp, instead.
 
 Results from ``mcp_servers`` do not pass through Airflow's secret masker, and 
under
 ``run_durably`` their calls run again on every retry.
@@ -542,8 +542,9 @@ Each model call and function tool call is recorded in the 
task instance's durabl
 journal as it completes (see :doc:`../durable_execution`). On Airflow 3.3 and 
later the
 journal is kept in the task state store. On earlier versions, set ``[common.ai]
 durable_cache_path`` to where it is kept; without it the call raises 
``ValueError:
-durable=True requires [common.ai] durable_cache_path to be set.`` Outside a 
task there
-is nothing to replay into, and the agent runs as ``Runner.run`` runs it.
+Durable execution on Airflow before 3.3 needs [common.ai] durable_cache_path 
to be set``.
+Outside a task there is nothing to replay into, and the agent runs as 
``Runner.run`` runs
+it.
 
 What was recorded is deleted when the agent's run succeeds, so clearing the 
task later
 starts over. That happens when ``run_durably_sync`` returns, not when the task
diff --git a/providers/common/ai/docs/troubleshooting.rst 
b/providers/common/ai/docs/troubleshooting.rst
index cc4cb5ecf0a..545318a9392 100644
--- a/providers/common/ai/docs/troubleshooting.rst
+++ b/providers/common/ai/docs/troubleshooting.rst
@@ -164,7 +164,7 @@ Run-time errors
     it either, so the task fails without retrying. Return text, numbers, 
lists, dicts or a
     Pydantic model. See :doc:`durable_execution`.
 
-``durable=True`` on Airflow below 3.3 fails with a ``ValueError`` about 
``durable_cache_path``
+Durable execution on Airflow below 3.3 fails with a ``ValueError`` about 
``durable_cache_path``
     On Airflow versions older than 3.3 the step cache lives in object storage 
and
     ``[common.ai] durable_cache_path`` must be set. On 3.3 and later the task 
state store
     is used and the option is ignored. See :doc:`durable_execution`.
diff --git a/providers/common/ai/provider.yaml 
b/providers/common/ai/provider.yaml
index 7196a6955d1..eaffa676ab8 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -116,15 +116,14 @@ config:
     options:
       durable_cache_path:
         description: |
-          ObjectStorage URI used to persist per-step caches when running
-          ``AgentOperator`` / ``@task.agent`` with ``durable=True`` on Airflow
-          **< 3.3**. Each task execution writes a single JSON file under this
+          ObjectStorage URI used to persist per-step caches for durable 
execution
+          on Airflow **< 3.3**. Each task execution writes a single JSON file 
under this
           path containing its cached model responses and tool results, so that
           on retry the agent can replay completed steps instead of re-issuing
           LLM calls and tool invocations. The file is deleted on successful 
task
           completion.
 
-          Required for ``durable=True`` only on Airflow < 3.3. On Airflow >= 
3.3
+          Required for durable execution only on Airflow < 3.3. On Airflow >= 
3.3
           the cache is stored in the AIP-103 task state store and this option 
is
           ignored. Any scheme supported by ``airflow.sdk.ObjectStoragePath`` is
           accepted (``file://``, ``s3://``, ``gs://``, ``azure://``, ...).
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/durable/storage.py 
b/providers/common/ai/src/airflow/providers/common/ai/durable/storage.py
index 3950b6f88b0..5a41b788dcb 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/durable/storage.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/durable/storage.py
@@ -38,7 +38,8 @@ def _get_base_path():
     path = conf.get(SECTION, "durable_cache_path", fallback="")
     if not path:
         raise ValueError(
-            "durable=True requires [common.ai] durable_cache_path to be set. "
+            "Durable execution on Airflow before 3.3 needs [common.ai] 
durable_cache_path "
+            "to be set, to say where its journal is kept. "
             "Example: durable_cache_path = file:///tmp/airflow_durable_cache"
         )
     return ObjectStoragePath(path)
diff --git a/providers/common/ai/src/airflow/providers/common/ai/exceptions.py 
b/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
index 1be907e6998..2386a40e73b 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
@@ -33,6 +33,16 @@ class ToolRunnerMaxIterationsError(RuntimeError):
     """
 
 
+class MCPToolsetNotSupportedError(NotImplementedError):
+    """
+    Raised when ``MCPToolset`` is handed to a framework adapter's 
``AirflowTools``.
+
+    An MCP session belongs to the event loop that opened it, so the framework 
connects to the
+    server with its own MCP client instead. The adapters this provider ships 
name that client
+    in the message.
+    """
+
+
 class ToolApprovalError(RuntimeError):
     """
     Raised when a run paused for tool approval cannot resume safely.
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py 
b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
index ffea5c04250..331957e8030 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
@@ -119,7 +119,7 @@ def get_provider_info():
                 "description": "Options for the 
``apache-airflow-providers-common-ai`` provider.\n",
                 "options": {
                     "durable_cache_path": {
-                        "description": "ObjectStorage URI used to persist 
per-step caches when running\n``AgentOperator`` / ``@task.agent`` with 
``durable=True`` on Airflow\n**< 3.3**. Each task execution writes a single 
JSON file under this\npath containing its cached model responses and tool 
results, so that\non retry the agent can replay completed steps instead of 
re-issuing\nLLM calls and tool invocations. The file is deleted on successful 
task\ncompletion.\n\nRequired for ``durable= [...]
+                        "description": "ObjectStorage URI used to persist 
per-step caches for durable execution\non Airflow **< 3.3**. Each task 
execution writes a single JSON file under this\npath containing its cached 
model responses and tool results, so that\non retry the agent can replay 
completed steps instead of re-issuing\nLLM calls and tool invocations. The file 
is deleted on successful task\ncompletion.\n\nRequired for durable execution 
only on Airflow < 3.3. On Airflow >= 3.3\n [...]
                         "version_added": "0.1.0",
                         "type": "string",
                         "example": "file:///tmp/airflow_durable_cache",
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py 
b/providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py
index 5f116b5569d..a7b6e84e4b9 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py
@@ -39,6 +39,7 @@ from collections.abc import Awaitable, Callable, Iterable, 
Sequence
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any, Protocol
 
+from airflow.providers.common.ai.exceptions import MCPToolsetNotSupportedError
 from airflow.providers.common.ai.utils.masking import mask_secrets
 from airflow.providers.common.ai.utils.tool_metrics import calling_framework, 
current_framework
 
@@ -49,6 +50,12 @@ log = logging.getLogger(__name__)
 
 __all__ = ["AirflowTool", "ToolCallError", "ToolProvider", "ToolResult", 
"collect_tools"]
 
+# The first sentence of the error for an MCPToolset handed to an adapter; the 
second says what
+# to use instead.
+_MCP_TOOLSET_NOT_SUPPORTED = (
+    "MCPToolset works in Pydantic AI agents and through the LangChain bridge, 
not through AirflowTools."
+)
+
 
 class ToolCallError(Exception):
     """
@@ -148,16 +155,35 @@ def collect_tools(sources: Iterable[ToolProvider | 
AirflowTool]) -> list[Airflow
     """
     Flatten toolsets and individual tools, as a framework adapter receives 
them, into tools.
 
+    :raises MCPToolsetNotSupportedError: when a source is an ``MCPToolset``. 
Connect the
+        framework's own MCP client to the server instead.
     :raises ValueError: when two tools share a name. Frameworks route a call 
by name, so one
         of them would silently never run; give the toolsets different 
``tool_prefix`` values,
         where they take one.
     """
+    return _collect_tools(sources)
+
+
+def _collect_tools(
+    sources: Iterable[ToolProvider | AirflowTool], *, mcp_hint: str | None = 
None
+) -> list[AirflowTool]:
+    """
+    :func:`collect_tools` for the adapters this provider ships.
+
+    :param mcp_hint: How the adapter's framework connects to an MCP server, as 
a sentence. It
+        replaces the generic advice in the error raised for an ``MCPToolset``.
+    """
     tools: list[AirflowTool] = []
     for source in sources:
         if isinstance(source, AirflowTool):
             tools.append(source)
-        else:
+            continue
+        try:
             tools.extend(source.airflow_tools())
+        except MCPToolsetNotSupportedError:
+            if mcp_hint is None:
+                raise
+            raise MCPToolsetNotSupportedError(f"{_MCP_TOOLSET_NOT_SUPPORTED} 
{mcp_hint}") from None
     names = [tool.name for tool in tools]
     if duplicates := sorted({name for name in names if names.count(name) > 1}):
         raise ValueError(
diff --git a/providers/common/ai/src/airflow/providers/common/ai/tools/adk.py 
b/providers/common/ai/src/airflow/providers/common/ai/tools/adk.py
index 5520dd7b060..a876e17195e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/tools/adk.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/tools/adk.py
@@ -34,7 +34,7 @@ except ImportError as e:
 
     raise AirflowOptionalProviderFeatureException(e)
 
-from airflow.providers.common.ai.tools import AirflowTool, collect_tools
+from airflow.providers.common.ai.tools import AirflowTool, _collect_tools
 from airflow.providers.common.ai.tools._from_toolset import tool_call_scope
 from airflow.providers.common.ai.utils.tool_metrics import calling_framework
 
@@ -46,6 +46,8 @@ if TYPE_CHECKING:
 
 __all__ = ["AirflowTools"]
 
+_MCP_HINT = "Connect ADK's McpToolset to the server instead."
+
 
 class AirflowTools(BaseToolset):
     """
@@ -82,7 +84,9 @@ class AirflowTools(BaseToolset):
 
     def __init__(self, *sources: ToolProvider | AirflowTool) -> None:
         super().__init__()
-        self._adk_tools: list[BaseTool] = [_AirflowAdkTool(tool) for tool in 
collect_tools(sources)]
+        self._adk_tools: list[BaseTool] = [
+            _AirflowAdkTool(tool) for tool in _collect_tools(sources, 
mcp_hint=_MCP_HINT)
+        ]
 
     async def get_tools(self, readonly_context: ReadonlyContext | None = None) 
-> list[BaseTool]:
         return self._adk_tools
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/tools/anthropic.py 
b/providers/common/ai/src/airflow/providers/common/ai/tools/anthropic.py
index a436441bb6a..e1f79d2d393 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/tools/anthropic.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/tools/anthropic.py
@@ -45,7 +45,7 @@ except ImportError as e:
     raise AirflowOptionalProviderFeatureException(e)
 
 from airflow.providers.common.ai.exceptions import ToolRunnerMaxIterationsError
-from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
collect_tools
+from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
_collect_tools
 from airflow.providers.common.ai.tools._from_toolset import tool_call_scope
 from airflow.providers.common.ai.utils.coroutines import run_coroutine_sync
 from airflow.providers.common.ai.utils.tool_definition import serialize_for_llm
@@ -61,6 +61,14 @@ log = logging.getLogger(__name__)
 
 __all__ = ["AirflowTools", "AsyncAirflowTools"]
 
+_ASYNC_MCP_HINT = "Convert the server's tools with 
anthropic.lib.tools.mcp.async_mcp_tool() instead."
+# The SDK's sync mcp_tool() needs an AnyIO worker thread with the session open 
on its loop,
+# which a sync task does not have.
+_MCP_HINT = (
+    "Run the agent with AsyncAnthropic and AsyncAirflowTools, and convert the 
server's tools "
+    "with anthropic.lib.tools.mcp.async_mcp_tool(), instead."
+)
+
 # The ``output_format`` a runner parses its final message into.
 _OutputT = TypeVar("_OutputT")
 
@@ -117,7 +125,7 @@ class AirflowTools:
 
     def __init__(self, *sources: ToolProvider | AirflowTool) -> None:
         self.tools: list[BetaFunctionTool[Callable[..., str]]] = [
-            _sync_tool(tool) for tool in collect_tools(sources)
+            _sync_tool(tool) for tool in _collect_tools(sources, 
mcp_hint=_MCP_HINT)
         ]
 
     def run(self, runner: BetaToolRunner[_OutputT]) -> 
ParsedBetaMessage[_OutputT]:
@@ -158,7 +166,7 @@ class AsyncAirflowTools:
 
     def __init__(self, *sources: ToolProvider | AirflowTool) -> None:
         self.tools: list[BetaAsyncFunctionTool[Callable[..., Coroutine[Any, 
Any, str]]]] = [
-            _async_tool(tool) for tool in collect_tools(sources)
+            _async_tool(tool) for tool in _collect_tools(sources, 
mcp_hint=_ASYNC_MCP_HINT)
         ]
 
     async def run(self, runner: BetaAsyncToolRunner[_OutputT]) -> 
ParsedBetaMessage[_OutputT]:
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/tools/claude_agent_sdk.py 
b/providers/common/ai/src/airflow/providers/common/ai/tools/claude_agent_sdk.py
index 49cae35fda9..4a9da23d161 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/tools/claude_agent_sdk.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/tools/claude_agent_sdk.py
@@ -42,7 +42,7 @@ import copy
 import logging
 from typing import TYPE_CHECKING, Any
 
-from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
collect_tools
+from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
_collect_tools
 from airflow.providers.common.ai.tools._from_toolset import tool_call_scope
 from airflow.providers.common.ai.utils.tool_definition import serialize_for_llm
 from airflow.providers.common.ai.utils.tool_metrics import calling_framework
@@ -56,6 +56,11 @@ log = logging.getLogger(__name__)
 
 __all__ = ["AirflowTools"]
 
+_MCP_HINT = (
+    "Pass the server to build_options(mcp_servers=...) and its tools 
(mcp__<name>__*) to "
+    "allowed_tools, or set both on ClaudeAgentOptions, instead."
+)
+
 
 _MAX_ERROR_DETAIL_CHARS = 500
 _MAX_ERROR_ENTRIES = 5
@@ -186,7 +191,7 @@ class AirflowTools:
 
     def __init__(self, *sources: ToolProvider | AirflowTool, server_name: str 
= "airflow") -> None:
         self.server_name = server_name
-        airflow_tools = collect_tools(sources)
+        airflow_tools = _collect_tools(sources, mcp_hint=_MCP_HINT)
         self.allowed_tools: list[str] = [f"mcp__{server_name}__{t.name}" for t 
in airflow_tools]
         # ``_run`` is the run in progress (guards re-entry); ``_last_run`` is 
the one that most
         # recently ended (refuses late calls). It is only read while ``_run`` 
is None.
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/tools/openai_agents.py 
b/providers/common/ai/src/airflow/providers/common/ai/tools/openai_agents.py
index 7da74585ecb..8d855171f82 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/tools/openai_agents.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/tools/openai_agents.py
@@ -52,7 +52,7 @@ except ImportError as e:
     raise AirflowOptionalProviderFeatureException(e)
 
 from airflow.providers.common.ai.observability import _content_capture_enabled
-from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
collect_tools
+from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
_collect_tools
 from airflow.providers.common.ai.tools._from_toolset import tool_call_scope
 from airflow.providers.common.ai.tools.tracing import _task_identity
 from airflow.providers.common.ai.utils.masking import dumps_masked, 
mask_secrets
@@ -71,6 +71,11 @@ if TYPE_CHECKING:
 
 __all__ = ["AirflowTools", "AirflowTraceProcessor"]
 
+_MCP_HINT = (
+    "Pass the server to the agent's mcp_servers with one of the SDK's 
MCPServer classes, "
+    "such as MCPServerStreamableHttp, instead."
+)
+
 
 class AirflowTools(list[Tool]):
     """
@@ -116,7 +121,7 @@ class AirflowTools(list[Tool]):
     """
 
     def __init__(self, *sources: ToolProvider | AirflowTool) -> None:
-        super().__init__(_to_function_tool(tool) for tool in 
collect_tools(sources))
+        super().__init__(_to_function_tool(tool) for tool in 
_collect_tools(sources, mcp_hint=_MCP_HINT))
 
 
 class _FailedToolCall(ToolCallError, AgentsException):
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/tools/strands.py 
b/providers/common/ai/src/airflow/providers/common/ai/tools/strands.py
index 2fb7d3416f0..a927c7e8429 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/tools/strands.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/tools/strands.py
@@ -36,7 +36,7 @@ except ImportError as e:
 
     raise AirflowOptionalProviderFeatureException(e)
 
-from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
collect_tools
+from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
_collect_tools
 from airflow.providers.common.ai.tools._from_toolset import tool_call_scope
 from airflow.providers.common.ai.utils.tool_metrics import calling_framework
 
@@ -56,6 +56,8 @@ if TYPE_CHECKING:
 
 __all__ = ["AirflowTools"]
 
+_MCP_HINT = "Connect Strands' MCPClient to the server instead."
+
 # Strands refuses two plugins with the same name on one agent, so each 
instance gets its own.
 _instance_numbers = itertools.count(1)
 
@@ -102,7 +104,7 @@ class AirflowTools(Plugin):
         self._name = f"airflow-tools-{next(_instance_numbers)}"
         self._run = object()
         self._airflow_tools: list[AgentTool] = [
-            _to_strands_tool(tool, lambda: self._run) for tool in 
collect_tools(sources)
+            _to_strands_tool(tool, lambda: self._run) for tool in 
_collect_tools(sources, mcp_hint=_MCP_HINT)
         ]
         super().__init__()
 
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/mcp.py 
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/mcp.py
index 1b7cc80992a..43b860995ea 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/mcp.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/mcp.py
@@ -20,6 +20,8 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, Any, Self
 
+from airflow.providers.common.ai.exceptions import MCPToolsetNotSupportedError
+from airflow.providers.common.ai.tools import _MCP_TOOLSET_NOT_SUPPORTED
 from airflow.providers.common.ai.utils.toolset_base import AirflowToolset
 
 if TYPE_CHECKING:
@@ -141,12 +143,10 @@ class MCPToolset(AirflowToolset):
         """
         Not supported: use the agent framework's own MCP client instead.
 
-        An MCP session belongs to the event loop that opened it, and the 
framework-neutral
-        tools run outside the Pydantic AI run that manages it, so every call 
would reconnect
-        to the server, and a stdio server would restart each time.
+        An MCP session belongs to the event loop that opened it, and a 
framework adapter's
+        ``AirflowTools`` run outside the Pydantic AI run that manages it, so 
every call would
+        reconnect to the server, and a stdio server would restart each time.
         """
-        raise NotImplementedError(
-            "MCPToolset works in Pydantic AI agents and through the LangChain 
bridge, not through "
-            "the framework-neutral tools. Connect your framework's own MCP 
client to the server "
-            "instead, such as Strands' MCPClient or ADK's McpToolset."
+        raise MCPToolsetNotSupportedError(
+            f"{_MCP_TOOLSET_NOT_SUPPORTED} Connect your framework's own MCP 
client to the server instead."
         )
diff --git a/providers/common/ai/tests/unit/common/ai/durable/test_storage.py 
b/providers/common/ai/tests/unit/common/ai/durable/test_storage.py
index aeae08f9e1a..a9098cc1922 100644
--- a/providers/common/ai/tests/unit/common/ai/durable/test_storage.py
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_storage.py
@@ -127,11 +127,16 @@ class TestDeleteSteps:
 
 
 class TestMissingConfig:
-    def test_a_missing_cache_path_names_the_option(self):
+    def 
test_a_missing_cache_path_names_the_option_without_naming_durable_true(self):
+        """run_durably() callers reach this too, and they never pass 
durable=True."""
         storage_module._get_base_path.cache_clear()
         try:
             with conf_vars({("common.ai", "durable_cache_path"): ""}):
-                with pytest.raises(ValueError, match=r"\[common.ai\] 
durable_cache_path"):
+                with pytest.raises(
+                    ValueError, match=r"^Durable execution .* needs 
\[common.ai\] durable_cache_path"
+                ) as exc_info:
                     DurableStorage(dag_id="d", task_id="t", 
run_id="r").load_step("k")
+
+            assert "durable=True" not in str(exc_info.value)
         finally:
             storage_module._get_base_path.cache_clear()
diff --git a/providers/common/ai/tests/unit/common/ai/tools/test_adk.py 
b/providers/common/ai/tests/unit/common/ai/tools/test_adk.py
index 00cb64ba766..e7193132629 100644
--- a/providers/common/ai/tests/unit/common/ai/tools/test_adk.py
+++ b/providers/common/ai/tests/unit/common/ai/tools/test_adk.py
@@ -32,8 +32,10 @@ from google.adk.models.llm_response import LlmResponse
 from google.adk.runners import InMemoryRunner
 from google.genai import types
 
+from airflow.providers.common.ai.exceptions import MCPToolsetNotSupportedError
 from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
ToolResult
 from airflow.providers.common.ai.tools.adk import AirflowTools
+from airflow.providers.common.ai.toolsets.mcp import MCPToolset
 from airflow.providers.common.ai.toolsets.sql import SQLToolset
 
 from unit.common.ai.toolsets.test_sql import _make_mock_db_hook
@@ -120,6 +122,10 @@ class TestAirflowTools:
         assert tool._detect_error_in_response({"error": "Unknown column"}) == 
"TOOL_ERROR"
         assert tool._detect_error_in_response({"result": "ok"}) is None
 
+    def test_an_mcp_toolset_points_to_adks_mcp_toolset(self):
+        with pytest.raises(MCPToolsetNotSupportedError, match="ADK's 
McpToolset"):
+            AirflowTools(MCPToolset("mcp_default"))
+
 
 class TestAgentRun:
     def test_the_agent_reads_a_toolset_result(self):
diff --git a/providers/common/ai/tests/unit/common/ai/tools/test_anthropic.py 
b/providers/common/ai/tests/unit/common/ai/tools/test_anthropic.py
index b8a2d736f6d..2c1d07be24e 100644
--- a/providers/common/ai/tests/unit/common/ai/tools/test_anthropic.py
+++ b/providers/common/ai/tests/unit/common/ai/tools/test_anthropic.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 import asyncio
 import copy
 import json
+import re
 from typing import TYPE_CHECKING, Any
 from unittest.mock import MagicMock, patch
 from uuid import uuid4
@@ -32,9 +33,10 @@ from anthropic import Anthropic, AsyncAnthropic, 
DefaultAsyncHttpxClient, Defaul
 from anthropic.types.beta import BetaTextBlock
 from pydantic_ai.exceptions import ModelRetry
 
-from airflow.providers.common.ai.exceptions import ToolRunnerMaxIterationsError
+from airflow.providers.common.ai.exceptions import 
MCPToolsetNotSupportedError, ToolRunnerMaxIterationsError
 from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
ToolResult
 from airflow.providers.common.ai.tools.anthropic import AirflowTools, 
AsyncAirflowTools
+from airflow.providers.common.ai.toolsets.mcp import MCPToolset
 from airflow.providers.common.ai.toolsets.sql import SQLToolset
 
 from unit.common.ai.tools.test__from_toolset import _scripted
@@ -205,6 +207,15 @@ def 
test_each_toolset_tool_becomes_a_runner_tool_with_its_schema(tools_class):
     assert definitions[3]["input_schema"]["required"] == ["sql"]
 
 
[email protected]("tools_class", [AirflowTools, AsyncAirflowTools])
+def 
test_an_mcp_toolset_points_to_the_anthropic_sdks_async_mcp_helper(tools_class):
+    """The SDK's sync mcp_tool() needs an AnyIO worker thread, so both runners 
point at the async one."""
+    with pytest.raises(
+        MCPToolsetNotSupportedError, 
match=re.escape("anthropic.lib.tools.mcp.async_mcp_tool()")
+    ):
+        tools_class(MCPToolset("mcp_default"))
+
+
 @pytest.mark.parametrize("tools_class", [AirflowTools, AsyncAirflowTools])
 def test_a_runner_tool_does_not_share_the_source_schema(tools_class):
     """A toolset's schema can be a module-level constant that the pydantic-ai 
path also uses."""
diff --git 
a/providers/common/ai/tests/unit/common/ai/tools/test_claude_agent_sdk.py 
b/providers/common/ai/tests/unit/common/ai/tools/test_claude_agent_sdk.py
index 8788f02cb07..02b9f6f495e 100644
--- a/providers/common/ai/tests/unit/common/ai/tools/test_claude_agent_sdk.py
+++ b/providers/common/ai/tests/unit/common/ai/tools/test_claude_agent_sdk.py
@@ -20,6 +20,7 @@ import asyncio
 import copy
 import importlib
 import json
+import re
 import sys
 from collections.abc import AsyncIterator
 from typing import Any
@@ -32,8 +33,10 @@ pytest.importorskip("claude_agent_sdk")
 from claude_agent_sdk import AssistantMessage, Message, ResultMessage, 
TextBlock, UserMessage
 from pydantic_ai.exceptions import ModelRetry
 
+from airflow.providers.common.ai.exceptions import MCPToolsetNotSupportedError
 from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
ToolResult, collect_tools
 from airflow.providers.common.ai.tools.claude_agent_sdk import AirflowTools
+from airflow.providers.common.ai.toolsets.mcp import MCPToolset
 from airflow.providers.common.ai.toolsets.sql import SQLToolset
 from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
 
@@ -179,6 +182,13 @@ async def _request_real_server(tools: AirflowTools, 
method: str, params: dict[st
 
 
 class TestTools:
+    def test_an_mcp_toolset_points_to_the_sdks_mcp_servers(self):
+        with pytest.raises(
+            MCPToolsetNotSupportedError,
+            match=re.escape("build_options(mcp_servers=...) and its tools 
(mcp__<name>__*) to allowed_tools"),
+        ):
+            AirflowTools(MCPToolset("mcp_default"))
+
     def test_each_toolset_tool_becomes_an_mcp_tool_with_its_schema(self):
         tools, handlers = _build_tools(_tool(ToolResult("ok")), 
SQLToolset("pg_default"))
 
diff --git 
a/providers/common/ai/tests/unit/common/ai/tools/test_openai_agents.py 
b/providers/common/ai/tests/unit/common/ai/tools/test_openai_agents.py
index 949f163dc2b..afb82eee50c 100644
--- a/providers/common/ai/tests/unit/common/ai/tools/test_openai_agents.py
+++ b/providers/common/ai/tests/unit/common/ai/tools/test_openai_agents.py
@@ -51,6 +51,7 @@ from opentelemetry.trace import SpanKind, StatusCode
 from pydantic_ai.exceptions import ModelRetry
 from pydantic_ai.toolsets.function import FunctionToolset
 
+from airflow.providers.common.ai.exceptions import MCPToolsetNotSupportedError
 from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
ToolResult
 from airflow.providers.common.ai.tools._from_toolset import 
airflow_tools_from_toolset
 from airflow.providers.common.ai.tools.openai_agents import (
@@ -60,6 +61,7 @@ from airflow.providers.common.ai.tools.openai_agents import (
     _mask_content,
 )
 from airflow.providers.common.ai.tools.tracing import agent_framework_tracing
+from airflow.providers.common.ai.toolsets.mcp import MCPToolset
 from airflow.providers.common.ai.toolsets.sql import SQLToolset
 
 from tests_common.test_utils.config import conf_vars
@@ -189,6 +191,10 @@ class TestAirflowTools:
 
         assert source.parameters == before
 
+    def test_an_mcp_toolset_points_to_the_sdks_mcp_servers(self):
+        with pytest.raises(MCPToolsetNotSupportedError, match="mcp_servers .* 
MCPServerStreamableHttp"):
+            AirflowTools(MCPToolset("mcp_default"))
+
 
 class TestAgentRun:
     def test_the_agent_reads_a_toolset_result(self):
diff --git a/providers/common/ai/tests/unit/common/ai/tools/test_strands.py 
b/providers/common/ai/tests/unit/common/ai/tools/test_strands.py
index 9bda221008e..593cc8e635b 100644
--- a/providers/common/ai/tests/unit/common/ai/tools/test_strands.py
+++ b/providers/common/ai/tests/unit/common/ai/tools/test_strands.py
@@ -33,9 +33,11 @@ from strands.models.model import Model
 from strands.tools.registry import ToolRegistry
 from strands.types.exceptions import EventLoopException
 
+from airflow.providers.common.ai.exceptions import MCPToolsetNotSupportedError
 from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
ToolResult
 from airflow.providers.common.ai.tools._from_toolset import 
airflow_tools_from_toolset
 from airflow.providers.common.ai.tools.strands import AirflowTools
+from airflow.providers.common.ai.toolsets.mcp import MCPToolset
 from airflow.providers.common.ai.toolsets.sql import SQLToolset
 
 from unit.common.ai.toolsets.test_sql import _make_mock_db_hook
@@ -128,6 +130,10 @@ def _run_agent(plugin: AirflowTools, tool_name: str, 
tool_input: dict[str, Any])
 
 
 class TestAirflowTools:
+    def test_an_mcp_toolset_points_to_strands_mcp_client(self):
+        with pytest.raises(MCPToolsetNotSupportedError, match="Strands' 
MCPClient"):
+            AirflowTools(MCPToolset("mcp_default"))
+
     def test_adds_every_tool_of_a_toolset(self):
         plugin = AirflowTools(SQLToolset("pg_default"))
 
diff --git a/providers/common/ai/tests/unit/common/ai/tools/test_tools.py 
b/providers/common/ai/tests/unit/common/ai/tools/test_tools.py
index 35d1975382c..be80d3b3fc7 100644
--- a/providers/common/ai/tests/unit/common/ai/tools/test_tools.py
+++ b/providers/common/ai/tests/unit/common/ai/tools/test_tools.py
@@ -18,11 +18,20 @@ from __future__ import annotations
 
 import asyncio
 import traceback
+from functools import partial
 from typing import Any
 
 import pytest
 
-from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
ToolResult, collect_tools
+from airflow.providers.common.ai.exceptions import MCPToolsetNotSupportedError
+from airflow.providers.common.ai.tools import (
+    AirflowTool,
+    ToolCallError,
+    ToolResult,
+    _collect_tools,
+    collect_tools,
+)
+from airflow.providers.common.ai.toolsets.mcp import MCPToolset
 
 
 def _tool(function) -> AirflowTool:
@@ -112,6 +121,38 @@ class TestCollectTools:
         ):
             collect_tools([_Provider(_named("query")), 
_Provider(_named("query"))])
 
+    @pytest.mark.parametrize(
+        ("collect", "advice"),
+        [
+            pytest.param(
+                collect_tools, "Connect your framework's own MCP client to the 
server instead.", id="public"
+            ),
+            pytest.param(
+                partial(_collect_tools, mcp_hint="Connect Acme's McpClient 
instead."),
+                "Connect Acme's McpClient instead.",
+                id="adapter-hint",
+            ),
+        ],
+    )
+    def test_an_mcp_toolset_error_says_what_to_connect_instead(self, collect, 
advice):
+        with pytest.raises(MCPToolsetNotSupportedError) as exc_info:
+            collect([MCPToolset("mcp_default")])
+
+        assert str(exc_info.value) == (
+            "MCPToolset works in Pydantic AI agents and through the LangChain 
bridge, not through "
+            f"AirflowTools. {advice}"
+        )
+
+    def test_other_not_implemented_errors_pass_through_unchanged(self):
+        """The hint is MCP-specific; another provider's NotImplementedError 
keeps its own message."""
+
+        class _Unfinished:
+            def airflow_tools(self) -> list[AirflowTool]:
+                raise NotImplementedError("not yet")
+
+        with pytest.raises(NotImplementedError, match="^not yet$"):
+            _collect_tools([_Unfinished()], mcp_hint="Connect Acme's McpClient 
instead.")
+
 
 @pytest.mark.enable_redact
 class TestAirflowToolMasking:
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_mcp.py 
b/providers/common/ai/tests/unit/common/ai/toolsets/test_mcp.py
index 11745066c63..5391d62e0e0 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_mcp.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_mcp.py
@@ -21,6 +21,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
+from airflow.providers.common.ai.exceptions import MCPToolsetNotSupportedError
 from airflow.providers.common.ai.toolsets.mcp import MCPToolset
 
 _HOOK_PATH = "airflow.providers.common.ai.hooks.mcp.MCPHook"
@@ -136,5 +137,10 @@ class TestMCPToolsetDelegation:
 
 class TestMCPToolsetOutsidePydanticAI:
     def test_points_other_frameworks_to_their_own_mcp_client(self):
-        with pytest.raises(NotImplementedError, match="framework's own MCP 
client"):
+        with pytest.raises(MCPToolsetNotSupportedError) as exc_info:
             MCPToolset(mcp_conn_id="mcp_default").airflow_tools()
+
+        assert str(exc_info.value) == (
+            "MCPToolset works in Pydantic AI agents and through the LangChain 
bridge, not through "
+            "AirflowTools. Connect your framework's own MCP client to the 
server instead."
+        )

Reply via email to