vn6295337 Claude Opus 4.5 commited on
Commit
d377800
·
1 Parent(s): f28dae4

Fix: Standardize MCP server timeouts to 90s and use subprocess+MCP in tests

Browse files

- Remove server_legacy.py (unused direct import path)
- Update test_mcp_e2e.py to use subprocess+MCP protocol (same as production)
- Standardize TOOL_TIMEOUT to 90s across all MCP servers to match mcp_client
- fundamentals: 60s -> 90s
- valuation: 45s -> 90s
- volatility: 45s -> 90s
- macro: 45s -> 90s (was causing timeouts on HuggingFace)
- news: 45s -> 90s
- sentiment: 60s -> 90s

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

mcp-servers/fundamentals-basket/config.py CHANGED
@@ -10,7 +10,7 @@ and SWOT analysis thresholds.
10
  # =============================================================================
11
 
12
  # Global timeout for MCP tool execution
13
- TOOL_TIMEOUT = 60.0
14
 
15
  # Per-source timeouts (increased for reliability)
16
  SEC_EDGAR_TIMEOUT = 30.0
 
10
  # =============================================================================
11
 
12
  # Global timeout for MCP tool execution
13
+ TOOL_TIMEOUT = 90.0 # Match mcp_client timeout
14
 
15
  # Per-source timeouts (increased for reliability)
16
  SEC_EDGAR_TIMEOUT = 30.0
mcp-servers/fundamentals-basket/server_legacy.py DELETED
@@ -1,1506 +0,0 @@
1
- """
2
- Financials Basket MCP Server
3
-
4
- Fetches fundamental financial data from SEC EDGAR for SWOT analysis:
5
- - Revenue, Net Income, Margins → Strengths/Weaknesses
6
- - Debt levels, leverage ratios → Weaknesses/Threats
7
- - R&D spend, CapEx → Opportunities
8
- - Cash flow metrics → Strengths
9
-
10
- API Documentation: https://www.sec.gov/edgar/sec-api-documentation
11
- No API key required. Rate limit: 10 requests/second.
12
- """
13
-
14
- import asyncio
15
- import json
16
- import logging
17
- import os
18
- from datetime import datetime
19
- from pathlib import Path
20
- from typing import Optional
21
-
22
- # Load environment variables
23
- from dotenv import load_dotenv
24
- env_paths = [
25
- Path.home() / ".env",
26
- Path(__file__).parent / ".env",
27
- Path(__file__).parent.parent.parent / ".env",
28
- ]
29
- for env_path in env_paths:
30
- if env_path.exists():
31
- load_dotenv(env_path)
32
- break
33
-
34
- # MCP SDK
35
- from mcp.server import Server
36
- from mcp.server.stdio import stdio_server
37
- from mcp.types import Tool, TextContent
38
-
39
- # Data fetching
40
- import httpx
41
- import yfinance as yf
42
- from concurrent.futures import ThreadPoolExecutor
43
-
44
- logging.basicConfig(level=logging.INFO)
45
- logger = logging.getLogger("fundamentals-basket")
46
-
47
- # Thread pool for yfinance (synchronous library)
48
- _executor = ThreadPoolExecutor(max_workers=2)
49
-
50
-
51
- def create_temporal_metric(value, source_metric) -> dict:
52
- """Create a metric with temporal data inherited from source metric.
53
-
54
- Args:
55
- value: The calculated metric value
56
- source_metric: Source metric dict containing temporal data (end_date, fiscal_year, form)
57
-
58
- Returns:
59
- Dict with value and inherited temporal data
60
- """
61
- if source_metric and isinstance(source_metric, dict):
62
- return {
63
- "value": value,
64
- "end_date": source_metric.get("end_date"),
65
- "fiscal_year": source_metric.get("fiscal_year"),
66
- "form": source_metric.get("form")
67
- }
68
- return {"value": value}
69
-
70
- # Initialize MCP server
71
- server = Server("fundamentals-basket")
72
-
73
- # SEC EDGAR requires User-Agent with contact info
74
- SEC_HEADERS = {
75
- "User-Agent": "AI-Strategy-Copilot/1.0 (contact@example.com)",
76
- "Accept": "application/json",
77
- }
78
-
79
- # Cache for CIK lookups
80
- CIK_CACHE = {}
81
-
82
-
83
- # ============================================================
84
- # HELPER FUNCTIONS
85
- # ============================================================
86
-
87
- def format_cik(cik: str) -> str:
88
- """Format CIK to 10 digits with leading zeros."""
89
- return str(cik).zfill(10)
90
-
91
-
92
- async def ticker_to_cik(ticker: str) -> Optional[str]:
93
- """
94
- Convert ticker symbol to CIK number.
95
- Uses SEC's company tickers JSON.
96
- """
97
- ticker = ticker.upper()
98
-
99
- if ticker in CIK_CACHE:
100
- return CIK_CACHE[ticker]
101
-
102
- try:
103
- async with httpx.AsyncClient() as client:
104
- url = "https://www.sec.gov/files/company_tickers.json"
105
- response = await client.get(url, headers=SEC_HEADERS, timeout=10)
106
- data = response.json()
107
-
108
- for entry in data.values():
109
- if entry.get("ticker") == ticker:
110
- cik = format_cik(entry.get("cik_str"))
111
- CIK_CACHE[ticker] = cik
112
- return cik
113
-
114
- return None
115
- except Exception as e:
116
- logger.error(f"CIK lookup error: {e}")
117
- return None
118
-
119
-
120
- def get_latest_value(facts: dict, concept: str, unit: str = "USD") -> Optional[dict]:
121
- """
122
- Extract latest value for a concept from company facts.
123
- Returns dict with value, period end date, and fiscal year.
124
- """
125
- # Defensive check for None or invalid facts
126
- if not facts or not isinstance(facts, dict):
127
- return None
128
-
129
- try:
130
- us_gaap = facts.get("us-gaap")
131
- if not us_gaap or not isinstance(us_gaap, dict):
132
- return None
133
-
134
- concept_data = us_gaap.get(concept)
135
- if not concept_data or not isinstance(concept_data, dict):
136
- return None
137
-
138
- units_data = concept_data.get("units")
139
- if not units_data or not isinstance(units_data, dict):
140
- return None
141
-
142
- units = units_data.get(unit, [])
143
- if not units or not isinstance(units, list):
144
- return None
145
-
146
- # Filter for annual (10-K) filings and get most recent
147
- annual_facts = [f for f in units if isinstance(f, dict) and f.get("form") == "10-K"]
148
- if not annual_facts:
149
- annual_facts = [f for f in units if isinstance(f, dict)] # Fallback to all if no 10-K
150
-
151
- if not annual_facts:
152
- return None
153
-
154
- # Sort by end date descending
155
- annual_facts.sort(key=lambda x: x.get("end", ""), reverse=True)
156
-
157
- latest = annual_facts[0]
158
- return {
159
- "value": latest.get("val"),
160
- "end_date": latest.get("end"),
161
- "fiscal_year": latest.get("fy"),
162
- "form": latest.get("form")
163
- }
164
- except Exception as e:
165
- logger.error(f"Error extracting {concept}: {e}")
166
- return None
167
-
168
-
169
- def calculate_growth(facts: dict, concept: str, years: int = 3) -> Optional[float]:
170
- """Calculate CAGR for a concept over specified years."""
171
- # Defensive check for None or invalid facts
172
- if not facts or not isinstance(facts, dict):
173
- return None
174
-
175
- try:
176
- us_gaap = facts.get("us-gaap")
177
- if not us_gaap or not isinstance(us_gaap, dict):
178
- return None
179
-
180
- concept_data = us_gaap.get(concept)
181
- if not concept_data or not isinstance(concept_data, dict):
182
- return None
183
-
184
- units_data = concept_data.get("units")
185
- if not units_data or not isinstance(units_data, dict):
186
- return None
187
-
188
- units = units_data.get("USD", [])
189
- if not units or not isinstance(units, list):
190
- return None
191
-
192
- annual_facts = [f for f in units if isinstance(f, dict) and f.get("form") == "10-K"]
193
- annual_facts.sort(key=lambda x: x.get("end", ""), reverse=True)
194
-
195
- if len(annual_facts) < years + 1:
196
- return None
197
-
198
- latest_val = annual_facts[0].get("val", 0) or 0
199
- older_val = annual_facts[years].get("val", 0) or 0
200
-
201
- if older_val <= 0 or latest_val <= 0:
202
- return None
203
-
204
- cagr = ((latest_val / older_val) ** (1 / years) - 1) * 100
205
- return round(cagr, 2)
206
- except Exception as e:
207
- logger.error(f"Growth calculation error: {e}")
208
- return None
209
-
210
-
211
- # ============================================================
212
- # DATA FETCHERS
213
- # ============================================================
214
-
215
- async def fetch_company_info(ticker: str) -> dict:
216
- """
217
- Fetch basic company information from SEC submissions.
218
- """
219
- cik = await ticker_to_cik(ticker)
220
- if not cik:
221
- return {"error": f"Could not find CIK for ticker {ticker}"}
222
-
223
- try:
224
- async with httpx.AsyncClient() as client:
225
- url = f"https://data.sec.gov/submissions/CIK{cik}.json"
226
- response = await client.get(url, headers=SEC_HEADERS, timeout=10)
227
- data = response.json()
228
-
229
- return {
230
- "ticker": ticker.upper(),
231
- "cik": cik,
232
- "name": data.get("name"),
233
- "sic": data.get("sic"),
234
- "sic_description": data.get("sicDescription"),
235
- "state": data.get("stateOfIncorporation"),
236
- "fiscal_year_end": data.get("fiscalYearEnd"),
237
- "source": "SEC EDGAR"
238
- }
239
- except Exception as e:
240
- logger.error(f"Company info error: {e}")
241
- return {"ticker": ticker, "error": str(e)}
242
-
243
-
244
- async def fetch_financials(ticker: str) -> dict:
245
- """
246
- Fetch key financial metrics from SEC EDGAR XBRL data.
247
- """
248
- cik = await ticker_to_cik(ticker)
249
- if not cik:
250
- return {"error": f"Could not find CIK for ticker {ticker}"}
251
-
252
- try:
253
- async with httpx.AsyncClient() as client:
254
- url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
255
- response = await client.get(url, headers=SEC_HEADERS, timeout=15)
256
- data = response.json()
257
-
258
- facts = data.get("facts", {})
259
-
260
- # Extract key metrics - pick concept with most recent date
261
- # (Companies change GAAP concepts over time, e.g., Apple switched from
262
- # "Revenues" to "RevenueFromContractWithCustomerExcludingAssessedTax" after FY2018)
263
- revenue_candidates = [
264
- get_latest_value(facts, "RevenueFromContractWithCustomerExcludingAssessedTax"),
265
- get_latest_value(facts, "Revenues"),
266
- get_latest_value(facts, "SalesRevenueNet"),
267
- ]
268
- revenue = max(
269
- [r for r in revenue_candidates if r and r.get("end_date")],
270
- key=lambda x: x.get("end_date", ""),
271
- default=None
272
- )
273
-
274
- net_income = get_latest_value(facts, "NetIncomeLoss")
275
-
276
- gross_profit = get_latest_value(facts, "GrossProfit")
277
-
278
- operating_income = get_latest_value(facts, "OperatingIncomeLoss")
279
-
280
- total_assets = get_latest_value(facts, "Assets")
281
-
282
- total_liabilities = get_latest_value(facts, "Liabilities")
283
-
284
- stockholders_equity = get_latest_value(facts, "StockholdersEquity")
285
-
286
- # Calculate margins (preserve temporal data from source metrics)
287
- gross_margin = None
288
- if revenue and gross_profit and revenue["value"] and gross_profit["value"]:
289
- gross_margin = create_temporal_metric(
290
- round((gross_profit["value"] / revenue["value"]) * 100, 2),
291
- revenue
292
- )
293
-
294
- operating_margin = None
295
- if revenue and operating_income and revenue["value"] and operating_income["value"]:
296
- operating_margin = create_temporal_metric(
297
- round((operating_income["value"] / revenue["value"]) * 100, 2),
298
- revenue
299
- )
300
-
301
- net_margin = None
302
- if revenue and net_income and revenue["value"] and net_income["value"]:
303
- net_margin = create_temporal_metric(
304
- round((net_income["value"] / revenue["value"]) * 100, 2),
305
- revenue
306
- )
307
-
308
- # Revenue growth
309
- # Calculate revenue growth using the same concept as revenue
310
- revenue_growth = calculate_growth(facts, "RevenueFromContractWithCustomerExcludingAssessedTax") or \
311
- calculate_growth(facts, "Revenues") or \
312
- calculate_growth(facts, "SalesRevenueNet")
313
-
314
- return {
315
- "ticker": ticker.upper(),
316
- "revenue": revenue,
317
- "revenue_growth_3yr": revenue_growth,
318
- "net_income": net_income,
319
- "gross_profit": gross_profit,
320
- "operating_income": operating_income,
321
- "gross_margin_pct": gross_margin,
322
- "operating_margin_pct": operating_margin,
323
- "net_margin_pct": net_margin,
324
- "total_assets": total_assets,
325
- "total_liabilities": total_liabilities,
326
- "stockholders_equity": stockholders_equity,
327
- "source": "SEC EDGAR XBRL",
328
- "as_of": datetime.now().strftime("%Y-%m-%d")
329
- }
330
- except Exception as e:
331
- logger.error(f"Financials error: {e}")
332
- return {"ticker": ticker, "error": str(e)}
333
-
334
-
335
- async def fetch_debt_metrics(ticker: str) -> dict:
336
- """
337
- Fetch debt and leverage metrics.
338
- """
339
- cik = await ticker_to_cik(ticker)
340
- if not cik:
341
- return {"error": f"Could not find CIK for ticker {ticker}"}
342
-
343
- try:
344
- async with httpx.AsyncClient() as client:
345
- url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
346
- response = await client.get(url, headers=SEC_HEADERS, timeout=15)
347
- data = response.json()
348
-
349
- facts = data.get("facts", {})
350
-
351
- # Debt metrics - prefer concepts with most recent data
352
- # Some companies use different concepts in different years
353
- def get_most_recent(*concepts):
354
- """Get the value with the most recent end_date among concepts."""
355
- candidates = []
356
- for concept in concepts:
357
- val = get_latest_value(facts, concept)
358
- if val and val.get("end_date"):
359
- candidates.append(val)
360
- if not candidates:
361
- return None
362
- # Sort by end_date descending and return most recent
363
- candidates.sort(key=lambda x: x.get("end_date", ""), reverse=True)
364
- return candidates[0]
365
-
366
- long_term_debt = get_most_recent(
367
- "LongTermDebtAndCapitalLeaseObligations", # Most comprehensive, often most recent
368
- "LongTermDebt",
369
- "LongTermDebtNoncurrent"
370
- )
371
-
372
- short_term_debt = get_latest_value(facts, "ShortTermBorrowings") or \
373
- get_latest_value(facts, "DebtCurrent")
374
-
375
- total_debt = get_most_recent(
376
- "DebtAndCapitalLeaseObligations",
377
- "LongTermDebtAndCapitalLeaseObligations",
378
- "LongTermDebt"
379
- )
380
-
381
- cash = get_latest_value(facts, "CashAndCashEquivalentsAtCarryingValue") or \
382
- get_latest_value(facts, "Cash")
383
-
384
- # Calculate net debt
385
- net_debt = None
386
- if total_debt and cash and total_debt.get("value") and cash.get("value"):
387
- net_debt = total_debt["value"] - cash["value"]
388
- elif long_term_debt and cash:
389
- ltd_val = long_term_debt.get("value", 0) or 0
390
- std_val = short_term_debt.get("value", 0) if short_term_debt else 0
391
- cash_val = cash.get("value", 0) or 0
392
- net_debt = ltd_val + std_val - cash_val
393
-
394
- # Get EBITDA or operating income for leverage ratio
395
- operating_income = get_latest_value(facts, "OperatingIncomeLoss")
396
-
397
- # Debt to equity (preserve temporal data)
398
- stockholders_equity = get_latest_value(facts, "StockholdersEquity")
399
- debt_to_equity = None
400
- if total_debt and stockholders_equity:
401
- debt_val = total_debt.get("value", 0) or 0
402
- equity_val = stockholders_equity.get("value", 0) or 0
403
- if equity_val > 0:
404
- debt_to_equity = create_temporal_metric(
405
- round(debt_val / equity_val, 2),
406
- total_debt # Inherit temporal data from total_debt
407
- )
408
-
409
- return {
410
- "ticker": ticker.upper(),
411
- "long_term_debt": long_term_debt,
412
- "short_term_debt": short_term_debt,
413
- "total_debt": total_debt,
414
- "cash": cash,
415
- "net_debt": {"value": net_debt} if net_debt else None,
416
- "debt_to_equity": debt_to_equity,
417
- "source": "SEC EDGAR XBRL",
418
- "as_of": datetime.now().strftime("%Y-%m-%d")
419
- }
420
- except Exception as e:
421
- logger.error(f"Debt metrics error: {e}")
422
- return {"ticker": ticker, "error": str(e)}
423
-
424
-
425
- async def fetch_cash_flow(ticker: str) -> dict:
426
- """
427
- Fetch cash flow metrics.
428
- """
429
- cik = await ticker_to_cik(ticker)
430
- if not cik:
431
- return {"error": f"Could not find CIK for ticker {ticker}"}
432
-
433
- try:
434
- async with httpx.AsyncClient() as client:
435
- url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
436
- response = await client.get(url, headers=SEC_HEADERS, timeout=15)
437
- data = response.json()
438
-
439
- facts = data.get("facts", {})
440
-
441
- operating_cf = get_latest_value(facts, "NetCashProvidedByUsedInOperatingActivities")
442
-
443
- capex = get_latest_value(facts, "PaymentsToAcquirePropertyPlantAndEquipment")
444
-
445
- # Free Cash Flow = Operating CF - CapEx
446
- fcf = None
447
- if operating_cf and capex:
448
- ocf_val = operating_cf.get("value", 0) or 0
449
- capex_val = capex.get("value", 0) or 0
450
- fcf = ocf_val - abs(capex_val) # CapEx is typically negative
451
-
452
- rd_expense = get_latest_value(facts, "ResearchAndDevelopmentExpense")
453
-
454
- return {
455
- "ticker": ticker.upper(),
456
- "operating_cash_flow": operating_cf,
457
- "capital_expenditure": capex,
458
- "free_cash_flow": {"value": fcf} if fcf else None,
459
- "rd_expense": rd_expense,
460
- "source": "SEC EDGAR XBRL",
461
- "as_of": datetime.now().strftime("%Y-%m-%d")
462
- }
463
- except Exception as e:
464
- logger.error(f"Cash flow error: {e}")
465
- return {"ticker": ticker, "error": str(e)}
466
-
467
-
468
- # 8-K Item Code Descriptions
469
- ITEM_8K_CODES = {
470
- "1.01": "Entry into Material Definitive Agreement",
471
- "1.02": "Termination of Material Definitive Agreement",
472
- "1.03": "Bankruptcy or Receivership",
473
- "1.04": "Mine Safety",
474
- "2.01": "Completion of Acquisition or Disposition of Assets",
475
- "2.02": "Results of Operations and Financial Condition",
476
- "2.03": "Creation of Direct Financial Obligation",
477
- "2.04": "Triggering Events (Accelerate/Increase Obligation)",
478
- "2.05": "Exit or Disposal Activities",
479
- "2.06": "Material Impairments",
480
- "3.01": "Delisting or Listing Standard Failure",
481
- "3.02": "Unregistered Sales of Equity Securities",
482
- "3.03": "Material Modification to Security Holder Rights",
483
- "4.01": "Changes in Certifying Accountant",
484
- "4.02": "Non-Reliance on Previously Issued Financials",
485
- "5.01": "Changes in Control of Registrant",
486
- "5.02": "Departure/Election of Directors or Officers",
487
- "5.03": "Amendments to Articles/Bylaws",
488
- "5.05": "Amendments to Code of Ethics",
489
- "5.06": "Change in Shell Company Status",
490
- "5.07": "Submission of Matters to Shareholder Vote",
491
- "5.08": "Shareholder Nominations",
492
- "6.01": "ABS Servicer Information",
493
- "6.02": "Change of ABS Servicer",
494
- "6.03": "Change in Credit Enhancement",
495
- "6.04": "Failure to Make Distribution",
496
- "6.05": "ABS Informational and Computational Material",
497
- "7.01": "Regulation FD Disclosure",
498
- "8.01": "Other Events",
499
- "9.01": "Financial Statements and Exhibits",
500
- }
501
-
502
- # High-priority 8-K items (material risk events)
503
- HIGH_PRIORITY_ITEMS = {
504
- "1.03", # Bankruptcy
505
- "2.04", # Triggering events
506
- "2.06", # Material impairments
507
- "3.01", # Delisting
508
- "4.02", # Non-reliance on financials
509
- "5.01", # Change in control
510
- "5.02", # Executive departure
511
- }
512
-
513
-
514
- async def fetch_material_events(ticker: str, limit: int = 20) -> dict:
515
- """
516
- Fetch recent 8-K material events for a company.
517
- Returns filings with item codes and SWOT categorization.
518
- """
519
- cik = await ticker_to_cik(ticker)
520
- if not cik:
521
- return {"error": f"Could not find CIK for ticker {ticker}"}
522
-
523
- try:
524
- async with httpx.AsyncClient() as client:
525
- url = f"https://data.sec.gov/submissions/CIK{cik}.json"
526
- response = await client.get(url, headers=SEC_HEADERS, timeout=10)
527
- data = response.json()
528
-
529
- # Get recent filings
530
- recent = data.get("filings", {}).get("recent", {})
531
- forms = recent.get("form", [])
532
- dates = recent.get("filingDate", [])
533
- accessions = recent.get("accessionNumber", [])
534
- items_list = recent.get("items", [])
535
- descriptions = recent.get("primaryDocument", [])
536
-
537
- # Filter for 8-K filings
538
- events = []
539
- high_priority_events = []
540
-
541
- for i, form in enumerate(forms):
542
- if form == "8-K" and len(events) < limit:
543
- item_codes = items_list[i] if i < len(items_list) else ""
544
-
545
- # Parse item codes (comma-separated)
546
- parsed_items = []
547
- is_high_priority = False
548
-
549
- if item_codes:
550
- for code in item_codes.split(","):
551
- code = code.strip()
552
- if code in ITEM_8K_CODES:
553
- parsed_items.append({
554
- "code": code,
555
- "description": ITEM_8K_CODES[code],
556
- "high_priority": code in HIGH_PRIORITY_ITEMS
557
- })
558
- if code in HIGH_PRIORITY_ITEMS:
559
- is_high_priority = True
560
-
561
- event = {
562
- "filing_date": dates[i] if i < len(dates) else None,
563
- "accession_number": accessions[i] if i < len(accessions) else None,
564
- "items": parsed_items,
565
- "raw_items": item_codes,
566
- "document": descriptions[i] if i < len(descriptions) else None,
567
- "high_priority": is_high_priority,
568
- "url": f"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK={cik}&type=8-K&dateb=&owner=include&count=40"
569
- }
570
-
571
- events.append(event)
572
- if is_high_priority:
573
- high_priority_events.append(event)
574
-
575
- # SWOT categorization
576
- swot_implications = {
577
- "weaknesses": [],
578
- "threats": []
579
- }
580
-
581
- for event in high_priority_events[:5]: # Top 5 high-priority
582
- for item in event.get("items", []):
583
- code = item.get("code")
584
- desc = item.get("description")
585
- date = event.get("filing_date")
586
-
587
- if code == "1.03":
588
- swot_implications["threats"].append(f"Bankruptcy filing ({date})")
589
- elif code == "2.06":
590
- swot_implications["weaknesses"].append(f"Material impairment ({date})")
591
- elif code == "3.01":
592
- swot_implications["threats"].append(f"Delisting/listing issue ({date})")
593
- elif code == "4.02":
594
- swot_implications["threats"].append(f"Financial restatement risk ({date})")
595
- elif code == "5.01":
596
- swot_implications["weaknesses"].append(f"Change in control ({date})")
597
- elif code == "5.02":
598
- swot_implications["weaknesses"].append(f"Executive/director change ({date})")
599
- elif code == "2.04":
600
- swot_implications["threats"].append(f"Debt obligation triggered ({date})")
601
-
602
- return {
603
- "ticker": ticker.upper(),
604
- "cik": cik,
605
- "total_8k_filings": len([f for f in forms if f == "8-K"]),
606
- "recent_events": events,
607
- "high_priority_count": len(high_priority_events),
608
- "high_priority_events": high_priority_events[:5],
609
- "swot_implications": swot_implications,
610
- "source": "SEC EDGAR",
611
- "as_of": datetime.now().strftime("%Y-%m-%d")
612
- }
613
- except Exception as e:
614
- logger.error(f"Material events error: {e}")
615
- return {"ticker": ticker, "error": str(e)}
616
-
617
-
618
- # Going concern keywords to search in 10-K filings
619
- GOING_CONCERN_KEYWORDS = [
620
- "going concern",
621
- "substantial doubt",
622
- "ability to continue",
623
- "continue as a going concern",
624
- "raise substantial doubt",
625
- "conditions that raise",
626
- "material uncertainty",
627
- "liquidity concerns",
628
- ]
629
-
630
-
631
- async def fetch_going_concern(ticker: str) -> dict:
632
- """
633
- Fetch latest 10-K and search for going concern language.
634
- Returns matches with surrounding context.
635
- """
636
- cik = await ticker_to_cik(ticker)
637
- if not cik:
638
- return {"error": f"Could not find CIK for ticker {ticker}"}
639
-
640
- try:
641
- async with httpx.AsyncClient() as client:
642
- # Get submissions to find latest 10-K
643
- url = f"https://data.sec.gov/submissions/CIK{cik}.json"
644
- response = await client.get(url, headers=SEC_HEADERS, timeout=10)
645
- data = response.json()
646
-
647
- recent = data.get("filings", {}).get("recent", {})
648
- forms = recent.get("form", [])
649
- accessions = recent.get("accessionNumber", [])
650
- dates = recent.get("filingDate", [])
651
- primary_docs = recent.get("primaryDocument", [])
652
-
653
- # Find latest 10-K
654
- filing_info = None
655
- for i, form in enumerate(forms):
656
- if form == "10-K":
657
- filing_info = {
658
- "form": form,
659
- "accession": accessions[i].replace("-", ""),
660
- "accession_formatted": accessions[i],
661
- "date": dates[i],
662
- "document": primary_docs[i] if i < len(primary_docs) else None
663
- }
664
- break
665
-
666
- if not filing_info:
667
- return {
668
- "ticker": ticker.upper(),
669
- "going_concern_found": False,
670
- "message": "No 10-K filing found",
671
- "source": "SEC EDGAR"
672
- }
673
-
674
- # Fetch the 10-K document
675
- doc_url = f"https://www.sec.gov/Archives/edgar/data/{cik.lstrip('0')}/{filing_info['accession']}/{filing_info['document']}"
676
-
677
- doc_response = await client.get(doc_url, headers=SEC_HEADERS, timeout=30)
678
-
679
- if doc_response.status_code != 200:
680
- return {
681
- "ticker": ticker.upper(),
682
- "going_concern_found": False,
683
- "message": f"Could not fetch 10-K document (status {doc_response.status_code})",
684
- "filing_date": filing_info["date"],
685
- "source": "SEC EDGAR"
686
- }
687
-
688
- # Get text content (handle HTML)
689
- content = doc_response.text.lower()
690
-
691
- # Remove HTML tags for cleaner search
692
- import re
693
- text_content = re.sub(r'<[^>]+>', ' ', content)
694
- text_content = re.sub(r'\s+', ' ', text_content)
695
-
696
- # Search for keywords
697
- matches = []
698
- for keyword in GOING_CONCERN_KEYWORDS:
699
- if keyword in text_content:
700
- # Find context around the keyword
701
- idx = text_content.find(keyword)
702
- start = max(0, idx - 150)
703
- end = min(len(text_content), idx + len(keyword) + 150)
704
- context = text_content[start:end].strip()
705
-
706
- # Count occurrences
707
- count = text_content.count(keyword)
708
-
709
- matches.append({
710
- "keyword": keyword,
711
- "count": count,
712
- "sample_context": f"...{context}..."
713
- })
714
-
715
- # Determine risk level
716
- has_going_concern = len(matches) > 0
717
- risk_level = "none"
718
- if has_going_concern:
719
- total_mentions = sum(m["count"] for m in matches)
720
- if any(kw in ["substantial doubt", "raise substantial doubt"] for kw in [m["keyword"] for m in matches]):
721
- risk_level = "high"
722
- elif total_mentions > 5:
723
- risk_level = "medium"
724
- else:
725
- risk_level = "low"
726
-
727
- # SWOT implications
728
- swot_implications = {"threats": []}
729
- if risk_level == "high":
730
- swot_implications["threats"].append(f"Going concern warning in 10-K ({filing_info['date']})")
731
- elif risk_level == "medium":
732
- swot_implications["threats"].append(f"Multiple going concern mentions in 10-K ({filing_info['date']})")
733
-
734
- return {
735
- "ticker": ticker.upper(),
736
- "going_concern_found": has_going_concern,
737
- "risk_level": risk_level,
738
- "filing_date": filing_info["date"],
739
- "filing_url": doc_url,
740
- "keyword_matches": matches,
741
- "swot_implications": swot_implications,
742
- "source": "SEC EDGAR 10-K",
743
- "as_of": datetime.now().strftime("%Y-%m-%d")
744
- }
745
-
746
- except Exception as e:
747
- logger.error(f"Going concern error: {e}")
748
- return {"ticker": ticker, "error": str(e)}
749
-
750
-
751
- async def fetch_ownership_filings(ticker: str, limit: int = 20) -> dict:
752
- """
753
- Fetch ownership-related filings: 13D/13G (5%+ ownership), Form 4 (insider trades), 13F mentions.
754
- """
755
- cik = await ticker_to_cik(ticker)
756
- if not cik:
757
- return {"error": f"Could not find CIK for ticker {ticker}"}
758
-
759
- try:
760
- async with httpx.AsyncClient() as client:
761
- url = f"https://data.sec.gov/submissions/CIK{cik}.json"
762
- response = await client.get(url, headers=SEC_HEADERS, timeout=10)
763
- data = response.json()
764
-
765
- recent = data.get("filings", {}).get("recent", {})
766
- forms = recent.get("form", [])
767
- dates = recent.get("filingDate", [])
768
- accessions = recent.get("accessionNumber", [])
769
- primary_docs = recent.get("primaryDocument", [])
770
-
771
- # Ownership form types
772
- ownership_forms = {
773
- "SC 13D": "Beneficial ownership >5% (activist/intent to influence)",
774
- "SC 13D/A": "Amendment to 13D",
775
- "SC 13G": "Beneficial ownership >5% (passive investor)",
776
- "SC 13G/A": "Amendment to 13G",
777
- "4": "Insider transaction (officer/director/10%+ owner)",
778
- "4/A": "Amendment to Form 4",
779
- "3": "Initial insider ownership statement",
780
- "5": "Annual insider ownership changes",
781
- }
782
-
783
- filings_13d_13g = []
784
- filings_form4 = []
785
-
786
- for i, form in enumerate(forms):
787
- if form in ownership_forms:
788
- filing = {
789
- "form": form,
790
- "description": ownership_forms[form],
791
- "filing_date": dates[i] if i < len(dates) else None,
792
- "accession_number": accessions[i] if i < len(accessions) else None,
793
- "document": primary_docs[i] if i < len(primary_docs) else None,
794
- }
795
-
796
- if form.startswith("SC 13"):
797
- if len(filings_13d_13g) < limit:
798
- filings_13d_13g.append(filing)
799
- elif form in ("3", "4", "4/A", "5"):
800
- if len(filings_form4) < limit:
801
- filings_form4.append(filing)
802
-
803
- # SWOT implications
804
- swot_implications = {
805
- "opportunities": [],
806
- "threats": []
807
- }
808
-
809
- # Recent 13D filings suggest activist interest
810
- recent_13d = [f for f in filings_13d_13g if f["form"] in ("SC 13D", "SC 13D/A")][:3]
811
- if recent_13d:
812
- dates_str = ", ".join([f["filing_date"] for f in recent_13d if f["filing_date"]])
813
- swot_implications["opportunities"].append(f"Activist investor interest (13D filings: {dates_str})")
814
-
815
- # Heavy insider selling could be a warning
816
- recent_form4 = filings_form4[:10]
817
- # Note: Would need to parse Form 4 XML to determine buy vs sell
818
-
819
- return {
820
- "ticker": ticker.upper(),
821
- "cik": cik,
822
- "ownership_filings": {
823
- "13d_13g": filings_13d_13g[:limit],
824
- "13d_13g_count": len([f for f in forms if f.startswith("SC 13")]),
825
- "form4_insider": filings_form4[:limit],
826
- "form4_count": len([f for f in forms if f in ("3", "4", "4/A", "5")]),
827
- },
828
- "swot_implications": swot_implications,
829
- "source": "SEC EDGAR",
830
- "as_of": datetime.now().strftime("%Y-%m-%d")
831
- }
832
- except Exception as e:
833
- logger.error(f"Ownership filings error: {e}")
834
- return {"ticker": ticker, "error": str(e)}
835
-
836
-
837
- # ============================================================
838
- # YAHOO FINANCE FALLBACK (when CIK not found)
839
- # ============================================================
840
-
841
- def _fetch_yfinance_financials_sync(ticker: str) -> dict:
842
- """
843
- Synchronous yfinance fetch for financial data.
844
- Used as fallback when SEC EDGAR CIK is not available.
845
- """
846
- try:
847
- tk = yf.Ticker(ticker)
848
- info = tk.info
849
-
850
- if not info or info.get("regularMarketPrice") is None:
851
- return {"error": f"No data found for ticker {ticker}"}
852
-
853
- # Extract financial metrics from yfinance info
854
- revenue = info.get("totalRevenue")
855
- net_income = info.get("netIncomeToCommon")
856
- gross_profit = info.get("grossProfits")
857
- operating_income = info.get("operatingIncome") or info.get("ebitda")
858
- total_assets = info.get("totalAssets")
859
- total_liabilities = info.get("totalDebt")
860
- stockholders_equity = info.get("bookValue")
861
- total_cash = info.get("totalCash")
862
- total_debt = info.get("totalDebt")
863
- free_cash_flow = info.get("freeCashflow")
864
- operating_cash_flow = info.get("operatingCashflow")
865
-
866
- # Calculate margins
867
- gross_margin = None
868
- if revenue and gross_profit and revenue > 0:
869
- gross_margin = round((gross_profit / revenue) * 100, 2)
870
-
871
- operating_margin = info.get("operatingMargins")
872
- if operating_margin:
873
- operating_margin = round(operating_margin * 100, 2)
874
-
875
- net_margin = info.get("profitMargins")
876
- if net_margin:
877
- net_margin = round(net_margin * 100, 2)
878
-
879
- # Revenue growth
880
- revenue_growth = info.get("revenueGrowth")
881
- if revenue_growth:
882
- revenue_growth = round(revenue_growth * 100, 2)
883
-
884
- # Debt to equity
885
- debt_to_equity = info.get("debtToEquity")
886
- if debt_to_equity:
887
- debt_to_equity = round(debt_to_equity / 100, 2) # yfinance returns as percentage
888
-
889
- # Net debt
890
- net_debt = None
891
- if total_debt is not None and total_cash is not None:
892
- net_debt = total_debt - total_cash
893
-
894
- return {
895
- "ticker": ticker.upper(),
896
- "company_name": info.get("longName") or info.get("shortName"),
897
- "sector": info.get("sector"),
898
- "industry": info.get("industry"),
899
- "financials": {
900
- "revenue": {"value": revenue} if revenue else None,
901
- "net_income": {"value": net_income} if net_income else None,
902
- "gross_profit": {"value": gross_profit} if gross_profit else None,
903
- "operating_income": {"value": operating_income} if operating_income else None,
904
- "gross_margin_pct": gross_margin,
905
- "operating_margin_pct": operating_margin,
906
- "net_margin": net_margin,
907
- "revenue_growth_3yr": revenue_growth,
908
- },
909
- "debt": {
910
- "total_debt": {"value": total_debt} if total_debt else None,
911
- "total_cash": {"value": total_cash} if total_cash else None,
912
- "net_debt": {"value": net_debt} if net_debt else None,
913
- "debt_to_equity": debt_to_equity,
914
- },
915
- "cash_flow": {
916
- "operating_cash_flow": {"value": operating_cash_flow} if operating_cash_flow else None,
917
- "free_cash_flow": {"value": free_cash_flow} if free_cash_flow else None,
918
- },
919
- "source": "Yahoo Finance (fallback)",
920
- "fallback": True,
921
- "fallback_reason": "CIK not found in SEC EDGAR",
922
- "as_of": datetime.now().strftime("%Y-%m-%d")
923
- }
924
-
925
- except Exception as e:
926
- logger.error(f"yfinance fallback error for {ticker}: {e}")
927
- return {"error": str(e), "fallback": True}
928
-
929
-
930
- async def fetch_yfinance_fallback(ticker: str) -> dict:
931
- """
932
- Async wrapper for yfinance fallback.
933
- """
934
- loop = asyncio.get_event_loop()
935
- return await loop.run_in_executor(_executor, _fetch_yfinance_financials_sync, ticker)
936
-
937
-
938
- def get_minimal_fallback(ticker: str) -> dict:
939
- """
940
- Third-tier fallback: return minimal valid response when all sources fail.
941
- Ensures 100% response rate even when SEC EDGAR and Yahoo Finance are unavailable.
942
- """
943
- return {
944
- "ticker": ticker.upper(),
945
- "company": {
946
- "name": ticker.upper(),
947
- "cik": None,
948
- "sic": None,
949
- "exchange": None
950
- },
951
- "financials": {
952
- "note": "Financial data temporarily unavailable",
953
- "revenue": None,
954
- "net_income": None,
955
- "gross_margin": None,
956
- "operating_margin": None,
957
- "net_margin": None
958
- },
959
- "debt": {
960
- "note": "Debt metrics temporarily unavailable",
961
- "total_debt": None,
962
- "debt_to_equity": None,
963
- "current_ratio": None
964
- },
965
- "cash_flow": {
966
- "note": "Cash flow data temporarily unavailable",
967
- "operating_cash_flow": None,
968
- "free_cash_flow": None
969
- },
970
- "swot_summary": {
971
- "strengths": [],
972
- "weaknesses": [],
973
- "opportunities": [],
974
- "threats": [],
975
- "note": "SWOT analysis unavailable - data sources temporarily unavailable"
976
- },
977
- "source": "Minimal Fallback (estimated)",
978
- "fallback": True,
979
- "fallback_reason": "SEC EDGAR and Yahoo Finance both unavailable",
980
- "swot_category": "NEUTRAL",
981
- "estimated": True,
982
- "generated_at": datetime.now().strftime("%Y-%m-%d")
983
- }
984
-
985
-
986
- def _build_swot_from_fallback(data: dict) -> dict:
987
- """
988
- Build SWOT summary from Yahoo Finance fallback data.
989
- """
990
- swot_summary = {
991
- "strengths": [],
992
- "weaknesses": [],
993
- "opportunities": [],
994
- "threats": []
995
- }
996
-
997
- financials = data.get("financials", {})
998
- debt = data.get("debt", {})
999
- cash_flow = data.get("cash_flow", {})
1000
-
1001
- # Analyze margins
1002
- net_margin = financials.get("net_margin")
1003
- if net_margin is not None:
1004
- if net_margin > 15:
1005
- swot_summary["strengths"].append(f"High profitability: {net_margin}% net margin")
1006
- elif net_margin > 5:
1007
- swot_summary["strengths"].append(f"Healthy net margin: {net_margin}%")
1008
- elif net_margin < 0:
1009
- swot_summary["weaknesses"].append(f"Unprofitable: {net_margin}% net margin")
1010
- elif net_margin < 5:
1011
- swot_summary["weaknesses"].append(f"Thin margins: {net_margin}% net margin")
1012
-
1013
- op_margin = financials.get("operating_margin_pct")
1014
- if op_margin is not None and op_margin > 20:
1015
- swot_summary["strengths"].append(f"Strong operating efficiency: {op_margin}% operating margin")
1016
-
1017
- # Revenue growth
1018
- growth = financials.get("revenue_growth_3yr")
1019
- if growth is not None:
1020
- if growth > 15:
1021
- swot_summary["strengths"].append(f"Strong revenue growth: {growth}%")
1022
- elif growth > 5:
1023
- swot_summary["strengths"].append(f"Positive revenue growth: {growth}%")
1024
- elif growth < 0:
1025
- swot_summary["weaknesses"].append(f"Declining revenue: {growth}%")
1026
-
1027
- # Debt analysis (handle both dict and plain number formats)
1028
- d_to_e_data = debt.get("debt_to_equity")
1029
- d_to_e = d_to_e_data.get("value") if isinstance(d_to_e_data, dict) else d_to_e_data
1030
- if d_to_e is not None:
1031
- if d_to_e > 2:
1032
- swot_summary["threats"].append(f"High leverage: {d_to_e}x debt-to-equity")
1033
- elif d_to_e > 1:
1034
- swot_summary["weaknesses"].append(f"Elevated debt: {d_to_e}x debt-to-equity")
1035
- elif d_to_e < 0.5:
1036
- swot_summary["strengths"].append(f"Low leverage: {d_to_e}x debt-to-equity")
1037
-
1038
- net_debt_data = debt.get("net_debt")
1039
- if net_debt_data and net_debt_data.get("value"):
1040
- net_debt_val = net_debt_data["value"]
1041
- if net_debt_val < 0:
1042
- swot_summary["strengths"].append("Net cash position (more cash than debt)")
1043
-
1044
- # Cash flow
1045
- fcf_data = cash_flow.get("free_cash_flow")
1046
- if fcf_data and fcf_data.get("value"):
1047
- fcf_val = fcf_data["value"]
1048
- if fcf_val > 0:
1049
- swot_summary["strengths"].append(f"Positive free cash flow: ${fcf_val/1e9:.1f}B")
1050
- else:
1051
- swot_summary["weaknesses"].append(f"Negative free cash flow: ${fcf_val/1e9:.1f}B")
1052
-
1053
- return swot_summary
1054
-
1055
-
1056
- async def get_sec_fundamentals_basket(ticker: str) -> dict:
1057
- """
1058
- Get complete SEC fundamentals basket with SWOT interpretation.
1059
- Falls back to Yahoo Finance if CIK is not found.
1060
- """
1061
- # First, check if CIK exists
1062
- cik = await ticker_to_cik(ticker)
1063
-
1064
- if not cik:
1065
- # Fallback to Yahoo Finance
1066
- logger.info(f"CIK not found for {ticker}, using Yahoo Finance fallback")
1067
- fallback_data = await fetch_yfinance_fallback(ticker)
1068
-
1069
- if "error" in fallback_data:
1070
- # Third-tier fallback: minimal valid response
1071
- logger.info(f"Yahoo Finance also failed for {ticker}, using minimal fallback")
1072
- return get_minimal_fallback(ticker)
1073
-
1074
- # Build SWOT from fallback data
1075
- swot_summary = _build_swot_from_fallback(fallback_data)
1076
- fallback_data["swot_summary"] = swot_summary
1077
- return fallback_data
1078
-
1079
- # Fetch all data concurrently from SEC EDGAR
1080
- company_task = fetch_company_info(ticker)
1081
- financials_task = fetch_financials(ticker)
1082
- debt_task = fetch_debt_metrics(ticker)
1083
- cashflow_task = fetch_cash_flow(ticker)
1084
-
1085
- company, financials, debt, cashflow = await asyncio.gather(
1086
- company_task, financials_task, debt_task, cashflow_task
1087
- )
1088
-
1089
- # Build SWOT summary
1090
- swot_summary = {
1091
- "strengths": [],
1092
- "weaknesses": [],
1093
- "opportunities": [],
1094
- "threats": []
1095
- }
1096
-
1097
- # Analyze financials for SWOT
1098
- if financials and "error" not in financials:
1099
- # Revenue growth
1100
- growth = financials.get("revenue_growth_3yr")
1101
- if growth is not None:
1102
- if growth > 15:
1103
- swot_summary["strengths"].append(f"Strong revenue growth: {growth}% CAGR (3yr)")
1104
- elif growth > 5:
1105
- swot_summary["strengths"].append(f"Positive revenue growth: {growth}% CAGR (3yr)")
1106
- elif growth < 0:
1107
- swot_summary["weaknesses"].append(f"Declining revenue: {growth}% CAGR (3yr)")
1108
-
1109
- # Margins (handle both dict and plain number formats)
1110
- net_margin_data = financials.get("net_margin_pct")
1111
- net_margin = net_margin_data.get("value") if isinstance(net_margin_data, dict) else net_margin_data
1112
- if net_margin is not None:
1113
- if net_margin > 15:
1114
- swot_summary["strengths"].append(f"High profitability: {net_margin}% net margin")
1115
- elif net_margin > 5:
1116
- swot_summary["strengths"].append(f"Healthy net margin: {net_margin}%")
1117
- elif net_margin < 0:
1118
- swot_summary["weaknesses"].append(f"Unprofitable: {net_margin}% net margin")
1119
- elif net_margin < 5:
1120
- swot_summary["weaknesses"].append(f"Thin margins: {net_margin}% net margin")
1121
-
1122
- op_margin_data = financials.get("operating_margin_pct")
1123
- op_margin = op_margin_data.get("value") if isinstance(op_margin_data, dict) else op_margin_data
1124
- if op_margin is not None and op_margin > 20:
1125
- swot_summary["strengths"].append(f"Strong operating efficiency: {op_margin}% operating margin")
1126
-
1127
- # Analyze debt for SWOT (handle both dict and plain number formats)
1128
- if debt and "error" not in debt:
1129
- d_to_e_data = debt.get("debt_to_equity")
1130
- d_to_e = d_to_e_data.get("value") if isinstance(d_to_e_data, dict) else d_to_e_data
1131
- if d_to_e is not None:
1132
- if d_to_e > 2:
1133
- swot_summary["threats"].append(f"High leverage: {d_to_e}x debt-to-equity")
1134
- elif d_to_e > 1:
1135
- swot_summary["weaknesses"].append(f"Elevated debt: {d_to_e}x debt-to-equity")
1136
- elif d_to_e < 0.5:
1137
- swot_summary["strengths"].append(f"Low leverage: {d_to_e}x debt-to-equity")
1138
-
1139
- net_debt_data = debt.get("net_debt")
1140
- if net_debt_data and net_debt_data.get("value"):
1141
- net_debt_val = net_debt_data["value"]
1142
- if net_debt_val < 0:
1143
- swot_summary["strengths"].append("Net cash position (more cash than debt)")
1144
-
1145
- # Analyze cash flow for SWOT
1146
- if cashflow and "error" not in cashflow:
1147
- fcf_data = cashflow.get("free_cash_flow")
1148
- if fcf_data and fcf_data.get("value"):
1149
- fcf_val = fcf_data["value"]
1150
- if fcf_val > 0:
1151
- swot_summary["strengths"].append(f"Positive free cash flow: ${fcf_val/1e9:.1f}B")
1152
- else:
1153
- swot_summary["weaknesses"].append(f"Negative free cash flow: ${fcf_val/1e9:.1f}B")
1154
-
1155
- rd = cashflow.get("rd_expense")
1156
- if rd and rd.get("value"):
1157
- revenue = financials.get("revenue", {}).get("value") if financials else None
1158
- if revenue and revenue > 0:
1159
- rd_pct = (rd["value"] / revenue) * 100
1160
- if rd_pct > 10:
1161
- swot_summary["opportunities"].append(f"High R&D investment: {rd_pct:.1f}% of revenue")
1162
-
1163
- return {
1164
- "ticker": ticker.upper(),
1165
- "company": company,
1166
- "financials": financials,
1167
- "debt": debt,
1168
- "cash_flow": cashflow,
1169
- "swot_summary": swot_summary,
1170
- "generated_at": datetime.now().strftime("%Y-%m-%d")
1171
- }
1172
-
1173
-
1174
- async def get_all_sources_fundamentals(ticker: str) -> dict:
1175
- """
1176
- Fetch financials from ALL sources (SEC EDGAR AND Yahoo Finance) in parallel.
1177
- Returns both results for comparison, not as a fallback chain.
1178
- """
1179
- # Fetch from both sources in parallel
1180
- sec_task = get_sec_fundamentals_basket(ticker)
1181
- yfinance_task = fetch_yfinance_fallback(ticker)
1182
-
1183
- sec_result, yfinance_result = await asyncio.gather(sec_task, yfinance_task)
1184
-
1185
- # Format SEC EDGAR results
1186
- sec_data = {
1187
- "source": "SEC EDGAR XBRL",
1188
- "as_of": sec_result.get("generated_at"),
1189
- "data": {}
1190
- }
1191
-
1192
- if sec_result.get("financials") and "error" not in sec_result.get("financials", {}):
1193
- fin = sec_result["financials"]
1194
- # Only 6 universal metrics that work across ALL industries
1195
- sec_data["data"] = {
1196
- "revenue": fin.get("revenue"),
1197
- "net_income": fin.get("net_income"),
1198
- "net_margin_pct": fin.get("net_margin_pct"),
1199
- "total_assets": fin.get("total_assets"),
1200
- "total_liabilities": fin.get("total_liabilities"),
1201
- "stockholders_equity": fin.get("stockholders_equity"),
1202
- }
1203
-
1204
- # Format Yahoo Finance results
1205
- yfinance_data = {
1206
- "source": "Yahoo Finance",
1207
- "as_of": yfinance_result.get("as_of") or datetime.now().strftime("%Y-%m-%d"),
1208
- "data": {}
1209
- }
1210
-
1211
- if "error" not in yfinance_result:
1212
- fin = yfinance_result.get("financials", {})
1213
- debt = yfinance_result.get("debt", {})
1214
- cf = yfinance_result.get("cash_flow", {})
1215
-
1216
- # Helper to extract raw value (handles both dict and non-dict)
1217
- def get_val(d, key):
1218
- v = d.get(key)
1219
- if isinstance(v, dict):
1220
- return v.get("value")
1221
- return v
1222
-
1223
- # Check if SEC EDGAR failed (no data)
1224
- sec_failed = not sec_data.get("data")
1225
-
1226
- if sec_failed:
1227
- # FALLBACK: Yahoo provides core metrics when SEC fails
1228
- yfinance_data["data"] = {
1229
- "revenue": {"value": get_val(fin, "revenue"), "period": "TTM"} if get_val(fin, "revenue") else None,
1230
- "net_income": {"value": get_val(fin, "net_income"), "period": "TTM"} if get_val(fin, "net_income") else None,
1231
- "net_margin_pct": {"value": get_val(fin, "net_margin")} if get_val(fin, "net_margin") else None,
1232
- "total_assets": {"value": get_val(debt, "total_assets")} if get_val(debt, "total_assets") else None,
1233
- "operating_margin_pct": {"value": get_val(fin, "operating_margin_pct")} if get_val(fin, "operating_margin_pct") else None,
1234
- "total_debt": {"value": get_val(debt, "total_debt")} if get_val(debt, "total_debt") else None,
1235
- "operating_cash_flow": {"value": get_val(cf, "operating_cash_flow")} if get_val(cf, "operating_cash_flow") else None,
1236
- "free_cash_flow": {"value": get_val(cf, "free_cash_flow")} if get_val(cf, "free_cash_flow") else None,
1237
- }
1238
- else:
1239
- # SUPPLEMENTARY: Only additional metrics not in SEC EDGAR
1240
- yfinance_data["data"] = {
1241
- "operating_margin_pct": {"value": get_val(fin, "operating_margin_pct")} if get_val(fin, "operating_margin_pct") else None,
1242
- "total_debt": {"value": get_val(debt, "total_debt")} if get_val(debt, "total_debt") else None,
1243
- "operating_cash_flow": {"value": get_val(cf, "operating_cash_flow")} if get_val(cf, "operating_cash_flow") else None,
1244
- "free_cash_flow": {"value": get_val(cf, "free_cash_flow")} if get_val(cf, "free_cash_flow") else None,
1245
- }
1246
- else:
1247
- yfinance_data["error"] = yfinance_result.get("error")
1248
-
1249
- return {
1250
- "ticker": ticker.upper(),
1251
- "sec_edgar": sec_data,
1252
- "yahoo_finance": yfinance_data,
1253
- "generated_at": datetime.now().strftime("%Y-%m-%d")
1254
- }
1255
-
1256
-
1257
- # ============================================================
1258
- # MCP TOOL DEFINITIONS
1259
- # ============================================================
1260
-
1261
- @server.list_tools()
1262
- async def list_tools():
1263
- """List available SEC EDGAR tools."""
1264
- return [
1265
- Tool(
1266
- name="get_company_info",
1267
- description="Get basic company information from SEC EDGAR (name, industry, CIK).",
1268
- inputSchema={
1269
- "type": "object",
1270
- "properties": {
1271
- "ticker": {
1272
- "type": "string",
1273
- "description": "Stock ticker symbol (e.g., AAPL, TSLA)"
1274
- }
1275
- },
1276
- "required": ["ticker"]
1277
- }
1278
- ),
1279
- Tool(
1280
- name="get_financials",
1281
- description="Get key financial metrics from SEC filings (revenue, income, margins).",
1282
- inputSchema={
1283
- "type": "object",
1284
- "properties": {
1285
- "ticker": {
1286
- "type": "string",
1287
- "description": "Stock ticker symbol"
1288
- }
1289
- },
1290
- "required": ["ticker"]
1291
- }
1292
- ),
1293
- Tool(
1294
- name="get_debt_metrics",
1295
- description="Get debt and leverage metrics (debt levels, debt-to-equity ratio).",
1296
- inputSchema={
1297
- "type": "object",
1298
- "properties": {
1299
- "ticker": {
1300
- "type": "string",
1301
- "description": "Stock ticker symbol"
1302
- }
1303
- },
1304
- "required": ["ticker"]
1305
- }
1306
- ),
1307
- Tool(
1308
- name="get_cash_flow",
1309
- description="Get cash flow metrics (operating CF, CapEx, free cash flow, R&D).",
1310
- inputSchema={
1311
- "type": "object",
1312
- "properties": {
1313
- "ticker": {
1314
- "type": "string",
1315
- "description": "Stock ticker symbol"
1316
- }
1317
- },
1318
- "required": ["ticker"]
1319
- }
1320
- ),
1321
- Tool(
1322
- name="get_sec_fundamentals",
1323
- description="Get complete SEC fundamentals basket with aggregated SWOT summary.",
1324
- inputSchema={
1325
- "type": "object",
1326
- "properties": {
1327
- "ticker": {
1328
- "type": "string",
1329
- "description": "Stock ticker symbol"
1330
- }
1331
- },
1332
- "required": ["ticker"]
1333
- }
1334
- ),
1335
- Tool(
1336
- name="get_material_events",
1337
- description="Get recent 8-K material events (bankruptcy, impairments, executive changes, delisting).",
1338
- inputSchema={
1339
- "type": "object",
1340
- "properties": {
1341
- "ticker": {
1342
- "type": "string",
1343
- "description": "Stock ticker symbol"
1344
- },
1345
- "limit": {
1346
- "type": "integer",
1347
- "description": "Number of recent 8-K filings to return (default: 20)",
1348
- "default": 20
1349
- }
1350
- },
1351
- "required": ["ticker"]
1352
- }
1353
- ),
1354
- Tool(
1355
- name="get_ownership_filings",
1356
- description="Get ownership filings: 13D/13G (5%+ ownership changes), Form 4 (insider transactions).",
1357
- inputSchema={
1358
- "type": "object",
1359
- "properties": {
1360
- "ticker": {
1361
- "type": "string",
1362
- "description": "Stock ticker symbol"
1363
- },
1364
- "limit": {
1365
- "type": "integer",
1366
- "description": "Number of filings per category to return (default: 20)",
1367
- "default": 20
1368
- }
1369
- },
1370
- "required": ["ticker"]
1371
- }
1372
- ),
1373
- Tool(
1374
- name="get_going_concern",
1375
- description="Search latest 10-K for going concern warnings (substantial doubt, liquidity issues).",
1376
- inputSchema={
1377
- "type": "object",
1378
- "properties": {
1379
- "ticker": {
1380
- "type": "string",
1381
- "description": "Stock ticker symbol"
1382
- }
1383
- },
1384
- "required": ["ticker"]
1385
- }
1386
- ),
1387
- Tool(
1388
- name="get_all_sources_fundamentals",
1389
- description="Get financials from ALL sources (SEC EDGAR + Yahoo Finance) for side-by-side comparison.",
1390
- inputSchema={
1391
- "type": "object",
1392
- "properties": {
1393
- "ticker": {
1394
- "type": "string",
1395
- "description": "Stock ticker symbol"
1396
- }
1397
- },
1398
- "required": ["ticker"]
1399
- }
1400
- )
1401
- ]
1402
-
1403
-
1404
- # Global timeout for all tool operations (seconds)
1405
- TOOL_TIMEOUT = 45.0
1406
-
1407
-
1408
- async def _execute_tool_with_timeout(name: str, ticker: str, arguments: dict) -> dict:
1409
- """Execute a tool with timeout. Returns result dict or error dict."""
1410
- if name == "get_company_info":
1411
- return await fetch_company_info(ticker)
1412
- elif name == "get_financials":
1413
- return await fetch_financials(ticker)
1414
- elif name == "get_debt_metrics":
1415
- return await fetch_debt_metrics(ticker)
1416
- elif name == "get_cash_flow":
1417
- return await fetch_cash_flow(ticker)
1418
- elif name == "get_sec_fundamentals":
1419
- return await get_sec_fundamentals_basket(ticker)
1420
- elif name == "get_material_events":
1421
- limit = arguments.get("limit", 20)
1422
- return await fetch_material_events(ticker, limit)
1423
- elif name == "get_ownership_filings":
1424
- limit = arguments.get("limit", 20)
1425
- return await fetch_ownership_filings(ticker, limit)
1426
- elif name == "get_going_concern":
1427
- return await fetch_going_concern(ticker)
1428
- elif name == "get_all_sources_fundamentals":
1429
- return await get_all_sources_fundamentals(ticker)
1430
- else:
1431
- return {"error": f"Unknown tool: {name}"}
1432
-
1433
-
1434
- @server.call_tool()
1435
- async def call_tool(name: str, arguments: dict):
1436
- """
1437
- Handle tool invocations with GUARANTEED JSON-RPC response.
1438
-
1439
- This function ALWAYS returns a valid TextContent response, even if:
1440
- - External APIs timeout
1441
- - Exceptions occur during processing
1442
- - Any unexpected error happens
1443
-
1444
- This ensures MCP protocol compliance and prevents client hangs.
1445
- """
1446
- try:
1447
- ticker = arguments.get("ticker", "").upper()
1448
- if not ticker and name != "list_tools":
1449
- return [TextContent(type="text", text=json.dumps({
1450
- "error": "ticker is required",
1451
- "ticker": None,
1452
- "source": "fundamentals-basket"
1453
- }))]
1454
-
1455
- # Execute tool with global timeout
1456
- try:
1457
- result = await asyncio.wait_for(
1458
- _execute_tool_with_timeout(name, ticker, arguments),
1459
- timeout=TOOL_TIMEOUT
1460
- )
1461
- except asyncio.TimeoutError:
1462
- logger.error(f"Tool {name} timed out after {TOOL_TIMEOUT}s for {ticker}")
1463
- result = {
1464
- "error": f"Tool execution timed out after {TOOL_TIMEOUT} seconds",
1465
- "ticker": ticker,
1466
- "tool": name,
1467
- "source": "fundamentals-basket",
1468
- "fallback": True
1469
- }
1470
-
1471
- # Ensure result is JSON serializable
1472
- return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))]
1473
-
1474
- except json.JSONDecodeError as e:
1475
- logger.error(f"JSON serialization error for {name}: {e}")
1476
- return [TextContent(type="text", text=json.dumps({
1477
- "error": f"JSON serialization failed: {str(e)}",
1478
- "ticker": arguments.get("ticker", ""),
1479
- "tool": name,
1480
- "source": "fundamentals-basket"
1481
- }))]
1482
-
1483
- except Exception as e:
1484
- # Catch-all: ALWAYS return valid JSON-RPC response
1485
- logger.error(f"Unexpected error in {name}: {type(e).__name__}: {e}")
1486
- return [TextContent(type="text", text=json.dumps({
1487
- "error": f"{type(e).__name__}: {str(e)}",
1488
- "ticker": arguments.get("ticker", ""),
1489
- "tool": name,
1490
- "source": "fundamentals-basket",
1491
- "fallback": True
1492
- }))]
1493
-
1494
-
1495
- # ============================================================
1496
- # MAIN
1497
- # ============================================================
1498
-
1499
- async def main():
1500
- """Run the MCP server."""
1501
- async with stdio_server() as (read_stream, write_stream):
1502
- await server.run(read_stream, write_stream, server.create_initialization_options())
1503
-
1504
-
1505
- if __name__ == "__main__":
1506
- asyncio.run(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
mcp-servers/macro-basket/server.py CHANGED
@@ -976,7 +976,8 @@ async def list_tools():
976
 
977
 
978
  # Global timeout for all tool operations (seconds)
979
- TOOL_TIMEOUT = 45.0
 
980
 
981
 
982
  async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict:
 
976
 
977
 
978
  # Global timeout for all tool operations (seconds)
979
+ # Increased to 90s to handle slow BEA/BLS/FRED API responses on HuggingFace
980
+ TOOL_TIMEOUT = 90.0
981
 
982
 
983
  async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict:
mcp-servers/news-basket/server.py CHANGED
@@ -658,7 +658,7 @@ async def list_tools():
658
 
659
 
660
  # Global timeout for all tool operations (seconds)
661
- TOOL_TIMEOUT = 45.0
662
 
663
 
664
  async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict:
 
658
 
659
 
660
  # Global timeout for all tool operations (seconds)
661
+ TOOL_TIMEOUT = 90.0 # Match mcp_client timeout
662
 
663
 
664
  async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict:
mcp-servers/sentiment-basket/server.py CHANGED
@@ -327,7 +327,7 @@ async def list_tools():
327
 
328
  # Global timeout for all tool operations (seconds)
329
  # Increased for completeness-first mode
330
- TOOL_TIMEOUT = 60.0
331
 
332
 
333
  async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict:
 
327
 
328
  # Global timeout for all tool operations (seconds)
329
  # Increased for completeness-first mode
330
+ TOOL_TIMEOUT = 90.0 # Match mcp_client timeout
331
 
332
 
333
  async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict:
mcp-servers/valuation-basket/server.py CHANGED
@@ -809,7 +809,7 @@ async def list_tools():
809
 
810
 
811
  # Global timeout for all tool operations (seconds)
812
- TOOL_TIMEOUT = 45.0
813
 
814
 
815
  async def _execute_tool_with_timeout(name: str, ticker: str, arguments: dict) -> dict:
 
809
 
810
 
811
  # Global timeout for all tool operations (seconds)
812
+ TOOL_TIMEOUT = 90.0 # Match mcp_client timeout
813
 
814
 
815
  async def _execute_tool_with_timeout(name: str, ticker: str, arguments: dict) -> dict:
mcp-servers/volatility-basket/server.py CHANGED
@@ -1098,7 +1098,7 @@ async def list_tools():
1098
 
1099
 
1100
  # Global timeout for all tool operations (seconds)
1101
- TOOL_TIMEOUT = 45.0
1102
 
1103
 
1104
  async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict:
 
1098
 
1099
 
1100
  # Global timeout for all tool operations (seconds)
1101
+ TOOL_TIMEOUT = 90.0 # Match mcp_client timeout
1102
 
1103
 
1104
  async def _execute_tool_with_timeout(name: str, arguments: dict) -> dict:
tests/test_mcp_e2e.py CHANGED
@@ -1,43 +1,33 @@
1
  """
2
- E2E test for all 6 MCP servers.
3
- Fetches data, validates responses, and generates a markdown report.
4
 
5
  Usage: python tests/test_mcp_e2e.py [TICKER] [COMPANY_NAME]
6
  Default: KO "The Coca-Cola Company"
7
  """
8
  import asyncio
9
  import sys
10
- import os
11
- import importlib.util
12
- from datetime import datetime, timedelta
13
  from pathlib import Path
14
- from typing import Any, Dict, List, Optional, Callable
15
 
16
  # Project root
17
  PROJECT_ROOT = Path(__file__).parent.parent
 
18
 
19
  # Load environment variables from project .env
20
  from dotenv import load_dotenv
21
  load_dotenv(PROJECT_ROOT / ".env")
22
 
23
-
24
- def load_module_from_path(module_name: str, file_path: Path):
25
- """Dynamically load a module from a specific file path."""
26
- spec = importlib.util.spec_from_file_location(module_name, file_path)
27
- module = importlib.util.module_from_spec(spec)
28
-
29
- # Add the module's directory to sys.path temporarily for relative imports
30
- module_dir = str(file_path.parent)
31
- if module_dir not in sys.path:
32
- sys.path.insert(0, module_dir)
33
-
34
- spec.loader.exec_module(module)
35
- return module
36
 
37
  # Default test company
38
  DEFAULT_TICKER = "KO"
39
  DEFAULT_COMPANY = "The Coca-Cola Company"
40
 
 
 
 
41
 
42
  class MCPTestResult:
43
  """Result from testing a single MCP."""
@@ -52,25 +42,31 @@ class MCPTestResult:
52
 
53
 
54
  async def test_fundamentals(ticker: str) -> MCPTestResult:
55
- """Test fundamentals-basket MCP."""
56
  result = MCPTestResult("fundamentals")
57
  start = datetime.now()
58
 
59
  try:
60
- module_path = PROJECT_ROOT / "mcp-servers" / "fundamentals-basket" / "server_legacy.py"
61
- module = load_module_from_path("fundamentals_server", module_path)
62
- get_all_sources_fundamentals = module.get_all_sources_fundamentals
63
-
64
- data = await get_all_sources_fundamentals(ticker)
 
65
  result.data = data
66
 
67
  if not isinstance(data, dict):
68
  result.errors.append("Response is not a dict")
69
  return result
70
 
71
- # Schema validation - fundamentals uses sec_edgar/yahoo_finance keys
72
- sec_data = data.get("sec_edgar", {})
73
- yahoo_data = data.get("yahoo_finance", {})
 
 
 
 
 
74
 
75
  if not sec_data and not yahoo_data:
76
  result.errors.append("No SEC or Yahoo data")
@@ -94,22 +90,27 @@ async def test_fundamentals(ticker: str) -> MCPTestResult:
94
 
95
 
96
  async def test_valuation(ticker: str) -> MCPTestResult:
97
- """Test valuation-basket MCP."""
98
  result = MCPTestResult("valuation")
99
  start = datetime.now()
100
 
101
  try:
102
- module_path = PROJECT_ROOT / "mcp-servers" / "valuation-basket" / "server.py"
103
- module = load_module_from_path("valuation_server", module_path)
104
- get_all_sources_valuation = module.get_all_sources_valuation
105
-
106
- data = await get_all_sources_valuation(ticker)
 
107
  result.data = data
108
 
109
  if not isinstance(data, dict):
110
  result.errors.append("Response is not a dict")
111
  return result
112
 
 
 
 
 
113
  # Schema validation
114
  if "sources" not in data:
115
  result.errors.append("Missing 'sources' key")
@@ -132,22 +133,27 @@ async def test_valuation(ticker: str) -> MCPTestResult:
132
 
133
 
134
  async def test_volatility(ticker: str) -> MCPTestResult:
135
- """Test volatility-basket MCP."""
136
  result = MCPTestResult("volatility")
137
  start = datetime.now()
138
 
139
  try:
140
- module_path = PROJECT_ROOT / "mcp-servers" / "volatility-basket" / "server.py"
141
- module = load_module_from_path("volatility_server", module_path)
142
- get_all_sources_volatility = module.get_all_sources_volatility
143
-
144
- data = await get_all_sources_volatility(ticker)
 
145
  result.data = data
146
 
147
  if not isinstance(data, dict):
148
  result.errors.append("Response is not a dict")
149
  return result
150
 
 
 
 
 
151
  # Schema validation
152
  if "metrics" not in data:
153
  result.errors.append("Missing 'metrics' key")
@@ -166,22 +172,27 @@ async def test_volatility(ticker: str) -> MCPTestResult:
166
 
167
 
168
  async def test_macro() -> MCPTestResult:
169
- """Test macro-basket MCP."""
170
  result = MCPTestResult("macro")
171
  start = datetime.now()
172
 
173
  try:
174
- module_path = PROJECT_ROOT / "mcp-servers" / "macro-basket" / "server.py"
175
- module = load_module_from_path("macro_server", module_path)
176
- get_all_sources_macro = module.get_all_sources_macro
177
-
178
- data = await get_all_sources_macro()
 
179
  result.data = data
180
 
181
  if not isinstance(data, dict):
182
  result.errors.append("Response is not a dict")
183
  return result
184
 
 
 
 
 
185
  # Schema validation
186
  if "metrics" not in data:
187
  result.errors.append("Missing 'metrics' key")
@@ -200,22 +211,27 @@ async def test_macro() -> MCPTestResult:
200
 
201
 
202
  async def test_news(ticker: str, company_name: str) -> MCPTestResult:
203
- """Test news-basket MCP."""
204
  result = MCPTestResult("news")
205
  start = datetime.now()
206
 
207
  try:
208
- module_path = PROJECT_ROOT / "mcp-servers" / "news-basket" / "server.py"
209
- module = load_module_from_path("news_server", module_path)
210
- get_all_sources_news = module.get_all_sources_news
211
-
212
- data = await get_all_sources_news(ticker, company_name)
 
213
  result.data = data
214
 
215
  if not isinstance(data, dict):
216
  result.errors.append("Response is not a dict")
217
  return result
218
 
 
 
 
 
219
  # Schema validation
220
  if "items" not in data:
221
  result.errors.append("Missing 'items' key")
@@ -244,22 +260,27 @@ async def test_news(ticker: str, company_name: str) -> MCPTestResult:
244
 
245
 
246
  async def test_sentiment(ticker: str, company_name: str) -> MCPTestResult:
247
- """Test sentiment-basket MCP."""
248
  result = MCPTestResult("sentiment")
249
  start = datetime.now()
250
 
251
  try:
252
- module_path = PROJECT_ROOT / "mcp-servers" / "sentiment-basket" / "server.py"
253
- module = load_module_from_path("sentiment_server", module_path)
254
- get_all_sources_sentiment = module.get_all_sources_sentiment
255
-
256
- data = await get_all_sources_sentiment(ticker, company_name)
 
257
  result.data = data
258
 
259
  if not isinstance(data, dict):
260
  result.errors.append("Response is not a dict")
261
  return result
262
 
 
 
 
 
263
  # Schema validation
264
  if "items" not in data:
265
  result.errors.append("Missing 'items' key")
@@ -342,11 +363,13 @@ def extract_quantitative_rows(results: List[MCPTestResult], ticker: str) -> List
342
  """Extract quantitative data rows from results."""
343
  rows = []
344
 
345
- # Fundamentals - uses sec_edgar/yahoo_finance structure with nested 'data' key
346
  fund_result = next((r for r in results if r.name == "fundamentals"), None)
347
  if fund_result and fund_result.data:
348
- # SEC EDGAR data - metrics are inside .data
349
- sec_wrapper = fund_result.data.get("sec_edgar", {})
 
 
350
  sec_data = sec_wrapper.get("data", {}) if isinstance(sec_wrapper, dict) else {}
351
  for metric_name, metric_val in sec_data.items():
352
  if isinstance(metric_val, dict):
@@ -360,8 +383,8 @@ def extract_quantitative_rows(results: List[MCPTestResult], ticker: str) -> List
360
  "category": "Fundamentals",
361
  })
