euparliamentmonitor 0.9.3 → 0.9.4

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euparliamentmonitor",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
6
6
  "main": "scripts/index.js",
@@ -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://dataservices.imf.org/REST/SDMX_3.0/...`
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://dataservices.imf.org/REST/SDMX_3.0/`.
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 `dataservices.imf.org`. This
15
- * server is mounted as an MCP container in gh-aw workflows; because MCP
16
- * containers run in a Docker network with direct outbound access (bypassing
17
- * Squid), `fetch_url` can reach the IMF API while the main runner cannot.
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://dataservices.imf.org/REST/SDMX_3.0/`
20
- * — all other URLs are rejected with an error message.
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 = 'dataservices.imf.org';
41
- const IMF_ALLOWED_PATH_PREFIX = '/REST/SDMX_3.0/';
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 unauthenticated headers for IMF SDMX REST requests. */
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://dataservices.imf.org/REST/SDMX_3.0/...`
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
- * Execute the `fetch_url` tool call.
154
+ * Read IMF subscription keys from the environment, in priority order.
141
155
  *
142
- * Only URLs matching the IMF SDMX 3.0 allowlist are permitted. Non-matching
143
- * or malformed URLs receive a JSON-RPC error response; HTTP errors and network
144
- * failures also surface as errors.
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
- * @param id - Request id to echo.
147
- * @param url - URL to fetch.
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
- try {
163
- const response = await fetchImpl(url, {
164
- headers: IMF_REQUEST_HEADERS,
165
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
166
- });
167
- if (!response.ok) {
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
- error: { code: -1, message: `HTTP ${response.status} ${response.statusText}` },
268
+ result: { content: [{ type: 'text', text: outcome.text }] },
172
269
  };
173
270
  }
174
- const text = await response.text();
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
- result: { content: [{ type: 'text', text }] },
290
+ error: {
291
+ code: -1,
292
+ message: `HTTP ${lastResponse.status} ${lastResponse.statusText}`,
293
+ },
179
294
  };
180
295
  }
181
- catch (err) {
182
- const message = err instanceof Error ? err.message : String(err);
183
- return { jsonrpc: '2.0', id, error: { code: -1, message } };
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://dataservices.imf.org/REST/SDMX_3.0/} via the
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 30 s).
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://dataservices.imf.org/REST/SDMX_3.0`).
46
- * - `IMF_API_TIMEOUT_MS` — per-request timeout (default `30000`).
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 — no gateway is needed
50
- * because the IMF SDMX 3.0 API is an unauthenticated public endpoint.
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. Empty on error.
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"`, `"IFS"`).
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`. The underlying SDMX
185
- * `/codelist/` endpoint is used, looked up from the datastructure so
186
- * the caller does not need to know the codelist identifier ahead of
187
- * time.
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. `"country"`, `"indicator"`).
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"`, `"IFS"`, ...).
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://dataservices.imf.org/REST/SDMX_3.0';
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 unauthenticated headers for direct IMF SDMX REST requests. */
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
- * @param codes - Ordered code values for a single dimension (may be empty = wildcard).
134
- * @returns URL-safe dimension component (`""` for wildcard, `"A+B"` for union).
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 left as
144
- * the wildcard (empty string). Extra filter keys not present in the
145
- * declared order are ignoredthe caller is expected to have discovered
146
- * the correct dimension names via {@link IMFMCPClient.getParameterDefs}.
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
- * @param dimensions - Declared dimension order (e.g. `["frequency","country","indicator"]`).
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. `"A.DEU.NGDP_RPCH"`).
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
- // Avoid direct dynamic object indexing here so the security lint rule
157
- // does not flag caller-supplied SDMX dimension names as an injection sink.
158
- const codes = Object.entries(filters).find(([key]) => key === dim)?.[1];
159
- return Array.isArray(codes) ? encodeSDMXDimension(codes) : '';
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 in
170
- * `analysis/imf/sdmx-dimensions-reference.md`.
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
- case 'FSI':
185
- return ['frequency', 'country', 'indicator'];
381
+ return ['COUNTRY', 'INDICATOR', 'TYPE_OF_TRANSFORMATION', 'FREQUENCY'];
382
+ case 'PCPS':
383
+ return ['COUNTRY', 'INDICATOR', 'DATA_TRANSFORMATION', 'FREQUENCY'];
186
384
  case 'DOT':
