Skip to main content
Glama
Maritime Vessel Data MCP Server

Maritime Vessel Data MCP Server

Maritime Vessel Data MCP Server

Ask an AI assistant "which tankers are anchored near Port Klang?" and get a real answer, from ships that are broadcasting their positions right now.

This is a Model Context Protocol server. It listens to live AIS radio traffic over Malaysian waters and exposes it as standard MCP tools, so any MCP compatible client can ask about vessels without a bespoke integration.

MIT licensed. Python 3.11 or newer. No account needed to try it, and a free API key to run it live.

A real response, abbreviated:

vessels_near_port("Tanjung Pelepas", 40)

{ "data": { "source": "live", "vessel_count": 63, "oldest_position_age_seconds": 140 },
  "matches": 27,
  "vessels": [
    { "mmsi": "563186500", "name": "ALS CERES", "type": "Cargo", "flag": "Singapore",
      "length_m": 255, "status": "Moored", "destination": "MYTTP",
      "lat": 1.2612, "lon": 103.7895, "distance_nm": 16.1 } ]}

Tools

Tool

What it answers

search_vessels(vessel_type, flag, status, limit)

which ships match a type, flag state or navigational status

vessels_near_port(port, radius_nm, limit)

what is within a radius of a named port, nearest first. Radius must be above 0 and at most 500 nautical miles

vessel_details(query)

everything known about one ship, by MMSI or name

Resource vessels://all returns the whole current picture.

Ports: Port Klang, Tanjung Pelepas, Penang, Malacca, Langkawi.

Both list tools take a limit, default 25, between 1 and 200. Every response reports matches, how many qualified, and returned, how many are included, so a capped answer says so rather than looking complete. For vessels_near_port the nearest are kept.

Responses are serialised compactly and positions are rounded to four decimal places, about 11 metres, which is finer than AIS itself reports. A typical question costs roughly a fifth of what it did before this was tuned. Fields AIS has not reported remain explicitly null rather than being dropped, because absent and unknown are different claims.

Filters are case insensitive, partial, and ignore surrounding whitespace. Bad input is refused rather than answered: a radius outside the allowed range is rejected by the schema before the call runs, and a blank vessel query returns an error asking for a name or MMSI rather than reporting that nothing matched everything.

Related MCP server: @globalfishingwatch/gfw-mcp-js

Quick start

git clone https://github.com/ariefrsee/maritime-mcp-server.git
cd maritime-mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install .

That puts a maritime-mcp-server command on your PATH inside the environment. Check it works without touching the network:

python -m maritime_mcp_server.smoke_test

It should end with All smoke checks passed.

Going live

Without an API key the server answers from a bundled sample of 18 vessels and says so. Get a free key from aisstream.io, then:

export AISSTREAM_API_KEY=your-key-here
maritime-mcp-server

You should see one line, AIS stream connected, and then silence. That is correct: an MCP server over stdio prints no banner and waits for a client.

Give it a minute before asking anything. AIS is a stream, not a database. The server learns about a ship only when that ship transmits, so it starts knowing nothing and fills up over the following minutes. During testing it held 0 vessels at 3 seconds, 40 at 150 seconds and 63 at five minutes. There is no backfill to request; the feed does not replay what you missed.

The subscribed box is 0.5N to 7.5N and 98.5E to 119.5E: the Strait of Malacca, both coasts of the peninsula, and Sabah and Sarawak.

Subscribing to water is not the same as having coverage of it. aisstream is fed by volunteer land-based receivers, and there are very few on the Malaysian coast. Measured over four minutes of live feed, counting distinct vessels within 30 nm of each port:

  Tanjung Pelepas        86        Kuching                 0
  Port Dickson           19        Bintulu                 0
  Malacca                 5        Miri                    0
  Kota Kinabalu           5        Labuan                  0
  Port Klang              0        Sandakan                0
  Penang                  0        Kuantan                 0
  Langkawi                0        Kemaman                 0

