import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' import { calendarResult, calendarUrl, callsResult, eventsUrl, failure, findCompany, latestCall, localDay, parseSymbols, readCalendar, readEvents, scrub, symbolsUrl, transcriptResult, transcriptUrl, type Company, type ToolResult, } from './earningscall.ts' // EarningsCall.biz as an MCP server: three tools over its REST API, for the plugin's connection, // earningscall/server. The key comes in from the connection, which keeps it sealed; it goes out in // the query, which is the only place EarningsCall reads it, and is taken back out of anything said // about a request. A Starter key allows ten calls a minute, so what cannot change is kept for the // life of the process (the symbol list for a day, a transcript for good) and the rest for a quarter // of an hour. const DAY_MS = 86_400_000 const LIST_MS = 15 * 60_000 const TIMEOUT_MS = 60_000 /** Transcripts kept at once; the oldest goes first. */ const TRANSCRIPTS_KEPT = 40 const INSTRUCTIONS = [ 'Earnings calls from EarningsCall.biz for about 9,000 companies, most going back to 2020.', 'list_calls gives a company’s calls with their fiscal year and quarter; get_transcript gives one call’s transcript, the latest by default; get_calendar gives the calls held on a day.', 'Transcripts are machine transcribed and plain: no speaker names, paragraph breaks inferred, and the questions-and-answers part marked where it could be found. One runs 40 to 60 thousand characters.', ].join(' ') export interface ServerOptions { apikey: string fetch?: typeof fetch now?: () => Date } /** A refusal worded for the caller. Anything else thrown is reported by its message, with the key taken out. */ class Refusal extends Error {} export function createServer({ apikey, fetch: send = fetch, now = () => new Date() }: ServerOptions): McpServer { const server = new McpServer({ name: 'earningscall', version: '1.0.0' }, { instructions: INSTRUCTIONS }) const cache = new Map }>() /** One shared answer per key while it is good, so two askers at once cost one request; a failure is not kept. */ function remember(key: string, ttl: number, load: () => Promise): Promise { const hit = cache.get(key) if (hit && Date.now() - hit.at < hit.ttl) return hit.value as Promise const value = load() cache.set(key, { at: Date.now(), ttl, value }) value.catch(() => { if (cache.get(key)?.value === value) cache.delete(key) }) const transcripts = [...cache.keys()].filter((k) => k.startsWith('transcript:')) if (transcripts.length > TRANSCRIPTS_KEPT) cache.delete(transcripts[0]!) return value } async function get(url: string, what: string): Promise { if (!apikey) throw new Refusal('No EarningsCall API key: set it in Connections.') let response: Response try { response = await send(url, { headers: { accept: 'application/json, text/plain' }, signal: AbortSignal.timeout(TIMEOUT_MS) }) } catch (err) { const cause = err instanceof Error && err.cause instanceof Error ? ` (${err.cause.message})` : '' throw new Refusal(`EarningsCall could not be reached asking for ${what}: ${err instanceof Error ? err.message : String(err)}${cause}`) } return response } /** The JSON EarningsCall answered, or `missing` for a 404 when there is one. */ async function getJson(url: string, what: string, missing?: unknown): Promise { const response = await get(url, what) if (response.status === 404 && missing !== undefined) return missing if (!response.ok) throw new Refusal(failure(response.status, what, response.headers)) try { return await response.json() } catch { throw new Refusal(`EarningsCall answered ${what} with something that is not JSON.`) } } const symbols = (): Promise => remember('symbols', DAY_MS, async () => { const response = await get(symbolsUrl(apikey), 'the list of companies') if (!response.ok) throw new Refusal(failure(response.status, 'the list of companies', response.headers)) return parseSymbols(await response.text()) }) async function company(symbol: string, exchange: string | undefined): Promise { const found = findCompany(await symbols(), symbol, exchange) if (found) return found const name = `${symbol.trim().toUpperCase()}${exchange ? ` on ${exchange.trim().toUpperCase()}` : ''}` throw new Refusal(`EarningsCall does not cover ${name}.`) } const events = (c: Company): Promise> => remember(`events:${c.exchange}:${c.symbol}`, LIST_MS, async () => readEvents(await getJson(eventsUrl(apikey, c), `calls for ${c.exchange}:${c.symbol}`))) const transcript = (c: Company, year: number, quarter: number): Promise<{ text: string; date: string | null }> => remember(`transcript:${c.exchange}:${c.symbol}:${year}:${quarter}`, Infinity, async () => { const what = `the Q${quarter} ${year} transcript for ${c.exchange}:${c.symbol}` const body = (await getJson(transcriptUrl(apikey, c, year, quarter), what)) as { text?: unknown; event?: { conference_date?: unknown } } | null if (typeof body?.text !== 'string') throw new Refusal(`EarningsCall answered without ${what}.`) const date = body.event?.conference_date return { text: body.text, date: typeof date === 'string' ? date : null } }) /** Runs a tool, turning whatever it throws into an error result the caller can read. */ const answer = (run: (args: A) => Promise) => async (args: A): Promise => { try { return await run(args) } catch (err) { const message = err instanceof Error ? err.message : String(err) return { content: [{ type: 'text', text: scrub(message, apikey) }], isError: true } } } const Ticker = z.string().describe('The ticker, like AAPL.') const Exchange = z .string() .optional() .describe('Only when the ticker trades on more than one exchange: NYSE, NASDAQ, AMEX, TSX, TSXV, OTC, LSE, CBOE, or STO.') server.registerTool( 'list_calls', { description: 'Lists a company’s earnings calls on EarningsCall.biz, newest first: the fiscal year and quarter EarningsCall labels each call with, when it was held, and whether its transcript is out. Use it to find the year and quarter get_transcript takes.', inputSchema: { symbol: Ticker, exchange: Exchange }, }, answer(async ({ symbol, exchange }: { symbol: string; exchange?: string }) => { const c = await company(symbol, exchange) const { company: name, calls } = await events(c) return callsResult({ ...c, name: name ?? c.name }, calls) }), ) server.registerTool( 'get_transcript', { description: 'The transcript of one earnings call from EarningsCall.biz: machine transcribed, plain text without speaker names, in paragraphs with the questions-and-answers part marked. Give year and quarter as list_calls shows them (the company’s fiscal year), or neither for the latest call. A transcript runs 40 to 60 thousand characters.', inputSchema: { symbol: Ticker, exchange: Exchange, year: z.number().int().optional().describe('Fiscal year, with quarter.'), quarter: z.number().int().min(1).max(4).optional().describe('Fiscal quarter, 1 to 4, with year.'), }, }, answer(async ({ symbol, exchange, year, quarter }: { symbol: string; exchange?: string; year?: number; quarter?: number }) => { if ((year === undefined) !== (quarter === undefined)) throw new Refusal('Give both year and quarter, or neither for the latest call.') const c = await company(symbol, exchange) let call: { year: number; quarter: number; date: string | null } let name = c.name if (year !== undefined && quarter !== undefined) { call = { year, quarter, date: null } } else { const listed = await events(c) const latest = latestCall(listed.calls) if (!latest) throw new Refusal(`EarningsCall has no published calls for ${c.exchange}:${c.symbol}.`) call = latest name = listed.company ?? name } const { text, date } = await transcript(c, call.year, call.quarter) return transcriptResult({ ...c, name }, { ...call, date: date ?? call.date }, text) }), ) server.registerTool( 'get_calendar', { description: 'The earnings calls held on one day across the companies EarningsCall.biz covers: when, ticker, company, fiscal quarter, and whether the transcript is ready. date is YYYY-MM-DD, today by default.', inputSchema: { date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() .describe('The day, YYYY-MM-DD. Today when left out.'), }, }, answer(async ({ date }: { date?: string }) => { const day = date ?? localDay(now()) // EarningsCall answers a day with no calls with a 404. const rows = await remember(`calendar:${day}`, LIST_MS, async () => readCalendar(await getJson(calendarUrl(apikey, day), `the calendar for ${day}`, []))) return calendarResult(day, rows) }), ) return server }