From fd75931b88a387a0a447621f3798624480b98149 Mon Sep 17 00:00:00 2001 From: qdonnars Date: Wed, 29 Apr 2026 22:07:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20MCP=20Apps=20support=20=E2=80=94?= =?UTF-8?q?=20iframe=20openwind.fr=20instead=20of=20inline=20HTML=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: "the html field is rarely well rendered across clients" — true, because relying on every chat host to render arbitrary HTML in chat is officially undocumented and fragile. Cursor and Le Chat code-fence it, Claude renders it opportunistically thanks to a hand-tuned "inject verbatim" prompt hack, terminal hosts ignore it. Migrating to **MCP Apps** (stable spec since 2026-01-26): the tool declares `_meta.ui.resourceUri = ui://openwind/plan-passage`. Hosts that support the extension (Claude, Claude Desktop, ChatGPT, VS Code Copilot, Goose, Postman, MCPJam) fetch the resource and render it in a sandboxed iframe. Hosts that don't (Cursor, Le Chat, terminal) fall back gracefully to the text response and the `openwind_url` deep-link. ## What's in the resource A 14-line HTML doc that iframes openwind.fr/plan{{queryString}}. The host fills the queryString from the tool's structured output (per the spec's templating convention). The rendered widget IS the live web app — single source of truth, zero duplicate widget code to maintain. Whatever evolves on openwind.fr automatically benefits MCP Apps users. The `frameDomains` CSP allowance is declared on the resource's meta so the nested openwind.fr iframe is permitted (default is `frame-src 'none'`). ## Removals - `html` field on `plan_passage` response — gone. The "inject verbatim" hack in the docstring — gone. The 1500-token-per-call widget HTML — gone. - `render`, `boat_name`, `leg_titles`, `locale`, `timezone` kwargs — all only used by the dropped widget renderer. Gone. - `render.py`: trimmed from 262 lines to 32. Only `build_openwind_url` survives (still used by the response payload + the `ui://` resource). ## Dependency bump `mcp[cli]>=1.2` → `mcp[cli]>=1.23.3,<1.28`: - 1.19.0+ for the `meta=` kwarg on `@mcp.tool()` and `@mcp.resource()` (PR #1463) - 1.23.3+ for the `text/html;profile=mcp-app` MIME validator (PR #1755) - <1.28 to avoid the post-1.27 FastMCP -> MCPServer rename Locally installed mcp 1.27.0; both `meta=` paths verified at import time (see new test `test_ui_resource_registered`). ## Tests - 20/20 mcp-core tests pass - 124/124 data-adapters tests pass - ruff clean - Locale / boat_name / leg_titles tests removed (they tested the dead widget) ## Out of scope The web app (openwind.fr) is unchanged — the iframe just opens the existing /plan view. No web-side migration needed. The `MARKETING_AUDIT.md` and unrelated hf-space landing-page tweaks staying in the working tree are someone else's work; left untouched. Co-Authored-By: Claude Sonnet 4.6 --- packages/mcp-core/pyproject.toml | 6 +- .../mcp-core/src/openwind_mcp_core/render.py | 254 +----------------- .../mcp-core/src/openwind_mcp_core/server.py | 193 ++++++------- packages/mcp-core/tests/test_server.py | 63 ++--- packages/mcp-core/uv.lock | 2 +- 5 files changed, 135 insertions(+), 383 deletions(-) diff --git a/packages/mcp-core/pyproject.toml b/packages/mcp-core/pyproject.toml index 17af5c5..2c92053 100644 --- a/packages/mcp-core/pyproject.toml +++ b/packages/mcp-core/pyproject.toml @@ -4,7 +4,11 @@ version = "0.1.0" description = "Cloud-agnostic FastMCP server for OpenWind — sailing planner tools." requires-python = ">=3.12" dependencies = [ - "mcp[cli]>=1.2", + # MCP Apps support requires: + # - 1.19.0+ for `meta=` kwarg on @tool / @resource (PR #1463) + # - 1.23.3+ for the `text/html;profile=mcp-app` MIME validator (PR #1755) + # - <1.28 to avoid the FastMCP -> MCPServer rename (post-1.27 HEAD) + "mcp[cli]>=1.23.3,<1.28", "openwind-data", ] diff --git a/packages/mcp-core/src/openwind_mcp_core/render.py b/packages/mcp-core/src/openwind_mcp_core/render.py index 007a948..cead796 100644 --- a/packages/mcp-core/src/openwind_mcp_core/render.py +++ b/packages/mcp-core/src/openwind_mcp_core/render.py @@ -1,110 +1,19 @@ -"""Server-side rendering of OpenWind widgets to final HTML. +"""Deep-link helper used by ``plan_passage``. -Companion to ``widget.py`` (which holds the static template + rendering -instructions returned by ``read_me``). Where ``read_me`` lets the LLM do the -substitution itself, this module performs the same substitution in Python and -hands back ready-to-display HTML — no placeholders left. +Historically this module also generated a ~5 KB self-contained HTML widget +that the LLM was asked to inject verbatim into chat. That pattern was fragile +across hosts (Cursor / Le Chat / terminal would code-fence or sanitize it), +so we removed it in PR #74 and migrated to MCP Apps: a sandboxed +``ui://openwind/plan-passage`` resource that iframes openwind.fr/plan +directly. The web app is now the single source of visual truth. -Why two paths: -- ``read_me`` stays the fallback for clients that want to customise rendering - or for debugging. Cross-client by definition (LLM emits HTML). -- ``render_passage`` is the fast path: deterministic Python substitution moves - the work off the LLM's slow output stream. The LLM passes the structured - output of ``estimate_passage`` (+ optionally ``score_complexity``) and gets - back a self-contained HTML string it can either relay verbatim or hand to - the host client's artifact / show_widget capability. - -Both paths share the same underlying ``PASSAGE_WIDGET_HTML`` template — single -source of visual truth. Only the substitution mechanism differs. +Only the URL builder remains here — used both by the tool's response payload +and by the MCP Apps resource template. """ from __future__ import annotations -from datetime import datetime -from typing import Any from urllib.parse import quote -from zoneinfo import ZoneInfo - -from .widget import PASSAGE_WIDGET_HTML - -_CX_COLORS: dict[int, str] = { - 1: "#1D9E75", - 2: "#1D9E75", - 3: "#EF9F27", - 4: "#D85A30", - 5: "#E24B4A", -} - -_FR_DAYS = ("Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim") -_FR_MONTHS = ( - "janv.", - "févr.", - "mars", - "avr.", - "mai", - "juin", - "juil.", - "août", - "sept.", - "oct.", - "nov.", - "déc.", -) -_EN_DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") -_EN_MONTHS = ( - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -) - -_LABEL_FR: dict[str, str] = { - ">DEPARTURE<": ">DÉPART<", - ">Distance<": ">Distance<", - ">Duration<": ">Durée<", - ">ETA<": ">ETA<", - ">Complexity<": ">Complexité<", - ">Open in OpenWind →<": ">Ouvrir dans OpenWind →<", -} - -_LEG_TEMPLATE = ( - '
' - '
{index}
' - '
' - '
{title}
' - '
' - "{distance} nm" - "TWS {tws}kn" - "TWA {twa}°" - "VMG {vmg}kn" - "
" - '
{eta}
' - "
" -) - -_CX_BAR_FILLED = '' -_CX_BAR_EMPTY = '' - -SUPPORTED_LOCALES = ("fr", "en") - - -def _format_date(dt: datetime, locale: str) -> str: - if locale == "fr": - return f"{_FR_DAYS[dt.weekday()]} {dt.day} {_FR_MONTHS[dt.month - 1]} {dt.year}" - return f"{_EN_DAYS[dt.weekday()]} {dt.day} {_EN_MONTHS[dt.month - 1]} {dt.year}" - - -def _archetype_display(archetype: str) -> str: - """``cruiser_30ft`` -> ``Cruiser 30ft``.""" - return " ".join(p.capitalize() for p in archetype.split("_")) def build_openwind_url( @@ -114,149 +23,10 @@ def build_openwind_url( ) -> str: """Build the openwind.fr/plan deep-link URL. - Public so ``plan_passage`` can include it in its response payload as the - fallback CTA for clients that don't render HTML inline (Le Chat, Goose, - terminals). + Used as the always-on fallback CTA for clients that don't render the MCP + Apps iframe (Le Chat, Goose, terminals) — they show this URL as + "View full plan →" instead. """ wpts = ";".join(f"{w['lat']:.3f},{w['lon']:.3f}" for w in waypoints) dep = quote(departure_iso, safe="") return f"https://openwind.fr/plan?wpts={wpts}&departure={dep}&archetype={archetype}" - - -def _waypoints_from_segments(segments: list[dict[str, Any]]) -> list[dict[str, float]]: - """Reconstruct user-supplied waypoints from sub-segments — start of seg[0] + - end of every seg. - - Note: segments are sub-segments after polyline splitting, so this returns - *sub-segment* boundaries, not the original user waypoints. Caller should - pass `waypoints` explicitly when the original ones are known — this is a - last-resort fallback for the deep-link URL. - """ - if not segments: - return [] - out: list[dict[str, float]] = [ - {"lat": segments[0]["start"]["lat"], "lon": segments[0]["start"]["lon"]} - ] - out.extend({"lat": s["end"]["lat"], "lon": s["end"]["lon"]} for s in segments) - return out - - -def _localize_labels(html: str, locale: str) -> str: - if locale == "fr": - for old, new in _LABEL_FR.items(): - html = html.replace(old, new) - return html - - -def render_passage( - passage: dict[str, Any], - complexity: dict[str, Any] | None = None, - *, - waypoints: list[dict[str, float]] | None = None, - boat_name: str | None = None, - leg_titles: list[str] | None = None, - locale: str = "fr", - timezone: str = "Europe/Paris", -) -> str: - """Render the passage widget to final, self-contained HTML. - - Args: - passage: dict shaped like the output of ``estimate_passage`` - (ISO datetimes, segments[], etc.). - complexity: dict shaped like the output of ``score_complexity``. - If ``None``, the complexity bars render empty and the score shows ``-``. - waypoints: original user waypoints for the deep-link URL. If ``None``, - inferred from segment endpoints (less accurate). - boat_name: optional commercial name (e.g. ``"OTAGO III"``); prepended to - the boat line. - leg_titles: optional human-friendly per-leg titles (e.g. - ``["Sortie rade", "Cap Sicié → Grand Ribaud"]``). Falls back to - ``"Leg N · wpN → wpN+1"`` for missing entries. - locale: ``"fr"`` (default) or ``"en"``. Drives label text and date format. - timezone: IANA tz for time display. Default ``Europe/Paris`` (the - project's primary cruising area). - """ - if locale not in SUPPORTED_LOCALES: - raise ValueError(f"locale must be one of {SUPPORTED_LOCALES}, got {locale!r}") - - tz = ZoneInfo(timezone) - - dep_iso = passage["departure_time"] - arr_iso = passage["arrival_time"] - dep_dt = datetime.fromisoformat(dep_iso).astimezone(tz) - arr_dt = datetime.fromisoformat(arr_iso).astimezone(tz) - - duration_total_min = round(passage["duration_h"] * 60) - duration_hours = duration_total_min // 60 - duration_minutes = duration_total_min % 60 - - archetype = passage["archetype"] - archetype_line = _archetype_display(archetype) - if boat_name: - archetype_line = f"{boat_name} · {archetype_line}" - - segments = passage["segments"] - if waypoints is None: - waypoints = _waypoints_from_segments(segments) - - if complexity is not None: - cx_level = complexity["level"] - cx_color = _CX_COLORS[cx_level] - complexity_score = str(cx_level) - bars = [ - _CX_BAR_FILLED.format(color=cx_color) if i <= cx_level else _CX_BAR_EMPTY - for i in range(1, 6) - ] - else: - cx_level = 0 - cx_color = _CX_COLORS[1] - complexity_score = "-" - bars = [_CX_BAR_EMPTY] * 5 - complexity_bars = "".join(bars) - - leg_blocks: list[str] = [] - for i, seg in enumerate(segments): - idx = i + 1 - if leg_titles and i < len(leg_titles): - title = leg_titles[i] - else: - title = f"Leg {idx} · wp{idx} → wp{idx + 1}" - end_dt = datetime.fromisoformat(seg["end_time"]).astimezone(tz) - # "VMG" here is speed-made-good toward the next waypoint. Segments - # follow the rhumb line, so this equals boat_speed_kn directly — more - # useful for passage planning than the racing VMG-to-wind. - leg_blocks.append( - _LEG_TEMPLATE.format( - color=cx_color, - index=idx, - title=title, - distance=f"{seg['distance_nm']:.1f}", - tws=f"{seg['tws_kn']:.1f}", - twa=round(seg["twa_deg"]), - vmg=f"{seg['boat_speed_kn']:.1f}", - eta=end_dt.strftime("%H:%M"), - ) - ) - - substitutions: dict[str, str] = { - "{{departure_time}}": dep_dt.strftime("%H:%M"), - "{{departure_date_display}}": _format_date(dep_dt, locale), - "{{timezone}}": dep_dt.tzname() or "", - "{{num_waypoints}}": str(len(waypoints)), - "{{total_distance}}": f"{passage['distance_nm']:.1f}", - "{{archetype_display}}": archetype_line, - "{{efficiency}}": f"{passage['efficiency']:.2f}", - "{{duration_hours}}": str(duration_hours), - "{{duration_minutes}}": str(duration_minutes), - "{{eta_time}}": arr_dt.strftime("%H:%M"), - "{{complexity_score}}": complexity_score, - "{{complexity_bars}}": complexity_bars, - "{{legs}}": "".join(leg_blocks), - "{{openwind_url}}": build_openwind_url(waypoints, dep_iso, archetype), - } - - html = PASSAGE_WIDGET_HTML - for placeholder, value in substitutions.items(): - html = html.replace(placeholder, value) - - return _localize_labels(html, locale) diff --git a/packages/mcp-core/src/openwind_mcp_core/server.py b/packages/mcp-core/src/openwind_mcp_core/server.py index 63421af..7902c8c 100644 --- a/packages/mcp-core/src/openwind_mcp_core/server.py +++ b/packages/mcp-core/src/openwind_mcp_core/server.py @@ -10,23 +10,23 @@ → ``cruiser_30ft``). No server-side mapping table. 2. ``get_marine_forecast`` — wind+sea around a point/window for one or more models. 3. ``plan_passage`` — single-shot end-to-end: timing along the polyline, - 1-5 complexity score, rendered HTML widget, and openwind.fr deep-link. - Replaces the previous trio (``estimate_passage`` + ``score_complexity`` + - ``render_passage_widget``) — one call, one Open-Meteo fetch, one round-trip. - -Typical orchestration pattern (LLM perspective): - -* Call ``list_boat_archetypes`` once at the start of the conversation, map the - user's commercial model from ``examples`` + ``length_ft`` + ``type``. -* For an "A → B" question, call ``plan_passage`` ONCE with the waypoints, - departure, and chosen archetype. Use ``model="auto"`` so the server picks - AROME (≤48 h) → ICON-EU (≤5 d) → GFS (≤16 d) automatically — the chosen - model is reflected in ``passage.model``. -* The response includes a ready-to-display ``html``: inject it verbatim in - your response — no markdown code-block fence, no reformatting. Use - ``openwind_url`` as fallback on text-only clients. -* ``get_marine_forecast`` is the escape hatch for sea-state lookup or model - comparison — not needed for the typical "A → B by date X" question. + 1-5 complexity score, rendered MCP App widget, and openwind.fr deep-link. + +## Rich rendering (MCP Apps) + +``plan_passage`` declares ``_meta.ui.resourceUri`` pointing at +``ui://openwind/plan-passage`` — a sandboxed HTML resource that iframes +``openwind.fr/plan?...``. Hosts that support the MCP Apps spec (Claude, +Claude Desktop, ChatGPT, VS Code Copilot, Goose, Postman, MCPJam — see the +`extension client matrix`_) render the widget inline. Hosts that do NOT +support MCP Apps (Cursor, Le Chat, terminal, …) silently fall back to the +tool's text response: a compact summary plus the ``openwind_url`` deep link. + +The dead-on-arrival ``html`` field that older versions returned has been +dropped — relying on every host to render arbitrary HTML in chat was fragile +by design (see PR #74 for the full reasoning). + +.. _extension client matrix: https://modelcontextprotocol.io/extensions/client-matrix """ from __future__ import annotations @@ -53,7 +53,15 @@ score_complexity as _score_complexity, ) -from .render import build_openwind_url, render_passage +from .render import build_openwind_url + +# MCP Apps UI resource URI for plan_passage. The host fetches this resource +# and renders it in a sandboxed iframe; the resource itself iframes +# openwind.fr/plan, so the rendered widget IS the live web app — single +# source of truth, no duplicate widget code to maintain. +PLAN_UI_RESOURCE_URI = "ui://openwind/plan-passage" +PLAN_UI_MIME = "text/html;profile=mcp-app" +PLAN_UI_FRAME_DOMAINS = ["https://openwind.fr"] def _archetype_summary(p: Any) -> dict[str, Any]: @@ -117,20 +125,21 @@ def _passage_to_dict(report: Any) -> dict[str, Any]: convergence iteration. Bias bounded by Mediterranean wind correlation. - Routes split into ~10 nm sub-segments by default for weather sampling. -## Multi-window sweep mode +## Compare-windows mode -`plan_passage` accepts an optional `latest_departure` to sweep N hourly -departure windows over the same route. Weather is fetched once (cache -prewarm), simulations are in-memory. Hard cap: 14 d x 24 h = 336 windows. -Returns a list of windows; the LLM picks qualitatively (no server-side -ranking). +`plan_passage` accepts an optional `latest_departure` that turns the call +into a window comparison: it walks N hourly departures over the same route +and returns one entry per window. Weather is fetched once (cache prewarm), +simulations are in-memory. Hard cap: 14 d x 24 h = 336 windows. The LLM +picks qualitatively (no server-side ranking). ## Mediterranean defaults - Tides ignored (< 40 cm, negligible vs forecast uncertainty). - Currents ignored (Liguro-Provencal too weak / variable for V1). - Wind model: AROME 1.3 km (<= 48 h horizon, captures thermals and local - winds). Auto-falls back to ICON-EU (<= 5 d) -> GFS (<= 16 d). + winds). Auto-falls back to ICON-EU (<= 5 d) -> ECMWF IFS 0.25 deg + (<= 10 d) -> GFS (<= 16 d). - Wave model: Open-Meteo Marine (significant Hs, period, direction). ## What is NOT modelled (V1) @@ -172,6 +181,38 @@ def build_server(*, adapter: MarineDataAdapter | None = None) -> FastMCP: server: FastMCP = FastMCP("openwind") fetch_adapter: MarineDataAdapter = adapter or OpenMeteoAdapter() + @server.resource( + PLAN_UI_RESOURCE_URI, + name="OpenWind plan widget", + mime_type=PLAN_UI_MIME, + meta={"ui": {"csp": {"frameDomains": PLAN_UI_FRAME_DOMAINS}}}, + ) + def plan_widget_resource() -> str: + """MCP Apps UI resource: a thin HTML doc that iframes openwind.fr/plan. + + The query string of the inner iframe is set client-side by the host + from the tool's structured output (waypoints, departure, archetype). + Hosts that support MCP Apps render this in a sandboxed iframe; the + nested openwind.fr iframe is allowed via the ``frameDomains`` CSP. + """ + # The {{params}} placeholder is filled by the host from the tool's + # `structuredContent` (per the MCP Apps templating convention). If a + # host doesn't yet support templating, the iframe just opens + # openwind.fr's empty /plan view — still better than a render crash. + return ( + "" + "" + "" + "Plan" + "" + "" + "" + "" + ) + @server.tool() def read_me() -> str: """Return OpenWind's calculation methodology as Markdown. @@ -182,7 +223,7 @@ def read_me() -> str: The returned text covers: polar lookup, default efficiency 0.75, VMG / tacking correction, wave derate, single-pass timing, - multi-window sweep semantics, Mediterranean simplifications + compare-windows mode semantics, Mediterranean simplifications (tides, currents), and what is intentionally NOT modelled in V1. """ return _METHODOLOGY @@ -247,7 +288,9 @@ async def get_marine_forecast( ], } - @server.tool() + @server.tool( + meta={"ui": {"resourceUri": PLAN_UI_RESOURCE_URI}}, + ) async def plan_passage( waypoints: list[dict[str, float]], departure: str, @@ -256,22 +299,16 @@ async def plan_passage( segment_length_nm: float = 10.0, model: str = AUTO_MODEL, max_hs_m: float | None = None, - render: bool = True, - boat_name: str | None = None, - leg_titles: list[str] | None = None, - locale: str = "fr", - timezone: str = "Europe/Paris", latest_departure: str | None = None, sweep_interval_hours: int = 1, target_eta: str | None = None, ) -> dict[str, Any]: - """Plan an A→B passage end-to-end: timing + complexity + widget + deep-link. + """Plan an A→B passage end-to-end: timing + complexity + deep-link. ONE call gives you everything for the typical "leaving Marseille for Porquerolles tomorrow on a 30-footer" question. The server fetches Open-Meteo once, computes the passage, scores its complexity from the - same report (no double-fetch), renders the HTML widget, and builds the - openwind.fr deep-link. + same report (no double-fetch), and emits the openwind.fr deep-link. ## Returned payload @@ -279,19 +316,20 @@ async def plan_passage( model used, segments[] with TWS/TWA/boat_speed/Hs, warnings). - ``complexity``: 1-5 difficulty score with wind/sea breakdown and a human-readable rationale. - - ``html``: ready-to-display widget HTML (~5 KB, self-contained, dark - mode aware). Always populated when ``render=True`` (default). - - ``openwind_url``: ALWAYS present. Deep-link to openwind.fr/plan that - re-renders the same passage server-side. + - ``openwind_url``: deep-link to openwind.fr/plan that renders the + same passage in the standalone web app. - ## How to display + ## How it renders - **IMPORTANT — inject ``html`` verbatim, no wrapper.** - Copy the exact string from ``result["html"]`` into your response as-is. - Do NOT wrap it in a markdown code block (no ```html``` fence) — that - prevents rendering. Do NOT reconstruct or reformat the widget manually. - On text-only clients (terminal, Le Chat), skip ``html`` and present - ``openwind_url`` as a "View full plan →" link instead. + On hosts that support MCP Apps (Claude, Claude Desktop, ChatGPT, VS + Code Copilot, Goose, Postman, MCPJam), the response is automatically + accompanied by an interactive widget — the live openwind.fr/plan view + served via the ``ui://openwind/plan-passage`` resource declared on + this tool's ``_meta``. Nothing to inject manually. + + On hosts without MCP Apps support (Cursor, Le Chat, terminal), present + a short text summary of the result (route, ETA, complexity, warnings) + and offer ``openwind_url`` as the "View full plan →" link. ## Args @@ -307,40 +345,27 @@ async def plan_passage( balances precision vs Open-Meteo budget; drop to 5 for tight coastal work, raise to 20 for long offshore legs. model: wind model. Default ``"auto"`` tries AROME (≤48 h) → - ICON-EU (≤5 d) → GFS (≤16 d). Pass an explicit name to bypass. + ICON-EU (≤5 d) → ECMWF IFS 0.25° (≤10 d) → GFS (≤16 d). + Pass an explicit name to bypass. max_hs_m: optional max significant wave height (meters) over the route — pass it if you have a sea-state estimate from ``get_marine_forecast`` and want it factored into the score. Defaults to wind-only scoring. - render: if True (default), populate ``html`` with the rendered - widget. Set to False on clients that don't display HTML at all, - to save ~1500 tokens of context. - boat_name: optional commercial name (e.g. ``"OTAGO III"``); - prepended to the widget's boat line. - leg_titles: optional human-friendly per-leg titles (e.g. - ``["Sortie rade", "Cap Sicié → Grand Ribaud"]``). Falls back - to ``"Leg N · wpN → wpN+1"`` for missing entries. - locale: ``"fr"`` (default) or ``"en"`` — drives widget label text - and date format. - timezone: IANA tz for time display in the widget (default - ``"Europe/Paris"``). - - ## Sweep mode (latest_departure set) - - When ``latest_departure`` is provided, the tool sweeps departure times - from ``departure`` to ``latest_departure`` every ``sweep_interval_hours`` - (default 1 h). Returns ``{"mode": "multi_window", "sweep": {...}, - "windows": [...]}`` instead of the single-passage payload. Each window - contains ``departure``, ``arrival``, ``duration_h``, ``distance_nm``, + + ## Compare-windows mode (latest_departure set) + + When ``latest_departure`` is provided, the tool switches into a + window-comparison call: it walks departure times from ``departure`` + up to ``latest_departure`` every ``sweep_interval_hours`` (default + 1 h). Returns ``{"mode": "multi_window", "sweep": {...}, "windows": + [...]}`` instead of the single-passage payload. Each window contains + ``departure``, ``arrival``, ``duration_h``, ``distance_nm``, ``complexity``, ``conditions_summary``, ``warnings``, and its own - ``openwind_url``. HTML is never rendered in sweep mode — the LLM reasons - qualitatively over the windows and presents 2-3 options; the user picks - one; the LLM then calls ``plan_passage`` once more with that specific - departure to get the rendered widget. + ``openwind_url``. ``target_eta``: optional ISO-8601 datetime. When set, only windows that - arrive within ±2 h of the target are returned. If none match, all windows - are returned with a ``meta_warnings`` note. + arrive within ±2 h of the target are returned. If none match, all + windows are returned with a ``meta_warnings`` note. ## Failure modes @@ -396,7 +421,7 @@ async def plan_passage( "meta_warnings": meta_warnings, } - # --- SINGLE MODE (unchanged) --- + # --- SINGLE MODE --- report = await _estimate_passage( pts, dep, @@ -408,25 +433,9 @@ async def plan_passage( ) score = _score_complexity(report, max_hs_m=max_hs_m) - passage_dict = _passage_to_dict(report) - complexity_dict = asdict(score) - - html: str | None = None - if render: - html = render_passage( - passage_dict, - complexity_dict, - waypoints=waypoints, - boat_name=boat_name, - leg_titles=leg_titles, - locale=locale, - timezone=timezone, - ) - return { - "passage": passage_dict, - "complexity": complexity_dict, - "html": html, + "passage": _passage_to_dict(report), + "complexity": asdict(score), "openwind_url": build_openwind_url(waypoints, departure, archetype), } diff --git a/packages/mcp-core/tests/test_server.py b/packages/mcp-core/tests/test_server.py index f947bd9..a1986e8 100644 --- a/packages/mcp-core/tests/test_server.py +++ b/packages/mcp-core/tests/test_server.py @@ -108,25 +108,24 @@ async def test_returns_five_archetypes_with_metadata(self) -> None: class TestPlanPassage: - """The single workhorse tool. Replaces estimate_passage + score_complexity - + render_passage_widget. The contract is: ONE call returns timing, - complexity, html, and openwind_url — and fetches Open-Meteo ONCE.""" + """The single workhorse tool. Replaces estimate_passage + score_complexity. + Contract: ONE call returns timing + complexity + openwind_url, fetches + Open-Meteo ONCE. Rich rendering moved to MCP Apps via _meta.ui resource.""" async def test_returns_full_payload(self) -> None: adapter = StubAdapter() server = build_server(adapter=adapter) out = await _call(server, "plan_passage", _BASE_PLAN_ARGS) - assert {"passage", "complexity", "html", "openwind_url"} <= out.keys() + assert {"passage", "complexity", "openwind_url"} <= out.keys() + # html field is gone in the MCP Apps era. + assert "html" not in out # Passage shape assert isinstance(out["passage"]["departure_time"], str) assert out["passage"]["archetype"] == "cruiser_40ft" assert len(out["passage"]["segments"]) >= 1 # Complexity shape assert 1 <= out["complexity"]["level"] <= 5 - # HTML rendered by default — no placeholders left - assert isinstance(out["html"], str) - assert "{{" not in out["html"] # URL always present assert out["openwind_url"].startswith("https://openwind.fr/plan?") @@ -144,18 +143,6 @@ async def test_no_double_fetch(self) -> None: "score_complexity may be re-fetching" ) - async def test_render_false_skips_html(self) -> None: - # Opt-out path for text-only clients that don't need the ~5 KB markup. - adapter = StubAdapter() - server = build_server(adapter=adapter) - out = await _call(server, "plan_passage", {**_BASE_PLAN_ARGS, "render": False}) - assert out["html"] is None - # URL still present — it's the always-on fallback CTA. - assert out["openwind_url"].startswith("https://openwind.fr/plan?") - # Passage and complexity still computed. - assert out["passage"]["archetype"] == "cruiser_40ft" - assert 1 <= out["complexity"]["level"] <= 5 - async def test_openwind_url_uses_explicit_waypoints(self) -> None: # The URL encodes the user's original waypoints, not the (potentially # subdivided) segments — so partage SMS reproduit fidèlement la nav. @@ -165,33 +152,13 @@ async def test_openwind_url_uses_explicit_waypoints(self) -> None: assert "wpts=43.300,5.350;43.000,6.200" in url assert "archetype=cruiser_40ft" in url - async def test_locale_fr_swaps_widget_labels(self) -> None: - server = build_server(adapter=StubAdapter()) - out = await _call(server, "plan_passage", {**_BASE_PLAN_ARGS, "locale": "fr"}) - body = out["html"] - assert ">DÉPART<" in body - assert ">Durée<" in body - assert ">Complexité<" in body - - async def test_locale_en_keeps_english(self) -> None: + async def test_ui_resource_registered(self) -> None: + # MCP Apps: the host needs to be able to fetch ui://openwind/plan-passage. + from openwind_mcp_core.server import PLAN_UI_RESOURCE_URI server = build_server(adapter=StubAdapter()) - out = await _call(server, "plan_passage", {**_BASE_PLAN_ARGS, "locale": "en"}) - assert ">DEPARTURE<" in out["html"] - assert ">DÉPART<" not in out["html"] - - async def test_boat_name_and_leg_titles_threaded_to_html(self) -> None: - server = build_server(adapter=StubAdapter()) - out = await _call( - server, - "plan_passage", - { - **_BASE_PLAN_ARGS, - "boat_name": "OTAGO III", - "leg_titles": ["Custom title only"], - }, - ) - assert "OTAGO III" in out["html"] - assert "Custom title only" in out["html"] + resources = await server.list_resources() + uris = [str(r.uri) for r in resources] + assert PLAN_UI_RESOURCE_URI in uris async def test_max_hs_factors_into_complexity(self) -> None: # max_hs_m used to be on its own tool (score_complexity); now it's @@ -243,8 +210,9 @@ async def test_sweep_window_shape(self) -> None: assert cs["predominant_sail_angle"] in ("pres", "travers", "largue", "portant") async def test_html_never_rendered_in_sweep(self) -> None: + # html field is gone everywhere now (MCP Apps era). server = build_server(adapter=StubAdapter()) - out = await _call(server, "plan_passage", {**_SWEEP_ARGS, "render": True}) + out = await _call(server, "plan_passage", _SWEEP_ARGS) assert "html" not in out for w in out["windows"]: assert "html" not in w @@ -272,8 +240,9 @@ async def test_sweep_departures_ordered_and_spaced(self) -> None: async def test_single_mode_backward_compatible(self) -> None: server = build_server(adapter=StubAdapter()) out = await _call(server, "plan_passage", _BASE_PLAN_ARGS) - assert {"passage", "complexity", "html", "openwind_url"} <= out.keys() + assert {"passage", "complexity", "openwind_url"} <= out.keys() assert "mode" not in out + assert "html" not in out # dropped in the MCP Apps migration async def test_target_eta_filters_windows(self) -> None: # With constant 12 kn from north, passage ~8h → arrival ~14:00 from 06:00 diff --git a/packages/mcp-core/uv.lock b/packages/mcp-core/uv.lock index 460844c..b2ddc41 100644 --- a/packages/mcp-core/uv.lock +++ b/packages/mcp-core/uv.lock @@ -363,7 +363,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mcp", extras = ["cli"], specifier = ">=1.2" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.23.3,<1.28" }, { name = "openwind-data", editable = "../data-adapters" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },