3 Commits c7ee0ea269 ... 05badb5cfd

Autore SHA1 Messaggio Data
  Lukas Goldschmidt 05badb5cfd fix: single-point timezone conversion, clean up double normalization 1 mese fa
  Lukas Goldschmidt db328c9bef fix: historical timezone handling — IANA for modern, LMT fallback for pre-1890 1 mese fa
  Lukas Goldschmidt 031aaafa4a fix: timezone handling — naive datetime + IANA timezone, strip bogus DB offsets 1 mese fa

+ 207 - 0
agent-guides/server-guide.md

@@ -0,0 +1,207 @@
+# Astro-MCP Server Guide
+
+## Overview
+
+Astro-MCP is an astrological chart calculation server. It consumes birth data
+and returns planetary positions, house cusps, angles, and aspects. All
+computation is delegated to a separate **ephemeris-mcp** server which runs the
+Swiss Ephemeris library.
+
+## Architecture
+
+```
+User / Agent
+    |
+    v
+astro-mcp (this server)
+    |  MCP SSE
+    v
+ephemeris-mcp (Swiss Ephemeris backend)
+```
+
+- **astro-mcp** handles: person database, chart interpretation, rendering,
+  timezone conversion, MCP tool interface
+- **ephemeris-mcp** handles: raw astronomical computation (planetary positions,
+  house cusps, sidereal time)
+
+## Timezone Rules (CRITICAL)
+
+### Single Conversion Point
+
+**`_get_person_birth_data()`** is the ONLY function that converts stored birth
+datetimes to UTC. It reads the naive local time and IANA timezone name from the
+persons database, combines them, and returns a UTC datetime. All `_byId` chart
+tools call this function. **No other function performs timezone conversion.**
+
+### Database Storage Format
+
+- `birth_datetime`: naive local time, NO offset (e.g. `"1965-07-02T00:05:00"`)
+- `timezone`: IANA timezone name (e.g. `"Europe/Vienna"`, `"America/New_York"`)
+- `longitude`: used as fallback for LMT when timezone is missing
+
+### Direct-Call Tools
+
+Tools that accept `birth_datetime` as a parameter (e.g. `calculate_natal_chart`,
+`calculate_transit_chart`) expect **UTC or offset-aware datetimes**. The caller
+is responsible for conversion. Use ISO 8601 format:
+- UTC: `"1965-07-01T23:05:00Z"` or `"1965-07-01T23:05:00+00:00"`
+- Offset-aware: `"1965-07-02T00:05:00+01:00"`
+
+### _byId Tools
+
+Tools that look up persons by ID/nickname (e.g. `calculate_natal_chart_by_id`)
+automatically convert to UTC. Just pass the person identifier.
+
+## Person Management
+
+### Adding a Person
+
+Use `person_manage` with `action: "add"`. Required fields:
+- `name`: Full name
+- `birth_datetime`: ISO 8601 datetime (any format — will be stored)
+- `latitude`, `longitude`: Birth coordinates
+- `tz`: IANA timezone name (e.g. `"Europe/Vienna"`)
+
+Optional: `nickname`, `birthplace`, `elevation`, `gender`, `description`, `notes`,
+`birth_time_known`.
+
+### Important: Timezone Must Be Set
+
+When adding a person, ALWAYS provide the `tz` field with the correct IANA
+timezone name. This is essential for accurate chart calculation, especially for:
+- Historical dates (before ~1890) where LMT differs from modern timezones
+- Locations that observed DST differently in the past
+- Any date where the UTC offset is not obvious
+
+### Listing and Updating
+
+- `person_manage` with `action: "list"` — list all persons
+- `person_manage` with `action: "get"` — retrieve by ID or nickname
+- `person_manage` with `action: "update"` — modify fields
+- `person_manage` with `action: "delete"` — remove person
+
+## Tool Categories
+
+### Data-Only Tools (return JSON)
+
+| Tool | Description |
+|------|-------------|
+| `get_sky_state` | Raw planetary positions + houses for a datetime/location |
+| `calculate_natal_chart` | Full natal chart: planets, houses, aspects, angles |
+| `calculate_transit_chart` | Transit-to-natal aspects |
+| `calculate_synastry_chart` | Relationship chart for two people |
+| `calculate_composite_chart` | Composite (midpoint) chart |
+| `calculate_davison_chart` | Davison (space-time midpoint) chart |
+| `get_transit_preview` | Daily transit snapshots over a date range |
+| `get_karmic_relationship_summary` | Karmic analysis for a relationship |
+
+### _byId Variants (lookup person from DB)
+
+Same as above but with `_byId` suffix. Accept `person_id` (or nickname) instead
+of birth data. Automatically handle timezone conversion.
+
+### Rendering Tools (return SVG)
+
+| Tool | Description |
+|------|-------------|
+| `render_natal_chart` | SVG natal chart wheel |
+| `render_transit_chart` | SVG transit chart |
+| `render_synastry_chart` | SVG synastry chart |
+| `render_composite_chart` | SVG composite chart |
+| `render_davison_chart` | SVG Davison chart |
+
+Each has a `_byId` variant.
+
+### Utility Tools
+
+| Tool | Description |
+|------|-------------|
+| `person_manage` | CRUD for persons database |
+| `list_house_systems` | List supported house systems (Placidus, Koch, etc.) |
+
+## House Systems
+
+Supported house system codes: P (Placidus), K (Koch), E (Equal), W (Whole Sign),
+A (Alcabitius), C (Campanus), M (Morinus), R (Porphyry), and more. Use
+`list_house_systems` for the full list.
+
+Default is Placidus.
+
+## Common Workflows
+
+### Natal Chart for a Known Person
+
+```
+1. person_manage(action="add", name="...", birth_datetime="...", latitude=..., longitude=..., tz="...")
+2. calculate_natal_chart_by_id(person_id="...", include_overview=true)
+```
+
+### Natal Chart for a One-Off Calculation
+
+```
+calculate_natal_chart(
+    birth_datetime="1990-05-15T10:30:00+01:00",  # UTC or offset-aware
+    latitude=47.07,
+    longitude=15.42,
+    include_overview=true
+)
+```
+
+### Transit Chart
+
+```
+calculate_transit_chart_by_id(
+    person_id="...",
+    transit_datetime="2026-06-07T12:00:00Z"  # UTC
+)
+```
+
+### Relationship Analysis
+
+```
+calculate_synastry_chart_by_id(
+    person1_id="...",
+    person2_id="...",
+    include_davison_full=true
+)
+```
+
+### Rendered Chart
+
+```
+render_natal_chart_by_id(
+    person_id="...",
+    style="modern",
+    color_mode="color",
+    size=800
+)
+```
+
+## Resources (fetch with astro:// URI)
+
+| URI | Content |
+|-----|---------|
+| `astro://guides/natal-astrology` | Natal chart interpretation guide |
+| `astro://guides/karmic-astrology` | Karmic astrology guide |
+| `astro://guides/relationship-astrology` | Relationship astrology guide |
+| `astro://guides/financial-astrology` | Financial astrology guide |
+
+## Error Handling
+
+All tools return `{"error": "message"}` on failure. Common errors:
+- `"person not found: ..."` — invalid person_id or nickname
+- `"add requires: name, birth_datetime, latitude, longitude"` — missing fields
+- `"empty_response"` — ephemeris server unreachable
+
+## Tips
+
+1. Always use `_byId` tools when working with stored persons — they handle
+   timezone conversion automatically.
+2. For direct-call tools, pass UTC datetimes to avoid ambiguity.
+3. The `include_overview` flag adds element/modality/hemisphere balance,
+   stelliums, empty houses, chart ruler, and house rulers.
+4. The `include_patterns` flag adds T-square, Grand Trine, Grand Cross, Yod
+   detection and chart shape classification.
+5. The `include_karmic` flag adds nodal axis, Saturn, Pluto polarity point,
+   Part of Fortune, and 12th house analysis.
+6. Use `top_n_aspects` to limit aspect output to the N tightest by orb.

