server.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. """
  2. Ephemeris MCP server — FastAPI + FastMCP entry point.
  3. Provides Swiss Ephemeris computation as a service: planetary positions,
  4. house cusps, lunar state, sidereal time, solar events, and more.
  5. Downstream consumers (e.g. astro-mcp, satrack-mcp) call these tools
  6. instead of bundling the Swiss Ephemeris themselves.
  7. All MCP tools are defined here. The ephemeris engine and cache are in
  8. ephemeris.py and storage.py respectively.
  9. """
  10. from __future__ import annotations
  11. import logging
  12. from datetime import datetime, timezone
  13. from fastapi import FastAPI
  14. from mcp.server.fastmcp import FastMCP
  15. from mcp.server.transport_security import TransportSecuritySettings
  16. from . import config
  17. from .ephemeris import (
  18. BODIES,
  19. close as ephemeris_close,
  20. get_constellation_at_ecliptic as _compute_constellation_at_ecliptic,
  21. get_lunar_state as _compute_lunar_state,
  22. get_planetary_positions as _compute_planetary_positions,
  23. get_sidereal_time as _compute_sidereal_time,
  24. get_solar_events as _compute_solar_events,
  25. init as ephemeris_init,
  26. )
  27. from .storage import cache_key, get_cache
  28. import swisseph as swe
  29. logger = logging.getLogger("ephemeris-mcp")
  30. # ── FastMCP + FastAPI ──────────────────────────────────────────────
  31. cache = get_cache()
  32. mcp = FastMCP(
  33. "ephemeris-mcp",
  34. transport_security=TransportSecuritySettings(
  35. enable_dns_rebinding_protection=False,
  36. ),
  37. )
  38. # ── Helpers ─────────────────────────────────────────────────────────
  39. def _now_jd() -> float:
  40. from swisseph import julday
  41. now = datetime.now(timezone.utc)
  42. return julday(now.year, now.month, now.day, now.hour + now.minute / 60 + now.second / 3600)
  43. def _parse_datetime(dt_str: str | None) -> float:
  44. """Parse ISO datetime string to Julian Day. Defaults to now."""
  45. if dt_str is None:
  46. return _now_jd()
  47. from swisseph import julday
  48. dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
  49. if dt.tzinfo is None:
  50. dt = dt.replace(tzinfo=timezone.utc)
  51. return julday(dt.year, dt.month, dt.day, dt.hour + dt.minute / 60 + dt.second / 3600)
  52. def _parse_date(date_str: str) -> float:
  53. """Parse ISO date string to Julian Day at noon."""
  54. from swisseph import julday
  55. dt = datetime.fromisoformat(date_str)
  56. return julday(dt.year, dt.month, dt.day, 12.0)
  57. def _sign_name(lon: float) -> str:
  58. signs = ["Aries","Taurus","Gemini","Cancer","Leo","Virgo","Libra","Scorpio","Sagittarius","Capricorn","Aquarius","Pisces"]
  59. return signs[int(lon / 30) % 12]
  60. def _sign_abbr(lon: float) -> str:
  61. return _sign_name(lon)[:3]
  62. def _utc_date_from_datetime(dt_str: str | None) -> str:
  63. """Convert an ISO datetime string to a UTC date string."""
  64. if dt_str is None:
  65. return __import__("datetime").datetime.now(timezone.utc).date().isoformat()
  66. dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
  67. if dt.tzinfo is None:
  68. dt = dt.replace(tzinfo=timezone.utc)
  69. return dt.astimezone(timezone.utc).date().isoformat()
  70. def _default_location(lat: float | None = None, lon: float | None = None) -> tuple[float, float]:
  71. resolved_lat = getattr(config, "DEFAULT_LAT", None) if lat is None else lat
  72. resolved_lon = getattr(config, "DEFAULT_LON", None) if lon is None else lon
  73. return (resolved_lat if resolved_lat is not None else 0.0, resolved_lon if resolved_lon is not None else 0.0)
  74. def _tool_names() -> list[str]:
  75. return [
  76. "get_planetary_positions",
  77. "get_solar_events",
  78. "get_lunar_state",
  79. "get_moon_phase",
  80. "get_sidereal_time",
  81. "get_constellation_at_ecliptic",
  82. "list_available_bodies",
  83. "get_sky_state",
  84. ]
  85. # ── Group A — Celestial Mechanics ──────────────────────────────────
  86. @mcp.tool()
  87. def get_planetary_positions(
  88. datetime: str | None = None,
  89. lat: float | None = None,
  90. lon: float | None = None,
  91. elevation: float = 0.0,
  92. geocentric: bool = True,
  93. ) -> dict:
  94. """
  95. Get positions of all major solar system bodies.
  96. Args:
  97. datetime: ISO 8601 datetime (UTC). Defaults to now.
  98. lat: Observer latitude in decimal degrees.
  99. lon: Observer longitude in decimal degrees.
  100. elevation: Observer elevation in meters.
  101. geocentric: If True, return geocentric positions instead of topocentric.
  102. Returns:
  103. Object with 'input' (echoed params), 'timestamp', and 'bodies' array.
  104. """
  105. lat, lon = _default_location(lat, lon)
  106. jd = _parse_datetime(datetime)
  107. ck = cache_key(
  108. "get_planetary_positions",
  109. cache_version=3,
  110. datetime=datetime or "now",
  111. elevation=elevation,
  112. geocentric=geocentric,
  113. lat=lat,
  114. lon=lon,
  115. )
  116. cached = cache.get(ck)
  117. if cached:
  118. logger.info(f"cache hit: {ck}")
  119. return {
  120. "input": {
  121. "datetime": datetime,
  122. "lat": lat,
  123. "lon": lon,
  124. "elevation": elevation,
  125. "geocentric": geocentric,
  126. },
  127. **cached,
  128. }
  129. positions = _compute_planetary_positions(jd, lat, lon, elevation, geocentric)
  130. result = {
  131. "input": {
  132. "datetime": datetime,
  133. "lat": lat,
  134. "lon": lon,
  135. "elevation": elevation,
  136. "geocentric": geocentric,
  137. },
  138. "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
  139. "julian_day": round(jd, 6),
  140. "bodies": positions,
  141. }
  142. cache.set(
  143. ck,
  144. {
  145. "timestamp_utc": result["timestamp_utc"],
  146. "julian_day": result["julian_day"],
  147. "bodies": positions,
  148. },
  149. config.CACHE_PLANETARY,
  150. )
  151. return result
  152. @mcp.tool()
  153. def get_solar_events(
  154. date: str,
  155. lat: float | None = None,
  156. lon: float | None = None,
  157. ) -> dict:
  158. """
  159. Get solar events (sunrise, sunset, solar noon, twilight) for a date and location.
  160. Args:
  161. date: ISO date string (YYYY-MM-DD).
  162. lat: Observer latitude in decimal degrees.
  163. lon: Observer longitude in decimal degrees.
  164. Returns:
  165. Object with all event times as ISO datetime strings and day length.
  166. """
  167. lat, lon = _default_location(lat, lon)
  168. jd = _parse_date(date)
  169. ck = cache_key("get_solar_events", date=date, lat=lat, lon=lon)
  170. cached = cache.get(ck)
  171. if cached:
  172. return {"input": {"date": date, "lat": lat, "lon": lon}, **cached}
  173. events = _compute_solar_events(jd, lat, lon)
  174. result = {
  175. "input": {"date": date, "lat": lat, "lon": lon},
  176. "date": date,
  177. "julian_day_base": round(jd, 6),
  178. "events_jd": events,
  179. }
  180. cache.set(
  181. ck,
  182. {"date": date, "events_jd": events, "julian_day_base": round(jd, 6)},
  183. config.CACHE_SOLAR,
  184. )
  185. return result
  186. @mcp.tool()
  187. def get_lunar_state(
  188. datetime: str | None = None,
  189. lat: float | None = None,
  190. lon: float | None = None,
  191. ) -> dict:
  192. """
  193. Get current lunar phase and position.
  194. Args:
  195. datetime: ISO 8601 datetime (UTC). Defaults to now.
  196. lat: Observer latitude in decimal degrees.
  197. lon: Observer longitude in decimal degrees.
  198. Returns:
  199. Object with phase name, illumination fraction, age, and position.
  200. """
  201. lat, lon = _default_location(lat, lon)
  202. jd = _parse_datetime(datetime)
  203. ck = cache_key("get_lunar_state", cache_version=3, datetime=datetime or "now", lat=lat, lon=lon)
  204. cached = cache.get(ck)
  205. cached_next_major_phase = cached.get("lunar_state", {}).get("next_major_phase") if cached else None
  206. if cached_next_major_phase and cached_next_major_phase.get("phase_name") and cached_next_major_phase.get("at_utc") and cached_next_major_phase.get("in_text"):
  207. return {"input": {"datetime": datetime, "lat": lat, "lon": lon}, **cached}
  208. if cached:
  209. cache.delete(ck)
  210. state = _compute_lunar_state(jd, lat, lon)
  211. result = {
  212. "input": {"datetime": datetime, "lat": lat, "lon": lon},
  213. "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
  214. "julian_day": round(jd, 6),
  215. "lunar_state": state,
  216. }
  217. cache.set(
  218. ck,
  219. {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], "lunar_state": state},
  220. config.CACHE_LUNAR,
  221. )
  222. return result
  223. @mcp.tool()
  224. def get_sidereal_time(
  225. datetime: str | None = None,
  226. lat: float | None = None,
  227. lon: float | None = None,
  228. ) -> dict:
  229. """
  230. Get sidereal time and obliquity of the ecliptic.
  231. Args:
  232. datetime: ISO 8601 datetime (UTC). Defaults to now.
  233. lat: Observer latitude.
  234. lon: Observer longitude.
  235. Returns:
  236. Object with Greenwich and local sidereal time, and obliquity.
  237. """
  238. lat, lon = _default_location(lat, lon)
  239. jd = _parse_datetime(datetime)
  240. ck = cache_key("get_sidereal_time", datetime=datetime or "now", lat=lat, lon=lon)
  241. cached = cache.get(ck)
  242. if cached:
  243. return {"input": {"datetime": datetime, "lat": lat, "lon": lon}, **cached}
  244. result_st = _compute_sidereal_time(jd, lat, lon)
  245. result = {
  246. "input": {"datetime": datetime, "lat": lat, "lon": lon},
  247. "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
  248. "julian_day": round(jd, 6),
  249. **result_st,
  250. }
  251. cache.set(
  252. ck,
  253. {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], **result_st},
  254. config.CACHE_SIDEREAL,
  255. )
  256. return result
  257. @mcp.tool()
  258. def get_constellation_at_ecliptic(ecliptic_lon: float) -> dict:
  259. """
  260. Look up IAU constellation at a given ecliptic longitude.
  261. Args:
  262. ecliptic_lon: Ecliptic longitude in degrees (0-360).
  263. Returns:
  264. Object with constellation abbreviation, full name, and position within.
  265. """
  266. result = _compute_constellation_at_ecliptic(ecliptic_lon)
  267. return {"input": {"ecliptic_lon": ecliptic_lon}, **result}
  268. @mcp.tool()
  269. def list_available_bodies(category: str | None = None) -> dict:
  270. """
  271. List all computable celestial bodies.
  272. This is a lightweight discovery tool for the v0.2 core slice.
  273. """
  274. bodies = []
  275. for name in BODIES:
  276. bodies.append(
  277. {
  278. "name": name,
  279. "full_name": name.replace("_", " ").title(),
  280. "available": True,
  281. }
  282. )
  283. if category:
  284. category_lower = category.lower()
  285. if category_lower == "planets":
  286. bodies = [b for b in bodies if b["name"] in {"sun", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto"}]
  287. elif category_lower == "lunar":
  288. bodies = [b for b in bodies if b["name"] == "moon"]
  289. elif category_lower == "major":
  290. bodies = [b for b in bodies if b["name"] in {"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"}]
  291. return {"bodies": bodies}
  292. @mcp.tool()
  293. def get_moon_phase(datetime: str | None = None, lat: float | None = None, lon: float | None = None) -> dict:
  294. """
  295. Convenience lunar alias for mcporter users.
  296. Phase naming is driven by lunar age, while illumination is reported as
  297. a separate descriptive value.
  298. """
  299. lat, lon = _default_location(lat, lon)
  300. state = get_lunar_state(datetime=datetime, lat=lat, lon=lon)
  301. return {
  302. "input": {"datetime": datetime, "lat": lat, "lon": lon},
  303. "phase_name": state["lunar_state"]["phase_name"],
  304. "illumination_fraction": state["lunar_state"]["illumination_fraction"],
  305. "age_days": state["lunar_state"]["age_days"],
  306. "next_major_phase": state["lunar_state"]["next_major_phase"],
  307. "ecliptic_lon": state["lunar_state"]["ecliptic_lon"],
  308. "ecliptic_lat": state["lunar_state"]["ecliptic_lat"],
  309. "distance_km": state["lunar_state"]["distance_km"],
  310. }
  311. @mcp.tool()
  312. def get_sky_state(
  313. datetime: str | None = None,
  314. lat: float | None = None,
  315. lon: float | None = None,
  316. elevation: float = 0.0,
  317. geocentric: bool = True,
  318. house_system: str | None = None,
  319. ) -> dict:
  320. """
  321. Return a consolidated raw sky-state snapshot for downstream chart engines.
  322. Combines planetary positions, lunar state, sidereal time, and solar events
  323. into a single response. Optionally includes house cusps and angles computed
  324. server-side via the Swiss Ephemeris when a house system is specified.
  325. This is the primary tool for downstream consumers (e.g. astro-mcp) that
  326. need a full sky-state from a single call.
  327. Args:
  328. datetime: ISO 8601 datetime (UTC). Defaults to now.
  329. lat: Observer latitude in decimal degrees.
  330. lon: Observer longitude in decimal degrees.
  331. elevation: Observer elevation in meters.
  332. geocentric: If True, return geocentric positions instead of topocentric.
  333. house_system: Optional house system code (single uppercase letter).
  334. When provided, includes 'houses' key with 12 cusps and angles
  335. (ASC, MC, DSC, IC). All 22 Swiss Ephemeris house systems:
  336. P=Placidus, K=Koch, E=Equal (0° Aries), W=Whole Sign,
  337. A=Alcabitius, C=Campanus, M=Morinus, R=Porphyry,
  338. T=Polich/Page, U=Krusinski-Pisa, V=Vehlow Equal,
  339. X=Meridian, Y=Horizontal, H=Azimuthal, O=Equal (MC),
  340. F=Carter poli-eq, D=Equal (15° Aries), G=Gauquelin sectors,
  341. I=Sunshine, J=Sunshine alt, L=Pullen SD, N=Equal/1,
  342. Q=Pullen SR, S=Sripati, Z=APC houses.
  343. """
  344. lat, lon = _default_location(lat, lon)
  345. date = _utc_date_from_datetime(datetime)
  346. jd = _parse_datetime(datetime)
  347. ck = cache_key(
  348. "get_sky_state",
  349. cache_version=2,
  350. datetime=datetime or "now",
  351. date=date,
  352. elevation=elevation,
  353. geocentric=geocentric,
  354. lat=lat,
  355. lon=lon,
  356. house_system=house_system,
  357. )
  358. cached = cache.get(ck)
  359. if cached:
  360. return {
  361. "input": {
  362. "datetime": datetime,
  363. "date": date,
  364. "lat": lat,
  365. "lon": lon,
  366. "elevation": elevation,
  367. "geocentric": geocentric,
  368. "house_system": house_system,
  369. },
  370. **cached,
  371. }
  372. planetary_positions = get_planetary_positions(
  373. datetime=datetime,
  374. lat=lat,
  375. lon=lon,
  376. elevation=elevation,
  377. geocentric=geocentric,
  378. )
  379. lunar_state = get_lunar_state(datetime=datetime, lat=lat, lon=lon)
  380. sidereal_time = get_sidereal_time(datetime=datetime, lat=lat, lon=lon)
  381. solar_events = get_solar_events(date=date, lat=lat, lon=lon)
  382. result = {
  383. "input": {
  384. "datetime": datetime,
  385. "date": date,
  386. "lat": lat,
  387. "lon": lon,
  388. "elevation": elevation,
  389. "geocentric": geocentric,
  390. "house_system": house_system,
  391. },
  392. "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
  393. "julian_day": round(jd, 6),
  394. "planetary_positions": planetary_positions,
  395. "solar_events": solar_events,
  396. "lunar_state": lunar_state,
  397. "sidereal_time": sidereal_time,
  398. }
  399. # Compute house cusps when requested
  400. if house_system is not None:
  401. hs_code = house_system.upper().encode()[:1]
  402. logger.info(f"house_system={house_system!r} hs_code={hs_code!r} type={type(hs_code)}")
  403. try:
  404. cusps, ascmc = swe.houses(jd, lat, lon, hs_code)
  405. houses = []
  406. for i in range(12):
  407. houses.append({
  408. "house": i + 1,
  409. "absolute_lon": cusps[i],
  410. "sign": _sign_name(cusps[i]),
  411. "abbreviation": _sign_abbr(cusps[i]),
  412. "degree": cusps[i] % 30.0,
  413. })
  414. result["houses"] = {
  415. "system": house_system.upper(),
  416. "cusps": houses,
  417. "ascendant": ascmc[0],
  418. "midheaven": ascmc[1],
  419. "descendant": ascmc[0] + 180.0 if ascmc[0] < 180.0 else ascmc[0] - 180.0,
  420. "imum_coeli": ascmc[1] + 180.0 if ascmc[1] < 180.0 else ascmc[1] - 180.0,
  421. "armc": ascmc[2] if len(ascmc) > 2 else None,
  422. "equatorial_ascendant": ascmc[3] if len(ascmc) > 3 else None,
  423. "vertex": ascmc[4] if len(ascmc) > 4 else None,
  424. "co_ascendant": ascmc[5] if len(ascmc) > 5 else None,
  425. "polar_ascendant": ascmc[6] if len(ascmc) > 6 else None,
  426. }
  427. except Exception as exc:
  428. logger.warning(f"house calculation failed for system {house_system!r}: {exc}")
  429. result["houses"] = {"system": house_system.upper(), "error": str(exc)}
  430. cache.set(
  431. ck,
  432. {
  433. "timestamp_utc": result["timestamp_utc"],
  434. "julian_day": result["julian_day"],
  435. "planetary_positions": planetary_positions,
  436. "solar_events": solar_events,
  437. "lunar_state": lunar_state,
  438. "sidereal_time": sidereal_time,
  439. "houses": result.get("houses"),
  440. },
  441. config.CACHE_SKY,
  442. )
  443. return result
  444. def create_app() -> FastAPI:
  445. """
  446. Build the FastAPI app for the v0.2 core slice.
  447. """
  448. config.LOG_DIR.mkdir(parents=True, exist_ok=True)
  449. logging.basicConfig(
  450. filename=str(config.LOG_DIR / "server.log"),
  451. level=logging.INFO,
  452. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  453. )
  454. ephemeris_init()
  455. app = FastAPI(title="ephemeris-mcp")
  456. app.mount("/mcp", mcp.sse_app())
  457. @app.get("/health")
  458. def health() -> dict:
  459. return {"ok": True, "server": "ephemeris-mcp", "port": config.PORT}
  460. @app.get("/")
  461. def root() -> dict:
  462. return {
  463. "server": "ephemeris-mcp",
  464. "status": "ready",
  465. "tools": _tool_names(),
  466. "mcp": {
  467. "sse": "/mcp/sse",
  468. "messages": "/mcp/messages",
  469. },
  470. }
  471. return app