euparliamentmonitor 0.9.2 → 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.
@@ -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.