362
 
363
- # Yahoo Finance data - metrics are inside .data, as_of is at wrapper level
364
- yahoo_wrapper = fund_result.data.get("yahoo_finance", {})
365
  yahoo_as_of = yahoo_wrapper.get("as_of", "-") if isinstance(yahoo_wrapper, dict) else "-"
366
  yahoo_data = yahoo_wrapper.get("data", {}) if isinstance(yahoo_wrapper, dict) else {}
367
  for metric_name, metric_val in yahoo_data.items():
 
1
  """
2
+ E2E test for all 6 MCP servers using subprocess+MCP protocol (same as production).
 
3
 
4
  Usage: python tests/test_mcp_e2e.py [TICKER] [COMPANY_NAME]
5
  Default: KO "The Coca-Cola Company"
6
  """
7
  import asyncio
8
  import sys
9
+ from datetime import datetime
 
 
10
  from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
 
13
  # Project root
14
  PROJECT_ROOT = Path(__file__).parent.parent
15
+ sys.path.insert(0, str(PROJECT_ROOT))
16
 
17
  # Load environment variables from project .env
18
  from dotenv import load_dotenv
19
  load_dotenv(PROJECT_ROOT / ".env")
20
 
21
+ # Import MCP client (subprocess+MCP protocol)
22
+ from mcp_client import call_mcp_server
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  # Default test company
25
  DEFAULT_TICKER = "KO"
26
  DEFAULT_COMPANY = "The Coca-Cola Company"
27
 
28
+ # MCP server timeout (seconds)
29
+ MCP_TIMEOUT = 90.0
30
+
31
 
32
  class MCPTestResult:
33
  """Result from testing a single MCP."""
 
42
 
43
 
44
  async def test_fundamentals(ticker: str) -> MCPTestResult:
45
+ """Test fundamentals-basket MCP via subprocess+MCP protocol."""
46
  result = MCPTestResult("fundamentals")
47
  start = datetime.now()
48
 
49
  try:
50
+ data = await call_mcp_server(
51
+ "fundamentals-basket",
52
+ "get_all_sources_fundamentals",
53
+ {"ticker": ticker},
54
+ timeout=MCP_TIMEOUT
55
+ )
56
  result.data = data
57
 
58
  if not isinstance(data, dict):
59
  result.errors.append("Response is not a dict")
60
  return result
61
 
62
+ if "error" in data:
63
+ result.errors.append(f"MCP error: {data['error']}")
64
+ return result
65
+
66
+ # Schema validation - fundamentals uses sources.sec_edgar/sources.yahoo_finance
67
+ sources = data.get("sources", {})
68
+ sec_data = sources.get("sec_edgar", {})
69
+ yahoo_data = sources.get("yahoo_finance", {})
70
 
71
  if not sec_data and not yahoo_data:
72
  result.errors.append("No SEC or Yahoo data")
 
90
 
91
 
92
  async def test_valuation(ticker: str) -> MCPTestResult:
93
+ """Test valuation-basket MCP via subprocess+MCP protocol."""
94
  result = MCPTestResult("valuation")
95
  start = datetime.now()
96
 
97
  try:
98
+ data = await call_mcp_server(
99
+ "valuation-basket",
100
+ "get_all_sources_valuation",
101
+ {"ticker": ticker},
102
+ timeout=MCP_TIMEOUT
103
+ )
104
  result.data = data
105
 
106
  if not isinstance(data, dict):
107
  result.errors.append("Response is not a dict")
108
  return result
109
 
110
+ if "error" in data:
111
+ result.errors.append(f"MCP error: {data['error']}")
112
+ return result
113
+
114
  # Schema validation
115
  if "sources" not in data:
116
  result.errors.append("Missing 'sources' key")
 
133
 
134
 
135
  async def test_volatility(ticker: str) -> MCPTestResult:
136
+ """Test volatility-basket MCP via subprocess+MCP protocol."""
137
  result = MCPTestResult("volatility")
138
  start = datetime.now()
139
 
140
  try:
141
+ data = await call_mcp_server(
142
+ "volatility-basket",
143
+ "get_all_sources_volatility",
144
+ {"ticker": ticker},
145
+ timeout=MCP_TIMEOUT
146
+ )
147
  result.data = data
