# 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
```python
@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
```python
@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": "", # or base64 png, or pdf path
"format": "svg",
"width": size,
"height": size,
}
```
Plus convenience variants:
```python
@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:
```python
@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)
```python
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
```python
{
"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**
```python
# 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**
```python
# 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**
```python
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.