@globalfishingwatch/mcp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.vesselEvents = vesselEvents;
4
+ exports.register = register;
5
+ const zod_1 = require("zod");
6
+ const api_js_1 = require("../lib/api.js");
7
+ const map_url_generator_js_1 = require("../lib/map-url-generator.js");
8
+ const response_js_1 = require("../lib/response.js");
9
+ const types_js_1 = require("../lib/types.js");
10
+ const datasetsByType = {
11
+ fishing: 'public-global-fishing-events:latest',
12
+ encounter: 'public-global-encounters-events:latest',
13
+ port_visit: 'public-global-port-visits-events:latest',
14
+ loitering: 'public-global-loitering-events:latest',
15
+ };
16
+ async function vesselEvents({ eventType, startDate, endDate, vesselId, limit, offset, confidence, encounterTypes, regionType, regionId, }) {
17
+ if (confidence !== undefined && eventType !== 'port_visit') {
18
+ return (0, response_js_1.createErrorResponse)('The confidence filter is only valid when eventType is "port_visit".');
19
+ }
20
+ if (encounterTypes !== undefined && eventType !== 'encounter') {
21
+ return (0, response_js_1.createErrorResponse)('The encounterTypes filter is only valid when eventType is "encounter".');
22
+ }
23
+ if ((regionType === undefined) !== (regionId === undefined)) {
24
+ return (0, response_js_1.createErrorResponse)('regionType and regionId must be provided together.');
25
+ }
26
+ const maxResults = limit ?? 20;
27
+ const pageOffset = offset ?? 0;
28
+ const dataset = datasetsByType[eventType];
29
+ const startIso = startDate
30
+ ? `${startDate}T00:00:00.000Z`
31
+ : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
32
+ const endIso = endDate
33
+ ? `${endDate}T23:59:59.999Z`
34
+ : new Date().toISOString();
35
+ const params = {
36
+ 'datasets[0]': dataset,
37
+ 'start-date': startIso,
38
+ 'end-date': endIso,
39
+ limit: String(maxResults),
40
+ offset: String(pageOffset),
41
+ sort: '-start',
42
+ };
43
+ if (vesselId)
44
+ params['vessels[0]'] = vesselId;
45
+ if (regionType && regionId) {
46
+ params['region-ids[0]'] = regionId;
47
+ params['region-datasets[0]'] = types_js_1.REGION_DATASETS[regionType];
48
+ }
49
+ if (eventType === 'port_visit') {
50
+ const confidenceList = confidence ?? [4];
51
+ confidenceList.forEach((v, i) => {
52
+ params[`confidences[${i}]`] = String(v);
53
+ });
54
+ }
55
+ if (eventType === 'encounter') {
56
+ const encounterTypeList = encounterTypes ?? ['CARRIER-FISHING', 'SUPPORT-FISHING'];
57
+ const expanded = [];
58
+ for (const v of encounterTypeList) {
59
+ expanded.push(v);
60
+ if (v !== 'FISHING-FISHING') {
61
+ expanded.push(v.split('-').reverse().join('-'));
62
+ }
63
+ }
64
+ [...new Set(expanded)].forEach((v, i) => {
65
+ params[`encounter-types[${i}]`] = v;
66
+ });
67
+ }
68
+ const response = await (0, api_js_1.gfwFetch)('/v3/events', params);
69
+ const data = await response.json();
70
+ const entries = data.entries.map((e) => {
71
+ const extrafields = {};
72
+ if (eventType === 'port_visit') {
73
+ extrafields['port'] = {
74
+ name: e.port_visit?.intermediateAnchorage?.name ?? null,
75
+ id: e.port_visit?.intermediateAnchorage?.id ?? null,
76
+ flag: e.port_visit?.intermediateAnchorage?.flag ?? null,
77
+ };
78
+ }
79
+ else if (eventType === 'encounter') {
80
+ extrafields['encounteredVessel'] = {
81
+ name: e.encounter?.vessel?.name ?? null,
82
+ id: e.encounter?.vessel?.id ?? null,
83
+ flag: e.encounter?.vessel?.flag ?? null,
84
+ ssvid: e.encounter?.vessel?.ssvid ?? null,
85
+ };
86
+ }
87
+ return {
88
+ id: e.id,
89
+ type: e.type,
90
+ start: e.start,
91
+ end: e.end,
92
+ lat: e.position.lat,
93
+ lon: e.position.lon,
94
+ vesselId: e.vessel.id,
95
+ regions: {
96
+ mpa: e.regions.mpa ?? [],
97
+ eez: e.regions.eez ?? [],
98
+ rfmo: e.regions.rfmo ?? [],
99
+ fao: e.regions.fao ?? [],
100
+ },
101
+ ...extrafields,
102
+ };
103
+ });
104
+ const mapUrl = vesselId && startDate && endDate
105
+ ? (0, map_url_generator_js_1.generateVesselProfileUrl)(vesselId, startDate, endDate, [eventType])
106
+ : null;
107
+ return {
108
+ total: data.total,
109
+ limit: data.limit,
110
+ offset: data.offset,
111
+ nextOffset: data.nextOffset || 0,
112
+ entries,
113
+ mapUrl,
114
+ };
115
+ }
116
+ function register(server) {
117
+ server.registerTool('vessel-events', {
118
+ title: 'Vessel Events Lookup',
119
+ description: "Retrieve fishing events from the Global Fishing Watch API. Filter by event type, date range, vessel ID, and region. Results include event metadata, positions, vessel info, and the region IDs (EEZ, MPA, RFMO, FAO) where each event intersects. Use the regions fields in each entry to filter or group events by region — for example, to find all events inside a specific EEZ, MPA, RFMO, or FAO area. Each entry also includes a mapUrl linking to the vessel's profile on the Global Fishing Watch map — share this URL with the user when presenting results.",
120
+ inputSchema: {
121
+ eventType: zod_1.z
122
+ .enum(['fishing', 'encounter', 'port_visit', 'loitering'])
123
+ .describe('Type of event to retrieve.'),
124
+ startDate: zod_1.z
125
+ .string()
126
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use ISO 8601 date format YYYY-MM-DD for startDate.')
127
+ .describe('Only include events on/after this date. If omitted, defaults to one month ago.'),
128
+ endDate: zod_1.z
129
+ .string()
130
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use ISO 8601 date format YYYY-MM-DD for endDate. IMPORTANT! this date is exclusive.')
131
+ .describe('Only include events on/before this date. If omitted, defaults to today.'),
132
+ vesselId: zod_1.z
133
+ .string()
134
+ .optional()
135
+ .describe('Filter events by a specific vessel ID. Use the Vessel Search tool to find vessel IDs.'),
136
+ limit: zod_1.z
137
+ .number()
138
+ .int()
139
+ .min(1)
140
+ .max(100)
141
+ .optional()
142
+ .describe('Maximum number of events to return (default 20, max 100).'),
143
+ offset: zod_1.z
144
+ .number()
145
+ .int()
146
+ .min(0)
147
+ .optional()
148
+ .describe('Pagination offset (default 0).'),
149
+ confidence: zod_1.z
150
+ .array(zod_1.z.number().int().min(2).max(4))
151
+ .min(1)
152
+ .optional()
153
+ .describe('Confidence levels to include for port visits. Each value must be 2, 3, or 4. Only valid when eventType is "port_visit". ALWAYS default to [4] unless the user explicitly requests other confidence levels.'),
154
+ encounterTypes: zod_1.z
155
+ .array(zod_1.z.enum([
156
+ 'CARRIER-FISHING',
157
+ 'CARRIER-BUNKER',
158
+ 'FISHING-BUNKER',
159
+ 'FISHING-FISHING',
160
+ 'SUPPORT-FISHING',
161
+ ]))
162
+ .min(1)
163
+ .optional()
164
+ .describe('Types of encounters to include. Only valid when eventType is "encounter". ALWAYS default to ["CARRIER-FISHING", "SUPPORT-FISHING"] unless the user explicitly requests other encounter types.'),
165
+ regionType: zod_1.z
166
+ .enum(['MPA', 'EEZ', 'RFMO'])
167
+ .optional()
168
+ .describe('Type of region to filter by: MPA (Marine Protected Area), EEZ (Exclusive Economic Zone), or RFMO (Regional Fisheries Management Organisation). Must be provided together with regionId.'),
169
+ regionId: zod_1.z
170
+ .string()
171
+ .optional()
172
+ .describe('Canonical ID of the region (MPA, EEZ, or RFMO). Use the Region ID Lookup tool if you only have the name. Must be provided together with regionType.'),
173
+ },
174
+ outputSchema: {
175
+ total: zod_1.z.number(),
176
+ limit: zod_1.z.number(),
177
+ offset: zod_1.z.number(),
178
+ nextOffset: zod_1.z.number().optional(),
179
+ entries: zod_1.z.array(zod_1.z.object({
180
+ id: zod_1.z.string(),
181
+ type: zod_1.z.string(),
182
+ start: zod_1.z.string(),
183
+ end: zod_1.z.string(),
184
+ lat: zod_1.z.number(),
185
+ lon: zod_1.z.number(),
186
+ vesselId: zod_1.z.string().nullish(),
187
+ vesselName: zod_1.z.string().nullish(),
188
+ vesselFlag: zod_1.z.string().nullish(),
189
+ regions: zod_1.z
190
+ .object({
191
+ mpa: zod_1.z.array(zod_1.z.string()),
192
+ eez: zod_1.z.array(zod_1.z.string()),
193
+ rfmo: zod_1.z.array(zod_1.z.string()),
194
+ fao: zod_1.z.array(zod_1.z.string()),
195
+ })
196
+ .describe('Region IDs where the event intersects, grouped by region type. Use these fields to filter or group events by region — for example, to find all events inside a specific EEZ, MPA, RFMO, or FAO area, match against the corresponding array.'),
197
+ port: zod_1.z
198
+ .object({
199
+ name: zod_1.z.string().nullish(),
200
+ id: zod_1.z.string().nullish(),
201
+ flag: zod_1.z.string().nullish(),
202
+ })
203
+ .optional()
204
+ .describe('Port details. Only present for port_visit events.'),
205
+ encounteredVessel: zod_1.z
206
+ .object({
207
+ name: zod_1.z.string().nullish(),
208
+ id: zod_1.z.string().nullish(),
209
+ flag: zod_1.z.string().nullish(),
210
+ ssvid: zod_1.z.string().nullish(),
211
+ })
212
+ .optional()
213
+ .describe('The other vessel involved in the encounter. Only present for encounter events.'),
214
+ })),
215
+ mapUrl: zod_1.z
216
+ .string()
217
+ .nullish()
218
+ .describe("Global Fishing Watch map URL to view the vessel's profile for the queried period. IMPORTANT!! Always share this full link with the user when presenting results."),
219
+ },
220
+ }, async (params) => {
221
+ try {
222
+ const output = await vesselEvents(params);
223
+ return (0, response_js_1.createToolResponse)(JSON.stringify(output, null, 2), output);
224
+ }
225
+ catch (err) {
226
+ return (0, response_js_1.createErrorResponse)(`Failed to fetch vessel events: ${err instanceof Error ? err.message : String(err)}`);
227
+ }
228
+ });
229
+ }
@@ -0,0 +1,259 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.vesselReport = vesselReport;
4
+ exports.register = register;
5
+ const zod_1 = require("zod");
6
+ const api_js_1 = require("../lib/api.js");
7
+ const map_url_generator_js_1 = require("../lib/map-url-generator.js");
8
+ const response_js_1 = require("../lib/response.js");
9
+ const types_js_1 = require("../lib/types.js");
10
+ async function vesselReport({ regionType, regionId, startDate, endDate, type, flags, vesselTypes, speeds, geartypes, groupBy, }) {
11
+ const activityType = type ?? 'FISHING';
12
+ const groupByValue = groupBy ?? 'VESSEL_ID';
13
+ const start = new Date(startDate);
14
+ const end = new Date(endDate);
15
+ const msInYear = 365 * 24 * 60 * 60 * 1000;
16
+ if (end.getTime() - start.getTime() > msInYear) {
17
+ return (0, response_js_1.createErrorResponse)('The report date range cannot exceed 1 year. Please reduce the range between startDate and endDate.');
18
+ }
19
+ if (activityType === 'PRESENCE' && (groupByValue === 'GEARTYPE' || groupByValue === 'FLAGANDGEARTYPE')) {
20
+ return (0, response_js_1.createErrorResponse)('groupBy "GEARTYPE" and "FLAGANDGEARTYPE" are only valid when type is "FISHING".');
21
+ }
22
+ if (activityType === 'FISHING' && Array.isArray(vesselTypes) && vesselTypes.length > 0) {
23
+ return (0, response_js_1.createErrorResponse)('vesselTypes filter is only valid when type is "PRESENCE".');
24
+ }
25
+ if (activityType === 'PRESENCE' && Array.isArray(geartypes) && geartypes.length > 0) {
26
+ return (0, response_js_1.createErrorResponse)('geartypes filter is only valid when type is "FISHING".');
27
+ }
28
+ const activityDataset = types_js_1.ACTIVITY_DATASETS[activityType];
29
+ const flagList = Array.isArray(flags) ? flags : [];
30
+ const speedList = Array.isArray(speeds) ? speeds : [];
31
+ const vesselTypeList = Array.isArray(vesselTypes) ? vesselTypes : [];
32
+ const geartypeList = Array.isArray(geartypes) ? geartypes : [];
33
+ const filters = [];
34
+ if (flagList.length > 0)
35
+ filters.push(`flag IN (${flagList.map((f) => `'${f}'`).join(',')})`);
36
+ if (speedList.length > 0)
37
+ filters.push(`speed IN (${speedList.map((s) => `'${s}'`).join(',')})`);
38
+ if (vesselTypeList.length > 0)
39
+ filters.push(`vessel_type IN (${vesselTypeList.map((t) => `'${t}'`).join(',')})`);
40
+ if (geartypeList.length > 0)
41
+ filters.push(`geartype IN (${geartypeList.map((g) => `'${g}'`).join(',')})`);
42
+ const params = {
43
+ format: 'JSON',
44
+ 'datasets[0]': activityDataset,
45
+ 'date-range': `${startDate}T00:00:00.000Z,${endDate}T23:59:59.999Z`,
46
+ 'spatial-aggregation': 'true',
47
+ 'temporal-resolution': 'ENTIRE',
48
+ 'region-id': regionId,
49
+ 'region-dataset': types_js_1.REGION_DATASETS[regionType],
50
+ 'group-by': groupByValue,
51
+ };
52
+ if (filters.length > 0)
53
+ params['filters[0]'] = filters.join(' AND ');
54
+ const response = await (0, api_js_1.gfwFetch)('/v3/4wings/report', params);
55
+ const data = await response.json();
56
+ const allRows = data.entries.flatMap((entry) => entry[activityDataset] ?? []);
57
+ const fishingHours = allRows.reduce((sum, row) => sum + row.hours, 0);
58
+ const topVessels = groupByValue === 'VESSEL_ID'
59
+ ? [...allRows].sort((a, b) => b.hours - a.hours).slice(0, 10).map((row) => ({
60
+ vesselId: row.vesselId,
61
+ shipName: row.shipName,
62
+ mmsi: row.mmsi,
63
+ flag: row.flag,
64
+ geartype: row.geartype,
65
+ hours: row.hours,
66
+ }))
67
+ : undefined;
68
+ const rows = (() => {
69
+ if (groupByValue === 'VESSEL_ID')
70
+ return undefined;
71
+ const aggregated = new Map();
72
+ for (const row of allRows) {
73
+ const key = groupByValue === 'FLAG' ? row.flag
74
+ : groupByValue === 'GEARTYPE' ? row.geartype
75
+ : `${row.flag}__${row.geartype}`;
76
+ const keyFields = groupByValue === 'FLAG' ? { flag: row.flag }
77
+ : groupByValue === 'GEARTYPE' ? { geartype: row.geartype }
78
+ : { flag: row.flag, geartype: row.geartype };
79
+ const existing = aggregated.get(key);
80
+ if (existing) {
81
+ existing.hours += row.hours;
82
+ }
83
+ else {
84
+ aggregated.set(key, { key: keyFields, hours: row.hours });
85
+ }
86
+ }
87
+ return [...aggregated.values()].sort((a, b) => b.hours - a.hours).map(({ key, hours }) => ({ ...key, hours }));
88
+ })();
89
+ const gfwMapUrl = (0, map_url_generator_js_1.generateReportUrl)(regionId, regionType, activityType, startDate, endDate, {
90
+ speed: speedList,
91
+ vesselType: vesselTypeList,
92
+ geartype: geartypeList,
93
+ flag: flagList,
94
+ });
95
+ return {
96
+ regionType,
97
+ regionId,
98
+ dateRange: { start: startDate, end: endDate },
99
+ fishingHours,
100
+ ...(topVessels && { topVessels }),
101
+ ...(rows && { rows }),
102
+ ...(flagList.length > 0 && { flags: flagList }),
103
+ ...(vesselTypeList.length > 0 && { vesselTypes: vesselTypeList }),
104
+ ...(speedList.length > 0 && { speeds: speedList }),
105
+ ...(geartypeList.length > 0 && { geartypes: geartypeList }),
106
+ gfwMapUrl,
107
+ };
108
+ }
109
+ function register(server) {
110
+ server.registerTool('vessel-report', {
111
+ title: 'Activity Hours Calculator',
112
+ description: 'Returns the number of activity hours for a given time period in a specific Marine Protected Area (MPA), Exclusive Economic Zone (EEZ), or Regional Fisheries Management Organisation (RFMO) identified by its canonical region ID. Use the Region ID Lookup tool first if you only know the human-readable name. Optionally filters by one or multiple vessel flag states. This tool calculates and reports the total fishing activity hours detected within the region boundaries during the specified date range. The tool also provides a link to the Global Fishing Watch (GFW) map where users can view the detailed data and navigate the fishing activity visually. IMPORTANT: This tool must NEVER be called in parallel. If multiple reports are needed, call this tool sequentially, one at a time, waiting for each result before making the next call. IMPORTANT: The gfwMapUrl returned by this tool must NEVER be truncated, shortened, or summarized — always display it in its entirety.',
113
+ inputSchema: {
114
+ regionType: zod_1.z
115
+ .enum(['MPA', 'EEZ', 'RFMO'])
116
+ .describe('Type of region to analyze: MPA (Marine Protected Area), EEZ (Exclusive Economic Zone), or RFMO (Regional Fisheries Management Organisation)'),
117
+ regionId: zod_1.z
118
+ .string()
119
+ .describe('Canonical ID of the region (MPA, EEZ, or RFMO). Use the Region ID Lookup tool if you only have the name.'),
120
+ startDate: zod_1.z
121
+ .string()
122
+ .describe('Start date of the report period (ISO 8601 format: YYYY-MM-DD)'),
123
+ endDate: zod_1.z
124
+ .string()
125
+ .describe('End date of the report period (ISO 8601 format: YYYY-MM-DD). IMPORTANT! this date is exclusive. The range between startDate and endDate must not exceed 1 year.'),
126
+ type: zod_1.z
127
+ .enum(['FISHING', 'PRESENCE'])
128
+ .optional()
129
+ .describe('Type of activity data to use for the report. ' +
130
+ '"FISHING" (default) uses AIS-based fishing effort data — hours when a vessel was actively fishing as detected by its movement pattern. Use this to answer questions about fishing activity, fishing pressure, or fishing hours inside a region. ' +
131
+ '"PRESENCE" uses vessel presence data — hours when any vessel was present inside the region regardless of whether it was fishing. Use this when the question is about vessel traffic, transit, or total time spent in the area.'),
132
+ flags: zod_1.z
133
+ .array(zod_1.z
134
+ .string()
135
+ .regex(/^[A-Z]{3}$/, 'Use ISO 3166-1 alpha-3 country codes (e.g., "ESP").'))
136
+ .min(1)
137
+ .max(10)
138
+ .optional()
139
+ .describe('Optional list of vessel flag states (ISO 3166-1 alpha-3 codes, e.g., "ESP", "USA", "CHN"). When omitted, all flags are included.'),
140
+ vesselTypes: zod_1.z
141
+ .array(zod_1.z.enum([
142
+ 'carrier',
143
+ 'seismic_vessel',
144
+ 'passenger',
145
+ 'other',
146
+ 'support',
147
+ 'bunker',
148
+ 'gear',
149
+ 'cargo',
150
+ 'fishing',
151
+ 'discrepancy',
152
+ ]))
153
+ .min(1)
154
+ .optional()
155
+ .describe('Optional list of vessel types to filter by. Only applicable when type is "PRESENCE". When omitted, all vessel types are included.'),
156
+ speeds: zod_1.z
157
+ .array(zod_1.z.enum(['2-4', '4-6', '6-10', '10-15', '15-25', '>25']))
158
+ .min(1)
159
+ .optional()
160
+ .describe('Optional list of speed ranges to filter by. Only applicable when type is "PRESENCE". When omitted, all speeds are included.'),
161
+ geartypes: zod_1.z
162
+ .array(zod_1.z.enum([
163
+ 'tuna_purse_seines',
164
+ 'driftnets',
165
+ 'trollers',
166
+ 'set_longlines',
167
+ 'purse_seines',
168
+ 'pots_and_traps',
169
+ 'other_fishing',
170
+ 'dredge_fishing',
171
+ 'set_gillnets',
172
+ 'fixed_gear',
173
+ 'trawlers',
174
+ 'fishing',
175
+ 'seiners',
176
+ 'other_purse_seines',
177
+ 'other_seines',
178
+ 'squid_jigger',
179
+ 'pole_and_line',
180
+ 'drifting_longlines',
181
+ ]))
182
+ .min(1)
183
+ .optional()
184
+ .describe('Optional list of gear types to filter by. Only applicable when type is "FISHING". When omitted, all gear types are included.'),
185
+ groupBy: zod_1.z
186
+ .enum(['VESSEL_ID', 'FLAG', 'GEARTYPE', 'FLAGANDGEARTYPE'])
187
+ .optional()
188
+ .describe('How to group the report results. ' +
189
+ '"VESSEL_ID" (default): results grouped per individual vessel. ' +
190
+ '"FLAG": results aggregated by flag state. ' +
191
+ '"GEARTYPE": results aggregated by gear type — only valid when type is "FISHING". ' +
192
+ '"FLAGANDGEARTYPE": results aggregated by flag state and gear type combined — only valid when type is "FISHING".'),
193
+ },
194
+ outputSchema: {
195
+ regionType: zod_1.z.enum(['MPA', 'EEZ', 'RFMO']),
196
+ regionId: zod_1.z.string(),
197
+ dateRange: zod_1.z.object({ start: zod_1.z.string(), end: zod_1.z.string() }),
198
+ fishingHours: zod_1.z
199
+ .number()
200
+ .describe('Total number of fishing hours detected in the region during the specified date range'),
201
+ flags: zod_1.z
202
+ .array(zod_1.z.string().regex(/^[A-Z]{3}$/))
203
+ .optional()
204
+ .describe('Flag state filters applied (if any). ISO 3166-1 alpha-3 codes.'),
205
+ vesselTypes: zod_1.z
206
+ .array(zod_1.z.string())
207
+ .optional()
208
+ .describe('Vessel type filters applied (if any).'),
209
+ speeds: zod_1.z
210
+ .array(zod_1.z.string())
211
+ .optional()
212
+ .describe('Speed range filters applied (if any).'),
213
+ geartypes: zod_1.z
214
+ .array(zod_1.z.string())
215
+ .optional()
216
+ .describe('Gear type filters applied (if any).'),
217
+ gfwMapUrl: zod_1.z
218
+ .string()
219
+ .describe('URL to the Global Fishing Watch map showing the detailed fishing activity data for the specified region and date range. IMPORTANT!! Always share this full link with the user when presenting reports. NEVER truncate, shorten, or summarize this URL — always display it in its entirety.'),
220
+ topVessels: zod_1.z
221
+ .array(zod_1.z.object({
222
+ vesselId: zod_1.z.string(),
223
+ shipName: zod_1.z.string().nullish(),
224
+ mmsi: zod_1.z.string().nullish(),
225
+ flag: zod_1.z.string().nullish(),
226
+ geartype: zod_1.z.string().nullish(),
227
+ hours: zod_1.z.number(),
228
+ }))
229
+ .optional()
230
+ .describe('Top 10 vessels by hours. Only present when groupBy is "VESSEL_ID".'),
231
+ rows: zod_1.z
232
+ .array(zod_1.z.record(zod_1.z.string().or(zod_1.z.number())))
233
+ .optional()
234
+ .describe('Aggregated rows sorted by hours descending. Present when groupBy is "FLAG", "GEARTYPE", or "FLAGANDGEARTYPE". Each row contains the grouping fields plus "hours".'),
235
+ },
236
+ }, async (params) => {
237
+ try {
238
+ const output = await vesselReport(params);
239
+ if ('isError' in output)
240
+ return output;
241
+ const flagText = output.flags && output.flags.length > 0 ? `\nFlag Filters: ${output.flags.join(', ')}` : '';
242
+ const activityType = params.type ?? 'FISHING';
243
+ const responseText = `${activityType === 'FISHING' ? 'Fishing Hours Report' : 'Presence Hours Report'} for ${output.regionType} ID: ${output.regionId}${flagText}
244
+ Date Range: ${output.dateRange.start} to ${output.dateRange.end}
245
+
246
+ Total ${activityType} Hours: ${output.fishingHours} hours
247
+
248
+ View detailed data on the Global Fishing Watch map:
249
+ ${output.gfwMapUrl}
250
+
251
+ Full data: ${JSON.stringify(output, null, 2)}`;
252
+ return (0, response_js_1.createToolResponse)(responseText, output);
253
+ }
254
+ catch (err) {
255
+ const activityType = params.type ?? 'FISHING';
256
+ return (0, response_js_1.createErrorResponse)(`Failed to generate ${activityType} hours report: ${err instanceof Error ? err.message : String(err)}`);
257
+ }
258
+ });
259
+ }
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.vesselSearch = vesselSearch;
4
+ exports.register = register;
5
+ const zod_1 = require("zod");
6
+ const api_js_1 = require("../lib/api.js");
7
+ const map_url_generator_js_1 = require("../lib/map-url-generator.js");
8
+ const response_js_1 = require("../lib/response.js");
9
+ const DATASET = 'public-global-vessel-identity:v4.0';
10
+ async function vesselSearch({ name, mmsi, imo, callsign, flag, activeFrom, activeTo, limit, }) {
11
+ const maxResults = limit ?? 10;
12
+ const conditions = [];
13
+ if (callsign)
14
+ conditions.push(`callsign = '${callsign.toUpperCase()}'`);
15
+ if (flag)
16
+ conditions.push(`flag = '${flag.toUpperCase()}'`);
17
+ if (imo)
18
+ conditions.push(`imo = '${imo.toUpperCase()}'`);
19
+ if (mmsi)
20
+ conditions.push(`ssvid = '${mmsi.toUpperCase()}'`);
21
+ if (activeTo)
22
+ conditions.push(`transmissionDateFrom < '${activeTo}T23:59:59Z'`);
23
+ if (activeFrom)
24
+ conditions.push(`transmissionDateTo > '${activeFrom}T00:00:00Z'`);
25
+ if (name)
26
+ conditions.push(`shipname LIKE '*${name.toUpperCase()}*'`);
27
+ if (conditions.length === 0) {
28
+ return (0, response_js_1.createErrorResponse)('No search criteria provided. At least one filter must be specified for a meaningful search.');
29
+ }
30
+ const params = {
31
+ 'datasets[0]': DATASET,
32
+ limit: String(maxResults),
33
+ where: conditions.join(' AND '),
34
+ };
35
+ const response = await (0, api_js_1.gfwFetch)('/v3/vessels/search', params);
36
+ const data = await response.json();
37
+ const results = data.entries.map((entry) => {
38
+ const info = entry.selfReportedInfo[0];
39
+ const combined = entry.combinedSourcesInfo[0];
40
+ const vesselId = info?.id ?? combined?.vesselId ?? '';
41
+ const from = info?.transmissionDateFrom;
42
+ const to = info?.transmissionDateTo;
43
+ return {
44
+ vesselId,
45
+ name: info?.shipname,
46
+ mmsi: info?.ssvid,
47
+ imo: info?.imo ?? undefined,
48
+ callsign: info?.callsign ?? undefined,
49
+ flag: info?.flag,
50
+ gearType: combined?.geartypes?.[0]?.name,
51
+ activeFrom: from,
52
+ activeTo: to,
53
+ mapUrl: vesselId ? (0, map_url_generator_js_1.generateVesselProfileUrl)(vesselId, from, to) : null,
54
+ };
55
+ });
56
+ return { total: data.total, limit: data.limit, results };
57
+ }
58
+ function register(server) {
59
+ server.registerTool('vessel-search', {
60
+ title: 'Vessel Search',
61
+ description: "Search vessels by name, MMSI, IMO, callsign, flag, gear type, or activity date range. Returns basic vessel metadata and identifiers, including a mapUrl for each vessel. Use the mapUrl to link the user directly to the vessel's profile on the Global Fishing Watch map. All filters are optional but at least one should be provided for meaningful results.",
62
+ inputSchema: {
63
+ name: zod_1.z
64
+ .string()
65
+ .trim()
66
+ .min(1)
67
+ .optional()
68
+ .describe('Vessel name or partial name (case-insensitive wildcard match).'),
69
+ mmsi: zod_1.z
70
+ .string()
71
+ .regex(/^[0-9]{9}$/, 'MMSI must be a 9-digit string.')
72
+ .optional()
73
+ .describe('Maritime Mobile Service Identity (9 digits).'),
74
+ imo: zod_1.z
75
+ .string()
76
+ .regex(/^[0-9]{7}$/, 'IMO must be a 7-digit string.')
77
+ .optional()
78
+ .describe('IMO number (7 digits).'),
79
+ callsign: zod_1.z
80
+ .string()
81
+ .trim()
82
+ .optional()
83
+ .describe('Vessel radio callsign (exact match).'),
84
+ flag: zod_1.z
85
+ .string()
86
+ .regex(/^[A-Z]{3}$/, 'Use ISO 3166-1 alpha-3 country code (e.g., "ESP").')
87
+ .optional()
88
+ .describe('Flag state ISO 3166-1 alpha-3 code (e.g., "ESP", "USA").'),
89
+ activeFrom: zod_1.z
90
+ .string()
91
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use ISO 8601 date format YYYY-MM-DD for activeFrom.')
92
+ .optional()
93
+ .describe('Filter for vessels active on or after this date (ISO 8601).'),
94
+ activeTo: zod_1.z
95
+ .string()
96
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'Use ISO 8601 date format YYYY-MM-DD for activeTo.')
97
+ .optional()
98
+ .describe('Filter for vessels active on or before this date (ISO 8601). IMPORTANT! this date is exclusive.'),
99
+ limit: zod_1.z
100
+ .number()
101
+ .int()
102
+ .min(1)
103
+ .max(50)
104
+ .optional()
105
+ .describe('Maximum number of results to return (default 10, max 50).'),
106
+ },
107
+ outputSchema: {
108
+ total: zod_1.z.number(),
109
+ limit: zod_1.z.number(),
110
+ results: zod_1.z.array(zod_1.z.object({
111
+ vesselId: zod_1.z.string(),
112
+ name: zod_1.z.string().nullish(),
113
+ mmsi: zod_1.z.string().nullish(),
114
+ imo: zod_1.z.string().nullish(),
115
+ callsign: zod_1.z.string().nullish(),
116
+ flag: zod_1.z.string().nullish(),
117
+ gearType: zod_1.z.string().nullish(),
118
+ activeFrom: zod_1.z.string().nullish(),
119
+ activeTo: zod_1.z.string().nullish(),
120
+ mapUrl: zod_1.z
121
+ .string()
122
+ .nullish()
123
+ .describe("Global Fishing Watch map URL to view this vessel's profile and track. IMPORTANT!! Always share this full link with the user when presenting vessel results."),
124
+ })),
125
+ },
126
+ }, async (params) => {
127
+ try {
128
+ const output = await vesselSearch(params);
129
+ return (0, response_js_1.createToolResponse)(JSON.stringify(output, null, 2), output);
130
+ }
131
+ catch (err) {
132
+ return (0, response_js_1.createErrorResponse)(`Failed to search vessels: ${err instanceof Error ? err.message : String(err)}`);
133
+ }
134
+ });
135
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@globalfishingwatch/mcp",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for Global Fishing Watch data — vessel search, events, fishing hours, and region lookups",
5
+ "author": "Global Fishing Watch",
6
+ "license": "Apache-2.0",
7
+ "keywords": [
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "global-fishing-watch",
11
+ "gfw",
12
+ "fishing",
13
+ "vessels",
14
+ "ais"
15
+ ],
16
+ "main": "./dist/index.js",
17
+ "bin": {
18
+ "gfw-mcp": "./dist/bin.js"
19
+ },
20
+ "files": [
21
+ "dist/"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "start": "node dist/index.js",
26
+ "dev": "tsx index.ts",
27
+ "cli": "tsx cli/index.ts",
28
+ "link": "npm run build && npm link",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "dependencies": {
32
+ "@modelcontextprotocol/sdk": "^1.22.0",
33
+ "@sentry/node": "^10.48.0",
34
+ "axios": "^1.13.6",
35
+ "commander": "^14.0.3",
36
+ "express": "^5.1.0",
37
+ "qs": "^6.15.1",
38
+ "zod": "^3.25.76"
39
+ },
40
+ "devDependencies": {
41
+ "@types/express": "^5.0.5",
42
+ "@types/node": "^24.10.1",
43
+ "tsx": "^4.21.0",
44
+ "typescript": "^5.9.3"
45
+ }
46
+ }