Pārlūkot izejas kodu

Phase 1: scaffold astro-mcp project

- Project structure: src/astro_mcp/, tests/, templates/, data/, logs/
- config.py: PORT=7016, EPHEMERIS_MCP_URL=192.168.0.200:7015, dotenv .env loading
- server.py: FastAPI + FastMCP, /health, /, SSE at /mcp/sse
- main.py: uvicorn entry point
- run.sh, killserver.sh, restart.sh, tests.sh
- Dockerfile: python:3.13-slim, no native deps
- docker-compose.yml: port 7016, env_file .env, healthcheck on /health
- .env / .env.example with ephemeris URL
- templates: base.html, dashboard.html, persons.html (stubs)
- tests/test_server.py: health + root smoke tests (2 passing)
- requirements.txt: fastapi, uvicorn, fastmcp, mcp, pydantic, jinja2, dotenv, pytest
Lukas Goldschmidt 1 mēnesi atpakaļ
vecāks
revīzija
2da3370c82

+ 10 - 0
.env.example

@@ -0,0 +1,10 @@
+# astro-mcp environment configuration
+# Copy to .env and adjust values as needed
+
+ASTRO_HOST=0.0.0.0
+ASTRO_PORT=7016
+ASTRO_DATA_DIR=./data
+ASTRO_LOG_DIR=./logs
+ASTRO_DB_PATH=./data/astro.sqlite3
+
+EPHEMERIS_MCP_URL=http://192.168.0.200:7015/mcp/sse

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+__pycache__
+*.pyc
+.venv/
+data/
+logs/
+.env
+*.sqlite3
+.DS_Store

+ 18 - 0
Dockerfile

@@ -0,0 +1,18 @@
+FROM python:3.13-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY main.py ./
+COPY src ./src
+
+RUN mkdir -p /app/data /app/logs
+
+EXPOSE 7016
+
+CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7016"]

+ 189 - 0
PROJECT.md

@@ -0,0 +1,189 @@
+# Astro-MCP Implementation Plan
+
+**Port: 7016** (ephemeris-mcp is 7015)
+
+## What We're Building
+
+An MCP server that consumes `ephemeris-mcp:get_sky_state` via MCP-over-SSE client, then performs pure astrological calculations (zodiac signs, houses, aspects, angles) on top of that astronomical data. No interpretation -- only structured JSON output. Includes a person database (SQLite), a Jinja2+vanillaJS dashboard, and a Docker container.
+
+## Project Structure
+
+```
+astro-mcp/
+├── Dockerfile
+├── docker-compose.yml
+├── main.py                          # entrypoint, same pattern as ephemeris-mcp
+├── requirements.txt
+├── .gitignore
+├── README.md
+├── PROJECT.md
+├── run.sh
+├── killserver.sh
+├── restart.sh
+├── tests.sh
+├── data/                            # SQLite DBs at runtime
+├── logs/                            # PID + server.log
+├── src/
+│   └── astro_mcp/
+│       ├── __init__.py
+│       ├── config.py                # PORT=7016, dirs, ephemeris URL
+│       ├── server.py                # FastAPI + FastMCP + lifespan + HTTP routes
+│       ├── ephemeris_client.py      # MCP SSE client calling ephemeris-mcp:get_sky_state
+│       ├── astrology.py             # pure calc: zodiac, houses, aspects, angles
+│       ├── models.py                # Pydantic models / type aliases for chart structs
+│       ├── storage.py               # SQLite: persons table + chart cache
+│       ├── dashboard.py             # Jinja2 templates + dashboard routes
+│       └── tools.py                 # @mcp.tool() definitions (wiring layer)
+├── templates/
+│   ├── base.html
+│   ├── dashboard.html
+│   └── persons.html
+└── tests/
+    ├── conftest.py
+    ├── test_astrology.py            # unit tests for pure calc functions
+    ├── test_tools.py                # integration tests with mocked ephemeris client
+    └── test_server.py               # HTTP endpoint smoke tests
+```
+
+## Dependencies (requirements.txt)
+
+```
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+fastmcp>=2.0.0
+mcp>=1.0.0
+pydantic>=2.8.0
+jinja2>=3.1.0
+python-dotenv>=1.0.1
+pytest>=8.0.0
+```
+
+Note: No `pyswisseph` -- all astronomical data comes from ephemeris-mcp via MCP client. No heavy native deps beyond what FastMCP/mcp pulls in.
+
+## Key Design Decisions
+
+1. **MCP client pattern**: Same as hermes-mcp's `argus_client.py` -- `sse_client(url)` + `ClientSession`. The ephemeris URL is configurable via `EPHEMERIS_MCP_URL` env var (default `http://ephemeris-mcp:7015/mcp/sse` for Docker, `http://127.0.0.1:7015/mcp/sse` for local dev).
+
+2. **Async throughout**: The ephemeris client is async (`sse_client` is async context manager). The FastMCP tools are `async def`. The SQLite access uses `asyncio.to_thread()` to avoid blocking.
+
+3. **Calculation module is pure sync**: `astrology.py` has no async, no I/O -- just math on dicts/lists. Easy to unit test without any event loop.
+
+4. **Ephemeris client call_sky_state helper**: A single internal async function that calls `ephemeris-mcp:get_sky_state` and returns the parsed dict. All tools go through this. Timeout: 8s.
+
+5. **House systems**: Placidus default, with `house_system` parameter on chart tools. Start with Placidus + Equal House only. The house calculation uses the sidereal time from `get_sky_state` output (`sidereal_time.local_sidereal_time`).
+
+6. **Aspect orbs (defaults)**: conjunction 8, sextile 6, square 8, trine 8, opposition 8. Configurable per-tool via optional `orb_limits` dict.
+
+7. **Person DB schema**:
+   ```sql
+   CREATE TABLE persons (
+       id TEXT PRIMARY KEY,
+       name TEXT NOT NULL,
+       nickname TEXT UNIQUE,
+       birth_datetime TEXT NOT NULL,  -- ISO 8601 UTC
+       latitude REAL NOT NULL,
+       longitude REAL NOT NULL,
+       elevation REAL DEFAULT 0.0,
+       created_at TEXT NOT NULL       -- ISO 8601 UTC
+   );
+   ```
+
+8. **Docker**: Based on `python:3.13-slim`, no build-essential needed (no pyswisseph). Health check hits `http://127.0.0.1:7016/health`.
+
+## MCP Tool Surface (7 tools)
+
+| Tool | Description |
+|---|---|
+| `get_planetary_positions` | Wrapper around ephemeris-mcp, adds sign + degree + retrograde. Optional `bodies` filter. |
+| `calculate_natal_chart` | Full natal chart: planets in signs/houses, aspects (applying/separating), angles. Params: `birth_datetime`, `lat`, `lon`, optional `house_system`, `orb_limits`. |
+| `calculate_transit_chart` | Transit planets + natal-to-transit aspects + transiting planets in natal houses. Same params as natal + `transit_datetime`. |
+| `calculate_synastry_chart` | Two birth data sets -> interaspects, house overlays, composite chart (midpoint method), Davison chart. |
+| `get_transit_preview` | Person ID + time range -> list of events (exact aspects, ingresses, stations, lunar phases). Optional `event_types` filter. |
+| `person_manage` | `action` param: `add`, `get`, `list`, `update`, `delete`. Birth data in add/update. |
+| `list_house_systems` | Discovery tool -- returns supported house systems. |
+
+## Docker Compose
+
+```yaml
+services:
+  astro-mcp:
+    build: {context: ., dockerfile: Dockerfile}
+    image: astro-mcp:latest
+    container_name: astro-mcp
+    restart: unless-stopped
+    ports: ["7016:7016"]
+    environment:
+      ASTRO_HOST: 0.0.0.0
+      ASTRO_PORT: 7016
+      ASTRO_DATA_DIR: /app/data
+      ASTRO_LOG_DIR: /app/logs
+      ASTRO_DB_PATH: /app/data/astro.sqlite3
+      EPHEMERIS_MCP_URL: http://ephemeris-mcp:7015/mcp/sse
+    volumes: ["./data:/app/data", "./logs:/app/logs"]
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7016/health').read()"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 20s
+```
+
+## Implementation Order
+
+### Phase 1 -- Scaffold
+- Create directory structure, `.gitignore`, `requirements.txt`, `src/astro_mcp/` package
+- `main.py` entry point (copy ephemeris-mcp pattern)
+- `config.py` (PORT=7016, dirs, EPHEMERIS_MCP_URL)
+- `server.py` basic FastAPI + FastMCP with `/health` and `/`
+- `run.sh`, `killserver.sh`, `restart.sh`, `tests.sh`
+- Dockerfile + docker-compose.yml
+- Verify: `./run.sh` starts, `curl /health` returns ok, commit
+
+### Phase 2 -- Astrology calculation module (pure, no I/O)
+- `astrology.py`: zodiac sign from ecliptic longitude, house cusps (Placidus + Equal), aspect detection with orbs, angle calculation (ASC/MC from sidereal time)
+- Pydantic models for chart structures
+- `tests/test_astrology.py`: comprehensive unit tests
+- Verify: `pytest tests/test_astrology.py -q` passes, commit
+
+### Phase 3 -- Ephemeris client + planetary positions tool
+- `ephemeris_client.py`: `call_sky_state(datetime, lat, lon)` using `sse_client`
+- `tools.py`: `get_planetary_positions` tool -- calls sky_state, adds sign/degree/retrograde to each body
+- Test with mocked client
+- Verify: tool returns enhanced body array, commit
+
+### Phase 4 -- Person storage + management
+- `storage.py`: persons DB, same cache pattern as ephemeris-mcp but using `asyncio.to_thread` for DB access
+- `person_manage` tool (add/get/list/update/delete)
+- `tests/test_tools.py`: person CRUD tests
+- Verify: CRUD round-trips, commit
+
+### Phase 5 -- Natal chart tool
+- Wire `calculate_natal_chart` in `tools.py`
+- Calls `call_sky_state` -> feeds planetary positions into astrology module -> assembles full chart dict
+- Test with mocked ephemeris output: verify all chart sections present (planets, houses, aspects, angles)
+- Verify: structured natal chart output, commit
+
+### Phase 6 -- Transit + Synastry tools
+- `calculate_transit_chart`: calls sky_state twice (natal + transit datetimes), computes aspects between them
+- `calculate_synastry_chart`: calls sky_state twice, computes interaspects, house overlays, composite (midpoint), Davison
+- Tests for both
+- Verify, commit
+
+### Phase 7 -- Transit preview
+- `get_transit_preview`: Given person_id + start/end range, iterates through time steps to find exact aspects (orb <= 0d01), ingresses (sign changes), retrograde stations (speed_lon sign changes), lunar phase changes
+- Uses the person DB + ephemeris client calls
+- This is the most algorithmically complex tool -- scan daily then refine with hourly substeps around detected events
+- Verify, commit
+
+### Phase 8 -- Dashboard
+- `dashboard.py`: Jinja2 templates mounted on FastAPI routes (`/dashboard`, `/dashboard/persons`, `/dashboard/persons/<id>`)
+- Vanilla JS for add/edit/delete persons via fetch API
+- Templates: base layout, person list, person detail with chart preview link
+- Mount routes in `server.py` lifespan or at module level
+- Verify: browser test, commit
+
+### Phase 9 -- Integration + docs
+- Full test suite running via `tests.sh`
+- `README.md`: tool descriptions, example calls, Docker usage
+- `PROJECT.md`: technical specs, schema, architecture
+- Final commit and push

