EarningsCall Transcripts & Calendar: Shows a company's earnings call transcripts and the daily earnings calendar from EarningsCall.biz in a single panel view.
| 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' |
| 2 | import { z } from 'zod' |
| 3 | import { |
| 4 | calendarResult, |
| 5 | calendarUrl, |
| 6 | callsResult, |
| 7 | eventsUrl, |
| 8 | failure, |
| 9 | findCompany, |
| 10 | latestCall, |
| 11 | localDay, |
| 12 | parseSymbols, |
| 13 | readCalendar, |
| 14 | readEvents, |
| 15 | scrub, |
| 16 | symbolsUrl, |
| 17 | transcriptResult, |
| 18 | transcriptUrl, |
| 19 | type Company, |
| 20 | type ToolResult, |
| 21 | } from './earningscall.ts' |
| 22 | |
| 23 | // EarningsCall.biz as an MCP server: three tools over its REST API, for the plugin's connection, |
| 24 | // earningscall/server. The key comes in from the connection, which keeps it sealed; it goes out in |
| 25 | // the query, which is the only place EarningsCall reads it, and is taken back out of anything said |
| 26 | // about a request. A Starter key allows ten calls a minute, so what cannot change is kept for the |
| 27 | // life of the process (the symbol list for a day, a transcript for good) and the rest for a quarter |
| 28 | // of an hour. |
| 29 | |
| 30 | const DAY_MS = 86_400_000 |
| 31 | const LIST_MS = 15 * 60_000 |
| 32 | const TIMEOUT_MS = 60_000 |
| 33 | /** Transcripts kept at once; the oldest goes first. */ |
| 34 | const TRANSCRIPTS_KEPT = 40 |
| 35 | |
| 36 | const INSTRUCTIONS = [ |
| 37 | 'Earnings calls from EarningsCall.biz for about 9,000 companies, most going back to 2020.', |
| 38 | '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.', |
| 39 | '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.', |
| 40 | ].join(' ') |
| 41 | |
| 42 | export interface ServerOptions { |
| 43 | apikey: string |
| 44 | fetch?: typeof fetch |
| 45 | now?: () => Date |
| 46 | } |
| 47 | |
| 48 | /** A refusal worded for the caller. Anything else thrown is reported by its message, with the key taken out. */ |
| 49 | class Refusal extends Error {} |
| 50 | |
| 51 | export function createServer({ apikey, fetch: send = fetch, now = () => new Date() }: ServerOptions): McpServer { |
| 52 | const server = new McpServer({ name: 'earningscall', version: '1.0.0' }, { instructions: INSTRUCTIONS }) |
| 53 | const cache = new Map<string, { at: number; ttl: number; value: Promise<unknown> }>() |
| 54 | |
| 55 | /** One shared answer per key while it is good, so two askers at once cost one request; a failure is not kept. */ |
| 56 | function remember<T>(key: string, ttl: number, load: () => Promise<T>): Promise<T> { |
| 57 | const hit = cache.get(key) |
| 58 | if (hit && Date.now() - hit.at < hit.ttl) return hit.value as Promise<T> |
| 59 | const value = load() |
| 60 | cache.set(key, { at: Date.now(), ttl, value }) |
| 61 | value.catch(() => { |
| 62 | if (cache.get(key)?.value === value) cache.delete(key) |
| 63 | }) |
| 64 | const transcripts = [...cache.keys()].filter((k) => k.startsWith('transcript:')) |
| 65 | if (transcripts.length > TRANSCRIPTS_KEPT) cache.delete(transcripts[0]!) |
| 66 | return value |
| 67 | } |
| 68 | |
| 69 | async function get(url: string, what: string): Promise<Response> { |
| 70 | if (!apikey) throw new Refusal('No EarningsCall API key: set it in Connections.') |
| 71 | let response: Response |
| 72 | try { |
| 73 | response = await send(url, { headers: { accept: 'application/json, text/plain' }, signal: AbortSignal.timeout(TIMEOUT_MS) }) |
| 74 | } catch (err) { |
| 75 | const cause = err instanceof Error && err.cause instanceof Error ? ` (${err.cause.message})` : '' |
| 76 | throw new Refusal(`EarningsCall could not be reached asking for ${what}: ${err instanceof Error ? err.message : String(err)}${cause}`) |
| 77 | } |
| 78 | return response |
| 79 | } |
| 80 | |
| 81 | /** The JSON EarningsCall answered, or `missing` for a 404 when there is one. */ |
| 82 | async function getJson(url: string, what: string, missing?: unknown): Promise<unknown> { |
| 83 | const response = await get(url, what) |
| 84 | if (response.status === 404 && missing !== undefined) return missing |
| 85 | if (!response.ok) throw new Refusal(failure(response.status, what, response.headers)) |
| 86 | try { |
| 87 | return await response.json() |
| 88 | } catch { |
| 89 | throw new Refusal(`EarningsCall answered ${what} with something that is not JSON.`) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | const symbols = (): Promise<Company[]> => |
| 94 | remember('symbols', DAY_MS, async () => { |
| 95 | const response = await get(symbolsUrl(apikey), 'the list of companies') |
| 96 | if (!response.ok) throw new Refusal(failure(response.status, 'the list of companies', response.headers)) |
| 97 | return parseSymbols(await response.text()) |
| 98 | }) |
| 99 | |
| 100 | async function company(symbol: string, exchange: string | undefined): Promise<Company> { |
| 101 | const found = findCompany(await symbols(), symbol, exchange) |
| 102 | if (found) return found |
| 103 | const name = `${symbol.trim().toUpperCase()}${exchange ? ` on ${exchange.trim().toUpperCase()}` : ''}` |
| 104 | throw new Refusal(`EarningsCall does not cover ${name}.`) |
| 105 | } |
| 106 | |
| 107 | const events = (c: Company): Promise<ReturnType<typeof readEvents>> => |
| 108 | remember(`events:${c.exchange}:${c.symbol}`, LIST_MS, async () => readEvents(await getJson(eventsUrl(apikey, c), `calls for ${c.exchange}:${c.symbol}`))) |
| 109 | |
| 110 | const transcript = (c: Company, year: number, quarter: number): Promise<{ text: string; date: string | null }> => |
| 111 | remember(`transcript:${c.exchange}:${c.symbol}:${year}:${quarter}`, Infinity, async () => { |
| 112 | const what = `the Q${quarter} ${year} transcript for ${c.exchange}:${c.symbol}` |
| 113 | const body = (await getJson(transcriptUrl(apikey, c, year, quarter), what)) as { text?: unknown; event?: { conference_date?: unknown } } | null |
| 114 | if (typeof body?.text !== 'string') throw new Refusal(`EarningsCall answered without ${what}.`) |
| 115 | const date = body.event?.conference_date |
| 116 | return { text: body.text, date: typeof date === 'string' ? date : null } |
| 117 | }) |
| 118 | |
| 119 | /** Runs a tool, turning whatever it throws into an error result the caller can read. */ |
| 120 | const answer = |
| 121 | <A>(run: (args: A) => Promise<ToolResult>) => |
| 122 | async (args: A): Promise<ToolResult | { content: { type: 'text'; text: string }[]; isError: true }> => { |
| 123 | try { |
| 124 | return await run(args) |
| 125 | } catch (err) { |
| 126 | const message = err instanceof Error ? err.message : String(err) |
| 127 | return { content: [{ type: 'text', text: scrub(message, apikey) }], isError: true } |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | const Ticker = z.string().describe('The ticker, like AAPL.') |
| 132 | const Exchange = z |
| 133 | .string() |
| 134 | .optional() |
| 135 | .describe('Only when the ticker trades on more than one exchange: NYSE, NASDAQ, AMEX, TSX, TSXV, OTC, LSE, CBOE, or STO.') |
| 136 | |
| 137 | server.registerTool( |
| 138 | 'list_calls', |
| 139 | { |
| 140 | description: |
| 141 | '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.', |
| 142 | inputSchema: { symbol: Ticker, exchange: Exchange }, |
| 143 | }, |
| 144 | answer(async ({ symbol, exchange }: { symbol: string; exchange?: string }) => { |
| 145 | const c = await company(symbol, exchange) |
| 146 | const { company: name, calls } = await events(c) |
| 147 | return callsResult({ ...c, name: name ?? c.name }, calls) |
| 148 | }), |
| 149 | ) |
| 150 | |
| 151 | server.registerTool( |
| 152 | 'get_transcript', |
| 153 | { |
| 154 | description: |
| 155 | '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.', |
| 156 | inputSchema: { |
| 157 | symbol: Ticker, |
| 158 | exchange: Exchange, |
| 159 | year: z.number().int().optional().describe('Fiscal year, with quarter.'), |
| 160 | quarter: z.number().int().min(1).max(4).optional().describe('Fiscal quarter, 1 to 4, with year.'), |
| 161 | }, |
| 162 | }, |
| 163 | answer(async ({ symbol, exchange, year, quarter }: { symbol: string; exchange?: string; year?: number; quarter?: number }) => { |
| 164 | if ((year === undefined) !== (quarter === undefined)) throw new Refusal('Give both year and quarter, or neither for the latest call.') |
| 165 | const c = await company(symbol, exchange) |
| 166 | let call: { year: number; quarter: number; date: string | null } |
| 167 | let name = c.name |
| 168 | if (year !== undefined && quarter !== undefined) { |
| 169 | call = { year, quarter, date: null } |
| 170 | } else { |
| 171 | const listed = await events(c) |
| 172 | const latest = latestCall(listed.calls) |
| 173 | if (!latest) throw new Refusal(`EarningsCall has no published calls for ${c.exchange}:${c.symbol}.`) |
| 174 | call = latest |
| 175 | name = listed.company ?? name |
| 176 | } |
| 177 | const { text, date } = await transcript(c, call.year, call.quarter) |
| 178 | return transcriptResult({ ...c, name }, { ...call, date: date ?? call.date }, text) |
| 179 | }), |
| 180 | ) |
| 181 | |
| 182 | server.registerTool( |
| 183 | 'get_calendar', |
| 184 | { |
| 185 | description: |
| 186 | '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.', |
| 187 | inputSchema: { |
| 188 | date: z |
| 189 | .string() |
| 190 | .regex(/^\d{4}-\d{2}-\d{2}$/) |
| 191 | .optional() |
| 192 | .describe('The day, YYYY-MM-DD. Today when left out.'), |
| 193 | }, |
| 194 | }, |
| 195 | answer(async ({ date }: { date?: string }) => { |
| 196 | const day = date ?? localDay(now()) |
| 197 | // EarningsCall answers a day with no calls with a 404. |
| 198 | const rows = await remember(`calendar:${day}`, LIST_MS, async () => readCalendar(await getJson(calendarUrl(apikey, day), `the calendar for ${day}`, []))) |
| 199 | return calendarResult(day, rows) |
| 200 | }), |
| 201 | ) |
| 202 | |
| 203 | return server |
| 204 | } |