euparliamentmonitor 0.9.19 → 0.9.21

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.
Files changed (158) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -3
  3. package/scripts/aggregator/editorial-brief-resolver.d.ts +38 -0
  4. package/scripts/aggregator/editorial-brief-resolver.js +32 -0
  5. package/scripts/aggregator/generator/render-one.js +35 -0
  6. package/scripts/aggregator/html/localize-body.d.ts +32 -0
  7. package/scripts/aggregator/html/localize-body.js +69 -0
  8. package/scripts/aggregator/html/shell.d.ts +10 -0
  9. package/scripts/aggregator/html/shell.js +11 -1
  10. package/scripts/aggregator/markdown-renderer.d.ts +23 -24
  11. package/scripts/aggregator/markdown-renderer.js +39 -25
  12. package/scripts/aggregator/metadata/artifact-highlight.d.ts +15 -22
  13. package/scripts/aggregator/metadata/artifact-highlight.js +14 -230
  14. package/scripts/aggregator/metadata/artifact-walker.d.ts +34 -0
  15. package/scripts/aggregator/metadata/artifact-walker.js +177 -0
  16. package/scripts/aggregator/metadata/editorial-highlight.d.ts +15 -0
  17. package/scripts/aggregator/metadata/editorial-highlight.js +53 -0
  18. package/scripts/aggregator/metadata/priority-finding-highlight.js +7 -2
  19. package/scripts/aggregator/metadata/resolve-helpers.js +9 -3
  20. package/scripts/aggregator/metadata/text-utils.js +7 -0
  21. package/scripts/aggregator/metadata/translated-sibling.d.ts +23 -0
  22. package/scripts/aggregator/metadata/translated-sibling.js +39 -0
  23. package/scripts/aggregator/reader-guide/builder.js +3 -1
  24. package/scripts/aggregator/reader-guide/labels.d.ts +7 -0
  25. package/scripts/aggregator/reader-guide/labels.js +22 -0
  26. package/scripts/aggregator/reader-intelligence-guide.d.ts +1 -1
  27. package/scripts/aggregator/reader-intelligence-guide.js +1 -1
  28. package/scripts/aggregator/seo-entity-extractor.d.ts +45 -0
  29. package/scripts/aggregator/seo-entity-extractor.js +211 -0
  30. package/scripts/constants/articles/breaking-strings-central.d.ts +8 -0
  31. package/scripts/constants/articles/breaking-strings-central.js +105 -0
  32. package/scripts/constants/articles/breaking-strings-east.d.ts +8 -0
  33. package/scripts/constants/articles/breaking-strings-east.js +203 -0
  34. package/scripts/constants/articles/breaking-strings-nordic.d.ts +8 -0
  35. package/scripts/constants/articles/breaking-strings-nordic.js +252 -0
  36. package/scripts/constants/articles/breaking-strings-west.d.ts +8 -0
  37. package/scripts/constants/articles/breaking-strings-west.js +154 -0
  38. package/scripts/constants/articles/breaking.d.ts +0 -1
  39. package/scripts/constants/articles/breaking.js +9 -6
  40. package/scripts/constants/articles/dashboard/ar.d.ts +8 -0
  41. package/scripts/constants/articles/dashboard/ar.js +71 -0
  42. package/scripts/constants/articles/dashboard/da.d.ts +8 -0
  43. package/scripts/constants/articles/dashboard/da.js +71 -0
  44. package/scripts/constants/articles/dashboard/de.d.ts +8 -0
  45. package/scripts/constants/articles/dashboard/de.js +71 -0
  46. package/scripts/constants/articles/dashboard/en.d.ts +8 -0
  47. package/scripts/constants/articles/dashboard/en.js +71 -0
  48. package/scripts/constants/articles/dashboard/es.d.ts +8 -0
  49. package/scripts/constants/articles/dashboard/es.js +71 -0
  50. package/scripts/constants/articles/dashboard/fi.d.ts +8 -0
  51. package/scripts/constants/articles/dashboard/fi.js +71 -0
  52. package/scripts/constants/articles/dashboard/fr.d.ts +8 -0
  53. package/scripts/constants/articles/dashboard/fr.js +71 -0
  54. package/scripts/constants/articles/dashboard/he.d.ts +8 -0
  55. package/scripts/constants/articles/dashboard/he.js +71 -0
  56. package/scripts/constants/articles/dashboard/index.d.ts +7 -0
  57. package/scripts/constants/articles/dashboard/index.js +33 -0
  58. package/scripts/constants/articles/dashboard/ja.d.ts +8 -0
  59. package/scripts/constants/articles/dashboard/ja.js +71 -0
  60. package/scripts/constants/articles/dashboard/ko.d.ts +8 -0
  61. package/scripts/constants/articles/dashboard/ko.js +71 -0
  62. package/scripts/constants/articles/dashboard/nl.d.ts +8 -0
  63. package/scripts/constants/articles/dashboard/nl.js +71 -0
  64. package/scripts/constants/articles/dashboard/no.d.ts +8 -0
  65. package/scripts/constants/articles/dashboard/no.js +71 -0
  66. package/scripts/constants/articles/dashboard/sv.d.ts +8 -0
  67. package/scripts/constants/articles/dashboard/sv.js +71 -0
  68. package/scripts/constants/articles/dashboard/zh.d.ts +8 -0
  69. package/scripts/constants/articles/dashboard/zh.js +71 -0
  70. package/scripts/constants/articles/dashboard.d.ts +7 -2
  71. package/scripts/constants/articles/dashboard.js +4 -8
  72. package/scripts/constants/articles/deep-analysis/ar.d.ts +8 -0
  73. package/scripts/constants/articles/deep-analysis/ar.js +75 -0
  74. package/scripts/constants/articles/deep-analysis/da.d.ts +8 -0
  75. package/scripts/constants/articles/deep-analysis/da.js +75 -0
  76. package/scripts/constants/articles/deep-analysis/de.d.ts +8 -0
  77. package/scripts/constants/articles/deep-analysis/de.js +75 -0
  78. package/scripts/constants/articles/deep-analysis/en.d.ts +8 -0
  79. package/scripts/constants/articles/deep-analysis/en.js +75 -0
  80. package/scripts/constants/articles/deep-analysis/es.d.ts +8 -0
  81. package/scripts/constants/articles/deep-analysis/es.js +75 -0
  82. package/scripts/constants/articles/deep-analysis/fi.d.ts +8 -0
  83. package/scripts/constants/articles/deep-analysis/fi.js +75 -0
  84. package/scripts/constants/articles/deep-analysis/fr.d.ts +8 -0
  85. package/scripts/constants/articles/deep-analysis/fr.js +75 -0
  86. package/scripts/constants/articles/deep-analysis/he.d.ts +8 -0
  87. package/scripts/constants/articles/deep-analysis/he.js +75 -0
  88. package/scripts/constants/articles/deep-analysis/index.d.ts +7 -0
  89. package/scripts/constants/articles/deep-analysis/index.js +33 -0
  90. package/scripts/constants/articles/deep-analysis/ja.d.ts +8 -0
  91. package/scripts/constants/articles/deep-analysis/ja.js +75 -0
  92. package/scripts/constants/articles/deep-analysis/ko.d.ts +8 -0
  93. package/scripts/constants/articles/deep-analysis/ko.js +75 -0
  94. package/scripts/constants/articles/deep-analysis/nl.d.ts +8 -0
  95. package/scripts/constants/articles/deep-analysis/nl.js +75 -0
  96. package/scripts/constants/articles/deep-analysis/no.d.ts +8 -0
  97. package/scripts/constants/articles/deep-analysis/no.js +75 -0
  98. package/scripts/constants/articles/deep-analysis/sv.d.ts +8 -0
  99. package/scripts/constants/articles/deep-analysis/sv.js +75 -0
  100. package/scripts/constants/articles/deep-analysis/zh.d.ts +8 -0
  101. package/scripts/constants/articles/deep-analysis/zh.js +75 -0
  102. package/scripts/constants/articles/deep-analysis.d.ts +4 -3
  103. package/scripts/constants/articles/deep-analysis.js +3 -7
  104. package/scripts/constants/articles/localized-keywords-central.d.ts +8 -0
  105. package/scripts/constants/articles/localized-keywords-central.js +118 -0
  106. package/scripts/constants/articles/localized-keywords-nordic.d.ts +8 -0
  107. package/scripts/constants/articles/localized-keywords-nordic.js +303 -0
  108. package/scripts/constants/articles/localized-keywords.js +4 -2
  109. package/scripts/constants/articles/swot-builder-central.d.ts +8 -0
  110. package/scripts/constants/articles/swot-builder-central.js +90 -0
  111. package/scripts/constants/articles/swot-builder-nordic.d.ts +8 -0
  112. package/scripts/constants/articles/swot-builder-nordic.js +216 -0
  113. package/scripts/constants/articles/swot.js +4 -2
  114. package/scripts/constants/articles/week-ahead-eu.d.ts +12 -0
  115. package/scripts/constants/articles/week-ahead-eu.js +278 -0
  116. package/scripts/constants/articles/week-ahead-global.d.ts +12 -0
  117. package/scripts/constants/articles/week-ahead-global.js +278 -0
  118. package/scripts/constants/articles/week-ahead.d.ts +4 -7
  119. package/scripts/constants/articles/week-ahead.js +11 -535
  120. package/scripts/constants/world-bank/category-map-analysis.d.ts +9 -0
  121. package/scripts/constants/world-bank/category-map-analysis.js +204 -0
  122. package/scripts/constants/world-bank/category-map-legislative.d.ts +9 -0
  123. package/scripts/constants/world-bank/category-map-legislative.js +130 -0
  124. package/scripts/constants/world-bank/category-map-periodic.d.ts +9 -0
  125. package/scripts/constants/world-bank/category-map-periodic.js +176 -0
  126. package/scripts/constants/world-bank/category-map.d.ts +3 -26
  127. package/scripts/constants/world-bank/category-map.js +8 -501
  128. package/scripts/discover-untranslated-briefs.js +123 -4
  129. package/scripts/generators/news-indexes/per-language.js +21 -7
  130. package/scripts/generators/political-intelligence/html.js +39 -8
  131. package/scripts/generators/sitemap/html.js +25 -7
  132. package/scripts/mcp/ep/client.d.ts +0 -1
  133. package/scripts/mcp/ep/client.js +0 -65
  134. package/scripts/mcp/ep/error-classifier.d.ts +2 -2
  135. package/scripts/mcp/ep/error-classifier.js +2 -2
  136. package/scripts/mcp/ep/tools-list.d.ts +13 -0
  137. package/scripts/mcp/ep/tools-list.js +79 -0
  138. package/scripts/mcp/ep-mcp-client.d.ts +1 -0
  139. package/scripts/mcp/ep-mcp-client.js +1 -0
  140. package/scripts/mcp/imf/client.d.ts +3 -64
  141. package/scripts/mcp/imf/client.js +18 -207
  142. package/scripts/mcp/imf/http-transport.d.ts +92 -0
  143. package/scripts/mcp/imf/http-transport.js +232 -0
  144. package/scripts/mcp/transport/connection.d.ts +25 -53
  145. package/scripts/mcp/transport/connection.js +90 -250
  146. package/scripts/mcp/transport/process.d.ts +62 -0
  147. package/scripts/mcp/transport/process.js +147 -0
  148. package/scripts/mcp/transport/reconnect.d.ts +73 -0
  149. package/scripts/mcp/transport/reconnect.js +96 -0
  150. package/scripts/validate-brief-translations.js +122 -6
  151. package/scripts/constants/articles/breaking-strings-eu.d.ts +0 -7
  152. package/scripts/constants/articles/breaking-strings-global.d.ts +0 -7
  153. package/scripts/constants/articles/dashboard-builder-eu.d.ts +0 -7
  154. package/scripts/constants/articles/dashboard-builder-global.d.ts +0 -7
  155. package/scripts/constants/articles/deep-analysis-strings-eu.d.ts +0 -7
  156. package/scripts/constants/articles/deep-analysis-strings-global.d.ts +0 -7
  157. package/scripts/constants/articles/localized-keywords-eu.d.ts +0 -7
  158. package/scripts/constants/articles/swot-builder-eu.d.ts +0 -7