+ 44 - 0
README.md

@@ -0,0 +1,44 @@
+# astro-mcp
+
+MCP server for astrological chart calculations. Consumes `ephemeris-mcp:get_sky_state`
+via MCP-over-SSE client and exposes calculated astrological data (natal charts, transits,
+synastry, transit previews) as structured JSON tools.
+
+## Quick Start
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+./run.sh
+```
+
+Server listens on port 7016 (configurable via `ASTRO_PORT`).
+
+## Docker
+
+```bash
+docker compose up --build
+```
+
+Health check: `GET http://localhost:7016/health`
+
+## MCP Endpoint
+
+SSE transport at `http://localhost:7016/mcp/sse`
+
+## Tools
+
+| Tool | Description |
+|---|---|
+| `get_planetary_positions` | Planetary positions with zodiac signs, degrees, retrograde flags |
+| `calculate_natal_chart` | Complete natal chart (planets, houses, aspects, angles) |
+| `calculate_transit_chart` | Transit chart with natal-to-transit aspects |
+| `calculate_synastry_chart` | Relationship chart (interaspects, overlays, composite, davison) |
+| `get_transit_preview` | Significant transit events for a person over a time range |
+| `person_manage` | CRUD operations for the persons database |
+| `list_house_systems` | List supported house systems |
+
+## Dashboard
+
+Person management dashboard at `http://localhost:7016/dashboard`

+ 27 - 0
docker-compose.yml

@@ -0,0 +1,27 @@
+services:
+  astro-mcp:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    image: astro-mcp:latest
+    container_name: astro-mcp
+    restart: unless-stopped
+    ports:
+      - "7016:7016"
+    env_file:
+      - .env
+    environment:
+      ASTRO_HOST: 0.0.0.0
+      ASTRO_PORT: 7016
+      ASTRO_DATA_DIR: /app/data
+      ASTRO_LOG_DIR: /app/logs
+      ASTRO_DB_PATH: /app/data/astro.sqlite3
+    volumes:
+      - ./data:/app/data
+      - ./logs:/app/logs
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7016/health').read()"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 20s

+ 19 - 0
killserver.sh

@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${ASTRO_PORT:-7016}"
+PIDS="$(lsof -ti tcp:"$PORT" || true)"
+
+if [ -z "$PIDS" ]; then
+  echo "No listeners found on port $PORT"
+  exit 0
+fi
+
+echo "Stopping listeners on port $PORT: $PIDS"
+kill $PIDS || true
+sleep 1
+PIDS="$(lsof -ti tcp:"$PORT" || true)"
+if [ -n "$PIDS" ]; then
+  echo "Force-killing remaining listeners on port $PORT: $PIDS"
+  kill -9 $PIDS || true
+fi

+ 19 - 0
main.py

@@ -0,0 +1,19 @@
+import uvicorn
+
+from src.astro_mcp.config import HOST, PORT
+from src.astro_mcp.server import create_app
+
+app = create_app()
+
+if __name__ == "__main__":
+    import os
+
+    reload_enabled = os.getenv("ASTRO_RELOAD", "0").lower() in {
+        "1", "true", "yes", "on",
+    }
+    uvicorn.run(
+        "main:app",
+        host=HOST,
+        port=PORT,
+        reload=reload_enabled,
+    )

+ 11 - 0
requirements.txt

@@ -0,0 +1,11 @@
+# Runtime
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+fastmcp>=2.0.0
+mcp>=1.0.0
+pydantic>=2.8.0
+jinja2>=3.1.0
+python-dotenv>=1.0.1
+
+# Testing
+pytest>=8.0.0

+ 5 - 0
restart.sh

@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+./killserver.sh
+sleep 1
+./run.sh

+ 20 - 0
run.sh

@@ -0,0 +1,20 @@
+# Run astro-mcp
+mkdir -p logs data
+
+if [ -f .venv/bin/activate ]; then
+  # shellcheck disable=SC1091
+  source .venv/bin/activate
+fi
+
+if [ -f logs/server.pid ] && kill -0 "$(cat logs/server.pid)" 2>/dev/null; then
+  echo "astro-mcp already running on pid $(cat logs/server.pid)"
+  exit 0
+fi
+
+nohup python -m uvicorn \
+  main:app \
+  --host 0.0.0.0 \
+  --port "${ASTRO_PORT:-7016}" \
+  > logs/server.log 2>&1 &
+echo $! > logs/server.pid
+echo "astro-mcp started on pid $(cat logs/server.pid)"

+ 0 - 0
src/astro_mcp/__init__.py


+ 0 - 0
src/astro_mcp/astrology.py


+ 22 - 0
src/astro_mcp/config.py

@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+# Load .env from the project root (two levels up from this file)
+load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
+
+BASE_DIR = Path(__file__).resolve().parent.parent.parent
+
+HOST = os.getenv("ASTRO_HOST", "0.0.0.0")
+PORT = int(os.getenv("ASTRO_PORT", "7016"))
+
+DATA_DIR = Path(os.getenv("ASTRO_DATA_DIR", str(BASE_DIR / "data")))
+LOG_DIR = Path(os.getenv("ASTRO_LOG_DIR", str(BASE_DIR / "logs")))
+DB_PATH = Path(os.getenv("ASTRO_DB_PATH", str(DATA_DIR / "astro.sqlite3")))
+
+EPHEMERIS_MCP_URL = os.getenv(
+    "EPHEMERIS_MCP_URL", "http://192.168.0.200:7015/mcp/sse"
+)

+ 0 - 0
src/astro_mcp/dashboard.py


+ 0 - 0
src/astro_mcp/ephemeris_client.py


+ 0 - 0
src/astro_mcp/models.py


+ 60 - 0
src/astro_mcp/server.py

