Compare commits

...

No commits in common. "b928fcef1dd5a103d374c9fe5bed153bc46126ec" and "9f6d7bb4ac3b7b0a74c01c7faf3271a6c856df92" have entirely different histories.

29 changed files with 4593 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": {
"mcnoaa-tides": {
"command": "uv",
"args": ["run", "--directory", "/home/rpm/claude/mat/noaa-tides", "mcnoaa-tides"]
}
}
}

282
README.md
View File

@ -1,2 +1,284 @@
# mcnoaa-tides # mcnoaa-tides
MCP server for [NOAA CO-OPS Tides and Currents](https://tidesandcurrents.noaa.gov/). Exposes tide predictions, observed water levels, and meteorological data from ~301 U.S. coastal stations as tools, resources, and prompts via [FastMCP 3.0](https://gofastmcp.com/).
Built for marine planning — fishing trips, boating, crabbing, safety checks.
## Install
```bash
# Run directly (no install needed)
uvx mcnoaa-tides
# Or add to Claude Code
claude mcp add mcnoaa-tides -- uvx mcnoaa-tides
# With visualization support (charts)
uv pip install mcnoaa-tides[viz]
# Local development
uv run mcnoaa-tides
```
## Tools
### `search_stations` — Find stations by name, state, or type
```
search_stations(state="WA")
```
Returns up to 50 matching stations:
```json
[
{"id": "9447130", "name": "Seattle", "state": "WA", "lat": 47.6026, "lng": -122.3393, "tidal": true},
{"id": "9446484", "name": "Tacoma", "state": "WA", "lat": 47.2671, "lng": -122.4132, "tidal": true},
{"id": "9444900", "name": "Port Townsend", "state": "WA", "lat": 48.1129, "lng": -122.7595, "tidal": true}
]
```
Also supports `query` (name search) and `is_tidal` (filter tidal vs non-tidal).
### `find_nearest_stations` — Proximity search by coordinates
```
find_nearest_stations(latitude=47.6, longitude=-122.34, limit=3)
```
Distances in nautical miles:
```json
[
{"id": "9447130", "name": "Seattle", "state": "WA", "lat": 47.6026, "lng": -122.3393, "distance_nm": 0.2},
{"id": "9446484", "name": "Tacoma", "state": "WA", "lat": 47.2671, "lng": -122.4132, "distance_nm": 20.1},
{"id": "9445958", "name": "Bremerton", "state": "WA", "lat": 47.5615, "lng": -122.6225, "distance_nm": 14.7}
]
```
### `get_station_info` — Expanded station metadata
```
get_station_info(station_id="9447130")
```
Returns sensors, datums, products, and station details. Seattle has been operating since 1899.
<details>
<summary>Full response (194 lines)</summary>
See [`examples/station-detail-seattle.json`](examples/station-detail-seattle.json)
</details>
### `get_tide_predictions` — High/low tide times
```
get_tide_predictions(station_id="9447130", hours=24)
```
Defaults to `hilo` interval (high/low times only) — the most useful format for planning:
```json
{
"predictions": [
{"t": "2026-02-21 00:49", "v": "2.658", "type": "L"},
{"t": "2026-02-21 07:08", "v": "12.261", "type": "H"},
{"t": "2026-02-21 13:43", "v": "1.167", "type": "L"},
{"t": "2026-02-21 19:54", "v": "9.857", "type": "H"}
]
}
```
`type`: `H` = high tide, `L` = low tide. Values in feet above MLLW.
Other intervals: `"h"` (hourly), `"6"` (6-minute). Datum options: `MLLW`, `MSL`, `NAVD`, `STND`.
### `get_observed_water_levels` — Actual readings
```
get_observed_water_levels(station_id="9447130", hours=3)
```
6-minute interval observations. Compare with predictions to see how reality diverges:
```json
{
"metadata": {"id": "9447130", "name": "Seattle", "lat": "47.6026", "lon": "-122.3393"},
"data": [
{"t": "2026-02-21 17:24", "v": "7.41", "s": "0.059", "f": "0,0,0,0", "q": "p"},
{"t": "2026-02-21 17:30", "v": "7.62", "s": "0.072", "f": "0,0,0,0", "q": "p"},
{"t": "2026-02-21 17:36", "v": "7.803", "s": "0.069", "f": "0,0,0,0", "q": "p"}
]
}
```
`q`: `"p"` = preliminary (real-time), `"v"` = verified (post-processed). `s` = standard deviation.
<details>
<summary>Full 3-hour response (213 lines)</summary>
See [`examples/water-levels-seattle.json`](examples/water-levels-seattle.json)
</details>
### `get_meteorological_data` — Weather observations
One tool, 8 products. Select via `product` parameter:
| Product | Fields | Units |
|---------|--------|-------|
| `air_temperature` | `v` | deg F |
| `water_temperature` | `v` | deg F |
| `wind` | `s` (speed), `d` (dir deg), `dr` (compass), `g` (gust) | knots |
| `air_pressure` | `v` | millibars |
| `conductivity` | `v` | mS/cm |
| `visibility` | `v` | nautical miles |
| `humidity` | `v` | percent |
| `salinity` | `v` | PSU |
```
get_meteorological_data(station_id="8454000", product="wind", hours=1)
```
```json
{
"metadata": {"id": "8454000", "name": "Providence", "lat": "41.8072", "lon": "-71.4007"},
"data": [
{"t": "2026-02-21 22:24", "s": "2.72", "d": "109.0", "dr": "ESE", "g": "3.3", "f": "0,0"},
{"t": "2026-02-21 22:30", "s": "3.3", "d": "103.0", "dr": "ESE", "g": "3.89", "f": "0,0"},
{"t": "2026-02-21 22:36", "s": "2.72", "d": "108.0", "dr": "ESE", "g": "3.5", "f": "0,0"}
]
}
```
Not all stations support all products — use `get_station_info` to check available sensors.
### `marine_conditions_snapshot` — Everything at once
```
marine_conditions_snapshot(station_id="9447130")
```
Fires 6 parallel API calls (predictions, water level, water temp, air temp, wind, pressure) and returns a combined snapshot. Products that aren't available at a station appear under `unavailable` instead of failing the whole request:
```json
{
"station_id": "9447130",
"fetched_utc": "2026-02-22T04:15:38.291Z",
"predictions": {"predictions": [{"t": "...", "v": "12.261", "type": "H"}, "..."]},
"water_level": {"data": [{"t": "...", "v": "10.22", "s": "0.053", "f": "0,0,0,0", "q": "p"}, "..."]},
"air_pressure": {"data": [{"t": "...", "v": "1012.4", "f": "0,0,0"}, "..."]},
"unavailable": {
"water_temperature": "ValueError: No data was found...",
"air_temperature": "ValueError: No data was found...",
"wind": "ValueError: No data was found..."
}
}
```
Best starting point for trip planning or safety assessments.
### `visualize_tides` — Tide prediction chart
```
visualize_tides(station_id="9447130", hours=48, format="png")
```
Generates a tide prediction chart with the water level curve, high/low markers, and optional observed water level overlay. PNG returns an inline image via MCP; HTML saves an interactive Plotly chart to `artifacts/charts/`.
![Seattle tide predictions — 48-hour forecast with predicted curve (blue), observed overlay (teal dashed), and high/low markers](examples/tide-chart-seattle.png)
Parameters:
- `hours` — forecast window (default 48)
- `include_observed` — overlay actual readings (default true)
- `format``"png"` (inline image) or `"html"` (interactive file)
Requires `mcnoaa-tides[viz]` — install with `uv pip install mcnoaa-tides[viz]`.
### `visualize_conditions` — Multi-panel conditions dashboard
```
visualize_conditions(station_id="9447130", hours=24, format="png")
```
Generates a multi-panel dashboard with up to 4 panels: tide predictions + observed overlay, wind speed/gust, air/water temperature, and barometric pressure with trend indicator. Products unavailable at a station are omitted.
![Seattle marine conditions — 4-panel dashboard showing tides, wind, temperature, and barometric pressure](examples/conditions-dashboard-seattle.png)
Parameters:
- `hours` — data window (default 24)
- `format``"png"` (inline image) or `"html"` (interactive file)
Requires `mcnoaa-tides[viz]`.
## Resources
| URI | Description |
|-----|-------------|
| `noaa://stations` | Full station catalog (~301 stations) |
| `noaa://stations/{station_id}` | Expanded metadata for one station |
| `noaa://stations/{station_id}/nearby` | Stations within 50 nm |
## Prompts
### `plan_fishing_trip`
Guides the LLM through station discovery, tide analysis, and weather assessment to recommend optimal fishing windows.
```
plan_fishing_trip(location="Narragansett Bay", target_species="striped bass", date="20260615")
```
### `marine_safety_check`
GO / CAUTION / NO-GO assessment based on wind, visibility, water temperature, and pressure trends.
```
marine_safety_check(station_id="9447130")
```
## Response field reference
| Field | Meaning |
|-------|---------|
| `t` | Timestamp (local station time) |
| `v` | Value (water level ft, temp F, pressure mb, etc.) |
| `type` | `H` (high) or `L` (low) — predictions only |
| `s` | Speed (wind, knots) or sigma (water level, std deviation) |
| `d` | Wind direction in degrees true |
| `dr` | Wind compass direction (N, NE, SW, etc.) |
| `g` | Wind gust speed (knots) |
| `f` | Data quality flags (comma-separated) |
| `q` | QA level: `p` = preliminary, `v` = verified |
## Development
```bash
git clone <repo-url> && cd mcnoaa-tides
uv sync --dev
# Run tests (mock client, no network)
uv run pytest tests/ -v
# Lint
uv run ruff check src/
# Start server locally
uv run mcnoaa-tides
# Headless test with Claude
claude -p "Search for tide stations in Rhode Island" \
--mcp-config .mcp.json \
--allowedTools "mcp__mcnoaa-tides__*"
```
## Data source
All data from [NOAA Center for Operational Oceanographic Products and Services (CO-OPS)](https://tidesandcurrents.noaa.gov/). No API key required. Station IDs are 7-digit numbers (e.g. `9447130` for Seattle, `8454000` for Providence).
Two separate APIs are used:
- **Data API** — observations and predictions (`api.tidesandcurrents.noaa.gov/api/prod/datagetter`)
- **Metadata API** — station info, sensors, datums (`api.tidesandcurrents.noaa.gov/mdapi/prod/webapi`)
## License
MIT

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

@ -0,0 +1,194 @@
{
"count": 1,
"units": null,
"stations": [
{
"tidal": true,
"greatlakes": false,
"shefcode": "EBSW1",
"details": {
"id": "9447130",
"established": "1899-01-01 00:00:00",
"removed": "",
"noaachart": "18450",
"timemeridian": -120,
"timezone": -8.0,
"origyear": "1988-09-13 00:00:00",
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/details.json"
},
"sensors": {
"units": "feet",
"sensors": [
{
"status": 1,
"refdatum": "MSL",
"sensorID": "F1",
"name": "Barometric Pressure",
"elevation": 18.2221,
"message": "",
"dcp": 1
},
{
"status": 1,
"refdatum": "MSL",
"sensorID": "site",
"name": "site",
"elevation": 12.480629,
"message": "",
"dcp": 0
},
{
"status": 1,
"refdatum": "",
"sensorID": "U1",
"name": "Tsunami WL",
"elevation": null,
"message": "",
"dcp": 1
},
{
"status": 1,
"refdatum": "",
"sensorID": "Y1",
"name": "Microwave WL",
"elevation": null,
"message": "",
"dcp": 1
}
],
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/sensors.json"
},
"floodlevels": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/floodlevels.json"
},
"datums": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/datums.json"
},
"supersededdatums": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/supersededdatums.json"
},
"harmonicConstituents": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/harcon.json"
},
"benchmarks": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/benchmarks.json"
},
"tidePredOffsets": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/tidepredoffsets.json"
},
"ofsMapOffsets": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/ofsmapoffsets.json"
},
"state": "WA",
"timezone": "PST",
"timezonecorr": -8,
"observedst": true,
"stormsurge": false,
"nearby": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/nearby.json"
},
"forecast": true,
"outlook": true,
"HTFhistorical": true,
"HTFmonthly": true,
"nonNavigational": false,
"id": "9447130",
"name": "Seattle",
"lat": 47.60264,
"lng": -122.3393,
"affiliations": "NWLON",
"portscode": null,
"products": {
"products": [
{
"name": "Water Levels",
"value": "https://tidesandcurrents.noaa.gov/waterlevels.html?id=9447130"
},
{
"name": "Reports",
"value": "https://tidesandcurrents.noaa.gov/reports.html?id=9447130"
},
{
"name": "Tide Predictions",
"value": "https://tidesandcurrents.noaa.gov/noaatidepredictions.html?id=9447130"
},
{
"name": "Meteorological",
"value": "https://tidesandcurrents.noaa.gov/met.html?id=9447130"
},
{
"name": "OFS",
"value": "https://tidesandcurrents.noaa.gov/ofs/ofs_station.html?stname=Seattle&ofs=ssc&stnid=9447130&subdomain=0_cp"
},
{
"name": "Benchmarks",
"value": "https://tidesandcurrents.noaa.gov/benchmarks.html?id=9447130"
},
{
"name": "Superseded Benchmarks",
"value": "https://tidesandcurrents.noaa.gov/benchmarks.html?id=9447130&type=superseded"
},
{
"name": "Datums",
"value": "https://tidesandcurrents.noaa.gov/datums.html?id=9447130"
},
{
"name": "Superseded Datums",
"value": "https://tidesandcurrents.noaa.gov/datums.html?id=9447130&epoch=1"
},
{
"name": "Harmonic",
"value": "https://tidesandcurrents.noaa.gov/harcon.html?id=9447130"
},
{
"name": "Inundation Analysis",
"value": "https://tidesandcurrents.noaa.gov/inundation/AnalysisParams?id=9447130"
},
{
"name": "Sea Level Trends",
"value": "https://tidesandcurrents.noaa.gov/sltrends/sltrends_station.shtml?id=9447130"
},
{
"name": "Extreme Water Levels",
"value": "https://tidesandcurrents.noaa.gov/est/est_station.shtml?stnid=9447130"
},
{
"name": "Physical Oceanagraphy",
"value": "https://tidesandcurrents.noaa.gov/physocean.html?id=9447130"
},
{
"name": "Coastal Inundation Dashboard",
"value": "https://tidesandcurrents.noaa.gov/inundationdb/inundation.html?id=9447130"
},
{
"name": "Annual High Tide Flood Outlook",
"value": "https://tidesandcurrents.noaa.gov/high-tide-flooding/annual-outlook.html?station=9447130"
},
{
"name": "Monthly High Tide Flood Outlook",
"value": "https://tidesandcurrents.noaa.gov/high-tide-flooding/monthly-outlook.html?station=9447130"
},
{
"name": "OFS Code",
"value": "ssc"
},
{
"name": "OFS Name",
"value": "Salish Sea and Columbia River"
}
],
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/products.json"
},
"disclaimers": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/disclaimers.json"
},
"notices": {
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130/notices.json"
},
"self": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/9447130.json",
"expand": "details,sensors,floodlevels,datums,harcon,tidepredoffsets,ofsmapoffsets,products,disclaimers,notices",
"tideType": "Mixed"
}
],
"self": null
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@ -0,0 +1,24 @@
{
"predictions": [
{
"t": "2026-02-21 00:49",
"v": "2.658",
"type": "L"
},
{
"t": "2026-02-21 07:08",
"v": "12.261",
"type": "H"
},
{
"t": "2026-02-21 13:43",
"v": "1.167",
"type": "L"
},
{
"t": "2026-02-21 19:54",
"v": "9.857",
"type": "H"
}
]
}

