Dev-iL commented on code in PR #69381:
URL: https://github.com/apache/airflow/pull/69381#discussion_r3524419383


##########
dev/mcp_server/src/airflow_mcp_server/client.py:
##########
@@ -0,0 +1,209 @@
+# 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.
+"""Thin async HTTP client for the Airflow REST API, with auth and a 
write-safety gate.
+
+Every tool in :mod:`airflow_mcp_server.server` funnels its HTTP calls through
+:func:`call_api`, which is the single choke point that decides whether a 
method is
+allowed (read-only by default) and turns HTTP failures into readable 
``ToolError``
+messages the calling agent can act on.
+
+Configuration is read from the environment *at call time* (not at import time) 
so
+that tests can monkeypatch it per-case:
+
+- ``AIRFLOW_API_URL`` -- base URL of the Airflow API server (default
+  ``http://localhost:28080``, Breeze's host-forwarded port).
+- ``AIRFLOW_USERNAME`` / ``AIRFLOW_PASSWORD`` -- SimpleAuthManager credentials 
used to
+  log in via ``POST {AIRFLOW_API_URL}/auth/token`` (default ``admin`` / 
``admin``,
+  Breeze's default SimpleAuthManager user).
+- ``AIRFLOW_ACCESS_TOKEN`` -- optional pre-issued JWT; when set, the login 
flow is
+  skipped entirely and this value is used for every request.
+- ``AIRFLOW_MCP_ALLOW_WRITES`` -- ``"true"``/``"1"`` to allow 
``POST``/``PATCH``/``PUT``
+  requests.
+- ``AIRFLOW_MCP_ALLOW_DELETES`` -- ``"true"``/``"1"`` to allow ``DELETE`` 
requests. This is a
+  *separate, stricter* gate than writes: it is off by default even when writes 
are enabled,
+  because deletions are irreversible. Enabling it is a deliberate, explicit 
opt-in.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+from typing import Any
+
+import httpx
+from fastmcp.exceptions import ToolError
+
+_DEFAULT_API_URL = "http://localhost:28080";
+_DEFAULT_USERNAME = "admin"
+_DEFAULT_PASSWORD = "admin"
+_WRITE_METHODS = {"POST", "PATCH", "PUT"}
+_WRITES_ENV = "AIRFLOW_MCP_ALLOW_WRITES"
+_DELETES_ENV = "AIRFLOW_MCP_ALLOW_DELETES"
+_TRUTHY_VALUES = {"true", "1"}
+_BODY_SNIPPET_LIMIT = 2000
+
+_http_client: httpx.AsyncClient | None = None
+_http_client_loop: asyncio.AbstractEventLoop | None = None
+_cached_token: str | None = None
+
+
+def _build_http_client() -> httpx.AsyncClient:
+    """Build the underlying async HTTP client. Tests patch this to inject a 
mock transport."""
+    return httpx.AsyncClient(timeout=30.0)
+
+
+def _get_http_client() -> httpx.AsyncClient:
+    """Return the shared HTTP client, rebuilding it if the running event loop 
has changed.
+
+    httpx.AsyncClient binds to the event loop it was created under; without 
this check, a
+    process that runs multiple asyncio event loops in sequence (e.g. successive
+    ``asyncio.run()`` calls in a script, or per-test event loops) would reuse 
a client tied
+    to a now-closed loop and fail with "Event loop is closed". The 
long-running server
+    process only ever has one loop, so this is a no-op there.
+    """
+    global _http_client, _http_client_loop
+    current_loop = asyncio.get_running_loop()
+    if _http_client is None or _http_client_loop is not current_loop:
+        _http_client = _build_http_client()
+        _http_client_loop = current_loop
+    return _http_client
+
+
+def reset_client_state() -> None:
+    """Drop the cached HTTP client and auth token so the next call rebuilds 
them from scratch."""
+    global _http_client, _http_client_loop, _cached_token
+    _http_client = None
+    _http_client_loop = None
+    _cached_token = None
+
+
+def _get_base_url() -> str:
+    return os.environ.get("AIRFLOW_API_URL", _DEFAULT_API_URL).rstrip("/")
+
+
+async def _fetch_token() -> str:
+    username = os.environ.get("AIRFLOW_USERNAME", _DEFAULT_USERNAME)
+    password = os.environ.get("AIRFLOW_PASSWORD", _DEFAULT_PASSWORD)
+    base_url = _get_base_url()
+    response = await _get_http_client().post(
+        f"{base_url}/auth/token",
+        json={"username": username, "password": password},
+    )
+    if response.status_code != 201:
+        raise ToolError(
+            f"Failed to authenticate with Airflow at {base_url}/auth/token: "
+            f"HTTP {response.status_code}: 
{response.text[:_BODY_SNIPPET_LIMIT]}"
+        )
+    return response.json()["access_token"]
+
+
+async def _get_token(*, force_refresh: bool = False) -> str:
+    """Return the bearer token to use, preferring an explicit pre-issued token 
if configured."""
+    global _cached_token
+    env_token = os.environ.get("AIRFLOW_ACCESS_TOKEN")
+    if env_token:
+        return env_token
+    if _cached_token is None or force_refresh:
+        _cached_token = await _fetch_token()
+    return _cached_token
+
+
+def _is_gate_enabled(env_var: str) -> bool:
+    return os.environ.get(env_var, "").strip().lower() in _TRUTHY_VALUES
+
+
+def _check_method_gate(method: str) -> None:
+    """Enforce the single method safety choke point for all Airflow API calls.
+
+    ``GET`` is always allowed; ``POST``/``PATCH``/``PUT`` need 
``AIRFLOW_MCP_ALLOW_WRITES``;
+    ``DELETE`` needs its own, stricter ``AIRFLOW_MCP_ALLOW_DELETES`` gate (off 
by default even
+    when writes are enabled, since deletions are irreversible).
+    """
+    if method == "GET":

Review Comment:
   Minor future-proofing:
   
   ```suggestion
       if method in ("GET", "QUERY"):
   ```
   
   Ref: https://www.rfc-editor.org/rfc/rfc10008.html



##########
dev/mcp_server/pyproject.toml:
##########
@@ -0,0 +1,65 @@
+# 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.
+
+[build-system]
+requires = [
+    "hatchling==1.30.1",
+    "packaging==26.2",
+    "pathspec==1.1.1",
+    "pluggy==1.6.0",
+    "tomli==2.4.1; python_version < '3.11'",
+    "trove-classifiers==2026.6.1.19",
+]
+build-backend = "hatchling.build"
+
+[project]
+name = "apache-airflow-dev-mcp-server"
+description = "Internal development MCP server for inspecting/debugging a 
running Airflow instance (e.g. in Breeze) through the public Airflow REST API. 
Not released, not shipped to users."
+version = "0.1.0"
+requires-python = ">=3.10"
+classifiers = ["Private :: Do Not Upload"]
+dependencies = [
+    # Brings in the official `mcp` SDK and `httpx` transitively.
+    "fastmcp>=3.0,<4.0",

Review Comment:
   Since this is a new module - is there any particular reason not to use the 
latest minor, i.e. 
   
   ```suggestion
       "fastmcp>=3.4,<4.0",
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to