hermes-astrology / mcp_server.py
aamanlamba's picture
Upload folder using huggingface_hub
1c93f85 verified
"""
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!")