View File

@ -0,0 +1,213 @@
{
"metadata": {
"id": "9447130",
"name": "Seattle",
"lat": "47.6026",
"lon": "-122.3393"
},
"data": [
{
"t": "2026-02-21 17:24",
"v": "7.41",
"s": "0.059",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 17:30",
"v": "7.62",
"s": "0.072",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 17:36",
"v": "7.803",
"s": "0.069",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 17:42",
"v": "7.987",
"s": "0.062",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 17:48",
"v": "8.164",
"s": "0.062",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 17:54",
"v": "8.335",
"s": "0.059",
"f": "1,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:00",
"v": "8.499",
"s": "0.062",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:06",
"v": "8.683",
"s": "0.059",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:12",
"v": "8.824",
"s": "0.062",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:18",
"v": "8.985",
"s": "0.056",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:24",
"v": "9.158",
"s": "0.052",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:30",
"v": "9.296",
"s": "0.052",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:36",
"v": "9.421",
"s": "0.043",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:42",
"v": "9.546",
"s": "0.043",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:48",
"v": "9.667",
"s": "0.046",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 18:54",
"v": "9.785",
"s": "0.033",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:00",
"v": "9.857",
"s": "0.03",
"f": "1,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:06",
"v": "9.926",
"s": "0.043",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:12",
"v": "9.995",
"s": "0.043",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:18",
"v": "10.044",
"s": "0.036",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:24",
"v": "10.11",
"s": "0.03",
"f": "1,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:30",
"v": "10.179",
"s": "0.039",
"f": "1,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:36",
"v": "10.202",
"s": "0.026",
"f": "1,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:42",
"v": "10.215",
"s": "0.03",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:48",
"v": "10.228",
"s": "0.023",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 19:54",
"v": "10.225",
"s": "0.033",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 20:00",
"v": "10.218",
"s": "0.026",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 20:06",
"v": "10.218",
"s": "0.026",
"f": "0,0,0,0",
"q": "p"
},
{
"t": "2026-02-21 20:12",
"v": "10.231",
"s": "0.03",
"f": "1,0,0,0",
"q": "p"
}
]
}

