Jaspers Hub

jaspers / yfinance

pluginOfficialReviewed★ 0↓ 11

yfinance: Free Yahoo Finance market data (quotes, history, fundamentals, options, news, movers) via a bundled MCP server, with five views.

19 KB
1import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2import { z } from 'zod'
3import {
4 analystsResult,
5 earningsResult,
6 fundResult,
7 holdersResult,
8 MODULES,
9 profileResult,
10 readAnalysts,
11 readEarnings,
12 readFund,
13 readHolders,
14 readProfile,
15 readStatistics,
16 statisticsResult,
17} from './company.ts'
18import { FREQUENCIES, financialsResult, readFinancials, STATEMENTS, timeseriesQuery } from './financials.ts'
19import { historyResult, INTERVALS, isIntraday, RANGES, readHistory } from './history.ts'
20import { expirationSeconds, nearestExpirations, optionsResult, readExpirations, readOptions, SIDES } from './options.ts'
21import {
22 marketSummaryResult,
23 MOVER_LISTS,
24 moversResult,
25 newsResult,
26 quotesResult,
27 readMarketSummary,
28 readNews,
29 readQuotes,
30 readScreen,
31 readSearch,
32 readTrending,
33 searchResult,
34 TRENDING_DESCRIPTION,
35 TRENDING_TITLE,
36 type MoverList,
37 type Quote,
38} from './quotes.ts'
39import type { ToolResult } from './result.ts'
40import { createYahoo, YahooError, type Query, type YahooOptions } from './yahoo.ts'
41
42// Yahoo Finance as an MCP server: fourteen read-only tools for the plugin's connection,
43// yfinance/server, and for anything else that speaks MCP. No key. Each tool answers text for the model
44// and structuredContent with rows for the views.
45
46export const VERSION = '1.0.0'
47
48const SECOND = 1000
49const MINUTE = 60 * SECOND
50const HOUR = 60 * MINUTE
51const DAY_SECONDS = 86_400
52
53const INSTRUCTIONS = [
54 'Market data, with no official API: quotes (some exchanges delayed), price history, company profiles, key statistics, financial statements, earnings and estimates, analyst ratings, holders, option chains, news, a market summary, market movers, and fund holdings. No key is needed.',
55 'Symbols: AAPL, BRK-B, SHOP.TO, 7203.T, ^GSPC (index), ES=F (future), EURUSD=X (currency), BTC-USD (crypto). search finds one from a name.',
56 'In structured results, a field whose name ends in Percent is in percent (7.97 is 7.97%). The data can be wrong or late, and requests can be limited.',
57].join(' ')
58
59/** A refusal worded for the caller. */
60class Refusal extends Error {}
61
62const SYMBOL = /^[A-Z0-9.^=&-]{1,32}$/
63
64function symbolOf(raw: string): string {
65 const symbol = raw.trim().toUpperCase()
66 if (!SYMBOL.test(symbol)) throw new Refusal(`"${raw.trim().slice(0, 40)}" is not a symbol. Find one with search.`)
67 return symbol
68}
69
70function dayStart(day: string): number {
71 return Date.parse(`${day}T00:00:00Z`) / 1000
72}
73
74type Failure = { content: { type: 'text'; text: string }[]; isError: true }
75
76/** Runs a tool, turning whatever it throws into an error result the caller can read. */
77function answer<A>(run: (args: A) => Promise<ToolResult>): (args: A) => Promise<ToolResult | Failure> {
78 return async (args) => {
79 try {
80 return await run(args)
81 } catch (err) {
82 const message = err instanceof Error ? err.message : String(err)
83 const text = err instanceof Refusal || err instanceof YahooError ? message : `Reading the market data answer failed: ${message}`
84 return { content: [{ type: 'text', text }], isError: true }
85 }
86 }
87}
88
89const READ_ONLY = { readOnlyHint: true, openWorldHint: true } as const
90
91export interface ServerOptions extends YahooOptions {
92 /** Milliseconds; what "today" is for date windows. */
93 now?: () => number
94}
95
96export function createServer(options: ServerOptions = {}): McpServer {
97 const now = options.now ?? Date.now
98 const yahoo = createYahoo({ ...options, now })
99 const server = new McpServer({ name: 'yfinance', version: VERSION }, { instructions: INSTRUCTIONS })
100
101 const nextMidnight = (): number => Math.ceil(now() / 1000 / DAY_SECONDS) * DAY_SECONDS
102
103 const summary = (symbol: string, modules: string, ttl: number, what: string): Promise<unknown> =>
104 yahoo.get({ path: `/v10/finance/quoteSummary/${encodeURIComponent(symbol)}`, query: { modules, formatted: false }, crumb: true, ttl, what })
105
106 const quotes = async (symbols: string[]): Promise<Quote[]> =>
107 readQuotes(
108 await yahoo.get({
109 path: '/v7/finance/quote',
110 query: { symbols: symbols.join(','), formatted: false },
111 crumb: true,
112 ttl: 15 * SECOND,
113 what: `the quote for ${symbols.join(', ')}`,
114 }),
115 )
116
117 const Symbol = z
118 .string()
119 .min(1)
120 .max(40)
121 .describe('A symbol: AAPL, BRK-B, SHOP.TO, ^GSPC (index), ES=F (future), EURUSD=X (currency), BTC-USD (crypto).')
122 const Day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
123
124 server.registerTool(
125 'search',
126 {
127 description:
128 'Finds symbols by company name, ticker, or keyword: stocks, ETFs, funds, indices, futures, currencies, and crypto, with the exchange, sector, and industry. Use it when unsure of a symbol, or for a listing on another exchange (SHOP.TO, APC.DE).',
129 inputSchema: {
130 query: z.string().min(1).max(100).describe('A name, ticker, or keyword: "apple", "nvidia", "gold".'),
131 limit: z.number().int().min(1).max(20).optional().describe('How many matches, 8 by default.'),
132 },
133 annotations: READ_ONLY,
134 },
135 answer(async ({ query, limit }: { query: string; limit?: number }) => {
136 const q = query.trim()
137 const json = await yahoo.get({
138 path: '/v1/finance/search',
139 query: { q, quotesCount: limit ?? 8, newsCount: 0, listsCount: 0, enableFuzzyQuery: false },
140 ttl: HOUR,
141 what: `a search for "${q}"`,
142 })
143 return searchResult(q, readSearch(json))
144 }),
145 )
146
147 server.registerTool(
148 'get_quote',
149 {
150 description:
151 'Live quotes for up to 50 symbols at once: price, change, day range, volume, market cap, P/E, EPS, dividend yield, 52-week range, market state, pre- or post-market price, and the average analyst rating. Some exchanges are delayed.',
152 inputSchema: { symbols: z.array(Symbol).min(1).max(50).describe('The symbols, like ["AAPL", "MSFT", "^GSPC"].') },
153 annotations: READ_ONLY,
154 },
155 answer(async ({ symbols }: { symbols: string[] }) => {
156 const asked = [...new Set(symbols.map(symbolOf))]
157 const found = await quotes(asked)
158 if (!found.length) throw new Refusal(`No quote for ${asked.join(', ')}. Check the symbol with search.`)
159 return quotesResult(found, asked)
160 }),
161 )
162
163 server.registerTool(
164 'get_price_history',
165 {
166 description:
167 'Price history: open, high, low, close, adjusted close, and volume per bar, a summary of the period, and the dividends and splits in it. Give range (1y by default) or start and end dates, and interval (1d by default). Minute bars reach back 8 days for 1m and 60 days for other minute sizes; hourly bars 730 days. The text lists the latest 400 bars.',
168 inputSchema: {
169 symbol: Symbol,
170 range: z.enum(RANGES).optional().describe('How far back, ending now: 1d, 5d, 1mo, 3mo, 6mo, ytd, 1y (default), 2y, 5y, 10y, max. Ignored when start is given.'),
171 interval: z.enum(INTERVALS).optional().describe('Bar size: 1m to 90m, 1h, 1d (default), 5d, 1wk, 1mo, 3mo.'),
172 start: Day.optional().describe('First day, YYYY-MM-DD, instead of range.'),
173 end: Day.optional().describe('Last day, YYYY-MM-DD, with start; today when left out.'),
174 prepost: z.boolean().optional().describe('Include pre- and post-market bars (intraday only).'),
175 },
176 annotations: READ_ONLY,
177 },
178 answer(
179 async ({ symbol: raw, range, interval, start, end, prepost }: { symbol: string; range?: string; interval?: string; start?: string; end?: string; prepost?: boolean }) => {
180 const symbol = symbolOf(raw)
181 if (end && !start) throw new Refusal('Give start with end, or a range.')
182 const every = interval ?? '1d'
183 const query: Query = { interval: every, includePrePost: prepost ?? false, events: 'div,splits' }
184 if (start) {
185 const from = dayStart(start)
186 const to = end ? dayStart(end) + DAY_SECONDS : nextMidnight()
187 if (!(to > from)) throw new Refusal('end has to be on or after start.')
188 query['period1'] = from
189 query['period2'] = to
190 } else {
191 query['range'] = range ?? '1y'
192 }
193 const json = await yahoo.get({
194 path: `/v8/finance/chart/${encodeURIComponent(symbol)}`,
195 query,
196 ttl: isIntraday(every) ? MINUTE : 15 * MINUTE,
197 what: `${symbol} price history`,
198 })
199 const history = readHistory(json)
200 return historyResult({ ...history, symbol: history.symbol || symbol })
201 },
202 ),
203 )
204
205 server.registerTool(
206 'get_company_profile',
207 {
208 description:
209 'A company’s profile: sector, industry, employees, headquarters, phone, website, business summary, officers with their pay, and ISS governance risk scores. For a fund, its summary.',
210 inputSchema: { symbol: Symbol },
211 annotations: READ_ONLY,
212 },
213 answer(async ({ symbol: raw }: { symbol: string }) => {
214 const symbol = symbolOf(raw)
215 return profileResult(readProfile(symbol, await summary(symbol, MODULES.profile, 6 * HOUR, `the ${symbol} profile`)))
216 }),
217 )
218
219 server.registerTool(
220 'get_key_statistics',
221 {
222 description:
223 'Key statistics: valuation (market cap, enterprise value, trailing and forward P/E, PEG, price/sales, price/book, EV/revenue, EV/EBITDA), margins and returns, revenue and earnings growth, cash and debt, beta and moving averages, shares, float, short interest, dividends, and fiscal dates.',
224 inputSchema: { symbol: Symbol },
225 annotations: READ_ONLY,
226 },
227 answer(async ({ symbol: raw }: { symbol: string }) => {
228 const symbol = symbolOf(raw)
229 return statisticsResult(readStatistics(symbol, await summary(symbol, MODULES.statistics, 5 * MINUTE, `${symbol} key statistics`)))
230 }),
231 )
232
233 server.registerTool(
234 'get_financials',
235 {
236 description:
237 'A company’s financial statements: the income statement, balance sheet, or cash flow statement, annual (about four years), quarterly (about five quarters), or trailing twelve months (income and cash flow). Values are as reported, in the statement’s currency, one column per period, latest first.',
238 inputSchema: {
239 symbol: Symbol,
240 statement: z.enum(STATEMENTS).optional().describe('income (default), balance, or cashflow.'),
241 frequency: z.enum(FREQUENCIES).optional().describe('annual (default), quarterly, or trailing.'),
242 },
243 annotations: READ_ONLY,
244 },
245 answer(async ({ symbol: raw, statement, frequency }: { symbol: string; statement?: (typeof STATEMENTS)[number]; frequency?: (typeof FREQUENCIES)[number] }) => {
246 const symbol = symbolOf(raw)
247 const which = statement ?? 'income'
248 const every = frequency ?? 'annual'
249 if (which === 'balance' && every === 'trailing') return financialsResult(readFinancials(symbol, which, every, null))
250 const json = await yahoo.get({
251 path: `/ws/fundamentals-timeseries/v1/finance/timeseries/${encodeURIComponent(symbol)}`,
252 query: timeseriesQuery(symbol, which, every, now()),
253 ttl: 6 * HOUR,
254 what: `the ${symbol} ${which} statement`,
255 })
256 return financialsResult(readFinancials(symbol, which, every, json))
257 }),
258 )
259
260 server.registerTool(
261 'get_earnings',
262 {
263 description:
264 'Earnings: the next report date with its EPS and revenue estimates, the last four quarters’ EPS against estimates, analyst estimates for this and next quarter and fiscal year (EPS, revenue, growth, trend, revisions), dividend dates, and annual revenue and earnings.',
265 inputSchema: { symbol: Symbol },
266 annotations: READ_ONLY,
267 },
268 answer(async ({ symbol: raw }: { symbol: string }) => {
269 const symbol = symbolOf(raw)
270 return earningsResult(readEarnings(symbol, await summary(symbol, MODULES.earnings, HOUR, `${symbol} earnings`)))
271 }),
272 )
273
274 server.registerTool(
275 'get_analyst_ratings',
276 {
277 description:
278 'Analyst views: price targets (low, mean, median, high), the consensus rating, the monthly count of strong buy to strong sell ratings, and the latest upgrades, downgrades, and target changes by firm.',
279 inputSchema: {
280 symbol: Symbol,
281 limit: z.number().int().min(1).max(50).optional().describe('How many rating changes, newest first; 15 by default.'),
282 },
283 annotations: READ_ONLY,
284 },
285 answer(async ({ symbol: raw, limit }: { symbol: string; limit?: number }) => {
286 const symbol = symbolOf(raw)
287 return analystsResult(readAnalysts(symbol, await summary(symbol, MODULES.analysts, HOUR, `${symbol} analyst ratings`), limit ?? 15))
288 }),
289 )
290
291 server.registerTool(
292 'get_holders',
293 {
294 description:
295 'Who owns a company: the insider and institutional share, the top institutions and funds with their stakes and changes, insiders and their holdings, recent insider purchases and sales, and six months of net insider buying.',
296 inputSchema: {
297 symbol: Symbol,
298 limit: z.number().int().min(1).max(25).optional().describe('How many of each list; 10 by default.'),
299 },
300 annotations: READ_ONLY,
301 },
302 answer(async ({ symbol: raw, limit }: { symbol: string; limit?: number }) => {
303 const symbol = symbolOf(raw)
304 return holdersResult(readHolders(symbol, await summary(symbol, MODULES.holders, 6 * HOUR, `${symbol} holders`), limit ?? 10))
305 }),
306 )
307
308 server.registerTool(
309 'get_options_chain',
310 {
311 description:
312 'An option chain: the expiration dates, and for one expiration (the nearest by default) the calls and puts at the strikes nearest the price, with last, bid, ask, volume, open interest, implied volatility, and whether in the money; the expiration’s totals and put/call ratios; and the at-the-money strike. Prices can be delayed.',
313 inputSchema: {
314 symbol: Symbol,
315 expiration: Day.optional().describe('The expiration day, YYYY-MM-DD, one of those listed; the nearest when left out.'),
316 strikes: z.number().int().min(1).max(50).optional().describe('How many strikes on each side of the price; 10 by default.'),
317 side: z.enum(SIDES).optional().describe('both (default), calls, or puts, for the text.'),
318 },
319 annotations: READ_ONLY,
320 },
321 answer(async ({ symbol: raw, expiration, strikes, side }: { symbol: string; expiration?: string; strikes?: number; side?: (typeof SIDES)[number] }) => {
322 const symbol = symbolOf(raw)
323 const path = `/v7/finance/options/${encodeURIComponent(symbol)}`
324 const each = strikes ?? 10
325 let json = await yahoo.get({ path, crumb: true, ttl: MINUTE, what: `${symbol} options` })
326 if (expiration) {
327 const listed = readExpirations(json, symbol)
328 if (!listed.length) throw new Refusal(`No options are listed for ${symbol}.`)
329 if (!listed.includes(expiration)) {
330 throw new Refusal(`${symbol} has no options expiring ${expiration}. The nearest expirations: ${nearestExpirations(listed, expiration).join(', ')}.`)
331 }
332 if (readOptions(json, each, symbol).expiration !== expiration) {
333 json = await yahoo.get({ path, query: { date: expirationSeconds(expiration) }, crumb: true, ttl: MINUTE, what: `${symbol} options expiring ${expiration}` })
334 }
335 }
336 return optionsResult(readOptions(json, each, symbol), side ?? 'both')
337 }),
338 )
339
340 server.registerTool(
341 'get_news',
342 {
343 description: 'Recent news for a ticker or a topic, newest first: headline, publisher, time, link, and the tickers each story mentions.',
344 inputSchema: {
345 query: z.string().min(1).max(100).describe('A ticker (AAPL) or a topic ("rate cut").'),
346 limit: z.number().int().min(1).max(20).optional().describe('How many stories; 10 by default.'),
347 },
348 annotations: READ_ONLY,
349 },
350 answer(async ({ query, limit }: { query: string; limit?: number }) => {
351 const q = query.trim()
352 const json = await yahoo.get({
353 path: '/v1/finance/search',
354 query: { q, quotesCount: 0, newsCount: limit ?? 10, listsCount: 0 },
355 ttl: 5 * MINUTE,
356 what: `news for "${q}"`,
357 })
358 return newsResult(q, readNews(json))
359 }),
360 )
361
362 server.registerTool(
363 'get_market_summary',
364 {
365 description:
366 'A snapshot of the markets: the main index futures or indices, oil, gold, silver, the 10-year Treasury yield, the VIX, major currencies, and crypto, each with price and change. region is a two-letter market: US by default, or GB, DE, FR, IN, HK, JP, AU, CA, and others.',
367 inputSchema: { region: z.string().regex(/^[A-Za-z]{2}$/).optional().describe('Two letters, US by default.') },
368 annotations: READ_ONLY,
369 },
370 answer(async ({ region }: { region?: string }) => {
371 const where = (region ?? 'US').toUpperCase()
372 const json = await yahoo.get({
373 path: '/v6/finance/quote/marketSummary',
374 query: { lang: 'en-US', region: where, formatted: false },
375 ttl: 30 * SECOND,
376 what: `the ${where} market summary`,
377 })
378 return marketSummaryResult(where, readMarketSummary(json))
379 }),
380 )
381
382 const lists = Object.keys(MOVER_LISTS) as [MoverList, ...MoverList[]]
383
384 server.registerTool(
385 'get_market_movers',
386 {
387 description: `Lists of US stocks, each with price, change, volume, and market cap: today's gainers, losers, and most active, the trending tickers, and saved screens (${lists
388 .filter((l) => !['gainers', 'losers', 'most_active', 'trending'].includes(l))
389 .join(', ')}).`,
390 inputSchema: {
391 list: z.enum(lists).optional().describe('gainers (default), losers, most_active, trending, or a saved screen.'),
392 limit: z.number().int().min(1).max(100).optional().describe('How many; 25 by default.'),
393 },
394 annotations: READ_ONLY,
395 },
396 answer(async ({ list, limit }: { list?: MoverList; limit?: number }) => {
397 const which = list ?? 'gainers'
398 const count = limit ?? 25
399 const saved = MOVER_LISTS[which]
400 if (saved) {
401 const json = await yahoo.get({
402 path: '/v1/finance/screener/predefined/saved',
403 query: { scrIds: saved, count, formatted: false },
404 ttl: MINUTE,
405 what: `the ${which.replace(/_/g, ' ')} list`,
406 })
407 return moversResult(which, readScreen(json))
408 }
409 const trending = readTrending(await yahoo.get({ path: '/v1/finance/trending/US', query: { count }, ttl: MINUTE, what: 'trending tickers' })).slice(0, count)
410 const found = trending.length ? await quotes(trending) : []
411 const bySymbol = new Map(found.map((q) => [q.symbol, q]))
412 const rows = trending.map((s) => bySymbol.get(s)).filter((q): q is Quote => q !== undefined)
413 return moversResult(which, { title: TRENDING_TITLE, description: TRENDING_DESCRIPTION, total: trending.length, rows })
414 }),
415 )
416
417 server.registerTool(
418 'get_fund_holdings',
419 {
420 description:
421 'An ETF’s or mutual fund’s profile: family, category, net assets, expense ratio, turnover, yield, NAV, returns, asset mix, sector weights, and top holdings.',
422 inputSchema: { symbol: Symbol },
423 annotations: READ_ONLY,
424 },
425 answer(async ({ symbol: raw }: { symbol: string }) => {
426 const symbol = symbolOf(raw)
427 return fundResult(readFund(symbol, await summary(symbol, MODULES.fund, 6 * HOUR, `the ${symbol} fund profile`)))
428 }),
429 )
430
431 return server
432}