| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360 |
- """
- Ephemeris MCP server — FastAPI + FastMCP entry point.
- All MCP tools are defined here. The ephemeris engine and cache are in
- ephemeris.py and storage.py respectively.
- """
- from __future__ import annotations
- import logging
- from datetime import datetime, timezone
- from fastapi import FastAPI
- from mcp.server.fastmcp import FastMCP
- from mcp.server.transport_security import TransportSecuritySettings
- from . import config
- from .ephemeris import (
- BODIES,
- close as ephemeris_close,
- get_constellation_at_ecliptic as _compute_constellation_at_ecliptic,
- get_lunar_state as _compute_lunar_state,
- get_planetary_positions as _compute_planetary_positions,
- get_sidereal_time as _compute_sidereal_time,
- get_solar_events as _compute_solar_events,
- init as ephemeris_init,
- )
- from .storage import cache_key, get_cache
- logger = logging.getLogger("ephemeris-mcp")
- # ── FastMCP + FastAPI ──────────────────────────────────────────────
- cache = get_cache()
- mcp = FastMCP(
- "ephemeris-mcp",
- transport_security=TransportSecuritySettings(
- enable_dns_rebinding_protection=False,
- ),
- )
- # ── Helpers ─────────────────────────────────────────────────────────
- def _now_jd() -> float:
- from swisseph import julday
- now = datetime.now(timezone.utc)
- return julday(now.year, now.month, now.day, now.hour + now.minute / 60 + now.second / 3600)
- def _parse_datetime(dt_str: str | None) -> float:
- """Parse ISO datetime string to Julian Day. Defaults to now."""
- if dt_str is None:
- return _now_jd()
- from swisseph import julday
- dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return julday(dt.year, dt.month, dt.day, dt.hour + dt.minute / 60 + dt.second / 3600)
- def _parse_date(date_str: str) -> float:
- """Parse ISO date string to Julian Day at noon."""
- from swisseph import julday
- dt = datetime.fromisoformat(date_str)
- return julday(dt.year, dt.month, dt.day, 12.0)
- def _default_location(lat: float | None = None, lon: float | None = None) -> tuple[float, float]:
- resolved_lat = getattr(config, "DEFAULT_LAT", None) if lat is None else lat
- resolved_lon = getattr(config, "DEFAULT_LON", None) if lon is None else lon
- return (resolved_lat if resolved_lat is not None else 0.0, resolved_lon if resolved_lon is not None else 0.0)
- def _tool_names() -> list[str]:
- return [
- "get_planetary_positions",
- "get_solar_events",
- "get_lunar_state",
- "get_moon_phase",
- "get_sidereal_time",
- "get_constellation_at_ecliptic",
- "list_available_bodies",
- ]
- # ── Group A — Celestial Mechanics ──────────────────────────────────
- @mcp.tool()
- def get_planetary_positions(
- datetime: str | None = None,
- lat: float | None = None,
- lon: float | None = None,
- elevation: float = 0.0,
- geocentric: bool = False,
- ) -> dict:
- """
- Get positions of all major solar system bodies.
- Args:
- datetime: ISO 8601 datetime (UTC). Defaults to now.
- lat: Observer latitude in decimal degrees.
- lon: Observer longitude in decimal degrees.
- elevation: Observer elevation in meters.
- geocentric: If True, return geocentric positions instead of topocentric.
- Returns:
- Object with 'input' (echoed params), 'timestamp', and 'bodies' array.
- """
- lat, lon = _default_location(lat, lon)
- jd = _parse_datetime(datetime)
- ck = cache_key("get_planetary_positions", datetime=datetime or "now", lat=lat, lon=lon, geocentric=geocentric)
- cached = cache.get(ck)
- if cached:
- logger.info(f"cache hit: {ck}")
- return {"input": {"datetime": datetime, "lat": lat, "lon": lon, "geocentric": geocentric}, **cached}
- positions = _compute_planetary_positions(jd, lat, lon, elevation, geocentric)
- result = {
- "input": {"datetime": datetime, "lat": lat, "lon": lon, "geocentric": geocentric},
- "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
- "julian_day": round(jd, 6),
- "bodies": positions,
- }
- cache.set(
- ck,
- {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], "bodies": positions},
- config.CACHE_PLANETARY,
- )
- return result
- @mcp.tool()
- def get_solar_events(
- date: str,
- lat: float | None = None,
- lon: float | None = None,
- ) -> dict:
- """
- Get solar events (sunrise, sunset, solar noon, twilight) for a date and location.
- Args:
- date: ISO date string (YYYY-MM-DD).
- lat: Observer latitude in decimal degrees.
- lon: Observer longitude in decimal degrees.
- Returns:
- Object with all event times as ISO datetime strings and day length.
- """
- lat, lon = _default_location(lat, lon)
- jd = _parse_date(date)
- ck = cache_key("get_solar_events", date=date, lat=lat, lon=lon)
- cached = cache.get(ck)
- if cached:
- return {"input": {"date": date, "lat": lat, "lon": lon}, **cached}
- events = _compute_solar_events(jd, lat, lon)
- result = {
- "input": {"date": date, "lat": lat, "lon": lon},
- "date": date,
- "julian_day_base": round(jd, 6),
- "events_jd": events,
- }
- cache.set(
- ck,
- {"date": date, "events_jd": events, "julian_day_base": round(jd, 6)},
- config.CACHE_SOLAR,
- )
- return result
- @mcp.tool()
- def get_lunar_state(
- datetime: str | None = None,
- lat: float | None = None,
- lon: float | None = None,
- ) -> dict:
- """
- Get current lunar phase and position.
- Args:
- datetime: ISO 8601 datetime (UTC). Defaults to now.
- lat: Observer latitude in decimal degrees.
- lon: Observer longitude in decimal degrees.
- Returns:
- Object with phase name, illumination fraction, age, and position.
- """
- lat, lon = _default_location(lat, lon)
- jd = _parse_datetime(datetime)
- ck = cache_key("get_lunar_state", datetime=datetime or "now", lat=lat, lon=lon)
- cached = cache.get(ck)
- cached_next_major_phase = cached.get("lunar_state", {}).get("next_major_phase") if cached else None
- 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"):
- return {"input": {"datetime": datetime, "lat": lat, "lon": lon}, **cached}
- if cached:
- cache.delete(ck)
- state = _compute_lunar_state(jd, lat, lon)
- result = {
- "input": {"datetime": datetime, "lat": lat, "lon": lon},
- "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
- "julian_day": round(jd, 6),
- "lunar_state": state,
- }
- cache.set(
- ck,
- {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], "lunar_state": state},
- config.CACHE_LUNAR,
- )
- return result
- @mcp.tool()
- def get_sidereal_time(
- datetime: str | None = None,
- lat: float | None = None,
- lon: float | None = None,
- ) -> dict:
- """
- Get sidereal time and obliquity of the ecliptic.
- Args:
- datetime: ISO 8601 datetime (UTC). Defaults to now.
- lat: Observer latitude.
- lon: Observer longitude.
- Returns:
- Object with Greenwich and local sidereal time, and obliquity.
- """
- lat, lon = _default_location(lat, lon)
- jd = _parse_datetime(datetime)
- ck = cache_key("get_sidereal_time", datetime=datetime or "now", lat=lat, lon=lon)
- cached = cache.get(ck)
- if cached:
- return {"input": {"datetime": datetime, "lat": lat, "lon": lon}, **cached}
- result_st = _compute_sidereal_time(jd, lat, lon)
- result = {
- "input": {"datetime": datetime, "lat": lat, "lon": lon},
- "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
- "julian_day": round(jd, 6),
- **result_st,
- }
- cache.set(
- ck,
- {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], **result_st},
- config.CACHE_SIDEREAL,
- )
- return result
- @mcp.tool()
- def get_constellation_at_ecliptic(ecliptic_lon: float) -> dict:
- """
- Look up IAU constellation at a given ecliptic longitude.
- Args:
- ecliptic_lon: Ecliptic longitude in degrees (0-360).
- Returns:
- Object with constellation abbreviation, full name, and position within.
- """
- result = _compute_constellation_at_ecliptic(ecliptic_lon)
- return {"input": {"ecliptic_lon": ecliptic_lon}, **result}
- @mcp.tool()
- def list_available_bodies(category: str | None = None) -> dict:
- """
- List all computable celestial bodies.
- This is a lightweight discovery tool for the v0.1 core slice.
- """
- bodies = []
- for name in BODIES:
- bodies.append(
- {
- "name": name,
- "full_name": name.replace("_", " ").title(),
- "available": True,
- }
- )
- if category:
- category_lower = category.lower()
- if category_lower == "planets":
- bodies = [b for b in bodies if b["name"] in {"sun", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto"}]
- elif category_lower == "lunar":
- bodies = [b for b in bodies if b["name"] == "moon"]
- elif category_lower == "major":
- bodies = [b for b in bodies if b["name"] in {"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"}]
- return {"bodies": bodies}
- @mcp.tool()
- def get_moon_phase(datetime: str | None = None, lat: float | None = None, lon: float | None = None) -> dict:
- """
- Convenience lunar alias for mcporter users.
- Phase naming is driven by lunar age, while illumination is reported as
- a separate descriptive value.
- """
- lat, lon = _default_location(lat, lon)
- state = get_lunar_state(datetime=datetime, lat=lat, lon=lon)
- return {
- "input": {"datetime": datetime, "lat": lat, "lon": lon},
- "phase_name": state["lunar_state"]["phase_name"],
- "illumination_fraction": state["lunar_state"]["illumination_fraction"],
- "age_days": state["lunar_state"]["age_days"],
- "next_major_phase": state["lunar_state"]["next_major_phase"],
- "ecliptic_lon": state["lunar_state"]["ecliptic_lon"],
- "ecliptic_lat": state["lunar_state"]["ecliptic_lat"],
- "distance_km": state["lunar_state"]["distance_km"],
- }
- def create_app() -> FastAPI:
- """
- Build the FastAPI app for the v0.1 core slice.
- """
- logging.basicConfig(
- filename=str(config.LOG_DIR / "server.log"),
- level=logging.INFO,
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
- )
- ephemeris_init()
- app = FastAPI(title="ephemeris-mcp")
- app.mount("/mcp", mcp.sse_app())
- @app.get("/health")
- def health() -> dict:
- return {"ok": True, "server": "ephemeris-mcp", "port": config.PORT}
- @app.get("/")
- def root() -> dict:
- return {
- "server": "ephemeris-mcp",
- "status": "ready",
- "tools": _tool_names(),
- "mcp": {
- "sse": "/mcp/sse",
- "messages": "/mcp/messages",
- },
- }
- return app
|