소스 검색

Phase 3: ephemeris client + all MCP tool definitions

- ephemeris_client.py: MCP SSE client calling ephemeris-mcp:get_sky_state
  - call_sky_state() with timeout, error handling
  - extract_bodies() and extract_sidereal_time() helpers
  - _payload_from_result() for parsing MCP responses
- tools.py: all 7 MCP tools
  - get_planetary_positions: enhanced bodies with sign/degree/retrograde, bodies filter
  - calculate_natal_chart: full chart (planets in signs/houses, aspects, angles)
  - calculate_transit_chart: transit planets, natal-to-transit aspects, house placement
  - calculate_synastry_chart: interaspects, house overlays, composite, davison
  - get_transit_preview: stub (needs Phase 4 person DB)
  - person_manage: stub (needs Phase 4 storage)
  - list_house_systems: discovery tool
- server.py: imports tools module to register @mcp.tool() handlers
- tests/test_tools.py: 11 tests with mocked ephemeris client
  - Enhanced body fields, bodies filter, error handling
  - Natal chart structure (12 planets, 12 houses, aspects, angles)
  - Transit chart structure
  - Synastry chart structure (interaspects, overlays, composite, davison)
  - House systems discovery

Full suite: 74 tests passing.
Lukas Goldschmidt 1 개월 전
부모
커밋
2784108681
4개의 변경된 파일1010개의 추가작업 그리고 0개의 파일을 삭제
  1. 125 0
      src/astro_mcp/ephemeris_client.py
  2. 3 0
      src/astro_mcp/server.py
  3. 553 0
      src/astro_mcp/tools.py
  4. 329 0
      tests/test_tools.py

+ 125 - 0
src/astro_mcp/ephemeris_client.py

