From 2443d0687a0acbe336e67da94f4a0ddd5b9095d7 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 23 Feb 2026 15:01:07 -0700 Subject: [PATCH] Add tool annotations, structured logging, sampling summaries, and client diagnostics - ToolAnnotations (readOnlyHint, openWorldHint) on all 14 tools - ctx.info/warning/debug logging for data quality, fetch status, assessments - ctx.sample() summaries in conditions snapshot, deployment briefing, anomaly detection - New test_client_capabilities diagnostic tool for MCP feature discovery --- src/mcnoaa_tides/server.py | 11 ++- src/mcnoaa_tides/tools/charts.py | 8 ++- src/mcnoaa_tides/tools/conditions.py | 47 ++++++++++++- src/mcnoaa_tides/tools/diagnostics.py | 76 ++++++++++++++++++++ src/mcnoaa_tides/tools/meteorological.py | 6 +- src/mcnoaa_tides/tools/smartpot.py | 89 ++++++++++++++++++++++-- src/mcnoaa_tides/tools/stations.py | 21 ++++-- src/mcnoaa_tides/tools/tides.py | 19 ++++- tests/conftest.py | 11 ++- tests/test_charts.py | 4 +- tests/test_smartpot.py | 4 +- tests/test_tools_stations.py | 13 +++- 12 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 src/mcnoaa_tides/tools/diagnostics.py diff --git a/src/mcnoaa_tides/server.py b/src/mcnoaa_tides/server.py index 7ee595f..5e59872 100644 --- a/src/mcnoaa_tides/server.py +++ b/src/mcnoaa_tides/server.py @@ -8,7 +8,15 @@ from fastmcp import FastMCP from mcnoaa_tides import __version__, prompts, resources from mcnoaa_tides.client import NOAAClient -from mcnoaa_tides.tools import charts, conditions, meteorological, smartpot, stations, tides +from mcnoaa_tides.tools import ( + charts, + conditions, + diagnostics, + meteorological, + smartpot, + stations, + tides, +) @asynccontextmanager @@ -52,6 +60,7 @@ meteorological.register(mcp) conditions.register(mcp) smartpot.register(mcp) charts.register(mcp) +diagnostics.register(mcp) # Register resources and prompts resources.register(mcp) diff --git a/src/mcnoaa_tides/tools/charts.py b/src/mcnoaa_tides/tools/charts.py index ce053c6..be36fd9 100644 --- a/src/mcnoaa_tides/tools/charts.py +++ b/src/mcnoaa_tides/tools/charts.py @@ -7,13 +7,16 @@ from typing import Literal from fastmcp import Context, FastMCP from fastmcp.utilities.types import Image +from mcp.types import ToolAnnotations from mcnoaa_tides.charts import check_deps from mcnoaa_tides.client import NOAAClient +_ANNOTATIONS = ToolAnnotations(readOnlyHint=True, openWorldHint=True) + def register(mcp: FastMCP) -> None: - @mcp.tool(tags={"visualization"}) + @mcp.tool(tags={"visualization"}, annotations=_ANNOTATIONS) async def visualize_tides( ctx: Context, station_id: str, @@ -70,6 +73,7 @@ def register(mcp: FastMCP) -> None: ) observed = obs_raw.get("data", []) except Exception: + await ctx.debug("Observed data unavailable — rendering predictions only") pass # observed overlay is optional — skip on failure # Look up station name @@ -96,7 +100,7 @@ def register(mcp: FastMCP) -> None: path = _save_html(html, station_id, "tides") return f"Interactive tide chart saved to: {path}" - @mcp.tool(tags={"visualization"}) + @mcp.tool(tags={"visualization"}, annotations=_ANNOTATIONS) async def visualize_conditions( ctx: Context, station_id: str, diff --git a/src/mcnoaa_tides/tools/conditions.py b/src/mcnoaa_tides/tools/conditions.py index 9dbc540..a812440 100644 --- a/src/mcnoaa_tides/tools/conditions.py +++ b/src/mcnoaa_tides/tools/conditions.py @@ -1,15 +1,19 @@ """Marine conditions snapshot — parallel multi-product fetch.""" import asyncio +import json from datetime import datetime, timezone from fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from mcnoaa_tides.client import NOAAClient +_ANNOTATIONS = ToolAnnotations(readOnlyHint=True, openWorldHint=True) + def register(mcp: FastMCP) -> None: - @mcp.tool(tags={"planning"}) + @mcp.tool(tags={"planning"}, annotations=_ANNOTATIONS) async def marine_conditions_snapshot( ctx: Context, station_id: str, @@ -57,6 +61,7 @@ def register(mcp: FastMCP) -> None: result = name, data except Exception as exc: msg = str(exc) or type(exc).__name__ + await ctx.warning(f"Failed to fetch {name}: {type(exc).__name__}: {msg}") result = name, f"{type(exc).__name__}: {msg}" completed += 1 await ctx.report_progress(completed, total, f"Fetched {name}") @@ -80,5 +85,45 @@ def register(mcp: FastMCP) -> None: if unavailable: snapshot["unavailable"] = unavailable + await ctx.warning( + f"Station {station_id}: {len(unavailable)} product(s) unavailable: " + + ", ".join(unavailable.keys()) + ) + + # Attempt to generate a natural-language summary via sampling + summary_input: dict = {"station_id": station_id} + if "predictions" in snapshot: + summary_input["tide_events"] = snapshot["predictions"].get("predictions", [])[:6] + if "wind" in snapshot: + wind_readings = snapshot["wind"].get("data", []) + if wind_readings: + summary_input["latest_wind"] = wind_readings[-1] + if "air_temperature" in snapshot: + air_readings = snapshot["air_temperature"].get("data", []) + if air_readings: + summary_input["latest_air_temp"] = air_readings[-1] + if "water_temperature" in snapshot: + water_readings = snapshot["water_temperature"].get("data", []) + if water_readings: + summary_input["latest_water_temp"] = water_readings[-1] + if "air_pressure" in snapshot: + pressure_readings = snapshot["air_pressure"].get("data", []) + if pressure_readings: + summary_input["latest_pressure"] = pressure_readings[-1] + + try: + sampling_result = await ctx.sample( + f"Summarize these marine conditions in 2-3 concise sentences " + f"for a boat captain:\n{json.dumps(summary_input)}", + system_prompt=( + "You are a marine weather briefer. Be concise, factual, and actionable. " + "Mention wind, tide timing, temperature, and any concerns." + ), + max_tokens=256, + ) + if sampling_result.text: + snapshot["summary"] = sampling_result.text + except Exception: + pass # Client doesn't support sampling — that's fine return snapshot diff --git a/src/mcnoaa_tides/tools/diagnostics.py b/src/mcnoaa_tides/tools/diagnostics.py new file mode 100644 index 0000000..1de639d --- /dev/null +++ b/src/mcnoaa_tides/tools/diagnostics.py @@ -0,0 +1,76 @@ +"""MCP client capability diagnostics.""" + +from fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations + +_ANNOTATIONS = ToolAnnotations(readOnlyHint=True, openWorldHint=False) + + +def register(mcp: FastMCP) -> None: + @mcp.tool(tags={"diagnostics"}, annotations=_ANNOTATIONS) + async def test_client_capabilities(ctx: Context) -> dict: + """Report which MCP features the connected client supports. + + Returns the client implementation info, protocol version, + and capability flags for sampling, elicitation, roots, and tasks. + Useful for understanding what features are available before + calling tools that depend on optional client capabilities. + """ + result: dict = { + "session_id": ctx.session_id, + } + + client_params = ctx.session.client_params + if client_params is None: + result["error"] = "No client params available" + return result + + # Client implementation info + result["client"] = { + "name": client_params.clientInfo.name, + "version": client_params.clientInfo.version, + } + result["protocol_version"] = client_params.protocolVersion + + # Capability flags + caps = client_params.capabilities + result["capabilities"] = { + "sampling": caps.sampling is not None, + "elicitation": caps.elicitation is not None, + "roots": caps.roots is not None, + "tasks": caps.tasks is not None, + } + + # Sampling detail + if caps.sampling: + result["sampling_detail"] = { + "context": caps.sampling.context is not None, + "tools": caps.sampling.tools is not None, + } + + # Elicitation detail + if caps.elicitation: + result["elicitation_detail"] = { + "form": caps.elicitation.form is not None, + "url": caps.elicitation.url is not None, + } + + # Roots detail + if caps.roots: + result["roots_detail"] = { + "list_changed": bool(caps.roots.listChanged), + } + + # Tasks detail + if caps.tasks: + result["tasks_detail"] = { + "list": caps.tasks.list is not None, + "cancel": caps.tasks.cancel is not None, + "requests": caps.tasks.requests is not None, + } + + # Experimental / extensions + if caps.experimental: + result["experimental"] = list(caps.experimental.keys()) + + return result diff --git a/src/mcnoaa_tides/tools/meteorological.py b/src/mcnoaa_tides/tools/meteorological.py index 56b93ab..ba9c7dd 100644 --- a/src/mcnoaa_tides/tools/meteorological.py +++ b/src/mcnoaa_tides/tools/meteorological.py @@ -3,9 +3,12 @@ from typing import Literal from fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from mcnoaa_tides.client import NOAAClient +_ANNOTATIONS = ToolAnnotations(readOnlyHint=True, openWorldHint=True) + MetProduct = Literal[ "air_temperature", "water_temperature", @@ -19,7 +22,7 @@ MetProduct = Literal[ def register(mcp: FastMCP) -> None: - @mcp.tool(tags={"weather"}) + @mcp.tool(tags={"weather"}, annotations=_ANNOTATIONS) async def get_meteorological_data( ctx: Context, station_id: str, @@ -46,6 +49,7 @@ def register(mcp: FastMCP) -> None: Date format: yyyyMMdd or "yyyyMMdd HH:mm". """ noaa: NOAAClient = ctx.lifespan_context["noaa_client"] + await ctx.info(f"Fetching {product} for station {station_id}") return await noaa.get_data( station_id, product=product, diff --git a/src/mcnoaa_tides/tools/smartpot.py b/src/mcnoaa_tides/tools/smartpot.py index 35370d2..6c4913b 100644 --- a/src/mcnoaa_tides/tools/smartpot.py +++ b/src/mcnoaa_tides/tools/smartpot.py @@ -6,9 +6,11 @@ anomaly detection. """ import asyncio +import json from datetime import datetime, timedelta, timezone from fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from mcnoaa_tides.client import NOAAClient from mcnoaa_tides.tidal import ( @@ -17,6 +19,8 @@ from mcnoaa_tides.tidal import ( parse_hilo_predictions, ) +_ANNOTATIONS = ToolAnnotations(readOnlyHint=True, openWorldHint=True) + async def _resolve_station( noaa: NOAAClient, @@ -55,7 +59,7 @@ async def _resolve_station( def register(mcp: FastMCP) -> None: - @mcp.tool(tags={"smartpot"}) + @mcp.tool(tags={"smartpot"}, annotations=_ANNOTATIONS) async def tidal_phase( ctx: Context, station_id: str = "", @@ -124,9 +128,10 @@ def register(mcp: FastMCP) -> None: } if distance_nm is not None: result["station_distance_nm"] = distance_nm + await ctx.debug(f"Tidal phase at {station['name']}: {phase_info.get('phase', 'unknown')}") return result - @mcp.tool(tags={"smartpot"}) + @mcp.tool(tags={"smartpot"}, annotations=_ANNOTATIONS) async def deployment_briefing( ctx: Context, latitude: float, @@ -248,7 +253,21 @@ def register(mcp: FastMCP) -> None: f"Pressure dropping {drop:.1f} mb — possible approaching weather system" ) - return { + # Log assessment level + if assessment == "NO-GO": + await ctx.warning( + f"Deployment NO-GO at {station['name']}: " + + "; ".join(advisories) + ) + elif assessment == "CAUTION": + await ctx.warning( + f"Deployment CAUTION at {station['name']}: " + + "; ".join(advisories) + ) + else: + await ctx.info(f"Deployment GO at {station['name']}") + + result = { "station": station, "station_distance_nm": distance_nm, "timestamp_utc": now_utc.isoformat(), @@ -264,7 +283,34 @@ def register(mcp: FastMCP) -> None: "advisories": advisories, } - @mcp.tool(tags={"smartpot"}) + # Attempt to generate a natural-language briefing via sampling + try: + briefing_input = { + "assessment": assessment, + "advisories": advisories, + "station": station["name"], + "soak_hours": soak_hours, + "tidal_cycles": tidal_cycles, + "conditions": conditions, + } + sampling_result = await ctx.sample( + f"Write a concise deployment briefing paragraph for a crab pot " + f"crew based on this data:\n{json.dumps(briefing_input)}", + system_prompt=( + "You are a crab pot deployment advisor. Be concise, factual, " + "and actionable. State the GO/CAUTION/NO-GO assessment, key " + "conditions, tide count, and any advisories in 2-3 sentences." + ), + max_tokens=256, + ) + if sampling_result.text: + result["summary"] = sampling_result.text + except Exception: + pass # Client doesn't support sampling + + return result + + @mcp.tool(tags={"smartpot"}, annotations=_ANNOTATIONS) async def catch_tidal_context( ctx: Context, events: list[dict], @@ -376,7 +422,7 @@ def register(mcp: FastMCP) -> None: return enriched - @mcp.tool(tags={"smartpot"}) + @mcp.tool(tags={"smartpot"}, annotations=_ANNOTATIONS) async def water_level_anomaly( ctx: Context, station_id: str, @@ -488,7 +534,13 @@ def register(mcp: FastMCP) -> None: "conditions as expected" ) - return { + if risk in ("high", "elevated"): + await ctx.warning( + f"Station {station_id}: {risk} anomaly — " + f"max {max_dev:.2f} ft {direction} predictions" + ) + + result = { "station_id": station_id, "window_hours": window_hours, "threshold_ft": threshold_ft, @@ -499,3 +551,28 @@ def register(mcp: FastMCP) -> None: "direction": direction, "sample_count": len(deviations), } + + # Attempt to generate a concise risk summary via sampling + try: + anomaly_input = { + "station_id": station_id, + "risk_level": risk, + "max_deviation_ft": round(max_dev, 3), + "direction": direction, + "window_hours": window_hours, + } + sampling_result = await ctx.sample( + f"Summarize this water level anomaly in 2 sentences for a " + f"marine operator:\n{json.dumps(anomaly_input)}", + system_prompt=( + "You are a marine conditions monitor. Be concise and factual. " + "State the risk level, deviation magnitude, and recommended action." + ), + max_tokens=128, + ) + if sampling_result.text: + result["summary"] = sampling_result.text + except Exception: + pass # Client doesn't support sampling + + return result diff --git a/src/mcnoaa_tides/tools/stations.py b/src/mcnoaa_tides/tools/stations.py index 9baa430..210b49a 100644 --- a/src/mcnoaa_tides/tools/stations.py +++ b/src/mcnoaa_tides/tools/stations.py @@ -1,12 +1,15 @@ """Station discovery tools: search, proximity, and metadata lookup.""" from fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from mcnoaa_tides.client import NOAAClient +_ANNOTATIONS = ToolAnnotations(readOnlyHint=True, openWorldHint=True) + def register(mcp: FastMCP) -> None: - @mcp.tool(tags={"discovery"}) + @mcp.tool(tags={"discovery"}, annotations=_ANNOTATIONS) async def search_stations( ctx: Context, query: str = "", @@ -24,9 +27,13 @@ def register(mcp: FastMCP) -> None: """ noaa: NOAAClient = ctx.lifespan_context["noaa_client"] results = await noaa.search(query=query, state=state, is_tidal=is_tidal) + await ctx.debug( + f"search_stations: {len(results)} matches " + f"(query={query!r}, state={state!r}, is_tidal={is_tidal})" + ) return [s.model_dump() for s in results[:50]] - @mcp.tool(tags={"discovery"}) + @mcp.tool(tags={"discovery"}, annotations=_ANNOTATIONS) async def find_nearest_stations( ctx: Context, latitude: float, @@ -50,12 +57,17 @@ def register(mcp: FastMCP) -> None: results = await noaa.find_nearest( latitude, longitude, limit=limit, max_distance=max_distance_nm ) - return [ + out = [ {**station.model_dump(), "distance_nm": round(dist, 1)} for station, dist in results ] + await ctx.debug( + f"find_nearest_stations: {len(out)} stations within {max_distance_nm} nm " + f"of ({latitude}, {longitude})" + ) + return out - @mcp.tool(tags={"discovery"}) + @mcp.tool(tags={"discovery"}, annotations=_ANNOTATIONS) async def get_station_info( ctx: Context, station_id: str, @@ -69,4 +81,5 @@ def register(mcp: FastMCP) -> None: Example: get_station_info(station_id="8454000") for Providence, RI. """ noaa: NOAAClient = ctx.lifespan_context["noaa_client"] + await ctx.info(f"Fetching metadata for station {station_id}") return await noaa.get_station_metadata(station_id) diff --git a/src/mcnoaa_tides/tools/tides.py b/src/mcnoaa_tides/tools/tides.py index 9e424bc..c399a7b 100644 --- a/src/mcnoaa_tides/tools/tides.py +++ b/src/mcnoaa_tides/tools/tides.py @@ -1,12 +1,15 @@ """Tide prediction and observed water level tools.""" from fastmcp import Context, FastMCP +from mcp.types import ToolAnnotations from mcnoaa_tides.client import NOAAClient +_ANNOTATIONS = ToolAnnotations(readOnlyHint=True, openWorldHint=True) + def register(mcp: FastMCP) -> None: - @mcp.tool(tags={"tides"}) + @mcp.tool(tags={"tides"}, annotations=_ANNOTATIONS) async def get_tide_predictions( ctx: Context, station_id: str, @@ -30,6 +33,7 @@ def register(mcp: FastMCP) -> None: t = timestamp, v = water level (ft), type = "H" or "L" (hilo only). """ noaa: NOAAClient = ctx.lifespan_context["noaa_client"] + await ctx.info(f"Fetching {interval} predictions for station {station_id}") return await noaa.get_data( station_id, product="predictions", @@ -40,7 +44,7 @@ def register(mcp: FastMCP) -> None: datum=datum, ) - @mcp.tool(tags={"tides"}) + @mcp.tool(tags={"tides"}, annotations=_ANNOTATIONS) async def get_observed_water_levels( ctx: Context, station_id: str, @@ -59,7 +63,8 @@ def register(mcp: FastMCP) -> None: Quality flag "p" = preliminary, "v" = verified. """ noaa: NOAAClient = ctx.lifespan_context["noaa_client"] - return await noaa.get_data( + await ctx.info(f"Fetching observed water levels for station {station_id}") + result = await noaa.get_data( station_id, product="water_level", begin_date=begin_date, @@ -67,3 +72,11 @@ def register(mcp: FastMCP) -> None: hours=hours, datum=datum, ) + # Flag preliminary data — important quality signal for downstream consumers + preliminary = [r for r in result.get("data", []) if r.get("q") == "p"] + if preliminary: + await ctx.warning( + f"Station {station_id}: {len(preliminary)} of " + f"{len(result.get('data', []))} readings are preliminary (unverified)" + ) + return result diff --git a/tests/conftest.py b/tests/conftest.py index 9702731..474a4f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,15 @@ from fastmcp.utilities.tests import run_server_async from mcnoaa_tides import prompts, resources from mcnoaa_tides.client import NOAAClient -from mcnoaa_tides.tools import charts, conditions, meteorological, smartpot, stations, tides +from mcnoaa_tides.tools import ( + charts, + conditions, + diagnostics, + meteorological, + smartpot, + stations, + tides, +) # Realistic station fixtures MOCK_STATIONS_RAW = [ @@ -155,6 +163,7 @@ def _build_test_server() -> FastMCP: conditions.register(mcp) smartpot.register(mcp) charts.register(mcp) + diagnostics.register(mcp) resources.register(mcp) prompts.register(mcp) return mcp diff --git a/tests/test_charts.py b/tests/test_charts.py index fe235fe..9ea8023 100644 --- a/tests/test_charts.py +++ b/tests/test_charts.py @@ -170,6 +170,6 @@ async def test_visualization_tools_registered(mcp_client: Client): async def test_total_tool_count(mcp_client: Client): - """Verify total tool count (9 base + 4 SmartPot = 13).""" + """Verify total tool count (9 base + 4 SmartPot + 1 diagnostics = 14).""" tools = await mcp_client.list_tools() - assert len(tools) == 13 + assert len(tools) == 14 diff --git a/tests/test_smartpot.py b/tests/test_smartpot.py index 8eb0f03..a3b96a7 100644 --- a/tests/test_smartpot.py +++ b/tests/test_smartpot.py @@ -38,9 +38,9 @@ async def test_smartpot_tools_registered(mcp_client: Client): async def test_total_tool_count(mcp_client: Client): - """Should have 13 tools total (9 original + 4 SmartPot).""" + """Should have 14 tools total (9 original + 4 SmartPot + 1 diagnostics).""" tools = await mcp_client.list_tools() - assert len(tools) == 13 + assert len(tools) == 14 # --- tidal_phase --- diff --git a/tests/test_tools_stations.py b/tests/test_tools_stations.py index e5f0c48..5f6c1b7 100644 --- a/tests/test_tools_stations.py +++ b/tests/test_tools_stations.py @@ -6,7 +6,7 @@ from fastmcp import Client async def test_tool_registration(mcp_client: Client): - """All 13 tools should be registered (9 original + 4 SmartPot).""" + """All 14 tools should be registered (9 original + 4 SmartPot + 1 diagnostics).""" tools = await mcp_client.list_tools() tool_names = {t.name for t in tools} expected = { @@ -23,10 +23,21 @@ async def test_tool_registration(mcp_client: Client): "deployment_briefing", "catch_tidal_context", "water_level_anomaly", + "test_client_capabilities", } assert expected == tool_names +async def test_tool_annotations(mcp_client: Client): + """All tools should declare readOnlyHint and openWorldHint annotations.""" + tools = await mcp_client.list_tools() + for tool in tools: + assert tool.annotations is not None, f"{tool.name} missing annotations" + assert tool.annotations.readOnlyHint is True, ( + f"{tool.name} should have readOnlyHint=True" + ) + + async def test_search_stations_by_state(mcp_client: Client): result = await mcp_client.call_tool("search_stations", {"state": "RI"}) stations = json.loads(result.content[0].text)