Jaspers Hub

jaspers / earningscall

pluginOfficialReviewed★ 0↓ 6

EarningsCall Transcripts & Calendar: Shows a company's earnings call transcripts and the daily earnings calendar from EarningsCall.biz in a single panel view.

9.2 KB
1import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2import { z } from 'zod'
3import {
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
30const DAY_MS = 86_400_000
31const LIST_MS = 15 * 60_000
32const TIMEOUT_MS = 60_000
33/** Transcripts kept at once; the oldest goes first. */
34const TRANSCRIPTS_KEPT = 40
35
36const 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
42export 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. */
49class Refusal extends Error {}
50
51export 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}