@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+import logging
+
+from fastapi import FastAPI
+from mcp.server.fastmcp import FastMCP
+from mcp.server.transport_security import TransportSecuritySettings
+
+from . import config
+
+logger = logging.getLogger("astro-mcp")
+
+mcp = FastMCP(
+    "astro-mcp",
+    transport_security=TransportSecuritySettings(
+        enable_dns_rebinding_protection=False,
+    ),
+)
+
+
+def _tool_names() -> list[str]:
+    return [
+        "get_planetary_positions",
+        "calculate_natal_chart",
+        "calculate_transit_chart",
+        "calculate_synastry_chart",
+        "get_transit_preview",
+        "person_manage",
+        "list_house_systems",
+    ]
+
+
+def create_app() -> FastAPI:
+    config.LOG_DIR.mkdir(parents=True, exist_ok=True)
+    logging.basicConfig(
+        filename=str(config.LOG_DIR / "server.log"),
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+    )
+
+    app = FastAPI(title="astro-mcp")
+    app.mount("/mcp", mcp.sse_app())
+
+    @app.get("/health")
+    def health() -> dict:
+        return {"ok": True, "server": "astro-mcp", "port": config.PORT}
+
+    @app.get("/")
+    def root() -> dict:
+        return {
+            "server": "astro-mcp",
+            "status": "ready",
+            "tools": _tool_names(),
+            "mcp": {
+                "sse": "/mcp/sse",
+                "messages": "/mcp/messages",
+            },
+        }
+
+    return app

+ 0 - 0
src/astro_mcp/storage.py


+ 0 - 0
src/astro_mcp/tools.py


+ 24 - 0
templates/base.html

@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>{% block title %}astro-mcp{% endblock %}</title>
+  <style>
+    * { box-sizing: border-box; margin: 0; padding: 0; }
+    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f1117; color: #c9d1d9; line-height: 1.6; }
+    .container { max-width: 960px; margin: 0 auto; padding: 2rem; }
+    h1 { color: #58a6ff; margin-bottom: 1rem; }
+    h2 { color: #79c0ff; margin: 1.5rem 0 0.75rem; }
+    a { color: #58a6ff; text-decoration: none; }
+    a:hover { text-decoration: underline; }
+    .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1.5rem; margin: 1rem 0; }
+    .status-ok { color: #3fb950; }
+  </style>
+</head>
+<body>
+  <div class="container">
+    {% block content %}{% endblock %}
+  </div>
+</body>
+</html>

+ 11 - 0
templates/dashboard.html

@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% block title %}Dashboard - astro-mcp{% endblock %}
+{% block content %}
+<h1>astro-mcp dashboard</h1>
+<div class="card">
+  <p>Server: <strong>astro-mcp</strong></p>
+  <p>Status: <span class="status-ok">ready</span></p>
+  <p>MCP SSE: <code>/mcp/sse</code></p>
+  <p>Persons: <a href="/dashboard/persons">manage persons</a></p>
+</div>
+{% endblock %}

+ 9 - 0
templates/persons.html

@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+{% block title %}Persons - astro-mcp{% endblock %}
+{% block content %}
+<h1>persons</h1>
+<p><a href="/dashboard">← back to dashboard</a></p>
+<div class="card">
+  <p>person management coming soon.</p>
+</div>
+{% endblock %}

+ 9 - 0
tests.sh

@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ -f .venv/bin/activate ]; then
+  # shellcheck disable=SC1091
+  source .venv/bin/activate
+fi
+
+python -m pytest -q tests/ 2>&1

+ 0 - 0
tests/__init__.py


+ 0 - 0
tests/conftest.py


+ 32 - 0
tests/test_server.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+
+from src.astro_mcp.server import create_app
+
+
+def test_health_endpoint_smoke() -> None:
+    client = TestClient(create_app())
+    res = client.get("/health")
+    assert res.status_code == 200
+    data = res.json()
+    assert data == {"ok": True, "server": "astro-mcp", "port": 7016}
+
+
+def test_root_lists_tools() -> None:
+    client = TestClient(create_app())
+    res = client.get("/")
+    assert res.status_code == 200
+    data = res.json()
+    assert data["server"] == "astro-mcp"
+    assert data["status"] == "ready"
+    assert data["tools"] == [
+        "get_planetary_positions",
+        "calculate_natal_chart",
+        "calculate_transit_chart",
+        "calculate_synastry_chart",
+        "get_transit_preview",
+        "person_manage",
+        "list_house_systems",
+    ]
+    assert data["mcp"] == {"sse": "/mcp/sse", "messages": "/mcp/messages"}