Date: 2026-06-07 Status: Design discussion — decide before coding
Should chart rendering be inside existing MCP tools (via include_svg param),
or should it be separate dedicated render tools?
include_svg on Existing Tools@mcp.tool()
async def calculate_natal_chart(
birth_datetime: str,
latitude: float,
longitude: float,
# ... existing params ...
include_svg: bool = False,
svg_style: str = "modern", # "modern" | "traditional" | "minimal"
svg_color: str = "color", # "color" | "bw"
svg_size: int = 600, # pixel width
) -> dict[str, Any]:
...
result = { ...planets, houses, aspects... }
if include_svg:
result["chart_svg"] = render_natal_wheel(result, style=svg_style, ...)
return result
Same pattern for calculate_transit_chart, calculate_synastry_chart,
calculate_composite_chart, calculate_davison_chart, and all _byId variants.
include_svg=false)Not recommended for the main path. The response bloat alone is a problem —
an LLM calling calculate_natal_chart for data processing gets 50KB of SVG
XML forced into its context window every time.
@mcp.tool()
async def render_natal_chart(
chart_data: dict,
style: str = "modern",
color_mode: str = "color",
size: int = 600,
format: str = "svg", # "svg" | "pdf" | "png"
) -> dict[str, Any]:
"""Render a natal chart wheel from chart calculation data.
Takes the output of calculate_natal_chart (or any chart tool) and
renders it as a visual chart wheel. The chart_data parameter accepts
the full dict returned by any chart calculation tool.
"""
...
return {
"svg": "<svg>...</svg>", # or base64 png, or pdf path
"format": "svg",
"width": size,
"height": size,
}
Plus convenience variants:
@mcp.tool()
async def render_natal_chart_by_id(
person_id: str,
house_system: str = "placidus",
style: str = "modern",
color_mode: str = "color",
size: int = 600,
include_table: bool = True, # include aspect table below wheel
include_planet_list: bool = True,
) -> dict[str, Any]:
"""Render natal chart for a person from the database.
Combines calculate_natal_chart + render_natal_chart in one call.
Fetches birth data, calculates positions, and renders the wheel.
"""
data = calculate_natal_chart(...) → gets lean JSONsvg = render_natal_chart(data) → gets SVG only when neededsvg = render_natal_chart_by_id("lucky") → one-shotrender_natal_chart_by_id duplicates the param lists of the data toolsRecommended. Clean architecture, flexible, doesn't bloat the data tools.
Keep data tools pure. Add a separate render tool that accepts either chart data OR a person_id:
@mcp.tool()
async def render_chart(
# One of these two is required:
chart_data: dict | None = None,
person_id: str | None = None,
# Rendering options:
chart_type: str = "natal", # "natal" | "transit" | "synastry" | ...
style: str = "modern",
color_mode: str = "color",
format: str = "svg",
size: int = 600,
transit_date: str | None = None, # for transit charts
person2_id: str | None = None, # for synastry
) -> dict[str, Any]:
"""Universal chart renderer.
Pass chart_data from any chart tool output, OR pass person_id to
auto-calculate + render in one step.
"""
Nice in theory, messy in practice. Go with Option B.
Core data tools (unchanged, stay lean):
calculate_natal_chart — pure datacalculate_transit_chart — pure datacalculate_synastry_chart — pure datacalculate_composite_chart — pure datacalculate_davison_chart — pure data_byId variants — pure dataNew render tools:
| Tool | Purpose |
|---|---|
render_natal_chart |
Render natal wheel from chart_data |
render_transit_chart |
Render bi-wheel from transit chart_data |
render_synastry_chart |
Render dual wheel from synastry chart_data |
render_composite_chart |
Render composite wheel from chart_data |
render_davison_chart |
Render Davison wheel from chart_data |
render_natal_chart_by_id |
One-shot: fetch DB + calc + render natal |
render_transit_chart_by_id |
One-shot: fetch DB + calc + render transit |
render_synastry_chart_by_id |
One-shot: fetch DB both + calc + render synastry |
style: str = "modern" # "modern" | "traditional" | "minimal"
color_mode: str = "color" # "color" | "bw"
size: int = 600 # SVG viewBox width in pixels (square)
format: str = "svg" # "svg" | "pdf" | "png"
include_table: bool = false # Aspect table below wheel
include_planets: bool = false # Planet data table
include_houses: bool = false # House cusp table
title: str | None = None # Custom title (default: auto-generated)
font_family: str = "astronomicon" # "astronomicon" | "unicode"
{
"svg": "<svg xmlns=...>...</svg>", # SVG string (when format="svg")
# OR
"pdf_b64": "JVBERi0xLjQK...", # base64 PDF (when format="pdf")
# OR
"png_b64": "iVBORw0KGgo...", # base64 PNG (when format="png")
"format": "svg",
"width": 600,
"height": 600,
"style": "modern",
"color_mode": "color",
"included": ["wheel", "table", "planets"],
"svg_size_bytes": 28341,
}
Use case 1: Agent wants to show a chart in chat
# One call — agent gives SVG to user
svg_result = render_natal_chart_by_id("lucky", style="modern")
# Agent outputs: chart_svg string → user sees the wheel
Use case 2: Agent wants data analysis + chart
# Step 1: Get data (lean, fast)
data = calculate_natal_chart_by_id("lucky", include_overview=true)
# Agent analyzes: "Sun in Leo, Moon in Cancer..."
# Step 2: Get chart only if needed
svg = render_natal_chart(data, style="traditional", color_mode="bw")
# Agent attaches SVG to response
Use case 3: Dashboard/web display
GET /dashboard/charts/person/{id}/natal?style=modern&color=width=800
→ Calls render_natal_chart internally → returns SVG inline in HTML
Use case 4: Print PDF
pdf = render_natal_chart_by_id("lucky", format="pdf", style="traditional",
include_table=true, include_houses=true)
# pdf["pdf_b64"] → decode → write to file → send to printer
svgwrite for SVG generation@font-face + unicode fallbackrender_natal_chart + render_natal_chart_by_id/dashboard/charts/ routesRender tool count: 8 render tools (5 chart types + 3 byId) seems like a lot.
Could collapse to just render_chart(chart_data, chart_type) +
render_chart_by_id(person_id, chart_type) with a chart_type enum.
→ Lean toward 2 universal tools to keep the surface small.
Chart data validation: What if the agent passes malformed chart_data?
→ Use a Pydantic model to validate the expected structure, return
clear error messages.
SVG size limits: A full chart SVG can be 30-60KB. MCP tool responses
should handle this fine, but some LLM context windows may not appreciate it.
→ Consider a compact mode that strips comments/whitespace from SVG.
Transit chart render: Needs two sets of planet positions + aspect lines. Bi-wheel layout (inner natal + outer transit) or side-by-side? → Bi-wheel is the standard, but side-by-side is clearer for >15 aspects. Support both, default to bi-wheel.
Synastry chart render: Two natal wheels + interaspect lines? → Side-by-side wheels with a middle column of key interaspects. Bi-wheel (person A inner, person B outer) for the visual.
Caching: SVG is deterministic for (chart_data_hash + style_options). → Cache in memory with LRU eviction. Key: sha256(chart_data + options).
Person info on chart: Name, birth date, location in the title block? → Extracted from chart_data["input"] if present. Respect person.private flag.