3 Commity c7ee0ea269 ... 05badb5cfd

Autor SHA1 Wiadomość Data
  Lukas Goldschmidt 05badb5cfd fix: single-point timezone conversion, clean up double normalization 1 miesiąc temu
  Lukas Goldschmidt db328c9bef fix: historical timezone handling — IANA for modern, LMT fallback for pre-1890 1 miesiąc temu
  Lukas Goldschmidt 031aaafa4a fix: timezone handling — naive datetime + IANA timezone, strip bogus DB offsets 1 miesiąc temu

+ 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 {}
     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:
     if dt_str is None:
         return 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"))
         dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
         if dt.tzinfo is not None:
         if dt.tzinfo is not None:
             dt = dt.astimezone(timezone.utc)
             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")
         return dt.strftime("%Y-%m-%dT%H:%M:%S")
     except Exception:
     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
         return dt_str
 
 
 
 
@@ -81,18 +95,20 @@ async def call_sky_state(
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Call ephemeris-mcp:get_sky_state and return the result dict.
     """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
     When house_system is provided, the response includes house cusps
     and angles computed by the Swiss Ephemeris on the server side.
     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()
     url = EPHEMERIS_MCP_URL.strip()
     if not url.endswith("/mcp/sse"):
     if not url.endswith("/mcp/sse"):
         url = url.rstrip("/") + "/mcp/sse"
         url = url.rstrip("/") + "/mcp/sse"
@@ -121,17 +137,6 @@ async def call_sky_state(
                     logger.warning("ephemeris-mcp returned empty payload")
                     logger.warning("ephemeris-mcp returned empty payload")
                     return {"error": "empty_response", "url": url}
                     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
                 return payload
     except Exception as exc:
     except Exception as exc:
         logger.error(f"ephemeris-mcp call failed: {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")
     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:
 def create_app() -> FastAPI:
     config.LOG_DIR.mkdir(parents=True, exist_ok=True)
     config.LOG_DIR.mkdir(parents=True, exist_ok=True)
     logging.basicConfig(
     logging.basicConfig(

+ 31 - 5
src/astro_mcp/tools.py

@@ -123,6 +123,9 @@ async def calculate_natal_chart(
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Calculate a complete natal chart from birth data.
     """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,
 PRIMARY TOOL for natal astrology. Returns planetary positions, houses, aspects,
 and angles. Use the optional flags to add interpretation layers.
 and angles. Use the optional flags to add interpretation layers.
 
 
@@ -155,6 +158,8 @@ Args:
 Returns:
 Returns:
     Dict with: input, chart_type, planets, houses, aspects, angles, lunar_phase,
     Dict with: input, chart_type, planets, houses, aspects, angles, lunar_phase,
     and optionally: overview, aspect_patterns, chart_shape, karmic."""
     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(
     sky = await call_sky_state(
         datetime=birth_datetime,
         datetime=birth_datetime,
         lat=latitude,
         lat=latitude,
@@ -233,10 +238,12 @@ Returns:
 
 
     # Add lunar phase from ephemeris
     # Add lunar phase from ephemeris
     lunar_state = sky.get("lunar_state", {})
     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"] = {
         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
     # 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]:
 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 . import storage
+    from .ephemeris_client import _normalize_datetime
     person = await storage.get_person(person_id=person_id)
     person = await storage.get_person(person_id=person_id)
     if not person:
     if not person:
         person = await storage.get_person(nickname=person_id)
         person = await storage.get_person(nickname=person_id)
     if not person:
     if not person:
         return {"error": f"person not found: {person_id}"}
         return {"error": f"person not found: {person_id}"}
+    utc_dt = _normalize_datetime(
+        person["birth_datetime"],
+        tz_name=person.get("timezone"),
+        lon=person["longitude"],
+    )
     return {
     return {
-        "birth_datetime": person["birth_datetime"],
+        "birth_datetime": utc_dt,
         "birthplace": person.get("birthplace"),
         "birthplace": person.get("birthplace"),
         "latitude": person["latitude"],
         "latitude": person["latitude"],
         "longitude": person["longitude"],
         "longitude": person["longitude"],