Nearly everything the server sees is in the Singapore Strait and around Tanjung Pelepas. Port Klang, Malaysia's largest port, returns nothing. Widening the bounding box does not change that; only a receiver near the port, or a paid feed with satellite AIS, would.

Use it from Claude Desktop

Add the server to claude_desktop_config.json. On macOS that lives at ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "maritime-vessel-data": {
      "command": "/absolute/path/to/.venv/bin/maritime-mcp-server",
      "env": { "AISSTREAM_API_KEY": "your-key-here" }
    }
  }
}

Run pwd in the project directory to get the absolute path. Restart Claude Desktop fully, then ask it something like "which vessels are near Tanjung Pelepas right now?" and it will call these tools for you.

Exporting the variable in your terminal does not reach a client launched process, so it has to go in the env block.

Where the data comes from

Every response opens with a data block naming its source.

"data": { "source": "snapshot", "vessel_count": 18, "snapshot_date": "2026-07-20",
          "note": "Live AIS is unavailable, so this is the bundled sample dataset." }

source is either live or snapshot. Nothing about how you call the tools changes between the two, and a snapshot answer is never mistakable for a live one.

AIS does not transmit everything these tools report. Flag state is derived from the MMSI country digits, length from the transmitted hull dimensions, and nearest port is computed here. Anything AIS has not reported is null, never guessed. A ship broadcasts its position every few seconds and its identity roughly once in ten of those, so a freshly seen vessel often has a position and no name, type or size until it next sends static data.

The feed also carries objects that are not ships, such as navigation buoys and base stations. Those are classified by their MMSI prefix and excluded.

Tests

pip install -e ".[dev]"
pytest

140 tests, well under a second. No API key, no network, no dependence on the clock. The translation layer runs against 199 real AIS messages captured over the Strait of Malacca and committed as a fixture, so it is checked against traffic that genuinely occurred rather than against invented input.

How it is built

Piece

Where

MCP tools and the source seam

maritime_mcp_server/server.py

AIS translation and lookup tables

maritime_mcp_server/ais_mapping.py

Vessel store, merging and expiry

maritime_mcp_server/store.py

Websocket client and reconnect

maritime_mcp_server/collector.py

Bundled fallback dataset

maritime_mcp_server/data/vessels.json

Built with the official mcp SDK, targeting the 2.x MCPServer API and pinned below 3.0.

Every decision, test result and mistake made while building this is written down under .shipline/, one folder per piece of work, including the plans, manual test scripts, retrospectives and runbooks.

Extending it

  • Widen the bounding box in collector.py to cover somewhere other than Malaysia.

  • Add ports to PORT_COORDS in server.py.

  • Add tools such as route ETA or anchorage occupancy. Clients discover them automatically.

  • Switch run() to the HTTP or SSE transport for remote clients, and add authentication.

Licence

MIT. See LICENSE.

Available Tools

14 tools
anchoragesA

Where vessels actually anchor, found in the recorded positions.

No chart is consulted. This looks for water where a lot of vessels reported "At anchor" and groups the touching parts into one place, so what comes back is where ships really wait rather than where they are permitted to. For a question about queueing, the first is the useful one.

Nothing is named. The cluster off eastern Singapore is obviously the eastern anchorage to anyone who works there, but AIS does not say so and this will not invent it. Each is given its extent, not a radius: an anchorage is usually a long thin thing along a coast and a circle would claim water nobody anchors in.

Bear the coverage caveat in mind. An anchorage nothing is listening to does not appear here, and that is not the same as an anchorage nobody uses.

Inputs: bbox, days, region. Returns: result

congestionA

How busy an area has been, hour by hour, from the recorded track.

Counts distinct vessels per hour by what they were reporting: at anchor, moored, or under way. The anchored count is the one that answers "how long is the queue", which is what a charterer rings an agent about.

Each vessel is counted once per hour it was heard in, so a vessel reporting twenty times in an hour counts once. An hour with no coverage reads as zero and is indistinguishable from an hour with no ships, which is why the response reports how many positions each hour was built from.

Inputs: bbox, hours, region. Returns: result

fleet_trackA

Where every vessel has been over the last few hours.

Returns JSON with a "data" block and a "fleet" list, one entry per vessel, each with its identity and its positions oldest first. Intended for playback and for checking work against what was actually observed.

Each position is one AIS report that was received. The gaps between them are real: the median vessel reports only a handful of times an hour, so two fixes an hour apart are two observations and not a path. Nothing is interpolated.

When "truncated" is true the row cap was reached and this is not the whole picture. Narrow the window rather than assuming the missing vessels are gone.

Inputs: hours, limit. Returns: result

list_regionsA

List the sea areas this server can watch, and what each one costs.

The feed is rationed by throughput rather than by area. A subscription above roughly 25 messages a second is closed by the vendor within a couple of minutes, and a whole-world subscription dies inside one, so a selection has to be chosen to fit a budget rather than simply widened.

Each region reports its measured message rate. A rate of null means nobody has measured that region yet, and a selection containing one has an unknown total: that is reported as unknown rather than as a partial sum. Use set_regions to change what is being watched.

Inputs: no named parameters. Returns: result

passage_timeA

How long this leg actually takes, from ships that made it.

Not distance divided by an assumed speed. That knows nothing about the strait, the traffic, the pilot boarding, or the hour spent waiting for a berth at the far end. This is measured from recorded tracks.

The spread comes with the middle, always. A leg whose median is fourteen hours and whose range is seven to sixty two is not a leg anyone should schedule to fourteen, and reporting the median alone would invite exactly that. Below five observed passages no median is given at all.

Time spent waiting at the origin is not passage time: the clock starts when the vessel leaves the origin's reach, not when it was first seen there. A vessel seen arriving but never departing is not counted, because a record that starts mid-voyage is not evidence of a voyage.

Inputs: origin (required), destination (required), days. Returns: result

port_callsA

When a vessel stopped, where, for how long, and whether she was working.

This is the skeleton of a Statement of Facts, read out of the recorded track rather than typed from memory: arrival, time alongside or at anchor, departure. Anchored and moored are kept apart, because in laytime terms one is waiting and the other is working.

Everything reported was observed. A vessel that has not been seen to leave has no departure time rather than an assumed one; a stop spanning a hole in coverage is cut at the hole rather than claimed as continuous; and a stop far from any known port is reported as at sea rather than given the name of the nearest one. Where an arrival was not witnessed, because the record starts mid-stop or follows a coverage hole, arrival_observed is false and the arrival time is only when this server first saw her there.

Inputs: query (required), days. Returns: result

search_vesselsA

Search the vessel dataset by type, flag state, and/or navigational status.

Any argument left blank is ignored, and surrounding whitespace is ignored. Returns JSON with a "data" block stating the source, a "matches" count of everything that matched, a "returned" count of how many are included, and a "vessels" list. When returned is less than matches the list was capped by the limit argument: raise it or narrow the search to see the rest. When data.source is "snapshot" the positions are from a fixed sample dataset and are not current: say so when answering. Fields that AIS has not reported yet are null, never guessed.

Inputs: flag, limit, status, vessel_type. Returns: result

set_regionsA

Change which sea areas the live subscription watches.

Applied to the open connection where possible, so the feed is not interrupted. Vessels already collected are not discarded: the store keeps them until they age out of its 30-minute window, so a region just switched away from fades rather than vanishing.

A selection over the throughput budget is accepted and reported, not refused. The caller asked for it, and the honest answer is that the feed will drop and reconnect repeatedly rather than a limit that does not exist.

Inputs: region_keys (required). Returns: result

traffic_densityA

