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 e29d36f527b Add `env_provider` and `Extra.env` support to MCP stdio 
transport (#69225)
e29d36f527b is described below

commit e29d36f527b62a96040992d10e826934e4dddf71
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Jul 2 05:31:42 2026 +0530

    Add `env_provider` and `Extra.env` support to MCP stdio transport (#69225)
---
 providers/common/ai/docs/connections/mcp.rst       |  67 ++++++-
 providers/common/ai/docs/toolsets.rst              |  13 +-
 .../common/ai/example_dags/example_mcp.py          |  41 ++++
 .../src/airflow/providers/common/ai/hooks/mcp.py   |  86 ++++++++-
 .../airflow/providers/common/ai/toolsets/mcp.py    |  21 ++-
 .../ai/tests/unit/common/ai/hooks/test_mcp.py      | 207 ++++++++++++++++++++-
 .../ai/tests/unit/common/ai/toolsets/test_mcp.py   |  22 ++-
 7 files changed, 435 insertions(+), 22 deletions(-)

diff --git a/providers/common/ai/docs/connections/mcp.rst 
b/providers/common/ai/docs/connections/mcp.rst
index 0eb5f7bfc60..07be621e9a1 100644
--- a/providers/common/ai/docs/connections/mcp.rst
+++ b/providers/common/ai/docs/connections/mcp.rst
@@ -59,6 +59,14 @@ Arguments (Extra field)
 
     Examples: ``["mcp-run-python"]``, ``["-m", "my_mcp_server"]``
 
+Environment (Extra field)
+    JSON object of environment variables for the ``stdio`` subprocess. Ignored
+    for ``http``/``sse``. For a secret that lives in a different connection or 
is
+    minted fresh per call, use an ``env_provider`` instead of storing it here
+    (see below).
+
+    Examples: ``{"MY_SERVER_MODE": "readonly"}``
+
 Examples
 --------
 
@@ -99,6 +107,15 @@ Examples
         "extra": "{\"transport\": \"stdio\", \"command\": \"python\", 
\"args\": [\"-m\", \"my_server\"], \"timeout\": 30}"
     }
 
+**Stdio with subprocess environment variables**
+
+.. code-block:: json
+
+    {
+        "conn_type": "mcp",
+        "extra": "{\"transport\": \"stdio\", \"command\": \"my-mcp-server\", 
\"args\": [], \"env\": {\"SERVER_MODE\": \"readonly\"}}"
+    }
+
 Short-lived or minted tokens
 ----------------------------
 
@@ -112,9 +129,11 @@ cannot be stored as a static connection ``password``. The 
same applies to OAuth
 refresh tokens, Workload Identity Federation, and GitHub App installation 
tokens.
 
 For these, pass a ``token_provider`` callable to ``MCPHook`` or ``MCPToolset``
-instead of a static token. It is called each time the connection is established
-and its return value is used as the bearer token, so a fresh token is minted 
(and
-registered with secret masking so it does not leak into task logs):
+instead of a static token. It is called once, the first time a given hook or
+toolset instance establishes a connection (the result is then cached for that
+instance's lifetime), and its return value is used as the bearer token, so a
+fresh token is minted (and registered with secret masking so it does not leak
+into task logs) without ever being written to the connection:
 
 .. code-block:: python
 
@@ -134,3 +153,45 @@ registered with secret masking so it does not leak into 
task logs):
 ``token_provider`` is resolved in DAG code (it is a Python callable, not a 
stored
 connection field), so the signing key stays in your environment and is never 
baked
 into the serialized DAG.
+
+Secrets in stdio subprocess environments
+-----------------------------------------
+
+The ``stdio`` transport runs the MCP server as a local subprocess, and many 
such
+servers read credentials from their own environment rather than accepting them
+as arguments -- for example, a server that reaches Splunk needs a Splunk API 
key
+in ``SPLUNK_API_KEY``. ``Extra.env`` (like the rest of ``extra``) is 
Fernet-encrypted
+at rest, the same as ``password``, so it is a fine place for a static value 
that
+genuinely belongs to *this* connection.
+
+Use ``env_provider`` instead when the credential has no stable static form to
+store here at all -- the same situation ``token_provider`` exists for on
+HTTP/SSE:
+
+- **It lives in a different connection.** A server that reaches Splunk needs a
+  Splunk credential, not a credential for the MCP server itself; duplicating
+  it into this connection's ``Extra.env`` means two places to rotate and a
+  real chance the copies drift.
+- **It's minted fresh per call** -- an OAuth token, a Vault lease, an
+  STS-assumed role -- so there is no fixed value to store anywhere, on this
+  connection or any other.
+
+``env_provider`` is called once, the first time a given hook or toolset 
instance
+establishes a connection (the result is then cached for that instance's
+lifetime). Its return value is merged over ``Extra.env`` (``env_provider`` keys
+win on conflicts), and -- as a secondary benefit -- every value it returns is
+explicitly registered with secret masking regardless of key name, unlike
+``Extra.env``, which is only masked in the Connections UI/API and task logs if
+the key name happens to match a fixed set of sensitive-looking names
+(``api_key``, ``token``, ``secret``, ``password``, etc.).
+
+The example below covers the first case -- the Splunk credential is already
+managed as its own Airflow connection:
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_mcp.py
+    :language: python
+    :start-after: [START howto_toolset_mcp_env_provider]
+    :end-before: [END howto_toolset_mcp_env_provider]
+
+Like ``token_provider``, ``env_provider`` is resolved in DAG code, so the 
secret is
+fetched at task-execution time and never baked into the serialized DAG.
diff --git a/providers/common/ai/docs/toolsets.rst 
b/providers/common/ai/docs/toolsets.rst
index fd2927187c9..d97a8fece2b 100644
--- a/providers/common/ai/docs/toolsets.rst
+++ b/providers/common/ai/docs/toolsets.rst
@@ -315,10 +315,17 @@ Parameters
   ``"weather_get_forecast"``).
 - ``token_provider``: Optional zero-argument callable returning a bearer token.
   When set, it overrides the connection's static ``password`` for the
