pgurazada1 commited on
Commit
5ac0dea
·
verified ·
1 Parent(s): 3005785

Create server.py

Browse files
Files changed (1) hide show
  1. server.py +533 -0
server.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+ import asyncio
4
+ import logging
5
+ import uvicorn
6
+ import os
7
+
8
+ import pandas as pd
9
+ import yfinance as yf
10
+
11
+ from enum import Enum
12
+ from pathlib import Path
13
+
14
+ from mcp.server.fastmcp import FastMCP
15
+ rom starlette.requests import Request
16
+ from starlette.responses import PlainTextResponse, JSONResponse
17
+ from starlette.middleware.base import BaseHTTPMiddleware
18
+
19
+ # Helper to ensure outputs dir exists and return path (repo root)
20
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
21
+
22
+ # Single shared outputs folder at the repository root
23
+ OUTPUTS_DIR = _REPO_ROOT / "outputs"
24
+
25
+ # Ensure the directory exists
26
+ OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+ # Set up logging
29
+ LOGS_DIR = _REPO_ROOT / "logs"
30
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
31
+ LOG_FILE = LOGS_DIR / "yahoo_finance_server.log"
32
+ logging.basicConfig(
33
+ level=logging.INFO,
34
+ format='%(asctime)s %(levelname)s %(message)s',
35
+ handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()]
36
+ )
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Hugging Face Token Auth Middleware
40
+ class HuggingFaceTokenAuthMiddleware(BaseHTTPMiddleware):
41
+ async def dispatch(self, request: Request, call_next):
42
+ # Allow "/" and "/tools" to be public, protect everything else
43
+ if request.url.path in ["/", "/tools"]:
44
+ return await call_next(request)
45
+ # Check Authorization header
46
+ auth = request.headers.get("authorization")
47
+ if not auth or not auth.lower().startswith("bearer "):
48
+ return PlainTextResponse("Missing or invalid Authorization header (expected Bearer token)", status_code=401)
49
+ token = auth.split(" ", 1)[1].strip()
50
+ # Validate token with Hugging Face API
51
+ async with httpx.AsyncClient() as client:
52
+ resp = await client.get(
53
+ "https://huggingface.co/api/whoami-v2",
54
+ headers={"Authorization": f"Bearer {token}"}
55
+ )
56
+ if resp.status_code != 200:
57
+ return PlainTextResponse("Invalid or expired Hugging Face token", status_code=401)
58
+ hf_user_info = resp.json()
59
+ request.state.hf_user = hf_user_info
60
+ return await call_next(request)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Helper: write DataFrame to <repo>/outputs and strip any timezone info
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def _strip_tz(df: pd.DataFrame) -> pd.DataFrame:
68
+ out = df.copy()
69
+ for col in out.select_dtypes(include=["datetimetz"]).columns:
70
+ out[col] = out[col].dt.tz_localize(None)
71
+ return out
72
+
73
+ def save_df_to_csv(df, base_name):
74
+ df_clean = _strip_tz(df)
75
+ file_path = OUTPUTS_DIR / f"{base_name}.csv"
76
+ if file_path.exists():
77
+ unique_id = uuid.uuid4().hex[:8]
78
+ file_path = OUTPUTS_DIR / f"{base_name}_{unique_id}.csv"
79
+ df_clean.to_csv(file_path, index=False)
80
+ return str(file_path), list(df_clean.columns)
81
+
82
+ def save_json_to_file(data, base_name):
83
+ file_path = OUTPUTS_DIR / f"{base_name}.json"
84
+ if file_path.exists():
85
+ unique_id = uuid.uuid4().hex[:8]
86
+ file_path = OUTPUTS_DIR / f"{base_name}_{unique_id}.json"
87
+ with open(file_path, "w") as f:
88
+ json.dump(data, f, indent=2)
89
+ # Schema: for dict, top-level keys; for list, type of first element or 'list'; else type
90
+ if isinstance(data, dict):
91
+ schema = list(data.keys())
92
+ preview = {k: data[k] for k in list(data)[:PREVIEW_ROWS]}
93
+ elif isinstance(data, list):
94
+ schema = [type(data[0]).__name__] if data else ["list"]
95
+ preview = data[:PREVIEW_ROWS]
96
+ else:
97
+ schema = [type(data).__name__]
98
+ preview = data
99
+ return str(file_path), schema, preview
100
+
101
+ class FinancialType(str, Enum):
102
+ income_stmt = "income_stmt"
103
+ quarterly_income_stmt = "quarterly_income_stmt"
104
+ balance_sheet = "balance_sheet"
105
+ quarterly_balance_sheet = "quarterly_balance_sheet"
106
+ cashflow = "cashflow"
107
+ quarterly_cashflow = "quarterly_cashflow"
108
+
109
+ class HolderType(str, Enum):
110
+ major_holders = "major_holders"
111
+ institutional_holders = "institutional_holders"
112
+ mutualfund_holders = "mutualfund_holders"
113
+ insider_transactions = "insider_transactions"
114
+ insider_purchases = "insider_purchases"
115
+ insider_roster_holders = "insider_roster_holders"
116
+
117
+ class RecommendationType(str, Enum):
118
+ recommendations = "recommendations"
119
+ upgrades_downgrades = "upgrades_downgrades"
120
+
121
+ # Initialize FastMCP server
122
+ yfinance_server = FastMCP(
123
+ "yfinance",
124
+ instructions="""
125
+ # Yahoo Finance MCP Server
126
+
127
+ This server is used to get information about a given ticker symbol from yahoo finance.
128
+
129
+ Available tools:
130
+ - get_historical_stock_prices: Get historical stock prices for a given ticker symbol from yahoo finance. Include the following information: Date, Open, High, Low, Close, Volume, Adj Close.
131
+ - get_stock_info: Get stock information for a given ticker symbol from yahoo finance. Include the following information: Stock Price & Trading Info, Company Information, Financial Metrics, Earnings & Revenue, Margins & Returns, Dividends, Balance Sheet, Ownership, Analyst Coverage, Risk Metrics, Other.
132
+ - get_yahoo_finance_news: Get news for a given ticker symbol from yahoo finance.
133
+ - get_stock_actions: Get stock dividends and stock splits for a given ticker symbol from yahoo finance.
134
+ - get_financial_statement: Get financial statement for a given ticker symbol from yahoo finance. You can choose from the following financial statement types: income_stmt, quarterly_income_stmt, balance_sheet, quarterly_balance_sheet, cashflow, quarterly_cashflow.
135
+ - get_holder_info: Get holder information for a given ticker symbol from yahoo finance. You can choose from the following holder types: major_holders, institutional_holders, mutualfund_holders, insider_transactions, insider_purchases, insider_roster_holders.
136
+ - get_option_expiration_dates: Fetch the available options expiration dates for a given ticker symbol.
137
+ - get_option_chain: Fetch the option chain for a given ticker symbol, expiration date, and option type.
138
+ - get_recommendations: Get recommendations or upgrades/downgrades for a given ticker symbol from yahoo finance. You can also specify the number of months back to get upgrades/downgrades for, default is 12.
139
+ """,
140
+ )
141
+
142
+ PREVIEW_ROWS = 20
143
+
144
+ @yfinance_server.custom_route("/", methods=["GET"])
145
+ async def home(request: Request) -> PlainTextResponse:
146
+ return PlainTextResponse(
147
+ """
148
+ Yahoo Finance MCP Server
149
+ ----
150
+ This server needs a HuggingFace token for access
151
+ """
152
+ )
153
+
154
+ # --- Tool: get_historical_stock_prices ---
155
+ def get_historical_stock_prices_sync(ticker, period, interval):
156
+ logger.info(f"Called get_historical_stock_prices_sync: ticker={ticker}, period={period}, interval={interval}")
157
+ company = yf.Ticker(ticker)
158
+ if company.isin is None:
159
+ logger.error(f"Company ticker {ticker} not found.")
160
+ return json.dumps({"error": f"Company ticker {ticker} not found."})
161
+ hist_data = company.history(period=period, interval=interval)
162
+ hist_data = hist_data.reset_index(names="Date")
163
+ file_base = f"{ticker}_{period}_{interval}_historical"
164
+ file_path, schema = save_df_to_csv(hist_data, file_base)
165
+ preview_json = hist_data.head(PREVIEW_ROWS).to_json(orient="records", date_format="iso")
166
+ logger.info(f"Returning historical data for {ticker}")
167
+ return json.dumps({
168
+ "file_path": file_path,
169
+ "schema": schema,
170
+ "preview": json.loads(preview_json)
171
+ })
172
+
173
+ @yfinance_server.tool(
174
+ name="get_historical_stock_prices",
175
+ description="""Get historical stock prices for a given ticker symbol from yahoo finance. Include the following information: Date, Open, High, Low, Close, Volume, Adj Close.\nArgs:\n ticker: str\n The ticker symbol of the stock to get historical prices for, e.g. \"AAPL\"\n period : str\n Valid periods: 1d,5d,1mo,3mo,6mo,1y,2y,5y,10y,ytd,max\n Either Use period parameter or use start and end\n Default is \"1mo\"\n interval : str\n Valid intervals: 1m,2m,5m,15m,30m,60m,90m,1h,1d,5d,1wk,1mo,3mo\n Intraday data cannot extend last 60 days\n Default is \"1d\"\n""",
176
+ )
177
+ async def get_historical_stock_prices(ticker: str, period: str = "1mo", interval: str = "1d") -> str:
178
+ loop = asyncio.get_running_loop()
179
+ try:
180
+ return await asyncio.wait_for(
181
+ loop.run_in_executor(None, get_historical_stock_prices_sync, ticker, period, interval),
182
+ timeout=30
183
+ )
184
+ except asyncio.TimeoutError:
185
+ return json.dumps({"error": "Timeout fetching historical stock prices"})
186
+ except Exception as e:
187
+ return json.dumps({"error": str(e)})
188
+
189
+ # --- Tool: get_stock_info ---
190
+ def get_stock_info_sync(ticker):
191
+ logger.info(f"Called get_stock_info_sync: ticker={ticker}")
192
+ company = yf.Ticker(ticker)
193
+ if company.isin is None:
194
+ logger.error(f"Company ticker {ticker} not found.")
195
+ return json.dumps({"error": f"Company ticker {ticker} not found."})
196
+ info = company.info
197
+ file_path, schema, preview = save_json_to_file(info, f"{ticker}_stock_info")
198
+ logger.info(f"Returning stock info for {ticker}")
199
+ return json.dumps({
200
+ "file_path": file_path,
201
+ "schema": schema,
202
+ "preview": preview
203
+ })
204
+
205
+ @yfinance_server.tool(
206
+ name="get_stock_info",
207
+ description="""Get stock information for a given ticker symbol from yahoo finance. Include the following information:\nStock Price & Trading Info, Company Information, Financial Metrics, Earnings & Revenue, Margins & Returns, Dividends, Balance Sheet, Ownership, Analyst Coverage, Risk Metrics, Other.\n\nArgs:\n ticker: str\n The ticker symbol of the stock to get information for, e.g. \"AAPL\"\n""",
208
+ )
209
+ async def get_stock_info(ticker: str) -> str:
210
+ loop = asyncio.get_running_loop()
211
+ try:
212
+ return await asyncio.wait_for(
213
+ loop.run_in_executor(None, get_stock_info_sync, ticker),
214
+ timeout=30
215
+ )
216
+ except asyncio.TimeoutError:
217
+ return json.dumps({"error": "Timeout fetching stock info"})
218
+ except Exception as e:
219
+ return json.dumps({"error": str(e)})
220
+
221
+ # --- Tool: get_yahoo_finance_news ---
222
+ def get_yahoo_finance_news_sync(ticker):
223
+ logger.info(f"Called get_yahoo_finance_news_sync: ticker={ticker}")
224
+ company = yf.Ticker(ticker)
225
+ if company.isin is None:
226
+ logger.error(f"Company ticker {ticker} not found.")
227
+ return json.dumps({"error": f"Company ticker {ticker} not found."})
228
+ try:
229
+ news = company.news
230
+ except Exception as e:
231
+ logger.error(f"Error getting news for {ticker}: {e}")
232
+ return json.dumps({"error": f"Error: getting news for {ticker}: {e}"})
233
+ news_list = []
234
+ for news_item in news:
235
+ if news_item.get("content", {}).get("contentType", "") == "STORY":
236
+ title = news_item.get("content", {}).get("title", "")
237
+ summary = news_item.get("content", {}).get("summary", "")
238
+ description = news_item.get("content", {}).get("description", "")
239
+ url = news_item.get("content", {}).get("canonicalUrl", {}).get("url", "")
240
+ news_list.append(
241
+ {"title": title, "summary": summary, "description": description, "url": url}
242
+ )
243
+ if not news_list:
244
+ logger.warning(f"No news found for company with ticker {ticker}.")
245
+ return json.dumps({"error": f"No news found for company that searched with {ticker} ticker."})
246
+ file_path, schema, preview = save_json_to_file(news_list, f"{ticker}_news")
247
+ logger.info(f"Returning news for {ticker}")
248
+ return json.dumps({
249
+ "file_path": file_path,
250
+ "schema": schema,
251
+ "preview": preview
252
+ })
253
+
254
+ @yfinance_server.tool(
255
+ name="get_yahoo_finance_news",
256
+ description="""Get news for a given ticker symbol from yahoo finance.\n\nArgs:\n ticker: str\n The ticker symbol of the stock to get news for, e.g. \"AAPL\"\n""",
257
+ )
258
+ async def get_yahoo_finance_news(ticker: str) -> str:
259
+ loop = asyncio.get_running_loop()
260
+ try:
261
+ return await asyncio.wait_for(
262
+ loop.run_in_executor(None, get_yahoo_finance_news_sync, ticker),
263
+ timeout=30
264
+ )
265
+ except asyncio.TimeoutError:
266
+ return json.dumps({"error": "Timeout fetching news"})
267
+ except Exception as e:
268
+ return json.dumps({"error": str(e)})
269
+
270
+ # --- Tool: get_stock_actions ---
271
+ def get_stock_actions_sync(ticker):
272
+ logger.info(f"Called get_stock_actions_sync: ticker={ticker}")
273
+ try:
274
+ company = yf.Ticker(ticker)
275
+ except Exception as e:
276
+ logger.error(f"Error getting stock actions for {ticker}: {e}")
277
+ return json.dumps({"error": f"Error: getting stock actions for {ticker}: {e}"})
278
+ actions_df = company.actions
279
+ actions_df = actions_df.reset_index(names="Date")
280
+ file_path, schema = save_df_to_csv(actions_df, f"{ticker}_actions")
281
+ preview_json = actions_df.head(PREVIEW_ROWS).to_json(orient="records", date_format="iso")
282
+ logger.info(f"Returning stock actions for {ticker}")
283
+ return json.dumps({
284
+ "file_path": file_path,
285
+ "schema": schema,
286
+ "preview": json.loads(preview_json)
287
+ })
288
+
289
+ @yfinance_server.tool(
290
+ name="get_stock_actions",
291
+ description="""Get stock dividends and stock splits for a given ticker symbol from yahoo finance.\n\nArgs:\n ticker: str\n The ticker symbol of the stock to get stock actions for, e.g. \"AAPL\"\n""",
292
+ )
293
+ async def get_stock_actions(ticker: str) -> str:
294
+ loop = asyncio.get_running_loop()
295
+ try:
296
+ return await asyncio.wait_for(
297
+ loop.run_in_executor(None, get_stock_actions_sync, ticker),
298
+ timeout=30
299
+ )
300
+ except asyncio.TimeoutError:
301
+ return json.dumps({"error": "Timeout fetching stock actions"})
302
+ except Exception as e:
303
+ return json.dumps({"error": str(e)})
304
+
305
+ # --- Tool: get_financial_statement ---
306
+ def get_financial_statement_sync(ticker, financial_type):
307
+ logger.info(f"Called get_financial_statement_sync: ticker={ticker}, financial_type={financial_type}")
308
+ company = yf.Ticker(ticker)
309
+ if company.isin is None:
310
+ logger.error(f"Company ticker {ticker} not found.")
311
+ return json.dumps({"error": f"Company ticker {ticker} not found."})
312
+ if financial_type == FinancialType.income_stmt:
313
+ financial_statement = company.income_stmt
314
+ elif financial_type == FinancialType.quarterly_income_stmt:
315
+ financial_statement = company.quarterly_income_stmt
316
+ elif financial_type == FinancialType.balance_sheet:
317
+ financial_statement = company.balance_sheet
318
+ elif financial_type == FinancialType.quarterly_balance_sheet:
319
+ financial_statement = company.quarterly_balance_sheet
320
+ elif financial_type == FinancialType.cashflow:
321
+ financial_statement = company.cashflow
322
+ elif financial_type == FinancialType.quarterly_cashflow:
323
+ financial_statement = company.quarterly_cashflow
324
+ else:
325
+ logger.error(f"Invalid financial type {financial_type} for {ticker}.")
326
+ return json.dumps({"error": f"Error: invalid financial type {financial_type}. Please use one of the following: {list(FinancialType)}."})
327
+ df = financial_statement.transpose().reset_index(names="date")
328
+ file_path, schema = save_df_to_csv(df, f"{ticker}_{financial_type}")
329
+ preview_json = df.head(PREVIEW_ROWS).to_json(orient="records", date_format="iso")
330
+ logger.info(f"Returning financial statement for {ticker}, type={financial_type}")
331
+ return json.dumps({
332
+ "file_path": file_path,
333
+ "schema": schema,
334
+ "preview": json.loads(preview_json)
335
+ })
336
+
337
+ @yfinance_server.tool(
338
+ name="get_financial_statement",
339
+ description="""Get financial statement for a given ticker symbol from yahoo finance. You can choose from the following financial statement types: income_stmt, quarterly_income_stmt, balance_sheet, quarterly_balance_sheet, cashflow, quarterly_cashflow.\n\nArgs:\n ticker: str\n The ticker symbol of the stock to get financial statement for, e.g. \"AAPL\"\n financial_type: str\n The type of financial statement to get. You can choose from the following financial statement types: income_stmt, quarterly_income_stmt, balance_sheet, quarterly_balance_sheet, cashflow, quarterly_cashflow.\n""",
340
+ )
341
+ async def get_financial_statement(ticker: str, financial_type: str) -> str:
342
+ loop = asyncio.get_running_loop()
343
+ try:
344
+ return await asyncio.wait_for(
345
+ loop.run_in_executor(None, get_financial_statement_sync, ticker, financial_type),
346
+ timeout=30
347
+ )
348
+ except asyncio.TimeoutError:
349
+ return json.dumps({"error": "Timeout fetching financial statement"})
350
+ except Exception as e:
351
+ return json.dumps({"error": str(e)})
352
+
353
+ # --- Tool: get_holder_info ---
354
+ def get_holder_info_sync(ticker, holder_type):
355
+ logger.info(f"Called get_holder_info_sync: ticker={ticker}, holder_type={holder_type}")
356
+ company = yf.Ticker(ticker)
357
+ if company.isin is None:
358
+ logger.error(f"Company ticker {ticker} not found.")
359
+ return json.dumps({"error": f"Company ticker {ticker} not found."})
360
+ if holder_type == HolderType.major_holders:
361
+ df = company.major_holders.reset_index(names="metric")
362
+ elif holder_type == HolderType.institutional_holders:
363
+ df = company.institutional_holders
364
+ elif holder_type == HolderType.mutualfund_holders:
365
+ df = company.mutualfund_holders
366
+ elif holder_type == HolderType.insider_transactions:
367
+ df = company.insider_transactions
368
+ elif holder_type == HolderType.insider_purchases:
369
+ df = company.insider_purchases
370
+ elif holder_type == HolderType.insider_roster_holders:
371
+ df = company.insider_roster_holders
372
+ else:
373
+ logger.error(f"Invalid holder type {holder_type} for {ticker}.")
374
+ return json.dumps({"error": f"Error: invalid holder type {holder_type}. Please use one of the following: {list(HolderType)}."})
375
+ df = df.reset_index() if df.index.name or df.index.names else df
376
+ file_path, schema = save_df_to_csv(df, f"{ticker}_{holder_type}")
377
+ preview_json = df.head(PREVIEW_ROWS).to_json(orient="records", date_format="iso")
378
+ logger.info(f"Returning holder info for {ticker}, type={holder_type}")
379
+ return json.dumps({
380
+ "file_path": file_path,
381
+ "schema": schema,
382
+ "preview": json.loads(preview_json)
383
+ })
384
+
385
+ @yfinance_server.tool(
386
+ name="get_holder_info",
387
+ description="""Get holder information for a given ticker symbol from yahoo finance. You can choose from the following holder types: major_holders, institutional_holders, mutualfund_holders, insider_transactions, insider_purchases, insider_roster_holders.\n\nArgs:\n ticker: str\n The ticker symbol of the stock to get holder information for, e.g. \"AAPL\"\n holder_type: str\n The type of holder information to get. You can choose from the following holder types: major_holders, institutional_holders, mutualfund_holders, insider_transactions, insider_purchases, insider_roster_holders.\n""",
388
+ )
389
+ async def get_holder_info(ticker: str, holder_type: str) -> str:
390
+ loop = asyncio.get_running_loop()
391
+ try:
392
+ return await asyncio.wait_for(
393
+ loop.run_in_executor(None, get_holder_info_sync, ticker, holder_type),
394
+ timeout=30
395
+ )
396
+ except asyncio.TimeoutError:
397
+ return json.dumps({"error": "Timeout fetching holder info"})
398
+ except Exception as e:
399
+ return json.dumps({"error": str(e)})
400
+
401
+ # --- Tool: get_option_expiration_dates ---
402
+ def get_option_expiration_dates_sync(ticker):
403
+ logger.info(f"Called get_option_expiration_dates_sync: ticker={ticker}")
404
+ company = yf.Ticker(ticker)
405
+ if company.isin is None:
406
+ logger.error(f"Company ticker {ticker} not found.")
407
+ return json.dumps({"error": f"Company ticker {ticker} not found."})
408
+ dates = list(company.options)
409
+ file_path, schema, preview = save_json_to_file(dates, f"{ticker}_option_expiration_dates")
410
+ logger.info(f"Returning option expiration dates for {ticker}")
411
+ return json.dumps({
412
+ "file_path": file_path,
413
+ "schema": schema,
414
+ "preview": preview
415
+ })
416
+
417
+ @yfinance_server.tool(
418
+ name="get_option_expiration_dates",
419
+ description="""Fetch the available options expiration dates for a given ticker symbol.\n\nArgs:\n ticker: str\n The ticker symbol of the stock to get option expiration dates for, e.g. \"AAPL\"\n""",
420
+ )
421
+ async def get_option_expiration_dates(ticker: str) -> str:
422
+ loop = asyncio.get_running_loop()
423
+ try:
424
+ return await asyncio.wait_for(
425
+ loop.run_in_executor(None, get_option_expiration_dates_sync, ticker),
426
+ timeout=30
427
+ )
428
+ except asyncio.TimeoutError:
429
+ return json.dumps({"error": "Timeout fetching option expiration dates"})
430
+ except Exception as e:
431
+ return json.dumps({"error": str(e)})
432
+
433
+ # --- Tool: get_option_chain ---
434
+ def get_option_chain_sync(ticker, expiration_date, option_type):
435
+ logger.info(f"Called get_option_chain_sync: ticker={ticker}, expiration_date={expiration_date}, option_type={option_type}")
436
+ company = yf.Ticker(ticker)
437
+ if company.isin is None:
438
+ logger.error(f"Company ticker {ticker} not found.")
439
+ return json.dumps({"error": f"Company ticker {ticker} not found."})
440
+ if expiration_date not in company.options:
441
+ logger.error(f"No options available for {ticker} on date {expiration_date}.")
442
+ return json.dumps({"error": f"No options available for the date {expiration_date}. You can use `get_option_expiration_dates` to get the available expiration dates."})
443
+ if option_type not in ["calls", "puts"]:
444
+ logger.error(f"Invalid option type {option_type} for {ticker}.")
445
+ return json.dumps({"error": "Invalid option type. Please use 'calls' or 'puts'."})
446
+ option_chain = company.option_chain(expiration_date)
447
+ df = option_chain.calls if option_type == "calls" else option_chain.puts
448
+ file_path, schema = save_df_to_csv(df, f"{ticker}_{expiration_date}_{option_type}_options")
449
+ preview_json = df.head(PREVIEW_ROWS).to_json(orient="records", date_format="iso")
450
+ logger.info(f"Returning option chain for {ticker}, date={expiration_date}, type={option_type}")
451
+ return json.dumps({
452
+ "file_path": file_path,
453
+ "schema": schema,
454
+ "preview": json.loads(preview_json)
455
+ })
456
+
457
+ @yfinance_server.tool(
458
+ name="get_option_chain",
459
+ description="""Fetch the option chain for a given ticker symbol, expiration date, and option type.\n\nArgs:\n ticker: str\n The ticker symbol of the stock to get option chain for, e.g. \"AAPL\"\n expiration_date: str\n The expiration date for the options chain (format: 'YYYY-MM-DD')\n option_type: str\n The type of option to fetch ('calls' or 'puts')\n""",
460
+ )
461
+ async def get_option_chain(ticker: str, expiration_date: str, option_type: str) -> str:
462
+ loop = asyncio.get_running_loop()
463
+ try:
464
+ return await asyncio.wait_for(
465
+ loop.run_in_executor(None, get_option_chain_sync, ticker, expiration_date, option_type),
466
+ timeout=30
467
+ )
468
+ except asyncio.TimeoutError:
469
+ return json.dumps({"error": "Timeout fetching option chain"})
470
+ except Exception as e:
471
+ return json.dumps({"error": str(e)})
472
+
473
+ # --- Tool: get_recommendations ---
474
+ def get_recommendations_sync(ticker, recommendation_type, months_back=12):
475
+ logger.info(f"Called get_recommendations_sync: ticker={ticker}, recommendation_type={recommendation_type}, months_back={months_back}")
476
+ company = yf.Ticker(ticker)
477
+ if company.isin is None:
478
+ logger.error(f"Company ticker {ticker} not found.")
479
+ return json.dumps({"error": f"Company ticker {ticker} not found."})
480
+ try:
481
+ if recommendation_type == RecommendationType.recommendations:
482
+ df = company.recommendations
483
+ elif recommendation_type == RecommendationType.upgrades_downgrades:
484
+ upgrades_downgrades = company.upgrades_downgrades.reset_index()
485
+ cutoff_date = pd.Timestamp.now() - pd.DateOffset(months=months_back)
486
+ upgrades_downgrades = upgrades_downgrades[
487
+ upgrades_downgrades["GradeDate"] >= cutoff_date
488
+ ]
489
+ upgrades_downgrades = upgrades_downgrades.sort_values("GradeDate", ascending=False)
490
+ latest_by_firm = upgrades_downgrades.drop_duplicates(subset=["Firm"])
491
+ df = latest_by_firm
492
+ else:
493
+ logger.error(f"Invalid recommendation type {recommendation_type} for {ticker}.")
494
+ return json.dumps({"error": f"Invalid recommendation type {recommendation_type}."})
495
+ df = df.reset_index() if df.index.name or df.index.names else df
496
+ file_path, schema = save_df_to_csv(df, f"{ticker}_{recommendation_type}_recommendations")
497
+ preview_json = df.head(PREVIEW_ROWS).to_json(orient="records", date_format="iso")
498
+ logger.info(f"Returning recommendations for {ticker}, type={recommendation_type}, months_back={months_back}")
499
+ return json.dumps({
500
+ "file_path": file_path,
501
+ "schema": schema,
502
+ "preview": json.loads(preview_json)
503
+ })
504
+ except Exception as e:
505
+ logger.error(f"Error getting recommendations for {ticker}: {e}")
506
+ return json.dumps({"error": f"Error: getting recommendations for {ticker}: {e}"})
507
+
508
+ @yfinance_server.tool(
509
+ name="get_recommendations",
510
+ description="""Get recommendations or upgrades/downgrades for a given ticker symbol from yahoo finance. You can also specify the number of months back to get upgrades/downgrades for, default is 12.\n\nArgs:\n ticker: str\n The ticker symbol of the stock to get recommendations for, e.g. \"AAPL\"\n recommendation_type: str\n The type of recommendation to get. You can choose from the following recommendation types: recommendations, upgrades_downgrades.\n months_back: int\n The number of months back to get upgrades/downgrades for, default is 12.\n""",
511
+ )
512
+ async def get_recommendations(ticker: str, recommendation_type: str, months_back: int = 12) -> str:
513
+ loop = asyncio.get_running_loop()
514
+ try:
515
+ return await asyncio.wait_for(
516
+ loop.run_in_executor(None, get_recommendations_sync, ticker, recommendation_type, months_back),
517
+ timeout=30
518
+ )
519
+ except asyncio.TimeoutError:
520
+ return json.dumps({"error": "Timeout fetching recommendations"})
521
+ except Exception as e:
522
+ return json.dumps({"error": str(e)})
523
+
524
+
525
+ # --- Build the app and add middleware ---
526
+ app = yfinance_server.streamable_http_app()
527
+ app.add_middleware(HuggingFaceTokenAuthMiddleware)
528
+
529
+ if __name__ == "__main__":
530
+ # Initialize and run the server
531
+
532
+ print("Starting Yahoo Finance MCP server...")
533
+ uvicorn.run(app, host="0.0.0.0", port=8000)