1
0

2 Коммиты 4632ec1e0b ... 83fe5496e1

Автор SHA1 Сообщение Дата
  Lukas Goldschmidt 83fe5496e1 refactored tools.py 1 месяц назад
  Lukas Goldschmidt e9621ea25c fixed top left text block 1 месяц назад

+ 460 - 0
src/astro_mcp/by_id_tools.py

@@ -0,0 +1,460 @@
+"""
+Database-backed chart tools for astro-mcp.
+
+_byId variants of chart calculation tools. These look up birth data from
+the persons database, convert naive local time to UTC, and delegate to
+the direct-call functions in chart_tools.py.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from .server import mcp
+from . import storage
+from .ephemeris_client import _normalize_datetime
+from .chart_tools import (
+    calculate_natal_chart,
+    calculate_transit_chart,
+    calculate_synastry_chart,
+    calculate_composite_chart,
+    calculate_davison_chart,
+    get_transit_preview,
+)
+
+logger = logging.getLogger("astro-mcp.tools")
+
+# ── Tool: get_karmic_relationship_summary ─────────────────────────────
+
+@mcp.tool()
+async def get_karmic_relationship_summary(
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+) -> dict[str, Any]:
+    """Generate a karmic relationship summary from synastry, composite, and Davison charts.
+
+Combines karmic indicators across all three relationship chart layers:
+- Synastry: Saturn/Pluto/Node interchart aspects
+- Composite: Saturn, Pluto, Node positions
+- Davison: Saturn, Pluto, Node positions
+
+This is the PRIMARY tool for karmic relationship analysis. It consolidates
+the key indicators into a single structured report with a karmic weight score.
+
+For interpretation guidance, fetch resources:
+- astro://guides/karmic-astrology
+- astro://guides/relationship-astrology
+
+Args:
+    person1_id: ID or nickname of person 1 in the persons database.
+    person2_id: ID or nickname of person 2 in the persons database.
+    house_system: House system (default: Placidus).
+
+Returns:
+    Dict with: karmic_weight (composite score), synastry_karmic_aspects,
+    composite_karmic_planets, davison_karmic_planets, summary
+    (saturn/pluto/node contact counts)."""
+    # Get synastry with karmic filter
+    synastry = await calculate_synastry_chart_by_id(
+        person1_id, person2_id,
+        house_system=house_system,
+        karmic_filter=True,
+    )
+    if "error" in synastry:
+        return synastry
+
+    # Get composite chart
+    composite = await calculate_composite_chart_by_id(
+        person1_id, person2_id,
+        house_system=house_system,
+    )
+    if "error" in composite:
+        return composite
+
+    # Get Davison chart
+    davison = await calculate_davison_chart_by_id(
+        person1_id, person2_id,
+        house_system=house_system,
+    )
+    if "error" in davison:
+        return davison
+
+    def _extract_karmic_planets(chart_data: dict, key: str) -> dict:
+        """Extract Saturn, Pluto, Node from a chart's planet list."""
+        result = {}
+        for p in chart_data.get(key, []):
+            if p["body"] in ("saturn", "pluto", "true_node"):
+                result[p["body"]] = {
+                    "sign": p.get("sign"),
+                    "house": p.get("house"),
+                    "retrograde": p.get("retrograde"),
+                }
+        return result
+
+    synastry_karmic_aspects = synastry.get("interaspects", [])
+    composite_karmic = _extract_karmic_planets(composite, "planets")
+    davison_karmic = _extract_karmic_planets(davison, "planets")
+
+    # Count karmic weight
+    karmic_weight = len(synastry_karmic_aspects)
+    if composite_karmic.get("saturn"):
+        karmic_weight += 1
+    if composite_karmic.get("pluto"):
+        karmic_weight += 1
+    if davison_karmic.get("saturn"):
+        karmic_weight += 1
+    if davison_karmic.get("pluto"):
+        karmic_weight += 1
+
+    return {
+        "karmic_weight": karmic_weight,
+        "synastry_karmic_aspects": synastry_karmic_aspects,
+        "composite_karmic_planets": composite_karmic,
+        "davison_karmic_planets": davison_karmic,
+        "summary": {
+            "saturn_contacts": len([a for a in synastry_karmic_aspects if "saturn" in (a["person1_planet"], a["person2_planet"])]),
+            "pluto_contacts": len([a for a in synastry_karmic_aspects if "pluto" in (a["person1_planet"], a["person2_planet"])]),
+            "node_contacts": len([a for a in synastry_karmic_aspects if "true_node" in (a["person1_planet"], a["person2_planet"])]),
+        },
+    }
+
+
+# ── _byId convenience tools ──────────────────────────────────────────
+# These tools accept a person_id (from the DB) and optional overrides,
+# then call the core chart tools with the person's birth data.
+# Unprovided optional params fall back to the default of the core tool.
+
+
+async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
+    """Fetch birth data from the persons database and convert datetime to UTC.
+
+    This is the SINGLE POINT where stored birth datetime is converted to UTC.
+    All _byId chart tools call this function and receive UTC datetimes — they
+    must NOT perform any additional timezone conversion.
+
+    The DB stores birth_datetime as naive local time (no offset) and timezone
+    as an IANA name (e.g. "Europe/Vienna"). This function combines them to
+    produce the correct UTC datetime, using zoneinfo for modern dates and
+    LMT (longitude/15) as fallback when no timezone is set.
+
+    Rule: birth_datetime in the returned dict is ALWAYS naive local time.
+    birth_datetime_utc is for internal ephemeris calls only.
+    """
+    from . import storage
+    from .ephemeris_client import _normalize_datetime
+    person = await storage.get_person(person_id=person_id)
+    if not person:
+        person = await storage.get_person(nickname=person_id)
+    if not person:
+        return {"error": f"person not found: {person_id}"}
+    utc_dt = _normalize_datetime(
+        person["birth_datetime"],
+        tz_name=person.get("timezone"),
+        lon=person["longitude"],
+    )
+    return {
+        "birth_datetime": person["birth_datetime"],   # naive local time
+        "timezone": person.get("timezone"),
+        "birth_datetime_utc": utc_dt,                  # for ephemeris calls
+        "birthplace": person.get("birthplace"),
+        "name": person.get("name"),
+        "nickname": person.get("nickname"),
+        "latitude": person["latitude"],
+        "longitude": person["longitude"],
+        "elevation": person.get("elevation", 0.0),
+    }
+
+
+@mcp.tool()
+async def calculate_natal_chart_by_id(
+    person_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    include_overview: bool = False,
+    include_patterns: bool = False,
+    include_karmic: bool = False,
+    top_n_aspects: int | None = None,
+) -> dict[str, Any]:
+    """Calculate natal chart for a person from the database.
+
+    Same as calculate_natal_chart but fetches birth data from the persons database
+    by ID or nickname. See calculate_natal_chart for full documentation.
+
+    For interpretation guidance, fetch resource: astro://guides/natal-astrology
+
+    Args:
+        person_id: ID or nickname of a person in the persons database.
+        house_system: House system (default: Placidus).
+        orb_limits: Optional orb configuration.
+        include_overview: Add element/modality/hemisphere balance, stelliums, etc.
+        include_patterns: Add aspect pattern detection and chart shape.
+        include_karmic: Add nodal axis, Saturn, Pluto polarity point, etc.
+        top_n_aspects: Limit aspects to the N tightest by orb.
+
+    Returns:
+        Complete natal chart structure (see calculate_natal_chart)."""
+    birth = await _get_person_birth_data(person_id)
+    if "error" in birth:
+        return birth
+    result = await calculate_natal_chart(
+        birth_datetime=birth["birth_datetime_utc"],
+        latitude=birth["latitude"],
+        longitude=birth["longitude"],
+        elevation=birth.get("elevation", 0.0),
+        house_system=house_system,
+        orb_limits=orb_limits,
+        include_overview=include_overview,
+        include_patterns=include_patterns,
+        include_karmic=include_karmic,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" not in result:
+        result["input"]["birth_datetime"] = birth["birth_datetime"]
+        result["input"]["timezone"] = birth.get("timezone")
+        result["input"]["name"] = birth.get("name")
+        result["input"]["birthplace"] = birth.get("birthplace")
+    return result
+
+
+@mcp.tool()
+async def calculate_transit_chart_by_id(
+    person_id: str,
+    transit_datetime: str,
+    transit_latitude: float | None = None,
+    transit_longitude: float | None = None,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+) -> dict[str, Any]:
+    """Calculate transit chart for a person from the database.
+
+Same as calculate_transit_chart but fetches birth data from the persons database.
+
+For interpretation guidance, fetch resource: astro://guides/natal-astrology
+
+Args:
+    person_id: ID of a person in the persons database.
+    transit_datetime: ISO 8601 transit datetime (UTC).
+    transit_latitude: Current location latitude. Defaults to birth latitude.
+    transit_longitude: Current location longitude. Defaults to birth longitude.
+    house_system: House system for natal houses (default: Placidus).
+    orb_limits: Optional orb configuration.
+
+Returns:
+    Transit chart structure (see calculate_transit_chart)."""
+    birth = await _get_person_birth_data(person_id)
+    if "error" in birth:
+        return birth
+    result = await calculate_transit_chart(
+        birth_datetime=birth["birth_datetime_utc"],
+        transit_datetime=transit_datetime,
+        latitude=birth["latitude"],
+        longitude=birth["longitude"],
+        transit_latitude=transit_latitude,
+        transit_longitude=transit_longitude,
+        elevation=birth.get("elevation", 0.0),
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" not in result:
+        result["input"]["birth_datetime"] = birth["birth_datetime"]
+        result["input"]["timezone"] = birth.get("timezone")
+    return result
+
+
+@mcp.tool()
+async def calculate_synastry_chart_by_id(
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    top_n_aspects: int | None = None,
+    karmic_filter: bool = False,
+    significator_filter: bool = False,
+    include_davison_full: bool = False,
+) -> dict[str, Any]:
+    """Calculate synastry chart for two persons from the database.
+
+    Same as calculate_synastry_chart but fetches birth data from the persons database
+    by ID or nickname. See calculate_synastry_chart for full documentation.
+
+    For interpretation guidance, fetch resource: astro://guides/relationship-astrology
+
+    Args:
+        person1_id: ID of person 1 in the persons database.
+        person2_id: ID of person 2 in the persons database.
+        house_system: House system (default: Placidus).
+        orb_limits: Optional orb configuration.
+        top_n_aspects: Limit interaspects to top N by orb.
+        karmic_filter: Only return Saturn/Pluto/Node interaspects.
+        significator_filter: Only return Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn.
+        include_davison_full: Compute full Davison chart.
+
+    Returns:
+        Synastry chart structure (see calculate_synastry_chart)."""
+    p1 = await _get_person_birth_data(person1_id)
+    if "error" in p1:
+        return p1
+    p2 = await _get_person_birth_data(person2_id)
+    if "error" in p2:
+        return p2
+    result = await calculate_synastry_chart(
+        person1_datetime=p1["birth_datetime_utc"],
+        person1_latitude=p1["latitude"],
+        person1_longitude=p1["longitude"],
+        person2_datetime=p2["birth_datetime_utc"],
+        person2_latitude=p2["latitude"],
+        person2_longitude=p2["longitude"],
+        elevation=p1.get("elevation", 0.0),
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+        karmic_filter=karmic_filter,
+        significator_filter=significator_filter,
+        include_davison_full=include_davison_full,
+    )
+    if "error" not in result:
+        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
+        result["input"]["person1_timezone"] = p1.get("timezone")
+        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
+        result["input"]["person2_timezone"] = p2.get("timezone")
+    return result
+
+
+@mcp.tool()
+async def calculate_composite_chart_by_id(
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+) -> dict[str, Any]:
+    """Calculate composite chart for two persons from the database.
+
+Same as calculate_composite_chart but fetches birth data from the persons database.
+
+For interpretation guidance, fetch resource: astro://guides/relationship-astrology
+
+Args:
+    person1_id: ID of person 1 in the persons database.
+    person2_id: ID of person 2 in the persons database.
+    house_system: House system (default: Placidus).
+    orb_limits: Optional orb configuration.
+
+Returns:
+    Composite chart structure (see calculate_composite_chart)."""
+    p1 = await _get_person_birth_data(person1_id)
+    if "error" in p1:
+        return p1
+    p2 = await _get_person_birth_data(person2_id)
+    if "error" in p2:
+        return p2
+    result = await calculate_composite_chart(
+        person1_datetime=p1["birth_datetime_utc"],
+        person1_latitude=p1["latitude"],
+        person1_longitude=p1["longitude"],
+        person2_datetime=p2["birth_datetime_utc"],
+        person2_latitude=p2["latitude"],
+        person2_longitude=p2["longitude"],
+        elevation=p1.get("elevation", 0.0),
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" not in result:
+        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
+        result["input"]["person1_timezone"] = p1.get("timezone")
+        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
+        result["input"]["person2_timezone"] = p2.get("timezone")
+    return result
+
+
+@mcp.tool()
+async def calculate_davison_chart_by_id(
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+) -> dict[str, Any]:
+    """Calculate Davison chart for two persons from the database.
+
+Same as calculate_davison_chart but fetches birth data from the persons database.
+
+For interpretation guidance, fetch resource: astro://guides/relationship-astrology
+
+Args:
+    person1_id: ID of person 1 in the persons database.
+    person2_id: ID of person 2 in the persons database.
+    house_system: House system (default: Placidus).
+    orb_limits: Optional orb configuration.
+
+Returns:
+    Davison chart structure (see calculate_davison_chart)."""
+    p1 = await _get_person_birth_data(person1_id)
+    if "error" in p1:
+        return p1
+    p2 = await _get_person_birth_data(person2_id)
+    if "error" in p2:
+        return p2
+    result = await calculate_davison_chart(
+        person1_datetime=p1["birth_datetime_utc"],
+        person1_latitude=p1["latitude"],
+        person1_longitude=p1["longitude"],
+        person2_datetime=p2["birth_datetime_utc"],
+        person2_latitude=p2["latitude"],
+        person2_longitude=p2["longitude"],
+        elevation=p1.get("elevation", 0.0),
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" not in result:
+        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
+        result["input"]["person1_timezone"] = p1.get("timezone")
+        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
+        result["input"]["person2_timezone"] = p2.get("timezone")
+    return result
+
+
+@mcp.tool()
+async def get_transit_preview_by_id(
+    person_id: str,
+    start_date: str,
+    end_date: str,
+    transit_latitude: float | None = None,
+    transit_longitude: float | None = None,
+    min_significance: float = 0.0,
+) -> dict[str, Any]:
+    """Daily transit-to-natal aspect snapshot for a person from the database.
+
+Same as get_transit_preview but fetches birth data from the persons database.
+
+For interpretation guidance, fetch resource: astro://guides/natal-astrology
+
+Args:
+    person_id: ID or nickname of a person in the persons database.
+    start_date: ISO date string for the start of the range (YYYY-MM-DD).
+    end_date: ISO date string for the end of the range (YYYY-MM-DD).
+    transit_latitude: Current location latitude. Defaults to birth latitude.
+    transit_longitude: Current location longitude. Defaults to birth longitude.
+    min_significance: Minimum significance score (0-10). Default 0 = all.
+
+Returns:
+    Daily transit snapshots (see get_transit_preview)."""
+    birth = await _get_person_birth_data(person_id)
+    if "error" in birth:
+        return birth
+    result = await get_transit_preview(
+        birth_datetime=birth["birth_datetime_utc"],
+        latitude=birth["latitude"],
+        longitude=birth["longitude"],
+        start_date=start_date,
+        end_date=end_date,
+        transit_latitude=transit_latitude,
+        transit_longitude=transit_longitude,
+        min_significance=min_significance,
+    )
+    if "error" not in result:
+        result["input"]["birth_datetime"] = birth["birth_datetime"]
+        result["input"]["timezone"] = birth.get("timezone")
+    return result
+
+

+ 5 - 0
src/astro_mcp/chart_renderer.py

@@ -491,6 +491,11 @@ def _render_title_corner(
                 tz_name = dt.strftime("%Z")
                 if tz_name and tz_name != "UTC":
                     dt_str += f" {tz_name}"
+            else:
+                # Naive datetime (local time from DB) — use the timezone field
+                tz_name = inp.get("timezone", "")
+                if tz_name:
+                    dt_str += f" {tz_name}"
             lines.append((dt_str, "8px", theme["data_text"], False))
         except Exception:
             pass

+ 1307 - 0
src/astro_mcp/chart_tools.py

@@ -0,0 +1,1307 @@
+"""
+Chart calculation tools for astro-mcp.
+
+Direct-call functions that compute natal, transit, synastry, composite, and
+Davison charts, plus transit preview scans. These functions accept UTC
+datetimes and raw birth coordinates — for DB-backed variants, see by_id_tools.py.
+"""
+
+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,
+    extract_houses,
+    extract_angles,
+)
+
+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.
+
+    Use for quick position lookups without full chart calculation. For complete natal
+    chart interpretation, use calculate_natal_chart instead.
+
+    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 echo, 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,
+    include_overview: bool = False,
+    include_patterns: bool = False,
+    include_karmic: bool = False,
+    top_n_aspects: int | None = None,
+) -> dict[str, Any]:
+    """Calculate a complete natal chart from birth data.
+
+    birth_datetime MUST be UTC. For DB-backed charts, use the _byId variant
+    (calculate_natal_chart_by_id) which handles timezone conversion automatically.
+
+PRIMARY TOOL for natal astrology. Returns planetary positions, houses, aspects,
+and angles. Use the optional flags to add interpretation layers.
+
+WORKFLOW:
+1. Basic chart: call without flags to get planets, houses, aspects, angles.
+2. Add overview (include_overview=true): element/modality/hemisphere balance,
+   stelliums, empty houses, chart ruler, house rulers, retrograde list.
+3. Add patterns (include_patterns=true): T-square, Grand Trine, Grand Cross,
+   Yod detection + chart shape (bundle, bowl, splash, locomotive, seesaw, splay).
+4. Add karmic (include_karmic=true): nodal axis, Saturn, Pluto polarity point,
+   Part of Fortune, 12th house, nodal/Saturn hard aspects.
+5. Full reading: all three flags true.
+
+For interpretation guidance, fetch resource: astro://guides/natal-astrology
+
+Args:
+    birth_datetime: ISO 8601 birth datetime with timezone (e.g., "1990-05-15T10:30:00+01:00").
+    latitude: Birth latitude in decimal degrees (-90 to 90).
+    longitude: Birth longitude in decimal degrees (-180 to 180).
+    elevation: Birth elevation in meters (default: 0).
+    house_system: "placidus" (default), "equal", or "whole_sign".
+    orb_limits: Optional per-aspect orb overrides, e.g., {"conjunction": 10}.
+    include_overview: Add element/modality/hemisphere balance, stelliums, empty houses,
+        chart ruler, house rulers, planet groupings, retrograde list.
+    include_patterns: Add aspect pattern detection and chart shape classification.
+    include_karmic: Add nodal axis, Saturn, Pluto polarity point, Part of Fortune,
+        12th house, and karmic aspect filters.
+    top_n_aspects: Limit aspects output to the N tightest by orb.
+
+Returns:
+    Dict with: input, chart_type, planets, houses, aspects, angles, lunar_phase,
+    and optionally: overview, aspect_patterns, chart_shape, karmic."""
+    # birth_datetime is UTC: _byId callers pre-convert via _get_person_birth_data,
+    # direct-call users are responsible for passing UTC or offset-aware datetime.
+    sky = await call_sky_state(
+        datetime=birth_datetime,
+        lat=latitude,
+        lon=longitude,
+        elevation=elevation,
+        geocentric=True,
+        house_system=house_system,
+    )
+
+    if "error" in sky:
+        return {"input": {"birth_datetime": birth_datetime, "latitude": latitude, "longitude": longitude}, "error": sky["error"]}
+
+    raw_bodies = extract_bodies(sky)
+
+    # Houses and angles from server-side Swiss Ephemeris
+    houses = extract_houses(sky)
+    angles = extract_angles(sky)
+
+    # 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 (pass speed_lon for applying/separating detection)
+    speed_lookup = {b["body"]: b.get("speed_lon") for b in raw_bodies}
+    aspect_bodies = [
+        {"name": p["body"], "lon": p["absolute_lon"], "speed_lon": speed_lookup.get(p["body"])}
+        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"],
+        })
+
+    # Build result
+    result: dict[str, Any] = {
+        "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,
+    }
+
+    # Add lunar phase from ephemeris
+    lunar_state = sky.get("lunar_state", {})
+    lunar = lunar_state.get("lunar_state", {}) if isinstance(lunar_state, dict) else {}
+    if lunar:
+        result["lunar_phase"] = {
+            "phase_name": lunar.get("phase_name"),
+            "illumination_fraction": lunar.get("illumination_fraction"),
+            "age_days": lunar.get("age_days"),
+        }
+
+    # Limit aspects if requested
+    if top_n_aspects is not None:
+        result["aspects"] = formatted_aspects[:top_n_aspects]
+
+    # Overview section
+    if include_overview:
+        asc_sign = angles.get("ascendant", {}).get("sign", "")
+        overview: dict[str, Any] = {
+            "element_balance": astrology.get_element_balance(planets),
+            "modality_balance": astrology.get_modality_balance(planets),
+            "hemisphere_emphasis": astrology.get_hemisphere_emphasis(planets),
+            "stelliums": astrology.detect_stelliums(planets),
+            "empty_houses": astrology.get_empty_houses(planets),
+            "chart_ruler": astrology.get_chart_ruler(asc_sign, planets),
+            "house_rulers": astrology.get_house_rulers(houses, planets),
+            "planets_by_house": astrology.group_planets_by_house(planets),
+            "planets_by_sign": astrology.group_planets_by_sign(planets),
+            "house_type_counts": astrology.get_house_type_counts(planets),
+            "retrograde_planets": astrology.get_retrograde_planets(planets),
+        }
+        result["overview"] = overview
+
+    # Aspect patterns + chart shape
+    if include_patterns:
+        patterns = astrology.detect_aspect_patterns(planets, formatted_aspects)
+        chart_shape = astrology.detect_chart_shape(planets)
+        result["aspect_patterns"] = patterns
+        result["chart_shape"] = chart_shape
+
+    # Karmic analysis
+    if include_karmic:
+        asc_lon = angles.get("ascendant", {}).get("absolute_lon", 0.0)
+        sun_lon = astrology._planet_lon("sun", planets) or 0.0
+        moon_lon = astrology._planet_lon("moon", planets) or 0.0
+
+        karmic: dict[str, Any] = {
+            "nodal_axis": astrology.get_nodal_axis(planets, houses),
+            "saturn": astrology.get_saturn_info(planets),
+            "pluto_polarity_point": astrology.get_pluto_polarity_point(planets, houses),
+            "part_of_fortune": astrology.get_part_of_fortune(asc_lon, sun_lon, moon_lon, houses),
+            "twelfth_house": astrology.get_twelfth_house_analysis(houses, planets),
+            "nodal_aspects": astrology.get_natal_aspects_to_planets(
+                formatted_aspects, {"true_node"}
+            ),
+            "saturn_aspects": astrology.get_natal_aspects_to_planets(
+                formatted_aspects, {"saturn"}, astrology.HARD_ASPECTS
+            ),
+        }
+        result["karmic"] = karmic
+
+    return result
+
+
+# ── Tool: calculate_transit_chart ────────────────────────────────────
+
+@mcp.tool()
+async def calculate_transit_chart(
+    birth_datetime: str,
+    transit_datetime: str,
+    latitude: float,
+    longitude: float,
+    transit_latitude: float | None = None,
+    transit_longitude: float | None = None,
+    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.
+
+Shows how current (or future) transiting planets aspect the natal chart. Useful for
+identifying active transit windows and their themes. For daily transit snapshots,
+use get_transit_preview instead.
+
+For interpretation guidance, fetch resources:
+- astro://guides/natal-astrology
+- astro://guides/financial-astrology (for market-timing and economic cycle analysis)
+
+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.
+    transit_latitude: Current location latitude for transit calculation. Defaults to birth latitude.
+    transit_longitude: Current location longitude for transit calculation. Defaults to birth longitude.
+    elevation: Birth elevation in meters.
+    house_system: House system for natal houses (default: Placidus).
+    orb_limits: Optional orb configuration.
+
+Returns:
+    Transit chart with natal_planets, transiting_planets, aspects (transit-to-natal),
+    and houses."""
+    # Default transit location to birth location if not specified
+    t_lat = transit_latitude if transit_latitude is not None else latitude
+    t_lon = transit_longitude if transit_longitude is not None else longitude
+
+    # Get natal sky state
+    natal_sky = await call_sky_state(
+        datetime=birth_datetime,
+        lat=latitude,
+        lon=longitude,
+        elevation=elevation,
+        geocentric=True,
+        house_system=house_system,
+    )
+
+    # Get transit sky state at transit location
+    transit_sky = await call_sky_state(
+        datetime=transit_datetime,
+        lat=t_lat,
+        lon=t_lon,
+        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)
+
+    # Houses from server-side Swiss Ephemeris
+    houses = extract_houses(natal_sky)
+
+    # 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,
+    top_n_aspects: int | None = None,
+    karmic_filter: bool = False,
+    significator_filter: bool = False,
+    include_davison_full: bool = False,
+) -> dict[str, Any]:
+    """Calculate a synastry (relationship) chart for two people.
+
+PRIMARY TOOL for relationship astrology. Returns interchart aspects, house overlays,
+composite chart, and Davison chart data. Use filters to focus on specific themes.
+
+WORKFLOW:
+1. Basic synastry: call without flags → get all interaspects + house overlays.
+2. Karmic focus: karmic_filter=true → only Saturn/Pluto/Node interaspects.
+3. Romantic focus: significator_filter=true → only Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn.
+4. Include Davison: include_davison_full=true → full Davison chart with planets/houses/aspects.
+
+For interpretation guidance, fetch resource: astro://guides/relationship-astrology
+
+For karmic relationship analysis, also see: get_karmic_relationship_summary
+
+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.
+    top_n_aspects: Limit interaspects to top N by orb.
+    karmic_filter: Only return Saturn/Pluto/Node interaspects.
+    significator_filter: Only return Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn pairs.
+    include_davison_full: Compute full Davison chart (planets, houses, aspects, angles).
+
+Returns:
+    Dict with: input, chart_type, chart1_natal, chart2_natal, interaspects,
+    house_overlays, composite_chart, davison_chart, summary (top_aspects,
+    saturn_contacts, node_contacts, venus_mars_contacts, sun_moon_contacts)."""
+    sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, elevation=elevation, house_system=house_system)
+    sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, elevation=elevation, house_system=house_system)
+
+    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)
+
+    # Houses from server-side Swiss Ephemeris
+    houses1 = extract_houses(sky1)
+    houses2 = extract_houses(sky2)
+
+    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"])
+
+    # Apply filters
+    filtered_aspects = interaspects
+    if karmic_filter:
+        karmic_planets = {"saturn", "pluto", "true_node"}
+        filtered_aspects = [
+            a for a in filtered_aspects
+            if a["person1_planet"] in karmic_planets or a["person2_planet"] in karmic_planets
+        ]
+    if significator_filter:
+        significator_pairs = {
+            frozenset(["venus", "mars"]), frozenset(["moon", "venus"]),
+            frozenset(["sun", "moon"]), frozenset(["sun", "saturn"]),
+        }
+        filtered_aspects = [
+            a for a in filtered_aspects
+            if frozenset([a["person1_planet"], a["person2_planet"]]) in significator_pairs
+        ]
+    if top_n_aspects is not None:
+        filtered_aspects = filtered_aspects[:top_n_aspects]
+
+    # 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
+    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
+    davison_mid_lat = (person1_latitude + person2_latitude) / 2
+    davison_mid_lon = (person1_longitude + person2_longitude) / 2
+
+    davison_result: dict[str, Any] = {
+        "date_midpoint_jd": davison["date_midpoint_jd"],
+        "latitude_midpoint": davison_mid_lat,
+        "longitude_midpoint": davison_mid_lon,
+    }
+
+    # Full Davison chart if requested
+    if include_davison_full:
+        davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
+        davison_sky = await call_sky_state(
+            datetime=davison_dt, lat=davison_mid_lat, lon=davison_mid_lon,
+            elevation=0.0, geocentric=True, house_system=house_system,
+        )
+        if "error" not in davison_sky:
+            davison_raw = extract_bodies(davison_sky)
+            davison_houses = extract_houses(davison_sky)
+
+            davison_planets = []
+            for body in davison_raw:
+                ecl_lon = body.get("ecliptic_lon", 0.0)
+                z = astrology.ecliptic_to_zodiac(ecl_lon)
+                house = astrology.get_house_placement(ecl_lon, davison_houses)
+                davison_planets.append({
+                    "body": body["body"],
+                    "sign": z["sign"],
+                    "degree_within_sign": z["degree"],
+                    "absolute_lon": z["absolute_lon"],
+                    "house": house,
+                    "retrograde": astrology.is_retrograde(body.get("speed_lon")),
+                })
+
+            davison_aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in davison_planets]
+            davison_aspects = astrology.compute_aspects(davison_aspect_bodies, orb_limits)
+            davison_formatted = []
+            for asp in davison_aspects:
+                davison_formatted.append({
+                    "body1": asp["body1"],
+                    "body2": asp["body2"],
+                    "aspect": asp["aspect"],
+                    "orb": asp["orb"],
+                    "applying": asp["applying"],
+                    "exactness": asp["exactness"],
+                })
+
+            davison_angles = extract_angles(davison_sky)
+
+            davison_result["planets"] = davison_planets
+            davison_result["houses"] = davison_houses
+            davison_result["aspects"] = davison_formatted
+            davison_result["angles"] = davison_angles
+
+    # Build summary
+    summary: dict[str, Any] = {
+        "top_aspects": interaspects[:15],
+        "saturn_contacts": [a for a in interaspects if a["person1_planet"] == "saturn" or a["person2_planet"] == "saturn"],
+        "node_contacts": [a for a in interaspects if a["person1_planet"] == "true_node" or a["person2_planet"] == "true_node"],
+        "venus_mars_contacts": [a for a in interaspects if frozenset([a["person1_planet"], a["person2_planet"]]) == frozenset(["venus", "mars"])],
+        "sun_moon_contacts": [a for a in interaspects if frozenset([a["person1_planet"], a["person2_planet"]]) == frozenset(["sun", "moon"])],
+    }
+
+    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": filtered_aspects,
+        "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": davison_result,
+        "summary": summary,
+    }
+
+
+def _jd_to_datetime(jd: float) -> str:
+    """Convert Julian Day to ISO 8601 datetime string."""
+    from datetime import datetime, timezone, timedelta
+    # JD 2440587.5 = 1970-01-01T00:00:00Z
+    unix_seconds = (jd - 2440587.5) * 86400.0
+    dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc)
+    return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+# ── Tool: get_transit_preview ────────────────────────────────────────
+
+@mcp.tool()
+async def get_transit_preview(
+    birth_datetime: str,
+    latitude: float,
+    longitude: float,
+    start_date: str,
+    end_date: str,
+    transit_latitude: float | None = None,
+    transit_longitude: float | None = None,
+    min_significance: float = 0.0,
+) -> dict[str, Any]:
+    """Daily transit-to-natal aspect snapshot over a time range.
+
+Shows which transiting planets aspect which natal planets for each day. Significance
+score (0-10) based on aspect type, planet importance, and orb tightness.
+
+Use this for identifying active transit windows and forecasting themes. For a single
+transit moment, use calculate_transit_chart instead.
+
+For interpretation guidance, fetch resources:
+- astro://guides/natal-astrology
+- astro://guides/financial-astrology (for market-timing and economic cycle analysis)
+
+Args:
+    birth_datetime: ISO 8601 birth datetime (UTC).
+    latitude: Birth latitude in decimal degrees.
+    longitude: Birth longitude in decimal degrees.
+    start_date: Start of range (YYYY-MM-DD).
+    end_date: End of range (YYYY-MM-DD). Maximum 365 days.
+    transit_latitude: Current location latitude. Defaults to birth latitude.
+    transit_longitude: Current location longitude. Defaults to birth longitude.
+    min_significance: Minimum significance score (0-10) to include. Default 0 = all.
+
+Returns:
+    Daily snapshots with active transit-to-natal aspects, sorted by date.
+    Each day: date, aspects (with orb, applying/separating, significance), count."""
+    from datetime import datetime, timedelta, timezone
+
+    from . import astrology
+
+    birth_dt = birth_datetime
+    birth_lat = latitude
+    birth_lon = longitude
+    t_lat = transit_latitude if transit_latitude is not None else birth_lat
+    t_lon = transit_longitude if transit_longitude is not None else birth_lon
+
+    # Parse date range
+    try:
+        start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
+        end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
+    except Exception:
+        return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
+
+    if end <= start:
+        return {"error": "end_date must be after start_date"}
+
+    if (end - start).days > 365:
+        return {"error": "Date range too large. Maximum 365 days."}
+
+    # Get natal planet positions (one ephemeris call)
+    natal_sky = await call_sky_state(
+        datetime=birth_dt, lat=birth_lat, lon=birth_lon, geocentric=True,
+    )
+    if "error" in natal_sky:
+        return {"error": f"natal ephemeris error: {natal_sky['error']}"}
+
+    natal_bodies = extract_bodies(natal_sky)
+    natal_lons = {b["body"]: b["ecliptic_lon"] for b in natal_bodies}
+
+    # Build list of all transit-to-natal aspect checks with per-pair orbs
+    aspect_checks = []
+    for t_name in astrology.TRANSIT_ORB_RADII:
+        for n_name in natal_lons:
+            for asp_def in astrology.ASPECT_DEFINITIONS:
+                asp_name = asp_def["name"]
+                asp_angle = asp_def["angle"]
+                max_orb = astrology.get_transit_orb(t_name, n_name, asp_name)
+                aspect_checks.append((t_name, n_name, asp_name, asp_angle, max_orb))
+
+    # Daily scan
+    days = []
+    current_day = start
+    while current_day <= end:
+        iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
+        sky = await call_sky_state(
+            datetime=iso, lat=t_lat, lon=t_lon, geocentric=True,
+        )
+        if "error" in sky:
+            current_day += timedelta(days=1)
+            continue
+
+        transit_bodies = extract_bodies(sky)
+        transit_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in transit_bodies}
+        transit_speeds = {b["body"]: b.get("speed_lon", 0.0) for b in transit_bodies}
+
+        day_aspects = []
+        for t_name, n_name, asp_name, asp_angle, max_orb in aspect_checks:
+            if t_name not in transit_lons or n_name not in natal_lons:
+                continue
+
+            t_lon = transit_lons[t_name]
+            n_lon = natal_lons[n_name]
+            diff = abs(t_lon - n_lon)
+            diff = min(diff, 360.0 - diff)
+            orb = abs(diff - asp_angle)
+
+            if orb > max_orb:
+                continue
+
+            significance = astrology.get_transit_significance(t_name, n_name, asp_name, orb, max_orb)
+            if significance < min_significance:
+                continue
+
+            # Determine applying/separating
+            t_speed = transit_speeds.get(t_name, 0.0)
+            applying = astrology._is_applying(t_lon, n_lon, t_speed, 0.0, asp_angle)
+
+            day_aspects.append({
+                "transiting": t_name,
+                "natal": n_name,
+                "aspect": asp_name,
+                "orb": round(orb, 4),
+                "applying": applying,
+                "significance": significance,
+            })
+
+        # Sort by significance (highest first)
+        day_aspects.sort(key=lambda a: a["significance"], reverse=True)
+
+        days.append({
+            "date": current_day.strftime("%Y-%m-%d"),
+            "aspects": day_aspects,
+            "count": len(day_aspects),
+        })
+
+        current_day += timedelta(days=1)
+
+    return {
+        "input": {
+            "birth_datetime": birth_datetime,
+            "latitude": latitude,
+            "longitude": longitude,
+            "start_date": start_date,
+            "end_date": end_date,
+            "min_significance": min_significance,
+        },
+        "days": days,
+        "total_aspects": sum(d["count"] for d in days),
+    }
+
+
+# ── Tool: calculate_composite_chart ──────────────────────────────────
+
+@mcp.tool()
+async def calculate_composite_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 composite chart (midpoint method) for two people.
+
+The composite chart represents the relationship itself as a single chart, calculated
+by taking the midpoint of each pair of planetary positions. It shows the relationship's
+identity, structure, and public face.
+
+For relationship timing, use get_composite_transit_preview.
+
+For interpretation guidance, fetch resource: astro://guides/relationship-astrology
+
+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:
+    Composite chart with planets, houses, aspects, angles, and composite_location.
+    Composite orbs: 3° max (tighter than natal). Use tight orbs for interpretation."""
+    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)
+
+    # Composite planets via midpoint method
+    composite_bodies = astrology.compute_composite_chart(
+        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies1],
+        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies2],
+    )
+
+    # Composite location: midpoint of birth locations
+    comp_lat = (person1_latitude + person2_latitude) / 2
+    comp_lon = (person1_longitude + person2_longitude) / 2
+
+    # Use composite datetime for house calculation
+    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
+
+    # Get composite sky state for houses and angles
+    # Use a date near the midpoint for house calculation
+    comp_sky = await call_sky_state(
+        datetime=person1_datetime, lat=comp_lat, lon=comp_lon, elevation=elevation,
+        house_system=house_system,
+    )
+    if "error" in comp_sky:
+        return {"error": f"composite ephemeris error: {comp_sky['error']}"}
+
+    houses = extract_houses(comp_sky)
+
+    # Build composite planet list with house placement
+    composite_planets = []
+    for cb in composite_bodies:
+        ecl_lon = cb["lon"]
+        zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
+        house = astrology.get_house_placement(ecl_lon, houses)
+        composite_planets.append({
+            "body": cb["name"],
+            "sign": zodiac["sign"],
+            "sign_abbreviation": zodiac["abbreviation"],
+            "degree_within_sign": zodiac["degree"],
+            "absolute_lon": zodiac["absolute_lon"],
+            "house": house,
+        })
+
+    # Aspects
+    aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in composite_planets]
+    aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
+    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"],
+        })
+
+    angles = extract_angles(comp_sky)
+
+    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": "composite",
+        "planets": composite_planets,
+        "houses": houses,
+        "aspects": formatted_aspects,
+        "angles": angles,
+        "composite_location": {"latitude": comp_lat, "longitude": comp_lon},
+    }
+
+
+# ── Tool: calculate_davison_chart ─────────────────────────────────────
+
+@mcp.tool()
+async def calculate_davison_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 Davison chart (midpoint in time and space) for two people.
+
+The Davison chart is a real moment in time (unlike the composite which is purely
+symbolic). It represents the relationship's inner experience and emotional tone.
+It can be progressed and directed like a natal chart.
+
+For relationship timing, use get_davison_transit_preview.
+
+For interpretation guidance, fetch resource: astro://guides/relationship-astrology
+
+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:
+    Davison chart with: chart_type, date_midpoint_jd, location_midpoint,
+    planets, houses, aspects, angles."""
+    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
+    mid_lat = (person1_latitude + person2_latitude) / 2
+    mid_lon = (person1_longitude + person2_longitude) / 2
+    davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
+
+    sky = await call_sky_state(
+        datetime=davison_dt, lat=mid_lat, lon=mid_lon,
+        elevation=elevation, geocentric=True, house_system=house_system,
+    )
+    if "error" in sky:
+        return {"error": f"davison ephemeris error: {sky['error']}"}
+
+    raw_bodies = extract_bodies(sky)
+    houses = extract_houses(sky)
+
+    planets = []
+    for body in raw_bodies:
+        ecl_lon = body.get("ecliptic_lon", 0.0)
+        z = astrology.ecliptic_to_zodiac(ecl_lon)
+        house = astrology.get_house_placement(ecl_lon, houses)
+        planets.append({
+            "body": body["body"],
+            "sign": z["sign"],
+            "degree_within_sign": z["degree"],
+            "absolute_lon": z["absolute_lon"],
+            "house": house,
+            "retrograde": astrology.is_retrograde(body.get("speed_lon")),
+        })
+
+    aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in planets]
+    aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
+    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"],
+        })
+
+    angles = extract_angles(sky)
+
+    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": "davison",
+        "date_midpoint_jd": davison["date_midpoint_jd"],
+        "location_midpoint": {"latitude": mid_lat, "longitude": mid_lon},
+        "planets": planets,
+        "houses": houses,
+        "aspects": formatted_aspects,
+        "angles": angles,
+    }
+
+
+# ── Tool: get_composite_transit_preview ───────────────────────────────
+
+@mcp.tool()
+async def get_composite_transit_preview(
+    person1_datetime: str,
+    person1_latitude: float,
+    person1_longitude: float,
+    person2_datetime: str,
+    person2_latitude: float,
+    person2_longitude: float,
+    start_date: str,
+    end_date: str,
+    min_significance: float = 0.0,
+) -> dict[str, Any]:
+    """Daily transit-to-composite chart aspect snapshot over a time range.
+
+Calculates the composite chart for two people, then shows transiting aspects to
+composite planet positions for each day. Use for timing relationship events and
+identifying when relationship themes are activated.
+
+For interpretation guidance, fetch resource: astro://guides/relationship-astrology
+
+Args:
+    person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
+    person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
+    start_date: Start of range (YYYY-MM-DD).
+    end_date: End of range (YYYY-MM-DD). Maximum 365 days.
+    min_significance: Minimum significance score (0-10) to include. Default 0 = all.
+
+Returns:
+    Daily snapshots with active transit-to-composite aspects, sorted by date.
+    Each day: date, aspects (transiting, composite, orb, applying, significance), count."""
+    from datetime import datetime, timedelta, timezone
+
+    # Calculate composite chart
+    sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, geocentric=True)
+    sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, geocentric=True)
+    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)
+    composite_bodies = astrology.compute_composite_chart(
+        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies1],
+        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies2],
+    )
+    composite_lons = {b["name"]: b["lon"] for b in composite_bodies}
+
+    # Parse date range
+    try:
+        start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
+        end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
+    except Exception:
+        return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
+    if end <= start:
+        return {"error": "end_date must be after start_date"}
+    if (end - start).days > 365:
+        return {"error": "Date range too large. Maximum 365 days."}
+
+    # Build aspect checks
+    aspect_checks = []
+    for t_name in astrology.TRANSIT_ORB_RADII:
+        for c_name in composite_lons:
+            for asp_def in astrology.ASPECT_DEFINITIONS:
+                asp_name = asp_def["name"]
+                asp_angle = asp_def["angle"]
+                max_orb = astrology.get_transit_orb(t_name, c_name, asp_name)
+                aspect_checks.append((t_name, c_name, asp_name, asp_angle, max_orb))
+
+    # Daily scan
+    days = []
+    current_day = start
+    while current_day <= end:
+        iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
+        sky = await call_sky_state(datetime=iso, lat=0.0, lon=0.0, geocentric=True)
+        if "error" in sky:
+            current_day += timedelta(days=1)
+            continue
+
+        transit_bodies = extract_bodies(sky)
+        transit_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in transit_bodies}
+        transit_speeds = {b["body"]: b.get("speed_lon", 0.0) for b in transit_bodies}
+
+        day_aspects = []
+        for t_name, c_name, asp_name, asp_angle, max_orb in aspect_checks:
+            if t_name not in transit_lons or c_name not in composite_lons:
+                continue
+            t_lon = transit_lons[t_name]
+            c_lon = composite_lons[c_name]
+            diff = abs(t_lon - c_lon)
+            diff = min(diff, 360.0 - diff)
+            orb = abs(diff - asp_angle)
+            if orb > max_orb:
+                continue
+            significance = astrology.get_transit_significance(t_name, c_name, asp_name, orb, max_orb)
+            if significance < min_significance:
+                continue
+            t_speed = transit_speeds.get(t_name, 0.0)
+            applying = astrology._is_applying(t_lon, c_lon, t_speed, 0.0, asp_angle)
+            day_aspects.append({
+                "transiting": t_name,
+                "composite": c_name,
+                "aspect": asp_name,
+                "orb": round(orb, 4),
+                "applying": applying,
+                "significance": significance,
+            })
+
+        day_aspects.sort(key=lambda a: a["significance"], reverse=True)
+        days.append({
+            "date": current_day.strftime("%Y-%m-%d"),
+            "aspects": day_aspects,
+            "count": len(day_aspects),
+        })
+        current_day += timedelta(days=1)
+
+    return {
+        "input": {
+            "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
+            "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
+            "start_date": start_date,
+            "end_date": end_date,
+        },
+        "days": days,
+        "total_aspects": sum(d["count"] for d in days),
+    }
+
+
+# ── Tool: get_davison_transit_preview ─────────────────────────────────
+
+@mcp.tool()
+async def get_davison_transit_preview(
+    person1_datetime: str,
+    person1_latitude: float,
+    person1_longitude: float,
+    person2_datetime: str,
+    person2_latitude: float,
+    person2_longitude: float,
+    start_date: str,
+    end_date: str,
+    min_significance: float = 0.0,
+) -> dict[str, Any]:
+    """Daily transit-to-Davison chart aspect snapshot over a time range.
+
+Calculates the Davison chart for two people, then shows transiting aspects to
+Davison planet positions for each day. Use for timing relationship milestones
+and long-term evolution tracking.
+
+For interpretation guidance, fetch resource: astro://guides/relationship-astrology
+
+Args:
+    person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
+    person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
+    start_date: Start of range (YYYY-MM-DD).
+    end_date: End of range (YYYY-MM-DD). Maximum 365 days.
+    min_significance: Minimum significance score (0-10) to include. Default 0 = all.
+
+Returns:
+    Daily snapshots with active transit-to-Davison aspects, sorted by date.
+    Each day: date, aspects (transiting, davison, orb, applying, significance), count."""
+    from datetime import datetime, timedelta, timezone
+
+    # Calculate Davison chart
+    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
+    mid_lat = (person1_latitude + person2_latitude) / 2
+    mid_lon = (person1_longitude + person2_longitude) / 2
+    davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
+
+    sky = await call_sky_state(datetime=davison_dt, lat=mid_lat, lon=mid_lon, geocentric=True)
+    if "error" in sky:
+        return {"error": f"davison ephemeris error: {sky['error']}"}
+
+    raw_bodies = extract_bodies(sky)
+    davison_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in raw_bodies}
+
+    # Parse date range
+    try:
+        start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
+        end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
+    except Exception:
+        return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
+    if end <= start:
+        return {"error": "end_date must be after start_date"}
+    if (end - start).days > 365:
+        return {"error": "Date range too large. Maximum 365 days."}
+
+    # Build aspect checks
+    aspect_checks = []
+    for t_name in astrology.TRANSIT_ORB_RADII:
+        for d_name in davison_lons:
+            for asp_def in astrology.ASPECT_DEFINITIONS:
+                asp_name = asp_def["name"]
+                asp_angle = asp_def["angle"]
+                max_orb = astrology.get_transit_orb(t_name, d_name, asp_name)
+                aspect_checks.append((t_name, d_name, asp_name, asp_angle, max_orb))
+
+    # Daily scan
+    days = []
+    current_day = start
+    while current_day <= end:
+        iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
+        sky = await call_sky_state(datetime=iso, lat=0.0, lon=0.0, geocentric=True)
+        if "error" in sky:
+            current_day += timedelta(days=1)
+            continue
+
+        transit_bodies = extract_bodies(sky)
+        transit_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in transit_bodies}
+        transit_speeds = {b["body"]: b.get("speed_lon", 0.0) for b in transit_bodies}
+
+        day_aspects = []
+        for t_name, d_name, asp_name, asp_angle, max_orb in aspect_checks:
+            if t_name not in transit_lons or d_name not in davison_lons:
+                continue
+            t_lon = transit_lons[t_name]
+            d_lon = davison_lons[d_name]
+            diff = abs(t_lon - d_lon)
+            diff = min(diff, 360.0 - diff)
+            orb = abs(diff - asp_angle)
+            if orb > max_orb:
+                continue
+            significance = astrology.get_transit_significance(t_name, d_name, asp_name, orb, max_orb)
+            if significance < min_significance:
+                continue
+            t_speed = transit_speeds.get(t_name, 0.0)
+            applying = astrology._is_applying(t_lon, d_lon, t_speed, 0.0, asp_angle)
+            day_aspects.append({
+                "transiting": t_name,
+                "davison": d_name,
+                "aspect": asp_name,
+                "orb": round(orb, 4),
+                "applying": applying,
+                "significance": significance,
+            })
+
+        day_aspects.sort(key=lambda a: a["significance"], reverse=True)
+        days.append({
+            "date": current_day.strftime("%Y-%m-%d"),
+            "aspects": day_aspects,
+            "count": len(day_aspects),
+        })
+        current_day += timedelta(days=1)
+
+    return {
+        "input": {
+            "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
+            "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
+            "start_date": start_date,
+            "end_date": end_date,
+        },
+        "days": days,
+        "total_aspects": sum(d["count"] for d in days),
+    }
+
+

+ 185 - 0
src/astro_mcp/person_tools.py

@@ -0,0 +1,185 @@
+"""
+Person database management tools for astro-mcp.
+
+CRUD operations for the persons table, plus house system listing.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from .server import mcp
+from . import storage
+
+# ── 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,
+    birthplace: str | None = None,
+    latitude: float | None = None,
+    longitude: float | None = None,
+    elevation: float | None = None,
+    alive: bool | None = None,
+    private: bool | None = None,
+    gender: str | None = None,
+    description: str | None = None,
+    notes: str | None = None,
+    tz: str | None = None,
+    birth_time_known: bool | None = None,
+) -> dict[str, Any]:
+    """Manage persons in the birth data database.
+
+    Store and retrieve birth data for individuals. Persons can be referenced by
+    ID or nickname in all _byId tool variants.
+
+    Actions:
+    - add: Create a new person (requires: name, birth_datetime, latitude, longitude)
+    - get: Retrieve by person_id or nickname
+    - list: List all persons
+    - update: Modify fields (requires: person_id)
+    - delete: Remove person (requires: person_id)
+
+    After adding persons, use the _byId tools (e.g., calculate_natal_chart_by_id)
+    so you don't need to pass birth data repeatedly.
+
+    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 (used with _byId tools).
+        birth_datetime: ISO 8601 naive local time, no offset (e.g. "1990-05-15T10:30:00").
+        birthplace: Optional birth place name (e.g., "Zurich, Switzerland").
+        latitude: Birth latitude (required for add).
+        longitude: Birth longitude (required for add).
+        elevation: Birth elevation in meters.
+        alive: Whether the person is alive (default: True).
+        private: Hidden from public listing (default: False).
+        gender: Person's gender (male/female/other).
+        description: Short one-line description or summary.
+        notes: Freeform longer notes.
+        tz: IANA timezone name for birthplace (e.g., "Europe/Vienna").
+        birth_time_known: Whether the birth time is accurate (default: True).
+
+    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,
+            birthplace=birthplace,
+            alive=alive if alive is not None else True,
+            private=private if private is not None else False,
+            gender=gender,
+            description=description,
+            notes=notes,
+            tz=tz,
+            birth_time_known=birth_time_known if birth_time_known is not None else True,
+        )
+        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,
+            birthplace=birthplace,
+            latitude=latitude,
+            longitude=longitude,
+            elevation=elevation,
+            alive=alive,
+            private=private,
+            gender=gender,
+            description=description,
+            notes=notes,
+            tz=tz,
+            birth_time_known=birth_time_known,
+        )
+        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.
+
+    All systems are computed server-side by the Swiss Ephemeris when
+    house_system is passed to get_sky_state or chart calculation tools.
+
+    Returns:
+        Object with systems array containing id and description for each."""
+    return {
+        "systems": [
+            {"id": "placidus", "name": "Placidus", "description": "Most common system; houses based on time divisions of the diurnal arc. Default."},
+            {"id": "koch", "name": "Koch", "description": "Based on the birth location and time; popular in the US."},
+            {"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. Vedic/traditional."},
+            {"id": "alcabitius", "name": "Alcabitius", "description": "Divides the diurnal and nocturnal arcs into equal 30° segments."},
+            {"id": "campanus", "name": "Campanus", "description": "Divides the prime vertical into 30° segments."},
+            {"id": "morinus", "name": "Morinus", "description": "Equal division of the celestial equator."},
+            {"id": "porphyry", "name": "Porphyry", "description": "Trisection of the arc between the four angles."},
+            {"id": "regiomontanus", "name": "Regiomontanus", "description": "Divides the celestial equator, projected onto the ecliptic."},
+            {"id": "polich_page", "name": "Polich/Page", "description": "Topocentric house system based on the ASC and MC."},
+            {"id": "krusinski", "name": "Krusinski-Pisa", "description": "A topocentric system with equal house sizes near the equator."},
+            {"id": "vehlow", "name": "Vehlow Equal", "description": "Equal houses with 15° Aries as the first cusp."},
+            {"id": "meridian", "name": "Meridian", "description": "Equal division of the celestial equator, different projection."},
+            {"id": "horizontal", "name": "Horizontal", "description": "Based on the local horizon."},
+            {"id": "azimuthal", "name": "Azimuthal", "description": "Equal division of the azimuthal circle."},
+            {"id": "equal_mc", "name": "Equal/MC", "description": "Equal houses with the MC as the 10th cusp."},
+            {"id": "carter", "name": "Carter poli-eq", "description": "A polar-equatorial house system."},
+            {"id": "equal_15", "name": "Equal from 15° Aries", "description": "Equal houses starting from 15° Aries."},
+            {"id": "gauquelin", "name": "Gauquelin sectors", "description": "36 sectors based on diurnal motion, used in statistical astrology."},
+            {"id": "sunshine", "name": "Sunshine", "description": "Based on the Sun's diurnal arc."},
+            {"id": "pullen_sd", "name": "Pullen SD", "description": "A sinusoidal division house system."},
+            {"id": "pullen_sr", "name": "Pullen SR", "description": "A sinusoidal regression house system."},
+            {"id": "sripati", "name": "Sripati", "description": "A Vedic house system combining Porphyry and Whole Sign."},
+            {"id": "apc", "name": "APC houses", "description": "A system used in the Association for Astrological Networking."},
+        ],
+    }
+
+

+ 836 - 0
src/astro_mcp/render_tools.py

@@ -0,0 +1,836 @@
+"""
+Chart rendering tools for astro-mcp.
+
+Render visual chart wheels (SVG/PNG/JPG) from calculated chart data.
+Combines calculation + rendering in one step.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from .server import mcp
+from .chart_renderer import (
+    render_natal_wheel,
+    render_transit_wheel,
+    render_synastry_wheel,
+)
+from .chart_tools import (
+    calculate_natal_chart,
+    calculate_transit_chart,
+    calculate_synastry_chart,
+    calculate_composite_chart,
+    calculate_davison_chart,
+)
+from .by_id_tools import (
+    calculate_natal_chart_by_id,
+    calculate_transit_chart_by_id,
+    calculate_synastry_chart_by_id,
+    calculate_composite_chart_by_id,
+    calculate_davison_chart_by_id,
+)
+
+# ═══════════════════════════════════════════════════════════════════════
+# CHART RENDERING TOOLS
+# ═══════════════════════════════════════════════════════════════════════
+# These tools render visual chart wheels from birth data.
+# They combine calculation + rendering in one step.
+# For data-only output, use the calculate_* tools instead.
+# ═══════════════════════════════════════════════════════════════════════
+
+# ── Shared render options (used by all render_* tools) ────────────────
+
+_RENDER_STYLE_HELP = (
+    "Chart visual style: 'modern' (clean, minimal), 'traditional' "
+    "(ornate, classical), or 'minimal' (bare bones)."
+)
+_RENDER_COLOR_HELP = (
+    "Color mode: 'color' (full color with element-themed zodiac ring), "
+    "'bw' (black/white, aspect lines distinguished by style), or "
+    "'dark' (dark background for web display)."
+)
+_RENDER_SIZE_HELP = "SVG width/height in pixels (default: 600)."
+_RENDER_TABLE_HELP = "Include an aspect table below the wheel."
+_RENDER_PLANETS_HELP = "Include a planet data table below the wheel."
+_RENDER_HOUSES_HELP = "Include a house cusp table below the wheel."
+_RENDER_TITLE_HELP = "Custom chart title. Auto-generated if not provided."
+
+
+# ── render_natal_chart ────────────────────────────────────────────────
+
+@mcp.tool()
+async def render_natal_chart(
+    # ── Birth data (same as 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,
+    top_n_aspects: int | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+    subtitle: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a natal chart wheel.
+
+    Calculates planetary positions and renders a visual zodiac wheel with
+    planets, houses, and aspect lines. Output as SVG or raster image (PNG/JPG).
+
+    BIRTH DATA (required):
+        birth_datetime: ISO 8601 datetime with timezone (e.g. "1990-05-15T10:30:00+01:00").
+        latitude: Birth latitude in decimal degrees (-90 to 90).
+        longitude: Birth longitude in decimal degrees (-180 to 180).
+
+    BIRTH DATA (optional):
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides, e.g. {"conjunction": 10}.
+        top_n_aspects: Limit aspects to the N tightest by orb.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait layout with tables
+            under the wheel), or "right" (landscape layout with tables to the right).
+        include_planets: Include a planet data table (requires table_position != "none").
+        include_houses: Include a house cusp table (requires table_position != "none").
+        title: Custom chart title. Auto-generated if not provided.
+        subtitle: Custom subtitle. Auto-generated from birth data if not provided.
+        format: Output format — "svg" (default), "png", or "jpg".
+
+    Returns:
+        Dict with "content", "format", "content_type", "width", "height",
+        and "included" (list of what's in the chart).
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_natal_chart(
+        birth_datetime=birth_datetime,
+        latitude=latitude,
+        longitude=longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+        subtitle=subtitle,
+        format=format,
+    )
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
+
+
+# ── render_natal_chart_by_id ──────────────────────────────────────────
+
+@mcp.tool()
+async def render_natal_chart_by_id(
+    # ── Person lookup (same as calculate_natal_chart_by_id) ─────────
+    person_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    top_n_aspects: int | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a natal chart wheel for a person from the database.
+
+    Looks up birth data by person_id or nickname, calculates the chart,
+    and renders it as a wheel. Output as SVG or raster image (PNG/JPG).
+
+    PERSON LOOKUP (required):
+        person_id: ID or nickname of a person in the persons database.
+
+    PERSON LOOKUP (optional):
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+        top_n_aspects: Limit aspects to the N tightest by orb.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg" (SVG string), "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_natal_chart_by_id(
+        person_id=person_id,
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+        format=format,
+    )
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
+
+
+# ── render_transit_chart ──────────────────────────────────────────────
+
+@mcp.tool()
+async def render_transit_chart(
+    # ── Birth data + transit date (same as calculate_transit_chart) ─
+    birth_datetime: str,
+    transit_datetime: str,
+    latitude: float,
+    longitude: float,
+    transit_latitude: float | None = None,
+    transit_longitude: float | None = None,
+    elevation: float = 0.0,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a transit chart as a bi-wheel (natal inner, transit outer).
+
+    Calculates natal and transiting planet positions, then renders a bi-wheel
+    showing natal planets inside and transiting planets outside, with
+    transit-to-natal aspect lines. Output as SVG or raster image (PNG/JPG).
+
+    BIRTH DATA (required):
+        birth_datetime: ISO 8601 birth datetime with timezone.
+        transit_datetime: ISO 8601 transit datetime (the "now" or future date).
+        latitude: Birth latitude in decimal degrees.
+        longitude: Birth longitude in decimal degrees.
+
+    TRANSIT LOCATION (optional):
+        transit_latitude: Location latitude for transit calculation. Defaults to birth latitude.
+        transit_longitude: Location longitude for transit calculation. Defaults to birth longitude.
+
+    CHART OPTIONS:
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        title: {_RENDER_TITLE_HELP}
+        format: Output format — "svg" (default), "png", or "jpg".
+
+    Returns:
+        Dict with "content", "format", "content_type", "width", "height", "included".
+    """
+    from .chart_renderer import render_transit_wheel
+
+    chart_data = await calculate_transit_chart(
+        birth_datetime=birth_datetime,
+        transit_datetime=transit_datetime,
+        latitude=latitude,
+        longitude=longitude,
+        transit_latitude=transit_latitude,
+        transit_longitude=transit_longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_transit_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        title=title,
+        format=format,
+    )
+    result["included"] = ["wheel"]
+    return result
+
+
+# ── render_transit_chart_by_id ────────────────────────────────────────
+
+@mcp.tool()
+async def render_transit_chart_by_id(
+    # ── Person lookup + transit date ────────────────────────────────
+    person_id: str,
+    transit_datetime: str,
+    transit_latitude: float | None = None,
+    transit_longitude: float | None = None,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a transit bi-wheel for a person from the database.
+
+    Looks up birth data by person_id, calculates transits for the given
+    date, and renders a bi-wheel chart. Output as SVG or raster image (PNG/JPG).
+
+    PERSON LOOKUP (required):
+        person_id: ID or nickname of a person in the persons database.
+        transit_datetime: ISO 8601 transit datetime.
+
+    TRANSIT LOCATION (optional):
+        transit_latitude: Location latitude. Defaults to birth latitude.
+        transit_longitude: Location longitude. Defaults to birth longitude.
+
+    CHART OPTIONS:
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_transit_wheel
+
+    chart_data = await calculate_transit_chart_by_id(
+        person_id=person_id,
+        transit_datetime=transit_datetime,
+        transit_latitude=transit_latitude,
+        transit_longitude=transit_longitude,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_transit_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        title=title,
+        format=format,
+    )
+    result["included"] = ["wheel"]
+    return result
+
+
+# ── render_synastry_chart ─────────────────────────────────────────────
+
+@mcp.tool()
+async def render_synastry_chart(
+    # ── Two people's birth data (same as 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,
+    top_n_aspects: int | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 800,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a synastry (relationship) chart with two side-by-side wheels.
+
+    Calculates both natal charts and renders them side by side with
+    interaspect lines between the two charts. Output as SVG or raster image (PNG/JPG).
+
+    PERSON 1 (required):
+        person1_datetime: ISO 8601 birth datetime with timezone.
+        person1_latitude: Birth latitude in decimal degrees.
+        person1_longitude: Birth longitude in decimal degrees.
+
+    PERSON 2 (required):
+        person2_datetime: ISO 8601 birth datetime with timezone.
+        person2_latitude: Birth latitude in decimal degrees.
+        person2_longitude: Birth longitude in decimal degrees.
+
+    CHART OPTIONS:
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+        top_n_aspects: Limit interaspects to the N tightest by orb.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_synastry_wheel
+
+    chart_data = await calculate_synastry_chart(
+        person1_datetime=person1_datetime,
+        person1_latitude=person1_latitude,
+        person1_longitude=person1_longitude,
+        person2_datetime=person2_datetime,
+        person2_latitude=person2_latitude,
+        person2_longitude=person2_longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_synastry_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        title=title,
+        format=format,
+    )
+    result["included"] = ["wheel"]
+    return result
+
+
+# ── render_synastry_chart_by_id ───────────────────────────────────────
+
+@mcp.tool()
+async def render_synastry_chart_by_id(
+    # ── Two person IDs ──────────────────────────────────────────────
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    top_n_aspects: int | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 800,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a synastry chart for two people from the database.
+
+    Looks up both persons by ID or nickname, calculates their synastry,
+    and renders side-by-side natal wheels with interaspect lines.
+    Output as SVG or raster image (PNG/JPG).
+
+    PERSON LOOKUP (required):
+        person1_id: ID or nickname of person 1 in the persons database.
+        person2_id: ID or nickname of person 2 in the persons database.
+
+    CHART OPTIONS:
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+        top_n_aspects: Limit interaspects to the N tightest by orb.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_synastry_wheel
+
+    chart_data = await calculate_synastry_chart_by_id(
+        person1_id=person1_id,
+        person2_id=person2_id,
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_synastry_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        title=title,
+        format=format,
+    )
+    result["included"] = ["wheel"]
+    return result
+
+
+# ── render_composite_chart ────────────────────────────────────────────
+
+@mcp.tool()
+async def render_composite_chart(
+    # ── Two people's birth data (same as calculate_composite_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,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a composite chart (midpoint method) as a single wheel.
+
+    Calculates the composite chart from two people's birth data and renders
+    it as a standard natal-style wheel representing the relationship.
+    Output as SVG or raster image (PNG/JPG).
+
+    PERSON 1 (required):
+        person1_datetime: ISO 8601 birth datetime with timezone.
+        person1_latitude: Birth latitude in decimal degrees.
+        person1_longitude: Birth longitude in decimal degrees.
+
+    PERSON 2 (required):
+        person2_datetime: ISO 8601 birth datetime with timezone.
+        person2_latitude: Birth latitude in decimal degrees.
+        person2_longitude: Birth longitude in decimal degrees.
+
+    CHART OPTIONS:
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_composite_chart(
+        person1_datetime=person1_datetime,
+        person1_latitude=person1_latitude,
+        person1_longitude=person1_longitude,
+        person2_datetime=person2_datetime,
+        person2_latitude=person2_latitude,
+        person2_longitude=person2_longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+        format=format,
+    )
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
+
+
+# ── render_composite_chart_by_id ──────────────────────────────────────
+
+@mcp.tool()
+async def render_composite_chart_by_id(
+    # ── Two person IDs ──────────────────────────────────────────────
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a composite chart for two people from the database.
+
+    Looks up both persons by ID, calculates the composite chart, and
+    renders it as a single natal-style wheel. Output as SVG or raster image (PNG/JPG).
+
+    PERSON LOOKUP (required):
+        person1_id: ID or nickname of person 1 in the persons database.
+        person2_id: ID or nickname of person 2 in the persons database.
+
+    CHART OPTIONS:
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+        format: Output format — "svg" (default), "png", or "jpg".
+
+    Returns:
+        Dict with "content", "format", "content_type", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_composite_chart_by_id(
+        person1_id=person1_id,
+        person2_id=person2_id,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+        format=format,
+    )
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
+
+
+# ── render_davison_chart ──────────────────────────────────────────────
+
+@mcp.tool()
+async def render_davison_chart(
+    # ── Two people's birth data (same as calculate_davison_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,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a Davison chart (midpoint in time and space) as a single wheel.
+
+    Calculates the Davison chart from two people's birth data and renders
+    it as a standard natal-style wheel. Output as SVG or raster image (PNG/JPG).
+
+    PERSON 1 (required):
+        person1_datetime: ISO 8601 birth datetime with timezone.
+        person1_latitude: Birth latitude in decimal degrees.
+        person1_longitude: Birth longitude in decimal degrees.
+
+    PERSON 2 (required):
+        person2_datetime: ISO 8601 birth datetime with timezone.
+        person2_latitude: Birth latitude in decimal degrees.
+        person2_longitude: Birth longitude in decimal degrees.
+
+    CHART OPTIONS:
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_davison_chart(
+        person1_datetime=person1_datetime,
+        person1_latitude=person1_latitude,
+        person1_longitude=person1_longitude,
+        person2_datetime=person2_datetime,
+        person2_latitude=person2_latitude,
+        person2_longitude=person2_longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+        format=format,
+    )
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
+
+
+# ── render_davison_chart_by_id ────────────────────────────────────────
+
+@mcp.tool()
+async def render_davison_chart_by_id(
+    # ── Two person IDs ──────────────────────────────────────────────
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+    format: str = "svg",
+) -> dict[str, Any]:
+    """Render a Davison chart for two people from the database.
+
+    Looks up both persons by ID, calculates the Davison chart, and
+    renders it as a single natal-style wheel. Output as SVG or raster image.
+
+    PERSON LOOKUP (required):
+        person1_id: ID or nickname of person 1 in the persons database.
+        person2_id: ID or nickname of person 2 in the persons database.
+
+    CHART OPTIONS:
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_davison_chart_by_id(
+        person1_id=person1_id,
+        person2_id=person2_id,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    result = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+        format=format,
+    )
+    result["included"] = _included_list(table_position, include_planets, include_houses)
+    return result
+
+
+# ── Helper ────────────────────────────────────────────────────────────
+
+def _included_list(table_position: str, planets: bool, houses: bool) -> list[str]:
+    result = ["wheel"]
+    if table_position in ("below", "right"):
+        if planets:
+            result.append("planet_table")
+        if houses:
+            result.append("house_table")
+    return result
+
+

+ 57 - 2696
src/astro_mcp/tools.py

@@ -1,2705 +1,66 @@
 """
 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.
+This module is a thin facade that re-exports all tools from the specialized
+submodules. It exists for backward compatibility — server.py imports this
+module to trigger @mcp.tool() registration, and tests import tools.* to
+access individual functions.
+
+Tool modules:
+    chart_tools   — direct-call chart calculation functions
+    by_id_tools   — database-backed _byId chart tools
+    person_tools  — person database CRUD + house system listing
+    render_tools  — chart rendering (SVG/PNG/JPG) tools
 """
 
 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, extract_houses, extract_angles
-
-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.
-
-    Use for quick position lookups without full chart calculation. For complete natal
-    chart interpretation, use calculate_natal_chart instead.
-
-    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 echo, 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,
-    include_overview: bool = False,
-    include_patterns: bool = False,
-    include_karmic: bool = False,
-    top_n_aspects: int | None = None,
-) -> dict[str, Any]:
-    """Calculate a complete natal chart from birth data.
-
-    birth_datetime MUST be UTC. For DB-backed charts, use the _byId variant
-    (calculate_natal_chart_by_id) which handles timezone conversion automatically.
-
-PRIMARY TOOL for natal astrology. Returns planetary positions, houses, aspects,
-and angles. Use the optional flags to add interpretation layers.
-
-WORKFLOW:
-1. Basic chart: call without flags to get planets, houses, aspects, angles.
-2. Add overview (include_overview=true): element/modality/hemisphere balance,
-   stelliums, empty houses, chart ruler, house rulers, retrograde list.
-3. Add patterns (include_patterns=true): T-square, Grand Trine, Grand Cross,
-   Yod detection + chart shape (bundle, bowl, splash, locomotive, seesaw, splay).
-4. Add karmic (include_karmic=true): nodal axis, Saturn, Pluto polarity point,
-   Part of Fortune, 12th house, nodal/Saturn hard aspects.
-5. Full reading: all three flags true.
-
-For interpretation guidance, fetch resource: astro://guides/natal-astrology
-
-Args:
-    birth_datetime: ISO 8601 birth datetime with timezone (e.g., "1990-05-15T10:30:00+01:00").
-    latitude: Birth latitude in decimal degrees (-90 to 90).
-    longitude: Birth longitude in decimal degrees (-180 to 180).
-    elevation: Birth elevation in meters (default: 0).
-    house_system: "placidus" (default), "equal", or "whole_sign".
-    orb_limits: Optional per-aspect orb overrides, e.g., {"conjunction": 10}.
-    include_overview: Add element/modality/hemisphere balance, stelliums, empty houses,
-        chart ruler, house rulers, planet groupings, retrograde list.
-    include_patterns: Add aspect pattern detection and chart shape classification.
-    include_karmic: Add nodal axis, Saturn, Pluto polarity point, Part of Fortune,
-        12th house, and karmic aspect filters.
-    top_n_aspects: Limit aspects output to the N tightest by orb.
-
-Returns:
-    Dict with: input, chart_type, planets, houses, aspects, angles, lunar_phase,
-    and optionally: overview, aspect_patterns, chart_shape, karmic."""
-    # birth_datetime is UTC: _byId callers pre-convert via _get_person_birth_data,
-    # direct-call users are responsible for passing UTC or offset-aware datetime.
-    sky = await call_sky_state(
-        datetime=birth_datetime,
-        lat=latitude,
-        lon=longitude,
-        elevation=elevation,
-        geocentric=True,
-        house_system=house_system,
-    )
-
-    if "error" in sky:
-        return {"input": {"birth_datetime": birth_datetime, "latitude": latitude, "longitude": longitude}, "error": sky["error"]}
-
-    raw_bodies = extract_bodies(sky)
-
-    # Houses and angles from server-side Swiss Ephemeris
-    houses = extract_houses(sky)
-    angles = extract_angles(sky)
-
-    # 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 (pass speed_lon for applying/separating detection)
-    speed_lookup = {b["body"]: b.get("speed_lon") for b in raw_bodies}
-    aspect_bodies = [
-        {"name": p["body"], "lon": p["absolute_lon"], "speed_lon": speed_lookup.get(p["body"])}
-        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"],
-        })
-
-    # Build result
-    result: dict[str, Any] = {
-        "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,
-    }
-
-    # Add lunar phase from ephemeris
-    lunar_state = sky.get("lunar_state", {})
-    lunar = lunar_state.get("lunar_state", {}) if isinstance(lunar_state, dict) else {}
-    if lunar:
-        result["lunar_phase"] = {
-            "phase_name": lunar.get("phase_name"),
-            "illumination_fraction": lunar.get("illumination_fraction"),
-            "age_days": lunar.get("age_days"),
-        }
-
-    # Limit aspects if requested
-    if top_n_aspects is not None:
-        result["aspects"] = formatted_aspects[:top_n_aspects]
-
-    # Overview section
-    if include_overview:
-        asc_sign = angles.get("ascendant", {}).get("sign", "")
-        overview: dict[str, Any] = {
-            "element_balance": astrology.get_element_balance(planets),
-            "modality_balance": astrology.get_modality_balance(planets),
-            "hemisphere_emphasis": astrology.get_hemisphere_emphasis(planets),
-            "stelliums": astrology.detect_stelliums(planets),
-            "empty_houses": astrology.get_empty_houses(planets),
-            "chart_ruler": astrology.get_chart_ruler(asc_sign, planets),
-            "house_rulers": astrology.get_house_rulers(houses, planets),
-            "planets_by_house": astrology.group_planets_by_house(planets),
-            "planets_by_sign": astrology.group_planets_by_sign(planets),
-            "house_type_counts": astrology.get_house_type_counts(planets),
-            "retrograde_planets": astrology.get_retrograde_planets(planets),
-        }
-        result["overview"] = overview
-
-    # Aspect patterns + chart shape
-    if include_patterns:
-        patterns = astrology.detect_aspect_patterns(planets, formatted_aspects)
-        chart_shape = astrology.detect_chart_shape(planets)
-        result["aspect_patterns"] = patterns
-        result["chart_shape"] = chart_shape
-
-    # Karmic analysis
-    if include_karmic:
-        asc_lon = angles.get("ascendant", {}).get("absolute_lon", 0.0)
-        sun_lon = astrology._planet_lon("sun", planets) or 0.0
-        moon_lon = astrology._planet_lon("moon", planets) or 0.0
-
-        karmic: dict[str, Any] = {
-            "nodal_axis": astrology.get_nodal_axis(planets, houses),
-            "saturn": astrology.get_saturn_info(planets),
-            "pluto_polarity_point": astrology.get_pluto_polarity_point(planets, houses),
-            "part_of_fortune": astrology.get_part_of_fortune(asc_lon, sun_lon, moon_lon, houses),
-            "twelfth_house": astrology.get_twelfth_house_analysis(houses, planets),
-            "nodal_aspects": astrology.get_natal_aspects_to_planets(
-                formatted_aspects, {"true_node"}
-            ),
-            "saturn_aspects": astrology.get_natal_aspects_to_planets(
-                formatted_aspects, {"saturn"}, astrology.HARD_ASPECTS
-            ),
-        }
-        result["karmic"] = karmic
-
-    return result
-
-
-# ── Tool: calculate_transit_chart ────────────────────────────────────
-
-@mcp.tool()
-async def calculate_transit_chart(
-    birth_datetime: str,
-    transit_datetime: str,
-    latitude: float,
-    longitude: float,
-    transit_latitude: float | None = None,
-    transit_longitude: float | None = None,
-    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.
-
-Shows how current (or future) transiting planets aspect the natal chart. Useful for
-identifying active transit windows and their themes. For daily transit snapshots,
-use get_transit_preview instead.
-
-For interpretation guidance, fetch resources:
-- astro://guides/natal-astrology
-- astro://guides/financial-astrology (for market-timing and economic cycle analysis)
-
-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.
-    transit_latitude: Current location latitude for transit calculation. Defaults to birth latitude.
-    transit_longitude: Current location longitude for transit calculation. Defaults to birth longitude.
-    elevation: Birth elevation in meters.
-    house_system: House system for natal houses (default: Placidus).
-    orb_limits: Optional orb configuration.
-
-Returns:
-    Transit chart with natal_planets, transiting_planets, aspects (transit-to-natal),
-    and houses."""
-    # Default transit location to birth location if not specified
-    t_lat = transit_latitude if transit_latitude is not None else latitude
-    t_lon = transit_longitude if transit_longitude is not None else longitude
-
-    # Get natal sky state
-    natal_sky = await call_sky_state(
-        datetime=birth_datetime,
-        lat=latitude,
-        lon=longitude,
-        elevation=elevation,
-        geocentric=True,
-        house_system=house_system,
-    )
-
-    # Get transit sky state at transit location
-    transit_sky = await call_sky_state(
-        datetime=transit_datetime,
-        lat=t_lat,
-        lon=t_lon,
-        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)
-
-    # Houses from server-side Swiss Ephemeris
-    houses = extract_houses(natal_sky)
-
-    # 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,
-    top_n_aspects: int | None = None,
-    karmic_filter: bool = False,
-    significator_filter: bool = False,
-    include_davison_full: bool = False,
-) -> dict[str, Any]:
-    """Calculate a synastry (relationship) chart for two people.
-
-PRIMARY TOOL for relationship astrology. Returns interchart aspects, house overlays,
-composite chart, and Davison chart data. Use filters to focus on specific themes.
-
-WORKFLOW:
-1. Basic synastry: call without flags → get all interaspects + house overlays.
-2. Karmic focus: karmic_filter=true → only Saturn/Pluto/Node interaspects.
-3. Romantic focus: significator_filter=true → only Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn.
-4. Include Davison: include_davison_full=true → full Davison chart with planets/houses/aspects.
-
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
-
-For karmic relationship analysis, also see: get_karmic_relationship_summary
-
-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.
-    top_n_aspects: Limit interaspects to top N by orb.
-    karmic_filter: Only return Saturn/Pluto/Node interaspects.
-    significator_filter: Only return Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn pairs.
-    include_davison_full: Compute full Davison chart (planets, houses, aspects, angles).
-
-Returns:
-    Dict with: input, chart_type, chart1_natal, chart2_natal, interaspects,
-    house_overlays, composite_chart, davison_chart, summary (top_aspects,
-    saturn_contacts, node_contacts, venus_mars_contacts, sun_moon_contacts)."""
-    sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, elevation=elevation, house_system=house_system)
-    sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, elevation=elevation, house_system=house_system)
-
-    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)
-
-    # Houses from server-side Swiss Ephemeris
-    houses1 = extract_houses(sky1)
-    houses2 = extract_houses(sky2)
-
-    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"])
-
-    # Apply filters
-    filtered_aspects = interaspects
-    if karmic_filter:
-        karmic_planets = {"saturn", "pluto", "true_node"}
-        filtered_aspects = [
-            a for a in filtered_aspects
-            if a["person1_planet"] in karmic_planets or a["person2_planet"] in karmic_planets
-        ]
-    if significator_filter:
-        significator_pairs = {
-            frozenset(["venus", "mars"]), frozenset(["moon", "venus"]),
-            frozenset(["sun", "moon"]), frozenset(["sun", "saturn"]),
-        }
-        filtered_aspects = [
-            a for a in filtered_aspects
-            if frozenset([a["person1_planet"], a["person2_planet"]]) in significator_pairs
-        ]
-    if top_n_aspects is not None:
-        filtered_aspects = filtered_aspects[:top_n_aspects]
-
-    # 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
-    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
-    davison_mid_lat = (person1_latitude + person2_latitude) / 2
-    davison_mid_lon = (person1_longitude + person2_longitude) / 2
-
-    davison_result: dict[str, Any] = {
-        "date_midpoint_jd": davison["date_midpoint_jd"],
-        "latitude_midpoint": davison_mid_lat,
-        "longitude_midpoint": davison_mid_lon,
-    }
-
-    # Full Davison chart if requested
-    if include_davison_full:
-        davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
-        davison_sky = await call_sky_state(
-            datetime=davison_dt, lat=davison_mid_lat, lon=davison_mid_lon,
-            elevation=0.0, geocentric=True, house_system=house_system,
-        )
-        if "error" not in davison_sky:
-            davison_raw = extract_bodies(davison_sky)
-            davison_houses = extract_houses(davison_sky)
-
-            davison_planets = []
-            for body in davison_raw:
-                ecl_lon = body.get("ecliptic_lon", 0.0)
-                z = astrology.ecliptic_to_zodiac(ecl_lon)
-                house = astrology.get_house_placement(ecl_lon, davison_houses)
-                davison_planets.append({
-                    "body": body["body"],
-                    "sign": z["sign"],
-                    "degree_within_sign": z["degree"],
-                    "absolute_lon": z["absolute_lon"],
-                    "house": house,
-                    "retrograde": astrology.is_retrograde(body.get("speed_lon")),
-                })
-
-            davison_aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in davison_planets]
-            davison_aspects = astrology.compute_aspects(davison_aspect_bodies, orb_limits)
-            davison_formatted = []
-            for asp in davison_aspects:
-                davison_formatted.append({
-                    "body1": asp["body1"],
-                    "body2": asp["body2"],
-                    "aspect": asp["aspect"],
-                    "orb": asp["orb"],
-                    "applying": asp["applying"],
-                    "exactness": asp["exactness"],
-                })
-
-            davison_angles = extract_angles(davison_sky)
-
-            davison_result["planets"] = davison_planets
-            davison_result["houses"] = davison_houses
-            davison_result["aspects"] = davison_formatted
-            davison_result["angles"] = davison_angles
-
-    # Build summary
-    summary: dict[str, Any] = {
-        "top_aspects": interaspects[:15],
-        "saturn_contacts": [a for a in interaspects if a["person1_planet"] == "saturn" or a["person2_planet"] == "saturn"],
-        "node_contacts": [a for a in interaspects if a["person1_planet"] == "true_node" or a["person2_planet"] == "true_node"],
-        "venus_mars_contacts": [a for a in interaspects if frozenset([a["person1_planet"], a["person2_planet"]]) == frozenset(["venus", "mars"])],
-        "sun_moon_contacts": [a for a in interaspects if frozenset([a["person1_planet"], a["person2_planet"]]) == frozenset(["sun", "moon"])],
-    }
-
-    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": filtered_aspects,
-        "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": davison_result,
-        "summary": summary,
-    }
-
-
-def _jd_to_datetime(jd: float) -> str:
-    """Convert Julian Day to ISO 8601 datetime string."""
-    from datetime import datetime, timezone, timedelta
-    # JD 2440587.5 = 1970-01-01T00:00:00Z
-    unix_seconds = (jd - 2440587.5) * 86400.0
-    dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc)
-    return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
-
-
-# ── Tool: get_transit_preview ────────────────────────────────────────
-
-@mcp.tool()
-async def get_transit_preview(
-    birth_datetime: str,
-    latitude: float,
-    longitude: float,
-    start_date: str,
-    end_date: str,
-    transit_latitude: float | None = None,
-    transit_longitude: float | None = None,
-    min_significance: float = 0.0,
-) -> dict[str, Any]:
-    """Daily transit-to-natal aspect snapshot over a time range.
-
-Shows which transiting planets aspect which natal planets for each day. Significance
-score (0-10) based on aspect type, planet importance, and orb tightness.
-
-Use this for identifying active transit windows and forecasting themes. For a single
-transit moment, use calculate_transit_chart instead.
-
-For interpretation guidance, fetch resources:
-- astro://guides/natal-astrology
-- astro://guides/financial-astrology (for market-timing and economic cycle analysis)
-
-Args:
-    birth_datetime: ISO 8601 birth datetime (UTC).
-    latitude: Birth latitude in decimal degrees.
-    longitude: Birth longitude in decimal degrees.
-    start_date: Start of range (YYYY-MM-DD).
-    end_date: End of range (YYYY-MM-DD). Maximum 365 days.
-    transit_latitude: Current location latitude. Defaults to birth latitude.
-    transit_longitude: Current location longitude. Defaults to birth longitude.
-    min_significance: Minimum significance score (0-10) to include. Default 0 = all.
-
-Returns:
-    Daily snapshots with active transit-to-natal aspects, sorted by date.
-    Each day: date, aspects (with orb, applying/separating, significance), count."""
-    from datetime import datetime, timedelta, timezone
-
-    from . import astrology
-
-    birth_dt = birth_datetime
-    birth_lat = latitude
-    birth_lon = longitude
-    t_lat = transit_latitude if transit_latitude is not None else birth_lat
-    t_lon = transit_longitude if transit_longitude is not None else birth_lon
-
-    # Parse date range
-    try:
-        start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
-        end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
-    except Exception:
-        return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
-
-    if end <= start:
-        return {"error": "end_date must be after start_date"}
-
-    if (end - start).days > 365:
-        return {"error": "Date range too large. Maximum 365 days."}
-
-    # Get natal planet positions (one ephemeris call)
-    natal_sky = await call_sky_state(
-        datetime=birth_dt, lat=birth_lat, lon=birth_lon, geocentric=True,
-    )
-    if "error" in natal_sky:
-        return {"error": f"natal ephemeris error: {natal_sky['error']}"}
-
-    natal_bodies = extract_bodies(natal_sky)
-    natal_lons = {b["body"]: b["ecliptic_lon"] for b in natal_bodies}
-
-    # Build list of all transit-to-natal aspect checks with per-pair orbs
-    aspect_checks = []
-    for t_name in astrology.TRANSIT_ORB_RADII:
-        for n_name in natal_lons:
-            for asp_def in astrology.ASPECT_DEFINITIONS:
-                asp_name = asp_def["name"]
-                asp_angle = asp_def["angle"]
-                max_orb = astrology.get_transit_orb(t_name, n_name, asp_name)
-                aspect_checks.append((t_name, n_name, asp_name, asp_angle, max_orb))
-
-    # Daily scan
-    days = []
-    current_day = start
-    while current_day <= end:
-        iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
-        sky = await call_sky_state(
-            datetime=iso, lat=t_lat, lon=t_lon, geocentric=True,
-        )
-        if "error" in sky:
-            current_day += timedelta(days=1)
-            continue
-
-        transit_bodies = extract_bodies(sky)
-        transit_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in transit_bodies}
-        transit_speeds = {b["body"]: b.get("speed_lon", 0.0) for b in transit_bodies}
-
-        day_aspects = []
-        for t_name, n_name, asp_name, asp_angle, max_orb in aspect_checks:
-            if t_name not in transit_lons or n_name not in natal_lons:
-                continue
-
-            t_lon = transit_lons[t_name]
-            n_lon = natal_lons[n_name]
-            diff = abs(t_lon - n_lon)
-            diff = min(diff, 360.0 - diff)
-            orb = abs(diff - asp_angle)
-
-            if orb > max_orb:
-                continue
-
-            significance = astrology.get_transit_significance(t_name, n_name, asp_name, orb, max_orb)
-            if significance < min_significance:
-                continue
-
-            # Determine applying/separating
-            t_speed = transit_speeds.get(t_name, 0.0)
-            applying = astrology._is_applying(t_lon, n_lon, t_speed, 0.0, asp_angle)
-
-            day_aspects.append({
-                "transiting": t_name,
-                "natal": n_name,
-                "aspect": asp_name,
-                "orb": round(orb, 4),
-                "applying": applying,
-                "significance": significance,
-            })
-
-        # Sort by significance (highest first)
-        day_aspects.sort(key=lambda a: a["significance"], reverse=True)
-
-        days.append({
-            "date": current_day.strftime("%Y-%m-%d"),
-            "aspects": day_aspects,
-            "count": len(day_aspects),
-        })
-
-        current_day += timedelta(days=1)
-
-    return {
-        "input": {
-            "birth_datetime": birth_datetime,
-            "latitude": latitude,
-            "longitude": longitude,
-            "start_date": start_date,
-            "end_date": end_date,
-            "min_significance": min_significance,
-        },
-        "days": days,
-        "total_aspects": sum(d["count"] for d in days),
-    }
-
-
-# ── 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,
-    birthplace: str | None = None,
-    latitude: float | None = None,
-    longitude: float | None = None,
-    elevation: float | None = None,
-    alive: bool | None = None,
-    private: bool | None = None,
-    gender: str | None = None,
-    description: str | None = None,
-    notes: str | None = None,
-    tz: str | None = None,
-    birth_time_known: bool | None = None,
-) -> dict[str, Any]:
-    """Manage persons in the birth data database.
-
-    Store and retrieve birth data for individuals. Persons can be referenced by
-    ID or nickname in all _byId tool variants.
-
-    Actions:
-    - add: Create a new person (requires: name, birth_datetime, latitude, longitude)
-    - get: Retrieve by person_id or nickname
-    - list: List all persons
-    - update: Modify fields (requires: person_id)
-    - delete: Remove person (requires: person_id)
-
-    After adding persons, use the _byId tools (e.g., calculate_natal_chart_by_id)
-    so you don't need to pass birth data repeatedly.
-
-    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 (used with _byId tools).
-        birth_datetime: ISO 8601 naive local time, no offset (e.g. "1990-05-15T10:30:00").
-        birthplace: Optional birth place name (e.g., "Zurich, Switzerland").
-        latitude: Birth latitude (required for add).
-        longitude: Birth longitude (required for add).
-        elevation: Birth elevation in meters.
-        alive: Whether the person is alive (default: True).
-        private: Hidden from public listing (default: False).
-        gender: Person's gender (male/female/other).
-        description: Short one-line description or summary.
-        notes: Freeform longer notes.
-        tz: IANA timezone name for birthplace (e.g., "Europe/Vienna").
-        birth_time_known: Whether the birth time is accurate (default: True).
-
-    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,
-            birthplace=birthplace,
-            alive=alive if alive is not None else True,
-            private=private if private is not None else False,
-            gender=gender,
-            description=description,
-            notes=notes,
-            tz=tz,
-            birth_time_known=birth_time_known if birth_time_known is not None else True,
-        )
-        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,
-            birthplace=birthplace,
-            latitude=latitude,
-            longitude=longitude,
-            elevation=elevation,
-            alive=alive,
-            private=private,
-            gender=gender,
-            description=description,
-            notes=notes,
-            tz=tz,
-            birth_time_known=birth_time_known,
-        )
-        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: calculate_composite_chart ──────────────────────────────────
-
-@mcp.tool()
-async def calculate_composite_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 composite chart (midpoint method) for two people.
-
-The composite chart represents the relationship itself as a single chart, calculated
-by taking the midpoint of each pair of planetary positions. It shows the relationship's
-identity, structure, and public face.
-
-For relationship timing, use get_composite_transit_preview.
-
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
-
-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:
-    Composite chart with planets, houses, aspects, angles, and composite_location.
-    Composite orbs: 3° max (tighter than natal). Use tight orbs for interpretation."""
-    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)
-
-    # Composite planets via midpoint method
-    composite_bodies = astrology.compute_composite_chart(
-        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies1],
-        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies2],
-    )
-
-    # Composite location: midpoint of birth locations
-    comp_lat = (person1_latitude + person2_latitude) / 2
-    comp_lon = (person1_longitude + person2_longitude) / 2
-
-    # Use composite datetime for house calculation
-    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
-
-    # Get composite sky state for houses and angles
-    # Use a date near the midpoint for house calculation
-    comp_sky = await call_sky_state(
-        datetime=person1_datetime, lat=comp_lat, lon=comp_lon, elevation=elevation,
-        house_system=house_system,
-    )
-    if "error" in comp_sky:
-        return {"error": f"composite ephemeris error: {comp_sky['error']}"}
-
-    houses = extract_houses(comp_sky)
-
-    # Build composite planet list with house placement
-    composite_planets = []
-    for cb in composite_bodies:
-        ecl_lon = cb["lon"]
-        zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
-        house = astrology.get_house_placement(ecl_lon, houses)
-        composite_planets.append({
-            "body": cb["name"],
-            "sign": zodiac["sign"],
-            "sign_abbreviation": zodiac["abbreviation"],
-            "degree_within_sign": zodiac["degree"],
-            "absolute_lon": zodiac["absolute_lon"],
-            "house": house,
-        })
-
-    # Aspects
-    aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in composite_planets]
-    aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
-    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"],
-        })
-
-    angles = extract_angles(comp_sky)
-
-    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": "composite",
-        "planets": composite_planets,
-        "houses": houses,
-        "aspects": formatted_aspects,
-        "angles": angles,
-        "composite_location": {"latitude": comp_lat, "longitude": comp_lon},
-    }
-
-
-# ── Tool: calculate_davison_chart ─────────────────────────────────────
-
-@mcp.tool()
-async def calculate_davison_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 Davison chart (midpoint in time and space) for two people.
-
-The Davison chart is a real moment in time (unlike the composite which is purely
-symbolic). It represents the relationship's inner experience and emotional tone.
-It can be progressed and directed like a natal chart.
-
-For relationship timing, use get_davison_transit_preview.
-
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
-
-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:
-    Davison chart with: chart_type, date_midpoint_jd, location_midpoint,
-    planets, houses, aspects, angles."""
-    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
-    mid_lat = (person1_latitude + person2_latitude) / 2
-    mid_lon = (person1_longitude + person2_longitude) / 2
-    davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
-
-    sky = await call_sky_state(
-        datetime=davison_dt, lat=mid_lat, lon=mid_lon,
-        elevation=elevation, geocentric=True, house_system=house_system,
-    )
-    if "error" in sky:
-        return {"error": f"davison ephemeris error: {sky['error']}"}
-
-    raw_bodies = extract_bodies(sky)
-    houses = extract_houses(sky)
-
-    planets = []
-    for body in raw_bodies:
-        ecl_lon = body.get("ecliptic_lon", 0.0)
-        z = astrology.ecliptic_to_zodiac(ecl_lon)
-        house = astrology.get_house_placement(ecl_lon, houses)
-        planets.append({
-            "body": body["body"],
-            "sign": z["sign"],
-            "degree_within_sign": z["degree"],
-            "absolute_lon": z["absolute_lon"],
-            "house": house,
-            "retrograde": astrology.is_retrograde(body.get("speed_lon")),
-        })
-
-    aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in planets]
-    aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
-    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"],
-        })
-
-    angles = extract_angles(sky)
-
-    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": "davison",
-        "date_midpoint_jd": davison["date_midpoint_jd"],
-        "location_midpoint": {"latitude": mid_lat, "longitude": mid_lon},
-        "planets": planets,
-        "houses": houses,
-        "aspects": formatted_aspects,
-        "angles": angles,
-    }
-
-
-# ── Tool: get_composite_transit_preview ───────────────────────────────
-
-@mcp.tool()
-async def get_composite_transit_preview(
-    person1_datetime: str,
-    person1_latitude: float,
-    person1_longitude: float,
-    person2_datetime: str,
-    person2_latitude: float,
-    person2_longitude: float,
-    start_date: str,
-    end_date: str,
-    min_significance: float = 0.0,
-) -> dict[str, Any]:
-    """Daily transit-to-composite chart aspect snapshot over a time range.
-
-Calculates the composite chart for two people, then shows transiting aspects to
-composite planet positions for each day. Use for timing relationship events and
-identifying when relationship themes are activated.
-
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
-
-Args:
-    person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
-    person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
-    start_date: Start of range (YYYY-MM-DD).
-    end_date: End of range (YYYY-MM-DD). Maximum 365 days.
-    min_significance: Minimum significance score (0-10) to include. Default 0 = all.
-
-Returns:
-    Daily snapshots with active transit-to-composite aspects, sorted by date.
-    Each day: date, aspects (transiting, composite, orb, applying, significance), count."""
-    from datetime import datetime, timedelta, timezone
-
-    # Calculate composite chart
-    sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, geocentric=True)
-    sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, geocentric=True)
-    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)
-    composite_bodies = astrology.compute_composite_chart(
-        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies1],
-        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies2],
-    )
-    composite_lons = {b["name"]: b["lon"] for b in composite_bodies}
-
-    # Parse date range
-    try:
-        start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
-        end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
-    except Exception:
-        return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
-    if end <= start:
-        return {"error": "end_date must be after start_date"}
-    if (end - start).days > 365:
-        return {"error": "Date range too large. Maximum 365 days."}
-
-    # Build aspect checks
-    aspect_checks = []
-    for t_name in astrology.TRANSIT_ORB_RADII:
-        for c_name in composite_lons:
-            for asp_def in astrology.ASPECT_DEFINITIONS:
-                asp_name = asp_def["name"]
-                asp_angle = asp_def["angle"]
-                max_orb = astrology.get_transit_orb(t_name, c_name, asp_name)
-                aspect_checks.append((t_name, c_name, asp_name, asp_angle, max_orb))
-
-    # Daily scan
-    days = []
-    current_day = start
-    while current_day <= end:
-        iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
-        sky = await call_sky_state(datetime=iso, lat=0.0, lon=0.0, geocentric=True)
-        if "error" in sky:
-            current_day += timedelta(days=1)
-            continue
-
-        transit_bodies = extract_bodies(sky)
-        transit_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in transit_bodies}
-        transit_speeds = {b["body"]: b.get("speed_lon", 0.0) for b in transit_bodies}
-
-        day_aspects = []
-        for t_name, c_name, asp_name, asp_angle, max_orb in aspect_checks:
-            if t_name not in transit_lons or c_name not in composite_lons:
-                continue
-            t_lon = transit_lons[t_name]
-            c_lon = composite_lons[c_name]
-            diff = abs(t_lon - c_lon)
-            diff = min(diff, 360.0 - diff)
-            orb = abs(diff - asp_angle)
-            if orb > max_orb:
-                continue
-            significance = astrology.get_transit_significance(t_name, c_name, asp_name, orb, max_orb)
-            if significance < min_significance:
-                continue
-            t_speed = transit_speeds.get(t_name, 0.0)
-            applying = astrology._is_applying(t_lon, c_lon, t_speed, 0.0, asp_angle)
-            day_aspects.append({
-                "transiting": t_name,
-                "composite": c_name,
-                "aspect": asp_name,
-                "orb": round(orb, 4),
-                "applying": applying,
-                "significance": significance,
-            })
-
-        day_aspects.sort(key=lambda a: a["significance"], reverse=True)
-        days.append({
-            "date": current_day.strftime("%Y-%m-%d"),
-            "aspects": day_aspects,
-            "count": len(day_aspects),
-        })
-        current_day += timedelta(days=1)
-
-    return {
-        "input": {
-            "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
-            "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
-            "start_date": start_date,
-            "end_date": end_date,
-        },
-        "days": days,
-        "total_aspects": sum(d["count"] for d in days),
-    }
-
-
-# ── Tool: get_davison_transit_preview ─────────────────────────────────
-
-@mcp.tool()
-async def get_davison_transit_preview(
-    person1_datetime: str,
-    person1_latitude: float,
-    person1_longitude: float,
-    person2_datetime: str,
-    person2_latitude: float,
-    person2_longitude: float,
-    start_date: str,
-    end_date: str,
-    min_significance: float = 0.0,
-) -> dict[str, Any]:
-    """Daily transit-to-Davison chart aspect snapshot over a time range.
-
-Calculates the Davison chart for two people, then shows transiting aspects to
-Davison planet positions for each day. Use for timing relationship milestones
-and long-term evolution tracking.
-
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
-
-Args:
-    person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
-    person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
-    start_date: Start of range (YYYY-MM-DD).
-    end_date: End of range (YYYY-MM-DD). Maximum 365 days.
-    min_significance: Minimum significance score (0-10) to include. Default 0 = all.
-
-Returns:
-    Daily snapshots with active transit-to-Davison aspects, sorted by date.
-    Each day: date, aspects (transiting, davison, orb, applying, significance), count."""
-    from datetime import datetime, timedelta, timezone
-
-    # Calculate Davison chart
-    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
-    mid_lat = (person1_latitude + person2_latitude) / 2
-    mid_lon = (person1_longitude + person2_longitude) / 2
-    davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
-
-    sky = await call_sky_state(datetime=davison_dt, lat=mid_lat, lon=mid_lon, geocentric=True)
-    if "error" in sky:
-        return {"error": f"davison ephemeris error: {sky['error']}"}
-
-    raw_bodies = extract_bodies(sky)
-    davison_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in raw_bodies}
-
-    # Parse date range
-    try:
-        start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
-        end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
-    except Exception:
-        return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
-    if end <= start:
-        return {"error": "end_date must be after start_date"}
-    if (end - start).days > 365:
-        return {"error": "Date range too large. Maximum 365 days."}
-
-    # Build aspect checks
-    aspect_checks = []
-    for t_name in astrology.TRANSIT_ORB_RADII:
-        for d_name in davison_lons:
-            for asp_def in astrology.ASPECT_DEFINITIONS:
-                asp_name = asp_def["name"]
-                asp_angle = asp_def["angle"]
-                max_orb = astrology.get_transit_orb(t_name, d_name, asp_name)
-                aspect_checks.append((t_name, d_name, asp_name, asp_angle, max_orb))
-
-    # Daily scan
-    days = []
-    current_day = start
-    while current_day <= end:
-        iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
-        sky = await call_sky_state(datetime=iso, lat=0.0, lon=0.0, geocentric=True)
-        if "error" in sky:
-            current_day += timedelta(days=1)
-            continue
-
-        transit_bodies = extract_bodies(sky)
-        transit_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in transit_bodies}
-        transit_speeds = {b["body"]: b.get("speed_lon", 0.0) for b in transit_bodies}
-
-        day_aspects = []
-        for t_name, d_name, asp_name, asp_angle, max_orb in aspect_checks:
-            if t_name not in transit_lons or d_name not in davison_lons:
-                continue
-            t_lon = transit_lons[t_name]
-            d_lon = davison_lons[d_name]
-            diff = abs(t_lon - d_lon)
-            diff = min(diff, 360.0 - diff)
-            orb = abs(diff - asp_angle)
-            if orb > max_orb:
-                continue
-            significance = astrology.get_transit_significance(t_name, d_name, asp_name, orb, max_orb)
-            if significance < min_significance:
-                continue
-            t_speed = transit_speeds.get(t_name, 0.0)
-            applying = astrology._is_applying(t_lon, d_lon, t_speed, 0.0, asp_angle)
-            day_aspects.append({
-                "transiting": t_name,
-                "davison": d_name,
-                "aspect": asp_name,
-                "orb": round(orb, 4),
-                "applying": applying,
-                "significance": significance,
-            })
-
-        day_aspects.sort(key=lambda a: a["significance"], reverse=True)
-        days.append({
-            "date": current_day.strftime("%Y-%m-%d"),
-            "aspects": day_aspects,
-            "count": len(day_aspects),
-        })
-        current_day += timedelta(days=1)
-
-    return {
-        "input": {
-            "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
-            "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
-            "start_date": start_date,
-            "end_date": end_date,
-        },
-        "days": days,
-        "total_aspects": sum(d["count"] for d in days),
-    }
-
-
-# ── Tool: get_karmic_relationship_summary ─────────────────────────────
-
-@mcp.tool()
-async def get_karmic_relationship_summary(
-    person1_id: str,
-    person2_id: str,
-    house_system: str = "placidus",
-) -> dict[str, Any]:
-    """Generate a karmic relationship summary from synastry, composite, and Davison charts.
-
-Combines karmic indicators across all three relationship chart layers:
-- Synastry: Saturn/Pluto/Node interchart aspects
-- Composite: Saturn, Pluto, Node positions
-- Davison: Saturn, Pluto, Node positions
-
-This is the PRIMARY tool for karmic relationship analysis. It consolidates
-the key indicators into a single structured report with a karmic weight score.
-
-For interpretation guidance, fetch resources:
-- astro://guides/karmic-astrology
-- astro://guides/relationship-astrology
-
-Args:
-    person1_id: ID or nickname of person 1 in the persons database.
-    person2_id: ID or nickname of person 2 in the persons database.
-    house_system: House system (default: Placidus).
-
-Returns:
-    Dict with: karmic_weight (composite score), synastry_karmic_aspects,
-    composite_karmic_planets, davison_karmic_planets, summary
-    (saturn/pluto/node contact counts)."""
-    # Get synastry with karmic filter
-    synastry = await calculate_synastry_chart_by_id(
-        person1_id, person2_id,
-        house_system=house_system,
-        karmic_filter=True,
-    )
-    if "error" in synastry:
-        return synastry
-
-    # Get composite chart
-    composite = await calculate_composite_chart_by_id(
-        person1_id, person2_id,
-        house_system=house_system,
-    )
-    if "error" in composite:
-        return composite
-
-    # Get Davison chart
-    davison = await calculate_davison_chart_by_id(
-        person1_id, person2_id,
-        house_system=house_system,
-    )
-    if "error" in davison:
-        return davison
-
-    def _extract_karmic_planets(chart_data: dict, key: str) -> dict:
-        """Extract Saturn, Pluto, Node from a chart's planet list."""
-        result = {}
-        for p in chart_data.get(key, []):
-            if p["body"] in ("saturn", "pluto", "true_node"):
-                result[p["body"]] = {
-                    "sign": p.get("sign"),
-                    "house": p.get("house"),
-                    "retrograde": p.get("retrograde"),
-                }
-        return result
-
-    synastry_karmic_aspects = synastry.get("interaspects", [])
-    composite_karmic = _extract_karmic_planets(composite, "planets")
-    davison_karmic = _extract_karmic_planets(davison, "planets")
-
-    # Count karmic weight
-    karmic_weight = len(synastry_karmic_aspects)
-    if composite_karmic.get("saturn"):
-        karmic_weight += 1
-    if composite_karmic.get("pluto"):
-        karmic_weight += 1
-    if davison_karmic.get("saturn"):
-        karmic_weight += 1
-    if davison_karmic.get("pluto"):
-        karmic_weight += 1
-
-    return {
-        "karmic_weight": karmic_weight,
-        "synastry_karmic_aspects": synastry_karmic_aspects,
-        "composite_karmic_planets": composite_karmic,
-        "davison_karmic_planets": davison_karmic,
-        "summary": {
-            "saturn_contacts": len([a for a in synastry_karmic_aspects if "saturn" in (a["person1_planet"], a["person2_planet"])]),
-            "pluto_contacts": len([a for a in synastry_karmic_aspects if "pluto" in (a["person1_planet"], a["person2_planet"])]),
-            "node_contacts": len([a for a in synastry_karmic_aspects if "true_node" in (a["person1_planet"], a["person2_planet"])]),
-        },
-    }
-
-
-# ── Tool: list_house_systems ─────────────────────────────────────────
-
-@mcp.tool()
-def list_house_systems() -> dict[str, Any]:
-    """List supported house systems.
-
-    All systems are computed server-side by the Swiss Ephemeris when
-    house_system is passed to get_sky_state or chart calculation tools.
-
-    Returns:
-        Object with systems array containing id and description for each."""
-    return {
-        "systems": [
-            {"id": "placidus", "name": "Placidus", "description": "Most common system; houses based on time divisions of the diurnal arc. Default."},
-            {"id": "koch", "name": "Koch", "description": "Based on the birth location and time; popular in the US."},
-            {"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. Vedic/traditional."},
-            {"id": "alcabitius", "name": "Alcabitius", "description": "Divides the diurnal and nocturnal arcs into equal 30° segments."},
-            {"id": "campanus", "name": "Campanus", "description": "Divides the prime vertical into 30° segments."},
-            {"id": "morinus", "name": "Morinus", "description": "Equal division of the celestial equator."},
-            {"id": "porphyry", "name": "Porphyry", "description": "Trisection of the arc between the four angles."},
-            {"id": "regiomontanus", "name": "Regiomontanus", "description": "Divides the celestial equator, projected onto the ecliptic."},
-            {"id": "polich_page", "name": "Polich/Page", "description": "Topocentric house system based on the ASC and MC."},
-            {"id": "krusinski", "name": "Krusinski-Pisa", "description": "A topocentric system with equal house sizes near the equator."},
-            {"id": "vehlow", "name": "Vehlow Equal", "description": "Equal houses with 15° Aries as the first cusp."},
-            {"id": "meridian", "name": "Meridian", "description": "Equal division of the celestial equator, different projection."},
-            {"id": "horizontal", "name": "Horizontal", "description": "Based on the local horizon."},
-            {"id": "azimuthal", "name": "Azimuthal", "description": "Equal division of the azimuthal circle."},
-            {"id": "equal_mc", "name": "Equal/MC", "description": "Equal houses with the MC as the 10th cusp."},
-            {"id": "carter", "name": "Carter poli-eq", "description": "A polar-equatorial house system."},
-            {"id": "equal_15", "name": "Equal from 15° Aries", "description": "Equal houses starting from 15° Aries."},
-            {"id": "gauquelin", "name": "Gauquelin sectors", "description": "36 sectors based on diurnal motion, used in statistical astrology."},
-            {"id": "sunshine", "name": "Sunshine", "description": "Based on the Sun's diurnal arc."},
-            {"id": "pullen_sd", "name": "Pullen SD", "description": "A sinusoidal division house system."},
-            {"id": "pullen_sr", "name": "Pullen SR", "description": "A sinusoidal regression house system."},
-            {"id": "sripati", "name": "Sripati", "description": "A Vedic house system combining Porphyry and Whole Sign."},
-            {"id": "apc", "name": "APC houses", "description": "A system used in the Association for Astrological Networking."},
-        ],
-    }
-
-
-# ── _byId convenience tools ──────────────────────────────────────────
-# These tools accept a person_id (from the DB) and optional overrides,
-# then call the core chart tools with the person's birth data.
-# Unprovided optional params fall back to the default of the core tool.
-
-
-async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
-    """Fetch birth data from the persons database and convert datetime to UTC.
-
-    This is the SINGLE POINT where stored birth datetime is converted to UTC.
-    All _byId chart tools call this function and receive UTC datetimes — they
-    must NOT perform any additional timezone conversion.
-
-    The DB stores birth_datetime as naive local time (no offset) and timezone
-    as an IANA name (e.g. "Europe/Vienna"). This function combines them to
-    produce the correct UTC datetime, using zoneinfo for modern dates and
-    LMT (longitude/15) as fallback when no timezone is set.
-
-    Rule: birth_datetime in the returned dict is ALWAYS naive local time.
-    birth_datetime_utc is for internal ephemeris calls only.
-    """
-    from . import storage
-    from .ephemeris_client import _normalize_datetime
-    person = await storage.get_person(person_id=person_id)
-    if not person:
-        person = await storage.get_person(nickname=person_id)
-    if not person:
-        return {"error": f"person not found: {person_id}"}
-    utc_dt = _normalize_datetime(
-        person["birth_datetime"],
-        tz_name=person.get("timezone"),
-        lon=person["longitude"],
-    )
-    return {
-        "birth_datetime": person["birth_datetime"],   # naive local time
-        "timezone": person.get("timezone"),
-        "birth_datetime_utc": utc_dt,                  # for ephemeris calls
-        "birthplace": person.get("birthplace"),
-        "latitude": person["latitude"],
-        "longitude": person["longitude"],
-        "elevation": person.get("elevation", 0.0),
-    }
-
-
-@mcp.tool()
-async def calculate_natal_chart_by_id(
-    person_id: str,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-    include_overview: bool = False,
-    include_patterns: bool = False,
-    include_karmic: bool = False,
-    top_n_aspects: int | None = None,
-) -> dict[str, Any]:
-    """Calculate natal chart for a person from the database.
-
-    Same as calculate_natal_chart but fetches birth data from the persons database
-    by ID or nickname. See calculate_natal_chart for full documentation.
-
-    For interpretation guidance, fetch resource: astro://guides/natal-astrology
-
-    Args:
-        person_id: ID or nickname of a person in the persons database.
-        house_system: House system (default: Placidus).
-        orb_limits: Optional orb configuration.
-        include_overview: Add element/modality/hemisphere balance, stelliums, etc.
-        include_patterns: Add aspect pattern detection and chart shape.
-        include_karmic: Add nodal axis, Saturn, Pluto polarity point, etc.
-        top_n_aspects: Limit aspects to the N tightest by orb.
-
-    Returns:
-        Complete natal chart structure (see calculate_natal_chart)."""
-    birth = await _get_person_birth_data(person_id)
-    if "error" in birth:
-        return birth
-    result = await calculate_natal_chart(
-        birth_datetime=birth["birth_datetime_utc"],
-        latitude=birth["latitude"],
-        longitude=birth["longitude"],
-        elevation=birth.get("elevation", 0.0),
-        house_system=house_system,
-        orb_limits=orb_limits,
-        include_overview=include_overview,
-        include_patterns=include_patterns,
-        include_karmic=include_karmic,
-        top_n_aspects=top_n_aspects,
-    )
-    if "error" not in result:
-        result["input"]["birth_datetime"] = birth["birth_datetime"]
-        result["input"]["timezone"] = birth.get("timezone")
-    return result
-
-
-@mcp.tool()
-async def calculate_transit_chart_by_id(
-    person_id: str,
-    transit_datetime: str,
-    transit_latitude: float | None = None,
-    transit_longitude: float | None = None,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-) -> dict[str, Any]:
-    """Calculate transit chart for a person from the database.
-
-Same as calculate_transit_chart but fetches birth data from the persons database.
-
-For interpretation guidance, fetch resource: astro://guides/natal-astrology
-
-Args:
-    person_id: ID of a person in the persons database.
-    transit_datetime: ISO 8601 transit datetime (UTC).
-    transit_latitude: Current location latitude. Defaults to birth latitude.
-    transit_longitude: Current location longitude. Defaults to birth longitude.
-    house_system: House system for natal houses (default: Placidus).
-    orb_limits: Optional orb configuration.
-
-Returns:
-    Transit chart structure (see calculate_transit_chart)."""
-    birth = await _get_person_birth_data(person_id)
-    if "error" in birth:
-        return birth
-    result = await calculate_transit_chart(
-        birth_datetime=birth["birth_datetime_utc"],
-        transit_datetime=transit_datetime,
-        latitude=birth["latitude"],
-        longitude=birth["longitude"],
-        transit_latitude=transit_latitude,
-        transit_longitude=transit_longitude,
-        elevation=birth.get("elevation", 0.0),
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" not in result:
-        result["input"]["birth_datetime"] = birth["birth_datetime"]
-        result["input"]["timezone"] = birth.get("timezone")
-    return result
-
-
-@mcp.tool()
-async def calculate_synastry_chart_by_id(
-    person1_id: str,
-    person2_id: str,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-    top_n_aspects: int | None = None,
-    karmic_filter: bool = False,
-    significator_filter: bool = False,
-    include_davison_full: bool = False,
-) -> dict[str, Any]:
-    """Calculate synastry chart for two persons from the database.
-
-    Same as calculate_synastry_chart but fetches birth data from the persons database
-    by ID or nickname. See calculate_synastry_chart for full documentation.
-
-    For interpretation guidance, fetch resource: astro://guides/relationship-astrology
-
-    Args:
-        person1_id: ID of person 1 in the persons database.
-        person2_id: ID of person 2 in the persons database.
-        house_system: House system (default: Placidus).
-        orb_limits: Optional orb configuration.
-        top_n_aspects: Limit interaspects to top N by orb.
-        karmic_filter: Only return Saturn/Pluto/Node interaspects.
-        significator_filter: Only return Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn.
-        include_davison_full: Compute full Davison chart.
-
-    Returns:
-        Synastry chart structure (see calculate_synastry_chart)."""
-    p1 = await _get_person_birth_data(person1_id)
-    if "error" in p1:
-        return p1
-    p2 = await _get_person_birth_data(person2_id)
-    if "error" in p2:
-        return p2
-    result = await calculate_synastry_chart(
-        person1_datetime=p1["birth_datetime_utc"],
-        person1_latitude=p1["latitude"],
-        person1_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime_utc"],
-        person2_latitude=p2["latitude"],
-        person2_longitude=p2["longitude"],
-        elevation=p1.get("elevation", 0.0),
-        house_system=house_system,
-        orb_limits=orb_limits,
-        top_n_aspects=top_n_aspects,
-        karmic_filter=karmic_filter,
-        significator_filter=significator_filter,
-        include_davison_full=include_davison_full,
-    )
-    if "error" not in result:
-        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
-        result["input"]["person1_timezone"] = p1.get("timezone")
-        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
-        result["input"]["person2_timezone"] = p2.get("timezone")
-    return result
-
-
-@mcp.tool()
-async def calculate_composite_chart_by_id(
-    person1_id: str,
-    person2_id: str,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-) -> dict[str, Any]:
-    """Calculate composite chart for two persons from the database.
-
-Same as calculate_composite_chart but fetches birth data from the persons database.
-
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
-
-Args:
-    person1_id: ID of person 1 in the persons database.
-    person2_id: ID of person 2 in the persons database.
-    house_system: House system (default: Placidus).
-    orb_limits: Optional orb configuration.
-
-Returns:
-    Composite chart structure (see calculate_composite_chart)."""
-    p1 = await _get_person_birth_data(person1_id)
-    if "error" in p1:
-        return p1
-    p2 = await _get_person_birth_data(person2_id)
-    if "error" in p2:
-        return p2
-    result = await calculate_composite_chart(
-        person1_datetime=p1["birth_datetime_utc"],
-        person1_latitude=p1["latitude"],
-        person1_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime_utc"],
-        person2_latitude=p2["latitude"],
-        person2_longitude=p2["longitude"],
-        elevation=p1.get("elevation", 0.0),
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" not in result:
-        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
-        result["input"]["person1_timezone"] = p1.get("timezone")
-        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
-        result["input"]["person2_timezone"] = p2.get("timezone")
-    return result
-
-
-@mcp.tool()
-async def calculate_davison_chart_by_id(
-    person1_id: str,
-    person2_id: str,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-) -> dict[str, Any]:
-    """Calculate Davison chart for two persons from the database.
-
-Same as calculate_davison_chart but fetches birth data from the persons database.
-
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
-
-Args:
-    person1_id: ID of person 1 in the persons database.
-    person2_id: ID of person 2 in the persons database.
-    house_system: House system (default: Placidus).
-    orb_limits: Optional orb configuration.
-
-Returns:
-    Davison chart structure (see calculate_davison_chart)."""
-    p1 = await _get_person_birth_data(person1_id)
-    if "error" in p1:
-        return p1
-    p2 = await _get_person_birth_data(person2_id)
-    if "error" in p2:
-        return p2
-    result = await calculate_davison_chart(
-        person1_datetime=p1["birth_datetime_utc"],
-        person1_latitude=p1["latitude"],
-        person1_longitude=p1["longitude"],
-        person2_datetime=p2["birth_datetime_utc"],
-        person2_latitude=p2["latitude"],
-        person2_longitude=p2["longitude"],
-        elevation=p1.get("elevation", 0.0),
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" not in result:
-        result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
-        result["input"]["person1_timezone"] = p1.get("timezone")
-        result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
-        result["input"]["person2_timezone"] = p2.get("timezone")
-    return result
-
-
-@mcp.tool()
-async def get_transit_preview_by_id(
-    person_id: str,
-    start_date: str,
-    end_date: str,
-    transit_latitude: float | None = None,
-    transit_longitude: float | None = None,
-    min_significance: float = 0.0,
-) -> dict[str, Any]:
-    """Daily transit-to-natal aspect snapshot for a person from the database.
-
-Same as get_transit_preview but fetches birth data from the persons database.
-
-For interpretation guidance, fetch resource: astro://guides/natal-astrology
-
-Args:
-    person_id: ID or nickname of a person in the persons database.
-    start_date: ISO date string for the start of the range (YYYY-MM-DD).
-    end_date: ISO date string for the end of the range (YYYY-MM-DD).
-    transit_latitude: Current location latitude. Defaults to birth latitude.
-    transit_longitude: Current location longitude. Defaults to birth longitude.
-    min_significance: Minimum significance score (0-10). Default 0 = all.
-
-Returns:
-    Daily transit snapshots (see get_transit_preview)."""
-    birth = await _get_person_birth_data(person_id)
-    if "error" in birth:
-        return birth
-    result = await get_transit_preview(
-        birth_datetime=birth["birth_datetime_utc"],
-        latitude=birth["latitude"],
-        longitude=birth["longitude"],
-        start_date=start_date,
-        end_date=end_date,
-        transit_latitude=transit_latitude,
-        transit_longitude=transit_longitude,
-        min_significance=min_significance,
-    )
-    if "error" not in result:
-        result["input"]["birth_datetime"] = birth["birth_datetime"]
-        result["input"]["timezone"] = birth.get("timezone")
-    return result
-
-
-# ═══════════════════════════════════════════════════════════════════════
-# CHART RENDERING TOOLS
-# ═══════════════════════════════════════════════════════════════════════
-# These tools render visual chart wheels from birth data.
-# They combine calculation + rendering in one step.
-# For data-only output, use the calculate_* tools instead.
-# ═══════════════════════════════════════════════════════════════════════
-
-# ── Shared render options (used by all render_* tools) ────────────────
-
-_RENDER_STYLE_HELP = (
-    "Chart visual style: 'modern' (clean, minimal), 'traditional' "
-    "(ornate, classical), or 'minimal' (bare bones)."
+# Re-export call_sky_state so existing test patches targeting
+# src.astro_mcp.tools.call_sky_state continue to work.
+from .ephemeris_client import call_sky_state  # noqa: F401
+
+# Import submodules to trigger @mcp.tool() decorator registration.
+from . import chart_tools  # noqa: F401
+from . import by_id_tools  # noqa: F401
+from . import person_tools  # noqa: F401
+from . import render_tools  # noqa: F401
+
+# Re-export all public tools for backward compatibility.
+from .chart_tools import (  # noqa: F401
+    get_planetary_positions,
+    calculate_natal_chart,
+    calculate_transit_chart,
+    calculate_synastry_chart,
+    calculate_composite_chart,
+    calculate_davison_chart,
+    get_transit_preview,
+    get_composite_transit_preview,
+    get_davison_transit_preview,
+    DEFAULT_ORBS,
 )
-_RENDER_COLOR_HELP = (
-    "Color mode: 'color' (full color with element-themed zodiac ring), "
-    "'bw' (black/white, aspect lines distinguished by style), or "
-    "'dark' (dark background for web display)."
+from .by_id_tools import (  # noqa: F401
+    _get_person_birth_data,
+    calculate_natal_chart_by_id,
+    calculate_transit_chart_by_id,
+    calculate_synastry_chart_by_id,
+    calculate_composite_chart_by_id,
+    calculate_davison_chart_by_id,
+    get_transit_preview_by_id,
+    get_karmic_relationship_summary,
+)
+from .person_tools import (  # noqa: F401
+    person_manage,
+    list_house_systems,
+)
+from .render_tools import (  # noqa: F401
+    render_natal_chart,
+    render_natal_chart_by_id,
+    render_transit_chart,
+    render_transit_chart_by_id,
+    render_synastry_chart,
+    render_synastry_chart_by_id,
+    render_composite_chart,
+    render_composite_chart_by_id,
+    render_davison_chart,
+    render_davison_chart_by_id,
 )
-_RENDER_SIZE_HELP = "SVG width/height in pixels (default: 600)."
-_RENDER_TABLE_HELP = "Include an aspect table below the wheel."
-_RENDER_PLANETS_HELP = "Include a planet data table below the wheel."
-_RENDER_HOUSES_HELP = "Include a house cusp table below the wheel."
-_RENDER_TITLE_HELP = "Custom chart title. Auto-generated if not provided."
-
-
-# ── render_natal_chart ────────────────────────────────────────────────
-
-@mcp.tool()
-async def render_natal_chart(
-    # ── Birth data (same as 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,
-    top_n_aspects: int | None = None,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 600,
-    table_position: str = "none",
-    include_planets: bool = False,
-    include_houses: bool = False,
-    title: str | None = None,
-    subtitle: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a natal chart wheel.
-
-    Calculates planetary positions and renders a visual zodiac wheel with
-    planets, houses, and aspect lines. Output as SVG or raster image (PNG/JPG).
-
-    BIRTH DATA (required):
-        birth_datetime: ISO 8601 datetime with timezone (e.g. "1990-05-15T10:30:00+01:00").
-        latitude: Birth latitude in decimal degrees (-90 to 90).
-        longitude: Birth longitude in decimal degrees (-180 to 180).
-
-    BIRTH DATA (optional):
-        elevation: Birth elevation in meters (default: 0).
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides, e.g. {"conjunction": 10}.
-        top_n_aspects: Limit aspects to the N tightest by orb.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        table_position: "none" (wheel only), "below" (portrait layout with tables
-            under the wheel), or "right" (landscape layout with tables to the right).
-        include_planets: Include a planet data table (requires table_position != "none").
-        include_houses: Include a house cusp table (requires table_position != "none").
-        title: Custom chart title. Auto-generated if not provided.
-        subtitle: Custom subtitle. Auto-generated from birth data if not provided.
-        format: Output format — "svg" (default), "png", or "jpg".
-
-    Returns:
-        Dict with "content", "format", "content_type", "width", "height",
-        and "included" (list of what's in the chart).
-    """
-    from .chart_renderer import render_natal_wheel
-
-    chart_data = await calculate_natal_chart(
-        birth_datetime=birth_datetime,
-        latitude=latitude,
-        longitude=longitude,
-        elevation=elevation,
-        house_system=house_system,
-        orb_limits=orb_limits,
-        top_n_aspects=top_n_aspects,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_natal_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        table_position=table_position,
-        include_planets=include_planets,
-        include_houses=include_houses,
-        title=title,
-        subtitle=subtitle,
-        format=format,
-    )
-    result["included"] = _included_list(table_position, include_planets, include_houses)
-    return result
-
-
-# ── render_natal_chart_by_id ──────────────────────────────────────────
-
-@mcp.tool()
-async def render_natal_chart_by_id(
-    # ── Person lookup (same as calculate_natal_chart_by_id) ─────────
-    person_id: str,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-    top_n_aspects: int | None = None,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 600,
-    table_position: str = "none",
-    include_planets: bool = False,
-    include_houses: bool = False,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a natal chart wheel for a person from the database.
-
-    Looks up birth data by person_id or nickname, calculates the chart,
-    and renders it as a wheel. Output as SVG or raster image (PNG/JPG).
-
-    PERSON LOOKUP (required):
-        person_id: ID or nickname of a person in the persons database.
-
-    PERSON LOOKUP (optional):
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-        top_n_aspects: Limit aspects to the N tightest by orb.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
-        include_planets: {_RENDER_PLANETS_HELP}
-        include_houses: {_RENDER_HOUSES_HELP}
-        title: {_RENDER_TITLE_HELP}
-
-    Returns:
-        Dict with "svg" (SVG string), "format", "width", "height", "included".
-    """
-    from .chart_renderer import render_natal_wheel
-
-    chart_data = await calculate_natal_chart_by_id(
-        person_id=person_id,
-        house_system=house_system,
-        orb_limits=orb_limits,
-        top_n_aspects=top_n_aspects,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_natal_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        table_position=table_position,
-        include_planets=include_planets,
-        include_houses=include_houses,
-        title=title,
-        format=format,
-    )
-    result["included"] = _included_list(table_position, include_planets, include_houses)
-    return result
-
-
-# ── render_transit_chart ──────────────────────────────────────────────
-
-@mcp.tool()
-async def render_transit_chart(
-    # ── Birth data + transit date (same as calculate_transit_chart) ─
-    birth_datetime: str,
-    transit_datetime: str,
-    latitude: float,
-    longitude: float,
-    transit_latitude: float | None = None,
-    transit_longitude: float | None = None,
-    elevation: float = 0.0,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 600,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a transit chart as a bi-wheel (natal inner, transit outer).
-
-    Calculates natal and transiting planet positions, then renders a bi-wheel
-    showing natal planets inside and transiting planets outside, with
-    transit-to-natal aspect lines. Output as SVG or raster image (PNG/JPG).
-
-    BIRTH DATA (required):
-        birth_datetime: ISO 8601 birth datetime with timezone.
-        transit_datetime: ISO 8601 transit datetime (the "now" or future date).
-        latitude: Birth latitude in decimal degrees.
-        longitude: Birth longitude in decimal degrees.
-
-    TRANSIT LOCATION (optional):
-        transit_latitude: Location latitude for transit calculation. Defaults to birth latitude.
-        transit_longitude: Location longitude for transit calculation. Defaults to birth longitude.
-
-    CHART OPTIONS:
-        elevation: Birth elevation in meters (default: 0).
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        title: {_RENDER_TITLE_HELP}
-        format: Output format — "svg" (default), "png", or "jpg".
-
-    Returns:
-        Dict with "content", "format", "content_type", "width", "height", "included".
-    """
-    from .chart_renderer import render_transit_wheel
-
-    chart_data = await calculate_transit_chart(
-        birth_datetime=birth_datetime,
-        transit_datetime=transit_datetime,
-        latitude=latitude,
-        longitude=longitude,
-        transit_latitude=transit_latitude,
-        transit_longitude=transit_longitude,
-        elevation=elevation,
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_transit_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        title=title,
-        format=format,
-    )
-    result["included"] = ["wheel"]
-    return result
-
-
-# ── render_transit_chart_by_id ────────────────────────────────────────
-
-@mcp.tool()
-async def render_transit_chart_by_id(
-    # ── Person lookup + transit date ────────────────────────────────
-    person_id: str,
-    transit_datetime: str,
-    transit_latitude: float | None = None,
-    transit_longitude: float | None = None,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 600,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a transit bi-wheel for a person from the database.
-
-    Looks up birth data by person_id, calculates transits for the given
-    date, and renders a bi-wheel chart. Output as SVG or raster image (PNG/JPG).
-
-    PERSON LOOKUP (required):
-        person_id: ID or nickname of a person in the persons database.
-        transit_datetime: ISO 8601 transit datetime.
-
-    TRANSIT LOCATION (optional):
-        transit_latitude: Location latitude. Defaults to birth latitude.
-        transit_longitude: Location longitude. Defaults to birth longitude.
-
-    CHART OPTIONS:
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        title: {_RENDER_TITLE_HELP}
-
-    Returns:
-        Dict with "svg", "format", "width", "height", "included".
-    """
-    from .chart_renderer import render_transit_wheel
-
-    chart_data = await calculate_transit_chart_by_id(
-        person_id=person_id,
-        transit_datetime=transit_datetime,
-        transit_latitude=transit_latitude,
-        transit_longitude=transit_longitude,
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_transit_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        title=title,
-        format=format,
-    )
-    result["included"] = ["wheel"]
-    return result
-
-
-# ── render_synastry_chart ─────────────────────────────────────────────
-
-@mcp.tool()
-async def render_synastry_chart(
-    # ── Two people's birth data (same as 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,
-    top_n_aspects: int | None = None,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 800,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a synastry (relationship) chart with two side-by-side wheels.
-
-    Calculates both natal charts and renders them side by side with
-    interaspect lines between the two charts. Output as SVG or raster image (PNG/JPG).
-
-    PERSON 1 (required):
-        person1_datetime: ISO 8601 birth datetime with timezone.
-        person1_latitude: Birth latitude in decimal degrees.
-        person1_longitude: Birth longitude in decimal degrees.
-
-    PERSON 2 (required):
-        person2_datetime: ISO 8601 birth datetime with timezone.
-        person2_latitude: Birth latitude in decimal degrees.
-        person2_longitude: Birth longitude in decimal degrees.
-
-    CHART OPTIONS:
-        elevation: Birth elevation in meters (default: 0).
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-        top_n_aspects: Limit interaspects to the N tightest by orb.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        title: {_RENDER_TITLE_HELP}
-
-    Returns:
-        Dict with "svg", "format", "width", "height", "included".
-    """
-    from .chart_renderer import render_synastry_wheel
-
-    chart_data = await calculate_synastry_chart(
-        person1_datetime=person1_datetime,
-        person1_latitude=person1_latitude,
-        person1_longitude=person1_longitude,
-        person2_datetime=person2_datetime,
-        person2_latitude=person2_latitude,
-        person2_longitude=person2_longitude,
-        elevation=elevation,
-        house_system=house_system,
-        orb_limits=orb_limits,
-        top_n_aspects=top_n_aspects,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_synastry_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        title=title,
-        format=format,
-    )
-    result["included"] = ["wheel"]
-    return result
-
-
-# ── render_synastry_chart_by_id ───────────────────────────────────────
-
-@mcp.tool()
-async def render_synastry_chart_by_id(
-    # ── Two person IDs ──────────────────────────────────────────────
-    person1_id: str,
-    person2_id: str,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-    top_n_aspects: int | None = None,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 800,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a synastry chart for two people from the database.
-
-    Looks up both persons by ID or nickname, calculates their synastry,
-    and renders side-by-side natal wheels with interaspect lines.
-    Output as SVG or raster image (PNG/JPG).
-
-    PERSON LOOKUP (required):
-        person1_id: ID or nickname of person 1 in the persons database.
-        person2_id: ID or nickname of person 2 in the persons database.
-
-    CHART OPTIONS:
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-        top_n_aspects: Limit interaspects to the N tightest by orb.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        title: {_RENDER_TITLE_HELP}
-
-    Returns:
-        Dict with "svg", "format", "width", "height", "included".
-    """
-    from .chart_renderer import render_synastry_wheel
-
-    chart_data = await calculate_synastry_chart_by_id(
-        person1_id=person1_id,
-        person2_id=person2_id,
-        house_system=house_system,
-        orb_limits=orb_limits,
-        top_n_aspects=top_n_aspects,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_synastry_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        title=title,
-        format=format,
-    )
-    result["included"] = ["wheel"]
-    return result
-
-
-# ── render_composite_chart ────────────────────────────────────────────
-
-@mcp.tool()
-async def render_composite_chart(
-    # ── Two people's birth data (same as calculate_composite_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,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 600,
-    table_position: str = "none",
-    include_planets: bool = False,
-    include_houses: bool = False,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a composite chart (midpoint method) as a single wheel.
-
-    Calculates the composite chart from two people's birth data and renders
-    it as a standard natal-style wheel representing the relationship.
-    Output as SVG or raster image (PNG/JPG).
-
-    PERSON 1 (required):
-        person1_datetime: ISO 8601 birth datetime with timezone.
-        person1_latitude: Birth latitude in decimal degrees.
-        person1_longitude: Birth longitude in decimal degrees.
-
-    PERSON 2 (required):
-        person2_datetime: ISO 8601 birth datetime with timezone.
-        person2_latitude: Birth latitude in decimal degrees.
-        person2_longitude: Birth longitude in decimal degrees.
-
-    CHART OPTIONS:
-        elevation: Birth elevation in meters (default: 0).
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
-        include_planets: {_RENDER_PLANETS_HELP}
-        include_houses: {_RENDER_HOUSES_HELP}
-        title: {_RENDER_TITLE_HELP}
-
-    Returns:
-        Dict with "svg", "format", "width", "height", "included".
-    """
-    from .chart_renderer import render_natal_wheel
-
-    chart_data = await calculate_composite_chart(
-        person1_datetime=person1_datetime,
-        person1_latitude=person1_latitude,
-        person1_longitude=person1_longitude,
-        person2_datetime=person2_datetime,
-        person2_latitude=person2_latitude,
-        person2_longitude=person2_longitude,
-        elevation=elevation,
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_natal_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        table_position=table_position,
-        include_planets=include_planets,
-        include_houses=include_houses,
-        title=title,
-        format=format,
-    )
-    result["included"] = _included_list(table_position, include_planets, include_houses)
-    return result
-
-
-# ── render_composite_chart_by_id ──────────────────────────────────────
-
-@mcp.tool()
-async def render_composite_chart_by_id(
-    # ── Two person IDs ──────────────────────────────────────────────
-    person1_id: str,
-    person2_id: str,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 600,
-    table_position: str = "none",
-    include_planets: bool = False,
-    include_houses: bool = False,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a composite chart for two people from the database.
-
-    Looks up both persons by ID, calculates the composite chart, and
-    renders it as a single natal-style wheel. Output as SVG or raster image (PNG/JPG).
-
-    PERSON LOOKUP (required):
-        person1_id: ID or nickname of person 1 in the persons database.
-        person2_id: ID or nickname of person 2 in the persons database.
-
-    CHART OPTIONS:
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
-        include_planets: {_RENDER_PLANETS_HELP}
-        include_houses: {_RENDER_HOUSES_HELP}
-        title: {_RENDER_TITLE_HELP}
-        format: Output format — "svg" (default), "png", or "jpg".
-
-    Returns:
-        Dict with "content", "format", "content_type", "width", "height", "included".
-    """
-    from .chart_renderer import render_natal_wheel
-
-    chart_data = await calculate_composite_chart_by_id(
-        person1_id=person1_id,
-        person2_id=person2_id,
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_natal_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        table_position=table_position,
-        include_planets=include_planets,
-        include_houses=include_houses,
-        title=title,
-        format=format,
-    )
-    result["included"] = _included_list(table_position, include_planets, include_houses)
-    return result
-
-
-# ── render_davison_chart ──────────────────────────────────────────────
-
-@mcp.tool()
-async def render_davison_chart(
-    # ── Two people's birth data (same as calculate_davison_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,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 600,
-    table_position: str = "none",
-    include_planets: bool = False,
-    include_houses: bool = False,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a Davison chart (midpoint in time and space) as a single wheel.
-
-    Calculates the Davison chart from two people's birth data and renders
-    it as a standard natal-style wheel. Output as SVG or raster image (PNG/JPG).
-
-    PERSON 1 (required):
-        person1_datetime: ISO 8601 birth datetime with timezone.
-        person1_latitude: Birth latitude in decimal degrees.
-        person1_longitude: Birth longitude in decimal degrees.
-
-    PERSON 2 (required):
-        person2_datetime: ISO 8601 birth datetime with timezone.
-        person2_latitude: Birth latitude in decimal degrees.
-        person2_longitude: Birth longitude in decimal degrees.
-
-    CHART OPTIONS:
-        elevation: Birth elevation in meters (default: 0).
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
-        include_planets: {_RENDER_PLANETS_HELP}
-        include_houses: {_RENDER_HOUSES_HELP}
-        title: {_RENDER_TITLE_HELP}
-
-    Returns:
-        Dict with "svg", "format", "width", "height", "included".
-    """
-    from .chart_renderer import render_natal_wheel
-
-    chart_data = await calculate_davison_chart(
-        person1_datetime=person1_datetime,
-        person1_latitude=person1_latitude,
-        person1_longitude=person1_longitude,
-        person2_datetime=person2_datetime,
-        person2_latitude=person2_latitude,
-        person2_longitude=person2_longitude,
-        elevation=elevation,
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_natal_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        table_position=table_position,
-        include_planets=include_planets,
-        include_houses=include_houses,
-        title=title,
-        format=format,
-    )
-    result["included"] = _included_list(table_position, include_planets, include_houses)
-    return result
-
-
-# ── render_davison_chart_by_id ────────────────────────────────────────
-
-@mcp.tool()
-async def render_davison_chart_by_id(
-    # ── Two person IDs ──────────────────────────────────────────────
-    person1_id: str,
-    person2_id: str,
-    house_system: str = "placidus",
-    orb_limits: dict[str, float] | None = None,
-    # ── Rendering options ───────────────────────────────────────────
-    style: str = "modern",
-    color_mode: str = "color",
-    size: int = 600,
-    table_position: str = "none",
-    include_planets: bool = False,
-    include_houses: bool = False,
-    title: str | None = None,
-    format: str = "svg",
-) -> dict[str, Any]:
-    """Render a Davison chart for two people from the database.
-
-    Looks up both persons by ID, calculates the Davison chart, and
-    renders it as a single natal-style wheel. Output as SVG or raster image.
-
-    PERSON LOOKUP (required):
-        person1_id: ID or nickname of person 1 in the persons database.
-        person2_id: ID or nickname of person 2 in the persons database.
-
-    CHART OPTIONS:
-        house_system: "placidus" (default), "equal", or "whole_sign".
-        orb_limits: Per-aspect orb overrides.
-
-    RENDERING OPTIONS:
-        style: {_RENDER_STYLE_HELP}
-        color_mode: {_RENDER_COLOR_HELP}
-        size: {_RENDER_SIZE_HELP}
-        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
-        include_planets: {_RENDER_PLANETS_HELP}
-        include_houses: {_RENDER_HOUSES_HELP}
-        title: {_RENDER_TITLE_HELP}
-
-    Returns:
-        Dict with "svg", "format", "width", "height", "included".
-    """
-    from .chart_renderer import render_natal_wheel
-
-    chart_data = await calculate_davison_chart_by_id(
-        person1_id=person1_id,
-        person2_id=person2_id,
-        house_system=house_system,
-        orb_limits=orb_limits,
-    )
-    if "error" in chart_data:
-        return chart_data
-
-    result = render_natal_wheel(
-        chart_data,
-        style=style,
-        color_mode=color_mode,
-        size=size,
-        table_position=table_position,
-        include_planets=include_planets,
-        include_houses=include_houses,
-        title=title,
-        format=format,
-    )
-    result["included"] = _included_list(table_position, include_planets, include_houses)
-    return result
-
-
-# ── Helper ────────────────────────────────────────────────────────────
-
-def _included_list(table_position: str, planets: bool, houses: bool) -> list[str]:
-    result = ["wheel"]
-    if table_position in ("below", "right"):
-        if planets:
-            result.append("planet_table")
-        if houses:
-            result.append("house_table")
-    return result

+ 7 - 7
tests/test_reference_charts.py

@@ -166,7 +166,7 @@ class TestEinsteinChart:
 
     async def test_planetary_positions(self, einstein_sky):
         """Verify planet signs and approximate degrees match reference."""
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = einstein_sky
             result = await tools.calculate_natal_chart(
                 birth_datetime=EINSTEIN["datetime"],
@@ -196,7 +196,7 @@ class TestEinsteinChart:
 
     async def test_angles(self, einstein_sky):
         """Verify ASC and MC match reference."""
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = einstein_sky
             result = await tools.calculate_natal_chart(
                 birth_datetime=EINSTEIN["datetime"],
@@ -216,7 +216,7 @@ class TestEinsteinChart:
 
     async def test_houses_present(self, einstein_sky):
         """Verify all 12 houses are returned with correct structure."""
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = einstein_sky
             result = await tools.calculate_natal_chart(
                 birth_datetime=EINSTEIN["datetime"],
@@ -236,7 +236,7 @@ class TestEinsteinChart:
 
     async def test_aspects_present(self, einstein_sky):
         """Verify aspects are computed."""
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = einstein_sky
             result = await tools.calculate_natal_chart(
                 birth_datetime=EINSTEIN["datetime"],
@@ -265,7 +265,7 @@ class TestChakaKhanChart:
 
     async def test_planetary_positions(self, chaka_sky):
         """Verify planet signs and approximate degrees match reference."""
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = chaka_sky
             result = await tools.calculate_natal_chart(
                 birth_datetime=CHAKA_KHAN["datetime"],
@@ -294,7 +294,7 @@ class TestChakaKhanChart:
 
     async def test_angles(self, chaka_sky):
         """Verify ASC and MC match reference."""
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = chaka_sky
             result = await tools.calculate_natal_chart(
                 birth_datetime=CHAKA_KHAN["datetime"],
@@ -314,7 +314,7 @@ class TestChakaKhanChart:
 
     async def test_retrograde_planets(self, chaka_sky):
         """Chaka Khan has 6 retrograde planets — verify they're detected."""
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = chaka_sky
             result = await tools.calculate_natal_chart(
                 birth_datetime=CHAKA_KHAN["datetime"],

+ 10 - 10
tests/test_tools.py

@@ -113,7 +113,7 @@ def 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:
+    with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
         mock.return_value = mock_sky_state
         app = create_app()
         yield app, mock
@@ -132,7 +132,7 @@ class TestGetPlanetaryPositions:
         """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:
+        with patch("src.astro_mcp.chart_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)
@@ -154,7 +154,7 @@ class TestGetPlanetaryPositions:
     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:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = mock_sky_state
             result = asyncio.run(
                 tools.get_planetary_positions(
@@ -171,7 +171,7 @@ class TestGetPlanetaryPositions:
     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:
+        with patch("src.astro_mcp.chart_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")
@@ -186,7 +186,7 @@ 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:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = mock_sky_state
             result = asyncio.run(
                 tools.calculate_natal_chart(
@@ -232,7 +232,7 @@ class TestCalculateNatalChart:
     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:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = mock_sky_state
             result = asyncio.run(
                 tools.calculate_natal_chart(
@@ -253,7 +253,7 @@ 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:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = mock_sky_state
             result = asyncio.run(
                 tools.calculate_transit_chart(
@@ -282,7 +282,7 @@ 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:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = mock_sky_state
             result = asyncio.run(
                 tools.calculate_synastry_chart(
@@ -316,7 +316,7 @@ class TestCalculateSynastryChart:
 class TestCalculateCompositeChart:
     def test_composite_chart(self):
         from src.astro_mcp import tools
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = make_mock_sky_state()
             import asyncio
             result = asyncio.run(
@@ -512,7 +512,7 @@ class TestTransitPreview:
 
     def test_min_significance(self, mock_sky_state):
         from src.astro_mcp import tools
-        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+        with patch("src.astro_mcp.chart_tools.call_sky_state", new_callable=AsyncMock) as mock:
             mock.return_value = mock_sky_state
             result = asyncio.run(
                 tools.get_transit_preview(