187
- return ['frequency', 'country', 'counterpartArea', 'indicator'];
385
+ return ['COUNTRY', 'COUNTERPART_AREA', 'INDICATOR', 'FREQUENCY'];
188
386
  case 'CDIS':
189
- return ['frequency', 'country', 'counterpartArea', 'sector', 'indicator'];
387
+ return ['COUNTRY', 'COUNTERPART_AREA', 'SECTOR', 'INDICATOR', 'FREQUENCY'];
190
388
  case 'CPIS':
191
- return ['frequency', 'country', 'counterpartArea', 'instrument', 'indicator'];
192
- case 'PCPS':
193
- return ['frequency', 'indicator'];
389
+ return ['COUNTRY', 'COUNTERPART_AREA', 'INSTRUMENT', 'INDICATOR', 'FREQUENCY'];
390
+ case 'FSI':
391
+ return ['COUNTRY', 'INDICATOR', 'SECTOR', 'FREQUENCY'];
194
392
  case 'GFSR':
195
- return ['frequency', 'country', 'indicator', 'sector'];
393
+ return ['COUNTRY', 'INDICATOR', 'SECTOR', 'FREQUENCY'];
196
394
  case 'GFS':
197
- return ['frequency', 'country', 'sector', 'unit', 'indicator'];
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 ['frequency', 'country', 'indicator'];
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 `frequency` populated.
448
+ * @returns The original filters or a shallow copy with `FREQUENCY` populated.
240
449
  * @internal
241
450
  */
242
451
  function withDefaultFrequency(databaseId, filters) {
243
- const hasFrequency = Object.entries(filters).some(([key]) => key === 'frequency');
244
- const frequency = defaultFrequency(databaseId);
245
- return !hasFrequency && frequency ? { ...filters, frequency: [frequency] } : filters;
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 envBase = process.env['IMF_API_BASE_URL'];
265
- const envTimeout = process.env['IMF_API_TIMEOUT_MS'];
266
- const parsedEnvTimeout = envTimeout !== undefined && envTimeout !== '' ? Number.parseInt(envTimeout, 10) : Number.NaN;
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. Empty on error.
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/IMF');
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/IMF');
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"`, `"IFS"`).
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 json = await this._getJSON(`/datastructure/${encodeURIComponent(databaseId)}`);
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`. The underlying SDMX
435
- * `/codelist/` endpoint is used, looked up from the datastructure so
436
- * the caller does not need to know the codelist identifier ahead of
437
- * time.
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. `"country"`, `"indicator"`).
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
- // 1. Discover the codelist id for the requested dimension.
451
- const structure = await this._getJSON(`/datastructure/${encodeURIComponent(databaseId)}?references=codelist`);
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
- // The SDMX codelist reference URN looks like
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"`, `"IFS"`, ...).
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 key = buildSDMXKey(dims, withDefaultFrequency(databaseId, filters));
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(databaseId)}/${key}?${qs.toString()}`;
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: IMF_REQUEST_HEADERS,
931
+ headers,
579
932
  signal: controller.signal,
580
933
  });
581
- if (!response.ok) {
582
- throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}`);
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
- return await response.text();
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);
@@ -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://dataservices.imf.org/REST/SDMX_3.0/` directly.
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://dataservices.imf.org/REST/SDMX_3.0 | IMF SDMX 3.0 REST API}
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://dataservices.imf.org/REST/SDMX_3.0`).
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
  *