148
 
149
  if not isinstance(data, dict):
150
  result.errors.append("Response is not a dict")
151
  return result
152
 
153
+ if "error" in data:
154
+ result.errors.append(f"MCP error: {data['error']}")
155
+ return result
156
+
157
  # Schema validation
158
  if "metrics" not in data:
159
  result.errors.append("Missing 'metrics' key")
 
172
 
173
 
174
  async def test_macro() -> MCPTestResult:
175
+ """Test macro-basket MCP via subprocess+MCP protocol."""
176
  result = MCPTestResult("macro")
177
  start = datetime.now()
178
 
179
  try:
180
+ data = await call_mcp_server(
181
+ "macro-basket",
182
+ "get_all_sources_macro",
183
+ {},
184
+ timeout=MCP_TIMEOUT
185
+ )
186
  result.data = data
187
 
188
  if not isinstance(data, dict):
189
  result.errors.append("Response is not a dict")
190
  return result
191
 
192
+ if "error" in data:
193
+ result.errors.append(f"MCP error: {data['error']}")
194
+ return result
195
+
196
  # Schema validation
197
  if "metrics" not in data:
198
  result.errors.append("Missing 'metrics' key")
 
211
 
212
 
213
  async def test_news(ticker: str, company_name: str) -> MCPTestResult:
214
+ """Test news-basket MCP via subprocess+MCP protocol."""
215
  result = MCPTestResult("news")
216
  start = datetime.now()
217
 
218
  try:
219
+ data = await call_mcp_server(
220
+ "news-basket",
221
+ "get_all_sources_news",
222
+ {"ticker": ticker, "company_name": company_name},
223
+ timeout=MCP_TIMEOUT
224
+ )
225
  result.data = data
226
 
227
  if not isinstance(data, dict):
228
  result.errors.append("Response is not a dict")
229
  return result
230
 
231
+ if "error" in data:
232
+ result.errors.append(f"MCP error: {data['error']}")
233
+ return result
234
+
235
  # Schema validation
236
  if "items" not in data:
237
  result.errors.append("Missing 'items' key")
 
260
 
261
 
262
  async def test_sentiment(ticker: str, company_name: str) -> MCPTestResult:
263
+ """Test sentiment-basket MCP via subprocess+MCP protocol."""
264
  result = MCPTestResult("sentiment")
265
  start = datetime.now()
266
 
267
  try:
268
+ data = await call_mcp_server(
269
+ "sentiment-basket",
270
+ "get_sentiment_basket",
271
+ {"ticker": ticker, "company_name": company_name},
272
+ timeout=MCP_TIMEOUT
273
+ )
274
  result.data = data
275
 
276
  if not isinstance(data, dict):
277
  result.errors.append("Response is not a dict")
278
  return result
279
 
280
+ if "error" in data:
281
+ result.errors.append(f"MCP error: {data['error']}")
282
+ return result
283
+
284
  # Schema validation
285
  if "items" not in data:
286
  result.errors.append("Missing 'items' key")
 
363
  """Extract quantitative data rows from results."""
364
  rows = []
365
 
366
+ # Fundamentals - uses sources.sec_edgar/sources.yahoo_finance with nested 'data' key
367
  fund_result = next((r for r in results if r.name == "fundamentals"), None)
368
  if fund_result and fund_result.data:
369
+ sources = fund_result.data.get("sources", {})
370
+
371
+ # SEC EDGAR data - metrics are inside sources.sec_edgar.data
372
+ sec_wrapper = sources.get("sec_edgar", {})
373
  sec_data = sec_wrapper.get("data", {}) if isinstance(sec_wrapper, dict) else {}
374
  for metric_name, metric_val in sec_data.items():
375
  if isinstance(metric_val, dict):
 
383
  "category": "Fundamentals",
384
  })
385
 
386
+ # Yahoo Finance data - metrics are inside sources.yahoo_finance.data
387
+ yahoo_wrapper = sources.get("yahoo_finance", {})
388
  yahoo_as_of = yahoo_wrapper.get("as_of", "-") if isinstance(yahoo_wrapper, dict) else "-"
389
  yahoo_data = yahoo_wrapper.get("data", {}) if isinstance(yahoo_wrapper, dict) else {}
390
  for metric_name, metric_val in yahoo_data.items():