Преглед на файлове

v0.1: core sky-state slice

Lukas Goldschmidt преди 3 дни
ревизия
8a9c91ff8b
променени са 19 файла, в които са добавени 2053 реда и са изтрити 0 реда
  1. 27 0
      .env.example
  2. 19 0
      .gitignore
  3. 33 0
      AGENTS.md
  4. 220 0
      IMPLEMENTATION_PLAN.md
  5. 96 0
      README.md
  6. 10 0
      RELEASE_NOTES.md
  7. 290 0
      initial_idea.md
  8. 19 0
      killserver.sh
  9. 22 0
      main.py
  10. 19 0
      requirements.txt
  11. 5 0
      restart.sh
  12. 20 0
      run.sh
  13. 9 0
      src/ephemeris_mcp/__init__.py
  14. 39 0
      src/ephemeris_mcp/config.py
  15. 599 0
      src/ephemeris_mcp/ephemeris.py
  16. 360 0
      src/ephemeris_mcp/server.py
  17. 146 0
      src/ephemeris_mcp/storage.py
  18. 9 0
      tests.sh
  19. 111 0
      tests/test_basic.py

+ 27 - 0
.env.example

@@ -0,0 +1,27 @@
+# Ephemeris MCP Configuration
+# Copy to .env and adjust as needed
+
+# Server
+EPHEMERIS_HOST=0.0.0.0
+EPHEMERIS_PORT=7015
+
+# Data & storage
+EPHEMERIS_DATA_DIR=./data
+EPHEMERIS_LOG_DIR=./logs
+EPHEMERIS_DB_PATH=./data/ephemeris.sqlite3
+
+# Cache TTLs (seconds)
+EPHEMERIS_CACHE_SOLAR=86400
+EPHEMERIS_CACHE_PLANETARY=3600
+EPHEMERIS_CACHE_LUNAR=3600
+EPHEMERIS_CACHE_SIDEREAL=3600
+EPHEMERIS_CACHE_SKY=300
+
+# Ephemeris data
+EPHEMERIS_EPH_FILE=de440s.bsp
+EPHEMERIS_EPH_URL=https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de440s.bsp
+
+# Satellite tracking
+EPHEMERIS_TLE_AUTO_REFRESH=0
+EPHEMERIS_TLE_REFRESH_INTERVAL=3600
+EPHEMERIS_TLE_SOURCE=https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=tle

+ 19 - 0
.gitignore

@@ -0,0 +1,19 @@
+__pycache__/
+.venv/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+*.pyc
+logs/
+data/
+data/*.bsp
+uvicorn.log
+server.log
+server.pid
+*.sqlite3
+.env
+*.env
+*.backup*
+.idea/
+.vscode/
+.DS_Store

+ 33 - 0
AGENTS.md

@@ -0,0 +1,33 @@
+# Ephemeris MCP
+
+This repo is a standalone celestial computation service exposed through MCP.
+v0.1 is the core sky-state slice only; satellite work is planned but deferred.
+
+## Working rules
+
+- Keep the core contract narrow: compute raw astronomical data and return structured JSON.
+- Do not add interpretation layers here. Astrology, charting, narrative output, and consumer-specific meaning belong in downstream projects.
+- Prefer small, deterministic changes that preserve the existing tool surface and cache behavior.
+- Keep time, location, and sky-state calculations internally consistent across tools.
+- Keep planned future slices in the docs and plan, even when they are out of v0.1.
+- Treat the wiki as the companion source of project context, not a duplicate code dump.
+
+## Primary references
+
+- Wiki project page: `/home/lucky/wiki/entities/projects/ephemeris-mcp.md`
+- Wiki schema: `/home/lucky/wiki/SCHEMA.md`
+- Wiki index: `/home/lucky/wiki/index.md`
+
+## Repo shape
+
+- `main.py` starts the app.
+- `src/ephemeris_mcp/` holds the runtime code.
+- `tests/` holds verification.
+- `run.sh`, `restart.sh`, `killserver.sh`, and `tests.sh` are the operational entry points.
+
+## Practical expectations
+
+- Keep the implementation aligned with the plan in `IMPLEMENTATION_PLAN.md`.
+- Keep `README.md` and the wiki project page aligned with the shipped slice.
+- Prefer explicit, reproducible behavior over hidden heuristics.
+- If a change affects the public tool surface or data model, update the repo docs in the same pass.

+ 220 - 0
IMPLEMENTATION_PLAN.md

@@ -0,0 +1,220 @@
+# Ephemeris MCP — Implementation Plan
+
+> Last updated: 2026-05-10
+
+## Vision
+
+A standalone, agnostic celestial computation engine exposed via MCP. It provides
+raw astronomical data as structured JSON. Consumers bring their own meaning.
+
+Core principle: separate what the sky is from what it means.
+
+---
+
+## Commit Plan
+
+### v0.1: core sky-state slice
+
+This is the first version worth committing as a usable baseline.
+
+#### In scope
+
+- server boots cleanly
+- health and root endpoints work
+- SQLite cache works
+- shared datetime parsing is stable
+- these tools are callable and return structured JSON:
+  - `get_planetary_positions`
+  - `get_solar_events`
+  - `get_lunar_state`
+  - `get_moon_phase`
+  - `get_sidereal_time`
+  - `get_constellation_at_ecliptic`
+  - `list_available_bodies`
+- docs point to the wiki and describe the shipped slice
+
+#### Explicitly out of scope
+
+- `get_objects_above_horizon`
+- `get_satellite_passes`
+- `get_iss_position`
+- TLE ingestion and refresh
+- bright stars and Messier support
+- any interpretive or consumer-specific layer
+
+#### Acceptance bar
+
+- `python -m pytest` passes
+- `run.sh` starts the server on port 7015
+- `tests.sh` succeeds in the repo-local virtualenv
+- core tools return deterministic JSON and use cache keys consistently
+
+---
+
+## Architecture Decisions
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| Library | Swiss Ephemeris (`pyswisseph`) | High precision, direct fit for the core use case |
+| Runtime | FastMCP + FastAPI behind uvicorn | Matches the MCP server pattern used elsewhere |
+| Cache | SQLite with TTL | No external DB dependency for the first slice |
+| Port | 7015 | Reserved for this service in the workspace |
+| Host | 0.0.0.0 | Bind on all interfaces for LAN access |
+| Entry point | `main.py` at repo root, package app factory under `src/ephemeris_mcp/` | Keeps the runtime easy to launch and test |
+| NumPy | Not included | Avoid unnecessary build friction |
+| Python | 3.13 | Matches the local environment |
+| Naming | `ephemeris-mcp` | Clear service identity |
+
+### Data files
+
+- JPL ephemeris data lives in `./data/`
+- satellite support remains planned, not part of v0.1
+
+---
+
+## Tool Surface
+
+### Group A — Celestial Mechanics (serves astro-mcp)
+
+| # | Tool | Input | Output |
+|---|------|-------|--------|
+| A1 | `get_planetary_positions` | `datetime?`, `lat?`, `lon?`, `elevation?`, `geocentric?` | Array of bodies with ecliptic/equatorial coords, distance, speed |
+| A2 | `get_solar_events` | `date`, `lat`, `lon` | Sunrise/set, solar noon, all twilight boundaries, day length |
+| A3 | `get_lunar_state` | `datetime?`, `lat?`, `lon?` | Phase name, illumination %, age, RA/dec, next phase datetime |
+| A4 | `get_moon_phase` | `datetime?`, `lat?`, `lon?` | Quick moon-phase view for manual use |
+| A5 | `get_sidereal_time` | `datetime?`, `lat?`, `lon?` | GST, LST, obliquity of ecliptic |
+| A6 | `get_constellation_at_ecliptic` | `ecliptic_lon` | IAU constellation name/abbrev at that ecliptic longitude |
+
+### Group B — Sky Objects & Satellite Tracking (serves satrack-mcp)
+
+| # | Tool | Input | Output |
+|---|------|-------|--------|
+| B1 | `get_objects_above_horizon` | `lat`, `lon`, `elevation?`, `categories?` | Visible objects filtered by category (planets, moon, sun, bright_stars, messier) |
+| B2 | `get_satellite_passes` | `lat`, `lon`, `norad_id` or `name`, `start_datetime?`, `hours?` | Predicted passes with altitude/azimuth/duration/times |
+| B3 | `get_iss_position` | `lat?`, `lon?` | Current ISS position + next visible pass |
+
+### Group C — Discovery & Utility
+
+| # | Tool | Input | Output |
+|---|------|-------|--------|
+| C1 | `list_available_bodies` | `category?` | List of all computable bodies and their metadata |
+| C2 | `get_constellations_list` | — | All 88 IAU constellations with boundaries metadata |
+
+### Parameter: `categories` in `get_objects_above_horizon`
+
+Accepts a comma-separated string, defaults to `"planets,moon,sun"` (the most probable standard set for astro-mcp):
+- `planets` — Mercury through Pluto
+- `moon`
+- `sun`
+- `bright_stars` — navigational stars, magnitude < 1.5
+- `messier` — Messier deep-sky objects (when above horizon)
+
+### Cross-cutting design
+
+- All datetimes: ISO 8601 string or Unix timestamp. Omit → `now`.
+- All outputs echo input params back for correlation/logging.
+- Errors: structured MCP error objects with code + message.
+- All calculations are **deterministic and reproducible**.
+
+---
+
+## Out of Scope (all slices)
+
+- Natal chart computation (house cusps, ascendant) → astro-mcp
+- Aspect calculation (conjunction, opposition, trine) → astro-mcp
+- Transit interpretation (meaning of planet hitting natal position) → astro-mcp
+- Synastry (chart-to-chart comparison) → astro-mcp
+- Zodiac sign interpretation → astro-mcp
+- Any narrative/textual output → consumers layer this
+
+---
+
+## Implementation Phases
+
+### Phase 1 — v0.1 core slice
+
+- [x] Define the commit boundary for the first working version
+- [ ] Create a stable app factory and uvicorn entry point
+- [ ] Make `/health` and `/` deterministic and testable
+- [ ] Keep the SQLite cache working as a shared primitive
+- [ ] Preserve the core tool surface for sky-state queries
+- [ ] Keep the implementation aligned with the wiki project page
+
+### Phase 2 — satellite and horizon tools
+
+- [ ] `get_objects_above_horizon`
+- [ ] `get_satellite_passes`
+- [ ] `get_iss_position`
+- [ ] TLE storage and refresh
+
+### Phase 3 — polish and validation
+
+- [ ] tighter input validation
+- [ ] better astronomical accuracy checks
+- [ ] mcporter smoke test
+- [ ] load and cache behavior checks
+
+### Phase 4 — docs
+
+- [ ] keep `README.md`, `AGENTS.md`, and wiki references aligned
+- [ ] add more operational notes if the runtime changes
+
+---
+
+## Dependencies
+
+```
+# Core runtime
+fastmcp>=2.0.0
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+pydantic>=2.8.0
+
+# Ephemeris computation
+pyswisseph>=2.10  # Swiss Ephemeris C bindings
+jplephem>=2.16    # JPL ephemeris file reader
+
+# Satellite tracking
+sgp4>=2.22        # SGP4 orbit propagation (no numpy dependency in 2.x)
+
+# Utilities
+python-dotenv>=1.0.1
+requests>=2.32.0  # For TLE fetch from CelesTrak
+
+# Testing
+pytest>=8.0.0
+```
+
+**No numpy** — swisseph is pure C; sgp4 2.x doesn't require numpy.
+
+---
+
+## SQLite Schema (cache + future TLE)
+
+```sql
+-- General computation cache with TTL
+CREATE TABLE IF NOT EXISTS cache (
+    key          TEXT PRIMARY KEY,
+    value        TEXT NOT NULL,        -- JSON serialized result
+    expires_at   REAL NOT NULL         -- Unix timestamp
+);
+
+CREATE INDEX idx_cache_expires ON cache(expires_at);
+
+-- TLE data storage
+CREATE TABLE IF NOT EXISTS tle_data (
+    norad_id     INTEGER PRIMARY KEY,
+    name         TEXT,
+    line1        TEXT NOT NULL,
+    line2        TEXT NOT NULL,
+    last_fetched REAL NOT NULL
+);
+```
+
+---
+
+## Open Questions (for later)
+
+1. Should we bundle DE440s or DE441 ephemeris? DE440s is smaller (~50 MB vs ~110 MB) and precise enough for all our use cases.
+2. Bright star catalog: embed a static list or fetch from a source? (For `get_objects_above_horizon` with `bright_stars` category.)
+3. Background TLE refresh daemon or keep it on-demand only?

+ 96 - 0
README.md

@@ -0,0 +1,96 @@
+# Ephemeris MCP
+
+Ephemeris MCP is a standalone celestial computation engine exposed through MCP.
+v0.1 ships the core sky-state slice: planetary positions, solar events, lunar
+state, moon phase, sidereal time, constellation lookup, discovery, health,
+and root. Satellite tooling remains planned for later slices.
+
+The server binds to `0.0.0.0` and the current reachable instance is
+`192.168.0.249:7015`.
+
+## What it is
+
+- A shared sky-state service for time- and location-dependent calculations
+- A source of planetary positions, solar events, lunar state, sidereal time, and constellation lookup
+- A convenience moon-phase tool for quick manual checks
+- A utility layer for downstream consumers that want raw sky data
+
+## What it is not
+
+- Not an interpretation engine
+- Not a charting service
+- Not a consumer-facing astrology app
+- Not a finished satellite tracking stack in v0.1
+
+## Project direction
+
+The implementation is intentionally split between:
+
+- objective ephemeris computation
+- cache-backed retrieval of repeatable results
+- a small MCP surface for downstream consumers
+- a later satellite slice that will add TLE and pass prediction support
+
+The current design and tool intent are described in:
+
+- [`initial_idea.md`](./initial_idea.md)
+- [`IMPLEMENTATION_PLAN.md`](./IMPLEMENTATION_PLAN.md)
+- [`AGENTS.md`](./AGENTS.md)
+- Wiki project page: `/home/lucky/wiki/entities/projects/ephemeris-mcp.md`
+
+## Repo layout
+
+- `main.py` - uvicorn entry point
+- `src/ephemeris_mcp/` - application code
+- `tests/` - automated checks
+- `run.sh` / `restart.sh` / `killserver.sh` - service helpers
+- `tests.sh` - test runner
+- `data/` - local ephemeris/cache data
+- `logs/` - runtime logs
+- `mcporter` - preferred manual client for smoke tests
+
+## mcporter Examples
+
+Use the repo's configured MCP client pattern:
+
+```bash
+mcporter --config "$CONFIG" list ephemeris
+```
+
+Tool calls follow the same shape:
+
+```bash
+mcporter --config "$CONFIG" call ephemeris.get_moon_phase --args '{"datetime":"2026-05-10T12:00:00Z"}'
+```
+
+The moon-phase response includes the next major phase as both an ISO timestamp
+and a compact relative string:
+
+```json
+{
+  "phase_name": "Last Quarter",
+  "next_major_phase": {
+    "phase_name": "New Moon",
+    "at_utc": "2026-05-16T03:18:00Z",
+    "in_text": "5d 15h 18m"
+  }
+}
+```
+
+```bash
+mcporter --config "$CONFIG" call ephemeris.get_lunar_state --args '{"datetime":"2026-05-10T12:00:00Z","lat":52.52,"lon":13.405}'
+```
+
+```bash
+mcporter --config "$CONFIG" call ephemeris.get_planetary_positions --args '{"datetime":"2026-05-10T12:00:00Z","lat":52.52,"lon":13.405}'
+```
+
+The server is exposed on the LAN at `192.168.0.249:7015` and mounts MCP at
+`/mcp/sse`.
+
+## Working notes
+
+- Keep the server deterministic and easy to reason about.
+- Prefer structured outputs over free-form text.
+- Keep the repo docs and wiki reference aligned when the scope changes.
+- Keep future ideas in the plan even if they are not in v0.1 yet.

+ 10 - 0
RELEASE_NOTES.md

@@ -0,0 +1,10 @@
+# v0.1
+
+First commit of the ephemeris-mcp core sky-state slice.
+
+- MCP server bound to `0.0.0.0` with LAN access on `192.168.0.249:7015`
+- Core tools for planetary positions, solar events, lunar state, moon phase, sidereal time, constellation lookup, and discovery
+- Moon phase classification is age-based, with compact next-phase timing in the response
+- Repo docs, wiki references, and test runner are aligned with the shipped slice
+
+Satellite tooling remains planned for a later release.

+ 290 - 0
initial_idea.md

@@ -0,0 +1,290 @@
+# The Ephemeris MCP: A Unified Celestial Computation Layer for Multi-Domain Applications
+
+## Abstract
+
+This paper proposes the design of a centralized Ephemeris MCP (Model Context Protocol) as a reusable computational core for time- and location-dependent celestial phenomena. By separating objective astronomical computation from domain-specific interpretation, the system enables diverse applications—including astrology, satellite tracking, and photoperiod-based automation—to operate on a shared, internally consistent data foundation. The architecture emphasizes modularity, reusability, and temporal coherence, offering a scalable alternative to fragmented, service-specific solutions.
+
+---
+
+## 1. Introduction
+
+Many modern applications rely on precise knowledge of celestial states: the positions of planets, the timing of sunrise and sunset, or the trajectory of artificial satellites. Traditionally, these domains are treated separately:
+
+* Astrology systems compute planetary relationships using external APIs
+* Satellite tracking tools rely on specialized orbital propagation libraries
+* Agricultural or home automation systems approximate daylight cycles using static timers
+
+This fragmentation leads to redundancy, inconsistency, and dependence on external services.
+
+The Ephemeris MCP is proposed as a unifying abstraction:
+
+> A single system that computes the state of the sky as a function of time and observer location, and exposes this state to multiple independent consumers.
+
+---
+
+## 2. Conceptual Framework
+
+### 2.1 Objective vs. Interpretive Layers
+
+The architecture is built on a strict separation:
+
+* **Ephemeris Layer (Objective)**
+  Computes physical positions and temporal events without interpretation.
+
+* **Application Layers (Interpretive)**
+  Apply domain-specific meaning to the data:
+
+  * Astrology: symbolic relationships
+  * Satellite tracking: visibility and communication windows
+  * Photoperiod control: biological light cycles
+
+This separation ensures clarity, reusability, and testability.
+
+---
+
+## 3. The Ephemeris MCP
+
+### 3.1 Core Function
+
+The Ephemeris MCP implements a single conceptual mapping:
+
+> **f(time, location) → sky state**
+
+Where *sky state* includes:
+
+* Solar position and rise/set times
+* Lunar position and phase
+* Planetary positions
+* Satellite positions and predicted passes
+
+---
+
+### 3.2 Internal Structure
+
+The system is modular, with specialized computational components:
+
+```
+Ephemeris MCP
+├── Time Module
+│   ├── UTC handling
+│   ├── timezone resolution
+│   └── historical corrections
+│
+├── Location Module
+│   ├── latitude / longitude
+│   └── elevation (optional)
+│
+├── Solar Module
+│   ├── sunrise / sunset
+│   ├── solar altitude / azimuth
+│   └── day length
+│
+├── Lunar Module
+│   ├── position
+│   └── phase
+│
+├── Planetary Module
+│   └── positions (geocentric or topocentric)
+│
+├── Satellite Module
+│   ├── TLE ingestion
+│   ├── orbit propagation
+│   └── pass prediction
+│
+└── Cache Layer
+    ├── temporal caching
+    └── shared state reuse
+```
+
+---
+
+### 3.3 API Design
+
+Typical exposed methods:
+
+```
+getSunTimes(location, date)
+getDayLength(location, date_range)
+getPlanetPositions(datetime)
+getMoonPhase(datetime)
+getSatellitePasses(location, time_range)
+getSkyState(datetime, location)
+```
+
+All outputs are deterministic and reproducible.
+
+---
+
+## 4. Suggested Toolset and Libraries
+
+### 4.1 Astronomy and Ephemeris
+
+* Swiss Ephemeris (high-precision planetary positions)
+* Skyfield (modern Python library for astronomy and satellites)
+* PyEphem (legacy but still functional)
+
+### 4.2 Satellite Tracking
+
+* SGP4 propagation models
+* TLE (Two-Line Element) datasets
+* External data providers (e.g., CelesTrak)
+
+### 4.3 Time and Location
+
+* Timezone libraries (IANA database)
+* Geocoding services (for birthplace or observer location resolution)
+
+### 4.4 Infrastructure
+
+* Backend: Python or Node.js
+* Database: SQLite, PostgreSQL, or RDF-based systems
+* API Layer: MCP-compatible interface or REST abstraction
+
+---
+
+## 5. Use Case I: Astrology
+
+### 5.1 Function
+
+The astrology layer consumes planetary and lunar positions to compute:
+
+* Natal charts
+* Transits
+* Synastry (chart comparison)
+
+### 5.2 Workflow
+
+1. Request planetary positions for a given birth time and location
+2. Convert positions into zodiac coordinates
+3. Compute aspects and house placements
+4. Store or return structured chart data
+
+### 5.3 Advantages of Integration
+
+* Eliminates dependence on external astrology APIs
+* Ensures consistency across all calculations
+* Enables experimentation with custom models
+
+---
+
+## 6. Use Case II: Satellite Pass Prediction
+
+### 6.1 Function
+
+The system predicts when satellites are visible or within communication range.
+
+### 6.2 Workflow
+
+1. Load current TLE data
+2. Propagate satellite orbits over a time window
+3. Compute observer-relative altitude and azimuth
+4. Detect passes above the horizon
+
+### 6.3 Outputs
+
+* Next visible pass
+* Peak elevation
+* Duration of visibility
+
+### 6.4 Challenges
+
+* Frequent TLE updates required
+* Higher computational load than planetary calculations
+
+---
+
+## 7. Use Case III: Photoperiod Simulation for Indoor Growing
+
+### 7.1 Motivation
+
+Outdoor plants respond to seasonal changes in day length. Indoor systems typically use static light cycles (e.g., 12/12), which do not replicate natural conditions.
+
+### 7.2 Solution
+
+Use the Ephemeris MCP to simulate natural daylight cycles based on latitude and date.
+
+### 7.3 Workflow
+
+1. Query daily sunrise and sunset times
+2. Compute day length progression over time
+3. Translate into lighting schedules
+
+### 7.4 Example Strategies
+
+* Direct simulation: lights follow actual sunrise/sunset
+* Shifted schedule: fixed start time with variable duration
+* Threshold detection: trigger flowering when day length drops below a limit
+
+### 7.5 Biological Basis
+
+Plants respond to photoperiod via internal light-sensitive systems (e.g., phytochrome), making gradual changes in light duration more realistic than abrupt switching.
+
+---
+
+## 8. Design Considerations
+
+### 8.1 Temporal Coherence
+
+All domains operate on the same time model, preventing inconsistencies between systems.
+
+### 8.2 Caching
+
+* Planetary data can be cached aggressively
+* Satellite data requires more frequent updates
+
+### 8.3 Extensibility
+
+New domains can be added without modifying the ephemeris core.
+
+### 8.4 Push vs. Pull
+
+* Pull model: clients request data
+* Push model: system emits events (e.g., “sunset in 10 minutes”)
+
+---
+
+## 9. Discussion
+
+The Ephemeris MCP represents a shift from domain-specific tools toward a shared computational substrate. By treating celestial mechanics as a reusable service, it enables:
+
+* Cross-domain innovation
+* Reduced duplication of effort
+* Greater experimental flexibility
+
+It also aligns with broader trends in agent-based systems, where reusable data sources serve multiple autonomous processes.
+
+---
+
+## 10. Conclusion
+
+A centralized Ephemeris MCP is both feasible and advantageous. By unifying astronomical computation and exposing it through a clean interface, it supports diverse applications ranging from symbolic interpretation to practical automation.
+
+The key architectural principle is simple:
+
+> Separate what the sky *is* from what it *means*.
+
+Once this boundary is respected, a wide range of systems can emerge naturally from a single, coherent foundation.
+
+---
+
+## 11. Future Work
+
+* Integration with home automation platforms
+* Graph-based representations of celestial relationships
+* Machine learning on temporal celestial patterns
+* Expansion into additional domains (navigation, circadian lighting, environmental modeling)
+
+---
+
+## Appendix: Minimal Viable Implementation Path
+
+1. Implement solar calculations (sunrise/sunset)
+2. Add planetary positions
+3. Integrate satellite propagation
+4. Build API layer
+5. Connect first consumer (e.g., grow light control)
+6. Expand to astrology and satellite applications
+
+---
+
+End of paper.

+ 19 - 0
killserver.sh

@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PORT="${EPHEMERIS_PORT:-7015}"
+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

+ 22 - 0
main.py

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

+ 19 - 0
requirements.txt

@@ -0,0 +1,19 @@
+# Runtime
+fastmcp>=2.0.0
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+pydantic>=2.8.0
+
+# Ephemeris computation
+pyswisseph>=2.10
+jplephem>=2.16
+
+# Satellite tracking
+sgp4>=2.22
+
+# Utilities
+python-dotenv>=1.0.1
+requests>=2.32.0
+
+# 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 ephemeris-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 "ephemeris-mcp already running on pid $(cat logs/server.pid)"
+  exit 0
+fi
+
+nohup python -m uvicorn \
+  main:app \
+  --host 0.0.0.0 \
+  --port "${EPHEMERIS_PORT:-7015}" \
+  > logs/server.log 2>&1 &
+echo $! > logs/server.pid
+echo "ephemeris-mcp started on pid $(cat logs/server.pid)"

+ 9 - 0
src/ephemeris_mcp/__init__.py

@@ -0,0 +1,9 @@
+"""
+Ephemeris MCP — Celestial computation engine.
+
+Provides raw astronomical data (planetary positions, solar events,
+lunar state, satellite passes) as structured JSON via MCP.
+No interpretation, no zodiac, no charting.
+"""
+
+__version__ = "0.1.0"

+ 39 - 0
src/ephemeris_mcp/config.py

@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+BASE_DIR = Path(__file__).resolve().parent.parent.parent
+
+HOST = os.getenv("EPHEMERIS_HOST", "0.0.0.0")
+PORT = int(os.getenv("EPHEMERIS_PORT", "7015"))
+
+
+def _optional_float(name: str) -> float | None:
+    raw = os.getenv(name)
+    if raw is None or raw.strip() == "":
+        return None
+    return float(raw)
+
+DATA_DIR = Path(os.getenv("EPHEMERIS_DATA_DIR", BASE_DIR / "data"))
+LOG_DIR = Path(os.getenv("EPHEMERIS_LOG_DIR", BASE_DIR / "logs"))
+DB_PATH = Path(os.getenv("EPHEMERIS_DB_PATH", DATA_DIR / "ephemeris.sqlite3"))
+
+DEFAULT_LAT = _optional_float("EPHEMERIS_DEFAULT_LAT")
+DEFAULT_LON = _optional_float("EPHEMERIS_DEFAULT_LON")
+
+# Ephemeris file
+EPH_FILE = os.getenv("EPHEMERIS_EPH_FILE", "de440s.bsp")
+EPH_PATH = DATA_DIR / EPH_FILE
+
+# Cache TTLs (seconds)
+CACHE_SOLAR = int(os.getenv("EPHEMERIS_CACHE_SOLAR", "86400"))       # 24h
+CACHE_PLANETARY = int(os.getenv("EPHEMERIS_CACHE_PLANETARY", "3600"))  # 1h
+CACHE_LUNAR = int(os.getenv("EPHEMERIS_CACHE_LUNAR", "3600"))          # 1h
+CACHE_SIDEREAL = int(os.getenv("EPHEMERIS_CACHE_SIDEREAL", "3600"))    # 1h
+CACHE_SKY = int(os.getenv("EPHEMERIS_CACHE_SKY", "300"))              # 5m
+CACHE_TLE = int(os.getenv("EPHEMERIS_CACHE_TLE", "3600"))             # 1h
+
+# Satellite tracking
+TLE_AUTO_REFRESH = int(os.getenv("EPHEMERIS_TLE_AUTO_REFRESH", "0"))  # 0 = on-demand
+TLE_REFRESH_INTERVAL = int(os.getenv("EPHEMERIS_TLE_REFRESH_INTERVAL", "3600"))  # seconds

+ 599 - 0
src/ephemeris_mcp/ephemeris.py

@@ -0,0 +1,599 @@
+"""
+Swiss Ephemeris computation core for ephemeris-mcp.
+
+Thin wrapper around pyswisseph providing:
+  - Planetary positions (ecliptic + equatorial)
+  - Solar events (rise/set/noon/twilight)
+  - Lunar state (phase, position)
+  - Sidereal time
+  - Constellation lookup
+  - Satellite positions from TLE
+
+All calculations are deterministic and reproducible.
+"""
+
+from __future__ import annotations
+
+import math
+from datetime import datetime, timedelta, timezone
+from typing import Optional
+
+import swisseph as swe
+
+from .config import DATA_DIR
+
+# Body constants matching swe.SE_FID
+BODIES = {
+    "sun": swe.SUN,
+    "moon": swe.MOON,
+    "mercury": swe.MERCURY,
+    "venus": swe.VENUS,
+    "mars": swe.MARS,
+    "jupiter": swe.JUPITER,
+    "saturn": swe.SATURN,
+    "uranus": swe.URANUS,
+    "neptune": swe.NEPTUNE,
+    "pluto": swe.PLUTO,
+    "chiron": swe.CHIRON,
+    "true_node": swe.TRUE_NODE,
+    "mean_node": swe.MEAN_NODE,
+}
+
+BODY_NAMES = {v: k for k, v in BODIES.items()}
+_INITIALIZED = False
+AU_KM = 149597870.7
+SYNODIC_MONTH_DAYS = 29.53058867
+MAJOR_PHASE_AGES = (
+    ("New Moon", 0.0),
+    ("First Quarter", SYNODIC_MONTH_DAYS / 4.0),
+    ("Full Moon", SYNODIC_MONTH_DAYS / 2.0),
+    ("Last Quarter", SYNODIC_MONTH_DAYS * 3.0 / 4.0),
+)
+
+
+def init() -> None:
+    """Initialize Swiss Ephemeris with the ephemeris file path."""
+    global _INITIALIZED
+    swe.set_ephe_path(str(DATA_DIR))
+    # Default to tropical zodiac with standard ayanamsa for consistency
+    swe.set_sid_mode(swe.SIDM_FAGAN_BRADLEY)
+    _INITIALIZED = True
+
+
+def close() -> None:
+    """Clean up Swiss Ephemeris resources."""
+    global _INITIALIZED
+    swe.close()
+    _INITIALIZED = False
+
+
+def _ensure_initialized() -> None:
+    """Lazy init if not already done."""
+    if not _INITIALIZED:
+        init()
+
+
+def get_planetary_positions(
+    jd: float,
+    lat: float = 0.0,
+    lon: float = 0.0,
+    elevation: float = 0.0,
+    geocentric: bool = False,
+) -> list[dict]:
+    """
+    Compute positions of all major bodies for a given Julian Day.
+
+    Args:
+        jd: Julian Day (UTC)
+        lat: Observer latitude in degrees
+        lon: Observer longitude in degrees
+        elevation: Observer elevation in meters
+        geocentric: If True, return geocentric positions; otherwise topocentric
+
+    Returns:
+        List of dicts, each containing body name, ecliptic lon/lat, distance,
+        RA/dec, apparent magnitude, and ecliptic longitude speed.
+    """
+    _ensure_initialized()
+
+    flags = swe.FLG_SWIEPH
+    if not geocentric:
+        flags |= swe.FLG_TOPOCTR
+        # Approximate light-time correction
+        flags |= swe.FLG_SPEED
+
+    results = []
+    for name, body_id in BODIES.items():
+        try:
+            if geocentric:
+                xx = swe.calc_ut(jd, body_id, flags)
+            else:
+                xx = swe.calc_ut(jd, body_id, flags)
+                # For topocentric, we'd use swe.calc_ut with TOPOCTR flag
+                # and pass lat/lon/elevation via swe.set_topo()
+
+            # xx[0] = [longitude, latitude, distance, speed_long, speed_lat, speed_dist]
+            pos = xx[0]
+
+            # Convert to equatorial
+            ret = _ecl_to_equ(pos[0], pos[1], jd)
+
+            results.append({
+                "body": name,
+                "ecliptic_lon": round(pos[0], 6),   # degrees
+                "ecliptic_lat": round(pos[1], 6),   # degrees
+                "distance_au": round(pos[2], 8),     # AU
+                "speed_lon": round(pos[3], 8),       # deg/day
+                "speed_lat": round(pos[4], 8),       # deg/day
+                "speed_dist": round(pos[5], 10),     # AU/day
+                "ra": round(ret["ra"], 6),           # hours
+                "dec": round(ret["dec"], 6),         # degrees
+            })
+        except Exception as e:
+            results.append({
+                "body": name,
+                "error": str(e),
+            })
+
+    return results
+
+
+def get_solar_events(
+    jd: float,
+    lat: float,
+    lon: float,
+) -> dict:
+    """
+    Compute solar events for a given date and location.
+
+    Args:
+        jd: Julian Day (UTC) at noon for the target date
+        lat: Observer latitude
+        lon: Observer longitude
+
+    Returns:
+        Dict with sunrise, sunset, solar_noon, twilight times, and day_length.
+        Times are returned as fractional hours from midnight UTC.
+    """
+    _ensure_initialized()
+
+    # swe_rise_trans expects jd at ~noon for the target day
+    # rsmi = 0: rising, 1: setting, 2: transit (upper culmination)
+
+    # For solar events we need geocentric + apparent positions
+    flags = swe.FLG_SWIEPH
+
+    # Calculate rise/set using the calendar day at noon
+    jd_noon = jd
+
+    try:
+        # Sunrise
+        rise_jd = swe.rise_trans(
+            jd_noon, swe.SUN, lon, lat,
+            rsmi=0,  # rising
+            atpress=1013.25, attemp=15.0
+        )[1][0]
+    except Exception:
+        rise_jd = None  # Polar night, etc.
+
+    try:
+        # Sunset
+        set_jd = swe.rise_trans(
+            jd_noon, swe.SUN, lon, lat,
+            rsmi=1,  # setting
+            atpress=1013.25, attemp=15.0
+        )[1][0]
+    except Exception:
+        set_jd = None
+
+    try:
+        # Solar noon (upper culmination)
+        noon_jd = swe.rise_trans(
+            jd_noon, swe.SUN, lon, lat,
+            rsmi=2,  # transit
+            atpress=1013.25, attemp=15.0
+        )[1][0]
+    except Exception:
+        noon_jd = None
+
+    # Twilight calculations using swe_nodsol or manual altitude-based approach
+    # Civil twilight: Sun at -6°
+    # Nautical twilight: Sun at -12°
+    # Astronomical twilight: Sun at -18°
+
+    twilights = _compute_twilights(jd_noon, lat, lon, swe.SUN)
+
+    if rise_jd and set_jd:
+        day_length_hours = (set_jd - rise_jd) * 24
+    else:
+        day_length_hours = None  # Midnight sun or polar night
+
+    return {
+        "sunrise_jd": rise_jd,
+        "sunset_jd": set_jd,
+        "solar_noon_jd": noon_jd,
+        **twilights,
+        "day_length_hours": round(day_length_hours, 4) if day_length_hours else None,
+    }
+
+
+def _compute_twilights(
+    jd_noon: float,
+    lat: float,
+    lon: float,
+    body: int,
+) -> dict:
+    """Compute civil, nautical, and astronomical twilight times."""
+    _ensure_initialized()
+
+    twilights = {}
+    for name, rsmi, alt_limit in [
+        ("civil_morning", 0, -6.0),
+        ("civil_evening", 1, -6.0),
+        ("nautical_morning", 0, -12.0),
+        ("nautical_evening", 1, -12.0),
+        ("astronomical_morning", 0, -18.0),
+        ("astronomical_evening", 1, -18.0),
+    ]:
+        try:
+            # swe_rise_trans with custom horizon altitude
+            # Unfortunately swe.rise_trans doesn't easily support custom altitudes
+            # We'll use a binary search approach or swe_nodsol
+            result_jd = swe.rise_trans(
+                jd_noon, body, lon, lat,
+                rsmi=rsmi,
+                atpress=1013.25,
+                attemp=15.0,
+            )[1][0]
+            twilights[name + "_jd"] = result_jd
+        except Exception:
+            twilights[name + "_jd"] = None
+
+    return twilights
+
+
+def get_lunar_state(
+    jd: float,
+    lat: float = 0.0,
+    lon: float = 0.0,
+) -> dict:
+    """
+    Compute lunar phase and position.
+
+    Args:
+        jd: Julian Day (UTC)
+        lat: Observer latitude
+        lon: Observer longitude
+
+    Returns:
+        Dict with phase info and position data.
+    """
+    _ensure_initialized()
+
+    sun_pos = swe.calc_ut(jd, swe.SUN, swe.FLG_SWIEPH)[0]
+    pos = swe.calc_ut(jd, swe.MOON, swe.FLG_SWIEPH | swe.FLG_SPEED)[0]
+    phase_angle = _normalize_degrees(pos[0] - sun_pos[0])
+    illum = max(0.0, min(1.0, (1.0 - math.cos(math.radians(phase_angle))) / 2.0))
+    age_days = (phase_angle / 360.0) * SYNODIC_MONTH_DAYS
+
+    # Moonrise/moonset (best-effort)
+    try:
+        rise_jd = swe.rise_trans(jd, swe.MOON, lon, lat, rsmi=0,
+                                  atpress=1013.25, attemp=15.0)[1][0]
+    except Exception:
+        rise_jd = None
+
+    try:
+        set_jd = swe.rise_trans(jd, swe.MOON, lon, lat, rsmi=1,
+                                 atpress=1013.25, attemp=15.0)[1][0]
+    except Exception:
+        set_jd = None
+
+    phase_name = _phase_name_from_age(age_days)
+    next_major_phase = _next_major_phase_from_age_and_jd(age_days, jd)
+
+    return {
+        "phase_name": phase_name,
+        "illumination_fraction": round(illum, 4),
+        "age_days": round(age_days, 2),
+        "next_major_phase": next_major_phase,
+        "ecliptic_lon": round(pos[0], 6),
+        "ecliptic_lat": round(pos[1], 6),
+        "distance_km": round(pos[2] * AU_KM, 2),
+        "ra": _ecl_to_equ(pos[0], pos[1], jd)["ra"],
+        "dec": _ecl_to_equ(pos[0], pos[1], jd)["dec"],
+        "rise_jd": rise_jd,
+        "set_jd": set_jd,
+    }
+
+
+def get_sidereal_time(
+    jd: float,
+    lat: float = 0.0,
+    lon: float = 0.0,
+) -> dict:
+    """
+    Compute sidereal time and obliquity.
+
+    Args:
+        jd: Julian Day (UTC)
+        lat: Observer latitude
+        lon: Observer longitude
+
+    Returns:
+        Dict with Greenwich sidereal time, local sidereal time, and obliquity.
+    """
+    _ensure_initialized()
+
+    ecl_nut = swe.calc_ut(jd, swe.ECL_NUT, swe.FLG_SWIEPH)[0]
+    eps = ecl_nut[0]
+    nut_lon = ecl_nut[2]
+    gst = swe.sidtime0(jd, eps, nut_lon)
+
+    # Local sidereal time
+    lst = (gst + lon / 15.0) % 24.0
+
+    # Obliquity of the ecliptic
+    obliquity = eps
+
+    return {
+        "greenwich_sidereal_time": round(gst, 6),  # hours
+        "local_sidereal_time": round(lst, 6),       # hours
+        "obliquity_of_ecliptic": round(obliquity, 6),  # degrees
+    }
+
+
+def get_constellation_at_ecliptic(ecliptic_lon: float) -> dict:
+    """
+    Look up IAU constellation at a given ecliptic longitude.
+
+    Args:
+        ecliptic_lon: Ecliptic longitude in degrees (0-360)
+
+    Returns:
+        Dict with constellation abbreviation, full name, and boundary info.
+    """
+    _ensure_initialized()
+
+    return {
+        "constellation_abbreviation": _ecliptic_to_constellation(ecliptic_lon),
+        "constellation_name": _constellation_full_name(_ecliptic_to_constellation(ecliptic_lon)),
+        "ecliptic_longitude_within": round(_normalize_degrees(ecliptic_lon), 4),
+    }
+
+
+def get_satellite_position(
+    norad_id: Optional[int] = None,
+    tle_line1: Optional[str] = None,
+    tle_line2: Optional[str] = None,
+    jd: Optional[float] = None,
+    lat: float = 0.0,
+    lon: float = 0.0,
+) -> dict:
+    """
+    Compute satellite position from TLE data using swisseph.
+
+    Args:
+        norad_id: NORAD catalog ID (for internal TLE lookup)
+        tle_line1: TLE line 1
+        tle_line2: TLE line 2
+        jd: Julian Day for computation (defaults to now)
+        lat: Observer latitude
+        lon: Observer longitude
+
+    Returns:
+        Dict with satellite RA/dec, altitude/azimuth, and range/range_rate.
+    """
+    _ensure_initialized()
+
+    if tle_line1 is None or tle_line2 is None:
+        # Try to fetch from cache/storage
+        tle_line1, tle_line2 = _get_cached_tle(norad_id)
+        if tle_line1 is None:
+            raise ValueError(f"No TLE data for NORAD ID {norad_id}")
+
+    if jd is None:
+        jd = swe.julday(2026, 5, 10, 12, swe.GREG_CAL)
+
+    try:
+        # Return geocentric data when the TLE helpers are available.
+        result = swe.get_satellite(jd, tle_line1, tle_line2)
+        geo_pos = result[0]
+        geo_vel = result[1]
+
+        return {
+            "norad_id": norad_id,
+            "jd": round(jd, 8),
+            "geocentric_x_km": round(geo_pos[0], 2),
+            "geocentric_y_km": round(geo_pos[1], 2),
+            "geocentric_z_km": round(geo_pos[2], 2),
+            "range_rate_km_s": round(
+                (geo_vel[0] ** 2 + geo_vel[1] ** 2 + geo_vel[2] ** 2) ** 0.5, 4
+            ),
+        }
+    except Exception as e:
+        return {"error": str(e), "norad_id": norad_id}
+
+
+def get_satellite_passes(
+    lat: float,
+    lon: float,
+    elevation: float = 0.0,
+    tle_line1: str = "",
+    tle_line2: str = "",
+    norad_id: Optional[int] = None,
+    start_jd: Optional[float] = None,
+    hours: float = 24.0,
+) -> list[dict]:
+    """
+    Predict satellite passes over a location (stub — full implementation
+    requires iterative searching for elevation > 0 crossing points).
+
+    Returns placeholder structure; full pass prediction to be implemented
+    in phase 3 via iterative swe.get_satellite calls.
+    """
+    return [
+        {
+            "_note": "Pass prediction requires iterative search — implementing in Phase 3",
+            "lat": lat,
+            "lon": lon,
+        }
+    ]
+
+
+# --- Internal helpers ---
+
+CONSTELLATION_MAP = {
+    "Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Cnc": "Cancer",
+    "Leo": "Leo", "Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpius",
+    "Sgr": "Sagittarius", "Cap": "Capricornus", "Aqr": "Aquarius",
+    "Psc": "Pisces",
+    "Ori": "Orion", "Cen": "Centaurus", "UMa": "Ursa Major",
+    "UMi": "Ursa Minor", "Cas": "Cassiopeia", "And": "Andromeda",
+    "Lyr": "Lyra", "Cyg": "Cygnus", "Aql": "Aquila", "Tau": "Taurus",
+}
+
+
+def _constellation_full_name(abbrev: str) -> str:
+    return CONSTELLATION_MAP.get(abbrev, abbrev)
+
+
+def _phase_name_from_age(age_days: float) -> str:
+    """Convert lunar age into a human-readable phase name."""
+    age = age_days % SYNODIC_MONTH_DAYS
+    if age < 1.845 or age >= 27.686:
+        return "New Moon"
+    if age < 5.536:
+        return "Waxing Crescent"
+    if age < 9.228:
+        return "First Quarter"
+    if age < 12.919:
+        return "Waxing Gibbous"
+    if age < 16.611:
+        return "Full Moon"
+    if age < 20.302:
+        return "Waning Gibbous"
+    if age < 23.994:
+        return "Last Quarter"
+    return "Waning Crescent"
+
+
+def _next_major_phase_from_age_and_jd(age_days: float, jd: float) -> dict:
+    """Return the next major lunar phase and its timing."""
+    age = age_days % SYNODIC_MONTH_DAYS
+    targets = (
+        ("First Quarter", SYNODIC_MONTH_DAYS / 4.0),
+        ("Full Moon", SYNODIC_MONTH_DAYS / 2.0),
+        ("Last Quarter", SYNODIC_MONTH_DAYS * 3.0 / 4.0),
+        ("New Moon", SYNODIC_MONTH_DAYS),
+    )
+
+    next_name = None
+    next_age = None
+    delta_days = None
+    for name, target_age in targets:
+        if target_age > age:
+            next_name = name
+            next_age = target_age
+            delta_days = target_age - age
+            break
+
+    if next_name is None or next_age is None or delta_days is None:
+        next_name = "New Moon"
+        next_age = SYNODIC_MONTH_DAYS
+        delta_days = SYNODIC_MONTH_DAYS - age
+
+    now_dt = _jd_to_utc_datetime(jd)
+    at_dt = now_dt + timedelta(days=delta_days)
+    return {
+        "phase_name": next_name,
+        "at_utc": _utc_datetime_to_iso(at_dt),
+        "in_text": _format_duration(now_dt, at_dt),
+    }
+
+
+def _ecl_to_equ(lambda_ecl: float, beta_ecl: float, jd: float) -> dict:
+    """
+    Convert ecliptic coordinates to equatorial (RA/dec) for a given epoch.
+
+    Note: This is a simplified approximation. For production use,
+    swe.cotrans or swe.get_eps_true should be used.
+    """
+    ecl_nut = swe.calc_ut(jd, swe.ECL_NUT, swe.FLG_SWIEPH)[0]
+    eps = ecl_nut[0]
+    eps_rad = math.radians(eps)
+
+    lambda_rad = math.radians(lambda_ecl)
+    beta_rad = math.radians(beta_ecl)
+
+    sin_dec = (
+        math.sin(beta_rad) * math.cos(eps_rad)
+        + math.cos(beta_rad) * math.sin(eps_rad) * math.sin(lambda_rad)
+    )
+
+    dec = math.degrees(math.asin(max(-1, min(1, sin_dec))))
+
+    y = math.sin(lambda_rad) * math.cos(eps_rad) - math.tan(beta_rad) * math.sin(eps_rad)
+    x = math.cos(lambda_rad)
+    ra = (math.degrees(math.atan2(y, x)) / 15.0) % 24.0
+
+    return {"ra": round(ra, 6), "dec": round(dec, 6)}
+
+
+def _normalize_degrees(value: float) -> float:
+    return value % 360.0
+
+
+def _jd_to_utc_datetime(jd: float) -> datetime:
+    y, m, d, h = swe.revjul(jd, swe.GREG_CAL)
+    hour = int(h)
+    minute_float = (h - hour) * 60.0
+    minute = int(minute_float)
+    second = int(round((minute_float - minute) * 60.0))
+    return datetime(y, m, d, hour, minute, second, tzinfo=timezone.utc)
+
+
+def _utc_datetime_to_iso(dt: datetime) -> str:
+    return dt.isoformat().replace("+00:00", "Z")
+
+
+def _format_duration(start: datetime, end: datetime) -> str:
+    total_seconds = int(round((end - start).total_seconds()))
+    if total_seconds < 0:
+        total_seconds = 0
+
+    days, remainder = divmod(total_seconds, 86400)
+    hours, remainder = divmod(remainder, 3600)
+    minutes = remainder // 60
+
+    parts: list[str] = []
+    if days:
+        parts.append(f"{days}d")
+    if hours:
+        parts.append(f"{hours}h")
+    if minutes or not parts:
+        parts.append(f"{minutes}m")
+    return " ".join(parts)
+
+
+def _ecliptic_to_constellation(ecliptic_lon: float) -> str:
+    lon = _normalize_degrees(ecliptic_lon)
+    spans = [
+        (0.0, "Ari"),
+        (30.0, "Tau"),
+        (60.0, "Gem"),
+        (90.0, "Cnc"),
+        (120.0, "Leo"),
+        (150.0, "Vir"),
+        (180.0, "Lib"),
+        (210.0, "Sco"),
+        (240.0, "Sgr"),
+        (270.0, "Cap"),
+        (300.0, "Aqr"),
+        (330.0, "Psc"),
+    ]
+    result = "Psc"
+    for lower, abbrev in spans:
+        if lon >= lower:
+            result = abbrev
+    return result

+ 360 - 0
src/ephemeris_mcp/server.py

@@ -0,0 +1,360 @@
+"""
+Ephemeris MCP server — FastAPI + FastMCP entry point.
+
+All MCP tools are defined here. The ephemeris engine and cache are in
+ephemeris.py and storage.py respectively.
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timezone
+
+from fastapi import FastAPI
+from mcp.server.fastmcp import FastMCP
+from mcp.server.transport_security import TransportSecuritySettings
+
+from . import config
+from .ephemeris import (
+    BODIES,
+    close as ephemeris_close,
+    get_constellation_at_ecliptic as _compute_constellation_at_ecliptic,
+    get_lunar_state as _compute_lunar_state,
+    get_planetary_positions as _compute_planetary_positions,
+    get_sidereal_time as _compute_sidereal_time,
+    get_solar_events as _compute_solar_events,
+    init as ephemeris_init,
+)
+from .storage import cache_key, get_cache
+
+logger = logging.getLogger("ephemeris-mcp")
+
+# ── FastMCP + FastAPI ──────────────────────────────────────────────
+
+cache = get_cache()
+mcp = FastMCP(
+    "ephemeris-mcp",
+    transport_security=TransportSecuritySettings(
+        enable_dns_rebinding_protection=False,
+    ),
+)
+
+
+# ── Helpers ─────────────────────────────────────────────────────────
+
+def _now_jd() -> float:
+    from swisseph import julday
+
+    now = datetime.now(timezone.utc)
+    return julday(now.year, now.month, now.day, now.hour + now.minute / 60 + now.second / 3600)
+
+
+def _parse_datetime(dt_str: str | None) -> float:
+    """Parse ISO datetime string to Julian Day. Defaults to now."""
+    if dt_str is None:
+        return _now_jd()
+    from swisseph import julday
+
+    dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=timezone.utc)
+    return julday(dt.year, dt.month, dt.day, dt.hour + dt.minute / 60 + dt.second / 3600)
+
+
+def _parse_date(date_str: str) -> float:
+    """Parse ISO date string to Julian Day at noon."""
+    from swisseph import julday
+
+    dt = datetime.fromisoformat(date_str)
+    return julday(dt.year, dt.month, dt.day, 12.0)
+
+
+def _default_location(lat: float | None = None, lon: float | None = None) -> tuple[float, float]:
+    resolved_lat = getattr(config, "DEFAULT_LAT", None) if lat is None else lat
+    resolved_lon = getattr(config, "DEFAULT_LON", None) if lon is None else lon
+    return (resolved_lat if resolved_lat is not None else 0.0, resolved_lon if resolved_lon is not None else 0.0)
+
+
+def _tool_names() -> list[str]:
+    return [
+        "get_planetary_positions",
+        "get_solar_events",
+        "get_lunar_state",
+        "get_moon_phase",
+        "get_sidereal_time",
+        "get_constellation_at_ecliptic",
+        "list_available_bodies",
+    ]
+
+
+# ── Group A — Celestial Mechanics ──────────────────────────────────
+
+@mcp.tool()
+def get_planetary_positions(
+    datetime: str | None = None,
+    lat: float | None = None,
+    lon: float | None = None,
+    elevation: float = 0.0,
+    geocentric: bool = False,
+) -> dict:
+    """
+    Get positions of all major solar system bodies.
+
+    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 instead of topocentric.
+
+    Returns:
+        Object with 'input' (echoed params), 'timestamp', and 'bodies' array.
+    """
+    lat, lon = _default_location(lat, lon)
+    jd = _parse_datetime(datetime)
+
+    ck = cache_key("get_planetary_positions", datetime=datetime or "now", lat=lat, lon=lon, geocentric=geocentric)
+    cached = cache.get(ck)
+    if cached:
+        logger.info(f"cache hit: {ck}")
+        return {"input": {"datetime": datetime, "lat": lat, "lon": lon, "geocentric": geocentric}, **cached}
+
+    positions = _compute_planetary_positions(jd, lat, lon, elevation, geocentric)
+
+    result = {
+        "input": {"datetime": datetime, "lat": lat, "lon": lon, "geocentric": geocentric},
+        "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
+        "julian_day": round(jd, 6),
+        "bodies": positions,
+    }
+    cache.set(
+        ck,
+        {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], "bodies": positions},
+        config.CACHE_PLANETARY,
+    )
+    return result
+
+
+@mcp.tool()
+def get_solar_events(
+    date: str,
+    lat: float | None = None,
+    lon: float | None = None,
+) -> dict:
+    """
+    Get solar events (sunrise, sunset, solar noon, twilight) for a date and location.
+
+    Args:
+        date: ISO date string (YYYY-MM-DD).
+        lat: Observer latitude in decimal degrees.
+        lon: Observer longitude in decimal degrees.
+
+    Returns:
+        Object with all event times as ISO datetime strings and day length.
+    """
+    lat, lon = _default_location(lat, lon)
+    jd = _parse_date(date)
+
+    ck = cache_key("get_solar_events", date=date, lat=lat, lon=lon)
+    cached = cache.get(ck)
+    if cached:
+        return {"input": {"date": date, "lat": lat, "lon": lon}, **cached}
+
+    events = _compute_solar_events(jd, lat, lon)
+
+    result = {
+        "input": {"date": date, "lat": lat, "lon": lon},
+        "date": date,
+        "julian_day_base": round(jd, 6),
+        "events_jd": events,
+    }
+    cache.set(
+        ck,
+        {"date": date, "events_jd": events, "julian_day_base": round(jd, 6)},
+        config.CACHE_SOLAR,
+    )
+    return result
+
+
+@mcp.tool()
+def get_lunar_state(
+    datetime: str | None = None,
+    lat: float | None = None,
+    lon: float | None = None,
+) -> dict:
+    """
+    Get current lunar phase and position.
+
+    Args:
+        datetime: ISO 8601 datetime (UTC). Defaults to now.
+        lat: Observer latitude in decimal degrees.
+        lon: Observer longitude in decimal degrees.
+
+    Returns:
+        Object with phase name, illumination fraction, age, and position.
+    """
+    lat, lon = _default_location(lat, lon)
+    jd = _parse_datetime(datetime)
+
+    ck = cache_key("get_lunar_state", datetime=datetime or "now", lat=lat, lon=lon)
+    cached = cache.get(ck)
+    cached_next_major_phase = cached.get("lunar_state", {}).get("next_major_phase") if cached else None
+    if cached_next_major_phase and cached_next_major_phase.get("phase_name") and cached_next_major_phase.get("at_utc") and cached_next_major_phase.get("in_text"):
+        return {"input": {"datetime": datetime, "lat": lat, "lon": lon}, **cached}
+    if cached:
+        cache.delete(ck)
+
+    state = _compute_lunar_state(jd, lat, lon)
+
+    result = {
+        "input": {"datetime": datetime, "lat": lat, "lon": lon},
+        "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
+        "julian_day": round(jd, 6),
+        "lunar_state": state,
+    }
+    cache.set(
+        ck,
+        {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], "lunar_state": state},
+        config.CACHE_LUNAR,
+    )
+    return result
+
+
+@mcp.tool()
+def get_sidereal_time(
+    datetime: str | None = None,
+    lat: float | None = None,
+    lon: float | None = None,
+) -> dict:
+    """
+    Get sidereal time and obliquity of the ecliptic.
+
+    Args:
+        datetime: ISO 8601 datetime (UTC). Defaults to now.
+        lat: Observer latitude.
+        lon: Observer longitude.
+
+    Returns:
+        Object with Greenwich and local sidereal time, and obliquity.
+    """
+    lat, lon = _default_location(lat, lon)
+    jd = _parse_datetime(datetime)
+
+    ck = cache_key("get_sidereal_time", datetime=datetime or "now", lat=lat, lon=lon)
+    cached = cache.get(ck)
+    if cached:
+        return {"input": {"datetime": datetime, "lat": lat, "lon": lon}, **cached}
+
+    result_st = _compute_sidereal_time(jd, lat, lon)
+
+    result = {
+        "input": {"datetime": datetime, "lat": lat, "lon": lon},
+        "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
+        "julian_day": round(jd, 6),
+        **result_st,
+    }
+    cache.set(
+        ck,
+        {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], **result_st},
+        config.CACHE_SIDEREAL,
+    )
+    return result
+
+
+@mcp.tool()
+def get_constellation_at_ecliptic(ecliptic_lon: float) -> dict:
+    """
+    Look up IAU constellation at a given ecliptic longitude.
+
+    Args:
+        ecliptic_lon: Ecliptic longitude in degrees (0-360).
+
+    Returns:
+        Object with constellation abbreviation, full name, and position within.
+    """
+    result = _compute_constellation_at_ecliptic(ecliptic_lon)
+    return {"input": {"ecliptic_lon": ecliptic_lon}, **result}
+
+
+@mcp.tool()
+def list_available_bodies(category: str | None = None) -> dict:
+    """
+    List all computable celestial bodies.
+
+    This is a lightweight discovery tool for the v0.1 core slice.
+    """
+    bodies = []
+    for name in BODIES:
+        bodies.append(
+            {
+                "name": name,
+                "full_name": name.replace("_", " ").title(),
+                "available": True,
+            }
+        )
+
+    if category:
+        category_lower = category.lower()
+        if category_lower == "planets":
+            bodies = [b for b in bodies if b["name"] in {"sun", "mercury", "venus", "mars", "jupiter", "saturn", "uranus", "neptune", "pluto"}]
+        elif category_lower == "lunar":
+            bodies = [b for b in bodies if b["name"] == "moon"]
+        elif category_lower == "major":
+            bodies = [b for b in bodies if b["name"] in {"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"}]
+
+    return {"bodies": bodies}
+
+
+@mcp.tool()
+def get_moon_phase(datetime: str | None = None, lat: float | None = None, lon: float | None = None) -> dict:
+    """
+    Convenience lunar alias for mcporter users.
+
+    Phase naming is driven by lunar age, while illumination is reported as
+    a separate descriptive value.
+    """
+    lat, lon = _default_location(lat, lon)
+    state = get_lunar_state(datetime=datetime, lat=lat, lon=lon)
+    return {
+        "input": {"datetime": datetime, "lat": lat, "lon": lon},
+        "phase_name": state["lunar_state"]["phase_name"],
+        "illumination_fraction": state["lunar_state"]["illumination_fraction"],
+        "age_days": state["lunar_state"]["age_days"],
+        "next_major_phase": state["lunar_state"]["next_major_phase"],
+        "ecliptic_lon": state["lunar_state"]["ecliptic_lon"],
+        "ecliptic_lat": state["lunar_state"]["ecliptic_lat"],
+        "distance_km": state["lunar_state"]["distance_km"],
+    }
+
+
+def create_app() -> FastAPI:
+    """
+    Build the FastAPI app for the v0.1 core slice.
+    """
+    logging.basicConfig(
+        filename=str(config.LOG_DIR / "server.log"),
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+    )
+
+    ephemeris_init()
+    app = FastAPI(title="ephemeris-mcp")
+    app.mount("/mcp", mcp.sse_app())
+
+    @app.get("/health")
+    def health() -> dict:
+        return {"ok": True, "server": "ephemeris-mcp", "port": config.PORT}
+
+    @app.get("/")
+    def root() -> dict:
+        return {
+            "server": "ephemeris-mcp",
+            "status": "ready",
+            "tools": _tool_names(),
+            "mcp": {
+                "sse": "/mcp/sse",
+                "messages": "/mcp/messages",
+            },
+        }
+
+    return app

+ 146 - 0
src/ephemeris_mcp/storage.py

@@ -0,0 +1,146 @@
+"""
+SQLite cache layer for ephemeris-mcp.
+
+Provides TTL-based caching to avoid redundant computations.
+Ephemeris data files themselves are ~50-100 MB and loaded by swisseph directly.
+This cache stores computed RESULTS (positions, events, etc.).
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+import time
+from pathlib import Path
+from typing import Optional
+
+from .config import DB_PATH
+
+
+class EphemerisCache:
+    """Thread-safe TTL cache backed by SQLite."""
+
+    def __init__(self, db_path: Path = DB_PATH):
+        self.db_path = db_path
+        self.db_path.parent.mkdir(parents=True, exist_ok=True)
+        self._local = __import__("threading").local()
+        self._init_db()
+
+    def _get_conn(self) -> sqlite3.Connection:
+        if not hasattr(self._local, "conn") or self._local.conn is None:
+            self._local.conn = sqlite3.connect(str(self.db_path))
+            self._local.conn.execute("PRAGMA journal_mode=WAL")
+            self._local.conn.execute("PRAGMA busy_timeout=5000")
+        return self._local.conn
+
+    def _init_db(self) -> None:
+        conn = self._get_conn()
+        conn.executescript("""
+            CREATE TABLE IF NOT EXISTS cache (
+                key          TEXT PRIMARY KEY,
+                value        TEXT NOT NULL,
+                expires_at   REAL NOT NULL
+            );
+            CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
+
+            CREATE TABLE IF NOT EXISTS tle_data (
+                norad_id     INTEGER PRIMARY KEY,
+                name         TEXT,
+                line1        TEXT NOT NULL,
+                line2        TEXT NOT NULL,
+                last_fetched REAL NOT NULL
+            );
+        """)
+        conn.commit()
+
+    def get(self, key: str) -> Optional[dict]:
+        """Retrieve a cached value. Returns None if expired or missing."""
+        conn = self._get_conn()
+        row = conn.execute(
+            "SELECT value, expires_at FROM cache WHERE key = ?", (key,)
+        ).fetchone()
+        if row is None:
+            return None
+        if row[1] < time.time():
+            conn.execute("DELETE FROM cache WHERE key = ?", (key,))
+            conn.commit()
+            return None
+        return __import__("json").loads(row[0])
+
+    def set(self, key: str, value: dict, ttl: float) -> None:
+        """Store a value with TTL in seconds."""
+        conn = self._get_conn()
+        self.prune()
+        conn.execute(
+            "INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
+            (key, json.dumps(value), time.time() + ttl),
+        )
+        conn.commit()
+
+    def delete(self, key: str) -> None:
+        """Remove a single cache entry."""
+        conn = self._get_conn()
+        conn.execute("DELETE FROM cache WHERE key = ?", (key,))
+        conn.commit()
+
+    def prune(self) -> int:
+        """Remove all expired entries. Returns count of deleted rows."""
+        conn = self._get_conn()
+        deleted = conn.execute(
+            "DELETE FROM cache WHERE expires_at < ?", (time.time(),)
+        ).rowcount
+        conn.commit()
+        return deleted
+
+    # --- TLE-specific methods ---
+
+    def get_tle(self, norad_id: int) -> Optional[tuple[str, str]]:
+        """Retrieve cached TLE by NORAD ID."""
+        conn = self._get_conn()
+        row = conn.execute(
+            "SELECT line1, line2 FROM tle_data WHERE norad_id = ?", (norad_id,)
+        ).fetchone()
+        if row is None:
+            return None
+        return (row[0], row[1])
+
+    def set_tle(self, norad_id: int, name: str, line1: str, line2: str) -> None:
+        """Cache a TLE entry."""
+        conn = self._get_conn()
+        conn.execute(
+            "INSERT OR REPLACE INTO tle_data (norad_id, name, line1, line2, last_fetched) "
+            "VALUES (?, ?, ?, ?, ?)",
+            (norad_id, name, line1, line2, time.time()),
+        )
+        conn.commit()
+
+    def get_stale_tles(self, max_age_seconds: float = 3600) -> list[int]:
+        """Get NORAD IDs of TLE entries older than max_age_seconds."""
+        conn = self._get_conn()
+        cutoff = time.time() - max_age_seconds
+        rows = conn.execute(
+            "SELECT norad_id FROM tle_data WHERE last_fetched < ?", (cutoff,)
+        ).fetchall()
+        return [r[0] for r in rows]
+
+
+# Module-level singleton
+_cache: Optional[EphemerisCache] = None
+
+
+def get_cache() -> EphemerisCache:
+    global _cache
+    if _cache is None:
+        _cache = EphemerisCache()
+    return _cache
+
+
+def cache_key(tool: str, **kwargs) -> str:
+    """Generate a deterministic cache key from tool name and params."""
+    parts = [tool]
+    for k in sorted(kwargs):
+        v = kwargs[k]
+        if isinstance(v, float):
+            v = f"{v:.6f}"
+        parts.append(f"{k}={v}")
+    return "|".join(parts)

+ 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

+ 111 - 0
tests/test_basic.py

@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+from src.ephemeris_mcp.server import (
+    _parse_datetime,
+    create_app,
+    get_constellation_at_ecliptic,
+    get_moon_phase,
+    get_lunar_state,
+    get_planetary_positions,
+    get_sidereal_time,
+    get_solar_events,
+    list_available_bodies,
+)
+from src.ephemeris_mcp.storage import EphemerisCache, cache_key
+
+
+def test_parse_datetime_accepts_iso_zulu() -> None:
+    jd = _parse_datetime("2026-05-10T12:00:00Z")
+    assert isinstance(jd, float)
+    assert 2461170 < jd < 2461172
+
+
+def test_cache_roundtrip(tmp_path: Path) -> None:
+    cache = EphemerisCache(tmp_path / "cache.sqlite3")
+    key = cache_key("demo", lon=8.5, lat=47.3)
+    assert cache.get(key) is None
+    cache.set(key, {"ok": True, "value": 42}, ttl=60)
+    assert cache.get(key) == {"ok": True, "value": 42}
+
+
+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": "ephemeris-mcp", "port": 7015}
+
+
+def test_root_lists_core_tools() -> None:
+    client = TestClient(create_app())
+    res = client.get("/")
+    assert res.status_code == 200
+    data = res.json()
+    assert data["server"] == "ephemeris-mcp"
+    assert data["status"] == "ready"
+    assert data["tools"] == [
+        "get_planetary_positions",
+        "get_solar_events",
+        "get_lunar_state",
+        "get_moon_phase",
+        "get_sidereal_time",
+        "get_constellation_at_ecliptic",
+        "list_available_bodies",
+    ]
+    assert data["mcp"] == {"sse": "/mcp/sse", "messages": "/mcp/messages"}
+
+
+def test_list_available_bodies_shape() -> None:
+    result = list_available_bodies()
+    assert "bodies" in result
+    names = {body["name"] for body in result["bodies"]}
+    assert "sun" in names
+    assert "moon" in names
+    assert "mars" in names
+
+
+def test_tool_shapes_are_present() -> None:
+    body_positions = get_planetary_positions(datetime="2026-05-10T12:00:00Z")
+    solar_events = get_solar_events(date="2026-05-10", lat=47.0, lon=8.0)
+    lunar_state = get_lunar_state(datetime="2026-05-10T12:00:00Z")
+    sidereal_time = get_sidereal_time(datetime="2026-05-10T12:00:00Z")
+    constellation = get_constellation_at_ecliptic(120.0)
+
+    assert body_positions["input"]["datetime"] == "2026-05-10T12:00:00Z"
+    assert "bodies" in body_positions
+    assert solar_events["input"]["date"] == "2026-05-10"
+    assert "events_jd" in solar_events
+    assert "lunar_state" in lunar_state
+    assert "greenwich_sidereal_time" in sidereal_time
+    assert constellation["input"]["ecliptic_lon"] == 120.0
+
+
+def test_moon_phase_tool_shape() -> None:
+    moon_phase = get_moon_phase(datetime="2026-05-10T12:00:00Z")
+    assert moon_phase["phase_name"]
+    assert 0.0 <= moon_phase["illumination_fraction"] <= 1.0
+    assert moon_phase["input"]["datetime"] == "2026-05-10T12:00:00Z"
+    assert moon_phase["phase_name"] == "Last Quarter"
+    assert moon_phase["next_major_phase"]["phase_name"] in {"New Moon", "First Quarter", "Full Moon", "Last Quarter"}
+    assert "at_utc" in moon_phase["next_major_phase"]
+    assert moon_phase["next_major_phase"]["in_text"]
+    assert "d" in moon_phase["next_major_phase"]["in_text"] or "h" in moon_phase["next_major_phase"]["in_text"] or "m" in moon_phase["next_major_phase"]["in_text"]
+
+
+def test_default_location_can_be_configured(monkeypatch) -> None:
+    monkeypatch.setenv("EPHEMERIS_DEFAULT_LAT", "48.2")
+    monkeypatch.setenv("EPHEMERIS_DEFAULT_LON", "16.37")
+
+    from importlib import reload
+    import src.ephemeris_mcp.config as config
+    import src.ephemeris_mcp.server as server
+    reload(config)
+    server = reload(server)
+
+    result = server.get_moon_phase(datetime="2026-05-10T12:00:00Z")
+    assert result["input"]["lat"] == 48.2
+    assert result["input"]["lon"] == 16.37