CoastWise MCP
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@CoastWise MCPis El Palmar good for a beach day tomorrow?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
CoastWise MCP
CoastWise answers one question over MCP: does this beach suit a beach day or a surf session, today or in the next six days? It evaluates forecast data with deterministic, documented rules — the same inputs always produce the same output, and every number in the response can be reproduced from the tables below.
Read this before you trust a score
The score is a suitability heuristic, not a safety guarantee. CoastWise weighs forecast numbers against tuning knobs that a human chose. It knows nothing about tides, rip currents, beach flags, lifeguard warnings, or the actual state of the sea at your feet. Never treat a
favorableverdict as permission to enter the water.
No LLM inference inside the server. The model never scores anything; the rules do.
Forecast data: Open-Meteo.com — weather and marine APIs, no API key required for non-commercial use.
Attribution: Weather data by Open-Meteo.com (CC BY 4.0).
Quick start
Requires Node.js 22.12 or newer — CI runs the suite on 22 and 24, and the test toolchain (vitest 5, which drives rolldown) refuses anything older. Node 18 and 20 are past end-of-life and are not supported. You also need network access to api.open-meteo.com and marine-api.open-meteo.com.
git clone https://github.com/alirezashamsaei/coastwise-mcp.git
cd coastwise-mcp
npm ci
npm run build
npm testCommand | What it does | Expected result |
| installs dependencies from | exits 0 |
| compiles TypeScript to | exits 0, no output |
| runs the vitest suite |
|
| runs the server on stdio | logs |
| builds, then launches MCP Inspector | prints a URL on port 6274 |
Related MCP server: ocean-conditions-agent
Demo: MCP Inspector walkthrough
npm run inspectThe Inspector prints:
MCP Inspector Web is up and running at:
http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=<token>Port 6274 is the Inspector's current default (Sandbox: 6275, verified against Inspector 2.12.0). The token changes on every run, so open the exact URL your terminal printed rather than typing the address by hand. If a browser does not open, copy that URL yourself.
Then:
Choose the stdio transport and point it at the build output: command
node, argumentdist/index.js, working directory left as the project root — then connect. The tool list showssmoke_check,get_beach_forecast,rate_activity_conditions,find_best_activity_window.Open the Tools tab, pick
rate_activity_conditions, and paste:
{
"latitude": 36.53,
"longitude": -6.29,
"date": "2026-10-27",
"activity": "surfing",
"skillLevel": "intermediate"
}Press Run tool.
Prefer the terminal? The same Inspector, in CLI mode, runs the whole flow non-interactively — this is what produced the output quoted below:
npx --yes @modelcontextprotocol/inspector --cli node dist/index.js \
--method tools/call --tool-name find_best_activity_window \
--tool-arg latitude=36.53 --tool-arg longitude=-6.29 \
--tool-arg date=2026-10-27 --tool-arg activity=beach_daySwap --method tools/list in for the tool inventory, or --tool-name rate_activity_conditions plus --tool-arg skillLevel=intermediate for the Suitability score.
36.53, -6.29 is El Palmar, San Fernando (Cádiz, Spain). date must fall inside today … today+6 — a date outside that range is an input error, not a verdict. Use tomorrow's date if the quoted one has aged out:
date -v+1d +%F # macOS
date -d tomorrow +%F # Linux
(Get-Date).AddDays(1).ToString('yyyy-MM-dd') # PowerShellExpected output (captured 2026-10-26 for 2026-10-27)
Forecast values shift as the upstream model updates, so your numbers will differ; the shape and the ordering rules are what to check.
Response excerpt — factors shows 3 of its rows and limitations is truncated:
{
"verdict": "mixed",
"score": {
"value": 58,
"raw": 6,
"attainableRange": { "min": -38, "max": 38 },
"thresholds": { "favorable": 70, "mixed": 40 }
},
"veto": null,
"factors": [
{ "factor": "temperature_2m", "label": "Air temperature", "unit": "°C",
"value": 19.615384615384617, "band": "18–22 °C", "score": 0,
"weight": 2, "contribution": 0, "required": true, "status": "scored" },
{ "factor": "wind_speed_10m", "label": "Wind speed", "unit": "km/h",
"value": 6.446153846153846, "band": "< 12 km/h", "score": 2,
"weight": 3, "contribution": 6, "required": true, "status": "scored" },
{ "factor": "wave_height", "label": "Wave height", "unit": "m",
"value": 0.2630769230769231, "band": "< 0.3 m", "score": -2,
"weight": 4, "contribution": -8, "required": true, "status": "scored" }
],
"improving": ["wind_speed_10m", "wave_period", "precipitation", "wind_gusts_10m",
"sea_surface_temperature", "weather_code"],
"reducing": ["wave_height", "swell_wave_height"],
"missing": [],
"window": { "start": "07:00", "end": "19:00", "defaulted": true,
"timezone": "Europe/Madrid" },
"location": { "latitude": 36.5245, "longitude": -6.2036605,
"timezone": "Europe/Madrid", "utcOffsetSeconds": 3600 },
"limitations": [ "…", "…" ]
}Two things to notice:
locationis the forecast grid cell, not your coordinate — Open-Meteo snaps to its own grid (and the marine grid snaps to0.0833°cells, which can be several km away).You can predict
58from the tables below: waves at0.26 mfall in< 0.3 m→−2× weight4= −8; wind6.4 km/hin< 12 km/h→+2×3= +6; temperature in18–22 °C→0. Sum the Contributions toraw = 6, thenround((6 − (−38)) / (38 − (−38)) × 100) = round(57.89) = 58, which sits between 40 and 70 →mixed.
Now the same call to find_best_activity_window with "activity": "beach_day" (excerpt — 3 of the 11 candidates shown):
{
"searched": { "start": "07:00", "end": "19:00", "timezone": "Europe/Madrid" },
"windowHours": 3,
"strideHours": 1,
"best": { "start": "2026-10-27T14:00", "end": "2026-10-27T16:00",
"score": 92, "verdict": "favorable" },
"reason": null,
"candidates": [
{ "start": "2026-10-27T14:00", "end": "2026-10-27T16:00", "score": 92, "verdict": "favorable" },
{ "start": "2026-10-27T15:00", "end": "2026-10-27T17:00", "score": 91, "verdict": "favorable" },
{ "start": "2026-10-27T13:00", "end": "2026-10-27T15:00", "score": 87, "verdict": "favorable" }
],
"evaluatedHours": 13,
"skippedHours": [],
"limitations": [ "…", "…" ]
}Eleven candidates were evaluated (07:00 … 17:00 starts, hourly stride, 3 hours each), listed score descending, earliest first on ties — and best is always simply the top entry. Had a required Factor been absent from a middle hour, the three blocks spanning that hour would not appear here at all, and the hour itself would land in skippedHours with the reason: a gap disqualifies, it never bridges. When nothing qualifies, best is null with a human-readable reason.
The three tools
All three share the same location/date core, each fetches its own forecast through one shared module (ADR-0002). There is no cache and no cross-tool state. (smoke_check is a fourth, trivial health check that echoes text — it is for confirming the transport works.)
Shared input
Field | Type | Rules |
| number | −90 … 90 |
| number | −180 … 180 |
|
| the beach's local calendar day; today … today+6 |
|
| selects the rule set |
|
| required iff |
|
| same-day, |
Bad input (out-of-range coordinate, malformed date, date beyond the horizon, surfing without skillLevel) returns an MCP error result — isError: true with a message. It is never dressed up as a verdict.
1. get_beach_forecast
Raw values for one date, with units, coverage and provenance. No verdict, no score — this is the "show me the numbers and what's missing" surface, and it must work even when a verdict cannot.
In: { latitude, longitude, date }
Out:
{
location: { latitude, longitude, timezone, utcOffsetSeconds },
date: string,
sources: Array<{ api, url, attribution, issuedAt }>, // both endpoints, CC BY 4.0
weather: HourlyBlock,
marine: HourlyBlock,
missing: Array<{ factor, scope: 'weather'|'marine', reason }>,
limitations: string[],
}
HourlyBlock = {
available: boolean, // false when every value in the block is null
units: Record<string, string>,
hours: Array<{ time: string, values: Record<string, number | null> }>,
}Only the 24 hours of the requested date are returned. Beyond ~15 km from open water the marine API answers HTTP 200 with a full-length array of nulls — that is reported as marine.available = false, not as an error.
2. rate_activity_conditions
The verdict for one place, one date, one activity over one window of hours.
In: shared input (including activity, optional skillLevel, optional window).
Out:
{
verdict: 'favorable' | 'mixed' | 'unfavorable' | 'insufficient_data',
score: null | {
value, // 0–100, rounded
raw, // signed point total
attainableRange: { min, max },
thresholds: { favorable: 70, mixed: 40 },
},
veto: null | {
id, // 'thunderstorm' | 'frozen_precipitation' | 'wave_above_skill_ceiling'
factor, value, unit, reason,
overriddenScore, // the score the rules produced before the override
},
factors: Array<{ factor, label, unit, value, band, score, weight,
contribution, required,
status: 'scored' | 'optional_missing' | 'context_only' }>,
improving: string[], // contributions > 0, largest first
reducing: string[], // contributions < 0, largest first
missing: Array<{ factor, reason }>,
window: { start, end, timezone, defaulted },
location: { latitude, longitude, timezone },
unusedSkillLevel?, // present when skillLevel was sent with beach_day
limitations: string[],
}insufficient_data → score: null, veto: null, missing populated. A Veto never hides the arithmetic: veto.overriddenScore shows what the score would otherwise have been.
3. find_best_activity_window
Slides a fixed 3-hour block across the requested window at hourly stride, scores each block by the mean of its hours, and names the most suitable stretch — or says plainly that none exists.
In: shared input; window is the search bounds.
Out:
{
searched: { start, end, timezone },
windowHours: 3,
strideHours: 1,
best: null | { start, end, score, verdict },
reason: string | null, // set iff best === null, never an empty object
candidates: Array<{ start, end, score, verdict }>, // qualifying blocks only, score desc
evaluatedHours: number,
skippedHours: Array<{ time, reason }>,
limitations: string[],
}A candidate qualifies only when all three consecutive hours carry every required Factor; ties resolve to the earliest. Each block is scored with exactly the code path rate_activity_conditions uses, so the two tools agree verdict-for-verdict — including on veto days, because this tool runs after the veto rules.
How a score is computed
Four steps, in order. Nothing here is learned, randomised, or fetched beyond the forecast.
Stage 1 — contribution
band = the band in the Factor's table whose range contains the value
score = band.score // −2 … +2
contribution = score × weight // weight from the table belowBands are inclusive-low, exclusive-high: a value sitting exactly on a boundary takes the upper band.
Considered = every required Factor, plus any optional Factor that has a value. An optional Factor with no value is skipped entirely — it neither adds points nor widens the attainable range. A value that no band covers is reported as context_only: no score, no contribution, and no guessed substitute.
Stage 2 — normalize
raw = Σ contribution
attainable = { min: Σ(−2 × weight), max: Σ(+2 × weight) } // over considered Factors only
value = round( (raw − min) / (max − min) × 100 )Normalizing over the range attainable for this exact Factor set is why a day scored with 4 Factors and a day scored with 8 Factors land on the same 0–100 scale.
Stage 3 — verdict
Condition | Verdict |
a required Factor is missing, or nothing was scored |
|
a Veto fires (below) |
|
|
|
|
|
otherwise |
|
Never: substitute an assumed value for missing data; score missing data as 0; fire a Veto on evidence we do not have.
Stage 4 — window aggregation
Both evaluators score a window of hours, not an instant. The hours collapse into one value per Factor first:
Factor kind | Rule | Why |
continuous ( | mean across the window | the window is an average of conditions |
| mode, first-seen wins ties | WMO codes are categorical; averaging |
any Factor absent from any hour | window is | partial coverage is not coverage |
A Veto is evaluated against every hour in the window, not against the aggregate: one thunderstorm in the window disqualifies it, even when the modal code is clear sky.
The rules, in full
These numbers are documented tuning knobs, not findings. They are reproduced here verbatim so you can predict a score without reading the code. Boundaries are inclusive-low, exclusive-high.
Weights
Factor |
|
| Required |
| 4 | 2 | both |
| 4 | 1 | scored for both; required for |
| 3 | 1 | both |
| 3 | 3 | both |
| 1 | 4 | surfing |
| — | 3 | surfing |
| 2 | — | optional |
| 1 | 1 | optional |
| 1 | 1 | optional (needs marine) |
| — | 2 | optional |
| — | 1 | optional |
Required sets (ADR-0004):
Activity | Required Factors |
|
|
|
|
cloud_cover and visibility are reported, not scored — weather_code already encodes sky condition, and scoring both would double-count. wave_direction is not scored: shore orientation is unknowable from a coordinate.
temperature_2m — °C
Range | Score |
| −2 |
| −1 |
| 0 |
| +1 |
| +2 |
| +1 |
| 0 |
40 °C scores 0, not negative: the curve flattens rather than falls. If you want extreme heat penalized, change this row and the tests that assert it.
weather_code — WMO
Code | Score |
| +2 |
| +1 |
| 0 |
| −1 |
| 0 |
| −2 and VETO |
| −2 and VETO |
Liquid-precipitation codes score 0 deliberately: precipitation carries that signal, and scoring both would double-count. An unrecognised WMO code has no band at all — it is reported as context_only, never guessed.
precipitation — mm/h
Range | Score |
| +2 |
| +1 |
| 0 |
| −1 |
| −2 |
The = 0 row is a zero-width point band; every other band is inclusive-low, exclusive-high, so 0.5 mm/h scores 0.
wind_speed_10m — km/h
Range | Score |
| +2 |
| +1 |
| 0 |
| −1 |
| −2 |
uv_index
Range | Score |
| 0 |
| +1 |
| +2 |
| +1 |
| 0 |
High UV is not a beach positive — burn risk, so the curve rises then falls.
wind_gusts_10m — km/h
Range | Score |
| +2 |
| +1 |
| 0 |
| −1 |
| −2 |
wave_height — m
beach_day (smaller is calmer):
Range | Score |
| +2 |
| +1 |
| 0 |
| −1 |
| −2 |
surfing — a curve with a sweet spot per Skill level, because flat water and closing-out surf are both bad. Each table is total and open-ended at the top, so no value can escape the bands.
Range | beginner | intermediate | advanced |
ceiling (Veto above) | 1.5 m | 2.5 m | 4.0 m |
| −2 | −2 | −2 |
| +1 | −2 | −2 |
| +1 | +1 | −2 |
| +2 | +1 | 0 |
| 0 | +1 | 0 |
| −1 | +2 | +1 |
| −2 | +2 | +1 |
| −2 | 0 | +2 |
| −2 | −1 | +2 |
| −2 | −2 | +2 |
| −2 | −2 | 0 |
| −2 | −2 | −2 |
Read as three separate tables at implementation time; this grid is documentation shorthand. Past a ceiling the curve has already fallen to its floor, so the band and the Veto agree about what the day is worth.
wave_period / swell_wave_period — s
Range | Score |
| −2 |
| −1 |
| 0 |
| +1 |
| +2 |
swell_wave_height — m
Range | Score |
| −1 |
| +2 |
| +1 |
| 0 |
| −1 |
sea_surface_temperature — °C
Range | Score |
| −1 |
| 0 |
| +2 |
| +1 |
| 0 |
Vetoes
Three. Each forces unfavorable regardless of the score, and carries a human-readable reason plus the overriddenScore it beat.
id | Fires when | Applies to |
|
| both |
|
| both |
|
|
|
Wind gusts are deliberately not a Veto — high gusts already score poorly, and a Veto that fires on a merely unpleasant day makes the tool look alarmist. No Veto ever fires on missing evidence.
Limitations
Every tool response carries all seven in a limitations array, so a client cannot miss them:
The score is a suitability heuristic, not a safety guarantee.
No tide data — Open-Meteo has no tide API;
sea_level_height_mslis mean-datum and "not suitable for coastal navigation."No rip currents, beach flags, surf ratings, or lifeguard warnings exist in any Open-Meteo product.
Wave direction is not scored — shore orientation is unknowable from a coordinate.
Offshore vs onshore wind is not distinguished — same reason.
Marine coverage is fragile within ~15 km of open water; beyond that,
surfingreturnsinsufficient_data.Marine horizon ≈ 7 days, shorter than weather's.
Explicitly out of scope: nearby-business search, route planning, a frontend, a database, remote/HTTP transport, cloud hosting.
Data source and attribution
Purpose | Endpoint |
Weather |
|
Marine |
|
Note the marine API lives on its own host — api.open-meteo.com/v1/marine is a 404.
Requests pass timezone=auto (so every timestamp is beach-local, no offset) and cap forecast_days at 7 so the weather and marine horizons stay aligned. cell_selection is never passed — see ADR-0005.
Attribution is mandatory under CC BY 4.0. Please credit:
Free tier: 10,000 calls/day, 5,000/hour, 600/minute, no API key for non-commercial use.
Architecture invariants
These hold across the whole codebase; a change that breaks one is a bug.
Deterministic rules. Same inputs, same output. No clock, no randomness, no LLM inference inside the server.
One module does HTTP (
src/forecast.ts), through an injectable fetcher. No test touches the network — every suite drives a fake fetcher, so CI runs offline and cannot flake on upstream.Every tool fetches its own forecast (ADR-0002): no cache, no cross-tool state.
Upstream failure ≠ missing data. An HTTP error is an MCP
isErrorresult; a 200 full ofnulls is a normal response that yieldsinsufficient_datawithmissing[].Missing data is reported, never substituted, never scored as
0, and never Vetoed on.No
console.log— it would corrupt the stdio JSON-RPC stream.console.erroronly.
Decision records
Why the design is shaped this way:
ADR | Decision |
Additive score with hard vetoes | |
Each tool fetches its own forecast | |
MCP SDK v1 over v2 | |
Per-activity required-factor sets | |
Never pass |
The full specification lives in docs/spec.md; the shared vocabulary in CONTEXT.md.
Project layout
src/
index.ts server wiring, stdio transport, tool registrations
schemas.ts zod input schemas
forecast.ts the shared Open-Meteo fetcher (the only module that does HTTP)
bands.ts the band tables above, as data
scoring.ts band lookup, contribution, normalization, verdict
vetoes.ts the three veto rules
evaluate.ts window aggregation + veto application
window.ts the 3-hour sliding search
tools.ts tool handlers
types.ts output types
constants.ts endpoints, attribution, limitations, thresholds
test/ vitest; no suite touches the network
docs/adr/ decision recordsLicense
MIT — see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Nearest NOAA buoy readings, tides and 7-day swell forecast for 150+ surf spots.
7 day surf forecasts (wave, wind, period) for any coordinate or named surf spot.
Wind, swell and tide conditions per foil spot; sessions and coaching for signed-in users.
Live scuba dive planning: forecast windows, best-time climatology, species seasons, 4,800+ sites.
Related MCP Servers
- FlicenseNot gradedqualityCmaintenanceProvides surf forecast data including swell height, period, direction, and wind conditions for any location worldwide using the Open-Meteo Marine API. It also includes tools to find the best day to surf and integrates with MCP clients via a Python server.21-
- FlicenseNot gradedqualityCmaintenanceEnables checking live tide, swell, and wind data for La Jolla surf spots, with an AI agent that makes judgment calls like a local surfer.-
- FlicenseNot gradedqualityDmaintenanceProvides AI assistants read-only access to discover and compare 184,900+ beaches, lakes, and swimming spots worldwide with current planning signals.-
- FlicenseNot gradedqualityBmaintenanceProvides current weather, forecasts, trend predictions, and personalized recommendations for any location using the Open-Meteo API.-