tools.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. """
  2. MCP tool definitions for astro-mcp.
  3. All tools are async and use the ephemeris client to get astronomical data,
  4. then the astrology module to transform it into astrological structures.
  5. """
  6. from __future__ import annotations
  7. import logging
  8. from typing import Any
  9. from .server import mcp
  10. from . import astrology
  11. from .ephemeris_client import call_sky_state, extract_bodies
  12. logger = logging.getLogger("astro-mcp.tools")
  13. DEFAULT_ORBS = astrology.DEFAULT_ORBS
  14. # ── Tool: get_planetary_positions ────────────────────────────────────
  15. @mcp.tool()
  16. async def get_planetary_positions(
  17. datetime: str | None = None,
  18. lat: float | None = None,
  19. lon: float | None = None,
  20. elevation: float = 0.0,
  21. geocentric: bool = True,
  22. bodies: list[str] | None = None,
  23. ) -> dict[str, Any]:
  24. """Get planetary positions enhanced with zodiac signs, degrees, and retrograde flags.
  25. Args:
  26. datetime: ISO 8601 datetime (UTC). Defaults to now.
  27. lat: Observer latitude in decimal degrees.
  28. lon: Observer longitude in decimal degrees.
  29. elevation: Observer elevation in meters.
  30. geocentric: If True, return geocentric positions.
  31. bodies: Optional list of body names to filter (e.g., ["sun", "moon"]).
  32. Returns:
  33. Object with 'input' (echoed params), 'timestamp', 'julian_day',
  34. and 'bodies' array. Each body includes ecliptic_lon, ecliptic_lat,
  35. sign, degree_within_sign, retrograde flag, speed_lon, and distance.
  36. """
  37. resolved_lat = lat if lat is not None else 0.0
  38. resolved_lon = lon if lon is not None else 0.0
  39. sky = await call_sky_state(
  40. datetime=datetime,
  41. lat=resolved_lat,
  42. lon=resolved_lon,
  43. elevation=elevation,
  44. geocentric=geocentric,
  45. )
  46. if "error" in sky:
  47. return {"input": {"datetime": datetime, "lat": resolved_lat, "lon": resolved_lon}, "error": sky["error"]}
  48. raw_bodies = extract_bodies(sky)
  49. enhanced_bodies = []
  50. for body in raw_bodies:
  51. name = body.get("body", "unknown")
  52. if bodies and name not in bodies:
  53. continue
  54. ecl_lon = body.get("ecliptic_lon", 0.0)
  55. ecl_lat = body.get("ecliptic_lat", 0.0)
  56. speed_lon = body.get("speed_lon")
  57. distance_au = body.get("distance_au", 0.0)
  58. zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
  59. retrograde = astrology.is_retrograde(speed_lon)
  60. enhanced_bodies.append({
  61. "body": name,
  62. "ecliptic_lon": ecl_lon,
  63. "ecliptic_lat": ecl_lat,
  64. "distance_au": distance_au,
  65. "speed_lon": speed_lon,
  66. "sign": zodiac["sign"],
  67. "sign_abbreviation": zodiac["abbreviation"],
  68. "degree_within_sign": zodiac["degree"],
  69. "retrograde": retrograde,
  70. })
  71. return {
  72. "input": {
  73. "datetime": datetime,
  74. "lat": resolved_lat,
  75. "lon": resolved_lon,
  76. "elevation": elevation,
  77. "geocentric": geocentric,
  78. "bodies_filter": bodies,
  79. },
  80. "timestamp_utc": sky.get("timestamp_utc"),
  81. "julian_day": sky.get("julian_day"),
  82. "bodies": enhanced_bodies,
  83. }
  84. # ── Tool: calculate_natal_chart ──────────────────────────────────────
  85. @mcp.tool()
  86. async def calculate_natal_chart(
  87. birth_datetime: str,
  88. latitude: float,
  89. longitude: float,
  90. elevation: float = 0.0,
  91. house_system: str = "placidus",
  92. orb_limits: dict[str, float] | None = None,
  93. ) -> dict[str, Any]:
  94. """Calculate a complete natal chart from birth data.
  95. Args:
  96. birth_datetime: ISO 8601 birth datetime (UTC).
  97. latitude: Birth latitude in decimal degrees.
  98. longitude: Birth longitude in decimal degrees.
  99. elevation: Birth elevation in meters.
  100. house_system: House system to use (default: Placidus).
  101. orb_limits: Optional dict of {aspect_name: max_orb_degrees}.
  102. Returns:
  103. Complete natal chart structure with planets, houses, aspects, and angles.
  104. """
  105. sky = await call_sky_state(
  106. datetime=birth_datetime,
  107. lat=latitude,
  108. lon=longitude,
  109. elevation=elevation,
  110. geocentric=True,
  111. )
  112. if "error" in sky:
  113. return {"input": {"birth_datetime": birth_datetime, "latitude": latitude, "longitude": longitude}, "error": sky["error"]}
  114. raw_bodies = extract_bodies(sky)
  115. sidereal = sky.get("sidereal_time", {})
  116. lst_hours = sidereal.get("local_sidereal_time", 0.0)
  117. # Calculate houses
  118. houses = astrology.calculate_houses(lst_hours, latitude, house_system)
  119. # Build planet list with house placement
  120. planets = []
  121. for body in raw_bodies:
  122. ecl_lon = body.get("ecliptic_lon", 0.0)
  123. ecl_lat = body.get("ecliptic_lat", 0.0)
  124. speed_lon = body.get("speed_lon")
  125. zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
  126. house = astrology.get_house_placement(ecl_lon, houses)
  127. planets.append({
  128. "body": body["body"],
  129. "sign": zodiac["sign"],
  130. "sign_abbreviation": zodiac["abbreviation"],
  131. "degree_within_sign": zodiac["degree"],
  132. "absolute_lon": zodiac["absolute_lon"],
  133. "ecliptic_lat": ecl_lat,
  134. "distance_au": body.get("distance_au", 0.0),
  135. "house": house,
  136. "retrograde": astrology.is_retrograde(speed_lon),
  137. })
  138. # Calculate aspects
  139. aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in planets]
  140. aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
  141. # Format aspects
  142. formatted_aspects = []
  143. for asp in aspects:
  144. formatted_aspects.append({
  145. "body1": asp["body1"],
  146. "body2": asp["body2"],
  147. "aspect": asp["aspect"],
  148. "orb": asp["orb"],
  149. "applying": asp["applying"],
  150. "exactness": asp["exactness"],
  151. })
  152. # Calculate angles
  153. angles = astrology.calculate_angles(lst_hours, latitude)
  154. return {
  155. "input": {
  156. "birth_datetime": birth_datetime,
  157. "latitude": latitude,
  158. "longitude": longitude,
  159. "elevation": elevation,
  160. "house_system": house_system,
  161. "orb_limits": orb_limits,
  162. },
  163. "chart_type": "natal",
  164. "planets": planets,
  165. "houses": houses,
  166. "aspects": formatted_aspects,
  167. "angles": angles,
  168. }
  169. # ── Tool: calculate_transit_chart ────────────────────────────────────
  170. @mcp.tool()
  171. async def calculate_transit_chart(
  172. birth_datetime: str,
  173. transit_datetime: str,
  174. latitude: float,
  175. longitude: float,
  176. elevation: float = 0.0,
  177. house_system: str = "placidus",
  178. orb_limits: dict[str, float] | None = None,
  179. ) -> dict[str, Any]:
  180. """Calculate a transit chart: transiting planets vs natal positions.
  181. Args:
  182. birth_datetime: ISO 8601 birth datetime (UTC).
  183. transit_datetime: ISO 8601 transit datetime (UTC).
  184. latitude: Birth latitude in decimal degrees.
  185. longitude: Birth longitude in decimal degrees.
  186. elevation: Birth elevation in meters.
  187. house_system: House system for natal houses (default: Placidus).
  188. orb_limits: Optional orb configuration.
  189. Returns:
  190. Transit chart with transiting planets, aspects to natal, and house placements.
  191. """
  192. # Get natal sky state
  193. natal_sky = await call_sky_state(
  194. datetime=birth_datetime,
  195. lat=latitude,
  196. lon=longitude,
  197. elevation=elevation,
  198. geocentric=True,
  199. )
  200. # Get transit sky state at birth location
  201. transit_sky = await call_sky_state(
  202. datetime=transit_datetime,
  203. lat=latitude,
  204. lon=longitude,
  205. elevation=elevation,
  206. geocentric=True,
  207. )
  208. if "error" in natal_sky:
  209. return {"error": f"natal: {natal_sky['error']}"}
  210. if "error" in transit_sky:
  211. return {"error": f"transit: {transit_sky['error']}"}
  212. natal_bodies = extract_bodies(natal_sky)
  213. transit_bodies = extract_bodies(transit_sky)
  214. # Natal houses from natal LST
  215. sidereal = natal_sky.get("sidereal_time", {})
  216. lst_hours = sidereal.get("local_sidereal_time", 0.0)
  217. houses = astrology.calculate_houses(lst_hours, latitude, house_system)
  218. # Build natal planets
  219. natal_planets = []
  220. for body in natal_bodies:
  221. ecl_lon = body.get("ecliptic_lon", 0.0)
  222. zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
  223. house = astrology.get_house_placement(ecl_lon, houses)
  224. natal_planets.append({
  225. "body": body["body"],
  226. "sign": zodiac["sign"],
  227. "degree_within_sign": zodiac["degree"],
  228. "absolute_lon": zodiac["absolute_lon"],
  229. "house": house,
  230. "retrograde": astrology.is_retrograde(body.get("speed_lon")),
  231. })
  232. # Build transit planets
  233. transit_planets = []
  234. for body in transit_bodies:
  235. ecl_lon = body.get("ecliptic_lon", 0.0)
  236. zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
  237. transit_house = astrology.get_house_placement(ecl_lon, houses)
  238. transit_planets.append({
  239. "body": body["body"],
  240. "sign": zodiac["sign"],
  241. "degree_within_sign": zodiac["degree"],
  242. "absolute_lon": zodiac["absolute_lon"],
  243. "natal_house": transit_house,
  244. "retrograde": astrology.is_retrograde(body.get("speed_lon")),
  245. })
  246. # Transit-to-natal aspects
  247. transit_aspects = []
  248. for t_body in transit_planets:
  249. for n_body in natal_planets:
  250. pair = [
  251. {"name": f"transit_{t_body['body']}", "lon": t_body["absolute_lon"], "speed_lon": None},
  252. {"name": f"natal_{n_body['body']}", "lon": n_body["absolute_lon"], "speed_lon": None},
  253. ]
  254. pair_aspects = astrology.compute_aspects(pair, orb_limits)
  255. for asp in pair_aspects:
  256. transit_aspects.append({
  257. "transiting": t_body["body"],
  258. "natal": n_body["body"],
  259. "aspect": asp["aspect"],
  260. "orb": asp["orb"],
  261. "exactness": asp["exactness"],
  262. })
  263. transit_aspects.sort(key=lambda a: a["orb"])
  264. return {
  265. "input": {
  266. "birth_datetime": birth_datetime,
  267. "transit_datetime": transit_datetime,
  268. "latitude": latitude,
  269. "longitude": longitude,
  270. "house_system": house_system,
  271. },
  272. "chart_type": "transit",
  273. "natal_planets": natal_planets,
  274. "transiting_planets": transit_planets,
  275. "aspects": transit_aspects,
  276. "houses": houses,
  277. }
  278. # ── Tool: calculate_synastry_chart ───────────────────────────────────
  279. @mcp.tool()
  280. async def calculate_synastry_chart(
  281. person1_datetime: str,
  282. person1_latitude: float,
  283. person1_longitude: float,
  284. person2_datetime: str,
  285. person2_latitude: float,
  286. person2_longitude: float,
  287. elevation: float = 0.0,
  288. house_system: str = "placidus",
  289. orb_limits: dict[str, float] | None = None,
  290. ) -> dict[str, Any]:
  291. """Calculate a synastry (relationship) chart for two people.
  292. Args:
  293. person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
  294. person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
  295. elevation: Birth elevation in meters.
  296. house_system: House system (default: Placidus).
  297. orb_limits: Optional orb configuration.
  298. Returns:
  299. Synastry chart with interaspects, house overlays, composite, and Davison charts.
  300. """
  301. sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, elevation=elevation)
  302. sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, elevation=elevation)
  303. if "error" in sky1:
  304. return {"error": f"person1: {sky1['error']}"}
  305. if "error" in sky2:
  306. return {"error": f"person2: {sky2['error']}"}
  307. bodies1 = extract_bodies(sky1)
  308. bodies2 = extract_bodies(sky2)
  309. sidereal1 = sky1.get("sidereal_time", {})
  310. lst1 = sidereal1.get("local_sidereal_time", 0.0)
  311. houses1 = astrology.calculate_houses(lst1, person1_latitude, house_system)
  312. sidereal2 = sky2.get("sidereal_time", {})
  313. lst2 = sidereal2.get("local_sidereal_time", 0.0)
  314. houses2 = astrology.calculate_houses(lst2, person2_latitude, house_system)
  315. def build_planet_list(bodies):
  316. result = []
  317. for b in bodies:
  318. ecl_lon = b.get("ecliptic_lon", 0.0)
  319. z = astrology.ecliptic_to_zodiac(ecl_lon)
  320. result.append({
  321. "body": b["body"],
  322. "sign": z["sign"],
  323. "degree_within_sign": z["degree"],
  324. "absolute_lon": z["absolute_lon"],
  325. "retrograde": astrology.is_retrograde(b.get("speed_lon")),
  326. })
  327. return result
  328. chart1_planets = build_planet_list(bodies1)
  329. chart2_planets = build_planet_list(bodies2)
  330. # Interaspects
  331. interaspects = []
  332. for p1 in chart1_planets:
  333. for p2 in chart2_planets:
  334. pair = [
  335. {"name": f"p1_{p1['body']}", "lon": p1["absolute_lon"]},
  336. {"name": f"p2_{p2['body']}", "lon": p2["absolute_lon"]},
  337. ]
  338. for asp in astrology.compute_aspects(pair, orb_limits):
  339. interaspects.append({
  340. "person1_planet": p1["body"],
  341. "person2_planet": p2["body"],
  342. "aspect": asp["aspect"],
  343. "orb": asp["orb"],
  344. "exactness": asp["exactness"],
  345. })
  346. interaspects.sort(key=lambda a: a["orb"])
  347. # House overlays: person2's planets in person1's houses
  348. p2_in_p1_houses = []
  349. for p2 in chart2_planets:
  350. house = astrology.get_house_placement(p2["absolute_lon"], houses1)
  351. p2_in_p1_houses.append({
  352. "planet": p2["body"],
  353. "house": house,
  354. })
  355. p1_in_p2_houses = []
  356. for p1 in chart1_planets:
  357. house = astrology.get_house_placement(p1["absolute_lon"], houses2)
  358. p1_in_p2_houses.append({
  359. "planet": p1["body"],
  360. "house": house,
  361. })
  362. # Composite chart (midpoint method)
  363. composite_bodies = astrology.compute_composite_chart(
  364. [{"name": p["body"], "lon": p["absolute_lon"]} for p in chart1_planets],
  365. [{"name": p["body"], "lon": p["absolute_lon"]} for p in chart2_planets],
  366. )
  367. composite_planets = []
  368. for cb in composite_bodies:
  369. z = astrology.ecliptic_to_zodiac(cb["lon"])
  370. composite_planets.append({
  371. "body": cb["name"],
  372. "sign": z["sign"],
  373. "degree_within_sign": z["degree"],
  374. "absolute_lon": z["absolute_lon"],
  375. })
  376. # Davison chart (date midpoint)
  377. davison = astrology.compute_davison_chart(person1_datetime, person2_datetime, person1_datetime, person2_datetime)
  378. davison_mid_lat = (person1_latitude + person2_latitude) / 2
  379. davison_mid_lon = (person1_longitude + person2_longitude) / 2
  380. return {
  381. "input": {
  382. "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
  383. "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
  384. "house_system": house_system,
  385. },
  386. "chart_type": "synastry",
  387. "chart1_natal": {"planets": chart1_planets, "houses": houses1},
  388. "chart2_natal": {"planets": chart2_planets, "houses": houses2},
  389. "interaspects": interaspects,
  390. "house_overlays": {
  391. "person2_in_person1_houses": p2_in_p1_houses,
  392. "person1_in_person2_houses": p1_in_p2_houses,
  393. },
  394. "composite_chart": {"planets": composite_planets},
  395. "davison_chart": {
  396. "date_midpoint_jd": davison["date_midpoint_jd"],
  397. "latitude_midpoint": davison_mid_lat,
  398. "longitude_midpoint": davison_mid_lon,
  399. },
  400. }
  401. # ── Tool: get_transit_preview ────────────────────────────────────────
  402. @mcp.tool()
  403. async def get_transit_preview(
  404. person_id: str,
  405. start_date: str,
  406. end_date: str,
  407. event_types: list[str] | None = None,
  408. orb_limits: dict[str, float] | None = None,
  409. ) -> dict[str, Any]:
  410. """Preview significant transit events for a person over a time range.
  411. Args:
  412. person_id: ID of a person in the persons database.
  413. start_date: ISO date string for the start of the range.
  414. end_date: ISO date string for the end of the range.
  415. event_types: Optional filter for event types
  416. (exact_aspect, ingress, retrograde_station, lunar_phase).
  417. orb_limits: Optional orb configuration for aspect detection.
  418. Returns:
  419. List of transit events with timestamps, descriptions, and orbs.
  420. """
  421. return {
  422. "input": {
  423. "person_id": person_id,
  424. "start_date": start_date,
  425. "end_date": end_date,
  426. "event_types": event_types,
  427. },
  428. "events": [],
  429. "_note": "Transit preview not yet implemented -- requires person database (Phase 4)",
  430. }
  431. # ── Tool: person_manage ─────────────────────────────────────────────
  432. @mcp.tool()
  433. async def person_manage(
  434. action: str,
  435. person_id: str | None = None,
  436. name: str | None = None,
  437. nickname: str | None = None,
  438. birth_datetime: str | None = None,
  439. latitude: float | None = None,
  440. longitude: float | None = None,
  441. elevation: float | None = None,
  442. ) -> dict[str, Any]:
  443. """Manage persons in the birth data database.
  444. Args:
  445. action: One of: add, get, list, update, delete.
  446. person_id: Required for get, update, delete.
  447. name: Person's full name (required for add).
  448. nickname: Optional short name for quick lookup.
  449. birth_datetime: ISO 8601 UTC datetime (required for add).
  450. latitude: Birth latitude (required for add).
  451. longitude: Birth longitude (required for add).
  452. elevation: Birth elevation in meters.
  453. Returns:
  454. Operation result with person data or error.
  455. """
  456. from . import storage
  457. action = action.lower().strip()
  458. if action == "add":
  459. if not name or not birth_datetime or latitude is None or longitude is None:
  460. return {"error": "add requires: name, birth_datetime, latitude, longitude"}
  461. person = await storage.add_person(
  462. name=name,
  463. birth_datetime=birth_datetime,
  464. latitude=latitude,
  465. longitude=longitude,
  466. elevation=elevation if elevation is not None else 0.0,
  467. nickname=nickname,
  468. )
  469. return {"action": "add", "person": person}
  470. elif action == "get":
  471. if not person_id and not nickname:
  472. return {"error": "get requires: person_id or nickname"}
  473. person = await storage.get_person(person_id=person_id, nickname=nickname)
  474. if not person:
  475. return {"action": "get", "error": "not_found"}
  476. return {"action": "get", "person": person}
  477. elif action == "list":
  478. persons = await storage.list_persons()
  479. return {"action": "list", "persons": persons, "count": len(persons)}
  480. elif action == "update":
  481. if not person_id:
  482. return {"error": "update requires: person_id"}
  483. person = await storage.update_person(
  484. person_id=person_id,
  485. name=name,
  486. nickname=nickname,
  487. birth_datetime=birth_datetime,
  488. latitude=latitude,
  489. longitude=longitude,
  490. elevation=elevation,
  491. )
  492. if not person:
  493. return {"action": "update", "error": "not_found"}
  494. return {"action": "update", "person": person}
  495. elif action == "delete":
  496. if not person_id:
  497. return {"error": "delete requires: person_id"}
  498. deleted = await storage.delete_person(person_id)
  499. if not deleted:
  500. return {"action": "delete", "error": "not_found"}
  501. return {"action": "delete", "deleted": True, "person_id": person_id}
  502. else:
  503. return {"error": f"unknown action: {action}. Use: add, get, list, update, delete"}
  504. # ── Tool: list_house_systems ─────────────────────────────────────────
  505. @mcp.tool()
  506. def list_house_systems() -> dict[str, Any]:
  507. """List supported house systems.
  508. Returns:
  509. Object with 'systems' array, each containing name and description.
  510. """
  511. return {
  512. "systems": [
  513. {"id": "placidus", "name": "Placidus", "description": "Most common system; houses based on time divisions of the diurnal arc. Default."},
  514. {"id": "equal", "name": "Equal House", "description": "Each house is exactly 30 degrees, starting from the ASC."},
  515. {"id": "whole_sign", "name": "Whole Sign", "description": "Each house corresponds to one full sign. The ASC sign is house 1."},
  516. ]
  517. }