View File

@ -0,0 +1,82 @@
{
"metadata": {
"id": "8454000",
"name": "Providence",
"lat": "41.8072",
"lon": "-71.4007"
},
"data": [
{
"t": "2026-02-21 22:24",
"s": "2.72",
"d": "109.0",
"dr": "ESE",
"g": "3.3",
"f": "0,0"
},
{
"t": "2026-02-21 22:30",
"s": "3.3",
"d": "103.0",
"dr": "ESE",
"g": "3.89",
"f": "0,0"
},
{
"t": "2026-02-21 22:36",
"s": "2.72",
"d": "108.0",
"dr": "ESE",
"g": "3.5",
"f": "0,0"
},
{
"t": "2026-02-21 22:42",
"s": "4.08",
"d": "93.0",
"dr": "E",
"g": "5.64",
"f": "0,0"
},
{
"t": "2026-02-21 22:48",
"s": "3.89",
"d": "81.0",
"dr": "E",
"g": "6.22",
"f": "0,0"
},
{
"t": "2026-02-21 22:54",
"s": "4.08",
"d": "77.0",
"dr": "ENE",
"g": "5.64",
"f": "0,0"
},
{
"t": "2026-02-21 23:00",
"s": "3.5",
"d": "101.0",
"dr": "E",
"g": "6.22",
"f": "0,0"
},
{
"t": "2026-02-21 23:06",
"s": "3.69",
"d": "81.0",
"dr": "E",
"g": "4.86",
"f": "0,0"
},
{
"t": "2026-02-21 23:12",
"s": "3.3",
"d": "76.0",
"dr": "ENE",
"g": "4.47",
"f": "0,0"
}
]
}

35
pyproject.toml Normal file
View File

@ -0,0 +1,35 @@
[project]
name = "mcnoaa-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"]
readme = "README.md"
license = "MIT"
[project.optional-dependencies]
viz = ["matplotlib>=3.8", "plotly>=5.18"]
[project.scripts]
mcnoaa-tides = "mcnoaa_tides.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mcnoaa_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", "mcnoaa-tides[viz]"]

View File

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

View File

