Skip to content

Commit 0682cf8

Browse files
josh-fellclaude
andcommitted
fix: correct proxy URL construction for Astro CLI reverse proxy discovery
The routes.json hostname field already contains the full hostname (e.g. providence.sirius.localhost), so appending .localhost again produced invalid URLs like http://providence.sirius.localhost.localhost:6563. Also fix existing port-scan tests to pass a nonexistent proxy_routes_path so they don't accidentally pick up real routes.json entries on the host. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 85d6053 commit 0682cf8

4 files changed

Lines changed: 414 additions & 55 deletions

File tree

astro-airflow-mcp/src/astro_airflow_mcp/__main__.py

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,56 +5,52 @@
55
import os
66
from pathlib import Path
77

8-
import yaml
9-
8+
from astro_airflow_mcp.constants import DEFAULT_AIRFLOW_URL
109
from astro_airflow_mcp.logging import configure_logging, get_logger
1110
from astro_airflow_mcp.server import configure, mcp
1211

1312
logger = get_logger("main")
1413

15-
# Default Airflow URL if no config is found
16-
DEFAULT_AIRFLOW_URL = "http://localhost:8080"
17-
1814

19-
def discover_airflow_url(project_dir: str | None) -> str | None:
20-
"""Discover Airflow URL from .astro/config.yaml in the project directory.
15+
def discover_airflow_url(
16+
project_dir: str | None,
17+
proxy_routes_path: Path | None = None,
18+
proxy_global_config_path: Path | None = None,
19+
) -> tuple[str, bool] | None:
20+
"""Discover Airflow URL, checking proxy routes first then .astro/config.yaml.
2121
22-
Looks for the Astro CLI config file and extracts the webserver/api-server port.
23-
Prefers api-server.port (Airflow 3.x) over webserver.port (Airflow 2.x).
22+
Discovery priority:
23+
1. Proxy routes.json match by project directory (Astro CLI reverse proxy)
24+
2. .astro/config.yaml port (Airflow 3.x api-server.port, then 2.x webserver.port)
2425
2526
Args:
2627
project_dir: The project directory to search in
28+
proxy_routes_path: Path to proxy routes.json (for testing)
29+
proxy_global_config_path: Path to global astro config (for testing)
2730
2831
Returns:
29-
The discovered Airflow URL (e.g., "http://localhost:8081"), or None if not found
32+
Tuple of (url, is_proxy) if found, None otherwise
3033
"""
3134
if not project_dir:
3235
return None
3336

34-
config_path = Path(project_dir) / ".astro" / "config.yaml"
35-
if not config_path.exists():
36-
return None
37-
38-
try:
39-
with open(config_path) as f:
40-
config = yaml.safe_load(f)
41-
42-
if not config:
43-
return None
37+
from astro_airflow_mcp.discovery.local import LocalDiscoveryBackend
4438

45-
# Try api-server.port first (Airflow 3.x), then webserver.port (Airflow 2.x)
46-
port = None
47-
if "api-server" in config and isinstance(config["api-server"], dict):
48-
port = config["api-server"].get("port")
49-
if port is None and "webserver" in config and isinstance(config["webserver"], dict):
50-
port = config["webserver"].get("port")
39+
backend = LocalDiscoveryBackend()
5140

52-
if port:
53-
return f"http://localhost:{port}"
41+
# Step 1: Check proxy routes for a matching project directory
42+
proxy_url = backend.find_proxy_url_for_project(
43+
project_dir,
44+
routes_path=proxy_routes_path,
45+
global_config_path=proxy_global_config_path,
46+
)
47+
if proxy_url:
48+
return (proxy_url, True)
5449

55-
except Exception as e:
56-
# Log but don't fail - we'll fall back to default
57-
logger.debug("Failed to read .astro/config.yaml: %s", e)
50+
# Step 2: Check .astro/config.yaml for port
51+
port = backend.get_astro_project_port(project_dir=Path(project_dir))
52+
if port:
53+
return (f"http://localhost:{port}", False)
5854

5955
return None
6056

@@ -147,10 +143,11 @@ def main():
147143
airflow_url = args.airflow_url
148144
url_source = "explicit"
149145
if not airflow_url:
150-
# Try auto-discovery from .astro/config.yaml
151-
airflow_url = discover_airflow_url(args.airflow_project_dir)
152-
if airflow_url:
153-
url_source = "auto-discovered"
146+
# Try auto-discovery: proxy routes → .astro/config.yaml → default
147+
result = discover_airflow_url(args.airflow_project_dir)
148+
if result:
149+
airflow_url, is_proxy = result
150+
url_source = "auto-discovered (proxy)" if is_proxy else "auto-discovered"
154151
else:
155152
airflow_url = DEFAULT_AIRFLOW_URL
156153
url_source = "default"

astro-airflow-mcp/src/astro_airflow_mcp/constants.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Shared constants for CLI and MCP server."""
22