-  ``Authorization`` header and is called each time the server connection is
-  established -- use it for short-lived or minted tokens (e.g. a Snowflake
-  managed MCP server authenticated with a key-pair JWT). See
+  ``Authorization`` header. Called once, the first time this toolset
+  establishes a connection -- use it for short-lived or minted tokens (e.g. a
+  Snowflake managed MCP server authenticated with a key-pair JWT). See
   :ref:`howto/connection:mcp`.
+- ``env_provider``: Optional zero-argument callable returning a
+  ``dict[str, str]`` merged over the connection's ``Extra.env`` (winning on key
+  conflicts) for the ``stdio`` subprocess environment -- use it when the
+  credential a local stdio MCP server needs lives in a different connection, or
+  is minted fresh per call (e.g. a Splunk/Vault token), rather than storing it
+  statically here. Called once, the first time this toolset establishes a
+  connection. See :ref:`howto/connection:mcp`.
 
 Using Multiple MCP Servers
 ^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_mcp.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_mcp.py
index f2e9a423679..023360b3516 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_mcp.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_mcp.py
@@ -91,3 +91,44 @@ example_mcp_multiple_servers()
 #           MCPToolset(StdioTransport(command="uvx", args=["mcp-run-python"])),
 #       ],
 #   )
+
+
+# ---------------------------------------------------------------------------
+# 4. Stdio server with a minted secret in the subprocess environment
+# ---------------------------------------------------------------------------
+# For local stdio MCP servers that read credentials from their own environment
+# (e.g. a server that needs a Splunk API key), pass env_provider instead of
+# storing the secret in the connection's static Extra.env. It is resolved at
+# task-execution time -- never baked into the serialized DAG -- and merged
+# over Extra.env, with env_provider's keys winning on conflicts. Here the
+# secret lives in a *different* connection (the Splunk one, not this MCP
+# server's own connection) -- something a static Extra.env cannot express.
+
+
+# [START howto_toolset_mcp_env_provider]
+def _mint_splunk_env() -> dict[str, str]:
+    from airflow.providers.common.compat.sdk import BaseHook
+
+    password = BaseHook.get_connection("splunk_default").password
+    if not password:
+        raise ValueError("splunk_default connection has no password set")
+    return {"SPLUNK_API_KEY": password}
+
+
+@dag(tags=["example"])
+def example_mcp_stdio_env_provider():
+    """Use a stdio MCP server whose subprocess needs a secret from another 
connection."""
+    AgentOperator(
+        task_id="stdio_env_agent",
+        prompt="Investigate the ticket and summarize findings.",
+        llm_conn_id="pydanticai_default",
+        system_prompt="You are a support triage agent with access to MCP 
tools.",
+        toolsets=[
+            MCPToolset(mcp_conn_id="spacefarer_mcp", 
env_provider=_mint_splunk_env),
+        ],
+    )
+
+
+# [END howto_toolset_mcp_env_provider]
+
+example_mcp_stdio_env_provider()
diff --git a/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py 
b/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
index b53ea712e8c..1cc816b3649 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
@@ -47,6 +47,8 @@ class MCPHook(BaseHook):
         - **Extra.transport**: Transport type — ``http`` (default), ``sse``, 
or ``stdio``
         - **Extra.command**: Command to run for stdio transport (e.g. ``uvx``)
         - **Extra.args**: Command arguments for stdio transport (e.g. 
``["mcp-run-python"]``)
+        - **Extra.env**: Environment variables for the stdio subprocess (e.g.
+          ``{"API_KEY": "..."}``). Ignored for HTTP/SSE.
         - **Extra.timeout**: Connection init timeout in seconds for stdio 
(default: 10)
 
     For HTTP/SSE transports the ``Authorization`` header is, by default, a 
static
@@ -54,17 +56,33 @@ class MCPHook(BaseHook):
     a freshly minted or short-lived token (e.g. a Snowflake managed MCP server
     authenticated with a key-pair JWT, OAuth/refresh tokens, Workload Identity
     Federation, or GitHub App installation tokens) can pass a 
``token_provider``
-    callable instead. It is invoked each time the server connection is 
established
-    and its return value is used as the bearer token, so a fresh token is 
minted
-    without storing a long-lived secret in the connection.
+    callable instead. It is invoked once, the first time this hook establishes 
a
+    connection, and its return value is used as the bearer token, so a fresh 
token
+    is minted without storing a long-lived secret in the connection.
+
+    For the ``stdio`` transport, the subprocess environment is, by default, the
+    static ``Extra.env`` mapping -- a fine place for a value that genuinely
+    belongs to this connection. When the credential has no stable static form 
to
+    store here at all -- it lives in a different connection (e.g. a Splunk
+    credential a Splunk connection already manages), or is minted fresh per 
call
+    (e.g. a Vault lease) -- pass an ``env_provider`` callable instead. Its 
return
+    value is merged over ``Extra.env`` (``env_provider`` keys win on 
conflicts),
+    invoked once, the first time this hook establishes a connection.
 
     :param mcp_conn_id: Airflow connection ID for the MCP server.
     :param tool_prefix: Optional prefix prepended to tool names
         (e.g. ``"weather"`` → ``"weather_get_forecast"``).
     :param token_provider: Optional zero-argument callable returning a bearer
         token string. When set, it overrides the connection ``password`` for 
