| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604 |
- """
- MCP tool definitions for astro-mcp.
- All tools are async and use the ephemeris client to get astronomical data,
- then the astrology module to transform it into astrological structures.
- """
- from __future__ import annotations
- import logging
- from typing import Any
- from .server import mcp
- from . import astrology
- from .ephemeris_client import call_sky_state, extract_bodies
- logger = logging.getLogger("astro-mcp.tools")
- DEFAULT_ORBS = astrology.DEFAULT_ORBS
- # ── Tool: get_planetary_positions ────────────────────────────────────
- @mcp.tool()
- async def get_planetary_positions(
- datetime: str | None = None,
- lat: float | None = None,
- lon: float | None = None,
- elevation: float = 0.0,
- geocentric: bool = True,
- bodies: list[str] | None = None,
- ) -> dict[str, Any]:
- """Get planetary positions enhanced with zodiac signs, degrees, and retrograde flags.
- 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.
- bodies: Optional list of body names to filter (e.g., ["sun", "moon"]).
- Returns:
- Object with 'input' (echoed params), 'timestamp', 'julian_day',
- and 'bodies' array. Each body includes ecliptic_lon, ecliptic_lat,
- sign, degree_within_sign, retrograde flag, speed_lon, and distance.
- """
- resolved_lat = lat if lat is not None else 0.0
- resolved_lon = lon if lon is not None else 0.0
- sky = await call_sky_state(
- datetime=datetime,
- lat=resolved_lat,
- lon=resolved_lon,
- elevation=elevation,
- geocentric=geocentric,
- )
- if "error" in sky:
- return {"input": {"datetime": datetime, "lat": resolved_lat, "lon": resolved_lon}, "error": sky["error"]}
- raw_bodies = extract_bodies(sky)
- enhanced_bodies = []
- for body in raw_bodies:
- name = body.get("body", "unknown")
- if bodies and name not in bodies:
- continue
- ecl_lon = body.get("ecliptic_lon", 0.0)
- ecl_lat = body.get("ecliptic_lat", 0.0)
- speed_lon = body.get("speed_lon")
- distance_au = body.get("distance_au", 0.0)
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
- retrograde = astrology.is_retrograde(speed_lon)
- enhanced_bodies.append({
- "body": name,
- "ecliptic_lon": ecl_lon,
- "ecliptic_lat": ecl_lat,
- "distance_au": distance_au,
- "speed_lon": speed_lon,
- "sign": zodiac["sign"],
- "sign_abbreviation": zodiac["abbreviation"],
- "degree_within_sign": zodiac["degree"],
- "retrograde": retrograde,
- })
- return {
- "input": {
- "datetime": datetime,
- "lat": resolved_lat,
- "lon": resolved_lon,
- "elevation": elevation,
- "geocentric": geocentric,
- "bodies_filter": bodies,
- },
- "timestamp_utc": sky.get("timestamp_utc"),
- "julian_day": sky.get("julian_day"),
- "bodies": enhanced_bodies,
- }
- # ── Tool: calculate_natal_chart ──────────────────────────────────────
- @mcp.tool()
- async def calculate_natal_chart(
- birth_datetime: str,
- latitude: float,
- longitude: float,
- elevation: float = 0.0,
- house_system: str = "placidus",
- orb_limits: dict[str, float] | None = None,
- ) -> dict[str, Any]:
- """Calculate a complete natal chart from birth data.
- Args:
- birth_datetime: ISO 8601 birth datetime (UTC).
- latitude: Birth latitude in decimal degrees.
- longitude: Birth longitude in decimal degrees.
- elevation: Birth elevation in meters.
- house_system: House system to use (default: Placidus).
- orb_limits: Optional dict of {aspect_name: max_orb_degrees}.
- Returns:
- Complete natal chart structure with planets, houses, aspects, and angles.
- """
- sky = await call_sky_state(
- datetime=birth_datetime,
- lat=latitude,
- lon=longitude,
- elevation=elevation,
- geocentric=True,
- )
- if "error" in sky:
- return {"input": {"birth_datetime": birth_datetime, "latitude": latitude, "longitude": longitude}, "error": sky["error"]}
- raw_bodies = extract_bodies(sky)
- sidereal = sky.get("sidereal_time", {})
- lst_hours = sidereal.get("local_sidereal_time", 0.0)
- # Calculate houses
- houses = astrology.calculate_houses(lst_hours, latitude, house_system)
- # Build planet list with house placement
- planets = []
- for body in raw_bodies:
- ecl_lon = body.get("ecliptic_lon", 0.0)
- ecl_lat = body.get("ecliptic_lat", 0.0)
- speed_lon = body.get("speed_lon")
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
- house = astrology.get_house_placement(ecl_lon, houses)
- planets.append({
- "body": body["body"],
- "sign": zodiac["sign"],
- "sign_abbreviation": zodiac["abbreviation"],
- "degree_within_sign": zodiac["degree"],
- "absolute_lon": zodiac["absolute_lon"],
- "ecliptic_lat": ecl_lat,
- "distance_au": body.get("distance_au", 0.0),
- "house": house,
- "retrograde": astrology.is_retrograde(speed_lon),
- })
- # Calculate aspects
- aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in planets]
- aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
- # Format aspects
- formatted_aspects = []
- for asp in aspects:
- formatted_aspects.append({
- "body1": asp["body1"],
- "body2": asp["body2"],
- "aspect": asp["aspect"],
- "orb": asp["orb"],
- "applying": asp["applying"],
- "exactness": asp["exactness"],
- })
- # Calculate angles
- angles = astrology.calculate_angles(lst_hours, latitude)
- return {
- "input": {
- "birth_datetime": birth_datetime,
- "latitude": latitude,
- "longitude": longitude,
- "elevation": elevation,
- "house_system": house_system,
- "orb_limits": orb_limits,
- },
- "chart_type": "natal",
- "planets": planets,
- "houses": houses,
- "aspects": formatted_aspects,
- "angles": angles,
- }
- # ── Tool: calculate_transit_chart ────────────────────────────────────
- @mcp.tool()
- async def calculate_transit_chart(
- birth_datetime: str,
- transit_datetime: str,
- latitude: float,
- longitude: float,
- elevation: float = 0.0,
- house_system: str = "placidus",
- orb_limits: dict[str, float] | None = None,
- ) -> dict[str, Any]:
- """Calculate a transit chart: transiting planets vs natal positions.
- Args:
- birth_datetime: ISO 8601 birth datetime (UTC).
- transit_datetime: ISO 8601 transit datetime (UTC).
- latitude: Birth latitude in decimal degrees.
- longitude: Birth longitude in decimal degrees.
- elevation: Birth elevation in meters.
- house_system: House system for natal houses (default: Placidus).
- orb_limits: Optional orb configuration.
- Returns:
- Transit chart with transiting planets, aspects to natal, and house placements.
- """
- # Get natal sky state
- natal_sky = await call_sky_state(
- datetime=birth_datetime,
- lat=latitude,
- lon=longitude,
- elevation=elevation,
- geocentric=True,
- )
- # Get transit sky state at birth location
- transit_sky = await call_sky_state(
- datetime=transit_datetime,
- lat=latitude,
- lon=longitude,
- elevation=elevation,
- geocentric=True,
- )
- if "error" in natal_sky:
- return {"error": f"natal: {natal_sky['error']}"}
- if "error" in transit_sky:
- return {"error": f"transit: {transit_sky['error']}"}
- natal_bodies = extract_bodies(natal_sky)
- transit_bodies = extract_bodies(transit_sky)
- # Natal houses from natal LST
- sidereal = natal_sky.get("sidereal_time", {})
- lst_hours = sidereal.get("local_sidereal_time", 0.0)
- houses = astrology.calculate_houses(lst_hours, latitude, house_system)
- # Build natal planets
- natal_planets = []
- for body in natal_bodies:
- ecl_lon = body.get("ecliptic_lon", 0.0)
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
- house = astrology.get_house_placement(ecl_lon, houses)
- natal_planets.append({
- "body": body["body"],
- "sign": zodiac["sign"],
- "degree_within_sign": zodiac["degree"],
- "absolute_lon": zodiac["absolute_lon"],
- "house": house,
- "retrograde": astrology.is_retrograde(body.get("speed_lon")),
- })
- # Build transit planets
- transit_planets = []
- for body in transit_bodies:
- ecl_lon = body.get("ecliptic_lon", 0.0)
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
- transit_house = astrology.get_house_placement(ecl_lon, houses)
- transit_planets.append({
- "body": body["body"],
- "sign": zodiac["sign"],
- "degree_within_sign": zodiac["degree"],
- "absolute_lon": zodiac["absolute_lon"],
- "natal_house": transit_house,
- "retrograde": astrology.is_retrograde(body.get("speed_lon")),
- })
- # Transit-to-natal aspects
- transit_aspects = []
- for t_body in transit_planets:
- for n_body in natal_planets:
- pair = [
- {"name": f"transit_{t_body['body']}", "lon": t_body["absolute_lon"], "speed_lon": None},
- {"name": f"natal_{n_body['body']}", "lon": n_body["absolute_lon"], "speed_lon": None},
- ]
- pair_aspects = astrology.compute_aspects(pair, orb_limits)
- for asp in pair_aspects:
- transit_aspects.append({
- "transiting": t_body["body"],
- "natal": n_body["body"],
- "aspect": asp["aspect"],
- "orb": asp["orb"],
- "exactness": asp["exactness"],
- })
- transit_aspects.sort(key=lambda a: a["orb"])
- return {
- "input": {
- "birth_datetime": birth_datetime,
- "transit_datetime": transit_datetime,
- "latitude": latitude,
- "longitude": longitude,
- "house_system": house_system,
- },
- "chart_type": "transit",
- "natal_planets": natal_planets,
- "transiting_planets": transit_planets,
- "aspects": transit_aspects,
- "houses": houses,
- }
- # ── Tool: calculate_synastry_chart ───────────────────────────────────
- @mcp.tool()
- async def calculate_synastry_chart(
- person1_datetime: str,
- person1_latitude: float,
- person1_longitude: float,
- person2_datetime: str,
- person2_latitude: float,
- person2_longitude: float,
- elevation: float = 0.0,
- house_system: str = "placidus",
- orb_limits: dict[str, float] | None = None,
- ) -> dict[str, Any]:
- """Calculate a synastry (relationship) chart for two people.
- Args:
- person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
- person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
- elevation: Birth elevation in meters.
- house_system: House system (default: Placidus).
- orb_limits: Optional orb configuration.
- Returns:
- Synastry chart with interaspects, house overlays, composite, and Davison charts.
- """
- sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, elevation=elevation)
- sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, elevation=elevation)
- if "error" in sky1:
- return {"error": f"person1: {sky1['error']}"}
- if "error" in sky2:
- return {"error": f"person2: {sky2['error']}"}
- bodies1 = extract_bodies(sky1)
- bodies2 = extract_bodies(sky2)
- sidereal1 = sky1.get("sidereal_time", {})
- lst1 = sidereal1.get("local_sidereal_time", 0.0)
- houses1 = astrology.calculate_houses(lst1, person1_latitude, house_system)
- sidereal2 = sky2.get("sidereal_time", {})
- lst2 = sidereal2.get("local_sidereal_time", 0.0)
- houses2 = astrology.calculate_houses(lst2, person2_latitude, house_system)
- def build_planet_list(bodies):
- result = []
- for b in bodies:
- ecl_lon = b.get("ecliptic_lon", 0.0)
- z = astrology.ecliptic_to_zodiac(ecl_lon)
- result.append({
- "body": b["body"],
- "sign": z["sign"],
- "degree_within_sign": z["degree"],
- "absolute_lon": z["absolute_lon"],
- "retrograde": astrology.is_retrograde(b.get("speed_lon")),
- })
- return result
- chart1_planets = build_planet_list(bodies1)
- chart2_planets = build_planet_list(bodies2)
- # Interaspects
- interaspects = []
- for p1 in chart1_planets:
- for p2 in chart2_planets:
- pair = [
- {"name": f"p1_{p1['body']}", "lon": p1["absolute_lon"]},
- {"name": f"p2_{p2['body']}", "lon": p2["absolute_lon"]},
- ]
- for asp in astrology.compute_aspects(pair, orb_limits):
- interaspects.append({
- "person1_planet": p1["body"],
- "person2_planet": p2["body"],
- "aspect": asp["aspect"],
- "orb": asp["orb"],
- "exactness": asp["exactness"],
- })
- interaspects.sort(key=lambda a: a["orb"])
- # House overlays: person2's planets in person1's houses
- p2_in_p1_houses = []
- for p2 in chart2_planets:
- house = astrology.get_house_placement(p2["absolute_lon"], houses1)
- p2_in_p1_houses.append({
- "planet": p2["body"],
- "house": house,
- })
- p1_in_p2_houses = []
- for p1 in chart1_planets:
- house = astrology.get_house_placement(p1["absolute_lon"], houses2)
- p1_in_p2_houses.append({
- "planet": p1["body"],
- "house": house,
- })
- # Composite chart (midpoint method)
- composite_bodies = astrology.compute_composite_chart(
- [{"name": p["body"], "lon": p["absolute_lon"]} for p in chart1_planets],
- [{"name": p["body"], "lon": p["absolute_lon"]} for p in chart2_planets],
- )
- composite_planets = []
- for cb in composite_bodies:
- z = astrology.ecliptic_to_zodiac(cb["lon"])
- composite_planets.append({
- "body": cb["name"],
- "sign": z["sign"],
- "degree_within_sign": z["degree"],
- "absolute_lon": z["absolute_lon"],
- })
- # Davison chart (date midpoint)
- davison = astrology.compute_davison_chart(person1_datetime, person2_datetime, person1_datetime, person2_datetime)
- davison_mid_lat = (person1_latitude + person2_latitude) / 2
- davison_mid_lon = (person1_longitude + person2_longitude) / 2
- return {
- "input": {
- "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
- "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
- "house_system": house_system,
- },
- "chart_type": "synastry",
- "chart1_natal": {"planets": chart1_planets, "houses": houses1},
- "chart2_natal": {"planets": chart2_planets, "houses": houses2},
- "interaspects": interaspects,
- "house_overlays": {
- "person2_in_person1_houses": p2_in_p1_houses,
- "person1_in_person2_houses": p1_in_p2_houses,
- },
- "composite_chart": {"planets": composite_planets},
- "davison_chart": {
- "date_midpoint_jd": davison["date_midpoint_jd"],
- "latitude_midpoint": davison_mid_lat,
- "longitude_midpoint": davison_mid_lon,
- },
- }
- # ── Tool: get_transit_preview ────────────────────────────────────────
- @mcp.tool()
- async def get_transit_preview(
- person_id: str,
- start_date: str,
- end_date: str,
- event_types: list[str] | None = None,
- orb_limits: dict[str, float] | None = None,
- ) -> dict[str, Any]:
- """Preview significant transit events for a person over a time range.
- Args:
- person_id: ID of a person in the persons database.
- start_date: ISO date string for the start of the range.
- end_date: ISO date string for the end of the range.
- event_types: Optional filter for event types
- (exact_aspect, ingress, retrograde_station, lunar_phase).
- orb_limits: Optional orb configuration for aspect detection.
- Returns:
- List of transit events with timestamps, descriptions, and orbs.
- """
- return {
- "input": {
- "person_id": person_id,
- "start_date": start_date,
- "end_date": end_date,
- "event_types": event_types,
- },
- "events": [],
- "_note": "Transit preview not yet implemented -- requires person database (Phase 4)",
- }
- # ── Tool: person_manage ─────────────────────────────────────────────
- @mcp.tool()
- async def person_manage(
- action: str,
- person_id: str | None = None,
- name: str | None = None,
- nickname: str | None = None,
- birth_datetime: str | None = None,
- latitude: float | None = None,
- longitude: float | None = None,
- elevation: float | None = None,
- ) -> dict[str, Any]:
- """Manage persons in the birth data database.
- Args:
- action: One of: add, get, list, update, delete.
- person_id: Required for get, update, delete.
- name: Person's full name (required for add).
- nickname: Optional short name for quick lookup.
- birth_datetime: ISO 8601 UTC datetime (required for add).
- latitude: Birth latitude (required for add).
- longitude: Birth longitude (required for add).
- elevation: Birth elevation in meters.
- Returns:
- Operation result with person data or error.
- """
- from . import storage
- action = action.lower().strip()
- if action == "add":
- if not name or not birth_datetime or latitude is None or longitude is None:
- return {"error": "add requires: name, birth_datetime, latitude, longitude"}
- person = await storage.add_person(
- name=name,
- birth_datetime=birth_datetime,
- latitude=latitude,
- longitude=longitude,
- elevation=elevation if elevation is not None else 0.0,
- nickname=nickname,
- )
- return {"action": "add", "person": person}
- elif action == "get":
- if not person_id and not nickname:
- return {"error": "get requires: person_id or nickname"}
- person = await storage.get_person(person_id=person_id, nickname=nickname)
- if not person:
- return {"action": "get", "error": "not_found"}
- return {"action": "get", "person": person}
- elif action == "list":
- persons = await storage.list_persons()
- return {"action": "list", "persons": persons, "count": len(persons)}
- elif action == "update":
- if not person_id:
- return {"error": "update requires: person_id"}
- person = await storage.update_person(
- person_id=person_id,
- name=name,
- nickname=nickname,
- birth_datetime=birth_datetime,
- latitude=latitude,
- longitude=longitude,
- elevation=elevation,
- )
- if not person:
- return {"action": "update", "error": "not_found"}
- return {"action": "update", "person": person}
- elif action == "delete":
- if not person_id:
- return {"error": "delete requires: person_id"}
- deleted = await storage.delete_person(person_id)
- if not deleted:
- return {"action": "delete", "error": "not_found"}
- return {"action": "delete", "deleted": True, "person_id": person_id}
- else:
- return {"error": f"unknown action: {action}. Use: add, get, list, update, delete"}
- # ── Tool: list_house_systems ─────────────────────────────────────────
- @mcp.tool()
- def list_house_systems() -> dict[str, Any]:
- """List supported house systems.
- Returns:
- Object with 'systems' array, each containing name and description.
- """
- return {
- "systems": [
- {"id": "placidus", "name": "Placidus", "description": "Most common system; houses based on time divisions of the diurnal arc. Default."},
- {"id": "equal", "name": "Equal House", "description": "Each house is exactly 30 degrees, starting from the ASC."},
- {"id": "whole_sign", "name": "Whole Sign", "description": "Each house corresponds to one full sign. The ASC sign is house 1."},
- ]
- }
|