Spaces:
Running
Running
| """ | |
| HERMES MCP Server - Hellenistic Astrology Tools | |
| Deploys on Modal for serverless execution | |
| """ | |
| import modal | |
| from typing import Dict, List, Optional | |
| from datetime import datetime | |
| import json | |
| # Modal app definition | |
| app = modal.App("hermes-astrology-mcp") | |
| # Define the image with required dependencies | |
| image = modal.Image.debian_slim(python_version="3.11").pip_install( | |
| "pyswisseph>=2.10.0", | |
| "pandas>=2.0.0", | |
| "numpy>=1.24.0", | |
| ) | |
| # ============================================================================ | |
| # ESSENTIAL DIGNITY CALCULATIONS | |
| # ============================================================================ | |
| EGYPTIAN_BOUNDS = { | |
| "Aries": [ | |
| (0, 6, "Jupiter"), (6, 12, "Venus"), (12, 20, "Mercury"), | |
| (20, 25, "Mars"), (25, 30, "Saturn") | |
| ], | |
| "Taurus": [ | |
| (0, 8, "Venus"), (8, 14, "Mercury"), (14, 22, "Jupiter"), | |
| (22, 27, "Saturn"), (27, 30, "Mars") | |
| ], | |
| "Gemini": [ | |
| (0, 6, "Mercury"), (6, 12, "Jupiter"), (12, 17, "Venus"), | |
| (17, 24, "Mars"), (24, 30, "Saturn") | |
| ], | |
| "Cancer": [ | |
| (0, 7, "Mars"), (7, 13, "Venus"), (13, 19, "Mercury"), | |
| (19, 26, "Jupiter"), (26, 30, "Saturn") | |
| ], | |
| "Leo": [ | |
| (0, 6, "Jupiter"), (6, 11, "Venus"), (11, 18, "Saturn"), | |
| (18, 24, "Mercury"), (24, 30, "Mars") | |
| ], | |
| "Virgo": [ | |
| (0, 7, "Mercury"), (7, 17, "Venus"), (17, 21, "Jupiter"), | |
| (21, 28, "Mars"), (28, 30, "Saturn") | |
| ], | |
| "Libra": [ | |
| (0, 6, "Saturn"), (6, 14, "Mercury"), (14, 21, "Jupiter"), | |
| (21, 28, "Venus"), (28, 30, "Mars") | |
| ], | |
| "Scorpio": [ | |
| (0, 7, "Mars"), (7, 11, "Venus"), (11, 19, "Mercury"), | |
| (19, 24, "Jupiter"), (24, 30, "Saturn") | |
| ], | |
| "Sagittarius": [ | |
| (0, 12, "Jupiter"), (12, 17, "Venus"), (17, 21, "Mercury"), | |
| (21, 26, "Saturn"), (26, 30, "Mars") | |
| ], | |
| "Capricorn": [ | |
| (0, 7, "Mercury"), (7, 14, "Jupiter"), (14, 22, "Venus"), | |
| (22, 26, "Saturn"), (26, 30, "Mars") | |
| ], | |
| "Aquarius": [ | |
| (0, 7, "Mercury"), (7, 13, "Venus"), (13, 20, "Jupiter"), | |
| (20, 25, "Mars"), (25, 30, "Saturn") | |
| ], | |
| "Pisces": [ | |
| (0, 12, "Venus"), (12, 16, "Jupiter"), (16, 19, "Mercury"), | |
| (19, 28, "Mars"), (28, 30, "Saturn") | |
| ] | |
| } | |
| DECANS = { | |
| "Aries": [(0, 10, "Mars"), (10, 20, "Sun"), (20, 30, "Venus")], | |
| "Taurus": [(0, 10, "Mercury"), (10, 20, "Moon"), (20, 30, "Saturn")], | |
| "Gemini": [(0, 10, "Jupiter"), (10, 20, "Mars"), (20, 30, "Sun")], | |
| "Cancer": [(0, 10, "Venus"), (10, 20, "Mercury"), (20, 30, "Moon")], | |
| "Leo": [(0, 10, "Saturn"), (10, 20, "Jupiter"), (20, 30, "Mars")], | |
| "Virgo": [(0, 10, "Sun"), (10, 20, "Venus"), (20, 30, "Mercury")], | |
| "Libra": [(0, 10, "Moon"), (10, 20, "Saturn"), (20, 30, "Jupiter")], | |
| "Scorpio": [(0, 10, "Mars"), (10, 20, "Sun"), (20, 30, "Venus")], | |
| "Sagittarius": [(0, 10, "Mercury"), (10, 20, "Moon"), (20, 30, "Saturn")], | |
| "Capricorn": [(0, 10, "Jupiter"), (10, 20, "Mars"), (20, 30, "Sun")], | |
| "Aquarius": [(0, 10, "Venus"), (10, 20, "Mercury"), (20, 30, "Moon")], | |
| "Pisces": [(0, 10, "Saturn"), (10, 20, "Jupiter"), (20, 30, "Mars")] | |
| } | |
| def get_bound_ruler(sign: str, degree: float) -> Dict: | |
| """Get the bound (term) ruler for a planet's position""" | |
| if sign not in EGYPTIAN_BOUNDS: | |
| return {"error": f"Invalid sign: {sign}"} | |
| bounds = EGYPTIAN_BOUNDS[sign] | |
| for start, end, ruler in bounds: | |
| if start <= degree < end: | |
| return { | |
| "sign": sign, | |
| "degree": degree, | |
| "bound_ruler": ruler, | |
| "bound_range": f"{start}-{end}°", | |
| "dignity_points": 2 | |
| } | |
| return {"error": "Degree out of range"} | |
| def get_decan_ruler(sign: str, degree: float) -> Dict: | |
| """Get the decan (face) ruler for a planet's position""" | |
| if sign not in DECANS: | |
| return {"error": f"Invalid sign: {sign}"} | |
| decans = DECANS[sign] | |
| for start, end, ruler in decans: | |
| if start <= degree < end: | |
| return { | |
| "sign": sign, | |
| "degree": degree, | |
| "decan_ruler": ruler, | |
| "decan_number": (start // 10) + 1, | |
| "decan_range": f"{start}-{end}°", | |
| "dignity_points": 1 | |
| } | |
| return {"error": "Degree out of range"} | |
| def calculate_full_dignities(planet: str, sign: str, degree: float, is_day_chart: bool) -> Dict: | |
| """ | |
| Complete essential dignity calculation | |
| Returns all dignity scores for a planet | |
| """ | |
| # This would import the dignity tables from app.py | |
| # For now, basic implementation | |
| result = { | |
| "planet": planet, | |
| "sign": sign, | |
| "degree": degree, | |
| "sect": "day" if is_day_chart else "night", | |
| "dignities": {}, | |
| "total_score": 0 | |
| } | |
| # Get bound and decan | |
| bound = get_bound_ruler.local(sign, degree) | |
| decan = get_decan_ruler.local(sign, degree) | |
| if "bound_ruler" in bound: | |
| result["dignities"]["bound"] = bound | |
| if bound["bound_ruler"] == planet: | |
| result["total_score"] += 2 | |
| if "decan_ruler" in decan: | |
| result["dignities"]["decan"] = decan | |
| if decan["decan_ruler"] == planet: | |
| result["total_score"] += 1 | |
| return result | |
| # ============================================================================ | |
| # ZODIACAL RELEASING CALCULATIONS | |
| # ============================================================================ | |
| ZODIACAL_RELEASING_ORDER = [ | |
| "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", | |
| "Capricorn", "Aquarius", "Pisces", "Aries", "Taurus", "Gemini" | |
| ] | |
| SIGN_YEARS = { | |
| "Cancer": 25, "Leo": 19, "Virgo": 20, "Libra": 8, "Scorpio": 15, | |
| "Sagittarius": 12, "Capricorn": 27, "Aquarius": 30, "Pisces": 12, | |
| "Aries": 15, "Taurus": 8, "Gemini": 20 | |
| } | |
| def calculate_zodiacal_releasing( | |
| starting_sign: str, | |
| birth_date: str, | |
| calculation_date: Optional[str] = None | |
| ) -> Dict: | |
| """ | |
| Calculate Zodiacal Releasing periods from a starting sign | |
| (Usually Lot of Fortune or Lot of Spirit) | |
| Based on Vettius Valens methodology | |
| """ | |
| if starting_sign not in ZODIACAL_RELEASING_ORDER: | |
| return {"error": f"Invalid starting sign: {starting_sign}"} | |
| birth = datetime.fromisoformat(birth_date) | |
| calc_date = datetime.fromisoformat(calculation_date) if calculation_date else datetime.now() | |
| # Calculate years since birth | |
| years_elapsed = (calc_date - birth).days / 365.25 | |
| # Start from the beginning sign | |
| current_index = ZODIACAL_RELEASING_ORDER.index(starting_sign) | |
| accumulated_years = 0 | |
| periods = [] | |
| # Calculate periods | |
| while accumulated_years < years_elapsed + 50: # Look ahead 50 years | |
| sign = ZODIACAL_RELEASING_ORDER[current_index % 12] | |
| period_years = SIGN_YEARS[sign] | |
| period_start = birth.replace(year=birth.year + int(accumulated_years)) | |
| period_end = birth.replace(year=birth.year + int(accumulated_years + period_years)) | |
| periods.append({ | |
| "sign": sign, | |
| "years": period_years, | |
| "start_date": period_start.isoformat(), | |
| "end_date": period_end.isoformat(), | |
| "is_current": accumulated_years <= years_elapsed < accumulated_years + period_years | |
| }) | |
| accumulated_years += period_years | |
| current_index += 1 | |
| return { | |
| "starting_sign": starting_sign, | |
| "birth_date": birth_date, | |
| "calculation_date": calc_date.isoformat(), | |
| "years_elapsed": round(years_elapsed, 2), | |
| "periods": periods[:20] # Return first 20 periods | |
| } | |
| # ============================================================================ | |
| # FIXED STARS DATABASE | |
| # ============================================================================ | |
| MAJOR_FIXED_STARS = [ | |
| {"name": "Regulus", "position_2000": "29° Leo 50'", "nature": "Mars-Jupiter", "magnitude": 1.35}, | |
| {"name": "Spica", "position_2000": "23° Libra 50'", "nature": "Venus-Mars", "magnitude": 0.98}, | |
| {"name": "Algol", "position_2000": "26° Taurus 10'", "nature": "Saturn-Jupiter", "magnitude": 2.12}, | |
| {"name": "Antares", "position_2000": "9° Sagittarius 46'", "nature": "Mars-Jupiter", "magnitude": 0.96}, | |
| {"name": "Aldebaran", "position_2000": "9° Gemini 47'", "nature": "Mars", "magnitude": 0.85}, | |
| {"name": "Sirius", "position_2000": "14° Cancer 05'", "nature": "Jupiter-Mars", "magnitude": -1.46}, | |
| ] | |
| def find_fixed_stars(degree: float, sign: str, orb: float = 1.0) -> List[Dict]: | |
| """ | |
| Find fixed stars within orb of a given position | |
| Traditional orb is 1° for conjunctions | |
| """ | |
| # Convert position to absolute longitude | |
| sign_order = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", | |
| "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] | |
| if sign not in sign_order: | |
| return [] | |
| position_longitude = (sign_order.index(sign) * 30) + degree | |
| nearby_stars = [] | |
| for star in MAJOR_FIXED_STARS: | |
| # Parse star position (simplified - real version would use ephemeris) | |
| # This is placeholder logic | |
| nearby_stars.append({ | |
| "star": star["name"], | |
| "nature": star["nature"], | |
| "magnitude": star["magnitude"], | |
| "orb": "Within calculation" | |
| }) | |
| return nearby_stars | |
| # ============================================================================ | |
| # FIRDARIA CALCULATIONS | |
| # ============================================================================ | |
| FIRDARIA_ORDER_DAY = ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars", "North Node", "South Node"] | |
| FIRDARIA_ORDER_NIGHT = ["Moon", "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "North Node", "South Node"] | |
| FIRDARIA_YEARS = { | |
| "Sun": 10, "Moon": 9, "Saturn": 11, "Jupiter": 12, | |
| "Mars": 7, "Venus": 8, "Mercury": 13, | |
| "North Node": 3, "South Node": 2 | |
| } | |
| def calculate_firdaria(birth_date: str, is_day_chart: bool, years_to_calculate: int = 75) -> Dict: | |
| """ | |
| Calculate Firdaria periods (Persian time-lord system) | |
| Different order for day vs night charts | |
| """ | |
| order = FIRDARIA_ORDER_DAY if is_day_chart else FIRDARIA_ORDER_NIGHT | |
| birth = datetime.fromisoformat(birth_date) | |
| periods = [] | |
| accumulated_years = 0 | |
| for planet in order: | |
| if accumulated_years >= years_to_calculate: | |
| break | |
| planet_years = FIRDARIA_YEARS[planet] | |
| period_start = birth.replace(year=birth.year + int(accumulated_years)) | |
| period_end = birth.replace(year=birth.year + int(accumulated_years + planet_years)) | |
| periods.append({ | |
| "planet": planet, | |
| "years": planet_years, | |
| "start_date": period_start.isoformat(), | |
| "end_date": period_end.isoformat(), | |
| "start_age": int(accumulated_years), | |
| "end_age": int(accumulated_years + planet_years) | |
| }) | |
| accumulated_years += planet_years | |
| return { | |
| "chart_type": "day" if is_day_chart else "night", | |
| "birth_date": birth_date, | |
| "periods": periods | |
| } | |
| # ============================================================================ | |
| # MCP TOOL ENDPOINTS | |
| # ============================================================================ | |
| def mcp_get_tools() -> Dict: | |
| """Return list of available MCP tools""" | |
| return { | |
| "tools": [ | |
| { | |
| "name": "calculate_full_dignities", | |
| "description": "Calculate complete essential dignities for a planet", | |
| "parameters": ["planet", "sign", "degree", "is_day_chart"] | |
| }, | |
| { | |
| "name": "get_bound_ruler", | |
| "description": "Get Egyptian bound (term) ruler for a position", | |
| "parameters": ["sign", "degree"] | |
| }, | |
| { | |
| "name": "get_decan_ruler", | |
| "description": "Get decan (face) ruler for a position", | |
| "parameters": ["sign", "degree"] | |
| }, | |
| { | |
| "name": "calculate_zodiacal_releasing", | |
| "description": "Calculate Zodiacal Releasing periods from Lot of Fortune or Spirit", | |
| "parameters": ["starting_sign", "birth_date", "calculation_date"] | |
| }, | |
| { | |
| "name": "calculate_firdaria", | |
| "description": "Calculate Firdaria (Persian time-lord) periods", | |
| "parameters": ["birth_date", "is_day_chart", "years_to_calculate"] | |
| }, | |
| { | |
| "name": "find_fixed_stars", | |
| "description": "Find fixed stars conjunct a position", | |
| "parameters": ["degree", "sign", "orb"] | |
| } | |
| ] | |
| } | |
| # ============================================================================ | |
| # LOCAL TESTING | |
| # ============================================================================ | |
| def main(): | |
| """Test MCP server functions locally""" | |
| print("Testing HERMES MCP Server...") | |
| # Test bound calculation | |
| print("\n1. Testing Bound Ruler:") | |
| result = get_bound_ruler.remote("Aries", 8.5) | |
| print(json.dumps(result, indent=2)) | |
| # Test zodiacal releasing | |
| print("\n2. Testing Zodiacal Releasing:") | |
| result = calculate_zodiacal_releasing.remote( | |
| starting_sign="Cancer", | |
| birth_date="1990-01-15T12:00:00" | |
| ) | |
| print(json.dumps(result, indent=2)) | |
| # Test firdaria | |
| print("\n3. Testing Firdaria:") | |
| result = calculate_firdaria.remote( | |
| birth_date="1990-01-15T12:00:00", | |
| is_day_chart=True, | |
| years_to_calculate=50 | |
| ) | |
| print(json.dumps(result, indent=2)) | |
| print("\n✅ MCP Server tests complete!") | |