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 5aadb5571ae Add a Databricks toolset for Unity Gateway MCP Services
(#74198)
5aadb5571ae is described below
commit 5aadb5571ae9f24bb2fd1b497f9f013bc8c9c4d1
Author: Firas Bouzazi <[email protected]>
AuthorDate: Sun Nov 8 02:16:14 2026 +0100
Add a Databricks toolset for Unity Gateway MCP Services (#74198)
Dag authors want their agents to use governed Databricks tools such as
Unity Catalog functions, Genie and AI Search through a Unity AI Gateway
MCP Service, without putting bearer tokens or gateway URLs in Dag code.
The generic MCP toolset needs both in a separate connection and sends a
fixed token, which expires during long agent runs with OAuth identities.
closes: #74193
---
docs/spelling_wordlist.txt | 1 +
providers/databricks/docs/index.rst | 5 +-
providers/databricks/docs/toolsets/index.rst | 33 +
providers/databricks/docs/toolsets/unity_mcp.rst | 126 +++
providers/databricks/provider.yaml | 8 +
providers/databricks/pyproject.toml | 10 +-
.../src/airflow/providers/databricks/exceptions.py | 31 +
.../providers/databricks/get_provider_info.py | 5 +
.../providers/databricks/toolsets/__init__.py | 17 +
.../providers/databricks/toolsets/unity_mcp.py | 629 ++++++++++++++
.../airflow/providers/databricks/version_compat.py | 2 +
.../databricks/example_databricks_unity_mcp.py | 69 ++
.../tests/unit/databricks/toolsets/__init__.py | 16 +
.../unit/databricks/toolsets/test_unity_mcp.py | 902 +++++++++++++++++++++
uv.lock | 12 +-
15 files changed, 1855 insertions(+), 11 deletions(-)
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index 3ba09fddae2..1c34f312e1d 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -1111,6 +1111,7 @@ Maxime
MaxRuntimeInSeconds
mb
MCP
+mcp
md
mem
memcached
diff --git a/providers/databricks/docs/index.rst
b/providers/databricks/docs/index.rst
index 0d7d40427a4..d3be73b253e 100644
--- a/providers/databricks/docs/index.rst
+++ b/providers/databricks/docs/index.rst
@@ -36,6 +36,7 @@
Connection types <connections/databricks>
Operators <operators/index>
+ Toolsets <toolsets/index>
Plugins <plugins/index>
.. toctree::
@@ -147,16 +148,16 @@ Install them when installing from PyPI. For example:
.. code-block:: bash
- pip install apache-airflow-providers-databricks[common.ai]
+ pip install apache-airflow-providers-databricks[avro]
==================
================================================================================================================================================================
Extra Dependencies
==================
================================================================================================================================================================
-``common.ai`` ``apache-airflow-providers-common-ai>=1.0.0``
``avro`` ``fastavro>=1.9.0; python_version<"3.14"``,
``fastavro>=1.10.0; python_version>="3.12" and python_version<"3.14"``,
``fastavro>=1.12.1; python_version>="3.14"``
``amazon`` ``apache-airflow-providers-amazon>=9.22.0``
``azure-identity`` ``azure-identity>=1.25.3``
+``common.ai`` ``apache-airflow-providers-common-ai[mcp]>=1.0.0``,
``fastmcp-slim[client]>=4.0.0``
``fab`` ``apache-airflow-providers-fab>=2.2.0``
``google`` ``apache-airflow-providers-google>=10.24.0``
``sdk`` ``databricks-sdk==0.10.0``
diff --git a/providers/databricks/docs/toolsets/index.rst
b/providers/databricks/docs/toolsets/index.rst
new file mode 100644
index 00000000000..cdad1abfd69
--- /dev/null
+++ b/providers/databricks/docs/toolsets/index.rst
@@ -0,0 +1,33 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ .. http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+
+Databricks Toolsets
+===================
+
+Toolsets give agents from the :doc:`Common AI provider
<apache-airflow-providers-common-ai:index>`
+tools backed by Databricks. They need the ``common.ai`` extra:
+
+.. code-block:: bash
+
+ pip install 'apache-airflow-providers-databricks[common.ai]'
+
+.. toctree::
+ :maxdepth: 1
+ :glob:
+
+ *
diff --git a/providers/databricks/docs/toolsets/unity_mcp.rst
b/providers/databricks/docs/toolsets/unity_mcp.rst
new file mode 100644
index 00000000000..b61c5d64f58
--- /dev/null
+++ b/providers/databricks/docs/toolsets/unity_mcp.rst
@@ -0,0 +1,126 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ .. http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+
+.. _howto/toolset:DatabricksUnityMCPToolset:
+
+Unity Gateway MCP Services
+==========================
+
+Use
:class:`~airflow.providers.databricks.toolsets.unity_mcp.DatabricksUnityMCPToolset`
to give an
+agent the tools of a Unity Gateway MCP Service: either one Databricks provides
for workspace tools
+and SaaS applications, such as ``system.ai.google_calendar``, or an external
MCP server registered as
+an MCP Service in Unity Catalog. The toolset works with
+:class:`~airflow.providers.common.ai.operators.agent.AgentOperator`,
``@task.agent``, and the LangChain
+bridge of the Common AI provider.
+
+The Dag names the service by its three-level Unity Catalog name,
``catalog.schema.service``, and the
+:ref:`Databricks connection <howto/connection:databricks>` to use. The toolset
builds the service URL,
+``https://<workspace host>/ai-gateway/mcp-services/<catalog.schema.service>``,
from the connection's
+host, so neither the gateway URL nor a token appears in Dag code, and the
connection's token is only
+sent to that workspace, over HTTPS. A connection whose ``schema`` is ``http``
is rejected unless its
+host is a loopback address. Requests go through the proxy in the connection's
``proxies`` extra, if
+set. Service names may contain only ASCII letters, digits and ``_`` in each
part; any other name is
+rejected before a request is made.
+
+.. exampleinclude::
/../../databricks/tests/system/databricks/example_databricks_unity_mcp.py
+ :language: python
+ :start-after: [START howto_toolset_databricks_unity_mcp]
+ :end-before: [END howto_toolset_databricks_unity_mcp]
+
+The service name and connection ID are templated when the toolset is passed to
``AgentOperator`` or
+``@task.agent``.
+
+Caller identity and authentication
+----------------------------------
+
+MCP Services need a workspace enabled for Unity Catalog, in a region where
Model Serving is
+supported.
+
+The gateway runs every tool call as the identity of the connection's
credentials. That identity needs
+``EXECUTE`` on the MCP Service, ``USE CATALOG`` and ``USE SCHEMA`` on its
parent catalog and schema
+(``EXECUTE`` alone is not enough), and an assignment to the workspace. It
needs no privilege on the
+Unity Catalog connection behind the service. On the built-in ``system.ai``
services, account users
+hold these privileges by default (see `MCP Services
+<https://docs.databricks.com/aws/en/agents/mcp-tools/mcp-services>`__). The
gateway exposes only the
+tools selected for the service, and the service's policies apply. Grant the
identity only the services
+the agent should use.
+
+Built-in services that act on a user's own data, such as
``system.ai.google_calendar`` or
+``system.ai.gmail``, need that identity to complete a one-time OAuth login
first, for example by
+opening the service in Catalog Explorer and clicking **Login**.
+
+The toolset sends the connection's token as a bearer token, so the connection
must use one of these
+authentication modes of the Databricks connection:
+
+* a personal access token (the identity is the token's user or service
principal);
+* service principal OAuth (``service_principal_oauth``);
+* Azure AD: a service principal, a managed identity, or
``DefaultAzureCredential``;
+* workload identity federation (Kubernetes, AWS IAM, or a supplied token
provider).
+
+For service principal OAuth, the OAuth secret must allow the ``all-apis``
scope, which the connection
+requests; a secret restricted to narrower scopes fails (see `OAuth for service
principals
+<https://docs.databricks.com/aws/en/dev-tools/auth/oauth-m2m>`__). Username
and password
+authentication is not supported. A token is fetched from the connection for
+every request, so OAuth and Azure AD tokens are refreshed during a long agent
run and when the toolset
+reconnects. Tokens minted for the toolset are masked in task logs.
+
+Errors and retries
+------------------
+
+When the MCP server answers a tool call with an error, such as invalid
arguments, the error goes to the
+model, which can correct the call and try again.
+
+When the gateway gives no answer of its own, a tool call that was already sent
may or may not have
+run, and repeating a tool that changes data could apply the change twice. The
toolset never retries
+it: the task fails with one of these exceptions from
:mod:`airflow.providers.databricks.exceptions`,
+which are also raised for failures while connecting and listing tools:
+
+.. list-table::
+ :header-rows: 1
+
+ * - Exception
+ - Cause
+ * - ``DatabricksUnityMCPAccessDeniedError``
+ - The gateway will not let the identity invoke the service: no service
has that name, the
+ identity lacks ``EXECUTE`` on the service or ``USE CATALOG`` / ``USE
SCHEMA`` on its parents,
+ or the credentials are invalid. A missing service is reported like a
missing privilege.
+ * - ``DatabricksUnityMCPThrottledError``
+ - HTTP 429. Its ``retry_after`` attribute holds the ``Retry-After``
delay when the gateway gives
+ it as a number of seconds.
+ * - ``DatabricksUnityMCPTransportError``
+ - The gateway could not be reached, the connection dropped, a request
got no answer in time,
+ or the MCP handshake did not finish within ``init_timeout``.
+ * - ``DatabricksUnityMCPError``
+ - Any other gateway error, or a failure to get a token from the
connection.
+
+A connection that cannot be used at all, such as one without a host or without
token-based
+authentication, raises ``ValueError`` when the toolset connects.
+
+Unity Gateway forwards a response only once the MCP server has finished
sending it, so connecting
+to a server that keeps its response stream open takes as long as the server
keeps it open. The toolset
+waits ``init_timeout`` seconds (30 by default) for the handshake; raise it for
slow services.
+
+The exceptions carry the HTTP status in ``http_status_code`` when it is known.
When the agent runs
+several tool calls of the toolset at once, the status of a failed call cannot
be told apart from the
+others', so a call that fails with HTTP 401, 403 or 429 is reported as
``DatabricksUnityMCPError``
+without it. Transport errors, timeouts and a denial the gateway reports in its
JSON-RPC answer keep
+their class. Use task retries for calls that are safe to repeat.
+
+These exception classes and their attributes reach the task in Pydantic AI
runs. Through the
+LangChain bridge, ``airflow_toolset_to_langchain_tools``, a failed tool call
raises
+``ToolCallError`` instead, whose message names the exception class and repeats
its message;
+``http_status_code`` and ``retry_after`` are not carried over.
diff --git a/providers/databricks/provider.yaml
b/providers/databricks/provider.yaml
index c11d7a3e8e2..894c986dd59 100644
--- a/providers/databricks/provider.yaml
+++ b/providers/databricks/provider.yaml
@@ -147,6 +147,9 @@ integrations:
how-to-guide:
- /docs/apache-airflow-providers-databricks/operators/workflow.rst
tags: [service]
+ - integration-name: Databricks Unity Gateway
+ external-doc-url: https://docs.databricks.com/aws/en/unity-gateway/concepts
+ tags: [service]
operators:
- integration-name: Databricks
@@ -201,6 +204,11 @@ sensors:
- airflow.providers.databricks.sensors.databricks_sql
- airflow.providers.databricks.sensors.databricks_partition
+toolsets:
+ - integration-name: Databricks Unity Gateway
+ python-modules:
+ - airflow.providers.databricks.toolsets.unity_mcp
+
connection-types:
- hook-class-name:
airflow.providers.databricks.hooks.databricks.DatabricksHook
hook-name: "Databricks"
diff --git a/providers/databricks/pyproject.toml
b/providers/databricks/pyproject.toml
index 1faa8dde64f..27a48f2b7de 100644
--- a/providers/databricks/pyproject.toml
+++ b/providers/databricks/pyproject.toml
@@ -77,9 +77,6 @@ dependencies = [
# The optional dependencies should be modified in place in the generated file
# Any change in the dependencies is preserved when the file is regenerated
[project.optional-dependencies]
-"common.ai" = [
- "apache-airflow-providers-common-ai>=1.0.0",
-]
# pip install apache-airflow-providers-databricks[sdk]
"avro" = [
'fastavro>=1.9.0; python_version<"3.14"',
@@ -92,6 +89,11 @@ dependencies = [
"azure-identity" = [
"azure-identity>=1.25.3",
]
+"common.ai" = [
+ "apache-airflow-providers-common-ai[mcp]>=1.0.0",
+ # 3.x is built on httpx and MCP SDK 1.x; the toolset needs the httpx2 and
MCP SDK 2.x of 4.0.
+ "fastmcp-slim[client]>=4.0.0",
+]
"fab" = [
"apache-airflow-providers-fab>=2.2.0"
]
@@ -129,7 +131,7 @@ dev = [
"apache-airflow-providers-microsoft-azure",
"apache-airflow-providers-common-sql[pandas,polars]",
"apache-airflow-providers-fab",
- "apache-airflow-providers-databricks[sqlalchemy]",
+ "apache-airflow-providers-databricks[common.ai,sqlalchemy]",
]
# To build docs:
diff --git
a/providers/databricks/src/airflow/providers/databricks/exceptions.py
b/providers/databricks/src/airflow/providers/databricks/exceptions.py
index b48e5dd0059..01d47523929 100644
--- a/providers/databricks/src/airflow/providers/databricks/exceptions.py
+++ b/providers/databricks/src/airflow/providers/databricks/exceptions.py
@@ -54,3 +54,34 @@ class DatabricksApiError(AirflowException):
def __init__(self, message: str, *, http_status_code: int | None = None)
-> None:
super().__init__(message)
self.http_status_code = http_status_code
+
+
+class DatabricksUnityMCPError(DatabricksApiError):
+ """Raised when a call to a Unity Gateway MCP Service fails."""
+
+
+class DatabricksUnityMCPAccessDeniedError(DatabricksUnityMCPError):
+ """
+ Raised when the gateway will not let the caller invoke the service.
+
+ The service may not exist, the caller may lack a privilege on it, or its
credentials may be invalid.
+ """
+
+
+class DatabricksUnityMCPThrottledError(DatabricksUnityMCPError):
+ """Raised when the gateway rate-limits the caller."""
+
+ def __init__(
+ self, message: str, *, http_status_code: int | None = None,
retry_after: float | None = None
+ ) -> None:
+ super().__init__(message, http_status_code=http_status_code)
+ self.retry_after = retry_after
+
+
+class DatabricksUnityMCPTransportError(DatabricksUnityMCPError):
+ """
+ Raised when the gateway cannot be reached or the connection drops.
+
+ When this interrupts a tool call, the tool may or may not have run, so the
call is not
+ retried automatically.
+ """
diff --git
a/providers/databricks/src/airflow/providers/databricks/get_provider_info.py
b/providers/databricks/src/airflow/providers/databricks/get_provider_info.py
index 397706c99e2..76af2eebfc1 100644
--- a/providers/databricks/src/airflow/providers/databricks/get_provider_info.py
+++ b/providers/databricks/src/airflow/providers/databricks/get_provider_info.py
@@ -68,6 +68,11 @@ def get_provider_info():
"how-to-guide":
["/docs/apache-airflow-providers-databricks/operators/workflow.rst"],
"tags": ["service"],
},
+ {
+ "integration-name": "Databricks Unity Gateway",
+ "external-doc-url":
"https://docs.databricks.com/aws/en/unity-gateway/concepts",
+ "tags": ["service"],
+ },
],
"operators": [
{
diff --git
a/providers/databricks/src/airflow/providers/databricks/toolsets/__init__.py
b/providers/databricks/src/airflow/providers/databricks/toolsets/__init__.py
new file mode 100644
index 00000000000..217e5db9607
--- /dev/null
+++ b/providers/databricks/src/airflow/providers/databricks/toolsets/__init__.py
@@ -0,0 +1,17 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git
a/providers/databricks/src/airflow/providers/databricks/toolsets/unity_mcp.py
b/providers/databricks/src/airflow/providers/databricks/toolsets/unity_mcp.py
new file mode 100644
index 00000000000..b69f9c169f9
--- /dev/null
+++
b/providers/databricks/src/airflow/providers/databricks/toolsets/unity_mcp.py
@@ -0,0 +1,629 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Toolset that gives a common.ai agent the tools of a Unity Gateway MCP
Service."""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import ipaddress
+import json
+import logging
+import re
+import threading
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlsplit
+
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException, mask_secret
+from airflow.providers.databricks.exceptions import (
+ DatabricksUnityMCPAccessDeniedError,
+ DatabricksUnityMCPError,
+ DatabricksUnityMCPThrottledError,
+ DatabricksUnityMCPTransportError,
+)
+from airflow.providers.databricks.hooks.databricks import DatabricksHook
+from airflow.providers.databricks.version_compat import AIRFLOW_V_3_2_PLUS
+
+try:
+ # httpx2 and fastmcp come with pydantic-ai's mcp extra, which the
common.ai extra installs.
+ import httpx2
+ from fastmcp.client.transports import StreamableHttpTransport
+ from fastmcp.exceptions import McpError
+ from pydantic_ai.mcp import MCPToolset as PydanticAIMCPToolset
+
+ from airflow.providers.common.ai.utils.toolset_base import AirflowToolset
+except ImportError as e:
+ raise AirflowOptionalProviderFeatureException(
+ "DatabricksUnityMCPToolset needs the 'common.ai' extra of the
databricks provider: "
+ "pip install 'apache-airflow-providers-databricks[common.ai]'"
+ ) from e
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable,
Iterator, Sequence
+
+ from pydantic_ai._run_context import RunContext
+ from pydantic_ai.toolsets.abstract import ToolsetTool
+
+ from airflow.providers.common.ai.tools import AirflowTool
+
+log = logging.getLogger(__name__)
+
+GATEWAY_MCP_SERVICES_PATH = "ai-gateway/mcp-services"
+
+# Restricting each part to these characters keeps the name from adding a path,
query or
+# fragment to the URL, so the request can only reach the service path on the
connection's host.
+_SERVICE_NAME_PART = r"[A-Za-z0-9_]+"
+_SERVICE_NAME =
re.compile(rf"{_SERVICE_NAME_PART}\.{_SERVICE_NAME_PART}\.{_SERVICE_NAME_PART}")
+
+# The JSON-RPC error code Unity Gateway answers with, at HTTP 403, when the
caller may not invoke
+# the service, including when no service has that name.
+_NOT_AUTHORIZED_CODE = -32007
+
+# Errors the MCP client makes up when the gateway gave no JSON-RPC answer,
such as an HTTP error
+# with a plain body or a response that never arrived (see
mcp.client.streamable_http). Every other
+# MCP error is the server's own answer. Matched on code and message, the only
things they carry.
+_CLIENT_ERRORS = {
+ (-32603, "Server returned an error response"),
+ (-32601, "Not Found"),
+ (-32600, "Session terminated"),
+ (-32000, "Connection closed"),
+}
+# What fastmcp raises, in place of the TimeoutError it loses, when the MCP
handshake outlasts
+# init_timeout (see fastmcp.client.client).
+_HANDSHAKE_TIMEOUT_MESSAGE = "Failed to initialize server session"
+# What fastmcp raises for a call that reaches the session after another
request ended it
+# (see fastmcp.client.client).
+_SESSION_ENDED_MESSAGE = "Client is not connected"
+# What the MCP client raises when a request outlasts its read timeout (see
+# mcp.shared.jsonrpc_dispatcher). A server may use the same code, so the
message is matched too.
+_REQUEST_TIMEOUT_CODE = -32001
+_REQUEST_TIMEOUT_MESSAGE = re.compile(r"Request '[^']*' timed out")
+_CLIENT_ERROR_PREFIXES = (
+ "Unexpected content type:",
+ "Failed to parse JSON response:",
+ "Failed to parse SSE message:",
+ "SSE stream ",
+ "server answered a request with 202 Accepted",
+ "Redirect to ",
+)
+
+
+def validate_service_name(service_name: str) -> None:
+ """
+ Raise ``ValueError`` unless ``service_name`` is a three-level
``catalog.schema.service`` name.
+
+ Each part may contain only ASCII letters, digits and underscores.
+ """
+ if not isinstance(service_name, str) or not
_SERVICE_NAME.fullmatch(service_name):
+ raise ValueError(
+ f"Invalid Unity Gateway MCP Service name {service_name!r}:
expected "
+ "'catalog.schema.service', each part made of ASCII letters, digits
or '_'."
+ )
+
+
+def _is_loopback(host: str) -> bool:
+ if host == "localhost":
+ return True
+ try:
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
+ except ValueError:
+ return False
+
+
+def _parse_retry_after(value: str | None) -> float | None:
+ if value is None:
+ return None
+ try:
+ seconds = float(value)
+ except ValueError:
+ # The HTTP-date form is not worth parsing: the caller only uses this
as a hint.
+ return None
+ return seconds if seconds >= 0 else None
+
+
+def _walk_exceptions(exc: BaseException) -> Iterator[BaseException]:
+ """Yield ``exc``, its causes and the exceptions inside any exception
groups, each once."""
+ seen: set[int] = set()
+ pending: list[BaseException] = [exc]
+ while pending:
+ current = pending.pop(0)
+ if id(current) in seen:
+ continue
+ seen.add(id(current))
+ yield current
+ # Duck-typed: the ExceptionGroup builtin is Python 3.11+, and anyio
uses the backport on 3.10.
+ if isinstance(grouped := getattr(current, "exceptions", None), (list,
tuple)):
+ pending.extend(e for e in grouped if isinstance(e, BaseException))
+ pending.extend(e for e in (current.__cause__, current.__context__) if
e is not None)
+
+
+def _describe(error: BaseException) -> str:
+ return str(error) or type(error).__name__
+
+
+def _find(exc: BaseException, types: type[BaseException] |
tuple[type[BaseException], ...]) -> Any:
+ """Return the first exception of ``types`` in ``exc``, its causes and any
exception groups."""
+ return next((e for e in _walk_exceptions(exc) if isinstance(e, types)),
None)
+
+
+def _is_handshake_timeout(error: BaseException) -> bool:
+ return any(
+ isinstance(e, TimeoutError)
+ or (isinstance(e, RuntimeError) and
str(e).endswith(_HANDSHAKE_TIMEOUT_MESSAGE))
+ for e in _walk_exceptions(error)
+ )
+
+
+def _is_request_timeout(error: McpError | None) -> bool:
+ return (
+ error is not None
+ and error.error.code == _REQUEST_TIMEOUT_CODE
+ and _REQUEST_TIMEOUT_MESSAGE.fullmatch(error.error.message) is not None
+ )
+
+
+def _is_session_ended(error: BaseException) -> bool:
+ return any(
+ isinstance(e, RuntimeError) and
str(e).startswith(_SESSION_ENDED_MESSAGE)
+ for e in _walk_exceptions(error)
+ )
+
+
+def _is_client_error(error: McpError) -> bool:
+ code, message = error.error.code, error.error.message
+ return (code, message) in _CLIENT_ERRORS or
message.startswith(_CLIENT_ERROR_PREFIXES)
+
+
+class _TokenUnavailableError(Exception):
+ """Raised, for this module only, when no token could be fetched for a
gateway request."""
+
+
+@dataclass(eq=False)
+class _Operation:
+ """
+ One toolset operation (connecting, listing tools or calling a tool) and
the gateway errors seen while it ran.
+
+ The MCP client does not say which HTTP response made a call fail, so the
error statuses are
+ only attributed to the operation when no other operation of the toolset
ran at the same time.
+ Request failures are recorded on every operation running then and kept
whatever the overlap:
+ they only explain a call that saw no answer, and a request that got no
response usually ends
+ the session for all of them. An overlapping call that fails for its own
reason may still get
+ another call's failure, which changes its class and message but not that
it fails.
+ """
+
+ overlapped: bool = False
+ errors: list[tuple[int, float | None]] = field(default_factory=list)
+ failures: list[Exception] = field(default_factory=list)
+
+ @property
+ def http_error(self) -> tuple[int, float | None] | None:
+ """Return the status and ``Retry-After`` of the last error response it
got, if it is known."""
+ return self.errors[-1] if self.errors and not self.overlapped else None
+
+
+class _DatabricksTokenAuth(httpx2.Auth):
+ """
+ Authenticate each gateway request with a token from the Databricks
connection.
+
+ Asking the hook on every request, rather than once, lets OAuth tokens
refresh during a long
+ agent run and on reconnection; the hook caches tokens until they are about
to expire.
+ """
+
+ def __init__(self, hook: DatabricksHook) -> None:
+ self._hook = hook
+ self._token_lock = threading.Lock()
+ self._masked_token: str | None = None
+
+ def fetch_token(self) -> str:
+ with self._token_lock:
+ token = self._hook._get_token(raise_error=False)
+ if not token:
+ raise ValueError(
+ f"Connection {self._hook.databricks_conn_id!r} has no
token-based authentication "
+ "configured. This toolset sends a bearer token: use a personal
access token, "
+ "service principal OAuth, Azure AD, or workload identity
federation. Username and "
+ "password authentication is not supported."
+ )
+ return token
+
+ def fetch_and_mask_token(self) -> None:
+ """Fetch a token and mask it; only call it through
``AirflowToolset.run_blocking``."""
+ self._mask(self.fetch_token())
+
+ def _mask(self, token: str) -> None:
+ # A personal access token is masked when the connection is fetched;
mask minted
+ # OAuth tokens too, so they never reach task logs.
+ mask_secret(token)
+ self._masked_token = token
+
+ async def async_auth_flow(
+ self, request: httpx2.Request
+ ) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
+ try:
+ if AIRFLOW_V_3_2_PLUS:
+ # Not AirflowToolset.run_blocking: its lock is shared by every
toolset, so a long
+ # SQL query in another toolset would hold up each request
here. The hook is this
+ # toolset's own, so a lock of its own is enough.
+ token = await asyncio.to_thread(self.fetch_token)
+ else:
+ # Reading the connection's extras masks them, which messages
the supervisor over
+ # a socket that has no lock of its own before Airflow 3.2.
+ token = await AirflowToolset.run_blocking(self.fetch_token)
+ except Exception as e:
+ raise _TokenUnavailableError(str(e)) from e
+ if token != self._masked_token:
+ # mask_secret messages the supervisor too.
+ await AirflowToolset.run_blocking(self._mask, token)
+ request.headers["Authorization"] = f"Bearer {token}"
+ yield request
+
+
+class _RecordingStream(httpx2.AsyncByteStream):
+ """A response body that records a transport error raised while it is
read."""
+
+ def __init__(self, stream: httpx2.AsyncByteStream, record:
Callable[[Exception], None]) -> None:
+ self._stream = stream
+ self._record = record
+
+ async def __aiter__(self) -> AsyncIterator[bytes]:
+ try:
+ async for chunk in self._stream:
+ yield chunk
+ except httpx2.TransportError as e:
+ self._record(e)
+ raise
+
+ async def aclose(self) -> None:
+ await self._stream.aclose()
+
+
+class _GatewayClient(httpx2.AsyncClient):
+ """
+ Record, on the toolset's running operations, why each JSON-RPC request to
the gateway failed.
+
+ Once the MCP session is open, requests are sent from the MCP client's own
tasks, and a call
+ waiting for an answer only learns that the connection closed. These
records say why.
+ """
+
+ def __init__(self, *args: Any, operations: set[_Operation], **kwargs: Any)
-> None:
+ super().__init__(*args, **kwargs)
+ self._operations = operations
+
+ async def send(self, request: httpx2.Request, **kwargs: Any) ->
httpx2.Response:
+ if not _is_jsonrpc_request(request):
+ return await super().send(request, **kwargs)
+
+ def record(failure: Exception) -> None:
+ for operation in self._operations:
+ operation.failures.append(failure)
+
+ try:
+ response = await super().send(request, **kwargs)
+ except (httpx2.TransportError, _TokenUnavailableError) as e:
+ record(e)
+ raise
+ if response.status_code >= 400:
+ retry_after =
_parse_retry_after(response.headers.get("Retry-After"))
+ for operation in self._operations:
+ operation.errors.append((response.status_code, retry_after))
+ if isinstance(response.stream, httpx2.AsyncByteStream):
+ response.stream = _RecordingStream(response.stream, record)
+ return response
+
+
+def _is_jsonrpc_request(request: httpx2.Request) -> bool:
+ """
+ Whether ``request`` sends a JSON-RPC request, which expects an answer.
+
+ Not a notification or a GET for server events: the MCP client tolerates
errors on those, so
+ they say nothing about why an operation failed.
+ """
+ if request.method != "POST":
+ return False
+ try:
+ return "id" in json.loads(request.content)
+ except (ValueError, TypeError):
+ return False
+
+
+class DatabricksUnityMCPToolset(AirflowToolset):
+ """
+ Give an agent the tools of a Unity Gateway MCP Service, authenticated as
the connection's identity.
+
+ The service is named by its three-level Unity Catalog name,
``catalog.schema.service``, and
+ reached at ``https://<workspace
host>/ai-gateway/mcp-services/<catalog.schema.service>``. The
+ workspace host and the credentials both come from the Databricks
connection, so Dag code holds
+ neither a gateway URL nor a token, and the token is only ever sent to that
workspace, over
+ HTTPS. The connection's ``proxies`` extra applies to gateway requests.
+
+ The gateway runs every tool call as the identity of the connection's
credentials (a user's
+ personal access token, a service principal, or an Azure AD / federated
identity). That identity
+ needs ``EXECUTE`` on the MCP Service, ``USE CATALOG`` and ``USE SCHEMA``
on its catalog and
+ schema, and an assignment to the workspace. It sees only the tools
selected for the service, and
+ the service's policies apply.
+
+ Tokens are fetched from the connection for each request, so OAuth tokens
refresh during a long
+ agent run and on reconnection. An error the server returns for a tool call
reaches the model,
+ which can correct its arguments and try again. When the gateway gives no
answer of its own, a
+ tool call may or may not have run, so it is never retried; it fails the
task with
+
:class:`~airflow.providers.databricks.exceptions.DatabricksUnityMCPAccessDeniedError`,
+
:class:`~airflow.providers.databricks.exceptions.DatabricksUnityMCPThrottledError`,
+
:class:`~airflow.providers.databricks.exceptions.DatabricksUnityMCPTransportError`,
or
+ :class:`~airflow.providers.databricks.exceptions.DatabricksUnityMCPError`,
as do failures
+ while connecting and listing tools.
+
+ .. code-block:: python
+
+ from airflow.providers.common.ai.operators.agent import AgentOperator
+ from airflow.providers.databricks.toolsets.unity_mcp import
DatabricksUnityMCPToolset
+
+ AgentOperator(
+ task_id="ask_mcp_service",
+ prompt="Which tools do you have?",
+ llm_conn_id="pydanticai_default",
+ toolsets=[DatabricksUnityMCPToolset("main.default.my_mcp",
databricks_conn_id="databricks")],
+ )
+
+ :param service_name: Three-level name of the MCP Service,
``catalog.schema.service``. Templated
+ when the toolset is passed to ``AgentOperator`` / ``@task.agent``.
+ :param databricks_conn_id: Databricks connection whose host and
credentials are used. Templated
+ when the toolset is passed to ``AgentOperator`` / ``@task.agent``.
+ :param tool_prefix: Optional prefix prepended to tool names.
+ :param init_timeout: Seconds to wait for the MCP handshake when
connecting. Unity Gateway forwards
+ a response only once the MCP server has finished sending it, so a
server that keeps its
+ response stream open needs more than pydantic-ai's default of 5
seconds.
+ """
+
+ # Rendered, on a copy, by AgentOperator.
+ agent_template_fields: Sequence[str] = ("_databricks_conn_id",
"_service_name")
+
+ def __init__(
+ self,
+ service_name: str,
+ *,
+ databricks_conn_id: str = DatabricksHook.default_conn_name,
+ tool_prefix: str | None = None,
+ init_timeout: float = 30,
+ ) -> None:
+ if not init_timeout > 0:
+ raise ValueError(f"init_timeout must be a positive number of
seconds, got {init_timeout!r}.")
+ self._databricks_conn_id = databricks_conn_id
+ self._service_name = service_name
+ self._tool_prefix = tool_prefix
+ self._init_timeout = init_timeout
+ self._server: Any = None
+ self._operations: set[_Operation] = set()
+ # Whether a gateway failure was reported while connected, so closing
the session, which
+ # re-raises that failure, must not replace the report.
+ self._failure_reported = False
+ # A templated name is checked once it has been rendered, when the
toolset connects.
+ if "{{" not in service_name:
+ validate_service_name(service_name)
+
+ @property
+ def id(self) -> str:
+ return
f"databricks-unity-mcp-{self._databricks_conn_id}-{self._service_name}"
+
+ def get_service_url(self, hook: DatabricksHook) -> str:
+ """Return the gateway URL of the MCP Service on the connection's
workspace."""
+ validate_service_name(self._service_name)
+ if not hook.host:
+ raise ValueError(f"Connection {self._databricks_conn_id!r} has no
workspace host.")
+ url =
hook._endpoint_url(f"{GATEWAY_MCP_SERVICES_PATH}/{self._service_name}")
+ parts = urlsplit(url)
+ if parts.scheme != "https" and not _is_loopback(parts.hostname or ""):
+ raise ValueError(
+ f"Connection {self._databricks_conn_id!r} uses the
{parts.scheme!r} scheme. Unity Gateway "
+ "requests carry a bearer token, so they are only sent over
HTTPS."
+ )
+ return url
+
+ def _get_server(self) -> Any:
+ hook = DatabricksHook(self._databricks_conn_id,
caller=type(self).__name__)
+ url = self.get_service_url(hook)
+ auth = _DatabricksTokenAuth(hook)
+ # Fail here, with a clear message, rather than inside the MCP client,
which reports
+ # any failure as a generic connection error.
+ try:
+ auth.fetch_and_mask_token()
+ except ValueError:
+ raise
+ except Exception as e:
+ raise _TokenUnavailableError(str(e)) from e
+ proxy = (hook.proxies or {}).get(urlsplit(url).scheme)
+ transport = StreamableHttpTransport(
+ url,
+ headers=hook.user_agent_header,
+ auth=auth,
+ httpx_client_factory=_build_client_factory(self._operations,
proxy),
+ )
+ toolset = PydanticAIMCPToolset(transport,
init_timeout=self._init_timeout)
+ return toolset.prefixed(self._tool_prefix) if self._tool_prefix else
toolset
+
+ async def _resolve_server(self) -> Any:
+ if self._server is None:
+ # Resolving the connection talks to the supervisor, so it takes
the blocking-call lock.
+ self._server = await self.run_blocking(self._get_server)
+ return self._server
+
+ def _translate_error(
+ self, error: Exception, operation: _Operation, *, during_tool_call:
bool
+ ) -> Exception | None:
+ """Return the provider exception for a gateway failure, or ``None`` to
re-raise ``error`` as is."""
+ service = self._service_name
+ mcp_error = _find(error, McpError)
+ # When a request fails once the session is open, the call only sees an
error the MCP
+ # client makes up; what the request ran into is in the operation's
failures.
+ session_ended = _is_session_ended(error)
+ saw_no_answer = session_ended or (mcp_error is not None and
_is_client_error(mcp_error))
+ failure = operation.failures[0] if saw_no_answer and
operation.failures else None
+ token_error = _find(error, _TokenUnavailableError)
+ if token_error is None and isinstance(failure, _TokenUnavailableError):
+ token_error = failure
+ if token_error is not None:
+ return DatabricksUnityMCPError(
+ f"Could not get a token from connection
{self._databricks_conn_id!r} to call MCP Service "
+ f"{service!r}: {token_error}"
+ )
+ status, retry_after = operation.http_error or (None, None)
+ if (mcp_error is not None and mcp_error.error.code ==
_NOT_AUTHORIZED_CODE) or status in (401, 403):
+ return DatabricksUnityMCPAccessDeniedError(
+ f"Unity Gateway denied access to MCP Service {service!r}"
+ f"{f' (HTTP {status})' if status else ''}. Either no service
has that name, or the "
+ f"identity of connection {self._databricks_conn_id!r} lacks
EXECUTE on the service or "
+ "USE CATALOG and USE SCHEMA on its catalog and schema, or its
credentials are not valid.",
+ http_status_code=status,
+ )
+ if status == 429:
+ hint = f" Retry after {retry_after:g} seconds." if retry_after is
not None else ""
+ return DatabricksUnityMCPThrottledError(
+ f"Unity Gateway rate-limited calls to MCP Service {service!r}
(HTTP 429).{hint}",
+ http_status_code=status,
+ retry_after=retry_after,
+ )
+ ambiguous = (
+ " The tool call may or may not have run, so it was not retried."
if during_tool_call else ""
+ )
+ # The MCP client reports its own timeouts as MCP errors, so they are
told apart from the
+ # server's answers before those are.
+ if _is_request_timeout(mcp_error):
+ return DatabricksUnityMCPTransportError(
+ f"Timed out waiting for Unity Gateway to answer MCP Service
{service!r}.{ambiguous}"
+ )
+ transport_error = _find(error, httpx2.TransportError)
+ if transport_error is None and isinstance(failure,
httpx2.TransportError):
+ transport_error = failure
+ if transport_error is not None:
+ return DatabricksUnityMCPTransportError(
+ f"Could not reach Unity Gateway for MCP Service {service!r}: "
+ f"{_describe(transport_error)}.{ambiguous}"
+ )
+ if during_tool_call and _is_handshake_timeout(error):
+ return DatabricksUnityMCPTransportError(
+ f"Timed out waiting for Unity Gateway to answer MCP Service
{service!r}.{ambiguous}"
+ )
+ if session_ended:
+ return DatabricksUnityMCPTransportError(
+ f"The connection to Unity Gateway for MCP Service {service!r}
ended.{ambiguous}"
+ )
+ if _is_handshake_timeout(error):
+ return DatabricksUnityMCPTransportError(
+ f"Unity Gateway did not complete the MCP handshake for MCP
Service {service!r} within "
+ f"{self._init_timeout:g} seconds. Raise init_timeout if the
service is slow to answer."
+ )
+ if mcp_error is not None:
+ if not _is_client_error(mcp_error):
+ # The server's own answer: for a tool call, it reaches the
model as a retry.
+ if during_tool_call:
+ return None
+ return DatabricksUnityMCPError(
+ f"MCP Service {service!r} returned an error:
{mcp_error.error.message} "
+ f"(code {mcp_error.error.code}).",
+ http_status_code=status,
+ )
+ what = f"HTTP {status}" if status else f"an error without an
answer ({mcp_error.error.message})"
+ return DatabricksUnityMCPError(
+ f"Unity Gateway returned {what} for MCP Service
{service!r}.{ambiguous}",
+ http_status_code=status,
+ )
+ return None
+
+ @contextlib.asynccontextmanager
+ async def _gateway_operation(self, *, during_tool_call: bool = False) ->
AsyncIterator[None]:
+ operation = _Operation(overlapped=bool(self._operations))
+ for other in self._operations:
+ other.overlapped = True
+ self._operations.add(operation)
+ try:
+ yield
+ except Exception as e:
+ translated = self._translate_error(e, operation,
during_tool_call=during_tool_call)
+ if translated is None:
+ raise
+ self._failure_reported = True
+ raise translated from e
+ finally:
+ self._operations.discard(operation)
+
+ async def __aenter__(self) -> DatabricksUnityMCPToolset:
+ self._failure_reported = False
+ async with self._gateway_operation():
+ await (await self._resolve_server()).__aenter__()
+ return self
+
+ async def __aexit__(self, *args: Any) -> bool | None:
+ if self._server is None:
+ return None
+ # pydantic-ai's CombinedToolset closes its toolsets without passing on
the exception
+ # that ends the run, so a reported failure is remembered rather than
read from args.
+ if args[0] is None and not self._failure_reported:
+ return await self._server.__aexit__(*args)
+ try:
+ return await self._server.__aexit__(*args)
+ except Exception:
+ # Closing the session re-raises the failure of a request that
already failed, such
+ # as the read timeout of a tool call, which would hide the error
explaining it.
+ log.warning(
+ "Error while closing the session of MCP Service %r",
self._service_name, exc_info=True
+ )
+ return None
+
+ async def get_tools(self, ctx: RunContext[Any]) -> dict[str,
ToolsetTool[Any]]:
+ async with self._gateway_operation():
+ return await (await self._resolve_server()).get_tools(ctx)
+
+ async def execute_tool(
+ self,
+ name: str,
+ tool_args: dict[str, Any],
+ *,
+ ctx: RunContext[Any],
+ tool: ToolsetTool[Any],
+ ) -> Any:
+ async with self._gateway_operation(during_tool_call=True):
+ return await (await self._resolve_server()).call_tool(name,
tool_args, ctx, tool)
+
+ def airflow_tools(self) -> list[AirflowTool]:
+ """
+ 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.
+ """
+ raise NotImplementedError(
+ "DatabricksUnityMCPToolset works in Pydantic AI agents and through
the LangChain bridge, "
+ "not through the framework-neutral tools."
+ )
+
+
+def _build_client_factory(operations: set[_Operation], proxy: str | None) ->
Any:
+ """Return an MCP HTTP client factory for gateway requests, sent through
``proxy`` if one is set."""
+
+ def factory(
+ headers: dict[str, str] | None = None,
+ timeout: httpx2.Timeout | None = None,
+ auth: httpx2.Auth | None = None,
+ **kwargs: Any,
+ ) -> httpx2.AsyncClient:
+ # The MCP client's defaults: a response stream may stay open, so reads
wait longer.
+ timeout = timeout or httpx2.Timeout(30.0, read=300.0)
+ if proxy:
+ kwargs["proxy"] = proxy
+ return _GatewayClient(headers=headers, timeout=timeout, auth=auth,
operations=operations, **kwargs)
+
+ return factory
diff --git
a/providers/databricks/src/airflow/providers/databricks/version_compat.py
b/providers/databricks/src/airflow/providers/databricks/version_compat.py
index 0956edd2111..402b4f953ae 100644
--- a/providers/databricks/src/airflow/providers/databricks/version_compat.py
+++ b/providers/databricks/src/airflow/providers/databricks/version_compat.py
@@ -33,7 +33,9 @@ def get_base_airflow_version_tuple() -> tuple[int, int, int]:
AIRFLOW_V_3_0_PLUS = get_base_airflow_version_tuple() >= (3, 0, 0)
+AIRFLOW_V_3_2_PLUS = get_base_airflow_version_tuple() >= (3, 2, 0)
__all__ = [
"AIRFLOW_V_3_0_PLUS",
+ "AIRFLOW_V_3_2_PLUS",
]
diff --git
a/providers/databricks/tests/system/databricks/example_databricks_unity_mcp.py
b/providers/databricks/tests/system/databricks/example_databricks_unity_mcp.py
new file mode 100644
index 00000000000..02a5e5235db
--- /dev/null
+++
b/providers/databricks/tests/system/databricks/example_databricks_unity_mcp.py
@@ -0,0 +1,69 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Example Dag giving an agent the tools of a Unity Gateway MCP Service.
+
+Needs a Databricks connection whose identity has EXECUTE on the MCP Service
and USE CATALOG and
+USE SCHEMA on its catalog and schema, an LLM connection for the agent, and
these environment
+variables:
+
+- ``UNITY_MCP_SERVICE``: the service's three-level name,
``catalog.schema.service``.
+- ``UNITY_MCP_READ_ONLY_TOOL``: the name of one of its tools that only reads,
which the agent calls
+ once.
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import datetime
+
+from airflow.providers.common.ai.operators.agent import AgentOperator
+from airflow.providers.common.compat.sdk import DAG
+from airflow.providers.databricks.toolsets.unity_mcp import
DatabricksUnityMCPToolset
+
+DAG_ID = "example_databricks_unity_mcp"
+UNITY_MCP_SERVICE = os.environ.get("UNITY_MCP_SERVICE",
"main.default.my_mcp_service")
+UNITY_MCP_READ_ONLY_TOOL = os.environ.get("UNITY_MCP_READ_ONLY_TOOL",
"my_read_only_tool")
+
+with DAG(
+ dag_id=DAG_ID,
+ schedule=None,
+ start_date=datetime(2021, 1, 1),
+ tags=["example"],
+ catchup=False,
+) as dag:
+ # [START howto_toolset_databricks_unity_mcp]
+ ask_unity_mcp_service = AgentOperator(
+ task_id="ask_unity_mcp_service",
+ prompt=(
+ "List the names of the tools you have. Then call the "
+ f"{UNITY_MCP_READ_ONLY_TOOL} tool once and summarize what it
returned."
+ ),
+ llm_conn_id="pydanticai_default",
+ toolsets=[
+ DatabricksUnityMCPToolset(UNITY_MCP_SERVICE,
databricks_conn_id="databricks_default"),
+ ],
+ )
+ # [END howto_toolset_databricks_unity_mcp]
+
+ from tests_common.test_utils.watcher import watcher
+
+ list(dag.tasks) >> watcher()
+
+from tests_common.test_utils.system_tests import get_test_run # noqa: E402
+
+test_run = get_test_run(dag)
diff --git a/providers/databricks/tests/unit/databricks/toolsets/__init__.py
b/providers/databricks/tests/unit/databricks/toolsets/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/providers/databricks/tests/unit/databricks/toolsets/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git
a/providers/databricks/tests/unit/databricks/toolsets/test_unity_mcp.py
b/providers/databricks/tests/unit/databricks/toolsets/test_unity_mcp.py
new file mode 100644
index 00000000000..4d75f195986
--- /dev/null
+++ b/providers/databricks/tests/unit/databricks/toolsets/test_unity_mcp.py
@@ -0,0 +1,902 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import ast
+import asyncio
+import functools
+import importlib
+import inspect
+import json
+import re
+import sys
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from unittest import mock
+
+import pytest
+
+pytest.importorskip("fastmcp")
+pytest.importorskip("airflow.providers.common.ai")
+
+import httpx2
+from fastmcp.exceptions import McpError
+from mcp_types import jsonrpc
+from pydantic_ai import ModelRetry, RunContext
+from pydantic_ai.mcp import MCPToolset as PydanticAIMCPToolset
+from pydantic_ai.toolsets import CombinedToolset
+
+from airflow.models import Connection
+from airflow.providers.common.ai.utils import toolset_base
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+from airflow.providers.databricks.exceptions import (
+ DatabricksUnityMCPAccessDeniedError,
+ DatabricksUnityMCPError,
+ DatabricksUnityMCPThrottledError,
+ DatabricksUnityMCPTransportError,
+)
+from airflow.providers.databricks.hooks.databricks import DatabricksHook
+from airflow.providers.databricks.toolsets import unity_mcp
+from airflow.providers.databricks.toolsets.unity_mcp import
DatabricksUnityMCPToolset
+
+CONN_ID = "databricks_unity"
+SERVICE = "main.tools.genie"
+
+# What Unity Gateway answers, at HTTP 403, for a service the caller may not
invoke or that does not exist.
+NOT_AUTHORIZED = {"code": -32007, "message": "Not authorized to invoke MCP
service."}
+INVALID_PARAMS = {"code": -32602, "message": "Invalid params: 'text' is
required"}
+
+
+class _StubGateway(BaseHTTPRequestHandler):
+ """
+ A Unity Gateway stand-in that serves one MCP Service with a single
``echo`` tool.
+
+ A call of ``echo`` whose text is ``"reply <status>"`` gets that HTTP
status with an empty body,
+ and ``"reply <status> <error>"`` gets it with the JSON-RPC error named by
``<error>``. With
+ ``"drop"`` the connection closes without a response, and with ``"drop
body"`` it closes
+ partway through the response body.
+ """
+
+ protocol_version = "HTTP/1.1"
+ errors = {"not_authorized": NOT_AUTHORIZED, "invalid_params":
INVALID_PARAMS}
+ # Per-test behaviour, set by the ``gateway`` fixture: a reply for every
request to a service,
+ # a status for notifications only, and a barrier tool calls wait at, so
that they overlap.
+ service_reply: dict[str, tuple[int, dict[str, str], str | None]] = {}
+ notification_status: int | None = None
+ tool_call_barrier: threading.Barrier | None = None
+ # Seconds to hold the answer to each MCP method; ``release`` ends any wait
early on teardown.
+ delays: dict[str, float] = {}
+ release = threading.Event()
+ requests: list[tuple[str, str | None, str | None]] = []
+
+ def do_POST(self):
+ body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))
or b"{}")
+ method = body.get("method")
+ self.requests.append((self.path, self.headers.get("Authorization"),
method))
+ service = self.path.rsplit("/", 1)[-1]
+ if service in self.service_reply:
+ status, headers, error = self.service_reply[service]
+ return self._reply_error(status, body.get("id"), error, headers)
+ if "id" not in body:
+ return self._reply(self.notification_status or 202)
+ self.release.wait(self.delays.get(method, 0))
+ if method == "initialize":
+ result = {
+ "protocolVersion": body["params"]["protocolVersion"],
+ "capabilities": {"tools": {}},
+ "serverInfo": {"name": "stub", "version": "1"},
+ }
+ elif method == "tools/list":
+ result = {
+ "tools": [
+ {
+ "name": "echo",
+ "description": "Echo the text back.",
+ "inputSchema": {"type": "object", "properties":
{"text": {"type": "string"}}},
+ }
+ ]
+ }
+ elif method == "tools/call":
+ if self.tool_call_barrier:
+ self.tool_call_barrier.wait()
+ if "text" not in body["params"]["arguments"]:
+ return self._reply(200, {"jsonrpc": "2.0", "id": body["id"],
"error": INVALID_PARAMS})
+ text = body["params"]["arguments"]["text"]
+ if text.startswith("drop"):
+ self.close_connection = True
+ if text == "drop body":
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", "1000")
+ self.end_headers()
+ self.wfile.write(b'{"jsonrpc": "2.0"')
+ return None
+ if text.startswith("reply "):
+ status, _, error = text.removeprefix("reply ").partition(" ")
+ return self._reply_error(int(status), body["id"], error or
None)
+ result = {"content": [{"type": "text", "text": f"echo: {text}"}],
"isError": False}
+ else:
+ result = {}
+ self._reply(200, {"jsonrpc": "2.0", "id": body["id"], "result":
result})
+
+ def do_GET(self):
+ self._reply(405)
+
+ def do_DELETE(self):
+ self._reply(200)
+
+ def _reply_error(self, status, request_id, error, headers=None):
+ payload = {"jsonrpc": "2.0", "id": request_id, "error":
self.errors[error]} if error else None
+ self._reply(status, payload, headers)
+
+ def _reply(self, status, payload=None, headers=None):
+ data = json.dumps(payload).encode() if payload is not None else b""
+ self.send_response(status)
+ for name, value in (headers or {}).items():
+ self.send_header(name, value)
+ if payload is not None:
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def log_message(self, *args):
+ pass
+
+
[email protected]
+def gateway():
+ _StubGateway.service_reply = {}
+ _StubGateway.notification_status = None
+ _StubGateway.tool_call_barrier = None
+ _StubGateway.delays = {}
+ _StubGateway.release = threading.Event()
+ _StubGateway.requests = []
+ server = ThreadingHTTPServer(("127.0.0.1", 0), _StubGateway)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ yield server
+ _StubGateway.release.set()
+ server.shutdown()
+ server.server_close()
+
+
[email protected]
+def gateway_conn(gateway, create_connection_without_db):
+ create_connection_without_db(
+ Connection(
+ conn_id=CONN_ID,
+ conn_type="databricks",
+ host="127.0.0.1",
+ port=gateway.server_address[1],
+ schema="http",
+ password="dapi-token",
+ )
+ )
+
+
+def _run(coro):
+ return asyncio.run(coro)
+
+
+def _ctx():
+ ctx = mock.MagicMock(spec=RunContext)
+ ctx.max_retries = 1
+ return ctx
+
+
+async def _list_and_call(toolset, text="hi"):
+ async with toolset:
+ tools = await toolset.get_tools(_ctx())
+ result = await toolset.execute_tool("echo", {"text": text},
ctx=_ctx(), tool=tools["echo"])
+ return tools, result
+
+
+async def _list_and_call_in_an_agent_run(toolset, text="hi"):
+ """Call ``echo`` the way an agent run does, through a CombinedToolset,
which closes ``toolset``."""
+ combined = CombinedToolset([toolset])
+ async with combined:
+ tools = await combined.get_tools(_ctx())
+ return await combined.call_tool("echo", {"text": text}, _ctx(),
tools["echo"])
+
+
+async def _call_concurrently(*texts):
+ """Call ``echo`` once per text, all at the same time, and return each
call's result or exception."""
+ toolset = DatabricksUnityMCPToolset(SERVICE, databricks_conn_id=CONN_ID)
+ async with toolset:
+ tools = await toolset.get_tools(_ctx())
+ return await asyncio.gather(
+ *(toolset.execute_tool("echo", {"text": t}, ctx=_ctx(),
tool=tools["echo"]) for t in texts),
+ return_exceptions=True,
+ )
+
+
+class TestServiceName:
+ @pytest.mark.parametrize(
+ "name",
+ [
+ "main.tools",
+ "main.tools.genie.extra",
+ "main.tools.genie/../../api/2.0/jobs",
+ "main.tools.genie?x=1",
+ "main.tools.genie#frag",
+ "main.tools.ge nie",
+ "main.tools.my-genie",
+ "main..genie",
+ "evil.example.com/main.tools.genie",
+ "",
+ ],
+ )
+ def test_rejects_names_that_are_not_three_safe_parts(self, name):
+ with pytest.raises(ValueError, match="catalog.schema.service"):
+ DatabricksUnityMCPToolset(name)
+
+ def test_templated_name_is_validated_once_rendered(self, gateway_conn):
+ toolset = DatabricksUnityMCPToolset("{{ params.service }}",
databricks_conn_id=CONN_ID)
+ toolset._service_name = "main.tools/../../api"
+
+ with pytest.raises(ValueError, match="catalog.schema.service"):
+ toolset._get_server()
+
+ def test_id_names_connection_and_service(self):
+ assert (
+ DatabricksUnityMCPToolset(SERVICE, databricks_conn_id=CONN_ID).id
+ == f"databricks-unity-mcp-{CONN_ID}-{SERVICE}"
+ )
+
+ def test_connection_and_service_are_agent_template_fields(self):
+ assert set(DatabricksUnityMCPToolset.agent_template_fields) == {
+ "_databricks_conn_id",
+ "_service_name",
+ }
+
+
+class TestServiceUrl:
+ @pytest.mark.parametrize(
+ ("host", "extra", "expected"),
+ [
+ ("xx.cloud.databricks.com", None,
"https://xx.cloud.databricks.com"),
+ ("https://xx.cloud.databricks.com/", None,
"https://xx.cloud.databricks.com"),
+ (None, {"host": "https://yy.azuredatabricks.net"},
"https://yy.azuredatabricks.net"),
+ ],
+ )
+ def test_url_is_built_on_the_connection_host(self,
create_connection_without_db, host, extra, expected):
+ create_connection_without_db(
+ Connection(conn_id=CONN_ID, conn_type="databricks", host=host,
password="t", extra=extra)
+ )
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+
+ assert (
+ toolset.get_service_url(DatabricksHook(CONN_ID))
+ == f"{expected}/ai-gateway/mcp-services/{SERVICE}"
+ )
+
+ def test_connection_without_host_is_rejected(self,
create_connection_without_db):
+ create_connection_without_db(Connection(conn_id=CONN_ID,
conn_type="databricks", password="t"))
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+
+ with pytest.raises(ValueError, match="no workspace host"):
+ toolset.get_service_url(DatabricksHook(CONN_ID))
+
+ @pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "[::1]"])
+ def test_plain_http_is_allowed_to_loopback_only(self,
create_connection_without_db, host):
+ create_connection_without_db(
+ Connection(conn_id=CONN_ID, conn_type="databricks", host=host,
schema="http", password="t")
+ )
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+
+ assert
toolset.get_service_url(DatabricksHook(CONN_ID)).startswith(f"http://{host}/")
+
+ def test_plain_http_to_a_workspace_is_rejected(self,
create_connection_without_db):
+ create_connection_without_db(
+ Connection(
+ conn_id=CONN_ID,
+ conn_type="databricks",
+ host="xx.cloud.databricks.com",
+ schema="http",
+ password="t",
+ )
+ )
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+
+ with pytest.raises(ValueError, match="only sent over HTTPS"):
+ toolset.get_service_url(DatabricksHook(CONN_ID))
+
+ def test_requests_go_through_the_connection_proxy(self, gateway,
create_connection_without_db):
+ # The stub gateway doubles as the proxy: a proxied request names the
full URL in its path.
+ create_connection_without_db(
+ Connection(
+ conn_id=CONN_ID,
+ conn_type="databricks",
+ host="127.0.0.2",
+ port=1,
+ schema="http",
+ password="dapi-token",
+ extra={"proxies": {"http":
f"http://127.0.0.1:{gateway.server_address[1]}"}},
+ )
+ )
+
+ _, result = _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ assert result == "echo: hi"
+ assert {path for path, _, _ in _StubGateway.requests} == {
+ f"http://127.0.0.2:1/ai-gateway/mcp-services/{SERVICE}"
+ }
+
+
+class TestAuthentication:
+ def test_tools_are_discovered_and_called_with_the_connection_token(self,
gateway, gateway_conn):
+ tools, result = _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ assert list(tools) == ["echo"]
+ assert result == "echo: hi"
+ assert _StubGateway.requests
+ assert {path for path, _, _ in _StubGateway.requests} ==
{f"/ai-gateway/mcp-services/{SERVICE}"}
+ assert {auth for _, auth, _ in _StubGateway.requests} == {"Bearer
dapi-token"}
+
+ @mock.patch.object(DatabricksHook, "_get_token", autospec=True)
+ def test_token_is_fetched_for_every_request_so_it_can_refresh(self,
get_token, gateway, gateway_conn):
+ tokens = (f"oauth-{i}" for i in range(1000))
+ get_token.side_effect = lambda *_, **__: next(tokens)
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ sent = [auth for _, auth, _ in _StubGateway.requests]
+ # One token is fetched up front to fail fast, then one per request.
+ assert sent == [f"Bearer oauth-{i}" for i in range(1, len(sent) + 1)]
+
+ @mock.patch.object(unity_mcp, "AIRFLOW_V_3_2_PLUS", True)
+ def test_requests_do_not_wait_for_other_toolsets_blocking_calls(self,
gateway, gateway_conn):
+ async def call_while_another_toolset_holds_the_lock():
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+ async with toolset:
+ tools = await toolset.get_tools(_ctx())
+ with toolset_base._blocking_call_lock:
+ return await asyncio.wait_for(
+ toolset.execute_tool("echo", {"text": "hi"},
ctx=_ctx(), tool=tools["echo"]),
+ timeout=10,
+ )
+
+ assert _run(call_while_another_toolset_holds_the_lock()) == "echo: hi"
+
+ @mock.patch.object(unity_mcp, "AIRFLOW_V_3_2_PLUS", False)
+ @mock.patch.object(DatabricksHook, "_get_token", autospec=True)
+ def
test_before_airflow_3_2_tokens_are_fetched_under_the_blocking_call_lock(
+ self, get_token, gateway, gateway_conn
+ ):
+ # Reading the connection's extras messages the supervisor, whose
socket has no lock of its own.
+ held = []
+ get_token.side_effect = lambda *_, **__:
held.append(toolset_base._blocking_call_lock.locked()) or "t"
+
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ assert len(held) > 1
+ assert all(held)
+
+ @mock.patch.object(DatabricksHook, "_get_token", autospec=True)
+ @mock.patch("airflow.providers.databricks.toolsets.unity_mcp.mask_secret",
autospec=True)
+ def test_each_new_token_is_masked_once_under_the_blocking_call_lock(
+ self, mask_secret, get_token, gateway, gateway_conn
+ ):
+ token = "oauth-1"
+ get_token.side_effect = lambda *_, **__: token
+ # Before Airflow 3.2, the supervisor socket mask_secret writes to has
no lock of its own.
+ mask_secret.side_effect = lambda _: (
+ toolset_base._blocking_call_lock.locked()
+ or pytest.fail("mask_secret was called without the blocking-call
lock")
+ )
+
+ async def refresh_between_calls():
+ nonlocal token
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+ async with toolset:
+ tools = await toolset.get_tools(_ctx())
+ await toolset.execute_tool("echo", {"text": "a"}, ctx=_ctx(),
tool=tools["echo"])
+ token = "oauth-2"
+ await toolset.execute_tool("echo", {"text": "b"}, ctx=_ctx(),
tool=tools["echo"])
+
+ _run(refresh_between_calls())
+
+ assert mask_secret.call_args_list == [mock.call("oauth-1"),
mock.call("oauth-2")]
+
+ def test_username_and_password_connection_is_rejected(self,
create_connection_without_db):
+ create_connection_without_db(
+ Connection(conn_id=CONN_ID, conn_type="databricks", host="h",
login="user", password="pass")
+ )
+
+ with pytest.raises(ValueError, match="sends a bearer token"):
+ DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)._get_server()
+
+ @mock.patch.object(DatabricksHook, "_get_token", autospec=True)
+ def test_token_failure_during_the_run_is_reported(self, get_token,
gateway, gateway_conn):
+ # The up-front check and the first request get a token; the next
request's fetch fails.
+ get_token.side_effect = ["dapi-token", "dapi-token", OSError("token
endpoint unreachable")]
+
+ with pytest.raises(DatabricksUnityMCPError, match="Could not get a
token") as err:
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ assert "token endpoint unreachable" in str(err.value)
+
+ @mock.patch.object(DatabricksHook, "_get_token", autospec=True)
+ def test_token_failure_before_connecting_is_reported(self, get_token,
gateway, gateway_conn):
+ # A hung token endpoint must not be mistaken for a slow MCP handshake.
+ get_token.side_effect = TimeoutError("token endpoint timed out")
+
+ with pytest.raises(DatabricksUnityMCPError, match="Could not get a
token.*token endpoint timed out"):
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ assert not _StubGateway.requests
+
+ @mock.patch.object(DatabricksHook, "_get_token", autospec=True)
+ def test_token_failure_during_a_tool_call_is_reported(self, get_token,
gateway, gateway_conn):
+ failing = False
+
+ def get_token_unless_failing(*_, **__):
+ if failing:
+ raise OSError("token endpoint unreachable")
+ return "dapi-token"
+
+ get_token.side_effect = get_token_unless_failing
+
+ async def call_after_the_token_endpoint_fails():
+ nonlocal failing
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+ async with toolset:
+ tools = await toolset.get_tools(_ctx())
+ failing = True
+ await toolset.execute_tool("echo", {"text": "hi"}, ctx=_ctx(),
tool=tools["echo"])
+
+ with pytest.raises(
+ DatabricksUnityMCPError, match="Could not get a token.*token
endpoint unreachable"
+ ):
+ _run(call_after_the_token_endpoint_fails())
+
+
+class TestIsJsonRpcRequest:
+ @pytest.mark.parametrize(
+ ("method", "body", "expected"),
+ [
+ ("POST", {"jsonrpc": "2.0", "id": 1, "method": "tools/call"},
True),
+ ("POST", {"jsonrpc": "2.0", "method":
"notifications/initialized"}, False),
+ ("GET", None, False),
+ ],
+ )
+ def test_only_requests_that_expect_an_answer_count(self, method, body,
expected):
+ request = httpx2.Request(method, "https://h/mcp", json=body)
+
+ assert unity_mcp._is_jsonrpc_request(request) is expected
+
+
+class TestGatewayClient:
+ @pytest.mark.parametrize(
+ ("method", "body", "recorded"),
+ [
+ ("POST", {"jsonrpc": "2.0", "id": 1, "method": "tools/call"},
True),
+ ("POST", {"jsonrpc": "2.0", "method":
"notifications/initialized"}, False),
+ ("GET", None, False),
+ ],
+ )
+ def test_records_failures_of_json_rpc_requests_only(self, method, body,
recorded):
+ operation = unity_mcp._Operation()
+
+ def fail(request):
+ if request.headers.get("X-Fail") == "connect":
+ raise httpx2.ConnectError("refused", request=request)
+ return httpx2.Response(503)
+
+ async def send_both():
+ async with unity_mcp._GatewayClient(
+ transport=httpx2.MockTransport(fail), operations={operation}
+ ) as client:
+ await client.request(method, "https://h/mcp", json=body)
+ with pytest.raises(httpx2.ConnectError):
+ await client.request(method, "https://h/mcp", json=body,
headers={"X-Fail": "connect"})
+
+ _run(send_both())
+
+ assert operation.errors == ([(503, None)] if recorded else [])
+ assert len(operation.failures) == (1 if recorded else 0)
+
+
+class TestErrors:
+ @pytest.mark.parametrize(
+ ("status", "headers", "error", "expected", "retry_after"),
+ [
+ (401, {}, None, DatabricksUnityMCPAccessDeniedError, None),
+ (403, {}, None, DatabricksUnityMCPAccessDeniedError, None),
+ (403, {}, "not_authorized", DatabricksUnityMCPAccessDeniedError,
None),
+ (404, {}, None, DatabricksUnityMCPError, None),
+ (429, {"Retry-After": "12"}, None,
DatabricksUnityMCPThrottledError, 12.0),
+ (429, {"Retry-After": "-5"}, None,
DatabricksUnityMCPThrottledError, None),
+ (
+ 429,
+ {"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"},
+ None,
+ DatabricksUnityMCPThrottledError,
+ None,
+ ),
+ ],
+ )
+ def test_gateway_errors_while_connecting_are_translated(
+ self, gateway, gateway_conn, status, headers, error, expected,
retry_after
+ ):
+ _StubGateway.service_reply[SERVICE] = (status, headers, error)
+
+ with pytest.raises(expected) as err:
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ assert type(err.value) is expected
+ assert err.value.http_status_code == status
+ # No tool call was sent, so nothing may have run.
+ assert "may or may not have run" not in str(err.value)
+ if expected is DatabricksUnityMCPThrottledError:
+ assert err.value.retry_after == retry_after
+
+ def test_missing_service_is_reported_as_access_denied(self, gateway,
gateway_conn):
+ _StubGateway.service_reply[SERVICE] = (403, {}, "not_authorized")
+
+ with pytest.raises(DatabricksUnityMCPAccessDeniedError, match="no
service has that name"):
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ def test_server_error_during_a_tool_call_says_the_call_may_have_run(self,
gateway, gateway_conn):
+ with pytest.raises(DatabricksUnityMCPError, match="may or may not have
run") as err:
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID), "reply 503"))
+
+ assert type(err.value) is DatabricksUnityMCPError
+ assert err.value.http_status_code == 503
+ # The failed call was sent once and not retried.
+ assert [m for _, _, m in _StubGateway.requests].count("tools/call") ==
1
+
+ def test_denied_tool_call_is_not_left_to_the_model(self, gateway,
gateway_conn):
+ with pytest.raises(DatabricksUnityMCPAccessDeniedError):
+ _run(
+ _list_and_call(
+ DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID), "reply 403 not_authorized"
+ )
+ )
+
+ @pytest.mark.parametrize(
+ "text", ["reply 200 invalid_params", "reply 400 invalid_params",
"reply 500 invalid_params"]
+ )
+ def test_json_rpc_error_from_the_server_is_left_to_the_model(self,
gateway, gateway_conn, text):
+ # A server may answer bad arguments with a JSON-RPC error at an HTTP
error status; it is
+ # still the server's answer, so the model can correct the call.
+ with pytest.raises(ModelRetry, match="'text' is required"):
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID), text))
+
+ def test_concurrent_calls_are_each_judged_by_their_own_failure(self,
gateway, gateway_conn):
+ # Calls run in parallel, so another call's response must not change
how a failure is
+ # reported: a server error stays a non-retried failure however the
other call ends.
+ _StubGateway.tool_call_barrier = threading.Barrier(2, timeout=10)
+ for _ in range(10):
+ failed, succeeded = _run(_call_concurrently("reply 503", "hi"))
+ assert type(failed) is DatabricksUnityMCPError
+ assert "may or may not have run" in str(failed)
+ assert succeeded == "echo: hi"
+
+ failed, throttled = _run(_call_concurrently("reply 503", "reply
429"))
+ # Neither status can be attributed to its call, so neither is
reported as throttling.
+ assert type(failed) is DatabricksUnityMCPError
+ assert type(throttled) is DatabricksUnityMCPError
+ assert failed.http_status_code is None
+
+ invalid, failed = _run(_call_concurrently("reply 400
invalid_params", "reply 503"))
+ assert isinstance(invalid, ModelRetry)
+ assert type(failed) is DatabricksUnityMCPError
+
+ # The dropped call ends the session; the other call either got its
answer first or
+ # finds the session gone, which is the same transport failure,
never a raw error.
+ dropped, other = _run(_call_concurrently("drop", "hi"))
+ assert type(dropped) is DatabricksUnityMCPTransportError
+ assert other == "echo: hi" or type(other) is
DatabricksUnityMCPTransportError, repr(other)
+
+ # The status is lost, but the gateway's JSON-RPC code still marks
the denial.
+ denied, succeeded = _run(_call_concurrently("reply 403
not_authorized", "hi"))
+ assert type(denied) is DatabricksUnityMCPAccessDeniedError
+ assert succeeded == "echo: hi"
+
+ def
test_tool_errors_after_a_tolerated_gateway_error_are_left_to_the_agent(self,
gateway, gateway_conn):
+ # The MCP client carries on when the gateway rejects a notification; a
later tool error the
+ # model can fix must still reach pydantic-ai as a retry, not as that
earlier gateway error.
+ _StubGateway.notification_status = 400
+
+ async def call_with_missing_argument():
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+ async with toolset:
+ tools = await toolset.get_tools(_ctx())
+ await toolset.execute_tool("echo", {}, ctx=_ctx(),
tool=tools["echo"])
+
+ with pytest.raises(ModelRetry, match="'text' is required"):
+ _run(call_with_missing_argument())
+
+ @pytest.mark.parametrize("call", [_list_and_call,
_list_and_call_in_an_agent_run])
+ @pytest.mark.parametrize("text", ["drop", "drop body"])
+ def test_connection_lost_during_a_tool_call_is_a_transport_error(self,
gateway, gateway_conn, text, call):
+ with pytest.raises(DatabricksUnityMCPTransportError, match="may or may
not have run"):
+ _run(call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID), text))
+
+ assert [m for _, _, m in _StubGateway.requests].count("tools/call") ==
1
+
+ @pytest.mark.parametrize(
+ ("mcp_error", "expected"),
+ [
+ # The server answered, so the overlapping call's failure says
nothing about this call.
+ (McpError(jsonrpc.INVALID_PARAMS, "Invalid params"), None),
+ (McpError(jsonrpc.CONNECTION_CLOSED, "Connection closed"),
DatabricksUnityMCPTransportError),
+ (RuntimeError("Client is not connected."),
DatabricksUnityMCPTransportError),
+ ],
+ )
+ def
test_recorded_request_failure_only_explains_a_call_without_an_answer(self,
mcp_error, expected):
+ operation = unity_mcp._Operation(overlapped=True,
failures=[httpx2.ConnectError("refused")])
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+
+ translated = toolset._translate_error(mcp_error, operation,
during_tool_call=True)
+
+ if expected is None:
+ assert translated is None
+ else:
+ assert type(translated) is expected
+
+ @staticmethod
+ def _with_context(error, context):
+ error.__context__ = context
+ return error
+
+ @pytest.mark.parametrize(
+ ("error", "during_tool_call", "expected", "match"),
+ [
+ pytest.param(
+ ExceptionGroup("task group", [httpx2.ConnectError("refused")]),
+ False,
+ DatabricksUnityMCPTransportError,
+ "refused",
+ id="transport error inside an exception group",
+ ),
+ pytest.param(
+ _with_context(RuntimeError("wrapped"),
httpx2.ConnectError("refused")),
+ False,
+ DatabricksUnityMCPTransportError,
+ "refused",
+ id="transport error in the context",
+ ),
+ pytest.param(
+ httpx2.ReadTimeout(""),
+ False,
+ DatabricksUnityMCPTransportError,
+ "ReadTimeout",
+ id="transport error without a message is named",
+ ),
+ pytest.param(
+ TimeoutError(),
+ False,
+ DatabricksUnityMCPTransportError,
+ "Raise init_timeout",
+ id="bare timeout while connecting",
+ ),
+ pytest.param(
+ RuntimeError("Failed to initialize server session"),
+ True,
+ DatabricksUnityMCPTransportError,
+ "Timed out waiting.*may or may not have run",
+ id="handshake timeout during a tool call",
+ ),
+ pytest.param(
+ McpError(-32001, "The warehouse is still starting"),
+ True,
+ None,
+ None,
+ id="server's own -32001 answer reaches the model",
+ ),
+ ],
+ )
+ def test_error_is_translated_from_its_chain(self, error, during_tool_call,
expected, match):
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+
+ translated = toolset._translate_error(
+ error, unity_mcp._Operation(), during_tool_call=during_tool_call
+ )
+
+ if expected is None:
+ assert translated is None
+ else:
+ assert type(translated) is expected
+ assert re.search(match, str(translated))
+
+ def test_status_of_the_last_failed_request_is_used(self):
+ operation = unity_mcp._Operation(errors=[(503, None), (429, 5.0)])
+
+ assert operation.http_error == (429, 5.0)
+
+ def test_call_that_finds_the_session_ended_is_a_transport_error(self):
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+
+ translated = toolset._translate_error(
+ RuntimeError("Client is not connected."), unity_mcp._Operation(),
during_tool_call=True
+ )
+
+ assert type(translated) is DatabricksUnityMCPTransportError
+ assert "may or may not have run" in str(translated)
+
+ def test_unreachable_gateway_is_a_transport_error(self,
create_connection_without_db):
+ create_connection_without_db(
+ Connection(
+ conn_id=CONN_ID, conn_type="databricks", host="127.0.0.1",
port=1, schema="http", password="t"
+ )
+ )
+
+ with pytest.raises(DatabricksUnityMCPTransportError, match="Could not
reach Unity Gateway"):
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+
+# The MCP client gives up on a request after pydantic-ai's read_timeout, 5
minutes by default.
[email protected](
+ unity_mcp, "PydanticAIMCPToolset", functools.partial(PydanticAIMCPToolset,
read_timeout=0.5)
+)
+class TestRequestTimeout:
+ @pytest.mark.parametrize("call", [_list_and_call,
_list_and_call_in_an_agent_run])
+ def test_tool_call_without_an_answer_is_not_left_to_the_model(self,
gateway, gateway_conn, call):
+ _StubGateway.delays = {"tools/call": 2}
+
+ with pytest.raises(DatabricksUnityMCPTransportError, match="may or may
not have run"):
+ _run(call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ assert [m for _, _, m in _StubGateway.requests].count("tools/call") ==
1
+
+ def test_listing_tools_without_an_answer_is_a_transport_error(self,
gateway, gateway_conn):
+ _StubGateway.delays = {"tools/list": 2}
+
+ with pytest.raises(DatabricksUnityMCPTransportError, match="Timed out
waiting") as err:
+ _run(_list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)))
+
+ assert "init_timeout" not in str(err.value)
+
+
+class TestClientErrorMessages:
+ """
+ The errors the MCP client makes up are told from the server's own by their
text.
+
+ A failure here means an MCP SDK or fastmcp update changed that text:
update the constants in
+ ``toolsets/unity_mcp.py`` to match, or such failures are mistaken for the
server's answers.
+ """
+
+ MADE_UP_ERRORS = [
+ ("mcp.client.streamable_http", "INTERNAL_ERROR", "Server returned an
error response"),
+ ("mcp.client.streamable_http", "METHOD_NOT_FOUND", "Not Found"),
+ ("mcp.client.streamable_http", "INVALID_REQUEST", "Session
terminated"),
+ ("mcp.shared.jsonrpc_dispatcher", "CONNECTION_CLOSED", "Connection
closed"),
+ ]
+
+ @pytest.mark.parametrize(("module", "code", "message"), MADE_UP_ERRORS)
+ def test_error_is_still_made_up_by_the_client(self, module, code, message):
+ assert f'code={code}, message="{message}"' in
inspect.getsource(importlib.import_module(module))
+
+ def test_every_matched_error_is_checked(self):
+ expected = {(getattr(jsonrpc, code), message) for _, code, message in
self.MADE_UP_ERRORS}
+ assert expected == unity_mcp._CLIENT_ERRORS
+
+ @staticmethod
+ def _find_made_up_errors() -> list[tuple[int, str]]:
+ """Return the code and message, or message prefix, of each error the
HTTP client makes up."""
+ module = importlib.import_module("mcp.client.streamable_http")
+ found = []
+ for node in ast.walk(ast.parse(inspect.getsource(module))):
+ if not isinstance(node, ast.Call):
+ continue
+ keywords = {k.arg: k.value for k in node.keywords}
+ func = node.func
+ if isinstance(func, ast.Name) and func.id == "ErrorData":
+ code, message = keywords.get("code"), keywords.get("message")
+ elif isinstance(func, ast.Attribute) and func.attr ==
"_resolve_abandoned_request":
+ code, message = keywords.get("code"), node.args[2]
+ else:
+ continue
+ if isinstance(message, ast.JoinedStr):
+ message = message.values[0]
+ # Messages built elsewhere, such as the redirect one, are checked
by the prefix test.
+ if not isinstance(message, ast.Constant) or not
isinstance(message.value, str):
+ continue
+ code_name = code.id if isinstance(code, ast.Name) else
"CONNECTION_CLOSED"
+ found.append((getattr(jsonrpc, code_name), message.value))
+ return found
+
+ # mcp 2.3.0 adds this one, for an SSE error while reading the answer to a
request.
+ def test_failed_sse_stream_is_recognized(self):
+ assert unity_mcp._is_client_error(McpError(jsonrpc.CONNECTION_CLOSED,
"SSE stream failed: boom"))
+
+ def test_every_error_the_client_makes_up_is_recognized(self):
+ made_up = self._find_made_up_errors()
+
+ assert len(made_up) >= 8
+ for code, message in made_up:
+ assert unity_mcp._is_client_error(McpError(code, message)), message
+
+ @pytest.mark.parametrize("prefix", unity_mcp._CLIENT_ERROR_PREFIXES)
+ def test_message_prefix_is_still_made_up_by_the_client(self, prefix):
+ assert prefix in
inspect.getsource(importlib.import_module("mcp.client.streamable_http"))
+
+ def test_session_ended_message_is_still_raised_by_fastmcp(self):
+ source =
inspect.getsource(importlib.import_module("fastmcp.client.client"))
+ assert unity_mcp._SESSION_ENDED_MESSAGE in source
+
+ def test_handshake_timeout_message_is_still_raised_by_fastmcp(self):
+ source =
inspect.getsource(importlib.import_module("fastmcp.client.client"))
+ assert unity_mcp._HANDSHAKE_TIMEOUT_MESSAGE in source
+
+
+class TestHandshakeTimeout:
+ def test_slow_handshake_within_init_timeout_connects(self, gateway,
gateway_conn):
+ _StubGateway.delays = {"initialize": 1}
+
+ _, result = _run(
+ _list_and_call(DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID, init_timeout=10))
+ )
+
+ assert result == "echo: hi"
+
+ def test_handshake_beyond_init_timeout_is_a_transport_error(self, gateway,
gateway_conn):
+ # Shorter than pydantic-ai's own 5 second default, so this only fails
if init_timeout is used.
+ _StubGateway.delays = {"initialize": 2}
+
+ with pytest.raises(DatabricksUnityMCPTransportError, match="within 0.5
seconds. Raise init_timeout"):
+ _run(
+ _list_and_call(
+ DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID, init_timeout=0.5)
+ )
+ )
+
+ @pytest.mark.parametrize("init_timeout", [0, -1])
+ def test_init_timeout_must_be_positive(self, init_timeout):
+ with pytest.raises(ValueError, match="init_timeout must be a positive
number"):
+ DatabricksUnityMCPToolset(SERVICE, init_timeout=init_timeout)
+
+
+class TestClosing:
+ @pytest.mark.parametrize("failed_before", [False, True])
+ def test_error_while_closing_after_a_clean_run_is_raised(self, gateway,
gateway_conn, failed_before):
+ toolset = DatabricksUnityMCPToolset(SERVICE,
databricks_conn_id=CONN_ID)
+ if failed_before:
+ # A failure in an earlier run must not hide close errors in the
next one.
+ with pytest.raises(DatabricksUnityMCPError):
+ _run(_list_and_call(toolset, "reply 503"))
+
+ async def close_with_an_error():
+ async with toolset:
+ await toolset.get_tools(_ctx())
+ real_exit = toolset._server.__aexit__
+
+ async def failing_exit(*args):
+ await real_exit(*args)
+ raise RuntimeError("close failed")
+
+ toolset._server.__aexit__ = failing_exit
+
+ with pytest.raises(RuntimeError, match="close failed"):
+ _run(close_with_an_error())
+
+
+class TestFrameworkNeutralTools:
+ def test_are_not_supported(self):
+ with pytest.raises(NotImplementedError, match="framework-neutral"):
+ DatabricksUnityMCPToolset(SERVICE).airflow_tools()
+
+
+class TestOptionalDependency:
+ def test_import_without_common_ai_names_the_extra(self):
+ module = "airflow.providers.databricks.toolsets.unity_mcp"
+ with mock.patch.dict(sys.modules,
{"airflow.providers.common.ai.utils.toolset_base": None}):
+ sys.modules.pop(module)
+ with pytest.raises(AirflowOptionalProviderFeatureException,
match=r"databricks\[common.ai\]"):
+ importlib.import_module(module)
diff --git a/uv.lock b/uv.lock
index bb6963fd7f3..76132824ca5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -5129,7 +5129,8 @@ azure-identity = [
{ name = "azure-identity" },
]
common-ai = [
- { name = "apache-airflow-providers-common-ai" },
+ { name = "apache-airflow-providers-common-ai", extra = ["mcp"] },
+ { name = "fastmcp-slim", extra = ["client"] },
]
fab = [
{ name = "apache-airflow-providers-fab" },
@@ -5158,7 +5159,7 @@ dev = [
{ name = "apache-airflow-providers-common-ai" },
{ name = "apache-airflow-providers-common-compat" },
{ name = "apache-airflow-providers-common-sql", extra = ["pandas",
"polars"] },
- { name = "apache-airflow-providers-databricks", extra = ["sqlalchemy"] },
+ { name = "apache-airflow-providers-databricks", extra = ["common-ai",
"sqlalchemy"] },
{ name = "apache-airflow-providers-fab" },
{ name = "apache-airflow-providers-google" },
{ name = "apache-airflow-providers-microsoft-azure" },
@@ -5175,7 +5176,7 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.14.0,<4" },
{ name = "apache-airflow", editable = "." },
{ name = "apache-airflow-providers-amazon", marker = "extra == 'amazon'",
editable = "providers/amazon" },
- { name = "apache-airflow-providers-common-ai", marker = "extra ==
'common-ai'", editable = "providers/common/ai" },
+ { name = "apache-airflow-providers-common-ai", extras = ["mcp"], marker =
"extra == 'common-ai'", editable = "providers/common/ai" },
{ name = "apache-airflow-providers-common-compat", editable =
"providers/common/compat" },
{ name = "apache-airflow-providers-common-sql", editable =
"providers/common/sql" },
{ name = "apache-airflow-providers-fab", marker = "extra == 'fab'",
editable = "providers/fab" },
@@ -5189,6 +5190,7 @@ requires-dist = [
{ name = "fastavro", marker = "python_full_version >= '3.12' and
python_full_version < '3.14' and extra == 'avro'", specifier = ">=1.10.0" },
{ name = "fastavro", marker = "python_full_version >= '3.14' and extra ==
'avro'", specifier = ">=1.12.1" },
{ name = "fastavro", marker = "python_full_version < '3.14' and extra ==
'avro'", specifier = ">=1.9.0" },
+ { name = "fastmcp-slim", extras = ["client"], marker = "extra ==
'common-ai'", specifier = ">=4.0.0" },
{ name = "mergedeep", specifier = ">=1.3.4" },
{ name = "pandas", marker = "python_full_version < '3.13'", specifier =
">=2.1.2" },
{ name = "pandas", marker = "python_full_version == '3.13.*'", specifier =
">=2.2.3" },
@@ -5199,7 +5201,7 @@ requires-dist = [
{ name = "requests", specifier = ">=2.32.0,<3" },
{ name = "tenacity", specifier = ">=8.3.0" },
]
-provides-extras = ["common-ai", "avro", "amazon", "azure-identity", "fab",
"google", "sdk", "standard", "openlineage", "sqlalchemy"]
+provides-extras = ["avro", "amazon", "azure-identity", "common-ai", "fab",
"google", "sdk", "standard", "openlineage", "sqlalchemy"]
[package.metadata.requires-dev]
dev = [
@@ -5210,7 +5212,7 @@ dev = [
{ name = "apache-airflow-providers-common-compat", editable =
"providers/common/compat" },
{ name = "apache-airflow-providers-common-sql", editable =
"providers/common/sql" },
{ name = "apache-airflow-providers-common-sql", extras = ["pandas",
"polars"], editable = "providers/common/sql" },
- { name = "apache-airflow-providers-databricks", extras = ["sqlalchemy"],
editable = "providers/databricks" },
+ { name = "apache-airflow-providers-databricks", extras = ["common-ai",
"sqlalchemy"], editable = "providers/databricks" },
{ name = "apache-airflow-providers-fab", editable = "providers/fab" },
{ name = "apache-airflow-providers-google", editable = "providers/google"
},
{ name = "apache-airflow-providers-microsoft-azure", editable =
"providers/microsoft/azure" },