+ 30 - 25
src/astro_mcp/ephemeris_client.py

@@ -53,11 +53,18 @@ def _payload_from_result(result: Any) -> dict[str, Any]:
     return {}
 
 
-def _normalize_datetime(dt_str: str | None) -> str | None:
-    """Normalize a datetime string to UTC ISO format without timezone offset.
-
-    The ephemeris server expects UTC datetimes as plain ISO strings
-    (e.g. '1965-07-02T22:05:00') without timezone info.
+def _normalize_datetime(dt_str: str | None, tz_name: str | None = None, lon: float = 0.0) -> str | None:
+    """Convert a datetime string to UTC.
+
+    This is a pure conversion utility — it does NOT know about the source
+    of the datetime. Callers must ensure the right inputs:
+    - If dt_str already has an offset, it is converted directly.
+    - If dt_str is naive, tz_name (IANA) is used for conversion.
+    - If dt_str is naive and tz_name is None, LMT from longitude is used.
+
+    The SINGLE POINT where DB birth data is converted to UTC is
+    _get_person_birth_data(). All other functions receive UTC datetimes
+    and must NOT call this function again.
     """
     if dt_str is None:
         return None
@@ -65,9 +72,16 @@ def _normalize_datetime(dt_str: str | None) -> str | None:
         dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
         if dt.tzinfo is not None:
             dt = dt.astimezone(timezone.utc)
+        elif tz_name:
+            from zoneinfo import ZoneInfo
+            dt = dt.replace(tzinfo=ZoneInfo(tz_name)).astimezone(timezone.utc)
+        else:
+            from datetime import timedelta
+            lmt_tz = timezone(timedelta(hours=lon / 15.0))
+            dt = dt.replace(tzinfo=lmt_tz).astimezone(timezone.utc)
         return dt.strftime("%Y-%m-%dT%H:%M:%S")
     except Exception:
-        logger.warning(f"failed to normalize datetime: {dt_str}")
+        logger.warning(f"failed to normalize datetime: {dt_str} tz={tz_name} lon={lon}")
         return dt_str
 
 
@@ -81,18 +95,20 @@ async def call_sky_state(
 ) -> dict[str, Any]:
     """Call ephemeris-mcp:get_sky_state and return the result dict.
 
