Based on: WISHLIST_ANALYSIS.md Strategy: Expand existing tools rather than creating new ones where possible. Each phase is independently testable. Stop after each phase for review.
File: src/astro_mcp/tools.py, calculate_natal_chart function
The natal chart tool builds planet lines without speed_lon, then calls compute_aspects on bodies that also lack speed_lon. This means applying is always None for natal aspects.
Change: When building the aspect_bodies list in calculate_natal_chart, include speed_lon from the raw ephemeris data. This requires keeping a lookup of speed_lon by body name from the raw bodies.
# Before (line ~170):
aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in planets]
# After:
speed_lookup = {b["body"]: b.get("speed_lon") for b in raw_bodies}
aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"], "speed_lon": speed_lookup.get(p["body"])} for p in planets]
Tests: Add a test in test_tools.py that verifies applying is not None for natal chart aspects when planets have different speeds.
Effort: ~5 lines changed.
File: src/astro_mcp/astrology.py (new functions)
Add these pure functions that operate on the planet list returned by calculate_natal_chart:
get_element_balance(planets) -> dictCount planets by element. Each sign maps to an element:
Returns: {"fire": N, "earth": N, "air": N, "water": N, "percentages": {...}}
get_modality_balance(planets) -> dictCount planets by modality:
Returns: {"cardinal": N, "fixed": N, "mutable": N, "percentages": {...}}
get_hemisphere_emphasis(planets) -> dictCount planets by house ranges:
Returns: {"upper": N, "lower": N, "east": N, "west": N}
detect_stelliums(planets) -> listScan for 3+ planets in the same sign OR same house.
Returns: [{"type": "sign"|"house", "key": str, "planets": [str, ...]}, ...]
get_empty_houses(planets) -> list[int]Return house numbers (1-12) with no planets.
get_chart_ruler(ascendant_sign, planets) -> dictSign-to-ruler mapping:
Return the ruling planet's full data from the planets list.
get_house_rulers(houses, planets) -> listFor each house cusp sign, find the ruling planet and its condition.
group_planets_by_house(planets) -> dictReturns: {house_number: [planet_names]}
group_planets_by_sign(planets) -> dictReturns: {sign_name: [planet_names]}
get_house_type_counts(planets) -> dictAngular (1,4,7,10), Succedent (2,5,8,11), Cadent (3,6,9,12).
get_retrograde_planets(planets) -> listFilter planets where retrograde=True, return with sign + house.
get_nodal_axis(planets, houses) -> dictExtract true_node from planets, compute South Node (opposite point).
get_saturn_info(planets) -> dictExtract Saturn from planets list with sign, house, retrograde.
get_part_of_fortune(ascendant_lon, sun_lon, moon_lon) -> dictFormula: normalize_degrees(ascendant_lon + moon_lon - sun_lon). Return sign + degree.
get_pluto_polarity_point(pluto_lon) -> dictFormula: normalize_degrees(pluto_lon + 180). Return sign + degree.
Tests: Add all functions to test_astrology.py with known inputs.
Effort: ~200 lines in astrology.py, ~150 lines in test_astrology.py.
File: src/astro_mcp/astrology.py
detect_aspect_patterns(planets, aspects) -> listScan the aspect list for known patterns:
T-square: Find an opposition (A-B). Check if any planet C forms a square to both A and B. Apex = C.
Grand Trine: Find 3 planets where each pair is in trine (3 trines total). All 3 must be in the same element.
Grand Cross: Find 4 planets forming 2 oppositions and 4 squares (each planet opposes one and squares two others).
Yod: Find 2 planets in sextile. Check if any planet C forms a quincunx (150°) to both. Note: quincunx is not in the current aspect definitions -- add it first.
Add quincunx to ASPECT_DEFINITIONS:
{"name": "quincunx", "angle": 150.0, "default_orb": 3.0, "symbol": "Qx"}
Returns: [{"type": "T-square"|"Grand Trine"|"Grand Cross"|"Yod", "planets": [...], "apex": str|null, "modality": str|null, "element": str|null}]
Tests: Create synthetic planet configurations that form each pattern type and verify detection.
Effort: ~150 lines in astrology.py, ~100 lines in tests.
File: src/astro_mcp/astrology.py
detect_chart_shape(planets) -> dictAnalyze the angular distribution of planets to classify the chart shape:
Returns: {"shape": str, "largest_gap": float, "gap_start": float, "gap_end": float}
Tests: Create synthetic planet configurations for each shape.
Effort: ~80 lines in astrology.py, ~80 lines in tests.
File: src/astro_mcp/tools.py
include_overview parameter to calculate_natal_chartasync def calculate_natal_chart(
...existing params...,
include_overview: bool = False,
include_patterns: bool = False,
include_karmic: bool = False,
top_n_aspects: int | None = None,
) -> dict[str, Any]:
When include_overview=True, add an "overview" key to the output containing:
lunar_state)When include_patterns=True, add an "aspect_patterns" key with T-square, Grand Trine, Grand Cross, Yod, and chart_shape.
When include_karmic=True, add a "karmic" key with:
When top_n_aspects is set, limit the aspects list to the N tightest.
Tests: Add tests in test_tools.py verifying each flag produces the expected output sections.
Effort: ~100 lines in tools.py, ~150 lines in tests.
File: src/astro_mcp/tools.py
calculate_synastry_chartasync def calculate_synastry_chart(
...existing params...,
top_n_aspects: int | None = None,
karmic_filter: bool = False,
significator_filter: bool = False,
) -> dict[str, Any]:
top_n_aspects: Limit interaspects to top N by orbkarmic_filter: Only return interaspects involving Saturn, Pluto, or true_nodesignificator_filter: Only return interaspects involving Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn pairsAdd a "summary" key to the output containing:
top_aspects: Top 10 interaspects by orbsaturn_contacts: All Saturn interchart aspectsnode_contacts: All Node interchart aspectsvenus_mars_contacts: All Venus-Mars interchart aspectssun_moon_contacts: All Sun-Moon interchart aspectsTests: Add tests verifying filters and summary sections.
Effort: ~60 lines in tools.py, ~100 lines in tests.
File: src/astro_mcp/tools.py
calculate_davison_chart@mcp.tool()
async def calculate_davison_chart(
person1_datetime: str,
person1_latitude: float,
person1_longitude: float,
person2_datetime: str,
person2_latitude: float,
person2_longitude: float,
elevation: float = 0.0,
house_system: str = "placidus",
orb_limits: dict[str, float] | None = None,
) -> dict[str, Any]:
Algorithm:
compute_davison_chart)call_sky_state at the Davison midpoint datetime + locationcalculate_natal_chart)calculate_davison_chart_by_idSame as above but accepts person1_id and person2_id from the database.
calculate_synastry_chartChange the Davison section from returning just midpoint coordinates to returning the full chart. Or alternatively, keep the midpoint data and add a "davison_chart_full" key.
Recommendation: Keep the existing midpoint data (for backward compatibility) and add a include_davison_full: bool = False parameter. When True, compute and include the full Davison chart.
Tests: Add tests for Davison chart calculation with known birth data.
Effort: ~150 lines in tools.py, ~100 lines in tests.
File: src/astro_mcp/tools.py
get_composite_transit_preview@mcp.tool()
async def get_composite_transit_preview(
person1_datetime: str,
person1_latitude: float,
person1_longitude: float,
person2_datetime: str,
person2_latitude: float,
person2_longitude: float,
start_date: str,
end_date: str,
min_significance: float = 0.0,
) -> dict[str, Any]:
Algorithm:
calculate_composite_chart)get_transit_preview)get_davison_transit_previewSame pattern but using Davison chart positions as the "natal" targets.
_byId variantsAdd get_composite_transit_preview_by_id and get_davison_transit_preview_by_id.
Tests: Add tests for composite and Davison transit previews.
Effort: ~150 lines in tools.py, ~100 lines in tests.
File: src/astro_mcp/tools.py
_tool_names()Add all new tools to the _tool_names() function in server.py:
calculate_davison_chartcalculate_davison_chart_by_idget_composite_transit_previewget_composite_transit_preview_by_idget_davison_transit_previewget_davison_transit_preview_by_idUpdate test_root_lists_tools in test_server.py to include the new tool names.
File: src/astro_mcp/tools.py
get_karmic_relationship_summary@mcp.tool()
async def get_karmic_relationship_summary(
person1_id: str,
person2_id: str,
house_system: str = "placidus",
) -> dict[str, Any]:
Algorithm:
Effort: ~100 lines in tools.py, ~50 lines in tests.
These are lower priority and can be done after the above phases are complete:
| Phase | Files Changed | New Lines (approx) | New Tools |
|---|---|---|---|
| 0: Bug fix | tools.py | ~5 | 0 |
| 1: Post-processing | astrology.py, test_astrology.py | ~350 | 0 |
| 2: Aspect patterns | astrology.py, test_astrology.py | ~250 | 0 |
| 3: Chart shape | astrology.py, test_astrology.py | ~160 | 0 |
| 4: Natal overview | tools.py, test_tools.py | ~250 | 0 (params) |
| 5: Synastry filters | tools.py, test_tools.py | ~160 | 0 (params) |
| 6: Davison chart | tools.py, test_tools.py, server.py | ~250 | 2 |
| 7: Composite/Davison transits | tools.py, test_tools.py, server.py | ~250 | 4 |
| 8: Return types | server.py, test_server.py | ~20 | 0 |
| 9: Karmic synthesis | tools.py, test_tools.py | ~150 | 1 |
| Total | ~1,845 | 7 |
test_astrology.pytest_tools.py (with mocked ephemeris)pytest tests/ after each phaseAfter all phases:
pytest tests/ passes with no failuresGET / output