Initial implementation: FastMCP 3.0 server wrapping NOAA CO-OPS API

7 tools: station search/nearest/info, tide predictions/observations,
meteorological data (Literal selector for 8 products), and parallel
marine conditions snapshot. 3 resources, 2 prompts, full test suite.
This commit is contained in:
Ryan Malloy 2026-02-21 21:04:03 -07:00
commit 6c244b3a63
17 changed files with 2280 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.eggs/
*.egg
.venv/
.env
.ruff_cache/
.pytest_cache/
.mypy_cache/
*.so

8
.mcp.json Normal file
View File

@ -0,0 +1,8 @@
{
"mcpServers": {
"noaa-tides": {
"command": "uv",
"args": ["run", "--directory", "/home/rpm/claude/mat/noaa-tides", "noaa-tides"]
}
}
}

31
pyproject.toml Normal file
View File

@ -0,0 +1,31 @@
[project]
name = "noaa-tides"
version = "2026.02.21"
description = "FastMCP server for NOAA CO-OPS Tides and Currents API"
authors = [{ name = "Ryan Malloy", email = "ryan@supported.systems" }]
requires-python = ">=3.12"
dependencies = ["fastmcp>=3.0.1"]
license = "MIT"
[project.scripts]
noaa-tides = "noaa_tides.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/noaa_tides"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "W"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.9"]

View File

@ -0,0 +1,6 @@
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("noaa-tides")
except PackageNotFoundError:
__version__ = "0.0.0-dev"

158
src/noaa_tides/client.py Normal file
View File

@ -0,0 +1,158 @@
"""Async NOAA CO-OPS API client with station caching and proximity search."""
import math
import time
import httpx
from noaa_tides.models import Station
DATA_URL = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
META_URL = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi"
CACHE_TTL = 86400 # 24 hours
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distance in nautical miles between two coordinates."""
R = 3440.065 # Earth radius in nautical miles
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
class NOAAClient:
"""Async client wrapping NOAA CO-OPS data and metadata APIs.
Caches the station catalog in memory (~301 entries) and refreshes every 24 hours.
The httpx.AsyncClient is created once and reused for connection pooling.
"""
def __init__(self) -> None:
self._http: httpx.AsyncClient | None = None
self._stations: list[Station] = []
self._cache_time: float = 0
async def initialize(self) -> None:
self._http = httpx.AsyncClient(timeout=30)
await self._refresh_stations()
async def close(self) -> None:
if self._http:
await self._http.aclose()
# -- Station cache --
async def _refresh_stations(self) -> None:
resp = await self._http.get(f"{META_URL}/stations.json")
resp.raise_for_status()
data = resp.json()
self._stations = [Station(**s) for s in data.get("stations", [])]
self._cache_time = time.monotonic()
async def get_stations(self) -> list[Station]:
if time.monotonic() - self._cache_time > CACHE_TTL:
await self._refresh_stations()
return list(self._stations)
# -- Metadata API --
async def get_station_metadata(self, station_id: str) -> dict:
resp = await self._http.get(
f"{META_URL}/stations/{station_id}.json",
params={"expand": "details,sensors,datums,products,disclaimers"},
)
resp.raise_for_status()
data = resp.json()
# The metadata API wraps the station in a "stations" list
stations = data.get("stations", [])
if stations:
return stations[0]
return data
# -- Data API --
async def get_data(
self,
station_id: str,
product: str,
begin_date: str = "",
end_date: str = "",
hours: int = 0,
datum: str = "MLLW",
interval: str = "",
units: str = "english",
time_zone: str = "lst_lte",
) -> dict:
"""Fetch data from the NOAA CO-OPS data API.
Date format: yyyyMMdd or yyyyMMdd HH:mm
If no date range or hours specified, defaults to last 24 hours.
"""
params: dict[str, str] = {
"station": station_id,
"product": product,
"datum": datum,
"units": units,
"time_zone": time_zone,
"format": "json",
"application": "noaa-tides-mcp",
}
if begin_date:
params["begin_date"] = begin_date
if end_date:
params["end_date"] = end_date
if hours:
params["range"] = str(hours)
if interval:
params["interval"] = interval
# Default to last 24h if no date range specified
if not begin_date and not end_date and not hours:
params["range"] = "24"
resp = await self._http.get(DATA_URL, params=params)
resp.raise_for_status()
result = resp.json()
if "error" in result:
raise ValueError(result["error"].get("message", "Unknown NOAA API error"))
return result
# -- In-memory search --
def search(
self,
query: str = "",
state: str = "",
is_tidal: bool | None = None,
) -> list[Station]:
matches = self._stations
if query:
q = query.lower()
matches = [s for s in matches if q in s.name.lower() or q in s.id]
if state:
st = state.upper()
matches = [s for s in matches if s.state and s.state.upper() == st]
if is_tidal is not None:
matches = [s for s in matches if s.tidal == is_tidal]
return matches
def find_nearest(
self,
lat: float,
lon: float,
limit: int = 5,
max_distance: float = 100,
) -> list[tuple[Station, float]]:
"""Return stations within max_distance nautical miles, sorted by proximity."""
results: list[tuple[Station, float]] = []
for station in self._stations:
dist = haversine(lat, lon, station.lat, station.lng)
if dist <= max_distance:
results.append((station, dist))
results.sort(key=lambda x: x[1])
return results[:limit]

47
src/noaa_tides/models.py Normal file
View File

@ -0,0 +1,47 @@
"""Pydantic models for NOAA CO-OPS API responses."""
from pydantic import BaseModel, Field
class Station(BaseModel):
id: str
name: str
state: str = ""
lat: float
lng: float
tidal: bool = True
greatlakes: bool = False
shefcode: str = ""
class TidePrediction(BaseModel):
t: str = Field(description="Timestamp (local station time)")
v: str = Field(description="Water level in feet")
type: str | None = Field(
None, description="H (high) or L (low) — only present for hilo interval"
)
class WaterLevelReading(BaseModel):
t: str = Field(description="Timestamp")
v: str = Field(description="Water level in feet")
s: str = Field("", description="Sigma (standard deviation)")
f: str = Field("", description="Data quality flags (comma-separated)")
q: str = Field("", description="Quality assurance level")
class WindReading(BaseModel):
t: str = Field(description="Timestamp")
s: str = Field(description="Speed in knots")
d: str = Field(description="Direction in degrees true")
dr: str = Field(description="Compass direction (N, NE, SW, etc.)")
g: str = Field(description="Gust speed in knots")
f: str = Field("", description="Data quality flags")
class MetReading(BaseModel):
"""Generic meteorological reading (temperature, pressure, conductivity, etc.)."""
t: str = Field(description="Timestamp")
v: str = Field(description="Value")
f: str = Field("", description="Data quality flags")

78
src/noaa_tides/prompts.py Normal file
View File

@ -0,0 +1,78 @@
"""MCP prompt templates for marine planning workflows."""
from fastmcp import FastMCP
def register(mcp: FastMCP) -> None:
@mcp.prompt()
def plan_fishing_trip(
location: str,
target_species: str = "",
date: str = "",
) -> str:
"""Guided fishing trip planning using tide and weather data.
Walks through station discovery, tide analysis, and conditions assessment
to recommend optimal fishing windows.
"""
species_note = f"Target species: {target_species}" if target_species else ""
date_note = f"Date: {date}" if date else "Date: today"
return f"""Plan a fishing trip based on marine conditions near {location}.
{species_note}
{date_note}
Workflow:
1. Use find_nearest_stations to locate NOAA tide stations near {location}.
Pick the closest station that supports tide predictions.
2. Use get_tide_predictions to find the next high and low tides.
Incoming (rising) tide is generally better for most species
it pushes bait and forage into estuaries and along structure.
3. Use marine_conditions_snapshot to check current conditions:
- Wind: sustained >15 kn makes small-boat fishing uncomfortable
- Water temperature: affects species activity and feeding patterns
- Pressure trend: falling pressure often improves bite rates
4. Recommend a fishing window that lines up favorable tide phase
with manageable weather. Include:
- Best tide window (time range)
- Expected water level at target time
- Weather summary (wind, temp, pressure)
- Any safety concerns
"""
@mcp.prompt()
def marine_safety_check(station_id: str) -> str:
"""Go/no-go safety assessment for a marine station.
Evaluates current conditions against common safety thresholds
for recreational boating and fishing.
"""
return f"""Perform a marine safety assessment for station {station_id}.
Use marine_conditions_snapshot to fetch current conditions, then evaluate:
GO / NO-GO criteria:
Wind:
GO sustained < 15 kn, gusts < 20 kn
CAUTION sustained 15-20 kn or gusts 20-25 kn
NO-GO sustained > 20 kn or gusts > 25 kn
Visibility:
GO > 2 nm
CAUTION 1-2 nm
NO-GO < 1 nm
Water temperature (hypothermia risk if capsized):
LOW RISK > 70 F
MODERATE 60-70 F (wear PFD, limit exposure)
HIGH RISK < 60 F (survival suit or dry suit recommended)
Pressure trend:
Rapidly falling pressure (> 3 mb/3hr) suggests approaching storm
Provide a clear GO / CAUTION / NO-GO recommendation with reasoning.
If any single factor is NO-GO, the overall assessment should be NO-GO.
"""

View File

@ -0,0 +1,43 @@
"""MCP resources: station catalog, station detail, nearby stations."""
import json
from fastmcp import Context, FastMCP
from noaa_tides.client import NOAAClient
def register(mcp: FastMCP) -> None:
@mcp.resource("noaa://stations")
async def station_catalog(ctx: Context) -> str:
"""Full NOAA tide station catalog. ~301 stations with id, name, state, coordinates."""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
stations = await noaa.get_stations()
return json.dumps(
[s.model_dump() for s in stations],
indent=2,
)
@mcp.resource("noaa://stations/{station_id}")
async def station_detail(station_id: str, ctx: Context) -> str:
"""Expanded metadata for a single station including sensors, datums, and products."""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
metadata = await noaa.get_station_metadata(station_id)
return json.dumps(metadata, indent=2)
@mcp.resource("noaa://stations/{station_id}/nearby")
async def nearby_stations(station_id: str, ctx: Context) -> str:
"""Stations within 50 nm of the given station, sorted by distance."""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
stations = await noaa.get_stations()
target = next((s for s in stations if s.id == station_id), None)
if not target:
return json.dumps({"error": f"Station {station_id} not found"})
nearby = noaa.find_nearest(target.lat, target.lng, limit=10, max_distance=50)
# Exclude the station itself
nearby = [(s, d) for s, d in nearby if s.id != station_id]
return json.dumps(
[{**s.model_dump(), "distance_nm": round(d, 1)} for s, d in nearby],
indent=2,
)

48
src/noaa_tides/server.py Normal file
View File

@ -0,0 +1,48 @@
"""FastMCP server for NOAA CO-OPS Tides and Currents API."""
import sys
from contextlib import asynccontextmanager
from fastmcp import FastMCP
from noaa_tides import __version__, prompts, resources
from noaa_tides.client import NOAAClient
from noaa_tides.tools import conditions, meteorological, stations, tides
@asynccontextmanager
async def lifespan(server: FastMCP):
"""Manage the NOAAClient lifecycle — create, pre-warm station cache, close."""
client = NOAAClient()
await client.initialize()
try:
yield {"noaa_client": client}
finally:
await client.close()
mcp = FastMCP(
"noaa-tides",
instructions=(
"NOAA Tides & Currents data server. "
"Provides tide predictions, observed water levels, and meteorological data "
"for ~301 U.S. coastal stations. Start with station discovery tools, "
"then fetch predictions or observations by station ID."
),
lifespan=lifespan,
)
# Register tool modules
stations.register(mcp)
tides.register(mcp)
meteorological.register(mcp)
conditions.register(mcp)
# Register resources and prompts
resources.register(mcp)
prompts.register(mcp)
def main():
print(f"noaa-tides v{__version__}", file=sys.stderr)
mcp.run()

View File

View File

@ -0,0 +1,76 @@
"""Marine conditions snapshot — parallel multi-product fetch."""
import asyncio
from datetime import datetime, timezone
from fastmcp import Context, FastMCP
from noaa_tides.client import NOAAClient
def register(mcp: FastMCP) -> None:
@mcp.tool(tags={"planning"})
async def marine_conditions_snapshot(
ctx: Context,
station_id: str,
hours: int = 24,
) -> dict:
"""Get a comprehensive marine conditions snapshot in a single call.
Fetches tide predictions, observed water levels, and meteorological data
in parallel (6 API calls). Products that aren't available at a station
are reported under "unavailable" rather than failing the whole request.
Returns:
station_id, timestamp, and one key per product:
predictions high/low tide times
water_level recent observed levels
water_temperature, air_temperature, wind, air_pressure
This is the best starting point for trip planning or safety checks.
"""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
today = datetime.now(timezone.utc).strftime("%Y%m%d")
# Define the product requests — predictions get hilo + begin_date for future data
requests = {
"predictions": {
"product": "predictions",
"interval": "hilo",
"begin_date": today,
"hours": hours,
},
"water_level": {"product": "water_level", "hours": hours},
"water_temperature": {"product": "water_temperature", "hours": hours},
"air_temperature": {"product": "air_temperature", "hours": hours},
"wind": {"product": "wind", "hours": hours},
"air_pressure": {"product": "air_pressure", "hours": hours},
}
async def fetch(name: str, params: dict) -> tuple[str, dict | str]:
try:
data = await noaa.get_data(station_id, **params)
return name, data
except Exception as exc:
return name, str(exc)
results = await asyncio.gather(
*[fetch(name, params) for name, params in requests.items()]
)
snapshot: dict = {
"station_id": station_id,
"fetched_utc": datetime.now(timezone.utc).isoformat(),
}
unavailable: dict[str, str] = {}
for name, data in results:
if isinstance(data, str):
unavailable[name] = data
else:
snapshot[name] = data
if unavailable:
snapshot["unavailable"] = unavailable
return snapshot

View File

@ -0,0 +1,55 @@
"""Meteorological data tool with Literal product selector."""
from typing import Literal
from fastmcp import Context, FastMCP
from noaa_tides.client import NOAAClient
MetProduct = Literal[
"air_temperature",
"water_temperature",
"wind",
"air_pressure",
"conductivity",
"visibility",
"humidity",
"salinity",
]
def register(mcp: FastMCP) -> None:
@mcp.tool(tags={"weather"})
async def get_meteorological_data(
ctx: Context,
station_id: str,
product: MetProduct,
begin_date: str = "",
end_date: str = "",
hours: int = 24,
) -> dict:
"""Get meteorological observations from a NOAA station.
Select one product at a time. Not all stations support all products
use get_station_info first to check available sensors.
Products and their response fields:
air_temperature t, v (deg F), f (flags)
water_temperature t, v (deg F), f
wind t, s (speed kn), d (dir deg), dr (compass), g (gust kn), f
air_pressure t, v (millibars), f
conductivity t, v (mS/cm), f
visibility t, v (nautical miles), f
humidity t, v (percent), f
salinity t, v (PSU), f
Date format: yyyyMMdd or "yyyyMMdd HH:mm".
"""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
return await noaa.get_data(
station_id,
product=product,
begin_date=begin_date,
end_date=end_date,
hours=hours,
)

View File

@ -0,0 +1,68 @@
"""Station discovery tools: search, proximity, and metadata lookup."""
from fastmcp import Context, FastMCP
from noaa_tides.client import NOAAClient
def register(mcp: FastMCP) -> None:
@mcp.tool(tags={"discovery"})
async def search_stations(
ctx: Context,
query: str = "",
state: str = "",
is_tidal: bool | None = None,
) -> list[dict]:
"""Search NOAA tide stations by name, state abbreviation, or tidal flag.
Examples:
- search_stations(query="providence") match by name
- search_stations(state="RI") all Rhode Island stations
- search_stations(state="WA", is_tidal=True) tidal stations in Washington
Returns up to 50 matching stations with id, name, state, coordinates, and tidal status.
"""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
results = noaa.search(query=query, state=state, is_tidal=is_tidal)
return [s.model_dump() for s in results[:50]]
@mcp.tool(tags={"discovery"})
async def find_nearest_stations(
ctx: Context,
latitude: float,
longitude: float,
limit: int = 5,
max_distance_nm: float = 100,
) -> list[dict]:
"""Find the nearest NOAA tide stations to a coordinate.
Distances are in nautical miles useful for marine planning.
Default searches within 100 nm and returns the 5 closest stations.
Example: find_nearest_stations(latitude=41.49, longitude=-71.32)
for stations near Narragansett Bay.
"""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
results = noaa.find_nearest(
latitude, longitude, limit=limit, max_distance=max_distance_nm
)
return [
{**station.model_dump(), "distance_nm": round(dist, 1)}
for station, dist in results
]
@mcp.tool(tags={"discovery"})
async def get_station_info(
ctx: Context,
station_id: str,
) -> dict:
"""Get detailed metadata for a specific NOAA station.
Returns expanded info including available products, sensors, datums,
and station details. Use this to verify what data a station supports
before requesting observations or predictions.
Example: get_station_info(station_id="8454000") for Providence, RI.
"""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
return await noaa.get_station_metadata(station_id)

View File

@ -0,0 +1,69 @@
"""Tide prediction and observed water level tools."""
from fastmcp import Context, FastMCP
from noaa_tides.client import NOAAClient
def register(mcp: FastMCP) -> None:
@mcp.tool(tags={"tides"})
async def get_tide_predictions(
ctx: Context,
station_id: str,
begin_date: str = "",
end_date: str = "",
hours: int = 48,
interval: str = "hilo",
datum: str = "MLLW",
) -> dict:
"""Get tide predictions for a station.
Defaults to high/low (hilo) predictions for the next 48 hours.
This is the most useful interval for fishing and trip planning
shows when tides turn rather than 6-minute incremental data.
Intervals: "hilo" (high/low times), "h" (hourly), "6" (6-minute).
Datum: "MLLW" (mean lower low water), "MSL", "NAVD", "STND", etc.
Date format: yyyyMMdd or "yyyyMMdd HH:mm".
Response contains 'predictions' array with fields:
t = timestamp, v = water level (ft), type = "H" or "L" (hilo only).
"""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
return await noaa.get_data(
station_id,
product="predictions",
begin_date=begin_date,
end_date=end_date,
hours=hours,
interval=interval,
datum=datum,
)
@mcp.tool(tags={"tides"})
async def get_observed_water_levels(
ctx: Context,
station_id: str,
begin_date: str = "",
end_date: str = "",
hours: int = 24,
datum: str = "MLLW",
) -> dict:
"""Get observed (actual) water level readings from a station.
Returns 6-minute interval observations from the last 24 hours by default.
Compare with predictions to see how actual conditions differ from forecast.
Response contains 'data' array with fields:
t = timestamp, v = water level (ft), s = sigma, f = quality flags, q = QA level.
Quality flag "p" = preliminary, "v" = verified.
"""
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
return await noaa.get_data(
station_id,
product="water_level",
begin_date=begin_date,
end_date=end_date,
hours=hours,
datum=datum,
)

153
tests/conftest.py Normal file
View File

@ -0,0 +1,153 @@
"""Test fixtures with mock NOAAClient injected via lifespan."""
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock
import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
from noaa_tides import prompts, resources
from noaa_tides.client import NOAAClient
from noaa_tides.tools import conditions, meteorological, stations, tides
# Realistic station fixtures
MOCK_STATIONS_RAW = [
{
"id": "8454000",
"name": "Providence",
"state": "RI",
"lat": 41.8071,
"lng": -71.4012,
"tidal": True,
"greatlakes": False,
"shefcode": "PRVD1",
},
{
"id": "8452660",
"name": "Newport",
"state": "RI",
"lat": 41.5043,
"lng": -71.3261,
"tidal": True,
"greatlakes": False,
"shefcode": "NWPR1",
},
{
"id": "8447930",
"name": "Woods Hole",
"state": "MA",
"lat": 41.5236,
"lng": -70.6714,
"tidal": True,
"greatlakes": False,
"shefcode": "WHOM3",
},
]
MOCK_PREDICTIONS = {
"predictions": [
{"t": "2026-02-21 04:30", "v": "4.521", "type": "H"},
{"t": "2026-02-21 10:42", "v": "-0.123", "type": "L"},
{"t": "2026-02-21 16:55", "v": "5.012", "type": "H"},
{"t": "2026-02-21 23:08", "v": "0.234", "type": "L"},
]
}
MOCK_WATER_LEVEL = {
"data": [
{"t": "2026-02-21 00:00", "v": "2.34", "s": "0.003", "f": "0,0,0,0", "q": "p"},
{"t": "2026-02-21 00:06", "v": "2.38", "s": "0.003", "f": "0,0,0,0", "q": "p"},
]
}
MOCK_WIND = {
"data": [
{
"t": "2026-02-21 00:00",
"s": "12.5",
"d": "225.00",
"dr": "SW",
"g": "18.2",
"f": "0,0",
}
]
}
MOCK_AIR_TEMP = {"data": [{"t": "2026-02-21 00:00", "v": "42.3", "f": "0,0,0"}]}
MOCK_WATER_TEMP = {"data": [{"t": "2026-02-21 00:00", "v": "38.7", "f": "0,0,0"}]}
MOCK_PRESSURE = {"data": [{"t": "2026-02-21 00:00", "v": "1013.2", "f": "0,0,0"}]}
MOCK_METADATA = {
"stations": [
{
"id": "8454000",
"name": "Providence",
"state": "RI",
"lat": 41.8071,
"lng": -71.4012,
"tidal": True,
"sensors": [{"name": "Water Level"}, {"name": "Air Temperature"}],
"products": {"self": "...", "tidePredictions": "..."},
"datums": {"datums": [{"name": "MLLW", "value": "0.0"}]},
}
]
}
def _build_mock_client() -> NOAAClient:
"""Build a NOAAClient with mocked HTTP but real search/nearest logic."""
from noaa_tides.models import Station
client = NOAAClient()
client._stations = [Station(**s) for s in MOCK_STATIONS_RAW]
client._cache_time = float("inf") # Never expires
client._http = AsyncMock()
async def mock_get_data(station_id, product, **kwargs):
responses = {
"predictions": MOCK_PREDICTIONS,
"water_level": MOCK_WATER_LEVEL,
"wind": MOCK_WIND,
"air_temperature": MOCK_AIR_TEMP,
"water_temperature": MOCK_WATER_TEMP,
"air_pressure": MOCK_PRESSURE,
}
if product in responses:
return responses[product]
raise ValueError(f"No data was found for product: {product}")
client.get_data = mock_get_data
async def mock_get_station_metadata(station_id):
return MOCK_METADATA["stations"][0]
client.get_station_metadata = mock_get_station_metadata
return client
@asynccontextmanager
async def _test_lifespan(server: FastMCP):
client = _build_mock_client()
yield {"noaa_client": client}
def _build_test_server() -> FastMCP:
mcp = FastMCP("noaa-tides-test", lifespan=_test_lifespan)
stations.register(mcp)
tides.register(mcp)
meteorological.register(mcp)
conditions.register(mcp)
resources.register(mcp)
prompts.register(mcp)
return mcp
@pytest.fixture
async def mcp_client():
server = _build_test_server()
async with run_server_async(server) as url:
async with Client(StreamableHttpTransport(url)) as client:
yield client

View File

@ -0,0 +1,139 @@
"""Tests for station discovery and data retrieval tools."""
import json
from fastmcp import Client
async def test_tool_registration(mcp_client: Client):
"""All 7 tools should be registered."""
tools = await mcp_client.list_tools()
tool_names = {t.name for t in tools}
expected = {
"search_stations",
"find_nearest_stations",
"get_station_info",
"get_tide_predictions",
"get_observed_water_levels",
"get_meteorological_data",
"marine_conditions_snapshot",
}
assert expected == tool_names
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)
assert len(stations) == 2
assert all(s["state"] == "RI" for s in stations)
async def test_search_stations_by_name(mcp_client: Client):
result = await mcp_client.call_tool("search_stations", {"query": "providence"})
stations = json.loads(result.content[0].text)
assert len(stations) == 1
assert stations[0]["id"] == "8454000"
async def test_search_stations_no_match(mcp_client: Client):
result = await mcp_client.call_tool("search_stations", {"query": "nonexistent"})
# FastMCP may return empty content list for empty results
if result.content:
stations = json.loads(result.content[0].text)
assert len(stations) == 0
else:
# Empty content means no matches — that's correct
pass
async def test_find_nearest_stations(mcp_client: Client):
# Search near Providence coordinates
result = await mcp_client.call_tool(
"find_nearest_stations",
{"latitude": 41.8, "longitude": -71.4},
)
stations = json.loads(result.content[0].text)
assert len(stations) >= 1
# Closest should be Providence
assert stations[0]["id"] == "8454000"
assert "distance_nm" in stations[0]
# Should be sorted by distance
distances = [s["distance_nm"] for s in stations]
assert distances == sorted(distances)
async def test_get_station_info(mcp_client: Client):
result = await mcp_client.call_tool("get_station_info", {"station_id": "8454000"})
info = json.loads(result.content[0].text)
assert info["id"] == "8454000"
assert info["name"] == "Providence"
assert "sensors" in info
async def test_get_tide_predictions(mcp_client: Client):
result = await mcp_client.call_tool(
"get_tide_predictions", {"station_id": "8454000"}
)
data = json.loads(result.content[0].text)
assert "predictions" in data
preds = data["predictions"]
assert len(preds) == 4
assert preds[0]["type"] == "H"
assert preds[1]["type"] == "L"
async def test_get_observed_water_levels(mcp_client: Client):
result = await mcp_client.call_tool(
"get_observed_water_levels", {"station_id": "8454000"}
)
data = json.loads(result.content[0].text)
assert "data" in data
assert len(data["data"]) == 2
async def test_get_meteorological_data_wind(mcp_client: Client):
result = await mcp_client.call_tool(
"get_meteorological_data",
{"station_id": "8454000", "product": "wind"},
)
data = json.loads(result.content[0].text)
assert "data" in data
assert data["data"][0]["dr"] == "SW"
async def test_marine_conditions_snapshot(mcp_client: Client):
result = await mcp_client.call_tool(
"marine_conditions_snapshot", {"station_id": "8454000"}
)
snapshot = json.loads(result.content[0].text)
assert snapshot["station_id"] == "8454000"
assert "fetched_utc" in snapshot
assert "predictions" in snapshot
assert "water_level" in snapshot
assert "wind" in snapshot
async def test_resource_registration(mcp_client: Client):
"""Resources should be registered."""
resources = await mcp_client.list_resources()
uris = {str(r.uri) for r in resources}
assert "noaa://stations" in uris
async def test_prompt_registration(mcp_client: Client):
"""Prompts should be registered."""
prompts = await mcp_client.list_prompts()
prompt_names = {p.name for p in prompts}
assert "plan_fishing_trip" in prompt_names
assert "marine_safety_check" in prompt_names
async def test_prompt_plan_fishing_trip(mcp_client: Client):
result = await mcp_client.get_prompt(
"plan_fishing_trip", {"location": "Narragansett Bay"}
)
assert len(result.messages) >= 1
text = result.messages[0].content
if hasattr(text, "text"):
text = text.text
assert "Narragansett Bay" in str(text)

1287
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff