Developer API Beta
๐ What you get
Dublin Bay buoy readings + marine forecasts, on tap. Same dataset, two surfaces:
- MCP endpoint โ for Claude Code, Cursor, or any LLM agent. 8 curated marine-data verbs; the server holds the key for you. Jump to MCP →
- PostgREST API โ for apps, scripts, dashboards. REST + RPCs with the anon JWT. Details below.
Backed by 27k+ hourly buoy readings since 2023-01-01, 400k+ forecast rows across ECMWF IFS and EWAM (refreshed every 30 min), and 5 RPCs for the heavy queries. No sign-up for read access.
๐ค Use it as an MCP server
Point your MCP-capable client at https://mcp.dublinbaybuoy.com/ (streamable-HTTP transport) and you get 8 named marine-data tools. No signup, no token, no config surface โ the server holds the anon key for you.
Claude Code / Claude Desktop / Cursor / Windsurf
Add to ~/.claude.json (or a project-scoped .mcp.json โ same shape for Cursor, Windsurf, and other MCP-capable IDEs):
{
"mcpServers": {
"dublin-bay": {
"type": "http",
"url": "https://mcp.dublinbaybuoy.com/"
}
}
}
Or one-liner on the Claude Code CLI:
claude mcp add --transport http dublin-bay https://mcp.dublinbaybuoy.com/
Restart the client โ tools appear as mcp__dublin-bay__*.
Python MCP SDK
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
async with streamablehttp_client("https://mcp.dublinbaybuoy.com/") as (r, w, _):
async with ClientSession(r, w) as s:
await s.initialize()
tools = await s.list_tools()
result = await s.call_tool("get_current_conditions", {})
print(result)
Tools exposed
| Tool | Returns |
|---|---|
describe_data | Metadata: coverage, units, upstream sources, attribution. Call first to orient an LLM. |
get_current_conditions | Latest hourly buoy row โ wind, gusts, waves, water temp, is_gale. |
get_recent_readings(hours=24) | Hourly series; auto-downsampled to daily means past 720 h. |
get_summary(days=7) | Means for wind / gust / wave / water temp over the window. |
get_wind_rose(days=30) | 16-bucket compass histogram + prevailing direction. |
get_records | All-time extremes since 2023-01. |
get_forecast(variable, model, lead_hours) | Forecast curves from 0 / 24 / 72 / 120 / 168 h ago โ visualise drift. |
get_forecast_skill(variable, model, days, bucket_hours) | MAE / RMSE / bias per lead bucket. |
Forecast variables: wind_speed_10m, wind_gusts_10m, wind_direction_10m, wave_height, wave_period, wave_direction, sea_surface_temperature. Models: ecmwf_ifs025, ewam.
Try these prompts
- “What's the wind doing in Dublin Bay right now?” โ
get_current_conditions - “How accurate is ECMWF's 72-hour wind forecast for Dublin Bay over the last month?” โ
get_forecast_skill - “What direction does the wind usually blow from here in winter?” โ
get_wind_rose
Why curated verbs, not raw SQL?
LLMs burn tokens and get creative with SQL. Named verbs like get_wind_rose(days=30) are cheaper, safer, and self-documenting. The server holds the anon key, so clients never rotate tokens. Read-only by design โ RLS on every table, SECURITY DEFINER on every RPC, no write paths exposed.
Self-host it
The source is ~200 lines of FastMCP + httpx, trivially portable to any Supabase project:
cd dublin-bay-bot/mcp
uv pip install --system -r requirements.txt
SUPABASE_URL=https://api.dublinbaybuoy.com \
SUPABASE_ANON_KEY=<anon key> \
PORT=8080 \
python server.py
api.dublinbaybuoy.com/mcp is available with a service-role key โ email [email protected].๐ Base URL & key
All endpoints are served from:
https://api.dublinbaybuoy.com/rest/v1/
Pass the public anon key on every request. It’s safe to embed in client code โ RLS limits it to SELECT on public tables:
apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NDU3MzAxLCJleHAiOjE5MzQxMzczMDF9.4ee1fobnsvjyRO14xKTi3rNhBv-UTeQRlQHMIp5Oqmw
โก Quick start
Pick a language — every example is a single standalone copy-paste. No env vars, no setup.
Latest reading
curl -sS "https://api.dublinbaybuoy.com/rest/v1/readings?select=*&order=timestamp.desc&limit=1" \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NDU3MzAxLCJleHAiOjE5MzQxMzczMDF9.4ee1fobnsvjyRO14xKTi3rNhBv-UTeQRlQHMIp5Oqmw" | jq
import httpx
ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NDU3MzAxLCJleHAiOjE5MzQxMzczMDF9.4ee1fobnsvjyRO14xKTi3rNhBv-UTeQRlQHMIp5Oqmw"
r = httpx.get(
"https://api.dublinbaybuoy.com/rest/v1/readings?select=*&order=timestamp.desc&limit=1",
headers={"apikey": ANON_KEY},
)
print(r.json())
// Paste into your browser's DevTools console, or a Node 18+ REPL.
const ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NDU3MzAxLCJleHAiOjE5MzQxMzczMDF9.4ee1fobnsvjyRO14xKTi3rNhBv-UTeQRlQHMIp5Oqmw";
const r = await fetch(
"https://api.dublinbaybuoy.com/rest/v1/readings?select=*&order=timestamp.desc&limit=1",
{ headers: { apikey: ANON_KEY } },
);
console.log(await r.json());
Last 24 hourly readings
curl -sS "https://api.dublinbaybuoy.com/rest/v1/readings?select=timestamp,avg_wind,gust_speed,wave_height&order=timestamp.desc&limit=24" \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NDU3MzAxLCJleHAiOjE5MzQxMzczMDF9.4ee1fobnsvjyRO14xKTi3rNhBv-UTeQRlQHMIp5Oqmw" | jq
r = httpx.get(
"https://api.dublinbaybuoy.com/rest/v1/readings?select=timestamp,avg_wind,gust_speed,wave_height&order=timestamp.desc&limit=24",
headers={"apikey": ANON_KEY},
)
print(r.json())
const r = await fetch(
"https://api.dublinbaybuoy.com/rest/v1/readings?select=timestamp,avg_wind,gust_speed,wave_height&order=timestamp.desc&limit=24",
{ headers: { apikey: ANON_KEY } },
);
console.log(await r.json());
Wind rose — last 30 days
curl -sS -X POST "https://api.dublinbaybuoy.com/rest/v1/rpc/rpc_wind_rose" \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NDU3MzAxLCJleHAiOjE5MzQxMzczMDF9.4ee1fobnsvjyRO14xKTi3rNhBv-UTeQRlQHMIp5Oqmw" \
-H "Content-Type: application/json" \
-d '{"days": 30}' | jq
r = httpx.post(
"https://api.dublinbaybuoy.com/rest/v1/rpc/rpc_wind_rose",
headers={"apikey": ANON_KEY},
json={"days": 30},
)
print(r.json())
const r = await fetch(
"https://api.dublinbaybuoy.com/rest/v1/rpc/rpc_wind_rose",
{
method: "POST",
headers: { apikey: ANON_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ days: 30 }),
},
);
console.log(await r.json());
Latest ECMWF wind-speed forecast curve
curl -sS -X POST "https://api.dublinbaybuoy.com/rest/v1/rpc/rpc_forecast_drift_snapshots" \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc2NDU3MzAxLCJleHAiOjE5MzQxMzczMDF9.4ee1fobnsvjyRO14xKTi3rNhBv-UTeQRlQHMIp5Oqmw" \
-H "Content-Type: application/json" \
-d '{"variable_name": "wind_speed_10m", "model_name": "ecmwf_ifs025"}' | jq
r = httpx.post(
"https://api.dublinbaybuoy.com/rest/v1/rpc/rpc_forecast_drift_snapshots",
headers={"apikey": ANON_KEY},
json={"variable_name": "wind_speed_10m", "model_name": "ecmwf_ifs025"},
)
print(r.json())
const r = await fetch(
"https://api.dublinbaybuoy.com/rest/v1/rpc/rpc_forecast_drift_snapshots",
{
method: "POST",
headers: { apikey: ANON_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
variable_name: "wind_speed_10m",
model_name: "ecmwf_ifs025",
}),
},
);
console.log(await r.json());
๐๏ธ Tables
Anonymous SELECT is allowed on these tables. Use standard PostgREST query syntax โ select, order, limit, range filters like timestamp=gte.2026-01-01.
| Table | Rows | Contents |
|---|---|---|
readings | ~27k | Hourly buoy observations โ avg_wind, gust_speed, wind_dir, wave_height, wave_period, water_temp, is_gale |
forecast_log | ~400k | Rolling forecast snapshots โ fetched_at_utc, target_at_utc, model, variable, value, lead_hours. 30-day TTL. |
ais_readings | ~400 | Raw SignalK measurements from the buoy’s AIS meteo broadcasts |
Units
- Wind & gusts โ knots (buoy and forecast agree)
- Direction โ degrees true (meteorological: from)
- Waves โ metres / seconds
- Temperature โ ยฐC
- All timestamps โ UTC
๐งฎ RPCs
Server-side functions for the heavy stuff. Call with POST /rest/v1/rpc/<name> and a JSON body of arguments.
rpc_summary(days int)
Min/mean/max for every variable over the window. One-call dashboard feed.
rpc_wind_rose(days int default null)
Wind-direction histogram in 16 buckets of 22.5ยฐ, zero-padded. Suitable for polar charts. Omit days for the full archive.
rpc_readings_downsampled(hours int)
Bucketed averages for long time-series charts. Hourly buckets for up to 30 days (hours โค 720); daily buckets beyond that.
rpc_forecast_drift(target_hours int)
Each model’s single best forecast for a target lead-time (e.g. “what does each model say about the weather 72 h from now?”).
rpc_forecast_drift_snapshots(variable_name, model_name, requested_leads int[])
Multiple forecast runs at progressively older leads, each with its full hourly curve. Powers the Mini App’s drift visualisation โ plot how the forecast for the coming week has evolved.
rpc_forecast_skill(variable_name, model_name default null, window_days default 30) Beta
Joins forecast_log against readings at target_at_utc and returns MAE, RMSE and bias per 6-hour lead bucket. Direction variables use short-arc circular error. See the spec section below.
๐ Forecast-skill endpoint (spec)
Answers: how well does ECMWF predict Dublin Bay wind 24 h out? 72 h? A week?
Request
POST /rest/v1/rpc/rpc_forecast_skill
{
"variable_name": "wind_speed_10m", // or wind_direction_10m, wave_height, ...
"model_name": "ecmwf_ifs025", // optional โ null = both models
"window_days": 30, // lookback, default 30
"lead_bucket_hours": 6 // bucket width, default 6
}
Response
[
{"model":"ecmwf_ifs025","variable":"wind_speed_10m","lead_bucket":0, "n":168,"mae":1.02,"bias":-0.11,"rmse":1.38},
{"model":"ecmwf_ifs025","variable":"wind_speed_10m","lead_bucket":24, "n":167,"mae":1.84,"bias":-0.22,"rmse":2.41},
{"model":"ecmwf_ifs025","variable":"wind_speed_10m","lead_bucket":72, "n":165,"mae":3.12,"bias":+0.04,"rmse":3.95},
...
]
How it’s computed
- Every forecast row in the window whose
target_at_utcmatches an observedreadings.timestampbecomes a (forecast, observed) pair. - Pairs are grouped by
lead_bucket = floor(lead_hours / lead_bucket_hours) * lead_bucket_hours. - MAE = mean of
|forecast โ observed|. For direction variables, short-arc distance:min(|fโo|, 360โ|fโo|). - Bias = mean signed error. For direction, signed short-arc (NULL is returned instead when circular-bias is meaningless).
- RMSE = โ(mean of errยฒ) using the same error definition as MAE.
- Anon-readable, stable, 30-day rolling lookback default. Full spec in
supabase/migrations/20260423140000_rpc_forecast_skill.sql.
Variable โ observation mapping
| forecast variable | readings column |
|---|---|
wind_speed_10m | avg_wind |
wind_gusts_10m | gust_speed |
wind_direction_10m | wind_dir |
wave_height | wave_height |
wave_period | wave_period |
sea_surface_temperature | water_temp |
wave_direction | no buoy counterpart โ not yet supported |
โ๏ธ Fair use & attribution
- Soft limit: ~60 requests/min on the anonymous key. Burst is fine; sustained load gets noticed.
- Please credit: “Data from Dublin Bay Buoy (dublinbaybuoy.com), sourced from Irish Lights MetOcean and Open-Meteo”.
- Data is provided as-is. Not for navigation.
- Questions, quota requests, or issues โ [email protected].