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