-    Datetime is normalized to UTC ISO format (no timezone offset)
-    before sending to the ephemeris server.
-
-    The ephemeris server returns Greenwich sidereal_time regardless of
-    the lon parameter. We correct local_sidereal_time here by adding
-    the longitude offset (15° = 1 hour, east positive).
+    datetime MUST be UTC. This function does NOT perform timezone conversion.
+    Callers are responsible for converting to UTC before calling:
+    - _byId functions use _get_person_birth_data() which returns UTC.
+    - Direct-call functions must pass UTC or offset-aware datetime.
 
     When house_system is provided, the response includes house cusps
     and angles computed by the Swiss Ephemeris on the server side.
+    The server-side computation is authoritative — no post-processing
+    of angles, houses, or sidereal time is done here.
     """
-    # Normalize datetime to UTC ISO string
-    dt_str = _normalize_datetime(datetime)
+    # Datetime must be UTC — callers are responsible for conversion.
+    # _byId functions pre-convert via _get_person_birth_data.
+    # Direct-call functions must pass UTC or offset-aware datetime.
+    dt_str = datetime or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
     url = EPHEMERIS_MCP_URL.strip()
     if not url.endswith("/mcp/sse"):
         url = url.rstrip("/") + "/mcp/sse"
@@ -121,17 +137,6 @@ async def call_sky_state(
                     logger.warning("ephemeris-mcp returned empty payload")
                     return {"error": "empty_response", "url": url}
 
-                # Fix local sidereal time: ephemeris may return incorrect LST.
-                # Compute it properly from Greenwich ST + longitude.
-                # East longitude positive, 15° = 1 hour.
-                if "sidereal_time" in payload:
-                    st = payload["sidereal_time"]
-                    if isinstance(st, dict):
-                        gst = st.get("greenwich_sidereal_time",
-                                     st.get("local_sidereal_time", 0.0))
-                        if isinstance(gst, (int, float)):
-                            st["local_sidereal_time"] = (gst + lon / 15.0) % 24.0
-
                 return payload
     except Exception as exc:
         logger.error(f"ephemeris-mcp call failed: {exc}")

+ 6 - 0
src/astro_mcp/server.py

@@ -98,6 +98,12 @@ def financial_astrology_guide() -> str:
     return _read_guide("financial-astrology.md")
 
 
+@mcp.resource("astro://guides/server-guide")
+def server_guide() -> str:
+    """Server usage guide — architecture, timezone rules, person management, tool reference."""
+    return _read_guide("server-guide.md")
+
+
 def create_app() -> FastAPI:
     config.LOG_DIR.mkdir(parents=True, exist_ok=True)
     logging.basicConfig(

+ 31 - 5
src/astro_mcp/tools.py

@@ -123,6 +123,9 @@ async def calculate_natal_chart(
 ) -> 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.
 
@@ -155,6 +158,8 @@ Args:
 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,
@@ -233,10 +238,12 @@ Returns:
 
     # Add lunar phase from ephemeris
     lunar_state = sky.get("lunar_state", {})
-    if lunar_state:
+    lunar = lunar_state.get("lunar_state", {}) if isinstance(lunar_state, dict) else {}
+    if lunar:
         result["lunar_phase"] = {
-            "phase_name": lunar_state.get("phase_name"),
-            "illumination_fraction": lunar_state.get("illumination_fraction"),
+            "phase_name": lunar.get("phase_name"),
+            "illumination_fraction": lunar.get("illumination_fraction"),
+            "age_days": lunar.get("age_days"),
         }
 
     # Limit aspects if requested
@@ -1568,15 +1575,34 @@ def list_house_systems() -> dict[str, Any]:
 
 
 async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
-    """Fetch birth data from DB by person_id or nickname."""
+    """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 UTC. Callers must not
+    re-normalize it.
+    """
     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"],
+        "birth_datetime": utc_dt,
         "birthplace": person.get("birthplace"),
         "latitude": person["latitude"],
         "longitude": person["longitude"],