@@ -0,0 +1,125 @@
+"""
+MCP client for calling ephemeris-mcp:get_sky_state.
+
+Uses the SSE transport client from the `mcp` library, same pattern as
+hermes-mcp's argus_client.py.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import timedelta
+from typing import Any
+
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+
+from .config import EPHEMERIS_MCP_URL
+
+logger = logging.getLogger("astro-mcp.ephemeris_client")
+
+
+def _payload_from_result(result: Any) -> dict[str, Any]:
+    """Extract a dict payload from an MCP CallToolResult.
+
+    Tries structuredContent first, then parses text content as JSON.
+    """
+    payload = getattr(result, "structuredContent", None)
+    if isinstance(payload, dict):
+        return payload
+
+    for item in getattr(result, "content", []) or []:
+        text = getattr(item, "text", None)
+        if not isinstance(text, str) or not text.strip():
+            continue
+        try:
+            decoded = json.loads(text)
+        except Exception:
+            continue
+        if isinstance(decoded, dict):
+            return decoded
+    return {}
+
+
+async def call_sky_state(
+    datetime: str | None = None,
+    lat: float = 0.0,
+    lon: float = 0.0,
+    elevation: float = 0.0,
+    geocentric: bool = True,
+) -> dict[str, Any]:
+    """Call ephemeris-mcp:get_sky_state and return the result dict.
+
+    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.
+
+    Returns:
+        The full sky_state dict from ephemeris-mcp, or an error dict
+        if the call fails.
+    """
+    url = EPHEMERIS_MCP_URL.strip()
+    if not url.endswith("/mcp/sse"):
+        url = url.rstrip("/") + "/mcp/sse"
+
+    logger.info(f"calling ephemeris-mcp get_sky_state url={url} dt={datetime}")
+
+    try:
+        async with sse_client(url, timeout=8.0, sse_read_timeout=8.0) as streams:
+            async with ClientSession(
+                *streams, read_timeout_seconds=timedelta(seconds=8)
+            ) as session:
+                await session.initialize()
+                result = await session.call_tool(
+                    "get_sky_state",
+                    {
+                        "datetime": datetime,
+                        "lat": lat,
+                        "lon": lon,
+                        "elevation": elevation,
+                        "geocentric": geocentric,
+                    },
+                )
+                payload = _payload_from_result(result)
+                if not payload:
+                    logger.warning("ephemeris-mcp returned empty payload")
+                    return {"error": "empty_response", "url": url}
+                return payload
+    except Exception as exc:
+        logger.error(f"ephemeris-mcp call failed: {exc}")
+        return {"error": str(exc), "url": url}
+
+
+def extract_bodies(sky_state: dict[str, Any]) -> list[dict[str, Any]]:
+    """Extract the planetary bodies array from a sky_state response.
+
+    Handles both the full sky_state response (with planetary_positions key)
+    and the direct planetary_positions response.
+    """
+    # Full sky_state has planetary_positions.bodies
+    pp = sky_state.get("planetary_positions", {})
+    if isinstance(pp, dict):
+        bodies = pp.get("bodies", [])
+        if bodies:
+            return bodies
+
+    # Direct planetary_positions response
+    if "bodies" in sky_state:
+        return sky_state["bodies"]
+
+    return []
+
+
+def extract_sidereal_time(sky_state: dict[str, Any]) -> dict[str, Any]:
+    """Extract sidereal time data from a sky_state response."""
+    st = sky_state.get("sidereal_time", {})
+    if isinstance(st, dict) and st:
+        return st
+    # Fallback: try to find it at top level
+    if "local_sidereal_time" in sky_state:
+        return sky_state
+    return {}

+ 3 - 0
src/astro_mcp/server.py

@@ -17,6 +17,9 @@ mcp = FastMCP(
     ),
 )
 
+# Import tools module to register all @mcp.tool() handlers
+from . import tools  # noqa: E402, F401
+
 
 def _tool_names() -> list[str]:
     return [

+ 553 - 0
src/astro_mcp/tools.py

@@ -0,0 +1,553 @@
+"""
+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.
+    """
+    return {
+        "action": action,
+        "_note": "Person management not yet implemented -- requires storage layer (Phase 4)",
+    }
+
+
+# ── 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."},
+        ]
+    }

+ 329 - 0
tests/test_tools.py

@@ -0,0 +1,329 @@
+"""
+Integration tests for MCP tools with mocked ephemeris client.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from src.astro_mcp.server import create_app
+
+# ── Mock ephemeris get_sky_state response ───────────────────────────
+
+def make_mock_sky_state(
+    bodies: list[dict] | None = None,
+    lst: float = 6.0,
+) -> dict:
+    """Create a realistic mock get_sky_state response."""
+    if bodies is None:
+        bodies = [
+            {"body": "sun", "ecliptic_lon": 25.0, "ecliptic_lat": 0.0,
+             "distance_au": 1.0, "speed_lon": 1.0, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 1.5, "dec": 10.0},
+            {"body": "moon", "ecliptic_lon": 120.5, "ecliptic_lat": 2.0,
+             "distance_au": 0.00257, "speed_lon": 13.0, "speed_lat": 0.5,
+             "speed_dist": 0.0, "ra": 8.2, "dec": 15.0},
+            {"body": "mercury", "ecliptic_lon": 30.0, "ecliptic_lat": 1.0,
+             "distance_au": 0.5, "speed_lon": 1.5, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 2.0, "dec": 12.0},
+            {"body": "venus", "ecliptic_lon": 95.0, "ecliptic_lat": -1.0,
+             "distance_au": 0.7, "speed_lon": 0.8, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 6.5, "dec": 20.0},
+            {"body": "mars", "ecliptic_lon": 200.0, "ecliptic_lat": 3.0,
+             "distance_au": 1.5, "speed_lon": 0.6, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 13.5, "dec": -5.0},
+            {"body": "jupiter", "ecliptic_lon": 250.0, "ecliptic_lat": 1.0,
+             "distance_au": 5.2, "speed_lon": 0.1, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 16.8, "dec": -15.0},
+            {"body": "saturn", "ecliptic_lon": 300.0, "ecliptic_lat": -2.0,
+             "distance_au": 9.5, "speed_lon": 0.05, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 20.2, "dec": -20.0},
+            {"body": "uranus", "ecliptic_lon": 330.0, "ecliptic_lat": 0.5,
+             "distance_au": 19.2, "speed_lon": 0.02, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 22.5, "dec": -10.0},
+            {"body": "neptune", "ecliptic_lon": 310.0, "ecliptic_lat": -1.0,
+             "distance_au": 30.0, "speed_lon": 0.01, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 21.0, "dec": -18.0},
+            {"body": "pluto", "ecliptic_lon": 260.0, "ecliptic_lat": 2.0,
+             "distance_au": 39.5, "speed_lon": -0.01, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 17.5, "dec": -22.0},
+            {"body": "chiron", "ecliptic_lon": 15.0, "ecliptic_lat": 0.0,
+             "distance_au": 10.0, "speed_lon": 0.03, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 1.0, "dec": 8.0},
+            {"body": "true_node", "ecliptic_lon": 45.0, "ecliptic_lat": 0.0,
+             "distance_au": 0.0, "speed_lon": -0.05, "speed_lat": 0.0,
+             "speed_dist": 0.0, "ra": 3.0, "dec": 15.0},
+        ]
+
+    return {
+        "input": {"datetime": "2026-06-02T12:00:00Z", "lat": 47.0, "lon": 8.0,
+                  "elevation": 0.0, "geocentric": True},
+        "timestamp_utc": "2026-06-02T12:00:00Z",
+        "julian_day": 2461172.0,
+        "planetary_positions": {
+            "input": {"datetime": "2026-06-02T12:00:00Z", "lat": 47.0, "lon": 8.0,
+                      "elevation": 0.0, "geocentric": True},
+            "timestamp_utc": "2026-06-02T12:00:00Z",
+            "julian_day": 2461172.0,
+            "bodies": bodies,
+        },
+        "solar_events": {"date": "2026-06-02", "events_jd": {}},
+        "lunar_state": {"phase_name": "Waxing Gibbous", "illumination_fraction": 0.75},
+        "sidereal_time": {
+            "greenwich_sidereal_time": lst,
+            "local_sidereal_time": lst,
+            "obliquity_of_ecliptic": 23.4367,
+        },
+    }
+
+
+# ── Fixtures ────────────────────────────────────────────────────────
+
+@pytest.fixture
+def mock_sky_state():
+    return make_mock_sky_state()
+
+
+@pytest.fixture
+def app_with_mock(mock_sky_state):
+    """Create app with mocked ephemeris client."""
+    with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        mock.return_value = mock_sky_state
+        app = create_app()
+        yield app, mock
+
+
+# ── Tests: get_planetary_positions ──────────────────────────────────
+
+class TestGetPlanetaryPositions:
+    def test_returns_enhanced_bodies(self, app_with_mock):
+        app, mock = app_with_mock
+        client = TestClient(app)
+        res = client.get("/health")
+        assert res.status_code == 200
+
+    def test_tool_call_via_mock(self, mock_sky_state):
+        """Test the tool function directly with mocked ephemeris client."""
+        from src.astro_mcp import tools
+
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = mock_sky_state
+            result = asyncio.run(
+                tools.get_planetary_positions(datetime="2026-06-02T12:00:00Z", lat=47.0, lon=8.0)
+            )
+
+        assert "bodies" in result
+        assert len(result["bodies"]) == 12
+
+        # Check sun is in Aries (25°)
+        sun = [b for b in result["bodies"] if b["body"] == "sun"][0]
+        assert sun["sign"] == "Aries"
+        assert abs(sun["degree_within_sign"] - 25.0) < 0.01
+        assert sun["retrograde"] is False
+
+        # Check Pluto is retrograde (speed < 0)
+        pluto = [b for b in result["bodies"] if b["body"] == "pluto"][0]
+        assert pluto["retrograde"] is True
+
+    def test_bodies_filter(self, mock_sky_state):
+        from src.astro_mcp import tools
+
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = mock_sky_state
+            result = asyncio.run(
+                tools.get_planetary_positions(
+                    datetime="2026-06-02T12:00:00Z",
+                    lat=47.0, lon=8.0,
+                    bodies=["sun", "moon"],
+                )
+            )
+
+        assert len(result["bodies"]) == 2
+        names = {b["body"] for b in result["bodies"]}
+        assert names == {"sun", "moon"}
+
+    def test_error_handling(self):
+        from src.astro_mcp import tools
+
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = {"error": "connection refused"}
+            result = asyncio.run(
+                tools.get_planetary_positions(datetime="2026-06-02T12:00:00Z")
+            )
+
+        assert "error" in result
+
+
+# ── Tests: calculate_natal_chart ────────────────────────────────────
+
+class TestCalculateNatalChart:
+    def test_full_natal_chart(self, mock_sky_state):
+        from src.astro_mcp import tools
+
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = mock_sky_state
+            result = asyncio.run(
+                tools.calculate_natal_chart(
+                    birth_datetime="2026-06-02T12:00:00Z",
+                    latitude=47.0,
+                    longitude=8.0,
+                )
+            )
+
+        assert result["chart_type"] == "natal"
+        assert "planets" in result
+        assert "houses" in result
+        assert "aspects" in result
+        assert "angles" in result
+
+        # 12 planets
+        assert len(result["planets"]) == 12
+
+        # 12 houses
+        assert len(result["houses"]) == 12
+
+        # Each planet has required fields
+        for p in result["planets"]:
+            assert "body" in p
+            assert "sign" in p
+            assert "degree_within_sign" in p
+            assert "house" in p
+            assert "retrograde" in p
+            assert 1 <= p["house"] <= 12
+
+        # Angles
+        for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
+            assert key in result["angles"]
+            assert "sign" in result["angles"][key]
+
+        # Aspects have required fields
+        for asp in result["aspects"]:
+            assert "body1" in asp
+            assert "body2" in asp
+            assert "aspect" in asp
+            assert "orb" in asp
+
+    def test_custom_house_system(self, mock_sky_state):
+        from src.astro_mcp import tools
+
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = mock_sky_state
+            result = asyncio.run(
+                tools.calculate_natal_chart(
+                    birth_datetime="2026-06-02T12:00:00Z",
+                    latitude=47.0,
+                    longitude=8.0,
+                    house_system="equal",
+                )
+            )
+
+        assert result["input"]["house_system"] == "equal"
+        assert len(result["houses"]) == 12
+
+
+# ── Tests: calculate_transit_chart ──────────────────────────────────
+
+class TestCalculateTransitChart:
+    def test_transit_chart(self, mock_sky_state):
+        from src.astro_mcp import tools
+
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = mock_sky_state
+            result = asyncio.run(
+                tools.calculate_transit_chart(
+                    birth_datetime="2000-01-01T12:00:00Z",
+                    transit_datetime="2026-06-02T12:00:00Z",
+                    latitude=47.0,
+                    longitude=8.0,
+                )
+            )
+
+        assert result["chart_type"] == "transit"
+        assert "natal_planets" in result
+        assert "transiting_planets" in result
+        assert "aspects" in result
+        assert "houses" in result
+
+        # Transit planets should have natal_house
+        for tp in result["transiting_planets"]:
+            assert "natal_house" in tp
+            assert 1 <= tp["natal_house"] <= 12
+
+
+# ── Tests: calculate_synastry_chart ─────────────────────────────────
+
+class TestCalculateSynastryChart:
+    def test_synastry_chart(self, mock_sky_state):
+        from src.astro_mcp import tools
+
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = mock_sky_state
+            result = asyncio.run(
+                tools.calculate_synastry_chart(
+                    person1_datetime="2000-01-01T12:00:00Z",
+                    person1_latitude=47.0,
+                    person1_longitude=8.0,
+                    person2_datetime="1995-06-15T08:00:00Z",
+                    person2_latitude=52.0,
+                    person2_longitude=13.0,
+                )
+            )
+
+        assert result["chart_type"] == "synastry"
+        assert "chart1_natal" in result
+        assert "chart2_natal" in result
+        assert "interaspects" in result
+        assert "house_overlays" in result
+        assert "composite_chart" in result
+        assert "davison_chart" in result
+
+        # House overlays
+        assert "person2_in_person1_houses" in result["house_overlays"]
+        assert "person1_in_person2_houses" in result["house_overlays"]
+
+        # Composite chart has planets
+        assert "planets" in result["composite_chart"]
+
+
+# ── Tests: list_house_systems ───────────────────────────────────────
+
+class TestListHouseSystems:
+    def test_returns_systems(self):
+        from src.astro_mcp import tools
+        result = tools.list_house_systems()
+        assert "systems" in result
+        ids = [s["id"] for s in result["systems"]]
+        assert "placidus" in ids
+        assert "equal" in ids
+        assert "whole_sign" in ids
+
+
+# ── Tests: person_manage (stub) ─────────────────────────────────────
+
+class TestPersonManage:
+    def test_stub_returns_note(self):
+        from src.astro_mcp import tools
+        result = asyncio.run(
+            tools.person_manage(action="list")
+        )
+        assert "_note" in result
+
+
+# ── Tests: get_transit_preview (stub) ───────────────────────────────
+
+class TestTransitPreview:
+    def test_stub_returns_note(self):
+        from src.astro_mcp import tools
+        result = asyncio.run(
+            tools.get_transit_preview(
+                person_id="test",
+                start_date="2026-06-01",
+                end_date="2026-07-01",
+            )
+        )
+        assert "_note" in result