the
-        ``Authorization`` header on HTTP/SSE transports and is called each 
time the
-        server connection is established. Ignored for the ``stdio`` transport.
+        ``Authorization`` header on HTTP/SSE transports. Called once, the first
+        time this hook establishes a connection (the result is then cached for
+        the hook's lifetime). Ignored for the ``stdio`` transport.
+    :param env_provider: Optional zero-argument callable returning a
+        ``dict[str, str]`` of environment variables for the ``stdio`` 
subprocess.
+        Merged over ``Extra.env`` (``env_provider`` wins on key conflicts). 
Called
+        once, the first time this hook establishes a connection (the result is
+        then cached for the hook's lifetime). Ignored for the ``http``/``sse``
+        transports.
     """
 
     conn_name_attr = "mcp_conn_id"
@@ -78,12 +96,14 @@ class MCPHook(BaseHook):
         tool_prefix: str | None = None,
         *,
         token_provider: Callable[[], str] | None = None,
+        env_provider: Callable[[], dict[str, str]] | None = None,
         **kwargs,
     ) -> None:
         super().__init__(**kwargs)
         self.mcp_conn_id = mcp_conn_id
         self.tool_prefix = tool_prefix
         self.token_provider = token_provider
+        self.env_provider = env_provider
         self._server: Any = None
 
     @staticmethod
@@ -119,6 +139,57 @@ class MCPHook(BaseHook):
             return {"Authorization": f"Bearer {conn.password}"}
         return None
 
+    def _validate_env_dict(self, env: dict[Any, Any], source: str) -> None:
+        """Raise if ``env`` (from ``source``, for error messages) has 
non-string keys/values."""
+        for key, value in env.items():
+            if not isinstance(key, str) or not key:
+                raise ValueError(
+                    f"{source} for connection {self.mcp_conn_id!r} must have 
non-empty string keys, "
+                    f"got {type(key).__name__}."
+                )
+            if not isinstance(value, str) or not value:
+                raise ValueError(
+                    f"{source} for connection {self.mcp_conn_id!r} must have 
non-empty string values, "
+                    f"got {type(value).__name__} for key {key!r}."
+                )
+
+    def _stdio_env(self, extra: dict[str, Any]) -> dict[str, str] | None:
+        """
+        Build the subprocess environment for the ``stdio`` transport.
+
+        Merges the static ``Extra.env`` mapping with ``env_provider`` (minted 
per
+        connection), with ``env_provider`` keys taking precedence. Returns 
``None``
+        when neither yields anything, in which case the MCP stdio client falls 
back
+        to a small allowlist of inherited variables (see the ``mcp`` package's
+        ``get_default_environment()``), not the full parent process 
environment.
+        """
+        extra_env = extra.get("env")
+        if extra_env is None:
+            extra_env = {}
+        if not isinstance(extra_env, dict):
+            raise ValueError(
+                f"'env' in extra for connection {self.mcp_conn_id!r} must be 
an object of "
+                f"string keys/values, got {type(extra_env).__name__}."
+            )
+        self._validate_env_dict(extra_env, "'env' in extra")
+
+        env: dict[str, str] = dict(extra_env)
+        if self.env_provider is not None:
+            provided = self.env_provider()
+            if not isinstance(provided, dict):
+                raise ValueError(
+                    f"env_provider for connection {self.mcp_conn_id!r} must 
return a dict[str, str], "
+                    f"got {type(provided).__name__}."
+                )
+            self._validate_env_dict(provided, "env_provider")
+            # The static connection extra is masked when the connection is 
fetched;
+            # mask minted values too so they never leak into task logs.
+            for value in provided.values():
+                mask_secret(value)
+            env.update(provided)
+
+        return env or None
+
     def get_conn(self) -> Any:
         """
         Return a configured PydanticAI MCP toolset instance.
@@ -169,7 +240,10 @@ class MCPHook(BaseHook):
             if isinstance(args, str):
                 args = [args]
             timeout = extra.get("timeout", 10)
-            toolset = MCPToolset(StdioTransport(command=command, args=args), 
init_timeout=timeout)
+            toolset = MCPToolset(
+                StdioTransport(command=command, args=args, 
env=self._stdio_env(extra)),
+                init_timeout=timeout,
+            )
         else:
             raise ValueError(
                 f"Unknown transport {transport!r} in connection 
{self.mcp_conn_id!r}. "
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 5a35cc3f54d..158d3bd7c35 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
@@ -50,15 +50,27 @@ class MCPToolset(AbstractToolset[Any]):
     For MCP endpoints that need a freshly minted or short-lived token (e.g. a
     Snowflake managed MCP server authenticated with a key-pair JWT, or OAuth /
     Workload Identity / GitHub App tokens), pass a ``token_provider`` callable.
-    It is invoked each time the connection is established and its return value 
is
-    used as the bearer token, so a fresh token is minted rather than storing a
-    long-lived secret in the connection.
+    It is invoked once, the first time this toolset establishes a connection, 
and
+    its return value is used as the bearer token, so a fresh token is minted
+    rather than storing a long-lived secret in the connection.
+
+    For ``stdio`` servers whose subprocess needs a credential that lives in a
+    different connection, or one minted fresh per call (e.g. a Splunk/Vault
+    token), pass an ``env_provider`` callable instead. It is invoked once, the
+    first time this toolset establishes a connection, and its return value is
+    merged over the connection's static ``Extra.env`` (``env_provider`` wins on
+    key conflicts).
 
     :param mcp_conn_id: Airflow connection ID for the MCP server.
     :param tool_prefix: Optional prefix prepended to tool names
         (e.g. ``"weather"`` → ``"weather_get_forecast"``).
     :param token_provider: Optional zero-argument callable returning a bearer
         token string, overriding the connection ``password`` for HTTP/SSE auth.
+        Called once, the first time this toolset establishes a connection.
+    :param env_provider: Optional zero-argument callable returning a
+        ``dict[str, str]`` merged over the connection's ``Extra.env`` (winning 
on
+        key conflicts) for the ``stdio`` subprocess environment. Called once, 
the
+        first time this toolset establishes a connection.
     """
 
     def __init__(
@@ -67,10 +79,12 @@ class MCPToolset(AbstractToolset[Any]):
         *,
         tool_prefix: str | None = None,
         token_provider: Callable[[], str] | None = None,
+        env_provider: Callable[[], dict[str, str]] | None = None,
     ) -> None:
         self._mcp_conn_id = mcp_conn_id
         self._tool_prefix = tool_prefix
         self._token_provider = token_provider
+        self._env_provider = env_provider
         self._server: Any = None
 
     @property
@@ -85,6 +99,7 @@ class MCPToolset(AbstractToolset[Any]):
                 mcp_conn_id=self._mcp_conn_id,
                 tool_prefix=self._tool_prefix,
                 token_provider=self._token_provider,
+                env_provider=self._env_provider,
             )
             self._server = hook.get_conn()
         return self._server
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py 
b/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
index 6e7a5629c3f..d226d585dd7 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
@@ -143,7 +143,7 @@ class TestMCPHookGetConn:
         with patch.object(hook, "get_connection", return_value=conn):
             result = hook.get_conn()
 
-        mock_transport_cls.assert_called_once_with(command="uvx", 
args=["mcp-run-python"])
+        mock_transport_cls.assert_called_once_with(command="uvx", 
args=["mcp-run-python"], env=None)
         
mock_toolset_cls.assert_called_once_with(mock_transport_cls.return_value, 
init_timeout=10)
         assert result is mock_toolset_cls.return_value
 
@@ -161,7 +161,7 @@ class TestMCPHookGetConn:
         with patch.object(hook, "get_connection", return_value=conn):
             hook.get_conn()
 
-        mock_transport_cls.assert_called_once_with(command="python", 
args=["-m", "server"])
+        mock_transport_cls.assert_called_once_with(command="python", 
args=["-m", "server"], env=None)
         
mock_toolset_cls.assert_called_once_with(mock_transport_cls.return_value, 
init_timeout=30)
 
     @patch(_MCP_TOOLSET, autospec=True)
@@ -176,7 +176,7 @@ class TestMCPHookGetConn:
         with patch.object(hook, "get_connection", return_value=conn):
             hook.get_conn()
 
-        mock_transport_cls.assert_called_once_with(command="uvx", 
args=["mcp-run-python"])
+        mock_transport_cls.assert_called_once_with(command="uvx", 
args=["mcp-run-python"], env=None)
 
     def test_http_without_host_raises(self):
         hook = MCPHook(mcp_conn_id="test_conn")
@@ -364,5 +364,204 @@ class TestMCPHookTokenProvider:
             hook.get_conn()
 
         provider.assert_not_called()
-        mock_transport_cls.assert_called_once_with(command="uvx", 
args=["mcp-run-python"])
+        mock_transport_cls.assert_called_once_with(command="uvx", 
args=["mcp-run-python"], env=None)
         
mock_toolset_cls.assert_called_once_with(mock_transport_cls.return_value, 
init_timeout=10)
+
+
+class TestMCPHookEnvProvider:
+    @patch(_MCP_TOOLSET, autospec=True)
+    @patch(_STDIO_TRANSPORT, autospec=True)
+    def test_stdio_uses_env_provider(self, mock_transport_cls, 
mock_toolset_cls):
+        hook = MCPHook(mcp_conn_id="test_conn", env_provider=lambda: 
{"SPLUNK_API_KEY": "minted"})
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps({"transport": "stdio", "command": 
"spacefarer-mcp", "args": []}),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.get_conn()
+
+        mock_transport_cls.assert_called_once_with(
+            command="spacefarer-mcp", args=[], env={"SPLUNK_API_KEY": "minted"}
+        )
+
+    @patch(_MCP_TOOLSET, autospec=True)
+    @patch(_STDIO_TRANSPORT, autospec=True)
+    def test_static_extra_env_used_when_no_provider(self, mock_transport_cls, 
mock_toolset_cls):
+        hook = MCPHook(mcp_conn_id="test_conn")
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps(
+                {
+                    "transport": "stdio",
+                    "command": "spacefarer-mcp",
+                    "args": [],
+                    "env": {"SPACEFARER_MCP_ALLOW_EXECUTION": "0"},
+                }
+            ),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.get_conn()
+
+        mock_transport_cls.assert_called_once_with(
+            command="spacefarer-mcp", args=[], 
env={"SPACEFARER_MCP_ALLOW_EXECUTION": "0"}
+        )
+
+    @patch(_MCP_TOOLSET, autospec=True)
+    @patch(_STDIO_TRANSPORT, autospec=True)
+    def test_env_provider_merges_over_static_extra_env(self, 
mock_transport_cls, mock_toolset_cls):
+        """env_provider keys win over Extra.env on conflicts; other static 
keys survive."""
+        hook = MCPHook(mcp_conn_id="test_conn", env_provider=lambda: 
{"SPLUNK_API_KEY": "fresh"})
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps(
+                {
+                    "transport": "stdio",
+                    "command": "spacefarer-mcp",
+                    "args": [],
+                    "env": {"SPLUNK_API_KEY": "stale", 
"SPACEFARER_MCP_ALLOW_EXECUTION": "0"},
+                }
+            ),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.get_conn()
+
+        mock_transport_cls.assert_called_once_with(
+            command="spacefarer-mcp",
+            args=[],
+            env={"SPLUNK_API_KEY": "fresh", "SPACEFARER_MCP_ALLOW_EXECUTION": 
"0"},
+        )
+
+    @patch(_MCP_TOOLSET, autospec=True)
+    @patch(_STDIO_TRANSPORT, autospec=True)
+    def test_env_provider_called_when_establishing_connection(self, 
mock_transport_cls, mock_toolset_cls):
+        provider = MagicMock(return_value={"SPLUNK_API_KEY": "minted"})
+        hook = MCPHook(mcp_conn_id="test_conn", env_provider=provider)
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps({"transport": "stdio", "command": 
"spacefarer-mcp", "args": []}),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.get_conn()
+
+        provider.assert_called_once_with()
+
+    @patch(_MCP_TOOLSET, autospec=True)
+    @patch(_STDIO_TRANSPORT, autospec=True)
+    def test_masks_provided_env_values(self, mock_transport_cls, 
mock_toolset_cls):
+        """Minted env values must be registered with secret masking, like the 
minted token."""
+        hook = MCPHook(mcp_conn_id="test_conn", env_provider=lambda: 
{"SPLUNK_API_KEY": "minted"})
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps({"transport": "stdio", "command": 
"spacefarer-mcp", "args": []}),
+        )
+        with (
+            patch.object(hook, "get_connection", return_value=conn),
+            patch("airflow.providers.common.ai.hooks.mcp.mask_secret", 
autospec=True) as mock_mask,
+        ):
+            hook.get_conn()
+
+        mock_mask.assert_called_once_with("minted")
+
+    @pytest.mark.parametrize("bad_value", ["", None, 123], ids=["empty", 
"none", "non_string"])
+    def test_invalid_env_provider_value_raises(self, bad_value):
+        hook = MCPHook(mcp_conn_id="test_conn", env_provider=lambda: 
{"SPLUNK_API_KEY": bad_value})
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps({"transport": "stdio", "command": 
"spacefarer-mcp", "args": []}),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(ValueError, match="must have non-empty string 
values"):
+                hook.get_conn()
+
+    def test_invalid_env_provider_key_raises(self):
+        hook = MCPHook(mcp_conn_id="test_conn", env_provider=lambda: {1: 
"value"})
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps({"transport": "stdio", "command": 
"spacefarer-mcp", "args": []}),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(ValueError, match="must have non-empty string 
keys"):
+                hook.get_conn()
+
+    def test_invalid_env_provider_return_type_raises(self):
+        hook = MCPHook(mcp_conn_id="test_conn", env_provider=lambda: 
"not-a-dict")
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps({"transport": "stdio", "command": 
"spacefarer-mcp", "args": []}),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(ValueError, match="must return a dict\\[str, 
str\\]"):
+                hook.get_conn()
+
+    def test_invalid_extra_env_type_raises(self):
+        hook = MCPHook(mcp_conn_id="test_conn")
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps(
+                {"transport": "stdio", "command": "spacefarer-mcp", "args": 
[], "env": ["not", "a", "dict"]}
+            ),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(ValueError, match="must be an object of"):
+                hook.get_conn()
+
+    @pytest.mark.parametrize("falsy_bad_env", [[], 0, False, ""], ids=["list", 
"zero", "false", "str"])
+    def test_falsy_invalid_extra_env_type_raises(self, falsy_bad_env):
+        """A falsy-but-wrong-type ``env`` (``[]``, ``0``, ...) must not 
silently become ``{}``."""
+        hook = MCPHook(mcp_conn_id="test_conn")
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps(
+                {
+                    "transport": "stdio",
+                    "command": "spacefarer-mcp",
+                    "args": [],
+                    "env": falsy_bad_env,
+                }
+            ),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(ValueError, match="must be an object of"):
+                hook.get_conn()
+
+    def test_invalid_extra_env_value_raises(self):
+        """A non-string value in the static Extra.env must fail with a clear 
message, not a bare
+        TypeError deep inside subprocess.Popen when the transport actually 
spawns."""
+        hook = MCPHook(mcp_conn_id="test_conn")
+        conn = Connection(
+            conn_id="test_conn",
+            conn_type="mcp",
+            extra=json.dumps(
+                {
+                    "transport": "stdio",
+                    "command": "spacefarer-mcp",
+                    "args": [],
+                    "env": {"SPACEFARER_MCP_ALLOW_EXECUTION": 0},
+                }
+            ),
+        )
+        with patch.object(hook, "get_connection", return_value=conn):
+            with pytest.raises(ValueError, match="must have non-empty string 
values"):
+                hook.get_conn()
+
+    @patch(_MCP_TOOLSET, autospec=True)
+    @patch(_HTTP_TRANSPORT, autospec=True)
+    def test_http_does_not_invoke_env_provider(self, mock_transport_cls, 
mock_toolset_cls):
+        """HTTP/SSE have no subprocess environment, so the env provider must 
not be called."""
+        provider = MagicMock(return_value={"SPLUNK_API_KEY": "minted"})
+        hook = MCPHook(mcp_conn_id="test_conn", env_provider=provider)
+        conn = Connection(conn_id="test_conn", conn_type="mcp", 
host="http://localhost:3001/mcp";)
+        with patch.object(hook, "get_connection", return_value=conn):
+            hook.get_conn()
+
+        provider.assert_not_called()
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 bbc3e079d58..b5fa0a56253 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
@@ -43,7 +43,9 @@ class TestMCPToolsetGetServer:
         ts = MCPToolset("mcp_conn")
         server = ts._get_server()
 
-        mock_hook_cls.assert_called_once_with(mcp_conn_id="mcp_conn", 
tool_prefix=None, token_provider=None)
+        mock_hook_cls.assert_called_once_with(
+            mcp_conn_id="mcp_conn", tool_prefix=None, token_provider=None, 
env_provider=None
+        )
         mock_hook_cls.return_value.get_conn.assert_called_once()
         assert server is mock_server
 
@@ -55,7 +57,7 @@ class TestMCPToolsetGetServer:
         ts._get_server()
 
         mock_hook_cls.assert_called_once_with(
-            mcp_conn_id="mcp_conn", tool_prefix="weather", token_provider=None
+            mcp_conn_id="mcp_conn", tool_prefix="weather", 
token_provider=None, env_provider=None
         )
 
     @patch(_HOOK_PATH, autospec=True)
@@ -69,7 +71,21 @@ class TestMCPToolsetGetServer:
         ts._get_server()
 
         mock_hook_cls.assert_called_once_with(
-            mcp_conn_id="mcp_conn", tool_prefix=None, token_provider=provider
+            mcp_conn_id="mcp_conn", tool_prefix=None, token_provider=provider, 
env_provider=None
+        )
+
+    @patch(_HOOK_PATH, autospec=True)
+    def test_passes_env_provider_to_hook(self, mock_hook_cls):
+        mock_hook_cls.return_value.get_conn.return_value = MagicMock()
+
+        def env_provider() -> dict[str, str]:
+            return {"SPLUNK_API_KEY": "minted"}
+
+        ts = MCPToolset("mcp_conn", env_provider=env_provider)
+        ts._get_server()
+
+        mock_hook_cls.assert_called_once_with(
+            mcp_conn_id="mcp_conn", tool_prefix=None, token_provider=None, 
env_provider=env_provider
         )
 
     @patch(_HOOK_PATH, autospec=True)

Reply via email to