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:
commit
6c244b3a63
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
8
.mcp.json
Normal 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
31
pyproject.toml
Normal 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"]
|
||||||
6
src/noaa_tides/__init__.py
Normal file
6
src/noaa_tides/__init__.py
Normal 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
158
src/noaa_tides/client.py
Normal 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
47
src/noaa_tides/models.py
Normal 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
78
src/noaa_tides/prompts.py
Normal 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.
|
||||||
|
"""
|
||||||
43
src/noaa_tides/resources.py
Normal file
43
src/noaa_tides/resources.py
Normal 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
48
src/noaa_tides/server.py
Normal 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()
|
||||||
0
src/noaa_tides/tools/__init__.py
Normal file
0
src/noaa_tides/tools/__init__.py
Normal file
76
src/noaa_tides/tools/conditions.py
Normal file
76
src/noaa_tides/tools/conditions.py
Normal 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
|
||||||
55
src/noaa_tides/tools/meteorological.py
Normal file
55
src/noaa_tides/tools/meteorological.py
Normal 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,
|
||||||
|
)
|
||||||
68
src/noaa_tides/tools/stations.py
Normal file
68
src/noaa_tides/tools/stations.py
Normal 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)
|
||||||
69
src/noaa_tides/tools/tides.py
Normal file
69
src/noaa_tides/tools/tides.py
Normal 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
153
tests/conftest.py
Normal 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
|
||||||
139
tests/test_tools_stations.py
Normal file
139
tests/test_tools_stations.py
Normal 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)
|
||||||
Loading…
x
Reference in New Issue
Block a user