Return HTML charts as EmbeddedResource instead of server-side files

HTML charts were being written to the server filesystem and returning
a path the client couldn't access. Now returns the HTML content
inline as an MCP EmbeddedResource with text/html MIME type, so the
client receives the full interactive chart over the wire.
This commit is contained in:
Ryan Malloy 2026-02-23 19:50:36 -07:00
parent 89cdeb0967
commit ad17d72894
2 changed files with 21 additions and 29 deletions

View File

@ -15,7 +15,6 @@ WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 ENV UV_COMPILE_BYTECODE=1
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
ENV MPLCONFIGDIR=/tmp/matplotlib ENV MPLCONFIGDIR=/tmp/matplotlib
ENV MCNOAA_CHARTS_DIR=/tmp/charts
COPY --from=deps /app/.venv /app/.venv COPY --from=deps /app/.venv /app/.venv
COPY src/ src/ COPY src/ src/

View File

@ -2,12 +2,11 @@
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Literal from typing import Literal
from fastmcp import Context, FastMCP from fastmcp import Context, FastMCP
from fastmcp.utilities.types import Image from fastmcp.utilities.types import Image
from mcp.types import ToolAnnotations from mcp.types import EmbeddedResource, TextResourceContents, ToolAnnotations
from mcnoaa_tides.charts import check_deps from mcnoaa_tides.charts import check_deps
from mcnoaa_tides.client import NOAAClient from mcnoaa_tides.client import NOAAClient
@ -23,15 +22,16 @@ def register(mcp: FastMCP) -> None:
hours: int = 48, hours: int = 48,
include_observed: bool = True, include_observed: bool = True,
format: Literal["png", "html"] = "png", format: Literal["png", "html"] = "png",
) -> Image | str: ) -> Image | EmbeddedResource:
"""Generate a tide prediction chart with high/low markers. """Generate a tide prediction chart with high/low markers.
Creates a visual chart of tide predictions showing the water level curve Creates a visual chart of tide predictions showing the water level curve
with high (H) and low (L) tide markers. Optionally overlays observed with high (H) and low (L) tide markers. Optionally overlays observed
water levels as a dashed line for comparison. water levels as a dashed line for comparison.
PNG format returns an inline image. HTML format saves an interactive PNG format returns an inline image. HTML format returns an interactive
chart to artifacts/charts/ and returns the file path. Plotly chart as an embedded resource (text/html) that the client can
save or render directly.
Requires mcnoaa-tides[viz] to be installed. Requires mcnoaa-tides[viz] to be installed.
""" """
@ -97,8 +97,7 @@ def register(mcp: FastMCP) -> None:
from mcnoaa_tides.charts.tides import render_tide_chart_html from mcnoaa_tides.charts.tides import render_tide_chart_html
html = render_tide_chart_html(predictions, observed, station_name) html = render_tide_chart_html(predictions, observed, station_name)
path = _save_html(html, station_id, "tides") return _html_resource(html, station_id, "tides")
return f"Interactive tide chart saved to: {path}"
@mcp.tool(tags={"visualization"}, annotations=_ANNOTATIONS) @mcp.tool(tags={"visualization"}, annotations=_ANNOTATIONS)
async def visualize_conditions( async def visualize_conditions(
@ -106,7 +105,7 @@ def register(mcp: FastMCP) -> None:
station_id: str, station_id: str,
hours: int = 24, hours: int = 24,
format: Literal["png", "html"] = "png", format: Literal["png", "html"] = "png",
) -> Image | str: ) -> Image | EmbeddedResource:
"""Generate a multi-panel marine conditions dashboard. """Generate a multi-panel marine conditions dashboard.
Creates a dashboard with up to 4 panels: Creates a dashboard with up to 4 panels:
@ -117,8 +116,9 @@ def register(mcp: FastMCP) -> None:
Products unavailable at a station are simply omitted from the dashboard. Products unavailable at a station are simply omitted from the dashboard.
PNG format returns an inline image. HTML format saves an interactive PNG format returns an inline image. HTML format returns an interactive
chart to artifacts/charts/ and returns the file path. Plotly dashboard as an embedded resource (text/html) that the client
can save or render directly.
Requires mcnoaa-tides[viz] to be installed. Requires mcnoaa-tides[viz] to be installed.
""" """
@ -184,23 +184,16 @@ def register(mcp: FastMCP) -> None:
from mcnoaa_tides.charts.conditions import render_conditions_html from mcnoaa_tides.charts.conditions import render_conditions_html
html = render_conditions_html(snapshot, station_name) html = render_conditions_html(snapshot, station_name)
path = _save_html(html, station_id, "conditions") return _html_resource(html, station_id, "conditions")
return f"Interactive conditions dashboard saved to: {path}"
def _save_html(html: str, station_id: str, chart_type: str) -> Path: def _html_resource(html: str, station_id: str, chart_type: str) -> EmbeddedResource:
"""Save HTML chart and return the path. """Wrap an HTML chart as an MCP EmbeddedResource for inline delivery."""
return EmbeddedResource(
Uses $MCNOAA_CHARTS_DIR if set, otherwise falls back to type="resource",
artifacts/charts/ (relative to cwd). The container sets resource=TextResourceContents(
MCNOAA_CHARTS_DIR=/tmp/charts so the nobody user can write. uri=f"chart://{chart_type}/{station_id}",
""" mimeType="text/html",
import os text=html,
),
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") )
base = os.environ.get("MCNOAA_CHARTS_DIR", "artifacts/charts")
out_dir = Path(base)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{station_id}_{chart_type}_{timestamp}.html"
path.write_text(html, encoding="utf-8")
return path