/** * Tool catalogue. Each tool is a pure function `(client, args) => text` so it can be unit-tested * without an MCP transport; `server.ts` wraps them into MCP tools. * * Endpoint map (HF Market Data): * v1 (live) /v1/{asset}/tickers · /v1/bars/{asset}/{ticker} · /v1/bars/{asset}?tickers= · * /v1/options/{tickers,chain,expirations,history} * v2 futures /v1/futures/roots · /v1/futures/{root}/contracts · /v1/futures/contract/{symbol}/bars · * /v1/futures/{root}/chain · /v1/futures/{root}/continuous · /v1/futures/{root}/term-structure · * /v1/futures/contract/{symbol}/coverage * v2 fundamentals /v1/fundamentals/{ticker}/{statements,facts/{concept},ratios,ratios/daily,filings,coverage} · * /v1/fundamentals/screener · /v1/fundamentals/frames/{concept} */ import { z } from 'zod'; import { HfmdClient, HfmdError, type Params } from './client.js'; import { DEFAULT_LIMIT, MAX_LIMIT, extract, renderResult, rateLine, shrink } from './format.js'; import { MAX_MESSAGES, MAX_TIMEOUT_MS, subscribeFilings, type SubscribeOptions } from './stream.js'; export type Shape = z.ZodRawShape; export interface ToolContext { /** Injected websocket implementation (tests). */ WebSocketImpl?: typeof WebSocket; } export interface ToolDef { name: string; title: string; description: string; schema: S; handler: (client: HfmdClient, args: z.infer>, ctx?: ToolContext) => Promise; } function def(t: ToolDef): ToolDef { return t; } // --- shared schema fragments ------------------------------------------------------------------- const V1_ASSETS = ['stock', 'etf', 'crypto', 'index', 'fx', 'futures'] as const; const TIMEFRAMES = ['1min', '5min', '30min', '1hour', '1day'] as const; const INTERVAL_ALIAS: Record = { '1m': '1min', '5m': '5min', '30m': '30min', '1h': '1hour', '1d': '1day', '1min': '1min', '5min': '5min', '30min': '30min', '1hour': '1hour', '1day': '1day' }; const V2_INTERVAL: Record = { '1min': '1m', '5min': '5m', '30min': '30m', '1hour': '1h', '1day': '1d', '1m': '1m', '5m': '5m', '30m': '30m', '1h': '1h', '1d': '1d' }; const limitSchema = z.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT) .describe(`Maximum rows to fetch (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT}). Results above 200 rows are summarised (head/tail + numeric ranges); lower the range or the limit to see every row.`); const dateSchema = (what: string) => z.string().regex(/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?$/, 'expected YYYY-MM-DD or YYYY-MM-DDTHH:MM').optional().describe(what); const timeframeSchema = z.enum([...TIMEFRAMES, '1m', '5m', '30m', '1h', '1d']).default('1day') .describe('Bar size: 1min, 5min, 30min, 1hour or 1day (aliases 1m/5m/30m/1h/1d accepted). Intraday timestamps are naive US/Eastern; daily bars are dates.'); function tf(v: string | undefined): string { return INTERVAL_ALIAS[v ?? '1day'] ?? '1day'; } const TICKER_RE = /^[A-Za-z0-9._^=:-]{1,20}$/; const symbol = (what: string) => z.string().regex(TICKER_RE, 'invalid symbol').transform((s) => s.trim().toUpperCase()).describe(what); // --- tools ------------------------------------------------------------------------------------- export const searchSymbols = def({ name: 'search_symbols', title: 'Search symbols', description: 'Find tickers, futures roots or optionable underlyings available in HF Market Data. ' + 'asset=stock|etf|crypto|index|fx lists tickers (substring search, e.g. "AAP"); asset=futures lists futures roots (ES, CL, NG, 6E…) with names and data ranges; ' + 'asset=options lists underlyings with options chains. Use this before get_bars when unsure of the exact symbol.', schema: { asset: z.enum([...V1_ASSETS, 'options']).describe('Universe to search.'), query: z.string().max(40).optional().describe('Substring/prefix to match (case-insensitive). Omit to list the first `limit` symbols.'), timeframe: timeframeSchema.optional().describe('For stock/etf/crypto/index/fx: only symbols with data at this bar size (default 1day).'), limit: z.number().int().min(1).max(2000).default(100).describe('Max symbols returned (default 100).'), }, async handler(client, a) { const q = a.query?.trim(); if (a.asset === 'futures') { const r = await client.get('/v1/futures/roots', { limit: 1000 }); const { rows } = extract(r.body); let list = rows ?? []; if (q) { const needle = q.toUpperCase(); list = list.filter((x) => String(x.root ?? '').toUpperCase().includes(needle) || String(x.name ?? '').toUpperCase().includes(needle) || String(x.asset_class ?? '').toUpperCase().includes(needle)); } return renderResult({ ...r, body: { data: list.slice(0, a.limit), meta: { count: list.length, filtered_by: q ?? null } } }, { title: `Futures roots${q ? ` matching "${q}"` : ''}` }); } if (a.asset === 'options') { const r = await client.get('/v1/options/tickers', { search: q, limit: a.limit }); return renderResult(r, { title: `Optionable underlyings${q ? ` matching "${q}"` : ''}` }); } const r = await client.get<{ tickers?: string[]; count?: number }>(`/v1/${a.asset}/tickers`, { search: q, limit: a.limit, timeframe: tf(a.timeframe) }); const body = r.body ?? {}; const tickers = Array.isArray(body.tickers) ? body.tickers : []; return [ `${a.asset} tickers${q ? ` matching "${q}"` : ''}: ${body.count ?? tickers.length} available, showing ${Math.min(tickers.length, a.limit)}`, JSON.stringify(shrink({ ...body, tickers: tickers.slice(0, a.limit) }, a.limit)), rateLine(r.rate), ].join('\n'); }, }); export const getBars = def({ name: 'get_bars', title: 'Get OHLCV bars', description: 'Historical OHLCV bars (open, high, low, close, volume) for one or several symbols. ' + 'asset=stock|etf|crypto|index|fx use the v1 bars endpoint (adjustment adj_splitdiv by default for stocks/ETFs); ' + 'asset=futures returns the vendor continuous series (adjustment contin_UNadj|contin_adj_ratio|contin_adj_absolute); ' + 'asset=futures_contract takes an individual contract symbol such as ESZ25 or CLM26 (v2). ' + 'Timeframes 1min…1day since ~2007 (2010 for futures). Dates YYYY-MM-DD; intraday datetimes are naive US/Eastern. ' + 'Keep ranges small for intraday data (1 day of 1-min bars ≈ 390-1440 rows).', schema: { asset: z.enum([...V1_ASSETS, 'futures_contract']).describe('Asset class of the symbol(s).'), symbol: z.union([symbol('Ticker / root / contract symbol, e.g. AAPL, SPY, BTCUSD, ES, ESZ25.'), z.array(symbol('Ticker')).min(1).max(20)]) .describe('One symbol, or up to 20 symbols (stock/etf/crypto/index/fx only) fetched in a single request.'), timeframe: timeframeSchema, start: dateSchema('First bar (inclusive), YYYY-MM-DD or YYYY-MM-DDTHH:MM.'), end: dateSchema('Last bar (inclusive), YYYY-MM-DD or YYYY-MM-DDTHH:MM.'), adjustment: z.string().max(30).optional().describe('stock/etf: UNADJUSTED | adj_split | adj_splitdiv (default). futures continuous: contin_UNadj (default) | contin_adj_ratio | contin_adj_absolute. Ignored for other assets.'), session: z.enum(['rth', 'eth', 'all']).optional().describe('futures_contract only: regular hours, extended hours or all (default all).'), order: z.enum(['asc', 'desc']).default('asc').describe('Sort by datetime. Use desc with a small limit to get the latest bars.'), limit: limitSchema, }, async handler(client, a) { const timeframe = tf(a.timeframe); if (a.asset === 'futures_contract') { const sym = Array.isArray(a.symbol) ? a.symbol[0] : a.symbol; if (Array.isArray(a.symbol) && a.symbol.length > 1) throw new HfmdError(400, 'INVALID_PARAMETER', 'futures_contract accepts a single contract symbol per call (call the tool once per contract).'); const r = await client.get(`/v1/futures/contract/${encodeURIComponent(sym)}/bars`, { interval: V2_INTERVAL[timeframe], from: a.start, to: a.end, session: a.session, order: a.order, limit: a.limit, }); return renderResult(r, { title: `${sym} ${timeframe} bars (individual futures contract)` }); } const common: Params = { timeframe, adjustment: a.adjustment, start: a.start, end: a.end, order: a.order, limit: a.limit }; if (Array.isArray(a.symbol)) { if (a.symbol.length === 1) return renderResult(await client.get(`/v1/bars/${a.asset}/${encodeURIComponent(a.symbol[0])}`, common), { title: `${a.symbol[0]} ${timeframe} bars` }); const r = await client.get(`/v1/bars/${a.asset}`, { ...common, tickers: a.symbol }); return renderResult(r, { title: `${a.symbol.join(', ')} ${timeframe} bars (multi, ${a.limit} rows total across tickers)` }); } const r = await client.get(`/v1/bars/${a.asset}/${encodeURIComponent(a.symbol)}`, common); return renderResult(r, { title: `${a.symbol} ${timeframe} bars${a.asset === 'futures' ? ' (vendor continuous, ' + (a.adjustment ?? 'contin_UNadj') + ')' : ''}` }); }, }); export const getFuturesContracts = def({ name: 'get_futures_contracts', title: 'List futures contracts', description: 'List the individual contracts (expirations) of a futures root, e.g. ES → ESH25, ESM25, ESU25, ESZ25… with expiration/last-trading/first-notice dates, ' + 'status (active/expired), data range, average daily volume and last open interest. Use it to pick contract symbols for get_bars(asset=futures_contract) or get_coverage.', schema: { root: symbol('Futures root, e.g. ES, NQ, CL, NG, GC, ZN, 6E.'), status: z.enum(['active', 'expired', 'all']).default('all').describe('Filter by contract status.'), year: z.number().int().min(2000).max(2100).optional().describe('Only contracts expiring in this year.'), limit: z.number().int().min(1).max(1000).default(200).describe('Max contracts returned.'), }, async handler(client, a) { const r = await client.get(`/v1/futures/${encodeURIComponent(a.root)}/contracts`, { status: a.status === 'all' ? undefined : a.status, year: a.year, limit: a.limit }); return renderResult(r, { title: `${a.root} contracts (${a.status})`, fullRowsThreshold: 400 }); }, }); export const getFuturesChain = def({ name: 'get_futures_chain', title: 'Futures chain as of a date', description: 'The chain of listed contracts for a root as of a given date (default today): front month first, with expirations, days to expiry, last price, volume and open interest. ' + 'Answers "which CL contract was the front month on 2024-03-15?" and gives the symbols needed for compare/term-structure work.', schema: { root: symbol('Futures root, e.g. ES, CL.'), as_of: dateSchema('Reference date YYYY-MM-DD (default: latest available).'), depth: z.number().int().min(1).max(60).optional().describe('Number of contracts along the curve (default: all listed on as_of).'), }, async handler(client, a) { const r = await client.get(`/v1/futures/${encodeURIComponent(a.root)}/chain`, { as_of: a.as_of, depth: a.depth }); return renderResult(r, { title: `${a.root} chain as of ${a.as_of ?? 'latest'}` }); }, }); export const getContinuous = def({ name: 'get_continuous', title: 'Custom continuous futures series', description: 'Build a continuous futures series server-side from individual contracts (v2): choose the roll rule (roll=volume rolls when the next contract out-trades the front; ' + 'roll=open_interest on OI; roll=calendar N days before expiry), the price adjustment (adjust=none | back_adjusted (additive, preserves point changes) | ratio (multiplicative, preserves returns)) ' + 'and the depth (1 = front month, 2 = second month…). The response meta lists the roll dates. Prefer this over get_bars(asset=futures) when the roll methodology matters.', schema: { root: symbol('Futures root, e.g. ES, CL, NG.'), roll: z.string().max(30).default('volume').describe('Roll rule: volume (default), open_interest, calendar (with roll_days), or any rule listed by the API.'), adjust: z.string().max(30).default('back_adjusted').describe('Price adjustment: none, back_adjusted (default), ratio.'), depth: z.number().int().min(1).max(12).default(1).describe('Curve position: 1 = front month (default), 2 = second month, …'), roll_days: z.number().int().min(0).max(60).optional().describe('For roll=calendar: business days before expiry to roll.'), timeframe: timeframeSchema, start: dateSchema('From date (inclusive).'), end: dateSchema('To date (inclusive).'), session: z.enum(['rth', 'eth', 'all']).optional().describe('Trading session filter (intraday).'), limit: limitSchema, }, async handler(client, a) { const timeframe = tf(a.timeframe); const r = await client.get(`/v1/futures/${encodeURIComponent(a.root)}/continuous`, { roll: a.roll, adjust: a.adjust, depth: a.depth, roll_days: a.roll_days, interval: V2_INTERVAL[timeframe], from: a.start, to: a.end, session: a.session, limit: a.limit, }); return renderResult(r, { title: `${a.root} continuous (roll=${a.roll}, adjust=${a.adjust}, depth=${a.depth}, ${timeframe})` }); }, }); export const getTermStructure = def({ name: 'get_term_structure', title: 'Futures term structure', description: 'The futures curve of a root on a date: every listed contract with its settlement/last price, volume, open interest, days to expiry, plus spreads vs the front month. ' + 'Upward-sloping curve = contango (later contracts more expensive: storage/financing), downward = backwardation (tight spot market). Compare two dates by calling it twice.', schema: { root: symbol('Futures root, e.g. CL, NG, ES, ZC.'), as_of: dateSchema('Curve date YYYY-MM-DD (default: latest).'), depth: z.number().int().min(2).max(60).optional().describe('Number of contracts along the curve.'), }, async handler(client, a) { const r = await client.get(`/v1/futures/${encodeURIComponent(a.root)}/term-structure`, { as_of: a.as_of, depth: a.depth }); return renderResult(r, { title: `${a.root} term structure as of ${a.as_of ?? 'latest'}` }); }, }); export const getOptionsChain = def({ name: 'get_options_chain', title: 'Options chain with Greeks', description: 'End-of-day options chain of a US stock/ETF for one trade date: strike, expiry, call/put, bid/ask/last, volume, open interest, implied volatility and Greeks (delta, gamma, theta, vega). ' + 'Chains are large: always filter by expiry and/or a strike range, or set list_expirations=true first to see the available expirations. History since 2010 by quarter.', schema: { ticker: symbol('Underlying ticker, e.g. AAPL, SPY, TSLA.'), trade_date: dateSchema('Trade date YYYY-MM-DD (default: latest available).'), expiry: dateSchema('Only this expiration date YYYY-MM-DD.'), call_put: z.enum(['C', 'P', 'call', 'put']).optional().describe('Only calls or only puts.'), strike_min: z.number().optional().describe('Minimum strike.'), strike_max: z.number().optional().describe('Maximum strike.'), min_volume: z.number().int().min(0).optional().describe('Drop contracts below this daily volume.'), list_expirations: z.boolean().default(false).describe('If true, return only the expiration dates listed on trade_date (cheap) instead of the chain.'), limit: limitSchema, }, async handler(client, a) { if (a.list_expirations) { const r = await client.get(`/v1/options/expirations/${encodeURIComponent(a.ticker)}`, { trade_date: a.trade_date }); return renderResult(r, { title: `${a.ticker} option expirations${a.trade_date ? ` on ${a.trade_date}` : ''}` }); } const cp = a.call_put ? (a.call_put[0].toUpperCase()) : undefined; const r = await client.get(`/v1/options/chain/${encodeURIComponent(a.ticker)}`, { trade_date: a.trade_date, expiry: a.expiry, call_put: cp, strike_min: a.strike_min, strike_max: a.strike_max, min_volume: a.min_volume, limit: a.limit, }); return renderResult(r, { title: `${a.ticker} options chain${a.trade_date ? ` ${a.trade_date}` : ''}${a.expiry ? ` exp ${a.expiry}` : ''}` }); }, }); export const getCoverage = def({ name: 'get_coverage', title: 'Data coverage / gaps', description: 'What data exists for a symbol and where the holes are. kind=futures_contract → per-timeframe first/last bar, bar counts and gaps > 3 business days for a contract such as ESZ25; ' + 'kind=fundamentals → which statements/concepts/filings are available for a ticker and since when. Use it before drawing conclusions from sparse series (missing values are never invented by the API).', schema: { kind: z.enum(['futures_contract', 'fundamentals']).describe('Coverage type.'), symbol: symbol('Contract symbol (ESZ25) or ticker (AAPL).'), }, async handler(client, a) { const path = a.kind === 'futures_contract' ? `/v1/futures/contract/${encodeURIComponent(a.symbol)}/coverage` : `/v1/fundamentals/${encodeURIComponent(a.symbol)}/coverage`; const r = await client.get(path); return renderResult(r, { title: `${a.symbol} coverage (${a.kind})` }); }, }); export const getFinancialStatements = def({ name: 'get_financial_statements', title: 'Financial statements (SEC XBRL)', description: 'Standardised income statement, balance sheet and cash-flow statement of a US company from SEC XBRL filings, annual or quarterly, point-in-time (each row carries filed_at/accession so you know when the number became public). ' + 'Pass concept (e.g. Revenues, NetIncomeLoss, us-gaap:Assets) to get the full history of a single XBRL fact including restatements instead of statements.', schema: { ticker: symbol('US ticker, e.g. AAPL, MSFT.'), statement: z.enum(['income', 'balance', 'cashflow', 'all']).default('all').describe('Which statement(s).'), period: z.enum(['annual', 'quarterly', 'ttm', 'all']).default('quarterly').describe('Fiscal period type.'), concept: z.string().max(120).optional().describe('XBRL concept for a single-fact history (overrides statement).'), start: dateSchema('Earliest period end date.'), end: dateSchema('Latest period end date.'), limit: z.number().int().min(1).max(MAX_LIMIT).default(40).describe('Max rows (default 40: ~10 years of quarters for one statement).'), }, async handler(client, a) { if (a.concept) { const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/facts/${encodeURIComponent(a.concept)}`, { period: a.period === 'all' ? undefined : a.period, from: a.start, to: a.end, limit: a.limit }); return renderResult(r, { title: `${a.ticker} fact ${a.concept}` }); } const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/statements`, { statement: a.statement === 'all' ? undefined : a.statement, period: a.period === 'all' ? undefined : a.period, from: a.start, to: a.end, limit: a.limit, }); return renderResult(r, { title: `${a.ticker} ${a.statement} statements (${a.period})`, fullRowsThreshold: 300 }); }, }); export const getRatios = def({ name: 'get_ratios', title: 'Latest fundamental ratios', description: 'Current valuation, profitability, leverage and growth ratios of a US company computed from its latest filings and price: P/E, P/B, EV/EBITDA, FCF yield, ROE, ROA, gross/operating/net margins, debt/equity, current ratio, revenue and EPS growth… ' + 'Each ratio states the period it is based on. For a time series use get_ratios_daily.', schema: { ticker: symbol('US ticker, e.g. AAPL.'), }, async handler(client, a) { const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/ratios`); return renderResult(r, { title: `${a.ticker} ratios (latest)` }); }, }); export const getRatiosDaily = def({ name: 'get_ratios_daily', title: 'Daily ratio history', description: 'Daily history of price-based ratios (P/E, P/B, P/S, EV/EBITDA, FCF yield, dividend yield, market cap…) recomputed every trading day with the fundamentals known at that date (point-in-time, no look-ahead). ' + 'Ideal for valuation charts and backtests. Narrow the date range or pick a few metrics to keep the output small.', schema: { ticker: symbol('US ticker.'), metrics: z.array(z.string().max(40)).max(20).optional().describe('Subset of ratio columns, e.g. ["pe","pb","fcf_yield"]. Default: all.'), start: dateSchema('From date.'), end: dateSchema('To date.'), limit: limitSchema, }, async handler(client, a) { const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/ratios/daily`, { metrics: a.metrics, from: a.start, to: a.end, limit: a.limit }); return renderResult(r, { title: `${a.ticker} daily ratios` }); }, }); export const screenFundamentals = def({ name: 'screen_fundamentals', title: 'Fundamental screener', description: 'Screen US stocks on fundamental ratios. filters is a comma-separated list of `metricvalue` conditions with ops <, <=, >, >=, =, e.g. "pe<15,roe>0.15,fcf_yield>0.06,market_cap>1e9". ' + 'Ratios are decimals (0.15 = 15 %). sort like "fcf_yield:desc". Optional as_of date screens with the data known on that day (point-in-time). Costs 2 requests of quota.', schema: { filters: z.string().min(1).max(500).describe('Conditions, e.g. "pe<15,roe>0.15". Known metrics: pe, pb, ps, ev_ebitda, fcf_yield, dividend_yield, roe, roa, gross_margin, operating_margin, net_margin, debt_to_equity, current_ratio, revenue_growth, eps_growth, market_cap, sector.'), sort: z.string().max(60).optional().describe('metric:asc|desc, e.g. "fcf_yield:desc".'), as_of: dateSchema('Point-in-time date YYYY-MM-DD (default: today).'), columns: z.array(z.string().max(40)).max(30).optional().describe('Extra columns to return besides the filtered metrics.'), limit: z.number().int().min(1).max(1000).default(50).describe('Max companies (default 50).'), }, async handler(client, a) { const r = await client.get('/v1/fundamentals/screener', { filters: a.filters, sort: a.sort, as_of: a.as_of, columns: a.columns, limit: a.limit }); return renderResult(r, { title: `Screener: ${a.filters}${a.sort ? ` sorted by ${a.sort}` : ''}` }); }, }); export const getFilings = def({ name: 'get_filings', title: 'SEC filings', description: 'SEC EDGAR filings of a US company (10-K, 10-Q, 8-K, 4, 13F, S-1, DEF 14A…): form type, filed date, period, accession number, primary document URL and a short description. ' + 'Filter by forms and dates. For live notifications use subscribe_filings.', schema: { ticker: symbol('US ticker or CIK, e.g. AAPL.'), forms: z.array(z.string().max(12)).max(20).optional().describe('Form types to keep, e.g. ["10-K","10-Q","8-K"].'), start: dateSchema('Filed on/after this date.'), end: dateSchema('Filed on/before this date.'), limit: z.number().int().min(1).max(1000).default(50).describe('Max filings (default 50).'), }, async handler(client, a) { const r = await client.get(`/v1/fundamentals/${encodeURIComponent(a.ticker)}/filings`, { forms: a.forms, from: a.start, to: a.end, limit: a.limit }); return renderResult(r, { title: `${a.ticker} filings${a.forms ? ` (${a.forms.join(', ')})` : ''}` }); }, }); export const subscribeFilingsTool = def({ name: 'subscribe_filings', title: 'Listen to the live filings stream', description: 'Open the websocket wss://…/v1/stream, subscribe to the `filings` channel (new SEC filings as they are published) and return the messages received within a bounded window. ' + `The call blocks until max_messages messages arrive or timeout_seconds elapse (max ${MAX_TIMEOUT_MS / 1000}s), then closes the socket — it does NOT stay subscribed between calls. ` + 'Outside SEC business hours (roughly 06:00-22:00 US/Eastern, weekdays) expect zero messages. Requires Node >= 22 (global WebSocket) or Node 20 with --experimental-websocket.', schema: { tickers: z.union([z.literal('all'), z.array(symbol('Ticker')).min(1).max(50)]).default('all').describe('Tickers to watch, or "all".'), forms: z.array(z.string().max(12)).max(20).optional().describe('Form types to keep, e.g. ["8-K","4"]. Default: all forms.'), max_messages: z.number().int().min(1).max(MAX_MESSAGES).default(10).describe('Stop after this many messages (default 10).'), timeout_seconds: z.number().int().min(1).max(MAX_TIMEOUT_MS / 1000).default(30).describe('Stop after this many seconds (default 30, max 120).'), }, async handler(client, a, ctx) { const opts: SubscribeOptions = { url: client.wsUrl, channel: 'filings', tickers: a.tickers, forms: a.forms, apiKey: client.apiKey, maxMessages: a.max_messages, timeoutMs: a.timeout_seconds * 1000, WebSocketImpl: ctx?.WebSocketImpl, }; const res = await subscribeFilings(opts); const head = `Filings stream: ${res.messages.length} message(s) in ${(res.elapsed_ms / 1000).toFixed(1)}s, stopped because ${res.stopped_because}${res.error ? ` (${res.error})` : ''}.`; const note = res.messages.length === 0 && res.stopped_because === 'timeout' ? ' No filing was published during the window — this is normal outside EDGAR hours; increase timeout_seconds or widen tickers/forms.' : ''; return [head + note, JSON.stringify({ url: res.url, subscription: res.subscription, messages: res.messages })].join('\n'); }, }); export const TOOLS: ToolDef[] = [ searchSymbols, getBars, getFuturesContracts, getFuturesChain, getContinuous, getTermStructure, getOptionsChain, getCoverage, getFinancialStatements, getRatios, getRatiosDaily, screenFundamentals, getFilings, subscribeFilingsTool, ]; export const TOOL_NAMES = TOOLS.map((t) => t.name); /** Run a tool by name with schema validation; used by tests and by the MCP wrapper. */ export async function runTool(name: string, client: HfmdClient, rawArgs: unknown, ctx?: ToolContext): Promise<{ text: string; isError: boolean }> { const tool = TOOLS.find((t) => t.name === name); if (!tool) return { text: `Unknown tool ${name}. Available: ${TOOL_NAMES.join(', ')}`, isError: true }; const parsed = z.object(tool.schema).safeParse(rawArgs ?? {}); if (!parsed.success) { const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '); return { text: `Invalid arguments for ${name}: ${issues}`, isError: true }; } try { return { text: await tool.handler(client, parsed.data, ctx), isError: false }; } catch (e) { if (e instanceof HfmdError) return { text: e.describe(), isError: true }; const msg = e instanceof Error ? e.message : String(e); return { text: `${name} failed: ${msg}`, isError: true }; } }