@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 +21 -0
- package/README.md +55 -0
- package/package.json +20 -0
- package/server.json +18 -0
- package/src/index.ts +489 -0
- package/tsconfig.json +14 -0
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
|
+
}
|