@ -0,0 +1,33 @@
"""Chart rendering for NOAA tide and conditions data.
Optional dependency requires mcnoaa-tides[viz] to be installed.
"""
# Marine color palette — shared across all chart renderers
OCEAN_BLUE = "#1B4F72"
TEAL = "#148F77"
SLATE = "#5D6D7E"
SAND = "#D4A017"
CORAL = "#E74C3C"
BG_COLOR = "#FAFAFA"
GRID_COLOR = "#E0E0E0"
def check_deps(format: str) -> None:
"""Raise ValueError with install hint if visualization deps are missing."""
if format == "png":
try:
import matplotlib # noqa: F401
except ImportError:
raise ValueError(
"PNG charts require matplotlib. Install with: "
"uv pip install mcnoaa-tides[viz]"
)
elif format == "html":
try:
import plotly # noqa: F401
except ImportError:
raise ValueError(
"HTML charts require plotly. Install with: "
"uv pip install mcnoaa-tides[viz]"
)

View File

@ -0,0 +1,387 @@
"""Multi-panel conditions dashboard — tide + wind + temperature + pressure."""
import io
from datetime import datetime
from mcnoaa_tides.charts import BG_COLOR, CORAL, GRID_COLOR, OCEAN_BLUE, SAND, SLATE, TEAL
def _parse_time_series(data: list[dict], value_key: str = "v") -> tuple[list, list]:
"""Extract (times, values) from NOAA data records, skipping blanks."""
times = []
values = []
for d in data:
val = d.get(value_key)
if val is None or val == "":
continue
times.append(datetime.strptime(d["t"], "%Y-%m-%d %H:%M"))
values.append(float(val))
return times, values
def _parse_wind(data: list[dict]) -> dict:
"""Extract wind speed, gust, and direction from wind records."""
times = []
speeds = []
gusts = []
directions = []
for d in data:
if d.get("s") is None or d["s"] == "":
continue
times.append(datetime.strptime(d["t"], "%Y-%m-%d %H:%M"))
speeds.append(float(d["s"]))
gusts.append(float(d.get("g", d["s"])))
directions.append(d.get("dr", ""))
return {"times": times, "speeds": speeds, "gusts": gusts, "directions": directions}
def render_conditions_png(snapshot: dict, station_name: str = "") -> bytes:
"""Render a multi-panel conditions dashboard as PNG bytes.
Args:
snapshot: Dict from marine_conditions_snapshot with predictions, water_level,
wind, air_temperature, water_temperature, air_pressure keys.
station_name: Station name for the chart title.
Returns:
PNG image bytes.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
# Determine which panels we can show (some products may be unavailable)
has_predictions = "predictions" in snapshot
has_water_level = "water_level" in snapshot
has_wind = "wind" in snapshot
has_air_temp = "air_temperature" in snapshot
has_water_temp = "water_temperature" in snapshot
has_pressure = "air_pressure" in snapshot
panels = []
if has_predictions or has_water_level:
panels.append("tide")
if has_wind:
panels.append("wind")
if has_air_temp or has_water_temp:
panels.append("temp")
if has_pressure:
panels.append("pressure")
if not panels:
# Nothing to plot — return a minimal placeholder
fig, ax = plt.subplots(figsize=(10, 3), facecolor=BG_COLOR)
ax.text(
0.5, 0.5, "No data available for visualization",
transform=ax.transAxes, ha="center", va="center", fontsize=14, color=SLATE,
)
ax.set_facecolor(BG_COLOR)
ax.axis("off")
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150, facecolor=BG_COLOR)
plt.close(fig)
buf.seek(0)
return buf.getvalue()
n_panels = len(panels)
fig, axes = plt.subplots(
n_panels, 1, figsize=(12, 3.5 * n_panels), facecolor=BG_COLOR, sharex=True,
)
if n_panels == 1:
axes = [axes]
date_fmt = mdates.DateFormatter("%b %d\n%H:%M")
for i, panel in enumerate(panels):
ax = axes[i]
ax.set_facecolor(BG_COLOR)
ax.grid(True, alpha=0.3, color=GRID_COLOR, zorder=0)
ax.tick_params(colors=SLATE, labelsize=8)
if panel == "tide":
# Predictions line
if has_predictions:
preds = snapshot["predictions"].get("predictions", [])
if preds:
from mcnoaa_tides.charts.tides import _parse_predictions
p_times, p_values, markers = _parse_predictions(preds)
ax.plot(
p_times, p_values, color=OCEAN_BLUE, linewidth=2,
label="Predicted", zorder=3,
)
for m in markers:
is_high = m["type"] == "H"
color = SAND if is_high else CORAL
ax.plot(
m["time"], m["value"], "o", color=color, markersize=6, zorder=4,
)
# Observed overlay
if has_water_level:
obs_data = snapshot["water_level"].get("data", [])
if obs_data:
from mcnoaa_tides.charts.tides import _parse_observed
o_times, o_values = _parse_observed(obs_data)
if o_times:
ax.plot(
o_times, o_values, color=TEAL, linewidth=1.5,
linestyle="--", alpha=0.8, label="Observed", zorder=2,
)
ax.axhline(y=0, color=SLATE, linewidth=0.5, alpha=0.4, zorder=1)
ax.set_ylabel("Water Level (ft)", fontsize=9, color=SLATE)
ax.legend(loc="upper right", fontsize=8, framealpha=0.9)
ax.set_title("Tides", fontsize=10, fontweight="bold", color=OCEAN_BLUE, loc="left")
elif panel == "wind":
wind = _parse_wind(snapshot["wind"].get("data", []))
if wind["times"]:
ax.plot(
wind["times"], wind["speeds"], color=OCEAN_BLUE, linewidth=1.5,
label="Speed", zorder=3,
)
ax.fill_between(
wind["times"], wind["speeds"], wind["gusts"],
alpha=0.2, color=CORAL, label="Gust", zorder=2,
)
ax.plot(
wind["times"], wind["gusts"], color=CORAL, linewidth=1,
linestyle="--", alpha=0.6, zorder=2,
)
ax.set_ylabel("Wind (kn)", fontsize=9, color=SLATE)
ax.legend(loc="upper right", fontsize=8, framealpha=0.9)
ax.set_title("Wind", fontsize=10, fontweight="bold", color=OCEAN_BLUE, loc="left")
elif panel == "temp":
if has_air_temp:
air_data = snapshot["air_temperature"].get("data", [])
at, av = _parse_time_series(air_data)
if at:
ax.plot(at, av, color=CORAL, linewidth=1.5, label="Air", zorder=3)
if has_water_temp:
water_data = snapshot["water_temperature"].get("data", [])
wt, wv = _parse_time_series(water_data)
if wt:
ax.plot(wt, wv, color=TEAL, linewidth=1.5, label="Water", zorder=3)
ax.set_ylabel("Temp (\u00b0F)", fontsize=9, color=SLATE)
ax.legend(loc="upper right", fontsize=8, framealpha=0.9)
ax.set_title(
"Temperature", fontsize=10, fontweight="bold", color=OCEAN_BLUE, loc="left",
)
elif panel == "pressure":
press_data = snapshot["air_pressure"].get("data", [])
pt, pv = _parse_time_series(press_data)
if pt:
ax.plot(pt, pv, color=SLATE, linewidth=1.5, zorder=3)
# Trend indicator: compare first and last
if len(pv) >= 2:
diff = pv[-1] - pv[0]
arrow = "\u2191" if diff > 0.5 else ("\u2193" if diff < -0.5 else "\u2192")
ax.annotate(
f"{arrow} {diff:+.1f} mb",
xy=(pt[-1], pv[-1]),
fontsize=9,
fontweight="bold",
color=OCEAN_BLUE,
xytext=(10, 0),
textcoords="offset points",
)
ax.set_ylabel("Pressure (mb)", fontsize=9, color=SLATE)
ax.set_title(
"Barometric Pressure", fontsize=10, fontweight="bold",
color=OCEAN_BLUE, loc="left",
)
# X-axis formatting on bottom panel
axes[-1].xaxis.set_major_formatter(date_fmt)
axes[-1].xaxis.set_major_locator(mdates.AutoDateLocator())
title = "Marine Conditions"
if station_name:
title = f"{station_name}{title}"
fig.suptitle(title, fontsize=14, fontweight="bold", color=OCEAN_BLUE, y=1.01)
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight", facecolor=BG_COLOR)
plt.close(fig)
buf.seek(0)
return buf.getvalue()
def render_conditions_html(snapshot: dict, station_name: str = "") -> str:
"""Render a multi-panel conditions dashboard as an interactive HTML string.
Args:
snapshot: Dict from marine_conditions_snapshot with data product keys.
station_name: Station name for the chart title.
Returns:
Complete HTML document string.
"""
import plotly.graph_objects as go
from plotly.subplots import make_subplots
has_predictions = "predictions" in snapshot
has_water_level = "water_level" in snapshot
has_wind = "wind" in snapshot
has_air_temp = "air_temperature" in snapshot
has_water_temp = "water_temperature" in snapshot
has_pressure = "air_pressure" in snapshot
panel_titles = []
if has_predictions or has_water_level:
panel_titles.append("Tides")
if has_wind:
panel_titles.append("Wind (kn)")
if has_air_temp or has_water_temp:
panel_titles.append("Temperature (\u00b0F)")
if has_pressure:
panel_titles.append("Barometric Pressure (mb)")
if not panel_titles:
return "<html><body><p>No data available for visualization.</p></body></html>"
n = len(panel_titles)
fig = make_subplots(rows=n, cols=1, shared_xaxes=True, subplot_titles=panel_titles)
row = 1
# Tide panel
if has_predictions or has_water_level:
if has_predictions:
preds = snapshot["predictions"].get("predictions", [])
if preds:
from mcnoaa_tides.charts.tides import _parse_predictions
p_times, p_values, markers = _parse_predictions(preds)
fig.add_trace(
go.Scatter(
x=p_times, y=p_values, mode="lines", name="Predicted",
line={"color": OCEAN_BLUE, "width": 2},
),
row=row, col=1,
)
highs = [m for m in markers if m["type"] == "H"]
lows = [m for m in markers if m["type"] == "L"]
if highs:
fig.add_trace(
go.Scatter(
x=[m["time"] for m in highs],
y=[m["value"] for m in highs],
mode="markers", name="High",
marker={"color": SAND, "size": 8, "symbol": "triangle-up"},
),
row=row, col=1,
)
if lows:
fig.add_trace(
go.Scatter(
x=[m["time"] for m in lows],
y=[m["value"] for m in lows],
mode="markers", name="Low",
marker={"color": CORAL, "size": 8, "symbol": "triangle-down"},
),
row=row, col=1,
)
if has_water_level:
obs_data = snapshot["water_level"].get("data", [])
if obs_data:
from mcnoaa_tides.charts.tides import _parse_observed
o_times, o_values = _parse_observed(obs_data)
if o_times:
fig.add_trace(
go.Scatter(
x=o_times, y=o_values, mode="lines", name="Observed",
line={"color": TEAL, "width": 1.5, "dash": "dash"},
opacity=0.8,
),
row=row, col=1,
)
row += 1
# Wind panel
if has_wind:
wind = _parse_wind(snapshot["wind"].get("data", []))
if wind["times"]:
fig.add_trace(
go.Scatter(
x=wind["times"], y=wind["speeds"], mode="lines", name="Speed",
line={"color": OCEAN_BLUE, "width": 1.5},
),
row=row, col=1,
)
fig.add_trace(
go.Scatter(
x=wind["times"], y=wind["gusts"], mode="lines", name="Gust",
line={"color": CORAL, "width": 1, "dash": "dash"},
opacity=0.6,
),
row=row, col=1,
)
row += 1
# Temperature panel
if has_air_temp or has_water_temp:
if has_air_temp:
air_data = snapshot["air_temperature"].get("data", [])
at, av = _parse_time_series(air_data)
if at:
fig.add_trace(
go.Scatter(
x=at, y=av, mode="lines", name="Air Temp",
line={"color": CORAL, "width": 1.5},
),
row=row, col=1,
)
if has_water_temp:
water_data = snapshot["water_temperature"].get("data", [])
wt, wv = _parse_time_series(water_data)
if wt:
fig.add_trace(
go.Scatter(
x=wt, y=wv, mode="lines", name="Water Temp",
line={"color": TEAL, "width": 1.5},
),
row=row, col=1,
)
row += 1
# Pressure panel
if has_pressure:
press_data = snapshot["air_pressure"].get("data", [])
pt, pv = _parse_time_series(press_data)
if pt:
fig.add_trace(
go.Scatter(
x=pt, y=pv, mode="lines", name="Pressure",
line={"color": SLATE, "width": 1.5},
),
row=row, col=1,
)
title = "Marine Conditions"
if station_name:
title = f"{station_name} &mdash; {title}"
fig.update_layout(
title={"text": title, "font": {"size": 16, "color": OCEAN_BLUE}},
plot_bgcolor=BG_COLOR,
paper_bgcolor=BG_COLOR,
font={"color": SLATE},
hovermode="x unified",
height=300 * n,
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
margin={"t": 80, "b": 40, "l": 60, "r": 20},
)
# Apply grid color to all axes
for i in range(1, n + 1):
fig.update_xaxes(gridcolor=GRID_COLOR, showgrid=True, row=i, col=1)
fig.update_yaxes(gridcolor=GRID_COLOR, showgrid=True, row=i, col=1)
return fig.to_html(include_plotlyjs="cdn", full_html=True)

View File

@ -0,0 +1,223 @@
"""Tide chart rendering — prediction curve with H/L markers and optional observed overlay."""
import io
from datetime import datetime
from mcnoaa_tides.charts import BG_COLOR, CORAL, GRID_COLOR, OCEAN_BLUE, SAND, SLATE, TEAL
def _parse_predictions(predictions: list[dict]) -> tuple[list[datetime], list[float], list[dict]]:
"""Extract timestamps, values, and H/L markers from prediction records."""
times = []
values = []
markers = []
for p in predictions:
dt = datetime.strptime(p["t"], "%Y-%m-%d %H:%M")
val = float(p["v"])
times.append(dt)
values.append(val)
if p.get("type") in ("H", "L"):
markers.append({"time": dt, "value": val, "type": p["type"]})
return times, values, markers
def _parse_observed(data: list[dict]) -> tuple[list[datetime], list[float]]:
"""Extract timestamps and values from observed water level records."""
times = []
values = []
for d in data:
if d.get("v") is None or d["v"] == "":
continue
dt = datetime.strptime(d["t"], "%Y-%m-%d %H:%M")
val = float(d["v"])
times.append(dt)
values.append(val)
return times, values
def render_tide_chart_png(
predictions: list[dict],
observed: list[dict] | None = None,
station_name: str = "",
) -> bytes:
"""Render a tide prediction chart as PNG bytes.
Args:
predictions: List of prediction dicts with t, v, and optional type fields.
observed: Optional list of observed water level dicts with t, v fields.
station_name: Station name for the chart title.
Returns:
PNG image bytes.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
pred_times, pred_values, markers = _parse_predictions(predictions)
fig, ax = plt.subplots(figsize=(12, 5), facecolor=BG_COLOR)
ax.set_facecolor(BG_COLOR)
# Prediction line
ax.plot(pred_times, pred_values, color=OCEAN_BLUE, linewidth=2, label="Predicted", zorder=3)
# H/L markers
for m in markers:
is_high = m["type"] == "H"
color = SAND if is_high else CORAL
ax.plot(m["time"], m["value"], marker="o", color=color, markersize=8, zorder=4)
label = f'{m["type"]} {m["value"]:.1f}ft'
ax.annotate(
label,
(m["time"], m["value"]),
textcoords="offset points",
xytext=(0, 14 if is_high else -18),
fontsize=8,
fontweight="bold",
color=color,
ha="center",
zorder=5,
)
# Observed overlay
if observed:
obs_times, obs_values = _parse_observed(observed)
if obs_times:
ax.plot(
obs_times,
obs_values,
color=TEAL,
linewidth=1.5,
linestyle="--",
alpha=0.8,
label="Observed",
zorder=2,
)
# Zero reference line (MLLW datum)
ax.axhline(y=0, color=SLATE, linewidth=0.5, linestyle="-", alpha=0.4, zorder=1)
# Formatting
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %d\n%H:%M"))
ax.xaxis.set_major_locator(mdates.AutoDateLocator())
ax.set_ylabel("Water Level (ft, MLLW)", fontsize=10, color=SLATE)
ax.set_xlabel("")
ax.tick_params(colors=SLATE, labelsize=9)
ax.grid(True, alpha=0.3, color=GRID_COLOR, zorder=0)
ax.legend(loc="upper right", fontsize=9, framealpha=0.9)
title = "Tide Predictions"
if station_name:
title = f"{station_name}{title}"
ax.set_title(title, fontsize=13, fontweight="bold", color=OCEAN_BLUE, pad=12)
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=150, bbox_inches="tight", facecolor=BG_COLOR)
plt.close(fig)
buf.seek(0)
return buf.getvalue()
def render_tide_chart_html(
predictions: list[dict],
observed: list[dict] | None = None,
station_name: str = "",
) -> str:
"""Render a tide prediction chart as an interactive HTML string.
Args:
predictions: List of prediction dicts with t, v, and optional type fields.
observed: Optional list of observed water level dicts with t, v fields.
station_name: Station name for the chart title.
Returns:
Complete HTML document string.
"""
import plotly.graph_objects as go
pred_times, pred_values, markers = _parse_predictions(predictions)
fig = go.Figure()
# Prediction line
fig.add_trace(
go.Scatter(
x=pred_times,
y=pred_values,
mode="lines",
name="Predicted",
line={"color": OCEAN_BLUE, "width": 2},
)
)
# H/L markers as separate traces
highs = [m for m in markers if m["type"] == "H"]
lows = [m for m in markers if m["type"] == "L"]
if highs:
fig.add_trace(
go.Scatter(
x=[m["time"] for m in highs],
y=[m["value"] for m in highs],
mode="markers+text",
name="High",
marker={"color": SAND, "size": 10, "symbol": "triangle-up"},
text=[f'H {m["value"]:.1f}ft' for m in highs],
textposition="top center",
textfont={"size": 10, "color": SAND},
)
)
if lows:
fig.add_trace(
go.Scatter(
x=[m["time"] for m in lows],
y=[m["value"] for m in lows],
mode="markers+text",
name="Low",
marker={"color": CORAL, "size": 10, "symbol": "triangle-down"},
text=[f'L {m["value"]:.1f}ft' for m in lows],
textposition="bottom center",
textfont={"size": 10, "color": CORAL},
)
)
# Observed overlay
if observed:
obs_times, obs_values = _parse_observed(observed)
if obs_times:
fig.add_trace(
go.Scatter(
x=obs_times,
y=obs_values,
mode="lines",
name="Observed",
line={"color": TEAL, "width": 1.5, "dash": "dash"},
opacity=0.8,
)
)
title = "Tide Predictions"
if station_name:
title = f"{station_name} &mdash; {title}"
fig.update_layout(
title={"text": title, "font": {"size": 16, "color": OCEAN_BLUE}},
xaxis_title="",
yaxis_title="Water Level (ft, MLLW)",
plot_bgcolor=BG_COLOR,
paper_bgcolor=BG_COLOR,
font={"color": SLATE},
hovermode="x unified",
legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
xaxis={"gridcolor": GRID_COLOR, "showgrid": True},
yaxis={"gridcolor": GRID_COLOR, "showgrid": True, "zeroline": True},
margin={"t": 60, "b": 40, "l": 60, "r": 20},
)
return fig.to_html(include_plotlyjs="cdn", full_html=True)

213
src/mcnoaa_tides/client.py Normal file
View File

@ -0,0 +1,213 @@
"""Async NOAA CO-OPS API client with station caching and proximity search."""
import math
import re
import sys
import time
import httpx
from mcnoaa_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
MAX_RANGE_HOURS = 720 # NOAA API cap ~30 days
_STATION_ID_RE = re.compile(r"^\d{7}$")
def _validate_station_id(station_id: str) -> str:
"""NOAA station IDs are 7-digit numbers (e.g. '8454000')."""
if not _STATION_ID_RE.match(station_id):
raise ValueError(
f"Invalid station ID '{station_id}': expected a 7-digit number (e.g. '8454000')"
)
return station_id
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:
try:
await self._refresh_stations()
except Exception:
# Serve stale data rather than failing the request.
# If cache was never populated, re-raise.
if not self._stations:
raise
print(
"Warning: station cache refresh failed, serving stale data",
file=sys.stderr,
)
return list(self._stations)
# -- Metadata API --
async def get_station_metadata(self, station_id: str) -> dict:
_validate_station_id(station_id)
try:
resp = await self._http.get(
f"{META_URL}/stations/{station_id}.json",
params={"expand": "details,sensors,datums,products,disclaimers"},
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise ValueError(
f"Station '{station_id}' not found. "
"Verify the ID using search_stations."
) from exc
raise RuntimeError(
f"NOAA metadata API error ({exc.response.status_code}). "
"The service may be temporarily unavailable."
) from exc
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_ldt",
) -> 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.
"""
_validate_station_id(station_id)
if hours and (hours < 0 or hours > MAX_RANGE_HOURS):
raise ValueError(f"hours must be between 1 and {MAX_RANGE_HOURS}, got {hours}")
params: dict[str, str] = {
"station": station_id,
"product": product,
"datum": datum,
"units": units,
"time_zone": time_zone,
"format": "json",
"application": "mcnoaa-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"
try:
resp = await self._http.get(DATA_URL, params=params)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
raise ValueError(
f"No data for station '{station_id}' product '{product}'. "
"Use get_station_info to check available products."
) from exc
raise RuntimeError(
f"NOAA data API error ({exc.response.status_code}). "
"The service may be temporarily unavailable."
) from exc
result = resp.json()
if "error" in result:
raise ValueError(result["error"].get("message", "Unknown NOAA API error"))
return result
# -- In-memory search --
async def search(
self,
query: str = "",
state: str = "",
is_tidal: bool | None = None,
) -> list[Station]:
"""Filter cached stations. Triggers cache refresh if TTL expired."""
stations = await self.get_stations()
matches = 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
async 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."""
stations = await self.get_stations()
results: list[tuple[Station, float]] = []
for station in 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]

View File

@ -0,0 +1,49 @@
"""Pydantic models for NOAA CO-OPS API responses."""
from pydantic import BaseModel, ConfigDict, Field
class Station(BaseModel):
model_config = ConfigDict(extra="ignore")
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")

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 mcnoaa_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 = await 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,
)

View File

@ -0,0 +1,61 @@
"""FastMCP server for NOAA CO-OPS Tides and Currents API."""
import sys
from contextlib import asynccontextmanager
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, stations, tides
@asynccontextmanager
async def lifespan(server: FastMCP):
"""Manage the NOAAClient lifecycle — create, pre-warm station cache, close."""
client = NOAAClient()
try:
await client.initialize()
except Exception as exc:
# Start with empty cache — will populate on first station request.
# HTTP client still needs to exist for data fetches.
import httpx
client._http = httpx.AsyncClient(timeout=30)
print(
f"Warning: station cache pre-warm failed ({exc}). "
"Will retry on first request.",
file=sys.stderr,
)
try:
yield {"noaa_client": client}
finally:
await client.close()
mcp = FastMCP(
"mcnoaa-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)
charts.register(mcp)
# Register resources and prompts
resources.register(mcp)
prompts.register(mcp)
def main():
print(f"mcnoaa-tides v{__version__}", file=sys.stderr)
mcp.run()

View File

View File

@ -0,0 +1,181 @@
"""Visualization tools — tide charts and conditions dashboards."""
import asyncio
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal
from fastmcp import Context, FastMCP
from fastmcp.utilities.types import Image
from mcnoaa_tides.charts import check_deps
from mcnoaa_tides.client import NOAAClient
def register(mcp: FastMCP) -> None:
@mcp.tool(tags={"visualization"})
async def visualize_tides(
ctx: Context,
station_id: str,
hours: int = 48,
include_observed: bool = True,
format: Literal["png", "html"] = "png",
) -> Image | str:
"""Generate a tide prediction chart with high/low markers.
Creates a visual chart of tide predictions showing the water level curve
with high (H) and low (L) tide markers. Optionally overlays observed
water levels as a dashed line for comparison.
PNG format returns an inline image. HTML format saves an interactive
chart to artifacts/charts/ and returns the file path.
Requires mcnoaa-tides[viz] to be installed.
"""
check_deps(format)
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
today = datetime.now(timezone.utc).strftime("%Y%m%d")
# Fetch predictions (6-minute interval for smooth curve) + hilo for markers
predictions_raw, hilo_raw = await asyncio.gather(
noaa.get_data(
station_id, product="predictions", begin_date=today,
hours=hours, interval="6",
),
noaa.get_data(
station_id, product="predictions", begin_date=today,
hours=hours, interval="hilo",
),
)
# Merge hilo type markers into the 6-minute data points
predictions = predictions_raw.get("predictions", [])
hilo_map = {}
for h in hilo_raw.get("predictions", []):
hilo_map[h["t"]] = h.get("type")
for p in predictions:
if p["t"] in hilo_map:
p["type"] = hilo_map[p["t"]]
# Fetch observed water levels if requested
observed = None
if include_observed:
try:
obs_raw = await noaa.get_data(
station_id, product="water_level", hours=hours,
)
observed = obs_raw.get("data", [])
except Exception:
pass # observed overlay is optional — skip on failure
# Look up station name
station_name = ""
try:
stations = await noaa.get_stations()
match = [s for s in stations if s.id == station_id]
if match:
station_name = match[0].name
except Exception:
pass
if format == "png":
from mcnoaa_tides.charts.tides import render_tide_chart_png
png_bytes = render_tide_chart_png(predictions, observed, station_name)
return Image(data=png_bytes, format="image/png")
else:
from mcnoaa_tides.charts.tides import render_tide_chart_html
html = render_tide_chart_html(predictions, observed, station_name)
path = _save_html(html, station_id, "tides")
return f"Interactive tide chart saved to: {path}"
@mcp.tool(tags={"visualization"})
async def visualize_conditions(
ctx: Context,
station_id: str,
hours: int = 24,
format: Literal["png", "html"] = "png",
) -> Image | str:
"""Generate a multi-panel marine conditions dashboard.
Creates a dashboard with up to 4 panels:
- Tide predictions with observed water level overlay
- Wind speed and gust
- Air and water temperature
- Barometric pressure with trend indicator
Products unavailable at a station are simply omitted from the dashboard.
PNG format returns an inline image. HTML format saves an interactive
chart to artifacts/charts/ and returns the file path.
Requires mcnoaa-tides[viz] to be installed.
"""
check_deps(format)
noaa: NOAAClient = ctx.lifespan_context["noaa_client"]
today = datetime.now(timezone.utc).strftime("%Y%m%d")
# Parallel fetch — same products as marine_conditions_snapshot
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 | None]:
try:
data = await noaa.get_data(station_id, **params)
return name, data
except Exception:
return name, None
results = await asyncio.gather(
*[fetch(name, params) for name, params in requests.items()]
)
snapshot: dict = {"station_id": station_id}
for name, data in results:
if data is not None:
snapshot[name] = data
# Look up station name
station_name = ""
try:
stations = await noaa.get_stations()
match = [s for s in stations if s.id == station_id]
if match:
station_name = match[0].name
except Exception:
pass
if format == "png":
from mcnoaa_tides.charts.conditions import render_conditions_png
png_bytes = render_conditions_png(snapshot, station_name)
return Image(data=png_bytes, format="image/png")
else:
from mcnoaa_tides.charts.conditions import render_conditions_html
html = render_conditions_html(snapshot, station_name)
path = _save_html(html, station_id, "conditions")
return f"Interactive conditions dashboard saved to: {path}"
def _save_html(html: str, station_id: str, chart_type: str) -> Path:
"""Save HTML chart to artifacts/charts/ and return the path."""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
out_dir = Path("artifacts/charts")
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{station_id}_{chart_type}_{timestamp}.html"
path.write_text(html, encoding="utf-8")
return path

View File

@ -0,0 +1,77 @@
"""Marine conditions snapshot — parallel multi-product fetch."""
import asyncio
from datetime import datetime, timezone
from fastmcp import Context, FastMCP
from mcnoaa_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:
msg = str(exc) or type(exc).__name__
return name, f"{type(exc).__name__}: {msg}"
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 mcnoaa_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,72 @@
"""Station discovery tools: search, proximity, and metadata lookup."""
from fastmcp import Context, FastMCP
from mcnoaa_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 = await 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"]
if limit < 1:
raise ValueError("limit must be at least 1")
if max_distance_nm <= 0:
raise ValueError("max_distance_nm must be positive")
results = await 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 mcnoaa_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,
)

154
tests/conftest.py Normal file
View File

@ -0,0 +1,154 @@
"""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 mcnoaa_tides import prompts, resources
from mcnoaa_tides.client import NOAAClient
from mcnoaa_tides.tools import charts, 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 mcnoaa_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("mcnoaa-tides-test", lifespan=_test_lifespan)
stations.register(mcp)
tides.register(mcp)
meteorological.register(mcp)
conditions.register(mcp)
charts.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

175
tests/test_charts.py Normal file
View File

@ -0,0 +1,175 @@
"""Tests for chart rendering and visualization tool registration."""
import pytest
from fastmcp import Client
from mcnoaa_tides.charts.conditions import render_conditions_html, render_conditions_png
from mcnoaa_tides.charts.tides import (
_parse_observed,
_parse_predictions,
render_tide_chart_html,
render_tide_chart_png,
)
# Mock data (matches conftest.py fixtures)
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"}]}
# -- Parsing helpers --
def test_parse_predictions():
preds = MOCK_PREDICTIONS["predictions"]
times, values, markers = _parse_predictions(preds)
assert len(times) == 4
assert len(values) == 4
assert len(markers) == 4
assert markers[0]["type"] == "H"
assert markers[1]["type"] == "L"
assert values[0] == pytest.approx(4.521)
def test_parse_observed():
obs = MOCK_WATER_LEVEL["data"]
times, values = _parse_observed(obs)
assert len(times) == 2
assert values[0] == pytest.approx(2.34)
def test_parse_observed_skips_blank_values():
data = [
{"t": "2026-02-21 00:00", "v": "2.34"},
{"t": "2026-02-21 00:06", "v": ""},
{"t": "2026-02-21 00:12", "v": None},
]
times, values = _parse_observed(data)
assert len(times) == 1
# -- Tide chart rendering --
def test_tide_chart_png_returns_bytes():
preds = MOCK_PREDICTIONS["predictions"]
result = render_tide_chart_png(preds, station_name="Test Station")
assert isinstance(result, bytes)
assert len(result) > 0
# PNG magic bytes
assert result[:4] == b"\x89PNG"
def test_tide_chart_png_with_observed():
preds = MOCK_PREDICTIONS["predictions"]
obs = MOCK_WATER_LEVEL["data"]
result = render_tide_chart_png(preds, observed=obs, station_name="Providence")
assert isinstance(result, bytes)
assert result[:4] == b"\x89PNG"
def test_tide_chart_html_returns_string():
preds = MOCK_PREDICTIONS["predictions"]
result = render_tide_chart_html(preds, station_name="Test Station")
assert isinstance(result, str)
assert "<html>" in result.lower() or "<!doctype" in result.lower()
assert "plotly" in result.lower()
def test_tide_chart_html_with_observed():
preds = MOCK_PREDICTIONS["predictions"]
obs = MOCK_WATER_LEVEL["data"]
result = render_tide_chart_html(preds, observed=obs, station_name="Providence")
assert isinstance(result, str)
assert "Observed" in result
# -- Conditions dashboard rendering --
def _build_snapshot(**overrides) -> dict:
"""Build a snapshot dict from mock data."""
snapshot = {
"station_id": "8454000",
"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,
}
snapshot.update(overrides)
return snapshot
def test_conditions_png_returns_bytes():
snapshot = _build_snapshot()
result = render_conditions_png(snapshot, station_name="Providence")
assert isinstance(result, bytes)
assert result[:4] == b"\x89PNG"
def test_conditions_png_partial_data():
"""Dashboard should render even with missing products."""
snapshot = _build_snapshot()
del snapshot["wind"]
del snapshot["air_temperature"]
result = render_conditions_png(snapshot, station_name="Providence")
assert isinstance(result, bytes)
assert result[:4] == b"\x89PNG"
def test_conditions_png_empty_snapshot():
"""Dashboard with no data produces a placeholder image."""
result = render_conditions_png({"station_id": "8454000"})
assert isinstance(result, bytes)
assert result[:4] == b"\x89PNG"
def test_conditions_html_returns_string():
snapshot = _build_snapshot()
result = render_conditions_html(snapshot, station_name="Providence")
assert isinstance(result, str)
assert "plotly" in result.lower()
def test_conditions_html_empty_snapshot():
result = render_conditions_html({"station_id": "8454000"})
assert isinstance(result, str)
assert "No data available" in result
# -- Tool registration --
async def test_visualization_tools_registered(mcp_client: Client):
"""The 2 new visualization tools should appear in the tool list."""
tools = await mcp_client.list_tools()
names = {t.name for t in tools}
assert "visualize_tides" in names
assert "visualize_conditions" in names
async def test_total_tool_count(mcp_client: Client):
"""Verify total tool count after adding visualization tools (7 + 2 = 9)."""
tools = await mcp_client.list_tools()
assert len(tools) == 9

View File

@ -0,0 +1,141 @@
"""Tests for station discovery and data retrieval tools."""
import json
from fastmcp import Client
async def test_tool_registration(mcp_client: Client):
"""All 9 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",
"visualize_tides",
"visualize_conditions",
}
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)

1724
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff