@pipeworx/mcp-imf-portwatch 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pipeworx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # mcp-imf-portwatch
2
+
3
+ IMF PortWatch MCP — global maritime trade & chokepoint signals (free, no auth)
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 965+ live data sources.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+
12
+ ## Quick Start
13
+
14
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "imf-portwatch": {
20
+ "url": "https://gateway.pipeworx.io/imf-portwatch/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 965+ data sources:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "pipeworx": {
32
+ "url": "https://gateway.pipeworx.io/mcp"
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Using with ask_pipeworx
39
+
40
+ Instead of calling tools directly, you can ask questions in plain English:
41
+
42
+ ```
43
+ ask_pipeworx({ question: "your question about Imf Portwatch data" })
44
+ ```
45
+
46
+ The gateway picks the right tool and fills the arguments automatically.
47
+
48
+ ## More
49
+
50
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
51
+ - [pipeworx.io](https://pipeworx.io)
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pipeworx/mcp-imf-portwatch",
3
+ "version": "0.1.0",
4
+ "description": "IMF PortWatch MCP — global maritime trade & chokepoint signals (free, no auth)",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "imf-portwatch"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-imf-portwatch"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.7.0"
19
+ }
20
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/imf-portwatch",
4
+ "title": "Imf Portwatch",
5
+ "description": "IMF PortWatch MCP — global maritime trade & chokepoint signals (free, no auth)",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/imf-portwatch",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-imf-portwatch",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/imf-portwatch/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,489 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: 'object';
6
+ properties: Record<string, unknown>;
7
+ required?: string[];
8
+ };
9
+ }
10
+
11
+ interface McpToolExport {
12
+ tools: McpToolDefinition[];
13
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
14
+ meter?: { credits: number };
15
+ cost?: Record<string, unknown>;
16
+ provider?: string;
17
+ }
18
+
19
+ /**
20
+ * IMF PortWatch MCP — global maritime trade & chokepoint signals (free, no auth)
21
+ *
22
+ * PortWatch is the IMF's open dashboard for tracking shipping activity, port
23
+ * disruptions, and chokepoint throughput. Data is published daily as ArcGIS
24
+ * FeatureServer layers — we surface the four most-useful ones for live
25
+ * geopolitical / trade bet research:
26
+ *
27
+ * - chokepoints_list: the 8 major chokepoints (Suez, Panama, Hormuz,
28
+ * Bab-el-Mandeb, Bosphorus, Malacca, Gibraltar,
29
+ * Cape of Good Hope) with annual traffic mix
30
+ * - chokepoint_daily_traffic: per-chokepoint vessel counts by day —
31
+ * this is the signal for "Suez transits dropped
32
+ * from 70/day to 30/day post-Houthi attacks"
33
+ * - port_search: 1500+ named ports with country / trade share
34
+ * - recent_disruptions: port-affecting events (cyclones, floods,
35
+ * conflicts) with alert level and impact area
36
+ *
37
+ * Source: https://portwatch.imf.org (open ArcGIS FeatureServer; no key).
38
+ */
39
+
40
+
41
+ const BASE = 'https://services9.arcgis.com/weJ1QsnbMYJlCHdG/ArcGIS/rest/services';
42
+
43
+ // Friendly-name → portid map for the major chokepoints. PortWatch's
44
+ // portid scheme (chokepoint1..28) is not memorable and the numbering is
45
+ // non-obvious (e.g. chokepoint7 is Cape of Good Hope, not Hormuz), so
46
+ // we accept names and translate. Mapping reflects the actual PortWatch
47
+ // portid assignment as of 2026-05. If you add an alias for a chokepoint
48
+ // not below, the resolver also falls back to a portname LIKE search.
49
+ const CHOKEPOINT_ALIASES: Record<string, string> = {
50
+ suez: 'chokepoint1', 'suez canal': 'chokepoint1',
51
+ panama: 'chokepoint2', 'panama canal': 'chokepoint2',
52
+ bosphorus: 'chokepoint3', bosporus: 'chokepoint3', 'bosphorus strait': 'chokepoint3', 'bosporus strait': 'chokepoint3',
53
+ 'bab-el-mandeb': 'chokepoint4', 'bab el mandeb': 'chokepoint4', 'bab al-mandab': 'chokepoint4', 'bab al-mandab strait': 'chokepoint4',
54
+ malacca: 'chokepoint5', 'strait of malacca': 'chokepoint5', 'malacca strait': 'chokepoint5',
55
+ hormuz: 'chokepoint6', 'strait of hormuz': 'chokepoint6',
56
+ 'cape of good hope': 'chokepoint7', 'good hope': 'chokepoint7',
57
+ gibraltar: 'chokepoint8', 'strait of gibraltar': 'chokepoint8', 'gibraltar strait': 'chokepoint8',
58
+ dover: 'chokepoint9', 'strait of dover': 'chokepoint9', 'dover strait': 'chokepoint9', 'english channel': 'chokepoint9',
59
+ // Secondary chokepoints — same scheme, less commonly cited
60
+ oresund: 'chokepoint10', 'oresund strait': 'chokepoint10',
61
+ taiwan: 'chokepoint11', 'taiwan strait': 'chokepoint11',
62
+ korea: 'chokepoint12', 'korea strait': 'chokepoint12',
63
+ tsugaru: 'chokepoint13', 'tsugaru strait': 'chokepoint13',
64
+ luzon: 'chokepoint14', 'luzon strait': 'chokepoint14',
65
+ lombok: 'chokepoint15', 'lombok strait': 'chokepoint15',
66
+ ombai: 'chokepoint16', 'ombai strait': 'chokepoint16',
67
+ bohai: 'chokepoint17', 'bohai strait': 'chokepoint17',
68
+ torres: 'chokepoint18', 'torres strait': 'chokepoint18',
69
+ sunda: 'chokepoint19', 'sunda strait': 'chokepoint19',
70
+ makassar: 'chokepoint20', 'makassar strait': 'chokepoint20',
71
+ magellan: 'chokepoint21', 'magellan strait': 'chokepoint21',
72
+ yucatan: 'chokepoint22', 'yucatan channel': 'chokepoint22',
73
+ windward: 'chokepoint23', 'windward passage': 'chokepoint23',
74
+ mona: 'chokepoint24', 'mona passage': 'chokepoint24',
75
+ balabac: 'chokepoint25', 'balabac strait': 'chokepoint25',
76
+ bering: 'chokepoint26', 'bering strait': 'chokepoint26',
77
+ mindoro: 'chokepoint27', 'mindoro strait': 'chokepoint27',
78
+ kerch: 'chokepoint28', 'kerch strait': 'chokepoint28',
79
+ };
80
+
81
+ const tools: McpToolExport['tools'] = [
82
+ {
83
+ name: 'chokepoints_list',
84
+ description:
85
+ 'List the 8 major maritime chokepoints PortWatch tracks (Suez, Panama, Bosphorus, Gibraltar, Dover, Malacca, Hormuz, Bab-el-Mandeb) with annual vessel counts, traffic mix by cargo type (container / dry bulk / tanker / RoRo / general), and top industries by volume. Use to discover what a chokepoint normally carries — for live daily throughput call chokepoint_daily_traffic.',
86
+ inputSchema: { type: 'object', properties: {} },
87
+ },
88
+ {
89
+ name: 'chokepoint_daily_traffic',
90
+ description:
91
+ 'Daily vessel counts and aggregate capacity (deadweight tonnage) transiting a specific chokepoint. Returns time series ordered most-recent-first, with per-cargo-type breakdown (container / dry bulk / general cargo / RoRo / tanker). Use for "is Suez traffic back to normal after the Houthi attacks", "Hormuz throughput vs 30d average", or any bet predicated on a chokepoint disruption persisting. Accepts friendly names (suez / panama / hormuz / bab-el-mandeb / etc.) or PortWatch portid (chokepoint1..8).',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ chokepoint: { type: 'string', description: 'Friendly name (e.g. "Hormuz", "Suez Canal") or portid ("chokepoint7")' },
96
+ days: { type: 'number', description: 'Lookback window in days (default 30, max 365). Data is daily.' },
97
+ },
98
+ required: ['chokepoint'],
99
+ },
100
+ },
101
+ {
102
+ name: 'port_search',
103
+ description:
104
+ 'Search the PortWatch port database (1,500+ ports globally) by name, country, or ISO3. Returns matching ports with country, continent, latitude/longitude, annual vessel counts by cargo type, top industries, and the port\'s share of the country\'s maritime imports/exports. Useful for identifying which ports would be affected by a country-level disruption, or finding the LOCODE / portid for a named port.',
105
+ inputSchema: {
106
+ type: 'object',
107
+ properties: {
108
+ query: { type: 'string', description: 'Free-text portname match (e.g. "Rotterdam", "Houston", "Singapore")' },
109
+ country: { type: 'string', description: 'Country filter — full name ("Netherlands") or ISO3 ("NLD")' },
110
+ limit: { type: 'number', description: 'Max results (default 20, max 100)' },
111
+ },
112
+ },
113
+ },
114
+ {
115
+ name: 'recent_disruptions',
116
+ description:
117
+ 'Port-affecting disruption events tracked by PortWatch — tropical cyclones, floods, earthquakes, conflict events — with start/end dates, alert level (GREEN/ORANGE/RED), severity, affected port count, and impacted population. Filter by country, alert level, or year. Use for "what disrupted shipping in the last month", "which port closures hit our supply chain", or to validate a bet on whether a current disruption is escalating.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: {
121
+ country: { type: 'string', description: 'Filter to a single country name (e.g. "Mozambique")' },
122
+ alert_level: { type: 'string', description: 'RED | ORANGE | GREEN — minimum alert level to include' },
123
+ year: { type: 'number', description: 'Filter to events from this calendar year' },
124
+ limit: { type: 'number', description: 'Max events to return (default 25, max 100)' },
125
+ },
126
+ },
127
+ },
128
+ ];
129
+
130
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
131
+ switch (name) {
132
+ case 'chokepoints_list':
133
+ return chokepointsList();
134
+ case 'chokepoint_daily_traffic':
135
+ return chokepointDailyTraffic(
136
+ reqStr(args, 'chokepoint', '"Hormuz"'),
137
+ clamp((args.days as number) ?? 30, 1, 365),
138
+ );
139
+ case 'port_search':
140
+ return portSearch(
141
+ args.query as string | undefined,
142
+ args.country as string | undefined,
143
+ clamp((args.limit as number) ?? 20, 1, 100),
144
+ );
145
+ case 'recent_disruptions':
146
+ return recentDisruptions(
147
+ args.country as string | undefined,
148
+ args.alert_level as string | undefined,
149
+ args.year as number | undefined,
150
+ clamp((args.limit as number) ?? 25, 1, 100),
151
+ );
152
+ default:
153
+ throw new Error(`Unknown tool: ${name}`);
154
+ }
155
+ }
156
+
157
+ // ── Implementations ──────────────────────────────────────────────────
158
+
159
+ type EsriFeature<T = Record<string, unknown>> = { attributes: T };
160
+ type EsriResponse<T = Record<string, unknown>> = { features?: EsriFeature<T>[]; error?: { code?: number; message?: string } };
161
+
162
+ async function esriQuery<T = Record<string, unknown>>(
163
+ service: string,
164
+ params: Record<string, string>,
165
+ ): Promise<EsriResponse<T>> {
166
+ const url = `${BASE}/${service}/FeatureServer/0/query?${new URLSearchParams({
167
+ where: '1=1',
168
+ outFields: '*',
169
+ f: 'json',
170
+ ...params,
171
+ })}`;
172
+ // Cache 15 min at the CF edge — PortWatch updates daily and the same
173
+ // chokepoint queries get hammered when multiple agents resolve the
174
+ // same bet. Errors get a short TTL so transient failures don't pin.
175
+ const res = await fetch(url, {
176
+ cf: {
177
+ cacheTtlByStatus: { '200-299': 900, '400-499': 30, '500-599': 5 },
178
+ cacheEverything: true,
179
+ },
180
+ } as RequestInit);
181
+ if (!res.ok) {
182
+ throw new Error(`PortWatch ArcGIS error: ${res.status} ${(await res.text()).slice(0, 200)}`);
183
+ }
184
+ const body = (await res.json()) as EsriResponse<T>;
185
+ if (body.error) {
186
+ throw new Error(`PortWatch query error: ${body.error.message ?? JSON.stringify(body.error)}`);
187
+ }
188
+ return body;
189
+ }
190
+
191
+ interface ChokepointAttrs {
192
+ portid: string;
193
+ portname: string;
194
+ fullname: string;
195
+ lat: number;
196
+ lon: number;
197
+ vessel_count_total: number;
198
+ vessel_count_container: number;
199
+ vessel_count_dry_bulk: number;
200
+ vessel_count_general_cargo: number;
201
+ vessel_count_RoRo: number;
202
+ vessel_count_tanker: number;
203
+ industry_top1: string;
204
+ industry_top2: string;
205
+ industry_top3: string;
206
+ }
207
+
208
+ async function chokepointsList() {
209
+ const body = await esriQuery<ChokepointAttrs>('PortWatch_chokepoints_database', {
210
+ orderByFields: 'portid ASC',
211
+ });
212
+ const feats = body.features ?? [];
213
+ return {
214
+ source: 'IMF PortWatch — annual aggregates',
215
+ count: feats.length,
216
+ chokepoints: feats.map((f) => {
217
+ const a = f.attributes;
218
+ return {
219
+ portid: a.portid,
220
+ name: a.portname,
221
+ full_name: a.fullname,
222
+ lat: a.lat,
223
+ lon: a.lon,
224
+ annual_vessel_count_total: a.vessel_count_total,
225
+ annual_by_cargo_type: {
226
+ container: a.vessel_count_container,
227
+ dry_bulk: a.vessel_count_dry_bulk,
228
+ general_cargo: a.vessel_count_general_cargo,
229
+ roro: a.vessel_count_RoRo,
230
+ tanker: a.vessel_count_tanker,
231
+ },
232
+ top_industries: [a.industry_top1, a.industry_top2, a.industry_top3].filter(Boolean),
233
+ // Convenience: include the friendly-name aliases that resolve to this portid
234
+ friendly_aliases: Object.entries(CHOKEPOINT_ALIASES)
235
+ .filter(([, id]) => id === a.portid)
236
+ .map(([alias]) => alias),
237
+ };
238
+ }),
239
+ };
240
+ }
241
+
242
+ interface ChokepointDailyAttrs {
243
+ date: string;
244
+ portid: string;
245
+ portname: string;
246
+ n_container: number;
247
+ n_dry_bulk: number;
248
+ n_general_cargo: number;
249
+ n_roro: number;
250
+ n_tanker: number;
251
+ n_cargo: number;
252
+ n_total: number;
253
+ capacity_container: number;
254
+ capacity_dry_bulk: number;
255
+ capacity_general_cargo: number;
256
+ capacity_roro: number;
257
+ capacity_tanker: number;
258
+ capacity_cargo: number;
259
+ capacity: number;
260
+ }
261
+
262
+ async function chokepointDailyTraffic(chokepoint: string, days: number) {
263
+ const key = chokepoint.toLowerCase().trim();
264
+ // Resolution order: alias map → raw portid → portname LIKE fallback.
265
+ // The fallback rescues names not in the alias map (or if IMF re-numbers
266
+ // chokepoints in the future) by doing a server-side LIKE on portname.
267
+ let portid: string | null = CHOKEPOINT_ALIASES[key] ?? (/^chokepoint\d+$/.test(key) ? key : null);
268
+ if (!portid) {
269
+ const safe = key.replace(/'/g, "''").toUpperCase();
270
+ const fallback = await esriQuery<{ portid: string; portname: string }>('PortWatch_chokepoints_database', {
271
+ where: `UPPER(portname) LIKE '%${safe}%'`,
272
+ outFields: 'portid,portname',
273
+ resultRecordCount: '1',
274
+ });
275
+ portid = fallback.features?.[0]?.attributes.portid ?? null;
276
+ }
277
+ if (!portid) {
278
+ return {
279
+ found: false,
280
+ reason: 'unknown_chokepoint',
281
+ input: chokepoint,
282
+ hint: `Unknown chokepoint "${chokepoint}". Pass one of: ${Object.keys(CHOKEPOINT_ALIASES).slice(0, 8).join(', ')}, or a PortWatch portid like "chokepoint1". Call chokepoints_list to enumerate the full set.`,
283
+ };
284
+ }
285
+ // PortWatch ArcGIS layers can return at most 2000 rows per page; 365d
286
+ // is well under that for a single chokepoint.
287
+ const body = await esriQuery<ChokepointDailyAttrs>('Daily_Chokepoints_Data', {
288
+ where: `portid='${portid}'`,
289
+ orderByFields: 'date DESC',
290
+ resultRecordCount: String(days),
291
+ });
292
+ const feats = body.features ?? [];
293
+ if (feats.length === 0) {
294
+ return { found: false, reason: 'no_data', portid, hint: 'PortWatch may not have populated data for this chokepoint yet.' };
295
+ }
296
+ const series = feats.map((f) => {
297
+ const a = f.attributes;
298
+ return {
299
+ date: a.date,
300
+ n_total: a.n_total,
301
+ n_container: a.n_container,
302
+ n_dry_bulk: a.n_dry_bulk,
303
+ n_general_cargo: a.n_general_cargo,
304
+ n_roro: a.n_roro,
305
+ n_tanker: a.n_tanker,
306
+ capacity_dwt: a.capacity,
307
+ };
308
+ });
309
+ // Headline stats: latest day + 7d/30d averages for quick momentum read.
310
+ const totals = series.map((s) => s.n_total);
311
+ const last7 = totals.slice(0, Math.min(7, totals.length));
312
+ const last30 = totals.slice(0, Math.min(30, totals.length));
313
+ const avg = (arr: number[]) => arr.length > 0 ? arr.reduce((s, v) => s + v, 0) / arr.length : 0;
314
+ return {
315
+ source: 'IMF PortWatch — daily vessel transits',
316
+ portid,
317
+ portname: feats[0].attributes.portname,
318
+ days_returned: feats.length,
319
+ latest: series[0],
320
+ summary: {
321
+ avg_vessels_7d: +avg(last7).toFixed(1),
322
+ avg_vessels_30d: +avg(last30).toFixed(1),
323
+ // 7d vs 30d ratio surfaces momentum: <0.85 = recent slowdown,
324
+ // >1.15 = recent surge. Same shape as our other momentum signals.
325
+ recent_vs_baseline_ratio: avg(last30) > 0 ? +(avg(last7) / avg(last30)).toFixed(3) : null,
326
+ },
327
+ series,
328
+ };
329
+ }
330
+
331
+ interface PortAttrs {
332
+ portid: string;
333
+ portname: string;
334
+ country: string;
335
+ ISO3: string;
336
+ continent: string;
337
+ fullname: string;
338
+ lat: number;
339
+ lon: number;
340
+ LOCODE: string;
341
+ vessel_count_total: number;
342
+ vessel_count_container: number;
343
+ vessel_count_tanker: number;
344
+ vessel_count_dry_bulk: number;
345
+ industry_top1: string;
346
+ industry_top2: string;
347
+ industry_top3: string;
348
+ share_country_maritime_import: number;
349
+ share_country_maritime_export: number;
350
+ }
351
+
352
+ async function portSearch(query: string | undefined, country: string | undefined, limit: number) {
353
+ const where: string[] = [];
354
+ if (query) {
355
+ // ArcGIS LIKE with case-insensitive — use UPPER on both sides.
356
+ const safe = query.replace(/'/g, "''");
357
+ where.push(`UPPER(portname) LIKE '%${safe.toUpperCase()}%'`);
358
+ }
359
+ if (country) {
360
+ const safe = country.replace(/'/g, "''");
361
+ // Try both ISO3 (3-char) and country name
362
+ if (/^[A-Z]{3}$/i.test(country)) {
363
+ where.push(`ISO3='${safe.toUpperCase()}'`);
364
+ } else {
365
+ where.push(`UPPER(country) LIKE '%${safe.toUpperCase()}%'`);
366
+ }
367
+ }
368
+ const whereClause = where.length > 0 ? where.join(' AND ') : '1=1';
369
+ const body = await esriQuery<PortAttrs>('PortWatch_ports_database', {
370
+ where: whereClause,
371
+ orderByFields: 'vessel_count_total DESC',
372
+ resultRecordCount: String(limit),
373
+ });
374
+ const feats = body.features ?? [];
375
+ return {
376
+ source: 'IMF PortWatch — port reference (annual aggregates)',
377
+ matched: feats.length,
378
+ ports: feats.map((f) => {
379
+ const a = f.attributes;
380
+ return {
381
+ portid: a.portid,
382
+ portname: a.portname,
383
+ full_name: a.fullname,
384
+ country: a.country,
385
+ iso3: a.ISO3,
386
+ continent: a.continent,
387
+ locode: a.LOCODE,
388
+ lat: a.lat,
389
+ lon: a.lon,
390
+ annual_vessels: a.vessel_count_total,
391
+ annual_by_cargo: {
392
+ container: a.vessel_count_container,
393
+ tanker: a.vessel_count_tanker,
394
+ dry_bulk: a.vessel_count_dry_bulk,
395
+ },
396
+ top_industries: [a.industry_top1, a.industry_top2, a.industry_top3].filter(Boolean),
397
+ country_maritime_import_share: a.share_country_maritime_import,
398
+ country_maritime_export_share: a.share_country_maritime_export,
399
+ };
400
+ }),
401
+ };
402
+ }
403
+
404
+ interface DisruptionAttrs {
405
+ eventid: number;
406
+ eventtype: string;
407
+ eventname: string;
408
+ htmlname: string;
409
+ alertlevel: string;
410
+ country: string;
411
+ fromdate: number; // epoch ms
412
+ todate: number; // epoch ms
413
+ year: number;
414
+ severitytext: string;
415
+ lat: number;
416
+ long: number;
417
+ affectedports: string;
418
+ n_affectedports: number;
419
+ affectedpopulation: string;
420
+ }
421
+
422
+ const ALERT_PRIORITY: Record<string, number> = { GREEN: 1, ORANGE: 2, RED: 3 };
423
+
424
+ async function recentDisruptions(
425
+ country: string | undefined,
426
+ alertLevel: string | undefined,
427
+ year: number | undefined,
428
+ limit: number,
429
+ ) {
430
+ const where: string[] = [];
431
+ if (country) {
432
+ const safe = country.replace(/'/g, "''");
433
+ where.push(`UPPER(country) LIKE '%${safe.toUpperCase()}%'`);
434
+ }
435
+ if (year && Number.isFinite(year)) {
436
+ where.push(`year=${Math.floor(year)}`);
437
+ }
438
+ // alertlevel filter applies as a minimum (RED-or-higher etc.)
439
+ const minPri = alertLevel ? (ALERT_PRIORITY[alertLevel.toUpperCase()] ?? 1) : 0;
440
+ const whereClause = where.length > 0 ? where.join(' AND ') : '1=1';
441
+ const body = await esriQuery<DisruptionAttrs>('portwatch_disruptions_database', {
442
+ where: whereClause,
443
+ orderByFields: 'fromdate DESC',
444
+ resultRecordCount: String(Math.min(200, limit * 3)),
445
+ });
446
+ let feats = body.features ?? [];
447
+ if (minPri > 0) {
448
+ feats = feats.filter((f) => (ALERT_PRIORITY[(f.attributes.alertlevel ?? '').toUpperCase()] ?? 0) >= minPri);
449
+ }
450
+ feats = feats.slice(0, limit);
451
+ return {
452
+ source: 'IMF PortWatch — port-affecting disruption events',
453
+ matched: feats.length,
454
+ disruptions: feats.map((f) => {
455
+ const a = f.attributes;
456
+ return {
457
+ eventid: a.eventid,
458
+ type: a.eventtype,
459
+ name: a.eventname ?? a.htmlname,
460
+ alert_level: a.alertlevel,
461
+ country: a.country,
462
+ from_date: a.fromdate ? new Date(a.fromdate).toISOString().slice(0, 10) : null,
463
+ to_date: a.todate ? new Date(a.todate).toISOString().slice(0, 10) : null,
464
+ severity: a.severitytext,
465
+ lat: a.lat,
466
+ lon: a.long,
467
+ affected_ports_count: a.n_affectedports,
468
+ affected_population: a.affectedpopulation,
469
+ };
470
+ }),
471
+ };
472
+ }
473
+
474
+ // ── Helpers ──────────────────────────────────────────────────────────
475
+
476
+ function reqStr(args: Record<string, unknown>, key: string, example: string): string {
477
+ const v = args[key];
478
+ if (typeof v !== 'string' || !v.trim()) {
479
+ throw new Error(`Required argument "${key}" missing. Pass a string like ${example}.`);
480
+ }
481
+ return v.trim();
482
+ }
483
+
484
+ function clamp(n: number | undefined, lo: number, hi: number): number {
485
+ const v = typeof n === 'number' && Number.isFinite(n) ? n : lo;
486
+ return Math.max(lo, Math.min(hi, Math.floor(v)));
487
+ }
488
+
489
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }