Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 | 2x 2x 2x 2x 29x 20x 3x 16x 16x 14x 6x 18x 18x 5x 5x 35x 35x 35x 35x 35x 35x 35x 3x 35x 35x 35x 3x 4x 4x 4x 4x 2x 2x 4x 2x 4x 4x 2x 4x 4x 2x 2x 2x 3x 1x 1x 2x 2x 2x 3x 3x 5x 5x 5x 3x 4x 1x 1x 3x 3x 3x 4x 5x 4x 6x 1x 1x 5x 5x 5x 6x 6x 6x 1x 4x 6x 1x 1x 1x 1x 4x 6x 1x 1x 4x 6x 8x 8x 3x 6x 9x 9x 1x 1x 8x 2x 2x 6x 6x 9x 9x 9x 9x 4x 2x 2x 2x 20x 20x 20x 20x 20x 18x 2x 16x 20x 14x 12x 12x 2x 2x 3x 2x 2x 2x 1x 1x 1x 2x 8x 1x 1x | // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
/**
* @module MCP/IMFMCPClient
* @description Native TypeScript IMF Data client — calls the IMF SDMX 3.0
* REST API at {@link https://dataservices.imf.org/REST/SDMX_3.0/} directly
* via `fetch()`, with no external MCP server process.
*
* Historical note: the first Wave-1 iteration delegated to the Python
* `c-cf/imf-data-mcp` MCP server. That dependency blocked Wave 0 rollout
* because the upstream project is a Python git-URL package (not npm) and
* could not be pinned to an integrity hash per the ISMS Secure Development
* Policy §7. This module replaces the Python transport with a direct,
* typed HTTP client — the public API is preserved so callers
* (`src/utils/imf-data.ts`, validator fingerprints, workflow probes) are
* untouched.
*
* ## Public API (unchanged from the MCP-backed iteration)
*
* - {@link IMFMCPClient} — class with semantic wrappers for five "tools".
* - {@link IMF_MCP_TOOLS} — stable virtual tool-name list used by the
* content-validator fingerprint and the workflow probe.
* - {@link getIMFMCPClient} / {@link closeIMFMCPClient} — singleton lifecycle.
*
* The return envelope of every method is {@link MCPToolResult}
* (`{ content: [{ type: "text", text: "<json>" }] }`) so downstream code
* that already calls `parseSDMXJSON(response.content[0]?.text)` continues
* to work unmodified.
*
* ## Transport
*
* - Uses the native Node 25 `fetch()` — no extra runtime dependency.
* - Every call has an independent `AbortController` with a configurable
* timeout (`IMF_API_TIMEOUT_MS`, default 30 s).
* - Errors (HTTP 4xx/5xx, network faults, JSON parse failures, abort) are
* caught and converted to the {@link IMF_FALLBACK} envelope. Callers
* upstream can therefore treat "no IMF" as "empty data" without
* defensive try/catch, matching the `WorldBankMCPClient` pattern.
*
* Environment variables:
* - `IMF_API_BASE_URL` — override base URL (default
* `https://dataservices.imf.org/REST/SDMX_3.0`).
* - `IMF_API_TIMEOUT_MS` — per-request timeout (default `30000`).
*
* Legacy env vars (`IMF_MCP_GATEWAY_URL`, `IMF_MCP_GATEWAY_API_KEY`,
* `IMF_MCP_SERVER_PATH`) are no longer consulted — no gateway is needed
* because the IMF SDMX 3.0 API is an unauthenticated public endpoint.
*/
import type { MCPToolResult, MCPClientOptions } from '../types/index.js';
// ─── Defaults ────────────────────────────────────────────────────────────────
/** Default base URL for the IMF SDMX 3.0 REST API. */
const DEFAULT_IMF_API_BASE_URL = 'https://dataservices.imf.org/REST/SDMX_3.0';
/** Default per-request timeout (milliseconds). */
const DEFAULT_IMF_API_TIMEOUT_MS = 30_000;
/** Fallback payload shape when an IMF call fails or the server is offline. */
const IMF_FALLBACK: MCPToolResult = {
content: [{ type: 'text', text: '' }],
};
/**
* Canonical list of "virtual tools" exposed by this client. The client no
* longer talks to an MCP server, but the tool-name list is preserved so
* it continues to serve as:
*
* 1. The content-validator fingerprint source (`IMF_STRONG_FINGERPRINTS`).
* 2. The workflow probe's heartbeat identifiers.
* 3. A drift guard against method additions: if a new helper method lands
* here, `test/integration/mcp/imf-mcp.test.js` fails unless the list
* and the test are updated in lock-step.
*
* Kept in sync with `analysis/methodologies/imf-indicator-mapping.md`.
*/
export const IMF_MCP_TOOLS: readonly string[] = [
'imf-list-databases',
'imf-search-databases',
'imf-get-parameter-defs',
'imf-get-parameter-codes',
'imf-fetch-data',
];
// ─── Client options ──────────────────────────────────────────────────────────
/**
* Options accepted by {@link IMFMCPClient}. Shape intentionally matches
* {@link MCPClientOptions} for historical compatibility — fields unused by
* the native HTTP transport (`serverPath`, `gatewayUrl`, `gatewayApiKey`,
* `maxConnectionAttempts`, `connectionRetryDelay`) are accepted and
* silently ignored so existing call-sites do not break.
*/
export interface IMFClientOptions extends MCPClientOptions {
/** Override the IMF REST base URL (default: {@link DEFAULT_IMF_API_BASE_URL}). */
apiBaseUrl?: string;
/** Per-request timeout in milliseconds (default: {@link DEFAULT_IMF_API_TIMEOUT_MS}). */
timeoutMs?: number;
/** Optional `fetch` implementation injection for testing. */
fetchImpl?: typeof fetch;
}
// ─── SDMX 3.0 response narrow types (only the fields we consume) ─────────────
interface SDMXCategoryReference {
id?: string;
name?: string | Record<string, string>;
description?: string | Record<string, string>;
}
interface SDMXDataflowListResponse {
data?: {
dataflows?: SDMXCategoryReference[];
};
}
interface SDMXDimensionValue {
id: string;
name?: string | Record<string, string>;
}
interface SDMXDimension {
id: string;
name?: string | Record<string, string>;
localRepresentation?: {
enumeration?: string;
};
values?: SDMXDimensionValue[];
}
interface SDMXDataStructureResponse {
data?: {
dataStructures?: Array<{
id?: string;
dataStructureComponents?: {
dimensionList?: { dimensions?: SDMXDimension[] };
};
}>;
codelists?: Array<{
id?: string;
codes?: SDMXDimensionValue[];
}>;
};
}
// ─── Utilities ───────────────────────────────────────────────────────────────
/**
* Unwrap SDMX localised labels to a plain string.
*
* SDMX 3.0 sometimes returns `name`/`description` as a language-keyed
* object (`{ en: "World Economic Outlook" }`); older payloads return a
* raw string. Prefer English, fall back to the first available value.
*
* @param raw - Raw label (string, locale object, or undefined).
* @returns Plain string (empty when no label is available).
* @internal
*/
function unwrapLocalisedLabel(raw: string | Record<string, string> | undefined): string {
if (!raw) return '';
if (typeof raw === 'string') return raw;
Eif (typeof raw['en'] === 'string') return raw['en'];
for (const v of Object.values(raw)) {
if (typeof v === 'string') return v;
}
return '';
}
/**
* Wrap a JSON-serialisable value in the canonical MCP tool-result shape
* so consumers that already expect `response.content[0]?.text` to hold a
* JSON blob keep working without change.
*
* @param payload - Serialisable payload (object, array, or already-stringified JSON).
* @returns MCP tool-result envelope with a single text content item.
* @internal
*/
function wrapAsMCPResult(payload: unknown): MCPToolResult {
const text = typeof payload === 'string' ? payload : JSON.stringify(payload ?? null);
return { content: [{ type: 'text', text }] };
}
/**
* Simple value-encoder for SDMX URL dimension components. SDMX uses `+`
* to join alternative codes inside a single dimension slot and `.` as
* the dimension separator, so the value must be URI-encoded first to
* avoid collisions with user-supplied codes that happen to contain
* those characters.
*
* @param codes - Ordered code values for a single dimension (may be empty = wildcard).
* @returns URL-safe dimension component (`""` for wildcard, `"A+B"` for union).
* @internal
*/
function encodeSDMXDimension(codes: readonly string[]): string {
return codes.map((c) => encodeURIComponent(c)).join('+');
}
/**
* Build an SDMX key from a filters map + declared dimension order.
*
* If a declared dimension is absent from `filters`, the slot is left as
* the wildcard (empty string). Extra filter keys not present in the
* declared order are ignored — the caller is expected to have discovered
* the correct dimension names via {@link IMFMCPClient.getParameterDefs}.
*
* @param dimensions - Declared dimension order (e.g. `["country","indicator","frequency"]`).
* @param filters - Map of dimension → selected codes.
* @returns SDMX key (e.g. `"DEU.NGDP_RPCH.A"`).
* @internal
*/
function buildSDMXKey(
dimensions: readonly string[],
filters: Readonly<Record<string, readonly string[]>>
): string {
return dimensions
.map((dim) => {
const codes = filters[dim];
return Array.isArray(codes) ? encodeSDMXDimension(codes) : '';
})
.join('.');
}
/**
* Infer the dimension order for a given dataflow when
* {@link IMFMCPClient.getParameterDefs} has not been called yet. Used as a
* fallback because the WEO datastructure in particular is so widely used
* that encoding a well-known default eliminates one round-trip per fetch.
*
* Order mirrors the conventional `{country}.{indicator}.{frequency}`
* layout documented on the IMF Data Services pages.
*
* @param databaseId - Dataflow identifier (case-insensitive).
* @returns Default dimension order used when the caller omits it.
* @internal
*/
function defaultDimensionOrder(databaseId: string): readonly string[] {
switch (databaseId.toUpperCase()) {
case 'WEO':
case 'FM':
return ['country', 'indicator', 'frequency'];
case 'IFS':
case 'CPI':
return ['frequency', 'country', 'indicator'];
case 'BOP_AGG':
case 'ER':
case 'PCPS':
return ['frequency', 'country', 'indicator'];
default:
return ['country', 'indicator', 'frequency'];
}
}
// ─── Client ──────────────────────────────────────────────────────────────────
/**
* Native TypeScript client for the IMF SDMX 3.0 REST API.
*
* Despite the historical class name, no MCP server process is involved —
* the class keeps the name `IMFMCPClient` purely to avoid breaking the
* existing import surface (`src/index.ts`, test suites, documentation).
* New code is welcome to import it as `IMFClient` (alias below).
*/
export class IMFMCPClient {
private readonly _apiBaseUrl: string;
private readonly _timeoutMs: number;
private readonly _fetchImpl: typeof fetch;
private _connected = false;
constructor(options: IMFClientOptions = {}) {
const envBase = process.env['IMF_API_BASE_URL'];
const envTimeout = process.env['IMF_API_TIMEOUT_MS'];
const parsedEnvTimeout =
envTimeout !== undefined && envTimeout !== '' ? Number.parseInt(envTimeout, 10) : Number.NaN;
const base =
options.apiBaseUrl ?? (envBase && envBase !== '' ? envBase : DEFAULT_IMF_API_BASE_URL);
// Strip trailing slashes without a regex so the CodeQL polynomial-ReDoS
// detector has nothing to flag. Single linear pass from the right.
let end = base.length;
while (end > 0 && base.charCodeAt(end - 1) === 47 /* '/' */) {
end -= 1;
}
this._apiBaseUrl = end === base.length ? base : base.slice(0, end);
this._timeoutMs =
options.timeoutMs !== undefined && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
? options.timeoutMs
: Number.isFinite(parsedEnvTimeout) && parsedEnvTimeout > 0
? parsedEnvTimeout
: DEFAULT_IMF_API_TIMEOUT_MS;
this._fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
}
/**
* Base URL currently in use (read-only — set at construction time).
*
* @returns The fully-qualified IMF SDMX base URL (no trailing slash).
*/
getApiBaseUrl(): string {
return this._apiBaseUrl;
}
/**
* Per-request timeout in milliseconds.
*
* @returns The timeout currently applied to every `fetch()` call.
*/
getTimeoutMs(): number {
return this._timeoutMs;
}
/**
* Mark the client as ready. HTTP is stateless so there is no real
* connection, but callers historically invoke `connect()` before use —
* this is preserved for API compatibility and also exercises the
* base URL to catch misconfiguration early.
*
* @returns A resolved promise; never throws for valid URLs.
*/
async connect(): Promise<void> {
try {
// Validate the base URL shape without making a network request so
// construction-time errors surface immediately.
new URL(this._apiBaseUrl);
this._connected = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid IMF_API_BASE_URL "${this._apiBaseUrl}": ${message}`, {
cause: error,
});
}
}
/**
* Whether {@link connect} has been called successfully.
*
* @returns `true` after a successful {@link connect}; reset by {@link disconnect}.
*/
isConnected(): boolean {
return this._connected;
}
/** Reset the connected flag. No real socket to close. */
disconnect(): void {
this._connected = false;
}
/**
* List every IMF database (dataflow) exposed by the SDMX 3.0 API.
*
* Virtual tool: `imf-list-databases`.
*
* @returns MCP-shaped result whose `content[0].text` carries a JSON
* array of `{ id, name, description }` entries. Empty on error.
*/
async listDatabases(): Promise<MCPToolResult> {
try {
const json = await this._getJSON<SDMXDataflowListResponse>('/dataflow/IMF');
const flows = json?.data?.dataflows ?? [];
const rows = flows.map((f) => ({
id: f.id ?? '',
name: unwrapLocalisedLabel(f.name),
description: unwrapLocalisedLabel(f.description),
}));
return wrapAsMCPResult(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn('imf-list-databases not available:', message);
return IMF_FALLBACK;
}
}
/**
* Search IMF databases by free-text keyword (case-insensitive
* substring match against id / name / description).
*
* Virtual tool: `imf-search-databases`. Runs client-side over the
* full dataflow list so a single SDMX round-trip serves every
* keyword query in a workflow run.
*
* @param keyword - Free-text keyword (e.g. `"inflation"`, `"trade"`).
* @returns Filtered list in MCP-shaped result; empty on error or when keyword is blank.
*/
async searchDatabases(keyword: string): Promise<MCPToolResult> {
if (!keyword) {
console.warn('imf-search-databases called without a keyword');
return IMF_FALLBACK;
}
try {
const json = await this._getJSON<SDMXDataflowListResponse>('/dataflow/IMF');
const flows = json?.data?.dataflows ?? [];
const needle = keyword.toLowerCase();
const rows = flows
.map((f) => ({
id: f.id ?? '',
name: unwrapLocalisedLabel(f.name),
description: unwrapLocalisedLabel(f.description),
}))
.filter((r) => {
const hay = `${r.id} ${r.name} ${r.description}`.toLowerCase();
return hay.includes(needle);
});
return wrapAsMCPResult(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn('imf-search-databases not available:', message);
return IMF_FALLBACK;
}
}
/**
* Fetch the dimension (parameter) definitions for a specific IMF
* dataflow. Essential before building an SDMX key for
* {@link fetchData} because each database has its own dimension set.
*
* Virtual tool: `imf-get-parameter-defs`.
*
* @param databaseId - IMF dataflow identifier (e.g. `"WEO"`, `"IFS"`).
* @returns MCP-shaped result whose `content[0].text` carries the
* ordered list of dimensions (`[{ id, name }]`). Empty on error.
*/
async getParameterDefs(databaseId: string): Promise<MCPToolResult> {
if (!databaseId) {
console.warn('imf-get-parameter-defs called without databaseId');
return IMF_FALLBACK;
}
try {
const json = await this._getJSON<SDMXDataStructureResponse>(
`/datastructure/${encodeURIComponent(databaseId)}`
);
const ds = json?.data?.dataStructures?.[0];
const dims = ds?.dataStructureComponents?.dimensionList?.dimensions ?? [];
const rows = dims.map((d) => ({ id: d.id, name: unwrapLocalisedLabel(d.name) }));
return wrapAsMCPResult(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn('imf-get-parameter-defs not available:', message);
return IMF_FALLBACK;
}
}
/**
* List valid codes for a single dimension of an IMF dataflow, with
* an optional free-text filter to narrow the result.
*
* Virtual tool: `imf-get-parameter-codes`. The underlying SDMX
* `/codelist/` endpoint is used, looked up from the datastructure so
* the caller does not need to know the codelist identifier ahead of
* time.
*
* @param databaseId - IMF dataflow identifier.
* @param parameter - Dimension name (e.g. `"country"`, `"indicator"`).
* @param search - Optional free-text search (case-insensitive substring).
* @returns MCP-shaped result with `[{ id, name }]` rows; empty on error.
*/
async getParameterCodes(
databaseId: string,
parameter: string,
search?: string
): Promise<MCPToolResult> {
if (!databaseId || !parameter) {
console.warn('imf-get-parameter-codes requires databaseId and parameter');
return IMF_FALLBACK;
}
try {
// 1. Discover the codelist id for the requested dimension.
const structure = await this._getJSON<SDMXDataStructureResponse>(
`/datastructure/${encodeURIComponent(databaseId)}?references=codelist`
);
const ds = structure?.data?.dataStructures?.[0];
const dims = ds?.dataStructureComponents?.dimensionList?.dimensions ?? [];
const dim = dims.find((d) => d.id.toLowerCase() === parameter.toLowerCase());
if (!dim) {
return wrapAsMCPResult([]);
}
// The SDMX codelist reference URN looks like
// "urn:sdmx:org.sdmx.infomodel.codelist.Codelist=IMF:CL_AREA(1.0)"
// We only need the codelist id — use string-split parsing
// (no regex) so the static-analysis "unsafe regex" detector has
// nothing to object to and the extraction stays obviously linear.
let codelistId: string | undefined = dim.localRepresentation?.enumeration;
if (codelistId) {
const afterEquals = codelistId.includes('=')
? (codelistId.split('=')[1] ?? '')
: codelistId;
const beforeParen = afterEquals.split('(')[0] ?? '';
const parts = beforeParen.split(':');
codelistId = (parts[parts.length - 1] ?? beforeParen).trim() || codelistId;
}
// Some payloads inline the values directly; prefer those when present.
let codes: SDMXDimensionValue[] = dim.values ?? [];
if (codes.length === 0 && codelistId) {
const cl = structure?.data?.codelists?.find((c) => c.id === codelistId);
codes = cl?.codes ?? [];
}
const needle = (search ?? '').toLowerCase();
const rows = codes
.map((c) => ({ id: c.id, name: unwrapLocalisedLabel(c.name) }))
.filter((r) => {
if (!needle) return true;
return `${r.id} ${r.name}`.toLowerCase().includes(needle);
});
return wrapAsMCPResult(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn('imf-get-parameter-codes not available:', message);
return IMF_FALLBACK;
}
}
/**
* Fetch a time-series slice from an IMF dataflow as SDMX-JSON.
*
* Virtual tool: `imf-fetch-data`. The response is already in SDMX-JSON
* format, so {@link parseSDMXJSON} (`src/utils/imf-data.ts`) can
* consume `response.content[0]?.text` directly without reshaping.
*
* @param options - Fetch parameters.
* @param options.databaseId - IMF dataflow ID (`"WEO"`, `"IFS"`, ...).
* @param options.startYear - Inclusive start year (e.g. `2015`).
* @param options.endYear - Inclusive end year (e.g. `2030` for WEO forecasts).
* @param options.filters - Map of dimension → selected codes.
* @param options.dimensionOrder - Optional override of the dimension order
* used to build the SDMX key. Defaults to
* {@link defaultDimensionOrder} for the database.
* @returns MCP-shaped result whose `content[0].text` carries the raw
* SDMX-JSON response. Empty on error or invalid inputs.
*/
async fetchData(options: {
databaseId: string;
startYear: number;
endYear: number;
filters: Readonly<Record<string, readonly string[]>>;
dimensionOrder?: readonly string[];
}): Promise<MCPToolResult> {
const { databaseId, startYear, endYear, filters, dimensionOrder } = options;
if (!databaseId || !filters || Object.keys(filters).length === 0) {
console.warn('imf-fetch-data requires databaseId and a non-empty filters map');
return IMF_FALLBACK;
}
if (!Number.isFinite(startYear) || !Number.isFinite(endYear) || endYear < startYear) {
console.warn(`imf-fetch-data invalid year range: ${startYear}-${endYear}`);
return IMF_FALLBACK;
}
try {
const dims = dimensionOrder ?? defaultDimensionOrder(databaseId);
const key = buildSDMXKey(dims, filters);
const qs = new URLSearchParams({
startPeriod: String(startYear),
endPeriod: String(endYear),
format: 'jsondata',
});
const url = `/data/${encodeURIComponent(databaseId)}/${key}?${qs.toString()}`;
const text = await this._getText(url);
return wrapAsMCPResult(text);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn('imf-fetch-data not available:', message);
return IMF_FALLBACK;
}
}
// ─── private transport helpers ─────────────────────────────────────────────
/**
* Build a full URL and GET it as text, enforcing the client-wide timeout.
*
* @param path - Path (already URL-encoded) to append to the base URL.
* @returns Response body (`text/*` or `application/*`) as a string.
* @throws When the HTTP status is not 2xx, the request times out, or
* the network layer raises.
* @internal
*/
private async _getText(path: string): Promise<string> {
const url = `${this._apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this._timeoutMs);
try {
const response = await this._fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}`);
}
return await response.text();
} finally {
clearTimeout(timer);
}
}
/**
* GET a URL and parse the response body as JSON.
*
* @template T - Narrow response type declared by the caller.
* @param path - Path to append to the base URL.
* @returns Parsed JSON value.
* @throws When the response is not JSON, not 2xx, or the request fails.
* @internal
*/
private async _getJSON<T>(path: string): Promise<T> {
const raw = await this._getText(path);
try {
return JSON.parse(raw) as T;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to parse IMF response as JSON: ${message}`, { cause: error });
}
}
}
/**
* Forward-looking alias for {@link IMFMCPClient}. New code should prefer
* `IMFClient`; the `IMFMCPClient` name is retained for backward
* compatibility with the MCP-backed iteration shipped in Wave 1.
*/
export const IMFClient = IMFMCPClient;
// ─── Singleton lifecycle ─────────────────────────────────────────────────────
/** Singleton instance, created lazily by {@link getIMFMCPClient}. */
let imfClientInstance: IMFMCPClient | null = null;
/**
* Get or create the singleton IMF client, validating the base URL on
* first use. Subsequent calls return the cached instance.
*
* @param options - Client options (override env vars and defaults).
* @returns Connected singleton client.
* @throws When the base URL is malformed (e.g. missing protocol).
*/
export async function getIMFMCPClient(options: IMFClientOptions = {}): Promise<IMFMCPClient> {
if (!imfClientInstance) {
const client = new IMFMCPClient(options);
try {
await client.connect();
imfClientInstance = client;
} catch (error) {
imfClientInstance = null;
throw error;
}
}
return imfClientInstance;
}
/** Close and clear the singleton instance (idempotent). */
export async function closeIMFMCPClient(): Promise<void> {
if (imfClientInstance) {
imfClientInstance.disconnect();
imfClientInstance = null;
}
}
|