server.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. """
  2. Ephemeris MCP server — FastAPI + FastMCP entry point.
  3. All MCP tools are defined here. The ephemeris engine and cache are in
  4. ephemeris.py and storage.py respectively.
  5. """
  6. from __future__ import annotations
  7. import logging
  8. from datetime import datetime, timezone
  9. from fastapi import FastAPI
  10. from mcp.server.fastmcp import FastMCP
  11. from mcp.server.transport_security import TransportSecuritySettings
  12. from . import config
  13. from .ephemeris import (
  14. BODIES,
  15. close as ephemeris_close,
  16. get_constellation_at_ecliptic as _compute_constellation_at_ecliptic,
  17. get_lunar_state as _compute_lunar_state,
  18. get_planetary_positions as _compute_planetary_positions,
  19. get_sidereal_time as _compute_sidereal_time,
  20. get_solar_events as _compute_solar_events,
  21. init as ephemeris_init,
  22. )
  23. from .storage import cache_key, get_cache
  24. logger = logging.getLogger("ephemeris-mcp")
  25. # ── FastMCP + FastAPI ──────────────────────────────────────────────
  26. cache = get_cache()
  27. mcp = FastMCP(
  28. "ephemeris-mcp",
  29. transport_security=TransportSecuritySettings(
  30. enable_dns_rebinding_protection=False,
  31. ),
  32. )
  33. # ── Helpers ─────────────────────────────────────────────────────────
  34. def _now_jd() -> float:
  35. from swisseph import julday
  36. now = datetime.now(timezone.utc)
  37. return julday(now.year, now.month, now.day, now.hour + now.minute / 60 + now.second / 3600)
  38. def _parse_datetime(dt_str: str | None) -> float:
  39. """Parse ISO datetime string to Julian Day. Defaults to now."""
  40. if dt_str is None:
  41. return _now_jd()
  42. from swisseph import julday
  43. dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
  44. if dt.tzinfo is None:
  45. dt = dt.replace(tzinfo=timezone.utc)
  46. return julday(dt.year, dt.month, dt.day, dt.hour + dt.minute / 60 + dt.second / 3600)
  47. def _parse_date(date_str: str) -> float:
  48. """Parse ISO date string to Julian Day at noon."""
  49. from swisseph import julday
  50. dt = datetime.fromisoformat(date_str)
  51. return julday(dt.year, dt.month, dt.day, 12.0)
  52. def _default_location(lat: float | None = None, lon: float | None = None) -> tuple[float, float]:
  53. resolved_lat = getattr(config, "DEFAULT_LAT", None) if lat is None else lat
  54. resolved_lon = getattr(config, "DEFAULT_LON", None) if lon is None else lon
  55. return (resolved_lat if resolved_lat is not None else 0.0, resolved_lon if resolved_lon is not None else 0.0)
  56. def _tool_names() -> list[str]:
  57. return [
  58. "get_planetary_positions",
  59. "get_solar_events",
  60. "get_lunar_state",
  61. "get_moon_phase",
  62. "get_sidereal_time",
  63. "get_constellation_at_ecliptic",
  64. "list_available_bodies",
  65. ]
  66. # ── Group A — Celestial Mechanics ──────────────────────────────────
  67. @mcp.tool()
  68. def get_planetary_positions(
  69. datetime: str | None = None,
  70. lat: float | None = None,
  71. lon: float | None = None,
  72. elevation: float = 0.0,
  73. geocentric: bool = False,
  74. ) -> dict:
  75. """
  76. Get positions of all major solar system bodies.
  77. Args:
  78. datetime: ISO 8601 datetime (UTC). Defaults to now.
  79. lat: Observer latitude in decimal degrees.
  80. lon: Observer longitude in decimal degrees.
  81. elevation: Observer elevation in meters.
  82. geocentric: If True, return geocentric positions instead of topocentric.
  83. Returns:
  84. Object with 'input' (echoed params), 'timestamp', and 'bodies' array.
  85. """
  86. lat, lon = _default_location(lat, lon)
  87. jd = _parse_datetime(datetime)
  88. ck = cache_key("get_planetary_positions", datetime=datetime or "now", lat=lat, lon=lon, geocentric=geocentric)
  89. cached = cache.get(ck)
  90. if cached:
  91. logger.info(f"cache hit: {ck}")
  92. return {"input": {"datetime": datetime, "lat": lat, "lon": lon, "geocentric": geocentric}, **cached}
  93. positions = _compute_planetary_positions(jd, lat, lon, elevation, geocentric)
  94. result = {
  95. "input": {"datetime": datetime, "lat": lat, "lon": lon, "geocentric": geocentric},
  96. "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
  97. "julian_day": round(jd, 6),
  98. "bodies": positions,
  99. }
  100. cache.set(
  101. ck,
  102. {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], "bodies": positions},
  103. config.CACHE_PLANETARY,
  104. )
  105. return result
  106. @mcp.tool()
  107. def get_solar_events(
  108. date: str,
  109. lat: float | None = None,
  110. lon: float | None = None,
  111. ) -> dict:
  112. """
  113. Get solar events (sunrise, sunset, solar noon, twilight) for a date and location.
  114. Args:
  115. date: ISO date string (YYYY-MM-DD).
  116. lat: Observer latitude in decimal degrees.
  117. lon: Observer longitude in decimal degrees.
  118. Returns:
  119. Object with all event times as ISO datetime strings and day length.
  120. """
  121. lat, lon = _default_location(lat, lon)
  122. jd = _parse_date(date)
  123. ck = cache_key("get_solar_events", date=date, lat=lat, lon=lon)
  124. cached = cache.get(ck)
  125. if cached:
  126. return {"input": {"date": date, "lat": lat, "lon": lon}, **cached}
  127. events = _compute_solar_events(jd, lat, lon)
  128. result = {
  129. "input": {"date": date, "lat": lat, "lon": lon},
  130. "date": date,
  131. "julian_day_base": round(jd, 6),
  132. "events_jd": events,
  133. }
  134. cache.set(
  135. ck,
  136. {"date": date, "events_jd": events, "julian_day_base": round(jd, 6)},
  137. config.CACHE_SOLAR,
  138. )
  139. return result
  140. @mcp.tool()
  141. def get_lunar_state(
  142. datetime: str | None = None,
  143. lat: float | None = None,
  144. lon: float | None = None,
  145. ) -> dict:
  146. """
  147. Get current lunar phase and position.
  148. Args:
  149. datetime: ISO 8601 datetime (UTC). Defaults to now.
  150. lat: Observer latitude in decimal degrees.
  151. lon: Observer longitude in decimal degrees.
  152. Returns:
  153. Object with phase name, illumination fraction, age, and position.
  154. """
  155. lat, lon = _default_location(lat, lon)
  156. jd = _parse_datetime(datetime)
  157. ck = cache_key("get_lunar_state", datetime=datetime or "now", lat=lat, lon=lon)
  158. cached = cache.get(ck)
  159. cached_next_major_phase = cached.get("lunar_state", {}).get("next_major_phase") if cached else None
  160. 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"):
  161. return {"input": {"datetime": datetime, "lat": lat, "lon": lon}, **cached}
  162. if cached:
  163. cache.delete(ck)
  164. state = _compute_lunar_state(jd, lat, lon)
  165. result = {
  166. "input": {"datetime": datetime, "lat": lat, "lon": lon},
  167. "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
  168. "julian_day": round(jd, 6),
  169. "lunar_state": state,
  170. }
  171. cache.set(
  172. ck,
  173. {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], "lunar_state": state},
  174. config.CACHE_LUNAR,
  175. )
  176. return result
  177. @mcp.tool()
  178. def get_sidereal_time(
  179. datetime: str | None = None,
  180. lat: float | None = None,
  181. lon: float | None = None,
  182. ) -> dict:
  183. """
  184. Get sidereal time and obliquity of the ecliptic.
  185. Args:
  186. datetime: ISO 8601 datetime (UTC). Defaults to now.
  187. lat: Observer latitude.
  188. lon: Observer longitude.
  189. Returns:
  190. Object with Greenwich and local sidereal time, and obliquity.
  191. """
  192. lat, lon = _default_location(lat, lon)
  193. jd = _parse_datetime(datetime)
  194. ck = cache_key("get_sidereal_time", datetime=datetime or "now", lat=lat, lon=lon)
  195. cached = cache.get(ck)
  196. if cached:
  197. return {"input": {"datetime": datetime, "lat": lat, "lon": lon}, **cached}
  198. result_st = _compute_sidereal_time(jd, lat, lon)
  199. result = {
  200. "input": {"datetime": datetime, "lat": lat, "lon": lon},
  201. "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
  202. "julian_day": round(jd, 6),
  203. **result_st,
  204. }
  205. cache.set(
  206. ck,
  207. {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], **result_st},
  208. config.CACHE_SIDEREAL,
  209. )
  210. return result
  211. @mcp.tool()
  212. def get_constellation_at_ecliptic(ecliptic_lon: float) -> dict:
  213. """
  214. Look up IAU constellation at a given ecliptic longitude.
  215. Args:
  216. ecliptic_lon: Ecliptic longitude in degrees (0-360).
  217. Returns:
  218. Object with constellation abbreviation, full name, and position within.
  219. """
  220. result = _compute_constellation_at_ecliptic(ecliptic_lon)
  221. return {"input": {"ecliptic_lon": ecliptic_lon}, **result}
  222. @mcp.tool()
  223. def list_available_bodies(category: str | None = None) -> dict:
  224. """
  225. List all computable celestial bodies.
  226. This is a lightweight discovery tool for the v0.1 core slice.
  227. """
  228. bodies = []
  229. for name in BODIES:
  230. bodies.append(
  231. {
  232. "name": name,
  233. "full_name": name.replace("_", " ").title(),
  234. "available": True,
  235. }
  236. )
  237. if category:
  238. category_lower = category.lower()
  239. if category_lower == "planets":
  240. bodies = [b for b in bodies if b["name"] in {"sun", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto"}]
  241. elif category_lower == "lunar":
  242. bodies = [b for b in bodies if b["name"] == "moon"]
  243. elif category_lower == "major":
  244. bodies = [b for b in bodies if b["name"] in {"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"}]
  245. return {"bodies": bodies}
  246. @mcp.tool()
  247. def get_moon_phase(datetime: str | None = None, lat: float | None = None, lon: float | None = None) -> dict:
  248. """
  249. Convenience lunar alias for mcporter users.
  250. Phase naming is driven by lunar age, while illumination is reported as
  251. a separate descriptive value.
  252. """
  253. lat, lon = _default_location(lat, lon)
  254. state = get_lunar_state(datetime=datetime, lat=lat, lon=lon)
  255. return {
  256. "input": {"datetime": datetime, "lat": lat, "lon": lon},
  257. "phase_name": state["lunar_state"]["phase_name"],
  258. "illumination_fraction": state["lunar_state"]["illumination_fraction"],
  259. "age_days": state["lunar_state"]["age_days"],
  260. "next_major_phase": state["lunar_state"]["next_major_phase"],
  261. "ecliptic_lon": state["lunar_state"]["ecliptic_lon"],
  262. "ecliptic_lat": state["lunar_state"]["ecliptic_lat"],
  263. "distance_km": state["lunar_state"]["distance_km"],
  264. }
  265. def create_app() -> FastAPI:
  266. """
  267. Build the FastAPI app for the v0.1 core slice.
  268. """
  269. logging.basicConfig(
  270. filename=str(config.LOG_DIR / "server.log"),
  271. level=logging.INFO,
  272. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  273. )
  274. ephemeris_init()
  275. app = FastAPI(title="ephemeris-mcp")
  276. app.mount("/mcp", mcp.sse_app())
  277. @app.get("/health")
  278. def health() -> dict:
  279. return {"ok": True, "server": "ephemeris-mcp", "port": config.PORT}
  280. @app.get("/")
  281. def root() -> dict:
  282. return {
  283. "server": "ephemeris-mcp",
  284. "status": "ready",
  285. "tools": _tool_names(),
  286. "mcp": {
  287. "sse": "/mcp/sse",
  288. "messages": "/mcp/messages",
  289. },
  290. }
  291. return app