Where vessels have actually been, as a grid of counts.

Read this as coverage as much as traffic. It shows where positions were received, so an empty cell means either that no ship went there or that nothing was listening. Port Klang reads as empty on this map and it is one of the busiest ports in the world.

Each cell reports distinct vessels and total positions separately, because they answer different questions: one ship anchored for two days makes hundreds of positions in a single cell, which is a fact about that ship and not about how busy the cell is.

Inputs: bbox, days, region, cell_degrees. Returns: result

vessel_detailsA

Look up a single vessel by exact MMSI or by (partial) name.

Returns JSON with a "data" block stating the source and a "vessel" record, or an error object if no unique match is found. When data.source is "snapshot" the position is not current. Null fields mean AIS has not reported that detail, not that the value is zero or unknown to the vessel.

Inputs: query (required). Returns: result

vessels_in_areaA

Vessels inside one area of sea, rather than everywhere the server watches.

This exists because a selection covering several regions can hold far more vessels than any one caller wants at once, and sending all of them is expensive for an answer about one strait. Ask for the water you care about.

The area filters what has already been collected, which is a different question from what is subscribed to: a region switched away from still has vessels in the store until they age out, and an area nobody is subscribed to simply returns nothing.

Inputs: bbox, limit, region. Returns: result

vessels_near_portA

List vessels within a radius (nautical miles) of a named port.

Recognized ports: Port Klang, Tanjung Pelepas, Penang, Malacca, Langkawi. Returns JSON with a "data" block stating the source, a "matches" count of everything inside the radius, a "returned" count of how many are included, and a "vessels" list annotated with distance_nm, nearest first. When returned is less than matches the nearest were kept and the rest omitted. When data.source is "snapshot" the positions are not current: say so when answering.

Inputs: port (required), limit, radius_nm. Returns: result

vessels_watchedA

Vessels inside the regions currently being watched, and nothing else.

The store keeps every vessel it has collected until the position ages out of the thirty minute window, which includes vessels from a region that has since been deselected. That is the right thing for the store to do and the wrong thing to show: someone who selects Malaysia means show me Malaysia, not Malaysia plus whatever was on screen ten minutes ago.

With no live subscription there is nothing being watched, so no filter applies and everything is returned with a note saying so.

Inputs: limit. Returns: result

vessel_trackA

Where a vessel has been over the last few hours.

Returns JSON with a "data" block and a "track" list of positions, oldest first. Each position is one AIS report that was actually received: the gaps between them are real, and nothing is interpolated to fill them.

History only exists from the point this server started keeping it. An empty track means nothing was heard, not that the vessel did not move.

Inputs: query (required), hours. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.1/5.0

    Scored across 14 tools

    Disambiguation5/5

    Each tool targets a clearly distinct resource or analytical question: vessel search, nearby vessels, area/watched filters, single/fleet tracks, region subscription, and derived metrics like passage time, port calls, congestion, density, and anchorages. Even the geographically overlapping tools are differentiated by their exact filter semantics and described with enough precision to prevent misselection.

    Naming Consistency3/5

    Names are readable and consistently snake_case, but the pattern is mixed: some are verb-first (search_vessels, list_regions, set_regions) while most are descriptive noun phrases (vessel_track, port_calls, traffic_density, anchorages). There is no single consistent convention, though the names remain self-explanatory.

    Tool Count5/5

    Fourteen tools is within the ideal range and each tool earns its place: discovery, details, tracks, region subscription, and specialized maritime analytics are all represented without redundant or filler tools. The count matches the breadth of the domain well.

    Completeness5/5

    The server covers the full read-only AIS workflow: finding vessels, retrieving details, viewing current positions and historical tracks, managing region subscriptions, and producing derived insights like passage times, port calls, congestion, traffic density, and anchorages. No obvious dead ends or missing operations are apparent for its stated purpose.

    Maintenance

    ActivitySlowing
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers