chart-rendering-api-design.md 11 KB

Chart Rendering — API Design: MCP Tools vs Separate Renderer

Date: 2026-06-07 Status: Design discussion — decide before coding


The Core Question

Should chart rendering be inside existing MCP tools (via include_svg param), or should it be separate dedicated render tools?


Option A: include_svg on Existing Tools

How it works

@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.

Pros

  • One call gets everything — data + visual in one shot
  • Agent workflow is simpler: "calculate my chart" → gets SVG to display
  • No extra round-trips
  • Backward compatible (default include_svg=false)

Cons

  • Bloats the response — a full SVG is ~20-50KB of XML text. When you only want data (most API calls), you pay the cost anyway (even if just in description length for the LLM context).
  • Mixes concerns — the calculation tool now also renders. Makes the function harder to maintain.
  • Style params proliferate — every chart tool gets 3-4 extra SVG params. With 10 chart tools, that's 30-40 extra parameters to document.
  • Can't render without recalculating — if you already have the chart data (e.g. from a previous call or from the DB), you can't just "render it" without re-doing the ephemeris calls.
  • SVG in JSON is ugly — escaped XML inside JSON is painful to read/debug.

Verdict

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.


Option B: Separate Dedicated Render Tools (RECOMMENDED)

How it works

@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.
    """

Pros

  • Clean separation — calculation tools stay lean, rendering is separate
  • Flexible — render any chart data, even from external sources
  • Multiple formats — SVG for web, PDF for print, PNG for thumbnails
  • Caching friendly — cache rendered SVG by hash of chart data + style params
  • Agent-friendly — agent can do:
    1. data = calculate_natal_chart(...) → gets lean JSON
    2. svg = render_natal_chart(data) → gets SVG only when needed
    3. Or skip step 1: svg = render_natal_chart_by_id("lucky") → one-shot
  • Dashboard-friendly — HTTP routes can call the same renderer

Cons

  • Two tool calls if you want both data + SVG (extra round-trip)
  • render_natal_chart_by_id duplicates the param lists of the data tools

Verdict

Recommended. Clean architecture, flexible, doesn't bloat the data tools.


Option C: Hybrid (Data tools + Render Resource)

The idea

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.
    """

Pros

  • Single render tool, not 10+ variants
  • Flexible input: raw data or person DB lookup
  • Easy to extend with new chart types

Cons

  • Complex parameter validation (mutually exclusive groups)
  • Docstring becomes very long
  • Too many responsibilities in one function

Verdict

Nice in theory, messy in practice. Go with Option B.


Recommended Design: Option B (Separated)

Tool Inventory

Core data tools (unchanged, stay lean):

  • calculate_natal_chart — pure data
  • calculate_transit_chart — pure data
  • calculate_synastry_chart — pure data
  • calculate_composite_chart — pure data
  • calculate_davison_chart — pure data
  • All _byId variants — pure data

New 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

Render Options (consistent across all render tools)

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"

Return Structure

{
    "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,
}

Agent Workflow Examples

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

Implementation Plan

Phase 1: Renderer Core

  • svgwrite for SVG generation
  • Layout engine: zodiac ring, house sectors, planet positions, aspect lines
  • Style system: modern/traditional, color/BW
  • Astronomicon font integration via @font-face + unicode fallback
  • Output: SVG string

Phase 2: Render Tools

  • render_natal_chart + render_natal_chart_by_id
  • PDF export via WeasyPrint
  • PNG export via cairosvg
  • Aspect table SVG
  • Planet/house table SVGs

Phase 3: Additional Chart Types

  • Transit bi-wheel
  • Synastry dual wheel
  • Composite wheel
  • Davison wheel

Phase 4: Dashboard

  • /dashboard/charts/ routes
  • Style picker, format picker, download buttons
  • Person lookup → chart display
  • Transit date slider

Open Questions

  1. Render 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.

  2. Chart data validation: What if the agent passes malformed chart_data? → Use a Pydantic model to validate the expected structure, return clear error messages.

  3. 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.

  4. 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.

  5. 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.

  6. Caching: SVG is deterministic for (chart_data_hash + style_options). → Cache in memory with LRU eviction. Key: sha256(chart_data + options).

  7. Person info on chart: Name, birth date, location in the title block? → Extracted from chart_data["input"] if present. Respect person.private flag.