Skip to main content
Glama
alirezashamsaei

CoastWise MCP

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 favorable verdict 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 test

Command

What it does

Expected result

npm ci

installs dependencies from package-lock.json

exits 0

npm run build

compiles TypeScript to dist/

exits 0, no output

npm test

runs the vitest suite

Test Files 7 passed (7), Tests 141 passed (141)

npm start

runs the server on stdio

logs coastwise-mcp running on stdio to stderr, then waits for JSON-RPC on stdin

npm run inspect

builds, then launches MCP Inspector

prints a URL on port 6274


Related MCP server: ocean-conditions-agent

Demo: MCP Inspector walkthrough

npm run inspect

The 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:

  1. Choose the stdio transport and point it at the build output: command node, argument dist/index.js, working directory left as the project root — then connect. The tool list shows smoke_check, get_beach_forecast, rate_activity_conditions, find_best_activity_window.

  2. Open the Tools tab, pick rate_activity_conditions, and paste:

{
  "latitude": 36.53,
  "longitude": -6.29,
  "date": "2026-10-27",
  "activity": "surfing",
  "skillLevel": "intermediate"
}
  1. 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_day

Swap --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')   # PowerShell

Expected 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:

  • location is the forecast grid cell, not your coordinate — Open-Meteo snaps to its own grid (and the marine grid snaps to 0.0833° cells, which can be several km away).

  • You can predict 58 from the tables below: waves at 0.26 m fall in < 0.3 m → −2 × weight 4 = −8; wind 6.4 km/h in < 12 km/h → +2 × 3 = +6; temperature in 18–22 °C → 0. Sum the Contributions to raw = 6, then round((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

latitude

number

−90 … 90

longitude

number

−180 … 180

date

YYYY-MM-DD

the beach's local calendar day; today … today+6

activity

beach_day | surfing

selects the rule set

skillLevel

beginner | intermediate | advanced

required iff activity === "surfing"; reported back but ignored for beach_day

window

{ start, end } as HH:MM

same-day, start < end; omitted → 07:00–19:00

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 below

Bands 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

insufficient_data

a Veto fires (below)

unfavorable

value ≥ 70

favorable

value ≥ 40

mixed

otherwise

unfavorable

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 (temperature_2m, wave_height, …)

mean across the window

the window is an average of conditions

weather_code

mode, first-seen wins ties

WMO codes are categorical; averaging 0 and 3 invents 1.5, a code nobody reported

any Factor absent from any hour

window is insufficient_data

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

beach_day

surfing

Required

temperature_2m

4

2

both

weather_code

4

1

scored for both; required for beach_day only

precipitation

3

1

both

wind_speed_10m

3

3

both

wave_height

1

4

surfing

wave_period

—

3

surfing

uv_index

2

—

optional

wind_gusts_10m

1

1

optional

sea_surface_temperature

1

1

optional (needs marine)

swell_wave_height

—

2

optional

swell_wave_period

—

1

optional

Required sets (ADR-0004):

Activity

Required Factors

beach_day

temperature_2m, weather_code, precipitation, wind_speed_10m

surfing

wave_height, wave_period, wind_speed_10m, temperature_2m, precipitation

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

< 14

−2

14–18

−1

18–22

0

22–26

+1

26–32

+2

32–35

+1

≥ 35

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

0 clear, 1 mainly clear

+2

2 partly cloudy

+1

3 overcast

0

45 fog, 48 depositing rime fog

−1

51–57 drizzle, 61–65 rain, 80–82 rain showers

0

66, 67, 71–77, 85, 86 frozen precipitation

−2 and VETO

95, 96, 99 thunderstorm

−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

= 0

+2

> 0 – 0.5

+1

0.5 – 2

0

2 – 10

−1

> 10

−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

< 12

+2

12–20

+1

20–28

0

28–40

−1

> 40

−2

uv_index

Range

Score

0–2

0

3–5

+1

6–7

+2

8–10

+1

> 10

0

High UV is not a beach positive — burn risk, so the curve rises then falls.

wind_gusts_10m — km/h

Range

Score

< 20

+2

20–30

+1

30–40

0

40–55

−1

> 55

−2

wave_height — m

beach_day (smaller is calmer):

Range

Score

< 0.3

+2

0.3–0.7

+1

0.7–1.2

0

1.2–2.0

−1

> 2.0

−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

< 0.3

−2

−2

−2

0.3–0.4

+1

−2

−2

0.4–0.5

+1

+1

−2

0.5–1.0

+2

+1

0

1.0–1.3

0

+1

0

1.3–1.8

−1

+2

+1

1.8–2.0

−2

+2

+1

2.0–2.2

−2

0

+2

2.2–2.5

−2

−1

+2

2.5–3.2

−2

−2

+2

3.2–4.0

−2

−2

0

> 4.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

< 5

−2

5–7

−1

7–9

0

9–12

+1

> 12

+2

swell_wave_height — m

Range

Score

< 0.3

−1

0.3–1.5

+2

1.5–2.5

+1

2.5–3.5

0

> 3.5

−1

sea_surface_temperature — °C

Range

Score

< 14

−1

14–18

0

18–26

+2

26–29

+1

≥ 29

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

thunderstorm

weather_code ∈ {95, 96, 99}

both

frozen_precipitation

weather_code ∈ {66, 67, 71, 73, 75, 77, 85, 86}

both

wave_above_skill_ceiling

wave_height above the ceiling in the table above

surfing only

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:

  1. The score is a suitability heuristic, not a safety guarantee.

  2. No tide data — Open-Meteo has no tide API; sea_level_height_msl is mean-datum and "not suitable for coastal navigation."

  3. No rip currents, beach flags, surf ratings, or lifeguard warnings exist in any Open-Meteo product.

  4. Wave direction is not scored — shore orientation is unknowable from a coordinate.

  5. Offshore vs onshore wind is not distinguished — same reason.

  6. Marine coverage is fragile within ~15 km of open water; beyond that, surfing returns insufficient_data.

  7. 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

https://api.open-meteo.com/v1/forecast

Marine

https://marine-api.open-meteo.com/v1/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:

Weather data by Open-Meteo.com

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 isError result; a 200 full of nulls is a normal response that yields insufficient_data with missing[].

  • 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.error only.


Decision records

Why the design is shaped this way:

ADR

Decision

0001

Additive score with hard vetoes

0002

Each tool fetches its own forecast

0003

MCP SDK v1 over v2

0004

Per-activity required-factor sets

0005

Never pass cell_selection

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 records

License

MIT — see LICENSE.

Related MCP Connectors

Related MCP Servers