euparliamentmonitor 0.9.3 → 0.9.5
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/package.json
CHANGED
|
@@ -32,7 +32,7 @@ export interface McpToolResult {
|
|
|
32
32
|
/**
|
|
33
33
|
* Returns `true` when `url` is allowed by the IMF-only fetch-proxy policy.
|
|
34
34
|
*
|
|
35
|
-
* Allowed: `https://
|
|
35
|
+
* Allowed: `https://api.imf.org/external/sdmx/3.0/...` (SDMX 2.1 rejected).
|
|
36
36
|
*
|
|
37
37
|
* @param url - Raw URL string to validate.
|
|
38
38
|
* @returns Whether the URL is permitted.
|
|
@@ -63,18 +63,6 @@ export declare function handleInitialize(id: number | string | null): JsonRpcSuc
|
|
|
63
63
|
* @returns JSON-RPC success with the tool descriptor array.
|
|
64
64
|
*/
|
|
65
65
|
export declare function handleToolsList(id: number | string | null): JsonRpcSuccess;
|
|
66
|
-
/**
|
|
67
|
-
* Execute the `fetch_url` tool call.
|
|
68
|
-
*
|
|
69
|
-
* Only URLs matching the IMF SDMX 3.0 allowlist are permitted. Non-matching
|
|
70
|
-
* or malformed URLs receive a JSON-RPC error response; HTTP errors and network
|
|
71
|
-
* failures also surface as errors.
|
|
72
|
-
*
|
|
73
|
-
* @param id - Request id to echo.
|
|
74
|
-
* @param url - URL to fetch.
|
|
75
|
-
* @param fetchImpl - Injectable `fetch` implementation (defaults to global).
|
|
76
|
-
* @returns JSON-RPC success or error.
|
|
77
|
-
*/
|
|
78
66
|
export declare function handleFetchUrl(id: number | string | null, url: string | undefined, fetchImpl?: typeof fetch): Promise<JsonRpcSuccess | JsonRpcError>;
|
|
79
67
|
/**
|
|
80
68
|
* Run the fetch-proxy MCP server, reading JSON-RPC messages from `input` and
|
|
@@ -6,18 +6,30 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Implements the Model Context Protocol (JSON-RPC 2.0 over stdio) with a
|
|
8
8
|
* single tool — `fetch_url` — that proxies HTTPS GET requests to the IMF
|
|
9
|
-
* SDMX 3.0 REST API at `https://
|
|
9
|
+
* Data Portal SDMX 3.0 REST API at `https://api.imf.org/external/sdmx/3.0/`.
|
|
10
10
|
*
|
|
11
11
|
* ## Why this exists
|
|
12
12
|
*
|
|
13
13
|
* The Agent Workflow Firewall (AWF) runs a Squid proxy that blocks outbound
|
|
14
|
-
* HTTPS even to allowlisted domains such as `
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* HTTPS even to allowlisted domains such as `api.imf.org`. This server is
|
|
15
|
+
* mounted as an MCP container in gh-aw workflows; because MCP containers
|
|
16
|
+
* run in a Docker network with direct outbound access (bypassing Squid),
|
|
17
|
+
* `fetch_url` can reach the IMF API while the main runner cannot.
|
|
18
18
|
*
|
|
19
|
-
* The server only allows calls to `https://
|
|
20
|
-
* —
|
|
19
|
+
* The server only allows calls to `https://api.imf.org/external/sdmx/3.0/`
|
|
20
|
+
* — SDMX 2.1 paths and any other URLs are rejected with an error message.
|
|
21
|
+
*
|
|
22
|
+
* ## Authentication
|
|
23
|
+
*
|
|
24
|
+
* The IMF Data Portal API is fronted by Azure API Management and requires
|
|
25
|
+
* a subscription key in the `Ocp-Apim-Subscription-Key` header for every
|
|
26
|
+
* request. The server reads the key from `IMF_API_PRIMARY_KEY` (with
|
|
27
|
+
* `IMF_API_SECONDARY_KEY` as a warm-standby fallback used on `401`/`403`
|
|
28
|
+
* responses to enable zero-downtime key rotation). When neither env var
|
|
29
|
+
* is set, the request is sent unauthenticated and IMF will return `204`
|
|
30
|
+
* (no subscription matched) — useful for diagnosing auth misconfiguration.
|
|
31
|
+
*
|
|
32
|
+
* The header is injected server-side; agent prompts never see the key.
|
|
21
33
|
*
|
|
22
34
|
* ## Usage
|
|
23
35
|
*
|
|
@@ -37,25 +49,27 @@
|
|
|
37
49
|
*/
|
|
38
50
|
import * as readline from 'node:readline';
|
|
39
51
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
40
|
-
const IMF_ALLOWED_HOSTNAME = '
|
|
41
|
-
const IMF_ALLOWED_PATH_PREFIX = '/
|
|
52
|
+
const IMF_ALLOWED_HOSTNAME = 'api.imf.org';
|
|
53
|
+
const IMF_ALLOWED_PATH_PREFIX = '/external/sdmx/3.0/';
|
|
42
54
|
const IMF_ALLOWED_PROTOCOL = 'https:';
|
|
43
55
|
/** Per-request fetch timeout (ms). */
|
|
44
56
|
const FETCH_TIMEOUT_MS = 180_000;
|
|
45
57
|
/** Product identifier sent to IMF SDMX endpoints. */
|
|
46
58
|
const IMF_USER_AGENT = 'euparliamentmonitor/0.9.0 (+https://github.com/Hack23/euparliamentmonitor)';
|
|
47
|
-
/** Common
|
|
59
|
+
/** Common headers for IMF SDMX REST requests (auth header added per-request). */
|
|
48
60
|
const IMF_REQUEST_HEADERS = Object.freeze({
|
|
49
61
|
Accept: 'application/json, application/vnd.sdmx.data+json, */*;q=0.8',
|
|
50
62
|
'User-Agent': IMF_USER_AGENT,
|
|
51
63
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
52
64
|
'Cache-Control': 'no-cache',
|
|
53
65
|
});
|
|
66
|
+
/** Azure APIM subscription-key header expected by `api.imf.org`. */
|
|
67
|
+
const IMF_SUBSCRIPTION_KEY_HEADER = 'Ocp-Apim-Subscription-Key';
|
|
54
68
|
// ─── Allowlist check ─────────────────────────────────────────────────────────
|
|
55
69
|
/**
|
|
56
70
|
* Returns `true` when `url` is allowed by the IMF-only fetch-proxy policy.
|
|
57
71
|
*
|
|
58
|
-
* Allowed: `https://
|
|
72
|
+
* Allowed: `https://api.imf.org/external/sdmx/3.0/...` (SDMX 2.1 rejected).
|
|
59
73
|
*
|
|
60
74
|
* @param url - Raw URL string to validate.
|
|
61
75
|
* @returns Whether the URL is permitted.
|
|
@@ -137,17 +151,83 @@ export function handleToolsList(id) {
|
|
|
137
151
|
};
|
|
138
152
|
}
|
|
139
153
|
/**
|
|
140
|
-
*
|
|
154
|
+
* Read IMF subscription keys from the environment, in priority order.
|
|
141
155
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
156
|
+
* Returns up to two keys: the primary (first attempt) and the secondary
|
|
157
|
+
* (used to retry on `401`/`403` so live key rotation never breaks a run).
|
|
158
|
+
* Empty / unset keys are filtered out so `[]` is returned only when no
|
|
159
|
+
* key is configured at all.
|
|
145
160
|
*
|
|
146
|
-
* @
|
|
147
|
-
* @
|
|
148
|
-
* @param fetchImpl - Injectable `fetch` implementation (defaults to global).
|
|
149
|
-
* @returns JSON-RPC success or error.
|
|
161
|
+
* @returns Ordered list of candidate API keys (length 0–2).
|
|
162
|
+
* @internal
|
|
150
163
|
*/
|
|
164
|
+
function readImfSubscriptionKeys() {
|
|
165
|
+
const candidates = [process.env['IMF_API_PRIMARY_KEY'], process.env['IMF_API_SECONDARY_KEY']];
|
|
166
|
+
const keys = [];
|
|
167
|
+
for (const k of candidates) {
|
|
168
|
+
if (typeof k === 'string' && k.length > 0 && !keys.includes(k)) {
|
|
169
|
+
keys.push(k);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return keys;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Build the request headers for an outbound IMF call. The `Ocp-Apim-Subscription-Key`
|
|
176
|
+
* header is added when a key is supplied; otherwise the request is sent
|
|
177
|
+
* unauthenticated (and IMF will return `204 No Content`).
|
|
178
|
+
*
|
|
179
|
+
* @param key - Subscription key, or `undefined` to send unauthenticated.
|
|
180
|
+
* @returns Plain object suitable for `fetch(..., { headers })`.
|
|
181
|
+
* @internal
|
|
182
|
+
*/
|
|
183
|
+
function buildImfHeaders(key) {
|
|
184
|
+
const headers = { ...IMF_REQUEST_HEADERS };
|
|
185
|
+
if (key !== undefined && key.length > 0) {
|
|
186
|
+
headers[IMF_SUBSCRIPTION_KEY_HEADER] = key;
|
|
187
|
+
}
|
|
188
|
+
return headers;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Classify a single `fetch()` response from `api.imf.org` so
|
|
192
|
+
* {@link handleFetchUrl} can decide whether to rotate keys, return
|
|
193
|
+
* success, or surface an explicit error.
|
|
194
|
+
*
|
|
195
|
+
* - `204 No Content` → explicit error (Azure APIM accepted the request
|
|
196
|
+
* but no Ocp-Apim-Subscription-Key matched; without this guard the
|
|
197
|
+
* empty body would be indistinguishable from a successful 200).
|
|
198
|
+
* - `401`/`403` with another key available → auth-retry signal.
|
|
199
|
+
* - Any other non-2xx → error with the HTTP status.
|
|
200
|
+
*
|
|
201
|
+
* @internal Exported for tests.
|
|
202
|
+
*
|
|
203
|
+
* @param response - The HTTP response returned by `fetch()`.
|
|
204
|
+
* @param hasNextAttempt - `true` when another subscription key is available for retry.
|
|
205
|
+
* @returns A classified outcome — `'ok'` with body text, `'auth-retry'` to rotate keys,
|
|
206
|
+
* or `'error'` with a JSON-RPC error envelope.
|
|
207
|
+
*/
|
|
208
|
+
async function classifyFetchResponse(response, hasNextAttempt) {
|
|
209
|
+
if ((response.status === 401 || response.status === 403) && hasNextAttempt) {
|
|
210
|
+
return { kind: 'auth-retry', response };
|
|
211
|
+
}
|
|
212
|
+
if (response.status === 204) {
|
|
213
|
+
return {
|
|
214
|
+
kind: 'error',
|
|
215
|
+
rpcError: {
|
|
216
|
+
code: -1,
|
|
217
|
+
message: `HTTP 204 No Content from ${IMF_ALLOWED_HOSTNAME} — likely missing or invalid ${IMF_SUBSCRIPTION_KEY_HEADER} (set IMF_API_PRIMARY_KEY)`,
|
|
218
|
+
},
|
|
219
|
+
response,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
return {
|
|
224
|
+
kind: 'error',
|
|
225
|
+
rpcError: { code: -1, message: `HTTP ${response.status} ${response.statusText}` },
|
|
226
|
+
response,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { kind: 'ok', text: await response.text() };
|
|
230
|
+
}
|
|
151
231
|
export async function handleFetchUrl(id, url, fetchImpl = globalThis.fetch) {
|
|
152
232
|
if (!url || !isAllowedImfUrl(url)) {
|
|
153
233
|
return {
|
|
@@ -159,29 +239,65 @@ export async function handleFetchUrl(id, url, fetchImpl = globalThis.fetch) {
|
|
|
159
239
|
},
|
|
160
240
|
};
|
|
161
241
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
242
|
+
// Try the primary key, then the secondary key on 401/403 (live rotation).
|
|
243
|
+
// When no keys are configured, fall through to a single unauthenticated
|
|
244
|
+
// attempt so the diagnostic surface (e.g. 204 No Content from IMF) is
|
|
245
|
+
// visible to the caller.
|
|
246
|
+
const keys = readImfSubscriptionKeys();
|
|
247
|
+
const attempts = keys.length > 0 ? [...keys] : [undefined];
|
|
248
|
+
let lastResponse;
|
|
249
|
+
let lastError;
|
|
250
|
+
for (let i = 0; i < attempts.length; i += 1) {
|
|
251
|
+
const key = attempts[i];
|
|
252
|
+
try {
|
|
253
|
+
const response = (await fetchImpl(url, {
|
|
254
|
+
headers: buildImfHeaders(key),
|
|
255
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
256
|
+
}));
|
|
257
|
+
lastResponse = response;
|
|
258
|
+
const outcome = await classifyFetchResponse(response, i + 1 < attempts.length);
|
|
259
|
+
if (outcome.kind === 'auth-retry') {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (outcome.kind === 'error') {
|
|
263
|
+
return { jsonrpc: '2.0', id, error: outcome.rpcError };
|
|
264
|
+
}
|
|
168
265
|
return {
|
|
169
266
|
jsonrpc: '2.0',
|
|
170
267
|
id,
|
|
171
|
-
|
|
268
|
+
result: { content: [{ type: 'text', text: outcome.text }] },
|
|
172
269
|
};
|
|
173
270
|
}
|
|
174
|
-
|
|
271
|
+
catch (err) {
|
|
272
|
+
lastError = err;
|
|
273
|
+
// Network errors are not auth-class — do not retry with the secondary
|
|
274
|
+
// key (the IMF endpoint is the same, only the header differs). Clear
|
|
275
|
+
// any HTTP response captured by an earlier attempt so the post-loop
|
|
276
|
+
// branch does not surface a stale 401/403 in place of this thrown
|
|
277
|
+
// error (the caller needs to see the real failure mode).
|
|
278
|
+
lastResponse = undefined;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (lastError !== undefined) {
|
|
283
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
284
|
+
return { jsonrpc: '2.0', id, error: { code: -1, message } };
|
|
285
|
+
}
|
|
286
|
+
if (lastResponse !== undefined && !lastResponse.ok) {
|
|
175
287
|
return {
|
|
176
288
|
jsonrpc: '2.0',
|
|
177
289
|
id,
|
|
178
|
-
|
|
290
|
+
error: {
|
|
291
|
+
code: -1,
|
|
292
|
+
message: `HTTP ${lastResponse.status} ${lastResponse.statusText}`,
|
|
293
|
+
},
|
|
179
294
|
};
|
|
180
295
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
296
|
+
return {
|
|
297
|
+
jsonrpc: '2.0',
|
|
298
|
+
id,
|
|
299
|
+
error: { code: -1, message: 'fetch_url failed without a response' },
|
|
300
|
+
};
|
|
185
301
|
}
|
|
186
302
|
// ─── Main server loop ─────────────────────────────────────────────────────────
|
|
187
303
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module MCP/IMFMCPClient
|
|
3
3
|
* @description Native TypeScript IMF Data client — calls the IMF SDMX 3.0
|
|
4
|
-
* REST API at {@link https://
|
|
4
|
+
* REST API at {@link https://api.imf.org/external/sdmx/3.0/} via the
|
|
5
5
|
* shared IMF-only `fetch-proxy` MCP gateway in gh-aw/AWF runs and direct
|
|
6
6
|
* `fetch()` in local/non-AWF contexts.
|
|
7
7
|
*
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*
|
|
35
35
|
* - Uses the native Node 25 `fetch()` — no extra runtime dependency.
|
|
36
36
|
* - Every call has an independent `AbortController` with a configurable
|
|
37
|
-
* timeout (`IMF_API_TIMEOUT_MS`, default
|
|
37
|
+
* timeout (`IMF_API_TIMEOUT_MS`, default 90 s).
|
|
38
38
|
* - Errors (HTTP 4xx/5xx, network faults, JSON parse failures, abort) are
|
|
39
39
|
* caught and converted to the {@link IMF_FALLBACK} envelope. Callers
|
|
40
40
|
* upstream can therefore treat "no IMF" as "empty data" without
|
|
@@ -42,12 +42,17 @@
|
|
|
42
42
|
*
|
|
43
43
|
* Environment variables:
|
|
44
44
|
* - `IMF_API_BASE_URL` — override base URL (default
|
|
45
|
-
* `https://
|
|
46
|
-
* - `IMF_API_TIMEOUT_MS` — per-request timeout (default `
|
|
45
|
+
* `https://api.imf.org/external/sdmx/3.0`).
|
|
46
|
+
* - `IMF_API_TIMEOUT_MS` — per-request timeout (default `90000`).
|
|
47
|
+
* - `IMF_API_PRIMARY_KEY` — Azure APIM subscription key for `api.imf.org`
|
|
48
|
+
* (required since September 2025; sent as `Ocp-Apim-Subscription-Key`).
|
|
49
|
+
* - `IMF_API_SECONDARY_KEY` — warm-standby subscription key, used to retry
|
|
50
|
+
* on `401`/`403` so live key rotation never breaks a run.
|
|
47
51
|
*
|
|
48
52
|
* Historic env vars (`IMF_MCP_GATEWAY_URL`, `IMF_MCP_GATEWAY_API_KEY`,
|
|
49
|
-
* `IMF_MCP_SERVER_PATH`) are no longer consulted —
|
|
50
|
-
*
|
|
53
|
+
* `IMF_MCP_SERVER_PATH`) are no longer consulted — the IMF SDMX surface
|
|
54
|
+
* runs over plain HTTPS (with subscription-key auth) via the AWF
|
|
55
|
+
* fetch-proxy MCP server when reached from a sandboxed agent.
|
|
51
56
|
*/
|
|
52
57
|
import type { MCPToolResult, MCPClientOptions } from '../types/index.js';
|
|
53
58
|
/**
|
|
@@ -113,6 +118,7 @@ export declare class IMFMCPClient {
|
|
|
113
118
|
private readonly _fetchImpl;
|
|
114
119
|
private readonly _fetchProxyGatewayUrl;
|
|
115
120
|
private readonly _fetchProxyApiKey;
|
|
121
|
+
private readonly _imfSubscriptionKeys;
|
|
116
122
|
private _connected;
|
|
117
123
|
constructor(options?: IMFClientOptions);
|
|
118
124
|
/**
|
|
@@ -147,10 +153,16 @@ export declare class IMFMCPClient {
|
|
|
147
153
|
/**
|
|
148
154
|
* List every IMF database (dataflow) exposed by the SDMX 3.0 API.
|
|
149
155
|
*
|
|
150
|
-
* Virtual tool: `imf-list-databases`.
|
|
156
|
+
* Virtual tool: `imf-list-databases`. Hits the umbrella
|
|
157
|
+
* `/structure/dataflow` endpoint which returns every published
|
|
158
|
+
* dataflow across all IMF sub-agencies (`IMF.RES`, `IMF.STA`,
|
|
159
|
+
* `IMF.FAD`, `IMF.WHD`, `IMF.MCM`, …) — typically ~190 entries.
|
|
160
|
+
* Each row includes the publishing `agency` so callers know which
|
|
161
|
+
* agency to use when calling {@link getParameterDefs} or {@link fetchData}.
|
|
151
162
|
*
|
|
152
163
|
* @returns MCP-shaped result whose `content[0].text` carries a JSON
|
|
153
|
-
* array of `{ id, name, description }` entries.
|
|
164
|
+
* array of `{ id, name, description, agency, version }` entries.
|
|
165
|
+
* Empty on error.
|
|
154
166
|
*/
|
|
155
167
|
listDatabases(): Promise<MCPToolResult>;
|
|
156
168
|
/**
|
|
@@ -170,28 +182,37 @@ export declare class IMFMCPClient {
|
|
|
170
182
|
* dataflow. Essential before building an SDMX key for
|
|
171
183
|
* {@link fetchData} because each database has its own dimension set.
|
|
172
184
|
*
|
|
173
|
-
* Virtual tool: `imf-get-parameter-defs`.
|
|
185
|
+
* Virtual tool: `imf-get-parameter-defs`. Uses the
|
|
186
|
+
* `/structure/dataflow/{agency}/{id}/+?references=datastructure`
|
|
187
|
+
* endpoint because the legacy `/structure/datastructure/IMF/{id}/+`
|
|
188
|
+
* shape returns 204 on `api.imf.org` after the September-2025 IMF
|
|
189
|
+
* Data Portal migration retired the umbrella `IMF` agency.
|
|
174
190
|
*
|
|
175
|
-
* @param databaseId - IMF dataflow identifier (e.g. `"WEO"`, `"
|
|
191
|
+
* @param databaseId - IMF dataflow identifier (e.g. `"WEO"`, `"FM"`).
|
|
192
|
+
* @param agencyId - Optional override; defaults to {@link resolveAgency}.
|
|
176
193
|
* @returns MCP-shaped result whose `content[0].text` carries the
|
|
177
194
|
* ordered list of dimensions (`[{ id, name }]`). Empty on error.
|
|
178
195
|
*/
|
|
179
|
-
getParameterDefs(databaseId: string): Promise<MCPToolResult>;
|
|
196
|
+
getParameterDefs(databaseId: string, agencyId?: string): Promise<MCPToolResult>;
|
|
180
197
|
/**
|
|
181
198
|
* List valid codes for a single dimension of an IMF dataflow, with
|
|
182
199
|
* an optional free-text filter to narrow the result.
|
|
183
200
|
*
|
|
184
|
-
* Virtual tool: `imf-get-parameter-codes`.
|
|
185
|
-
* `/
|
|
186
|
-
*
|
|
187
|
-
*
|
|
201
|
+
* Virtual tool: `imf-get-parameter-codes`. Uses
|
|
202
|
+
* `/structure/dataflow/{agency}/{id}/+?references=all` to fetch the
|
|
203
|
+
* DSD plus its referenced conceptSchemes and codelists in one
|
|
204
|
+
* round-trip — SDMX 3.0 binds the codelist on the *concept*
|
|
205
|
+
* (`coreRepresentation.enumeration`), so resolving codes requires
|
|
206
|
+
* walking dim → conceptIdentity → conceptScheme → concept → codelist.
|
|
188
207
|
*
|
|
189
208
|
* @param databaseId - IMF dataflow identifier.
|
|
190
|
-
* @param parameter - Dimension name (e.g. `"
|
|
209
|
+
* @param parameter - Dimension name (e.g. `"COUNTRY"`, `"INDICATOR"`;
|
|
210
|
+
* matched case-insensitively).
|
|
191
211
|
* @param search - Optional free-text search (case-insensitive substring).
|
|
212
|
+
* @param agencyId - Optional agency override; defaults to {@link resolveAgency}.
|
|
192
213
|
* @returns MCP-shaped result with `[{ id, name }]` rows; empty on error.
|
|
193
214
|
*/
|
|
194
|
-
getParameterCodes(databaseId: string, parameter: string, search?: string): Promise<MCPToolResult>;
|
|
215
|
+
getParameterCodes(databaseId: string, parameter: string, search?: string, agencyId?: string): Promise<MCPToolResult>;
|
|
195
216
|
/**
|
|
196
217
|
* Fetch a time-series slice from an IMF dataflow as SDMX-JSON.
|
|
197
218
|
*
|
|
@@ -202,13 +223,17 @@ export declare class IMFMCPClient {
|
|
|
202
223
|
* April-2026 aggregator-pipeline migration.)
|
|
203
224
|
*
|
|
204
225
|
* @param options - Fetch parameters.
|
|
205
|
-
* @param options.databaseId - IMF dataflow ID (`"WEO"`, `"
|
|
226
|
+
* @param options.databaseId - IMF dataflow ID (`"WEO"`, `"FM"`, ...).
|
|
206
227
|
* @param options.startYear - Inclusive start year (e.g. `2015`).
|
|
207
228
|
* @param options.endYear - Inclusive end year (e.g. `2030` for WEO forecasts).
|
|
208
|
-
* @param options.filters - Map of dimension → selected codes.
|
|
229
|
+
* @param options.filters - Map of dimension → selected codes. Filter
|
|
230
|
+
* keys are matched case-insensitively against the DSD dimensions
|
|
231
|
+
* (legacy lowercase `country`/`indicator`/`frequency` continue to work).
|
|
209
232
|
* @param options.dimensionOrder - Optional override of the dimension order
|
|
210
233
|
* used to build the SDMX key. Defaults to
|
|
211
234
|
* {@link defaultDimensionOrder} for the database.
|
|
235
|
+
* @param options.agencyId - Optional SDMX agency override (e.g. `"IMF.RES"`,
|
|
236
|
+
* `"IMF.STA"`). Defaults to {@link resolveAgency}.
|
|
212
237
|
* @returns MCP-shaped result whose `content[0].text` carries the raw
|
|
213
238
|
* SDMX-JSON response. Empty on error or invalid inputs.
|
|
214
239
|
*/
|
|
@@ -218,6 +243,7 @@ export declare class IMFMCPClient {
|
|
|
218
243
|
endYear: number;
|
|
219
244
|
filters: Readonly<Record<string, readonly string[]>>;
|
|
220
245
|
dimensionOrder?: readonly string[];
|
|
246
|
+
agencyId?: string;
|
|
221
247
|
}): Promise<MCPToolResult>;
|
|
222
248
|
/**
|
|
223
249
|
* Build a full URL and GET it as text, enforcing the client-wide timeout.
|
|
@@ -231,6 +257,29 @@ export declare class IMFMCPClient {
|
|
|
231
257
|
* @internal
|
|
232
258
|
*/
|
|
233
259
|
private _getText;
|
|
260
|
+
/**
|
|
261
|
+
* Direct-fetch strategy with subscription-key rotation.
|
|
262
|
+
*
|
|
263
|
+
* Iterates configured `IMF_API_PRIMARY_KEY` → `IMF_API_SECONDARY_KEY`,
|
|
264
|
+
* retrying only on `401`/`403`. Network errors short-circuit immediately.
|
|
265
|
+
*
|
|
266
|
+
* @param url - Fully-qualified IMF SDMX URL.
|
|
267
|
+
* @returns Response body text on success.
|
|
268
|
+
* @throws The last HTTP/network error when all configured keys are exhausted.
|
|
269
|
+
* @internal
|
|
270
|
+
*/
|
|
271
|
+
private _fetchDirectWithKeyRotation;
|
|
272
|
+
/**
|
|
273
|
+
* Single direct-fetch attempt with one subscription key. Classifies the
|
|
274
|
+
* outcome so {@link _fetchDirectWithKeyRotation} can decide whether to
|
|
275
|
+
* rotate keys or surface the error.
|
|
276
|
+
*
|
|
277
|
+
* @param url - Fully-qualified IMF SDMX URL.
|
|
278
|
+
* @param key - Subscription key for this attempt, or `undefined` to send unauthenticated.
|
|
279
|
+
* @returns `'ok'` with body text, `'auth'` with the 401/403 error, or `'error'` for everything else.
|
|
280
|
+
* @internal
|
|
281
|
+
*/
|
|
282
|
+
private _fetchOnceWithKey;
|
|
234
283
|
/**
|
|
235
284
|
* Fetch a URL via the MCP fetch-proxy gateway (JSON-RPC 2.0 over HTTP).
|
|
236
285
|
* The fetch-proxy server runs in a container that bypasses the AWF Squid proxy.
|
|
@@ -1,21 +1,192 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
4
|
-
/** Default base URL for the IMF SDMX 3.0 REST API. */
|
|
5
|
-
const DEFAULT_IMF_API_BASE_URL = 'https://
|
|
4
|
+
/** Default base URL for the IMF Data Portal SDMX 3.0 REST API. */
|
|
5
|
+
const DEFAULT_IMF_API_BASE_URL = 'https://api.imf.org/external/sdmx/3.0';
|
|
6
6
|
/** Default per-request timeout (milliseconds). */
|
|
7
7
|
const DEFAULT_IMF_API_TIMEOUT_MS = 90_000;
|
|
8
8
|
/** Product identifier sent to IMF SDMX endpoints. */
|
|
9
9
|
const IMF_USER_AGENT = 'euparliamentmonitor/0.9.0 (+https://github.com/Hack23/euparliamentmonitor)';
|
|
10
10
|
/** IMF SDMX accepts JSON data; keep a fallback for proxy/content negotiation. */
|
|
11
11
|
const IMF_ACCEPT_HEADER = 'application/json, application/vnd.sdmx.data+json, */*;q=0.8';
|
|
12
|
-
/** Common
|
|
12
|
+
/** Common headers for direct IMF SDMX REST requests (auth header added per-request). */
|
|
13
13
|
const IMF_REQUEST_HEADERS = Object.freeze({
|
|
14
14
|
Accept: IMF_ACCEPT_HEADER,
|
|
15
15
|
'User-Agent': IMF_USER_AGENT,
|
|
16
16
|
'Accept-Language': 'en-US,en;q=0.9',
|
|
17
17
|
'Cache-Control': 'no-cache',
|
|
18
18
|
});
|
|
19
|
+
/** Azure APIM subscription-key header expected by `api.imf.org`. */
|
|
20
|
+
const IMF_SUBSCRIPTION_KEY_HEADER = 'Ocp-Apim-Subscription-Key';
|
|
21
|
+
/**
|
|
22
|
+
* Per-dataflow → maintainer agency map for the post-September-2025 IMF
|
|
23
|
+
* Data Portal, where the umbrella `IMF` agency was retired in favour of
|
|
24
|
+
* sub-departmental agency IDs (`IMF.RES`, `IMF.STA`, `IMF.FAD`, `IMF.WHD`,
|
|
25
|
+
* `IMF.MCM`, …). Discovered live against
|
|
26
|
+
* `GET /structure/dataflow` on `api.imf.org/external/sdmx/3.0`.
|
|
27
|
+
*
|
|
28
|
+
* Keys are uppercased dataflow IDs; values are the canonical agency that
|
|
29
|
+
* publishes them. Unknown / vintage-suffixed IDs (e.g. `WEO_2025_OCT_VINTAGE`)
|
|
30
|
+
* fall through to {@link DEFAULT_IMF_AGENCY}.
|
|
31
|
+
*/
|
|
32
|
+
const IMF_DATAFLOW_AGENCY = Object.freeze({
|
|
33
|
+
// IMF.RES — Research Department (forecasts + commodity prices)
|
|
34
|
+
WEO: 'IMF.RES',
|
|
35
|
+
PCPS: 'IMF.RES',
|
|
36
|
+
ITS: 'IMF.RES',
|
|
37
|
+
// IMF.FAD — Fiscal Affairs Department
|
|
38
|
+
FM: 'IMF.FAD',
|
|
39
|
+
// IMF.STA — Statistics Department (everything else editorial)
|
|
40
|
+
CPI: 'IMF.STA',
|
|
41
|
+
CPI_WCA: 'IMF.STA',
|
|
42
|
+
BOP: 'IMF.STA',
|
|
43
|
+
BOP_AGG: 'IMF.STA',
|
|
44
|
+
ER: 'IMF.STA',
|
|
45
|
+
IFS: 'IMF.STA',
|
|
46
|
+
DOT: 'IMF.STA',
|
|
47
|
+
CDIS: 'IMF.STA',
|
|
48
|
+
CPIS: 'IMF.STA',
|
|
49
|
+
GFS: 'IMF.STA',
|
|
50
|
+
GFS_SOO: 'IMF.STA',
|
|
51
|
+
GFS_BS: 'IMF.STA',
|
|
52
|
+
GFS_COFOG: 'IMF.STA',
|
|
53
|
+
GFS_SSUC: 'IMF.STA',
|
|
54
|
+
GFS_SOEF: 'IMF.STA',
|
|
55
|
+
GFS_SFCP: 'IMF.STA',
|
|
56
|
+
FSI: 'IMF.STA',
|
|
57
|
+
MFS: 'IMF.STA',
|
|
58
|
+
MFS_FC: 'IMF.STA',
|
|
59
|
+
FA: 'IMF.STA',
|
|
60
|
+
GFSR: 'IMF.STA',
|
|
61
|
+
});
|
|
62
|
+
/** Fallback agency when {@link IMF_DATAFLOW_AGENCY} has no entry — most editorial dataflows are STA-published. */
|
|
63
|
+
const DEFAULT_IMF_AGENCY = 'IMF.STA';
|
|
64
|
+
/**
|
|
65
|
+
* Parse an SDMX URN into its three salient parts:
|
|
66
|
+
* agency (optional), id, and concept-id (only present for Concept URNs).
|
|
67
|
+
*
|
|
68
|
+
* Examples handled:
|
|
69
|
+
* - `urn:sdmx:org.sdmx.infomodel.codelist.Codelist=IMF.RES:CL_WEO_INDICATOR(2.0+.0)`
|
|
70
|
+
* → `{ agency: 'IMF.RES', id: 'CL_WEO_INDICATOR', conceptId: '' }`
|
|
71
|
+
* - `urn:sdmx:org.sdmx.infomodel.conceptscheme.Concept=IMF.RES:CS_WEO(4.0+.0).INDICATOR`
|
|
72
|
+
* → `{ agency: 'IMF.RES', id: 'CS_WEO', conceptId: 'INDICATOR' }`
|
|
73
|
+
*
|
|
74
|
+
* Pure string-split parsing (no regex) so the static-analysis "unsafe regex"
|
|
75
|
+
* detector has nothing to object to and the extraction stays linear.
|
|
76
|
+
*
|
|
77
|
+
* Assumption: IMF SDMX 3.0 always emits a `(version)` block on every
|
|
78
|
+
* URN we encounter, so the concept-id extraction relies on the closing
|
|
79
|
+
* `)`. If the version block is missing or malformed (no `)`) we treat
|
|
80
|
+
* the URN as having no concept-id rather than fall back to slicing
|
|
81
|
+
* from the start of the body — which would silently leak the codelist
|
|
82
|
+
* id into the conceptId field.
|
|
83
|
+
*
|
|
84
|
+
* @param urn - SDMX URN to parse.
|
|
85
|
+
* @returns Parsed parts (any field may be empty if absent in the URN).
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
function parseSDMXUrn(urn) {
|
|
89
|
+
const eqIdx = urn.indexOf('=');
|
|
90
|
+
const body = eqIdx >= 0 ? urn.slice(eqIdx + 1) : urn;
|
|
91
|
+
const parenIdx = body.indexOf('(');
|
|
92
|
+
const head = parenIdx >= 0 ? body.slice(0, parenIdx) : body;
|
|
93
|
+
// Concept URNs have a trailing `.CONCEPT_ID` after the closing paren.
|
|
94
|
+
// Guard the missing-`)` case explicitly: `indexOf(')', n)` returns -1
|
|
95
|
+
// when absent, and `body.slice(-1 + 1) = body.slice(0)` would echo
|
|
96
|
+
// the whole URN body into `tail` — yielding a bogus conceptId.
|
|
97
|
+
let tail = '';
|
|
98
|
+
if (parenIdx >= 0) {
|
|
99
|
+
const closeIdx = body.indexOf(')', parenIdx);
|
|
100
|
+
if (closeIdx >= 0)
|
|
101
|
+
tail = body.slice(closeIdx + 1);
|
|
102
|
+
}
|
|
103
|
+
const conceptId = tail.startsWith('.') ? tail.slice(1) : '';
|
|
104
|
+
const colonIdx = head.indexOf(':');
|
|
105
|
+
const agency = colonIdx >= 0 ? head.slice(0, colonIdx) : '';
|
|
106
|
+
const id = colonIdx >= 0 ? head.slice(colonIdx + 1) : head;
|
|
107
|
+
return { agency, id, conceptId };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the codelist URN for a single dimension by walking the SDMX
|
|
111
|
+
* 3.0 dimension → concept → codelist binding chain.
|
|
112
|
+
*
|
|
113
|
+
* Tries the legacy SDMX 2.1 shape first (`localRepresentation.enumeration`
|
|
114
|
+
* directly on the dimension) before falling back to the SDMX 3.0 shape
|
|
115
|
+
* where the binding lives on the concept (`coreRepresentation.enumeration`).
|
|
116
|
+
*
|
|
117
|
+
* @param dim - DSD dimension entry.
|
|
118
|
+
* @param conceptSchemes - Inlined conceptSchemes from the same payload.
|
|
119
|
+
* @returns Codelist URN, or `undefined` when no binding is declared.
|
|
120
|
+
* @internal
|
|
121
|
+
*/
|
|
122
|
+
function resolveCodelistUrn(dim, conceptSchemes) {
|
|
123
|
+
const direct = dim.localRepresentation?.enumeration;
|
|
124
|
+
if (direct)
|
|
125
|
+
return direct;
|
|
126
|
+
if (!dim.conceptIdentity)
|
|
127
|
+
return undefined;
|
|
128
|
+
const { agency, id, conceptId } = parseSDMXUrn(dim.conceptIdentity);
|
|
129
|
+
if (!conceptId)
|
|
130
|
+
return undefined;
|
|
131
|
+
const cs = conceptSchemes.find((s) => s.id === id && (agency === '' || s.agencyID === agency));
|
|
132
|
+
const concept = cs?.concepts?.find((c) => c.id === conceptId);
|
|
133
|
+
return concept?.coreRepresentation?.enumeration;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the actual list of codes for a single dimension by looking up
|
|
137
|
+
* the bound codelist in the inlined codelists payload. Falls back to
|
|
138
|
+
* any inlined `dim.values` array when present.
|
|
139
|
+
*
|
|
140
|
+
* @param dim - DSD dimension entry.
|
|
141
|
+
* @param payload - The `data` block of an SDMX `references=all` response
|
|
142
|
+
* (must contain `conceptSchemes` and `codelists`).
|
|
143
|
+
* @returns Ordered list of `{ id, name }` codes; empty when the
|
|
144
|
+
* dimension has neither inlined values nor a resolvable codelist.
|
|
145
|
+
* @internal
|
|
146
|
+
*/
|
|
147
|
+
function resolveCodelistCodes(dim, payload) {
|
|
148
|
+
if (dim.values && dim.values.length > 0)
|
|
149
|
+
return dim.values;
|
|
150
|
+
const urn = resolveCodelistUrn(dim, payload.conceptSchemes ?? []);
|
|
151
|
+
if (!urn)
|
|
152
|
+
return [];
|
|
153
|
+
const { agency, id } = parseSDMXUrn(urn);
|
|
154
|
+
if (!id)
|
|
155
|
+
return [];
|
|
156
|
+
const cls = payload.codelists ?? [];
|
|
157
|
+
const exact = cls.find((c) => c.id === id && (!agency || c.agencyID === agency));
|
|
158
|
+
const cl = exact ?? cls.find((c) => c.id === id);
|
|
159
|
+
return cl?.codes ?? [];
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the SDMX agency for a dataflow.
|
|
163
|
+
*
|
|
164
|
+
* Strips any `_YYYY_MMM_VINTAGE` suffix before lookup so monthly vintage
|
|
165
|
+
* IDs (`WEO_2025_OCT_VINTAGE`) inherit the same agency as their
|
|
166
|
+
* canonical "latest" sibling (`WEO`).
|
|
167
|
+
*
|
|
168
|
+
* @param databaseId - IMF dataflow identifier.
|
|
169
|
+
* @returns Agency code (e.g. `"IMF.RES"`).
|
|
170
|
+
* @internal
|
|
171
|
+
*/
|
|
172
|
+
function resolveAgency(databaseId) {
|
|
173
|
+
const upper = databaseId.toUpperCase();
|
|
174
|
+
// Avoid dynamic object indexing so the security lint does not flag user input as a sink.
|
|
175
|
+
const direct = Object.entries(IMF_DATAFLOW_AGENCY).find(([k]) => k === upper)?.[1];
|
|
176
|
+
if (direct)
|
|
177
|
+
return direct;
|
|
178
|
+
// Vintage suffix: WEO_2025_OCT_VINTAGE → WEO
|
|
179
|
+
const vintageIdx = upper.indexOf('_VINTAGE');
|
|
180
|
+
if (vintageIdx > 0) {
|
|
181
|
+
const trimmed = upper.slice(0, vintageIdx).split('_').slice(0, -2).join('_');
|
|
182
|
+
if (trimmed) {
|
|
183
|
+
const fromBase = Object.entries(IMF_DATAFLOW_AGENCY).find(([k]) => k === trimmed)?.[1];
|
|
184
|
+
if (fromBase)
|
|
185
|
+
return fromBase;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return DEFAULT_IMF_AGENCY;
|
|
189
|
+
}
|
|
19
190
|
/** Fallback payload shape when an IMF call fails or the server is offline. */
|
|
20
191
|
const IMF_FALLBACK = {
|
|
21
192
|
content: [{ type: 'text', text: '' }],
|
|
@@ -130,8 +301,15 @@ export function countIMFSDMXObservations(payload) {
|
|
|
130
301
|
* avoid collisions with user-supplied codes that happen to contain
|
|
131
302
|
* those characters.
|
|
132
303
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
304
|
+
* Callers must NOT pass an empty array. The post-Sept-2025 IMF Data
|
|
305
|
+
* Portal rejects bare empty positions (`DEU..A` returns 0 series); the
|
|
306
|
+
* SDMX 3.0 wildcard for "match every code in this dimension" is the
|
|
307
|
+
* literal `*` and is emitted by {@link buildSDMXKey} directly when a
|
|
308
|
+
* dimension has no caller-supplied codes — never by passing `[]` here.
|
|
309
|
+
*
|
|
310
|
+
* @param codes - Ordered, non-empty code values for a single dimension.
|
|
311
|
+
* @returns URL-safe dimension component (`"A"` for a single code,
|
|
312
|
+
* `"A+B"` for a union; never `""` — see note above).
|
|
135
313
|
* @internal
|
|
136
314
|
*/
|
|
137
315
|
function encodeSDMXDimension(codes) {
|
|
@@ -140,23 +318,33 @@ function encodeSDMXDimension(codes) {
|
|
|
140
318
|
/**
|
|
141
319
|
* Build an SDMX key from a filters map + declared dimension order.
|
|
142
320
|
*
|
|
143
|
-
* If a declared dimension is absent from `filters`, the slot is
|
|
144
|
-
* the
|
|
145
|
-
*
|
|
146
|
-
* the
|
|
321
|
+
* If a declared dimension is absent from `filters`, the slot is filled
|
|
322
|
+
* with the SDMX 3.0 wildcard `*` (the post-Sept-2025 IMF Data Portal
|
|
323
|
+
* rejects bare empty positions — `DEU..A` returns 0 series, `DEU.*.A`
|
|
324
|
+
* returns the full set). Extra filter keys not present in the declared
|
|
325
|
+
* order are ignored — the caller is expected to have discovered the
|
|
326
|
+
* correct dimension names via {@link IMFMCPClient.getParameterDefs}.
|
|
147
327
|
*
|
|
148
|
-
*
|
|
328
|
+
* Filter keys are matched case-insensitively against declared dimension
|
|
329
|
+
* names so callers can keep using the legacy lowercase aliases
|
|
330
|
+
* (`country`, `indicator`, `frequency`) even though the IMF SDMX 3.0
|
|
331
|
+
* DSDs use uppercase (`COUNTRY`, `INDICATOR`, `FREQUENCY`).
|
|
332
|
+
*
|
|
333
|
+
* @param dimensions - Declared dimension order (e.g. `["COUNTRY","INDICATOR","FREQUENCY"]`).
|
|
149
334
|
* @param filters - Map of dimension → selected codes.
|
|
150
|
-
* @returns SDMX key (e.g. `"
|
|
335
|
+
* @returns SDMX key (e.g. `"DEU.NGDP_RPCH.A"` or `"DEU.*.A"` for wildcard indicator).
|
|
151
336
|
* @internal
|
|
152
337
|
*/
|
|
153
338
|
function buildSDMXKey(dimensions, filters) {
|
|
339
|
+
const lowercasedFilters = Object.entries(filters).map(([key, value]) => [key.toLowerCase(), value]);
|
|
154
340
|
return dimensions
|
|
155
341
|
.map((dim) => {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
342
|
+
const dimLc = dim.toLowerCase();
|
|
343
|
+
const codes = lowercasedFilters.find(([key]) => key === dimLc)?.[1];
|
|
344
|
+
// SDMX 3.0 wildcard for "match every code in this dimension" is `*`.
|
|
345
|
+
// The legacy SDMX 2.1 convention of leaving the segment bare is
|
|
346
|
+
// rejected by `api.imf.org` post-Sept-2025 (returns 0 series).
|
|
347
|
+
return Array.isArray(codes) && codes.length > 0 ? encodeSDMXDimension(codes) : '*';
|
|
160
348
|
})
|
|
161
349
|
.join('.');
|
|
162
350
|
}
|
|
@@ -166,8 +354,12 @@ function buildSDMXKey(dimensions, filters) {
|
|
|
166
354
|
* fallback because the WEO datastructure in particular is so widely used
|
|
167
355
|
* that encoding a well-known default eliminates one round-trip per fetch.
|
|
168
356
|
*
|
|
169
|
-
* Order mirrors the IMF SDMX 3.0 DSDs catalogued
|
|
170
|
-
* `
|
|
357
|
+
* Order mirrors the IMF SDMX 3.0 DSDs catalogued live against
|
|
358
|
+
* `api.imf.org/external/sdmx/3.0/structure/dataflow/{agency}/{id}/+?references=datastructure`
|
|
359
|
+
* (post-Sept-2025 Data Portal migration). All dimension names are
|
|
360
|
+
* UPPERCASE and `FREQUENCY` is the **last** series-level dimension —
|
|
361
|
+
* the legacy SDMX 2.1 convention of leading with `FREQ` no longer
|
|
362
|
+
* applies on `api.imf.org`.
|
|
171
363
|
*
|
|
172
364
|
* @param databaseId - Dataflow identifier (case-insensitive).
|
|
173
365
|
* @returns Default dimension order used when the caller omits it.
|
|
@@ -178,25 +370,37 @@ function defaultDimensionOrder(databaseId) {
|
|
|
178
370
|
case 'WEO':
|
|
179
371
|
case 'FM':
|
|
180
372
|
case 'IFS':
|
|
181
|
-
case 'CPI':
|
|
182
373
|
case 'BOP_AGG':
|
|
374
|
+
return ['COUNTRY', 'INDICATOR', 'FREQUENCY'];
|
|
375
|
+
case 'CPI':
|
|
376
|
+
case 'CPI_WCA':
|
|
377
|
+
return ['COUNTRY', 'INDEX_TYPE', 'COICOP_1999', 'TYPE_OF_TRANSFORMATION', 'FREQUENCY'];
|
|
378
|
+
case 'BOP':
|
|
379
|
+
return ['COUNTRY', 'BOP_ACCOUNTING_ENTRY', 'INDICATOR', 'UNIT', 'FREQUENCY'];
|
|
183
380
|
case 'ER':
|
|
184
|
-
|
|
185
|
-
|
|
381
|
+
return ['COUNTRY', 'INDICATOR', 'TYPE_OF_TRANSFORMATION', 'FREQUENCY'];
|
|
382
|
+
case 'PCPS':
|
|
383
|
+
return ['COUNTRY', 'INDICATOR', 'DATA_TRANSFORMATION', 'FREQUENCY'];
|
|
186
384
|
case 'DOT':
|
|
187
|
-
return ['
|
|
385
|
+
return ['COUNTRY', 'COUNTERPART_AREA', 'INDICATOR', 'FREQUENCY'];
|
|
188
386
|
case 'CDIS':
|
|
189
|
-
return ['
|
|
387
|
+
return ['COUNTRY', 'COUNTERPART_AREA', 'SECTOR', 'INDICATOR', 'FREQUENCY'];
|
|
190
388
|
case 'CPIS':
|
|
191
|
-
return ['
|
|
192
|
-
case '
|
|
193
|
-
return ['
|
|
389
|
+
return ['COUNTRY', 'COUNTERPART_AREA', 'INSTRUMENT', 'INDICATOR', 'FREQUENCY'];
|
|
390
|
+
case 'FSI':
|
|
391
|
+
return ['COUNTRY', 'INDICATOR', 'SECTOR', 'FREQUENCY'];
|
|
194
392
|
case 'GFSR':
|
|
195
|
-
return ['
|
|
393
|
+
return ['COUNTRY', 'INDICATOR', 'SECTOR', 'FREQUENCY'];
|
|
196
394
|
case 'GFS':
|
|
197
|
-
|
|
395
|
+
case 'GFS_SOO':
|
|
396
|
+
case 'GFS_BS':
|
|
397
|
+
case 'GFS_COFOG':
|
|
398
|
+
case 'GFS_SSUC':
|
|
399
|
+
case 'GFS_SOEF':
|
|
400
|
+
case 'GFS_SFCP':
|
|
401
|
+
return ['COUNTRY', 'SECTOR', 'UNIT', 'INDICATOR', 'FREQUENCY'];
|
|
198
402
|
default:
|
|
199
|
-
return ['
|
|
403
|
+
return ['COUNTRY', 'INDICATOR', 'FREQUENCY'];
|
|
200
404
|
}
|
|
201
405
|
}
|
|
202
406
|
/**
|
|
@@ -232,17 +436,102 @@ function defaultFrequency(databaseId) {
|
|
|
232
436
|
}
|
|
233
437
|
}
|
|
234
438
|
/**
|
|
235
|
-
* Add a dataflow-specific default frequency when the caller omitted one
|
|
439
|
+
* Add a dataflow-specific default frequency when the caller omitted one,
|
|
440
|
+
* and normalise the legacy `freq` alias to the SDMX 3.0 `FREQUENCY`
|
|
441
|
+
* dimension name. {@link buildSDMXKey} matches dimension keys by name
|
|
442
|
+
* (`frequency`/`FREQUENCY`), so a caller that passes `freq: ['A']`
|
|
443
|
+
* would otherwise be silently dropped and the FREQUENCY slot filled
|
|
444
|
+
* with the `*` wildcard — pulling far more data than intended.
|
|
236
445
|
*
|
|
237
446
|
* @param databaseId - Dataflow identifier.
|
|
238
447
|
* @param filters - Caller-supplied SDMX dimension filters.
|
|
239
|
-
* @returns The original filters or a shallow copy with `
|
|
448
|
+
* @returns The original filters or a shallow copy with `FREQUENCY` populated.
|
|
240
449
|
* @internal
|
|
241
450
|
*/
|
|
242
451
|
function withDefaultFrequency(databaseId, filters) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
452
|
+
// Pull any caller-supplied frequency value out of the legacy `freq`
|
|
453
|
+
// alias (or any case variant of `frequency`) so we can re-emit it
|
|
454
|
+
// under the canonical uppercase `FREQUENCY` key that buildSDMXKey
|
|
455
|
+
// and defaultDimensionOrder both use.
|
|
456
|
+
let freqCodes;
|
|
457
|
+
const passthrough = {};
|
|
458
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
459
|
+
const k = key.toLowerCase();
|
|
460
|
+
if (k === 'frequency' || k === 'freq') {
|
|
461
|
+
if (Array.isArray(value) && value.length > 0 && freqCodes === undefined) {
|
|
462
|
+
freqCodes = value;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
passthrough[key] = value;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const fallback = freqCodes ??
|
|
470
|
+
(() => {
|
|
471
|
+
const f = defaultFrequency(databaseId);
|
|
472
|
+
return f ? [f] : undefined;
|
|
473
|
+
})();
|
|
474
|
+
return fallback ? { ...passthrough, FREQUENCY: fallback } : filters;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Resolve the IMF base URL and per-request timeout from constructor options
|
|
478
|
+
* and environment variables. Extracted so the {@link IMFMCPClient}
|
|
479
|
+
* constructor stays under SonarJS's cognitive-complexity threshold.
|
|
480
|
+
*
|
|
481
|
+
* @param options - Caller-supplied options (take precedence over env).
|
|
482
|
+
* @returns Resolved `base` (raw, may have trailing slashes) and `timeout`.
|
|
483
|
+
* @internal
|
|
484
|
+
*/
|
|
485
|
+
function readBaseAndTimeout(options) {
|
|
486
|
+
const envBase = process.env['IMF_API_BASE_URL'];
|
|
487
|
+
const envTimeout = process.env['IMF_API_TIMEOUT_MS'];
|
|
488
|
+
const parsedEnvTimeout = envTimeout !== undefined && envTimeout !== '' ? Number.parseInt(envTimeout, 10) : Number.NaN;
|
|
489
|
+
const base = options.apiBaseUrl ?? (envBase && envBase !== '' ? envBase : DEFAULT_IMF_API_BASE_URL);
|
|
490
|
+
let timeout;
|
|
491
|
+
if (options.timeoutMs !== undefined &&
|
|
492
|
+
Number.isFinite(options.timeoutMs) &&
|
|
493
|
+
options.timeoutMs > 0) {
|
|
494
|
+
timeout = options.timeoutMs;
|
|
495
|
+
}
|
|
496
|
+
else if (Number.isFinite(parsedEnvTimeout) && parsedEnvTimeout > 0) {
|
|
497
|
+
timeout = parsedEnvTimeout;
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
timeout = DEFAULT_IMF_API_TIMEOUT_MS;
|
|
501
|
+
}
|
|
502
|
+
return { base, timeout };
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Strip trailing slashes without using a regex, so the CodeQL polynomial-
|
|
506
|
+
* ReDoS detector has nothing to flag. Single linear pass from the right.
|
|
507
|
+
*
|
|
508
|
+
* @param s - Input string.
|
|
509
|
+
* @returns The input with all trailing `/` characters removed.
|
|
510
|
+
* @internal
|
|
511
|
+
*/
|
|
512
|
+
function stripTrailingSlashes(s) {
|
|
513
|
+
let end = s.length;
|
|
514
|
+
while (end > 0 && s.charCodeAt(end - 1) === 47 /* '/' */) {
|
|
515
|
+
end -= 1;
|
|
516
|
+
}
|
|
517
|
+
return end === s.length ? s : s.slice(0, end);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Read IMF Azure-APIM subscription keys from the environment, in priority
|
|
521
|
+
* order (primary, then secondary). Empty / unset / duplicate keys are
|
|
522
|
+
* filtered out so the returned array is `[]` only when no key is set at all.
|
|
523
|
+
*
|
|
524
|
+
* @returns Ordered list of candidate API keys (length 0–2).
|
|
525
|
+
* @internal
|
|
526
|
+
*/
|
|
527
|
+
function readImfSubscriptionKeysFromEnv() {
|
|
528
|
+
const candidates = [process.env['IMF_API_PRIMARY_KEY'], process.env['IMF_API_SECONDARY_KEY']];
|
|
529
|
+
const keys = [];
|
|
530
|
+
for (const k of candidates) {
|
|
531
|
+
if (typeof k === 'string' && k.length > 0 && !keys.includes(k))
|
|
532
|
+
keys.push(k);
|
|
533
|
+
}
|
|
534
|
+
return keys;
|
|
246
535
|
}
|
|
247
536
|
// ─── Client ──────────────────────────────────────────────────────────────────
|
|
248
537
|
/**
|
|
@@ -259,31 +548,22 @@ export class IMFMCPClient {
|
|
|
259
548
|
_fetchImpl;
|
|
260
549
|
_fetchProxyGatewayUrl;
|
|
261
550
|
_fetchProxyApiKey;
|
|
551
|
+
_imfSubscriptionKeys;
|
|
262
552
|
_connected = false;
|
|
263
553
|
constructor(options = {}) {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const base = options.apiBaseUrl ?? (envBase && envBase !== '' ? envBase : DEFAULT_IMF_API_BASE_URL);
|
|
268
|
-
// Strip trailing slashes without a regex so the CodeQL polynomial-ReDoS
|
|
269
|
-
// detector has nothing to flag. Single linear pass from the right.
|
|
270
|
-
let end = base.length;
|
|
271
|
-
while (end > 0 && base.charCodeAt(end - 1) === 47 /* '/' */) {
|
|
272
|
-
end -= 1;
|
|
273
|
-
}
|
|
274
|
-
this._apiBaseUrl = end === base.length ? base : base.slice(0, end);
|
|
275
|
-
this._timeoutMs =
|
|
276
|
-
options.timeoutMs !== undefined && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
277
|
-
? options.timeoutMs
|
|
278
|
-
: Number.isFinite(parsedEnvTimeout) && parsedEnvTimeout > 0
|
|
279
|
-
? parsedEnvTimeout
|
|
280
|
-
: DEFAULT_IMF_API_TIMEOUT_MS;
|
|
554
|
+
const { base, timeout } = readBaseAndTimeout(options);
|
|
555
|
+
this._apiBaseUrl = stripTrailingSlashes(base);
|
|
556
|
+
this._timeoutMs = timeout;
|
|
281
557
|
this._fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
282
558
|
// MCP fetch-proxy gateway for AWF sandbox (bypasses Squid proxy)
|
|
283
559
|
this._fetchProxyGatewayUrl =
|
|
284
560
|
options.fetchProxyGatewayUrl ?? process.env['FETCH_MCP_GATEWAY_URL'] ?? undefined;
|
|
285
561
|
this._fetchProxyApiKey =
|
|
286
562
|
options.fetchProxyApiKey ?? process.env['EP_MCP_GATEWAY_API_KEY'] ?? undefined;
|
|
563
|
+
// IMF Azure-APIM subscription keys (primary + secondary). The fetch-proxy
|
|
564
|
+
// server already injects the same header when it is the transport, so this
|
|
565
|
+
// is mainly for the direct-fetch fallback used outside the AWF sandbox.
|
|
566
|
+
this._imfSubscriptionKeys = readImfSubscriptionKeysFromEnv();
|
|
287
567
|
}
|
|
288
568
|
/**
|
|
289
569
|
* Base URL currently in use (read-only — set at construction time).
|
|
@@ -338,19 +618,27 @@ export class IMFMCPClient {
|
|
|
338
618
|
/**
|
|
339
619
|
* List every IMF database (dataflow) exposed by the SDMX 3.0 API.
|
|
340
620
|
*
|
|
341
|
-
* Virtual tool: `imf-list-databases`.
|
|
621
|
+
* Virtual tool: `imf-list-databases`. Hits the umbrella
|
|
622
|
+
* `/structure/dataflow` endpoint which returns every published
|
|
623
|
+
* dataflow across all IMF sub-agencies (`IMF.RES`, `IMF.STA`,
|
|
624
|
+
* `IMF.FAD`, `IMF.WHD`, `IMF.MCM`, …) — typically ~190 entries.
|
|
625
|
+
* Each row includes the publishing `agency` so callers know which
|
|
626
|
+
* agency to use when calling {@link getParameterDefs} or {@link fetchData}.
|
|
342
627
|
*
|
|
343
628
|
* @returns MCP-shaped result whose `content[0].text` carries a JSON
|
|
344
|
-
* array of `{ id, name, description }` entries.
|
|
629
|
+
* array of `{ id, name, description, agency, version }` entries.
|
|
630
|
+
* Empty on error.
|
|
345
631
|
*/
|
|
346
632
|
async listDatabases() {
|
|
347
633
|
try {
|
|
348
|
-
const json = await this._getJSON('/dataflow
|
|
634
|
+
const json = await this._getJSON('/structure/dataflow');
|
|
349
635
|
const flows = json?.data?.dataflows ?? [];
|
|
350
636
|
const rows = flows.map((f) => ({
|
|
351
637
|
id: f.id ?? '',
|
|
352
638
|
name: unwrapLocalisedLabel(f.name),
|
|
353
639
|
description: unwrapLocalisedLabel(f.description),
|
|
640
|
+
agency: f.agencyID ?? '',
|
|
641
|
+
version: f.version ?? '',
|
|
354
642
|
}));
|
|
355
643
|
return wrapAsMCPResult(rows);
|
|
356
644
|
}
|
|
@@ -377,7 +665,7 @@ export class IMFMCPClient {
|
|
|
377
665
|
return IMF_FALLBACK;
|
|
378
666
|
}
|
|
379
667
|
try {
|
|
380
|
-
const json = await this._getJSON('/dataflow
|
|
668
|
+
const json = await this._getJSON('/structure/dataflow');
|
|
381
669
|
const flows = json?.data?.dataflows ?? [];
|
|
382
670
|
const needle = keyword.toLowerCase();
|
|
383
671
|
const rows = flows
|
|
@@ -385,6 +673,8 @@ export class IMFMCPClient {
|
|
|
385
673
|
id: f.id ?? '',
|
|
386
674
|
name: unwrapLocalisedLabel(f.name),
|
|
387
675
|
description: unwrapLocalisedLabel(f.description),
|
|
676
|
+
agency: f.agencyID ?? '',
|
|
677
|
+
version: f.version ?? '',
|
|
388
678
|
}))
|
|
389
679
|
.filter((r) => {
|
|
390
680
|
const hay = `${r.id} ${r.name} ${r.description}`.toLowerCase();
|
|
@@ -403,19 +693,25 @@ export class IMFMCPClient {
|
|
|
403
693
|
* dataflow. Essential before building an SDMX key for
|
|
404
694
|
* {@link fetchData} because each database has its own dimension set.
|
|
405
695
|
*
|
|
406
|
-
* Virtual tool: `imf-get-parameter-defs`.
|
|
696
|
+
* Virtual tool: `imf-get-parameter-defs`. Uses the
|
|
697
|
+
* `/structure/dataflow/{agency}/{id}/+?references=datastructure`
|
|
698
|
+
* endpoint because the legacy `/structure/datastructure/IMF/{id}/+`
|
|
699
|
+
* shape returns 204 on `api.imf.org` after the September-2025 IMF
|
|
700
|
+
* Data Portal migration retired the umbrella `IMF` agency.
|
|
407
701
|
*
|
|
408
|
-
* @param databaseId - IMF dataflow identifier (e.g. `"WEO"`, `"
|
|
702
|
+
* @param databaseId - IMF dataflow identifier (e.g. `"WEO"`, `"FM"`).
|
|
703
|
+
* @param agencyId - Optional override; defaults to {@link resolveAgency}.
|
|
409
704
|
* @returns MCP-shaped result whose `content[0].text` carries the
|
|
410
705
|
* ordered list of dimensions (`[{ id, name }]`). Empty on error.
|
|
411
706
|
*/
|
|
412
|
-
async getParameterDefs(databaseId) {
|
|
707
|
+
async getParameterDefs(databaseId, agencyId) {
|
|
413
708
|
if (!databaseId) {
|
|
414
709
|
console.warn('imf-get-parameter-defs called without databaseId');
|
|
415
710
|
return IMF_FALLBACK;
|
|
416
711
|
}
|
|
417
712
|
try {
|
|
418
|
-
const
|
|
713
|
+
const agency = agencyId ?? resolveAgency(databaseId);
|
|
714
|
+
const json = await this._getJSON(`/structure/dataflow/${encodeURIComponent(agency)}/${encodeURIComponent(databaseId)}/+?references=datastructure`);
|
|
419
715
|
const ds = json?.data?.dataStructures?.[0];
|
|
420
716
|
const dims = ds?.dataStructureComponents?.dimensionList?.dimensions ?? [];
|
|
421
717
|
const rows = dims.map((d) => ({ id: d.id, name: unwrapLocalisedLabel(d.name) }));
|
|
@@ -431,58 +727,46 @@ export class IMFMCPClient {
|
|
|
431
727
|
* List valid codes for a single dimension of an IMF dataflow, with
|
|
432
728
|
* an optional free-text filter to narrow the result.
|
|
433
729
|
*
|
|
434
|
-
* Virtual tool: `imf-get-parameter-codes`.
|
|
435
|
-
* `/
|
|
436
|
-
*
|
|
437
|
-
*
|
|
730
|
+
* Virtual tool: `imf-get-parameter-codes`. Uses
|
|
731
|
+
* `/structure/dataflow/{agency}/{id}/+?references=all` to fetch the
|
|
732
|
+
* DSD plus its referenced conceptSchemes and codelists in one
|
|
733
|
+
* round-trip — SDMX 3.0 binds the codelist on the *concept*
|
|
734
|
+
* (`coreRepresentation.enumeration`), so resolving codes requires
|
|
735
|
+
* walking dim → conceptIdentity → conceptScheme → concept → codelist.
|
|
438
736
|
*
|
|
439
737
|
* @param databaseId - IMF dataflow identifier.
|
|
440
|
-
* @param parameter - Dimension name (e.g. `"
|
|
738
|
+
* @param parameter - Dimension name (e.g. `"COUNTRY"`, `"INDICATOR"`;
|
|
739
|
+
* matched case-insensitively).
|
|
441
740
|
* @param search - Optional free-text search (case-insensitive substring).
|
|
741
|
+
* @param agencyId - Optional agency override; defaults to {@link resolveAgency}.
|
|
442
742
|
* @returns MCP-shaped result with `[{ id, name }]` rows; empty on error.
|
|
443
743
|
*/
|
|
444
|
-
async getParameterCodes(databaseId, parameter, search) {
|
|
744
|
+
async getParameterCodes(databaseId, parameter, search, agencyId) {
|
|
445
745
|
if (!databaseId || !parameter) {
|
|
446
746
|
console.warn('imf-get-parameter-codes requires databaseId and parameter');
|
|
447
747
|
return IMF_FALLBACK;
|
|
448
748
|
}
|
|
449
749
|
try {
|
|
450
|
-
|
|
451
|
-
|
|
750
|
+
const agency = agencyId ?? resolveAgency(databaseId);
|
|
751
|
+
// `references=all` returns DSD + conceptSchemes + codelists in one
|
|
752
|
+
// round-trip. The IMF SDMX 3.0 DSDs put the codelist binding on
|
|
753
|
+
// the *concept* (`coreRepresentation.enumeration`), not on the
|
|
754
|
+
// dimension itself, so we need both the DSD (for the dimension →
|
|
755
|
+
// concept link) and the conceptScheme (for the concept → codelist
|
|
756
|
+
// link). The payload is large (~2-3 MB for WEO) — it's fetched
|
|
757
|
+
// once per workflow run by gh-aw and cached upstream.
|
|
758
|
+
const structure = await this._getJSON(`/structure/dataflow/${encodeURIComponent(agency)}/${encodeURIComponent(databaseId)}/+?references=all`);
|
|
452
759
|
const ds = structure?.data?.dataStructures?.[0];
|
|
453
760
|
const dims = ds?.dataStructureComponents?.dimensionList?.dimensions ?? [];
|
|
454
761
|
const dim = dims.find((d) => d.id.toLowerCase() === parameter.toLowerCase());
|
|
455
762
|
if (!dim) {
|
|
456
763
|
return wrapAsMCPResult([]);
|
|
457
764
|
}
|
|
458
|
-
|
|
459
|
-
// "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=IMF:CL_AREA(1.0)"
|
|
460
|
-
// We only need the codelist id — use string-split parsing
|
|
461
|
-
// (no regex) so the static-analysis "unsafe regex" detector has
|
|
462
|
-
// nothing to object to and the extraction stays obviously linear.
|
|
463
|
-
let codelistId = dim.localRepresentation?.enumeration;
|
|
464
|
-
if (codelistId) {
|
|
465
|
-
const afterEquals = codelistId.includes('=')
|
|
466
|
-
? (codelistId.split('=')[1] ?? '')
|
|
467
|
-
: codelistId;
|
|
468
|
-
const beforeParen = afterEquals.split('(')[0] ?? '';
|
|
469
|
-
const parts = beforeParen.split(':');
|
|
470
|
-
codelistId = (parts[parts.length - 1] ?? beforeParen).trim() || codelistId;
|
|
471
|
-
}
|
|
472
|
-
// Some payloads inline the values directly; prefer those when present.
|
|
473
|
-
let codes = dim.values ?? [];
|
|
474
|
-
if (codes.length === 0 && codelistId) {
|
|
475
|
-
const cl = structure?.data?.codelists?.find((c) => c.id === codelistId);
|
|
476
|
-
codes = cl?.codes ?? [];
|
|
477
|
-
}
|
|
765
|
+
const codes = resolveCodelistCodes(dim, structure?.data ?? {});
|
|
478
766
|
const needle = (search ?? '').toLowerCase();
|
|
479
767
|
const rows = codes
|
|
480
768
|
.map((c) => ({ id: c.id, name: unwrapLocalisedLabel(c.name) }))
|
|
481
|
-
.filter((r) => {
|
|
482
|
-
if (!needle)
|
|
483
|
-
return true;
|
|
484
|
-
return `${r.id} ${r.name}`.toLowerCase().includes(needle);
|
|
485
|
-
});
|
|
769
|
+
.filter((r) => !needle || `${r.id} ${r.name}`.toLowerCase().includes(needle));
|
|
486
770
|
return wrapAsMCPResult(rows);
|
|
487
771
|
}
|
|
488
772
|
catch (error) {
|
|
@@ -501,18 +785,22 @@ export class IMFMCPClient {
|
|
|
501
785
|
* April-2026 aggregator-pipeline migration.)
|
|
502
786
|
*
|
|
503
787
|
* @param options - Fetch parameters.
|
|
504
|
-
* @param options.databaseId - IMF dataflow ID (`"WEO"`, `"
|
|
788
|
+
* @param options.databaseId - IMF dataflow ID (`"WEO"`, `"FM"`, ...).
|
|
505
789
|
* @param options.startYear - Inclusive start year (e.g. `2015`).
|
|
506
790
|
* @param options.endYear - Inclusive end year (e.g. `2030` for WEO forecasts).
|
|
507
|
-
* @param options.filters - Map of dimension → selected codes.
|
|
791
|
+
* @param options.filters - Map of dimension → selected codes. Filter
|
|
792
|
+
* keys are matched case-insensitively against the DSD dimensions
|
|
793
|
+
* (legacy lowercase `country`/`indicator`/`frequency` continue to work).
|
|
508
794
|
* @param options.dimensionOrder - Optional override of the dimension order
|
|
509
795
|
* used to build the SDMX key. Defaults to
|
|
510
796
|
* {@link defaultDimensionOrder} for the database.
|
|
797
|
+
* @param options.agencyId - Optional SDMX agency override (e.g. `"IMF.RES"`,
|
|
798
|
+
* `"IMF.STA"`). Defaults to {@link resolveAgency}.
|
|
511
799
|
* @returns MCP-shaped result whose `content[0].text` carries the raw
|
|
512
800
|
* SDMX-JSON response. Empty on error or invalid inputs.
|
|
513
801
|
*/
|
|
514
802
|
async fetchData(options) {
|
|
515
|
-
const { databaseId, startYear, endYear, filters, dimensionOrder } = options;
|
|
803
|
+
const { databaseId, startYear, endYear, filters, dimensionOrder, agencyId } = options;
|
|
516
804
|
if (!databaseId || !filters || Object.keys(filters).length === 0) {
|
|
517
805
|
console.warn('imf-fetch-data requires databaseId and a non-empty filters map');
|
|
518
806
|
return IMF_FALLBACK;
|
|
@@ -522,14 +810,32 @@ export class IMFMCPClient {
|
|
|
522
810
|
return IMF_FALLBACK;
|
|
523
811
|
}
|
|
524
812
|
try {
|
|
813
|
+
const agency = agencyId ?? resolveAgency(databaseId);
|
|
525
814
|
const dims = dimensionOrder ?? defaultDimensionOrder(databaseId);
|
|
526
|
-
const
|
|
815
|
+
const normalisedFilters = withDefaultFrequency(databaseId, filters);
|
|
816
|
+
const key = buildSDMXKey(dims, normalisedFilters);
|
|
817
|
+
// Guard against accidentally unbounded downloads: at least one
|
|
818
|
+
// *non-FREQUENCY* slot must be concrete (not `*`). FREQUENCY
|
|
819
|
+
// auto-injects via withDefaultFrequency, so requiring it would
|
|
820
|
+
// not prove the caller intended a bounded query — a typo'd
|
|
821
|
+
// filter key (e.g. `region` instead of `country` for WEO) would
|
|
822
|
+
// otherwise yield `*.*.A`, downloading the full WEO cross-product.
|
|
823
|
+
// Callers wanting a single wildcard slot (e.g. `DEU.*.A`) still
|
|
824
|
+
// pass because `DEU` pins the COUNTRY dimension.
|
|
825
|
+
const slots = key.split('.');
|
|
826
|
+
const hasConcreteNonFreqSlot = dims.some((dim, i) => dim.toUpperCase() !== 'FREQUENCY' && slots[i] !== '*');
|
|
827
|
+
if (!hasConcreteNonFreqSlot) {
|
|
828
|
+
console.warn(`imf-fetch-data refusing unbounded request for ${databaseId} (${dims.join('.')}=${key}): ` +
|
|
829
|
+
`at least one non-FREQUENCY dimension must have a concrete filter value. ` +
|
|
830
|
+
`Filter keys received: ${Object.keys(filters).join(', ') || '<none>'}`);
|
|
831
|
+
return IMF_FALLBACK;
|
|
832
|
+
}
|
|
527
833
|
const qs = new URLSearchParams({
|
|
528
834
|
startPeriod: String(startYear),
|
|
529
835
|
endPeriod: String(endYear),
|
|
530
836
|
format: 'jsondata',
|
|
531
837
|
});
|
|
532
|
-
const url = `/data/${encodeURIComponent(
|
|
838
|
+
const url = `/data/dataflow/${encodeURIComponent(agency)}/${encodeURIComponent(databaseId)}/+/${key}?${qs.toString()}`;
|
|
533
839
|
const text = await this._getText(url);
|
|
534
840
|
return wrapAsMCPResult(text);
|
|
535
841
|
}
|
|
@@ -570,18 +876,83 @@ export class IMFMCPClient {
|
|
|
570
876
|
}
|
|
571
877
|
}
|
|
572
878
|
// Strategy 2: Direct fetch (works outside AWF sandbox)
|
|
879
|
+
// Tries the primary subscription key first, falling back to the secondary
|
|
880
|
+
// on 401/403 so live IMF key rotation never breaks an in-flight run.
|
|
881
|
+
return this._fetchDirectWithKeyRotation(url);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Direct-fetch strategy with subscription-key rotation.
|
|
885
|
+
*
|
|
886
|
+
* Iterates configured `IMF_API_PRIMARY_KEY` → `IMF_API_SECONDARY_KEY`,
|
|
887
|
+
* retrying only on `401`/`403`. Network errors short-circuit immediately.
|
|
888
|
+
*
|
|
889
|
+
* @param url - Fully-qualified IMF SDMX URL.
|
|
890
|
+
* @returns Response body text on success.
|
|
891
|
+
* @throws The last HTTP/network error when all configured keys are exhausted.
|
|
892
|
+
* @internal
|
|
893
|
+
*/
|
|
894
|
+
async _fetchDirectWithKeyRotation(url) {
|
|
895
|
+
const attempts = this._imfSubscriptionKeys.length > 0 ? [...this._imfSubscriptionKeys] : [undefined];
|
|
896
|
+
let lastError;
|
|
897
|
+
for (let i = 0; i < attempts.length; i += 1) {
|
|
898
|
+
const isLast = i + 1 >= attempts.length;
|
|
899
|
+
const outcome = await this._fetchOnceWithKey(url, attempts[i]);
|
|
900
|
+
if (outcome.kind === 'ok')
|
|
901
|
+
return outcome.text;
|
|
902
|
+
lastError = outcome.error;
|
|
903
|
+
if (outcome.kind === 'auth' && !isLast)
|
|
904
|
+
continue;
|
|
905
|
+
throw outcome.error;
|
|
906
|
+
}
|
|
907
|
+
if (lastError !== undefined)
|
|
908
|
+
throw lastError;
|
|
909
|
+
throw new Error(`IMF request to ${url} failed without producing a response`);
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Single direct-fetch attempt with one subscription key. Classifies the
|
|
913
|
+
* outcome so {@link _fetchDirectWithKeyRotation} can decide whether to
|
|
914
|
+
* rotate keys or surface the error.
|
|
915
|
+
*
|
|
916
|
+
* @param url - Fully-qualified IMF SDMX URL.
|
|
917
|
+
* @param key - Subscription key for this attempt, or `undefined` to send unauthenticated.
|
|
918
|
+
* @returns `'ok'` with body text, `'auth'` with the 401/403 error, or `'error'` for everything else.
|
|
919
|
+
* @internal
|
|
920
|
+
*/
|
|
921
|
+
async _fetchOnceWithKey(url, key) {
|
|
922
|
+
const headers = { ...IMF_REQUEST_HEADERS };
|
|
923
|
+
if (key !== undefined && key.length > 0) {
|
|
924
|
+
headers[IMF_SUBSCRIPTION_KEY_HEADER] = key;
|
|
925
|
+
}
|
|
573
926
|
const controller = new AbortController();
|
|
574
927
|
const timer = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
575
928
|
try {
|
|
576
929
|
const response = await this._fetchImpl(url, {
|
|
577
930
|
method: 'GET',
|
|
578
|
-
headers
|
|
931
|
+
headers,
|
|
579
932
|
signal: controller.signal,
|
|
580
933
|
});
|
|
581
|
-
if (
|
|
582
|
-
|
|
934
|
+
if (response.ok) {
|
|
935
|
+
// `api.imf.org` returns 204 when the request reached Azure APIM
|
|
936
|
+
// but no Ocp-Apim-Subscription-Key matched. 204 is technically
|
|
937
|
+
// 2xx, so without this explicit branch the empty body would be
|
|
938
|
+
// returned as a successful SDMX-JSON envelope and downstream
|
|
939
|
+
// parsers would silently produce empty series. Treat 204 as an
|
|
940
|
+
// error so missing/invalid keys are caught at Stage A.
|
|
941
|
+
if (response.status === 204) {
|
|
942
|
+
return {
|
|
943
|
+
kind: 'error',
|
|
944
|
+
error: new Error(`HTTP 204 No Content for ${url} — likely missing or invalid ${IMF_SUBSCRIPTION_KEY_HEADER} (set IMF_API_PRIMARY_KEY)`),
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
return { kind: 'ok', text: await response.text() };
|
|
583
948
|
}
|
|
584
|
-
|
|
949
|
+
const error = new Error(`HTTP ${response.status} ${response.statusText} for ${url}`);
|
|
950
|
+
const isAuthFailure = response.status === 401 || response.status === 403;
|
|
951
|
+
return { kind: isAuthFailure ? 'auth' : 'error', error };
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
955
|
+
return { kind: 'error', error };
|
|
585
956
|
}
|
|
586
957
|
finally {
|
|
587
958
|
clearTimeout(timer);
|
package/scripts/types/imf.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @description Types for IMF (International Monetary Fund) economic data
|
|
4
4
|
* integration via the native TypeScript SDMX 3.0 REST client
|
|
5
5
|
* (`src/mcp/imf-mcp-client.ts`), which calls
|
|
6
|
-
* `https://
|
|
6
|
+
* `https://api.imf.org/external/sdmx/3.0/` directly.
|
|
7
7
|
*
|
|
8
8
|
* Used to enrich EU Parliament articles with **fresher** macroeconomic context
|
|
9
9
|
* than the World Bank WDI provides (IMF WEO ships 2025 actuals + 2026-2030
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* (parsers, indicator/country maps, HTML builders) was purged in the
|
|
29
29
|
* April-2026 aggregator-pipeline migration.
|
|
30
30
|
*
|
|
31
|
-
* @see {@link https://
|
|
31
|
+
* @see {@link https://api.imf.org/external/sdmx/3.0 | IMF SDMX 3.0 REST API}
|
|
32
32
|
* @see {@link https://github.com/Hack23/euparliamentmonitor/blob/main/analysis/methodologies/imf-indicator-mapping.md | IMF Indicator Mapping methodology} for the committee →
|
|
33
33
|
* IMF indicator mapping enforced at Stage-C editorial review.
|
|
34
34
|
*/
|
|
@@ -42,7 +42,7 @@ import type { MCPClientOptions } from './mcp.js';
|
|
|
42
42
|
* actually consumed by the native HTTP transport:
|
|
43
43
|
*
|
|
44
44
|
* - `apiBaseUrl` — override the IMF REST base URL (default
|
|
45
|
-
* `https://
|
|
45
|
+
* `https://api.imf.org/external/sdmx/3.0`).
|
|
46
46
|
* - `timeoutMs` — per-request timeout in milliseconds.
|
|
47
47
|
* - `fetchImpl` — optional `fetch` injection for tests.
|
|
48
48
|
*
|