/** * Minimal HTTP client for the HF Market Data API (native fetch, no dependencies). * * - Base URL from `HFMD_BASE_URL` (default https://www.hfmarketdata.io). * - Optional bearer key from `HFMD_API_KEY` (keyless mode works with reduced limits). * - Always requests `format=json`, parses both the v1 envelope `{count,data}` and the * v2 envelope `{data,meta}`, surfaces `X-RateLimit-*` headers and maps every failure to * a single `HfmdError` shape (`{status, code, message, docs, retryAfter}`). */ export const DEFAULT_BASE_URL = 'https://www.hfmarketdata.io'; export const USER_AGENT = 'hfmarketdata-mcp/1.0.0 (+https://www.hfmarketdata.io/integrations/mcp)'; export type Params = Record; export interface RateInfo { limitRequests: number | null; remainingRequests: number | null; limitRows: number | null; remainingRows: number | null; /** Seconds (or epoch seconds, depending on the server) until the window resets. */ reset: number | null; rowCount: number | null; retryAfter: number | null; } export interface ApiResult { status: number; url: string; body: T; rate: RateInfo; ms: number; } export class HfmdError extends Error { status: number; code: string; docs?: string; details?: unknown; rate?: RateInfo; url?: string; constructor(status: number, code: string, message: string, extra: { docs?: string; details?: unknown; rate?: RateInfo; url?: string } = {}) { super(message); this.name = 'HfmdError'; this.status = status; this.code = code; this.docs = extra.docs; this.details = extra.details; this.rate = extra.rate; this.url = extra.url; } /** Human readable, single paragraph, safe to return to an LLM. */ describe(): string { const parts = [`HF Market Data error ${this.status} ${this.code}: ${this.message}`]; if (this.status === 429) { const ra = this.rate?.retryAfter; parts.push(ra != null ? `Retry after ${ra}s.` : 'Retry later.'); parts.push('Keyless mode has low hourly limits (30 requests/hour); set HFMD_API_KEY (free API key = 120 req/min; higher limits on request to contact@spboucher.ai, also free) or narrow the query.'); } else if (this.status === 401) { parts.push('Check HFMD_API_KEY (format hfmd_live_…) or unset it to use keyless mode.'); } else if (this.status === 404 && this.code === 'NOT_FOUND') { parts.push('This endpoint may not be deployed yet on this server (v2 futures/fundamentals roll out progressively). Check hfmarketdata://status.'); } if (this.docs) parts.push(`Docs: ${this.docs}`); if (this.url) parts.push(`URL: ${this.url}`); return parts.join(' '); } } export interface ClientOptions { baseUrl?: string; apiKey?: string; fetchImpl?: typeof fetch; timeoutMs?: number; } function num(h: Headers, k: string): number | null { const v = h.get(k); if (v == null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } export function rateFromHeaders(h: Headers): RateInfo { return { limitRequests: num(h, 'x-ratelimit-limit-requests') ?? num(h, 'x-ratelimit-limit'), remainingRequests: num(h, 'x-ratelimit-remaining-requests') ?? num(h, 'x-ratelimit-remaining'), limitRows: num(h, 'x-ratelimit-limit-rows'), remainingRows: num(h, 'x-ratelimit-remaining-rows'), reset: num(h, 'x-ratelimit-reset'), rowCount: num(h, 'x-row-count'), retryAfter: num(h, 'retry-after'), }; } export function describeRate(rate: RateInfo | undefined): string | null { if (!rate) return null; const bits: string[] = []; if (rate.remainingRequests != null) bits.push(`requests ${rate.remainingRequests}${rate.limitRequests != null ? `/${rate.limitRequests}` : ''} left`); if (rate.remainingRows != null) bits.push(`rows ${rate.remainingRows}${rate.limitRows != null ? `/${rate.limitRows}` : ''} left`); if (rate.reset != null) bits.push(`reset in ${rate.reset}s`); return bits.length ? `Rate limit: ${bits.join(' · ')}` : null; } export class HfmdClient { readonly baseUrl: string; readonly apiKey: string | undefined; private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; constructor(opts: ClientOptions = {}) { this.baseUrl = (opts.baseUrl ?? process.env.HFMD_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); const key = opts.apiKey ?? process.env.HFMD_API_KEY; this.apiKey = key && key.trim() ? key.trim() : undefined; this.fetchImpl = opts.fetchImpl ?? globalThis.fetch; this.timeoutMs = opts.timeoutMs ?? 60_000; if (typeof this.fetchImpl !== 'function') { throw new Error('global fetch is not available: hfmarketdata-mcp needs Node.js >= 20'); } } get keyless(): boolean { return !this.apiKey; } /** Websocket URL derived from the base URL (https → wss). */ get wsUrl(): string { return this.baseUrl.replace(/^http/, 'ws') + '/v1/stream'; } buildUrl(path: string, params: Params = {}): string { const u = new URL(this.baseUrl + (path.startsWith('/') ? path : `/${path}`)); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === '') continue; u.searchParams.set(k, Array.isArray(v) ? v.join(',') : String(v)); } if (!u.searchParams.has('format')) u.searchParams.set('format', 'json'); return u.toString(); } async get(path: string, params: Params = {}): Promise> { const url = this.buildUrl(path, params); const headers: Record = { Accept: 'application/json', 'User-Agent': USER_AGENT }; if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), this.timeoutMs); const t0 = Date.now(); let res: Response; try { res = await this.fetchImpl(url, { method: 'GET', headers, signal: ctrl.signal }); } catch (e) { clearTimeout(timer); const msg = e instanceof Error ? e.message : String(e); const code = ctrl.signal.aborted ? 'TIMEOUT' : 'NETWORK_ERROR'; throw new HfmdError(0, code, ctrl.signal.aborted ? `request timed out after ${this.timeoutMs} ms` : `network error: ${msg}`, { url }); } clearTimeout(timer); const ms = Date.now() - t0; const rate = rateFromHeaders(res.headers); const ct = res.headers.get('content-type') || ''; let body: unknown; const text = await res.text(); if (ct.includes('json') || text.startsWith('{') || text.startsWith('[')) { try { body = JSON.parse(text); } catch { body = text; } } else { body = text; } if (!res.ok) throw errorFromResponse(res.status, body, rate, url); return { status: res.status, url, body: body as T, rate, ms }; } } export function errorFromResponse(status: number, body: unknown, rate: RateInfo, url: string): HfmdError { const b = (body ?? {}) as Record; const err = (b.error ?? {}) as Record; let code = typeof err.code === 'string' ? err.code : undefined; let message = typeof err.message === 'string' ? err.message : undefined; if (!message) { const detail = b.detail; if (typeof detail === 'string') message = detail; else if (Array.isArray(detail)) message = detail.map((d) => (d && typeof d === 'object' && 'msg' in d ? `${(d as any).loc?.join('.') ?? ''}: ${(d as any).msg}` : JSON.stringify(d))).join('; '); else if (typeof body === 'string' && body.trim()) message = body.trim().slice(0, 300); } if (!code) { code = status === 429 ? 'RATE_LIMIT_EXCEEDED' : status === 401 ? 'INVALID_API_KEY' : status === 403 ? 'FORBIDDEN' : status === 404 ? 'NOT_FOUND' : status === 400 || status === 422 ? 'INVALID_PARAMETER' : status >= 500 ? 'INTERNAL_ERROR' : 'HTTP_ERROR'; } return new HfmdError(status, code, message ?? `HTTP ${status}`, { docs: typeof err.docs === 'string' ? err.docs : undefined, details: err.details, rate, url, }); }