3+
import os
4+
from pathlib import Path
5+
36
# Terminal states for DAG runs (polling stops when reached)
47
TERMINAL_DAG_RUN_STATES = {"success", "failed", "upstream_failed"}
58

@@ -15,3 +18,21 @@
1518

1619
# Read-only mode environment variable
1720
READ_ONLY_ENV_VAR = "AF_READ_ONLY"
21+
22+
# Astro CLI reverse proxy defaults
23+
DEFAULT_PROXY_PORT = 6563
24+
25+
26+
def get_astro_home() -> Path:
27+
"""Return the Astro CLI home directory, respecting ASTRO_HOME env var."""
28+
return Path(os.environ.get("ASTRO_HOME", Path.home() / ".astro")).expanduser()
29+
30+
31+
def get_proxy_routes_path() -> Path:
32+
"""Return the path to the proxy routes.json file."""
33+
return get_astro_home() / "proxy" / "routes.json"
34+
35+
36+
def get_astro_global_config_path() -> Path:
37+
"""Return the path to the global Astro CLI config.yaml."""
38+
return get_astro_home() / "config.yaml"

astro-airflow-mcp/src/astro_airflow_mcp/discovery/local.py

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import json
67
import socket
78
from pathlib import Path
89
from typing import Any
910

1011
import httpx
1112
import yaml
1213

14+
from astro_airflow_mcp.constants import (
15+
DEFAULT_PROXY_PORT,
16+
get_astro_global_config_path,
17+
get_proxy_routes_path,
18+
)
1319
from astro_airflow_mcp.discovery.base import DiscoveredInstance, DiscoveryError
1420

1521

@@ -57,7 +63,7 @@ def is_available(self) -> bool:
5763
"""Local discovery is always available."""
5864
return True
5965

60-
def _get_astro_project_port(self, project_dir: Path | None = None) -> int | None:
66+
def get_astro_project_port(self, project_dir: Path | None = None) -> int | None:
6167
"""Check for .astro/config.yaml and extract the configured port.
6268
6369
Looks for:
@@ -96,35 +102,147 @@ def _get_astro_project_port(self, project_dir: Path | None = None) -> int | None
96102
except (OSError, yaml.YAMLError, ValueError, TypeError):
97103
return None
98104

105+
def _get_proxy_port(self, global_config_path: Path | None = None) -> int:
106+
"""Read the proxy port from the global Astro CLI config.
107+
108+
Args:
109+
global_config_path: Path to ~/.astro/config.yaml (for testing)
110+
111+
Returns:
112+
Proxy port number (default: DEFAULT_PROXY_PORT)
113+
"""
114+
if global_config_path is None:
115+
global_config_path = get_astro_global_config_path()
116+
117+
try:
118+
with open(global_config_path) as f:
119+
config = yaml.safe_load(f)
120+
if (
121+
config
122+
and "proxy" in config
123+
and isinstance(config["proxy"], dict)
124+
and (port := config["proxy"].get("port")) is not None
125+
):
126+
return int(port)
127+
except (OSError, yaml.YAMLError, ValueError, TypeError):
128+
pass
129+
130+
return DEFAULT_PROXY_PORT
131+
132+
def _read_proxy_routes(self, routes_path: Path | None = None) -> list[dict]:
133+
"""Read proxy routes from ~/.astro/proxy/routes.json.
134+
135+
Args:
136+
routes_path: Path to routes.json (for testing)
137+
138+
Returns:
139+
List of route dicts, or empty list if unavailable
140+
"""
141+
if routes_path is None:
142+
routes_path = get_proxy_routes_path()
143+
144+
try:
145+
with open(routes_path) as f:
146+
data = json.load(f)
147+
if isinstance(data, list):
148+
return data
149+
except (OSError, json.JSONDecodeError, TypeError):
150+
pass
151+
152+
return []
153+
154+
def find_proxy_url_for_project(
155+
self,
156+
project_dir: str,
157+
routes_path: Path | None = None,
158+
global_config_path: Path | None = None,
159+
) -> str | None:
160+
"""Find the proxy URL for a specific project directory.
161+
162+
Args:
163+
project_dir: The project directory to match
164+
routes_path: Path to routes.json (for testing)
165+
global_config_path: Path to global astro config (for testing)
166+
167+
Returns:
168+
Proxy URL if a matching route is found, None otherwise
169+
"""
170+
routes = self._read_proxy_routes(routes_path=routes_path)
171+
if not routes:
172+
return None
173+
174+
project_path = str(Path(project_dir).resolve())
175+
for route in routes:
176+
if not (route_dir := route.get("projectDir")):
177+
continue
178+
if str(Path(route_dir).resolve()) != project_path:
179+
continue
180+
if not (hostname := route.get("hostname")):
181+
continue
182+
proxy_port = self._get_proxy_port(global_config_path=global_config_path)
183+
return f"http://{hostname}:{proxy_port}"
184+
185+
return None
186+
99187
def discover(
100188
self,
101189
ports: list[int] | None = None,
102190
hosts: list[str] | None = None,
103191
timeout: float | None = None,
192+
proxy_routes_path: Path | None = None,
193+
proxy_global_config_path: Path | None = None,
104194
**kwargs: Any,
105195
) -> list[DiscoveredInstance]:
106-
"""Discover local Airflow instances by scanning ports.
196+
"""Discover local Airflow instances.
107197
108-
First checks for .astro/config.yaml in the current directory to find
109-
the configured port. Falls back to scanning common ports if not found.
198+
Checks proxy routes first (for Astro CLI reverse proxy), then falls
199+
back to scanning ports for running Airflow instances.
110200
111201
Args:
112202
ports: Ports to scan (default: check .astro/config.yaml, then common ports)
113203
hosts: Hosts to scan (default: localhost, 127.0.0.1)
114204
timeout: Connection timeout in seconds
205+
proxy_routes_path: Path to proxy routes.json (for testing)
206+
proxy_global_config_path: Path to global astro config (for testing)
115207
**kwargs: Additional options (ignored)
116208
117209
Returns:
118-
List of discovered instances
210+
List of discovered instances (proxy routes first, then port-scanned)
119211
"""
120212
if timeout is None:
121213
timeout = self.DEFAULT_HTTP_TIMEOUT
122214

215+
instances: list[DiscoveredInstance] = []
216+
217+
# Step 1: Check proxy routes (Astro CLI reverse proxy)
218+
if proxy_routes := self._read_proxy_routes(routes_path=proxy_routes_path):
219+
proxy_port = self._get_proxy_port(global_config_path=proxy_global_config_path)
220+
for route in proxy_routes:
221+
if not (hostname := route.get("hostname")):
222+
continue
223+
proxy_url = f"http://{hostname}:{proxy_port}"
224+
instance_name = f"{hostname}:{proxy_port}"
225+
instances.append(
226+
DiscoveredInstance(
227+
name=instance_name,
228+
url=proxy_url,
229+
source=self.name,
230+
auth_token=None,
231+
metadata={
232+
"proxy": True,
233+
"project_dir": route.get("projectDir", ""),
234+
"mode": route.get("mode", ""),
235+
"backend_port": route.get("port", ""),
236+
},
237+
)
238+
)
239+
240+
# Step 2: Port scanning
123241
# Build port list: check .astro/config.yaml first, then fallback to defaults
124242
if ports:
125243
scan_ports = ports
126244
else:
127-
astro_port = self._get_astro_project_port()
245+
astro_port = self.get_astro_project_port()
128246
if astro_port:
129247
# Prioritize Astro project port, then check other common ports
130248
scan_ports = [astro_port] + [p for p in self.DEFAULT_PORTS if p != astro_port]
@@ -133,7 +251,6 @@ def discover(
133251

134252
scan_hosts = hosts if hosts else self.DEFAULT_HOSTS
135253

136-
instances: list[DiscoveredInstance] = []
137254
seen_urls: set[str] = set()
138255

139256
for host in scan_hosts:

0 commit comments

Comments
 (0)