@@ -150,72 +150,11 @@ export declare class IMFMCPClient {
150
150
  agencyId?: string;
151
151
  }): Promise<MCPToolResult>;
152
152
  /**
153
- * Build a full URL and GET it as text, enforcing the client-wide timeout.
154
- * Tries the IMF-only MCP fetch-proxy gateway first (bypasses AWF Squid
155
- * proxy in agentic workflow sandbox), then falls back to direct fetch.
156
- *
157
- * @param path - Path (already URL-encoded) to append to the base URL.
158
- * @returns Response body (`text/*` or `application/*`) as a string.
159
- * @throws When the HTTP status is not 2xx, the request times out, or
160
- * the network layer raises.
161
- * @internal
162
- */
163
- private _getText;
164
- /**
165
- * Direct-fetch strategy with subscription-key rotation.
166
- *
167
- * Iterates configured `IMF_API_PRIMARY_KEY` → `IMF_API_SECONDARY_KEY`,
168
- * retrying only on `401`/`403`. Network errors short-circuit immediately.
169
- *
170
- * @param url - Fully-qualified IMF SDMX URL.
171
- * @returns Response body text on success.
172
- * @throws The last HTTP/network error when all configured keys are exhausted.
173
- * @internal
174
- */
175
- private _fetchDirectWithKeyRotation;
176
- /**
177
- * Single direct-fetch attempt with one subscription key. Classifies the
178
- * outcome so {@link _fetchDirectWithKeyRotation} can decide whether to
179
- * rotate keys or surface the error.
180
- *
181
- * @param url - Fully-qualified IMF SDMX URL.
182
- * @param key - Subscription key for this attempt, or `undefined` to send unauthenticated.
183
- * @returns `'ok'` with body text, `'auth'` with the 401/403 error, or `'error'` for everything else.
184
- * @internal
185
- */
186
- private _fetchOnceWithKey;
187
- /**
188
- * Remove any configured IMF subscription keys from an Error's message and
189
- * stack so that downstream `console.warn` / fallback envelopes cannot leak
190
- * the secret even if the underlying fetch implementation (or proxy) embeds
191
- * request headers in its thrown error.
192
- *
193
- * @param error - Error returned by `_fetchImpl` or constructed from a non-Error throw.
194
- * @returns A new {@link Error} whose `message` (and `stack` when present) have
195
- * each configured subscription key replaced with `[REDACTED]`. Returns the
196
- * original error untouched when no keys are configured.
197
- * @internal
198
- */
199
- private _redactSubscriptionKeys;
200
- /**
201
- * Fetch a URL via the MCP fetch-proxy gateway (JSON-RPC 2.0 over HTTP).
202
- * The fetch-proxy server runs in a container that bypasses the AWF Squid proxy.
203
- *
204
- * @param url - Fully-qualified URL to fetch.
205
- * @returns Response text, or null if the gateway call fails.
206
- * @internal
207
- */
208
- private _fetchViaGateway;
209
- /**
210
- * GET a URL and parse the response body as JSON.
153
+ * Build an {@link IMFHttpContext} adapter for http-transport.ts helpers.
211
154
  *
212
- * @template T - Narrow response type declared by the caller.
213
- * @param path - Path to append to the base URL.
214
- * @returns Parsed JSON value.
215
- * @throws When the response is not JSON, not 2xx, or the request fails.
216
- * @internal
155
+ * @returns Context adapter for IMF HTTP transport helpers
217
156
  */
