""" 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")] } @app.function(image=image) 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"} @app.function(image=image) 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"} @app.function(image=image) 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 } @app.function(image=image) 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}, ] @app.function(image=image) 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 } @app.function(image=image) 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 # ============================================================================ @app.function(image=image) 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 # ============================================================================ @app.local_entrypoint() 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!")