33from __future__ import annotations
44
55import asyncio
6+ import json
67import socket
78from pathlib import Path
89from typing import Any
910
1011import httpx
1112import 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+ )
1319from 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