218
- private _getJSON;
157
+ private _httpCtx;
219
158
  }
220
159
  /**
221
160
  * Forward-looking alias for `IMFMCPClient`. New code should prefer
@@ -1,9 +1,10 @@
1
1
  // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { IMF_FALLBACK, IMF_REQUEST_HEADERS, IMF_SUBSCRIPTION_KEY_HEADER } from './config.js';
3
+ import { IMF_FALLBACK } from './config.js';
4
4
  import { resolveAgency, resolveCodelistCodes, defaultDimensionOrder, buildSDMXKey, withDefaultFrequency, } from './sdmx.js';
5
5
  import { unwrapLocalisedLabel, wrapAsMCPResult } from './observations.js';
6
6
  import { readBaseAndTimeout, stripTrailingSlashes, readImfSubscriptionKeysFromEnv, } from './utils.js';
7
+ import { getText, getJSON } from './http-transport.js';
7
8
  export class IMFMCPClient {
8
9
  _apiBaseUrl;
9
10
  _timeoutMs;
@@ -99,7 +100,7 @@ export class IMFMCPClient {
99
100
  */
100
101
  async listDatabases() {
101
102
  try {
102
- const json = await this._getJSON('/structure/dataflow');
103
+ const json = await getJSON('/structure/dataflow', this._httpCtx());
103
104
  const flows = json?.data?.dataflows ?? [];
104
105
  const rows = flows.map((f) => ({
105
106
  id: f.id ?? '',
@@ -133,7 +134,7 @@ export class IMFMCPClient {
133
134
  return IMF_FALLBACK;
134
135
  }
135
136
  try {
136
- const json = await this._getJSON('/structure/dataflow');
137
+ const json = await getJSON('/structure/dataflow', this._httpCtx());
137
138
  const flows = json?.data?.dataflows ?? [];
138
139
  const needle = keyword.toLowerCase();
139
140
  const rows = flows
@@ -179,7 +180,7 @@ export class IMFMCPClient {
179
180
  }
180
181
  try {
181
182
  const agency = agencyId ?? resolveAgency(databaseId);
182
- const json = await this._getJSON(`/structure/dataflow/${encodeURIComponent(agency)}/${encodeURIComponent(databaseId)}/+?references=datastructure`);
183
+ const json = await getJSON(`/structure/dataflow/${encodeURIComponent(agency)}/${encodeURIComponent(databaseId)}/+?references=datastructure`, this._httpCtx());
183
184
  const ds = json?.data?.dataStructures?.[0];
184
185
  const dims = ds?.dataStructureComponents?.dimensionList?.dimensions ?? [];
185
186
  const rows = dims.map((d) => ({ id: d.id, name: unwrapLocalisedLabel(d.name) }));
@@ -216,7 +217,7 @@ export class IMFMCPClient {
216
217
  }
217
218
  try {
218
219
  const agency = agencyId ?? resolveAgency(databaseId);
219
- const structure = await this._getJSON(`/structure/dataflow/${encodeURIComponent(agency)}/${encodeURIComponent(databaseId)}/+?references=all`);
220
+ const structure = await getJSON(`/structure/dataflow/${encodeURIComponent(agency)}/${encodeURIComponent(databaseId)}/+?references=all`, this._httpCtx());
220
221
  const ds = structure?.data?.dataStructures?.[0];
221
222
  const dims = ds?.dataStructureComponents?.dimensionList?.dimensions ?? [];
222
223
  const dim = dims.find((d) => d.id.toLowerCase() === parameter.toLowerCase());
@@ -289,7 +290,7 @@ export class IMFMCPClient {
289
290
  format: 'jsondata',
290
291
  });
291
292
  const url = `/data/dataflow/${encodeURIComponent(agency)}/${encodeURIComponent(databaseId)}/+/${key}?${qs.toString()}`;
292
- const text = await this._getText(url);
293
+ const text = await getText(url, this._httpCtx());
293
294
  return wrapAsMCPResult(text);
294
295
  }
295
296
  catch (error) {
@@ -298,211 +299,21 @@ export class IMFMCPClient {
298
299
  return IMF_FALLBACK;
299
300
  }
300
301
  }
301
- // ─── private transport helpers ─────────────────────────────────────────────
302
+ // ─── private HTTP context factory ─────────────────────────────────────────
302
303
  /**
303
- * Build a full URL and GET it as text, enforcing the client-wide timeout.
304
- * Tries the IMF-only MCP fetch-proxy gateway first (bypasses AWF Squid
305
- * proxy in agentic workflow sandbox), then falls back to direct fetch.
304
+ * Build an {@link IMFHttpContext} adapter for http-transport.ts helpers.
306
305
  *
307
- * @param path - Path (already URL-encoded) to append to the base URL.
308
- * @returns Response body (`text/*` or `application/*`) as a string.
309
- * @throws When the HTTP status is not 2xx, the request times out, or
310
- * the network layer raises.
311
- * @internal
306
+ * @returns Context adapter for IMF HTTP transport helpers
312
307
  */
313
- async _getText(path) {
314
- const url = `${this._apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
315
- if (this._fetchProxyGatewayUrl) {
316
- try {
317
- const result = await this._fetchViaGateway(url);
318
- if (result !== null)
319
- return result;
320
- }
321
- catch {
322
- // Gateway unavailable — fall through to direct fetch
323
- }
324
- }
325
- return this._fetchDirectWithKeyRotation(url);
326
- }
327
- /**
328
- * Direct-fetch strategy with subscription-key rotation.
329
- *
330
- * Iterates configured `IMF_API_PRIMARY_KEY` → `IMF_API_SECONDARY_KEY`,
331
- * retrying only on `401`/`403`. Network errors short-circuit immediately.
332
- *
333
- * @param url - Fully-qualified IMF SDMX URL.
334
- * @returns Response body text on success.
335
- * @throws The last HTTP/network error when all configured keys are exhausted.
336
- * @internal
337
- */
338
- async _fetchDirectWithKeyRotation(url) {
339
- const attempts = this._imfSubscriptionKeys.length > 0 ? [...this._imfSubscriptionKeys] : [undefined];
340
- let lastError;
341
- for (let i = 0; i < attempts.length; i += 1) {
342
- const isLast = i + 1 >= attempts.length;
343
- const outcome = await this._fetchOnceWithKey(url, attempts[i]);
344
- if (outcome.kind === 'ok')
345
- return outcome.text;
346
- lastError = outcome.error;
347
- if (outcome.kind === 'auth' && !isLast)
348
- continue;
349
- throw outcome.error;
350
- }
351
- if (lastError !== undefined)
352
- throw lastError;
353
- throw new Error(`IMF request to ${url} failed without producing a response`);
354
- }
355
- /**
356
- * Single direct-fetch attempt with one subscription key. Classifies the
357
- * outcome so {@link _fetchDirectWithKeyRotation} can decide whether to
358
- * rotate keys or surface the error.
359
- *
360
- * @param url - Fully-qualified IMF SDMX URL.
361
- * @param key - Subscription key for this attempt, or `undefined` to send unauthenticated.
362
- * @returns `'ok'` with body text, `'auth'` with the 401/403 error, or `'error'` for everything else.
363
- * @internal
364
- */
365
- async _fetchOnceWithKey(url, key) {
366
- const headers = { ...IMF_REQUEST_HEADERS };
367
- if (key !== undefined && key.length > 0) {
368
- headers[IMF_SUBSCRIPTION_KEY_HEADER] = key;
369
- }
370
- const controller = new AbortController();
371
- const timer = setTimeout(() => controller.abort(), this._timeoutMs);
372
- try {
373
- const response = await this._fetchImpl(url, {
374
- method: 'GET',
375
- headers,
376
- signal: controller.signal,
377
- });
378
- if (response.ok) {
379
- if (response.status === 204) {
380
- return {
381
- kind: 'error',
382
- error: new Error(`HTTP 204 No Content for ${url} — likely missing or invalid ${IMF_SUBSCRIPTION_KEY_HEADER} (set IMF_API_PRIMARY_KEY)`),
383
- };
384
- }
385
- return { kind: 'ok', text: await response.text() };
386
- }
387
- const error = this._redactSubscriptionKeys(new Error(`HTTP ${response.status} ${response.statusText} for ${url}`));
388
- const isAuthFailure = response.status === 401 || response.status === 403;
389
- return { kind: isAuthFailure ? 'auth' : 'error', error };
390
- }
391
- catch (err) {
392
- const error = err instanceof Error ? err : new Error(String(err));
393
- return { kind: 'error', error: this._redactSubscriptionKeys(error) };
394
- }
395
- finally {
396
- clearTimeout(timer);
397
- }
398
- }
399
- /**
400
- * Remove any configured IMF subscription keys from an Error's message and
401
- * stack so that downstream `console.warn` / fallback envelopes cannot leak
402
- * the secret even if the underlying fetch implementation (or proxy) embeds
403
- * request headers in its thrown error.
404
- *
405
- * @param error - Error returned by `_fetchImpl` or constructed from a non-Error throw.
406
- * @returns A new {@link Error} whose `message` (and `stack` when present) have
407
- * each configured subscription key replaced with `[REDACTED]`. Returns the
408
- * original error untouched when no keys are configured.
409
- * @internal
410
- */
411
- _redactSubscriptionKeys(error) {
412
- if (this._imfSubscriptionKeys.length === 0)
413
- return error;
414
- const redact = (s) => {
415
- let out = s;
416
- for (const key of this._imfSubscriptionKeys) {
417
- if (!key)
418
- continue;
419
- // Escape regex metacharacters in the key
420
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
421
- out = out.replace(new RegExp(escaped, 'g'), '[REDACTED]');
422
- }
423
- return out;
424
- };
425
- const redacted = new Error(redact(error.message));
426
- if (error.stack) {
427
- redacted.stack = redact(error.stack);
428
- }
429
- return redacted;
430
- }
431
- /**
432
- * Fetch a URL via the MCP fetch-proxy gateway (JSON-RPC 2.0 over HTTP).
433
- * The fetch-proxy server runs in a container that bypasses the AWF Squid proxy.
434
- *
435
- * @param url - Fully-qualified URL to fetch.
436
- * @returns Response text, or null if the gateway call fails.
437
- * @internal
438
- */
439
- async _fetchViaGateway(url) {
440
- const gatewayUrl = this._fetchProxyGatewayUrl;
441
- if (!gatewayUrl)
442
- return null;
443
- const rpcRequest = {
444
- jsonrpc: '2.0',
445
- id: Date.now(),
446
- method: 'tools/call',
447
- params: {
448
- name: 'fetch_url',
449
- arguments: { url },
450
- },
451
- };
452
- const headers = {
453
- 'Content-Type': 'application/json',
454
- Accept: 'application/json, text/event-stream',
308
+ _httpCtx() {
309
+ return {
310
+ apiBaseUrl: this._apiBaseUrl,
311
+ timeoutMs: this._timeoutMs,
312
+ fetchImpl: this._fetchImpl,
313
+ fetchProxyGatewayUrl: this._fetchProxyGatewayUrl,
314
+ fetchProxyApiKey: this._fetchProxyApiKey,
315
+ imfSubscriptionKeys: this._imfSubscriptionKeys,
455
316
  };
456
- if (this._fetchProxyApiKey) {
457
- headers['Authorization'] = `Bearer ${this._fetchProxyApiKey}`;
458
- }
459
- const controller = new AbortController();
460
- const timer = setTimeout(() => controller.abort(), this._timeoutMs);
461
- try {
462
- const response = await this._fetchImpl(gatewayUrl, {
463
- method: 'POST',
464
- headers,
465
- body: JSON.stringify(rpcRequest),
466
- signal: controller.signal,
467
- });
468
- if (!response.ok)
469
- return null;
470
- let body = await response.text();
471
- if (body.trimStart().startsWith('data:')) {
472
- const lines = body.split('\n').filter((l) => l.startsWith('data:'));
473
- body = lines.map((l) => l.slice(5).trim()).join('');
474
- }
475
- const parsed = JSON.parse(body);
476
- if (parsed.error)
477
- return null;
478
- const text = parsed.result?.content?.[0]?.text;
479
- return text && text.length > 0 ? text : null;
480
- }
481
- catch {
482
- return null;
483
- }
484
- finally {
485
- clearTimeout(timer);
486
- }
487
- }
488
- /**
489
- * GET a URL and parse the response body as JSON.
490
- *
491
- * @template T - Narrow response type declared by the caller.
492
- * @param path - Path to append to the base URL.
493
- * @returns Parsed JSON value.
494
- * @throws When the response is not JSON, not 2xx, or the request fails.
495
- * @internal
496
- */
497
- async _getJSON(path) {
498
- const raw = await this._getText(path);
499
- try {
500
- return JSON.parse(raw);
501
- }
502
- catch (error) {
503
- const message = error instanceof Error ? error.message : String(error);
504
- throw new Error(`Failed to parse IMF response as JSON: ${message}`, { cause: error });
505
- }
506
317
  }
507
318
  }
508
319
  /**
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Adapter passed by {@link IMFMCPClient} to HTTP transport helpers.
3
+ * Bundles the fields required for authenticated IMF SDMX 3.0 requests
4
+ * without requiring helpers to reference the client class directly.
5
+ */
6
+ export interface IMFHttpContext {
7
+ /** Fully-qualified IMF SDMX base URL (no trailing slash) */
8
+ readonly apiBaseUrl: string;
9
+ /** Per-request timeout in milliseconds */
10
+ readonly timeoutMs: number;
11
+ /** Fetch implementation (allows injection in tests) */
12
+ readonly fetchImpl: typeof fetch;
13
+ /** Optional fetch-proxy gateway URL (bypasses AWF Squid proxy) */
14
+ readonly fetchProxyGatewayUrl: string | undefined;
15
+ /** Optional API key for the fetch-proxy gateway */
16
+ readonly fetchProxyApiKey: string | undefined;
17
+ /** Configured Azure-APIM subscription keys (primary / secondary) */
18
+ readonly imfSubscriptionKeys: readonly string[];
19
+ }
20
+ /**
21
+ * Build a full URL from `path` and GET it as text, applying the configured
22
+ * timeout. Tries the IMF-only MCP fetch-proxy gateway first when configured
23
+ * (bypasses AWF Squid proxy in agentic workflow sandbox), then falls back to
24
+ * direct fetch with subscription-key rotation.
25
+ *
26
+ * @param path - Path (already URL-encoded) to append to the base URL.
27
+ * @param ctx - HTTP context adapter from the client instance.
28
+ * @returns Response body as a string.
29
+ * @throws When the HTTP status is not 2xx, the request times out, or
30
+ * the network layer raises.
31
+ */
32
+ export declare function getText(path: string, ctx: IMFHttpContext): Promise<string>;
33
+ /**
34
+ * GET a URL, parse the response body as JSON, and return the typed value.
35
+ *
36
+ * @template T - Narrow response type declared by the caller.
37
+ * @param path - Path to append to the base URL.
38
+ * @param ctx - HTTP context adapter from the client instance.
39
+ * @returns Parsed JSON value.
40
+ * @throws When the response is not JSON, not 2xx, or the request fails.
41
+ */
42
+ export declare function getJSON<T>(path: string, ctx: IMFHttpContext): Promise<T>;
43
+ /**
44
+ * Direct-fetch strategy with subscription-key rotation.
45
+ *
46
+ * Iterates configured `IMF_API_PRIMARY_KEY` → `IMF_API_SECONDARY_KEY`,
47
+ * retrying only on `401`/`403`. Network errors short-circuit immediately.
48
+ *
49
+ * @param url - Fully-qualified IMF SDMX URL.
50
+ * @param ctx - HTTP context adapter.
51
+ * @returns Response body text on success.
52
+ * @throws The last HTTP/network error when all configured keys are exhausted.
53
+ */
54
+ export declare function fetchDirectWithKeyRotation(url: string, ctx: IMFHttpContext): Promise<string>;
55
+ /**
56
+ * Single direct-fetch attempt with one subscription key.
57
+ *
58
+ * @param url - Fully-qualified IMF SDMX URL.
59
+ * @param key - Subscription key for this attempt, or `undefined` to send unauthenticated.
60
+ * @param ctx - HTTP context adapter.
61
+ * @returns `'ok'` with body text, `'auth'` with the 401/403 error, or `'error'` for everything else.
62
+ */
63
+ export declare function fetchOnceWithKey(url: string, key: string | undefined, ctx: IMFHttpContext): Promise<{
64
+ kind: 'ok';
65
+ text: string;
66
+ } | {
67
+ kind: 'auth' | 'error';
68
+ error: Error;
69
+ }>;
70
+ /**
71
+ * Remove any configured IMF subscription keys from an Error's message and
72
+ * stack so that downstream `console.warn` / fallback envelopes cannot leak
73
+ * the secret even if the underlying fetch implementation embeds request headers
74
+ * in its thrown error.
75
+ *
76
+ * @param error - Error returned by `fetchImpl` or constructed from a non-Error throw.
77
+ * @param keys - IMF subscription keys to redact.
78
+ * @returns A new {@link Error} whose `message` (and `stack` when present) have
79
+ * each configured subscription key replaced with `[REDACTED]`. Returns the
80
+ * original error untouched when no keys are configured.
81
+ */
82
+ export declare function redactSubscriptionKeys(error: Error, keys: readonly string[]): Error;
83
+ /**
84
+ * Fetch a URL via the MCP fetch-proxy gateway (JSON-RPC 2.0 over HTTP).
85
+ * The fetch-proxy server runs in a container that bypasses the AWF Squid proxy.
86
+ *
87
+ * @param url - Fully-qualified URL to fetch.
88
+ * @param ctx - HTTP context adapter.
89
+ * @returns Response text, or null if the gateway call fails.
90
+ */
91
+ export declare function fetchViaGateway(url: string, ctx: IMFHttpContext): Promise<string | null>;
92
+ //# sourceMappingURL=http-transport.d.ts.map
@@ -0,0 +1,232 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * @module MCP/imf/http-transport
5
+ * @description HTTP fetch helpers for IMF SDMX 3.0 client: direct fetch with
6
+ * subscription-key rotation, gateway proxy fallback, and key redaction.
7
+ *
8
+ * Extracted from `client.ts` to keep individual file sizes under 400 LOC.
9
+ * Operates on an explicit {@link IMFHttpContext} adapter rather than `this`.
10
+ */
11
+ import { IMF_REQUEST_HEADERS, IMF_SUBSCRIPTION_KEY_HEADER } from './config.js';
12
+ import { parseSSEResponse } from '../transport/sse-parser.js';
13
+ // ─── Public helpers ────────────────────────────────────────────────────────────
14
+ /**
15
+ * Build a full URL from `path` and GET it as text, applying the configured
16
+ * timeout. Tries the IMF-only MCP fetch-proxy gateway first when configured
17
+ * (bypasses AWF Squid proxy in agentic workflow sandbox), then falls back to
18
+ * direct fetch with subscription-key rotation.
19
+ *
20
+ * @param path - Path (already URL-encoded) to append to the base URL.
21
+ * @param ctx - HTTP context adapter from the client instance.
22
+ * @returns Response body as a string.
23
+ * @throws When the HTTP status is not 2xx, the request times out, or
24
+ * the network layer raises.
25
+ */
26
+ export async function getText(path, ctx) {
27
+ const url = `${ctx.apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
28
+ if (ctx.fetchProxyGatewayUrl) {
29
+ try {
30
+ const result = await fetchViaGateway(url, ctx);
31
+ if (result !== null)
32
+ return result;
33
+ }
34
+ catch {
35
+ // Gateway unavailable — fall through to direct fetch
36
+ }
37
+ }
38
+ return fetchDirectWithKeyRotation(url, ctx);
39
+ }
40
+ /**
41
+ * GET a URL, parse the response body as JSON, and return the typed value.
42
+ *
43
+ * @template T - Narrow response type declared by the caller.
44
+ * @param path - Path to append to the base URL.
45
+ * @param ctx - HTTP context adapter from the client instance.
46
+ * @returns Parsed JSON value.
47
+ * @throws When the response is not JSON, not 2xx, or the request fails.
48
+ */
49
+ export async function getJSON(path, ctx) {
50
+ const raw = await getText(path, ctx);
51
+ try {
52
+ return JSON.parse(raw);
53
+ }
54
+ catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ throw new Error(`Failed to parse IMF response as JSON: ${message}`, { cause: error });
57
+ }
58
+ }
59
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
60
+ /**
61
+ * Direct-fetch strategy with subscription-key rotation.
62
+ *
63
+ * Iterates configured `IMF_API_PRIMARY_KEY` → `IMF_API_SECONDARY_KEY`,
64
+ * retrying only on `401`/`403`. Network errors short-circuit immediately.
65
+ *
66
+ * @param url - Fully-qualified IMF SDMX URL.
67
+ * @param ctx - HTTP context adapter.
68
+ * @returns Response body text on success.
69
+ * @throws The last HTTP/network error when all configured keys are exhausted.
70
+ */
71
+ export async function fetchDirectWithKeyRotation(url, ctx) {
72
+ const attempts = ctx.imfSubscriptionKeys.length > 0 ? [...ctx.imfSubscriptionKeys] : [undefined];
73
+ let lastError;
74
+ for (let i = 0; i < attempts.length; i += 1) {
75
+ const isLast = i + 1 >= attempts.length;
76
+ const outcome = await fetchOnceWithKey(url, attempts[i], ctx);
77
+ if (outcome.kind === 'ok')
78
+ return outcome.text;
79
+ lastError = outcome.error;
80
+ if (outcome.kind === 'auth' && !isLast)
81
+ continue;
82
+ throw outcome.error;
83
+ }
84
+ if (lastError !== undefined)
85
+ throw lastError;
86
+ throw new Error(`IMF request to ${url} failed without producing a response`);
87
+ }
88
+ /**
89
+ * Single direct-fetch attempt with one subscription key.
90
+ *
91
+ * @param url - Fully-qualified IMF SDMX URL.
92
+ * @param key - Subscription key for this attempt, or `undefined` to send unauthenticated.
93
+ * @param ctx - HTTP context adapter.
94
+ * @returns `'ok'` with body text, `'auth'` with the 401/403 error, or `'error'` for everything else.
95
+ */
96
+ export async function fetchOnceWithKey(url, key, ctx) {
97
+ const headers = { ...IMF_REQUEST_HEADERS };
98
+ if (key !== undefined && key.length > 0) {
99
+ headers[IMF_SUBSCRIPTION_KEY_HEADER] = key;
100
+ }
101
+ const controller = new AbortController();
102
+ const timer = setTimeout(() => controller.abort(), ctx.timeoutMs);
103
+ try {
104
+ const response = await ctx.fetchImpl(url, {
105
+ method: 'GET',
106
+ headers,
107
+ signal: controller.signal,
108
+ });
109
+ if (response.ok) {
110
+ if (response.status === 204) {
111
+ return {
112
+ kind: 'error',
113
+ error: new Error(`HTTP 204 No Content for ${url} — likely missing or invalid ${IMF_SUBSCRIPTION_KEY_HEADER} (set IMF_API_PRIMARY_KEY)`),
114
+ };
115
+ }
116
+ return { kind: 'ok', text: await response.text() };
117
+ }
118
+ const error = redactSubscriptionKeys(new Error(`HTTP ${response.status} ${response.statusText} for ${url}`), ctx.imfSubscriptionKeys);
119
+ const isAuthFailure = response.status === 401 || response.status === 403;
120
+ return { kind: isAuthFailure ? 'auth' : 'error', error };
121
+ }
122
+ catch (err) {
123
+ const error = err instanceof Error ? err : new Error(String(err));
124
+ return { kind: 'error', error: redactSubscriptionKeys(error, ctx.imfSubscriptionKeys) };
125
+ }
126
+ finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
130
+ /**
131
+ * Remove any configured IMF subscription keys from an Error's message and
132
+ * stack so that downstream `console.warn` / fallback envelopes cannot leak
133
+ * the secret even if the underlying fetch implementation embeds request headers
134
+ * in its thrown error.
135
+ *
136
+ * @param error - Error returned by `fetchImpl` or constructed from a non-Error throw.
137
+ * @param keys - IMF subscription keys to redact.
138
+ * @returns A new {@link Error} whose `message` (and `stack` when present) have
139
+ * each configured subscription key replaced with `[REDACTED]`. Returns the
140
+ * original error untouched when no keys are configured.
141
+ */
142
+ export function redactSubscriptionKeys(error, keys) {
143
+ if (keys.length === 0)
144
+ return error;
145
+ const redact = (s) => {
146
+ let out = s;
147
+ for (const key of keys) {
148
+ if (!key)
149
+ continue;
150
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
151
+ out = out.replace(new RegExp(escaped, 'g'), '[REDACTED]');
152
+ }
153
+ return out;
154
+ };
155
+ const redacted = new Error(redact(error.message));
156
+ if (error.stack) {
157
+ redacted.stack = redact(error.stack);
158
+ }
159
+ return redacted;
160
+ }
161
+ /**
162
+ * Fetch a URL via the MCP fetch-proxy gateway (JSON-RPC 2.0 over HTTP).
163
+ * The fetch-proxy server runs in a container that bypasses the AWF Squid proxy.
164
+ *
165
+ * @param url - Fully-qualified URL to fetch.
166
+ * @param ctx - HTTP context adapter.
167
+ * @returns Response text, or null if the gateway call fails.
168
+ */
169
+ export async function fetchViaGateway(url, ctx) {
170
+ const gatewayUrl = ctx.fetchProxyGatewayUrl;
171
+ if (!gatewayUrl)
172
+ return null;
173
+ const rpcRequest = {
174
+ jsonrpc: '2.0',
175
+ id: Date.now(),
176
+ method: 'tools/call',
177
+ params: {
178
+ name: 'fetch_url',
179
+ arguments: { url },
180
+ },
181
+ };
182
+ const headers = {
183
+ 'Content-Type': 'application/json',
184
+ Accept: 'application/json, text/event-stream',
185
+ };
186
+ if (ctx.fetchProxyApiKey) {
187
+ // Reject CR/LF in the API key to prevent HTTP header injection (mirrors
188
+ // buildAuthorizationHeader in transport/gateway.ts).
189
+ if (/[\r\n]/.test(ctx.fetchProxyApiKey)) {
190
+ console.warn('Invalid IMF fetch-proxy API key: control characters (CR/LF) are not allowed; skipping gateway fallback.');
191
+ return null;
192
+ }
193
+ headers['Authorization'] = `Bearer ${ctx.fetchProxyApiKey}`;
194
+ }
195
+ const controller = new AbortController();
196
+ const timer = setTimeout(() => controller.abort(), ctx.timeoutMs);
197
+ try {
198
+ const response = await ctx.fetchImpl(gatewayUrl, {
199
+ method: 'POST',
200
+ headers,
201
+ body: JSON.stringify(rpcRequest),
202
+ signal: controller.signal,
203
+ });
204
+ if (!response.ok)
205
+ return null;
206
+ const body = await response.text();
207
+ const trimmed = body.trimStart();
208
+ let parsed = null;
209
+ if (trimmed.startsWith('data:') || trimmed.startsWith('event:')) {
210
+ parsed = parseSSEResponse(body);
211
+ }
212
+ else {
213
+ try {
214
+ parsed = JSON.parse(body);
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ }
220
+ if (!parsed || parsed.error)
221
+ return null;
222
+ const text = parsed.result?.content?.[0]?.text;
223
+ return text && text.length > 0 ? text : null;
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ finally {
229
+ clearTimeout(timer);
230
+ }
231
+ }
232
+ //# sourceMappingURL=http-transport.js.map