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