main
py 1,065 lines 41.6 KB
Raw
1 """Yahoo Finance MCP adapter with explicit capabilities and strict normalization.
2
3 The repository-owned first-party server returns the versioned contract validated
4 below. A capability remains callable only when configuration marks one exact
5 region/requirement/tool mapping SUPPORTED; malformed or incomplete results fail
6 closed so policy can use the existing regional fallback.
7 """
8 from __future__ import annotations
9
10 import asyncio
11 import json
12 import math
13 import os
14 from contextlib import asynccontextmanager
15 from dataclasses import dataclass
16 from datetime import date, datetime, timedelta, timezone
17 from decimal import Decimal, InvalidOperation
18 from enum import StrEnum
19 from typing import Any, AsyncContextManager, Callable, Protocol
20 from urllib.parse import quote
21
22 import httpx2
23 from mcp import Client, StdioServerParameters
24 from mcp.client.streamable_http import streamable_http_client
25 from opentelemetry.propagate import inject
26 from pydantic import Field, SecretStr, ValidationError, field_validator, model_validator
27
28 from app.contracts import McpErrorCode, McpGatewayError, McpRiskClass, StrictContract
29 from app.external import (
30 ExternalCapabilityState,
31 ExternalMcpProviderCapability,
32 ExternalMcpProviderMetadata,
33 ExternalProviderHealthState,
34 )
35 from app.resilience import McpCircuitBreaker
36
37
38 PROVIDER_ID = "YAHOO_FINANCE_MCP"
39 ADAPTER_VERSION = "YAHOO_FINANCE_MCP_ADAPTER_V1"
40 TOOL_SCHEMA_VERSION = "YAHOO_FINANCE_MCP_TOOL_V1"
41 SUPPORTED_REGIONS = ("INDIA", "USA", "EUROPE")
42
43
44 class _RetryableProviderFailure(Exception):
45 def __init__(self, code: McpErrorCode) -> None:
46 super().__init__(code.value)
47 self.code = code
48
49
50 _FIRST_PARTY_ERRORS = {
51 "YAHOO_MCP_UPSTREAM_TIMEOUT": McpErrorCode.DOWNSTREAM_TIMEOUT,
52 "YAHOO_MCP_UPSTREAM_UNAVAILABLE": McpErrorCode.EXTERNAL_PROVIDER_UNAVAILABLE,
53 "YAHOO_MCP_RATE_LIMITED": McpErrorCode.EXTERNAL_PROVIDER_RATE_LIMITED,
54 "YAHOO_MCP_UNSUPPORTED": McpErrorCode.EXTERNAL_CAPABILITY_UNSUPPORTED,
55 "YAHOO_MCP_INVALID_RESPONSE": McpErrorCode.EXTERNAL_SCHEMA_INVALID,
56 "YAHOO_MCP_INCOMPLETE": McpErrorCode.EXTERNAL_RESULT_INCOMPLETE,
57 "YAHOO_MCP_IDENTITY_MISMATCH": McpErrorCode.EXTERNAL_IDENTITY_CONFLICT,
58 }
59
60 _READINESS_CATALYST_EVENT_TYPES = frozenset(
61 {
62 "NEW_ORDER",
63 "ORDER_BACKLOG_CHANGE",
64 "MAJOR_CONTRACT",
65 "GOVERNMENT_CONTRACT",
66 "ORDER_CANCELLED",
67 "CAPEX",
68 "FACTORY_EXPANSION",
69 "CAPACITY_EXPANSION",
70 "NEW_FACILITY",
71 "PROJECT_DELAY",
72 "MANAGEMENT_GUIDANCE",
73 "GUIDANCE_RAISED",
74 "GUIDANCE_LOWERED",
75 "GUIDANCE_CUT",
76 "GUIDANCE_MAINTAINED",
77 "REVENUE_GUIDANCE",
78 "MARGIN_GUIDANCE",
79 }
80 )
81
82
83 class YahooMcpCapability(StrEnum):
84 LATEST_PRICE = "LATEST_PRICE"
85 MARKET_HISTORY = "MARKET_HISTORY"
86 COMPANY_PROFILE = "COMPANY_PROFILE"
87 VALUATION_INPUTS = "VALUATION_INPUTS"
88 ANNUAL_FINANCIALS = "ANNUAL_FINANCIALS"
89 QUARTERLY_FINANCIALS = "QUARTERLY_FINANCIALS"
90 BALANCE_SHEET = "BALANCE_SHEET"
91 INCOME_STATEMENT = "INCOME_STATEMENT"
92 CASH_FLOW = "CASH_FLOW"
93 GROWTH_INPUTS = "GROWTH_INPUTS"
94 EARNINGS_TREND = "EARNINGS_TREND"
95 NEWS = "NEWS"
96 CATALYSTS_EVENTS = "CATALYSTS_EVENTS"
97 SHAREHOLDING = "SHAREHOLDING"
98 SECTOR_INDUSTRY = "SECTOR_INDUSTRY"
99 ANALYST_DATA = "ANALYST_DATA"
100 TECHNICAL_PRICE_INPUTS = "TECHNICAL_PRICE_INPUTS"
101
102
103 _REQUIREMENT_CAPABILITY = {
104 "LATEST_PRICE": YahooMcpCapability.LATEST_PRICE,
105 "HISTORICAL_PRICE_SERIES": YahooMcpCapability.MARKET_HISTORY,
106 "VALUATION_INPUTS": YahooMcpCapability.VALUATION_INPUTS,
107 "BUSINESS_QUALITY_FACTS": YahooMcpCapability.ANNUAL_FINANCIALS,
108 "GROWTH_FACTS": YahooMcpCapability.GROWTH_INPUTS,
109 "BALANCE_SHEET_FACTS": YahooMcpCapability.BALANCE_SHEET,
110 "QUARTERLY_FINANCIALS": YahooMcpCapability.QUARTERLY_FINANCIALS,
111 "CURRENT_NEWS": YahooMcpCapability.NEWS,
112 "ORDER_BOOK_CAPEX_GUIDANCE": YahooMcpCapability.CATALYSTS_EVENTS,
113 "SHAREHOLDING": YahooMcpCapability.SHAREHOLDING,
114 "SECTOR_MACRO": YahooMcpCapability.SECTOR_INDUSTRY,
115 "COMPANY_PROFILE": YahooMcpCapability.COMPANY_PROFILE,
116 "ANALYST_DATA": YahooMcpCapability.ANALYST_DATA,
117 }
118
119
120 class YahooFinanceCapabilityRegistry:
121 """One configured capability decision for every region/requirement pair."""
122
123 def __init__(self, capabilities: list[ExternalMcpProviderCapability]) -> None:
124 values = {(item.region, item.requirement_id): item for item in capabilities}
125 if len(values) != len(capabilities):
126 raise ValueError("Yahoo MCP capabilities must be unique by region and requirement")
127 self._values = values
128
129 @classmethod
130 def from_json(cls, value: str | None) -> "YahooFinanceCapabilityRegistry":
131 try:
132 configured = json.loads(value or "[]")
133 except json.JSONDecodeError as exc:
134 raise ValueError("AIP_MCP_YAHOO_CAPABILITIES_JSON must be valid JSON") from exc
135 if not isinstance(configured, list):
136 raise ValueError("AIP_MCP_YAHOO_CAPABILITIES_JSON must be a JSON array")
137 defaults = {
138 (region, requirement): ExternalMcpProviderCapability(
139 region=region,
140 requirementId=requirement,
141 capability=capability.value,
142 state=(
143 ExternalCapabilityState.UNSUPPORTED
144 if capability is YahooMcpCapability.SHAREHOLDING
145 else ExternalCapabilityState.UNKNOWN
146 ),
147 )
148 for region in SUPPORTED_REGIONS
149 for requirement, capability in _REQUIREMENT_CAPABILITY.items()
150 }
151 for raw in configured:
152 capability = ExternalMcpProviderCapability.model_validate(raw)
153 expected = _REQUIREMENT_CAPABILITY.get(capability.requirement_id)
154 if expected is None or capability.capability != expected.value:
155 raise ValueError(
156 f"Unsupported Yahoo MCP requirement/capability mapping: "
157 f"{capability.requirement_id}/{capability.capability}"
158 )
159 if capability.region not in SUPPORTED_REGIONS:
160 raise ValueError(f"Unsupported Yahoo MCP region: {capability.region}")
161 if capability.state is ExternalCapabilityState.SUPPORTED and not capability.tool:
162 raise ValueError("A SUPPORTED Yahoo MCP capability requires an exact tool name")
163 _reject_sensitive_tool_arguments(capability.tool_arguments)
164 defaults[(capability.region, capability.requirement_id)] = capability
165 return cls(list(defaults.values()))
166
167 def capability_for(
168 self, *, region: str, requirement_id: str
169 ) -> ExternalMcpProviderCapability | None:
170 return self._values.get((region.strip().upper(), requirement_id.strip().upper()))
171
172 @property
173 def capabilities(self) -> tuple[ExternalMcpProviderCapability, ...]:
174 return tuple(self._values[key] for key in sorted(self._values))
175
176 @property
177 def supported(self) -> tuple[ExternalMcpProviderCapability, ...]:
178 return tuple(
179 item
180 for item in self.capabilities
181 if item.state is ExternalCapabilityState.SUPPORTED and item.tool
182 )
183
184
185 @dataclass(frozen=True)
186 class YahooFinanceMcpConfig:
187 transport: str
188 endpoint: str | None
189 stdio_command: str | None
190 stdio_args: tuple[str, ...]
191 auth_type: str
192 auth_header_name: str
193 auth_token: SecretStr | None
194 stdio_token_env_name: str | None
195 timeout_seconds: float
196 max_retries: int
197 retry_backoff_seconds: float
198 max_concurrency: int
199
200 def __post_init__(self) -> None:
201 transport = self.transport.strip().lower()
202 auth_type = self.auth_type.strip().upper()
203 if transport not in {"streamable-http", "stdio"}:
204 raise ValueError("Yahoo MCP transport must be streamable-http or stdio")
205 if transport == "streamable-http" and not self.endpoint:
206 raise ValueError("Yahoo MCP Streamable HTTP requires an endpoint")
207 if transport == "stdio" and not self.stdio_command:
208 raise ValueError("Yahoo MCP STDIO requires an executable")
209 if self.timeout_seconds <= 0 or self.max_concurrency < 1:
210 raise ValueError("Yahoo MCP timeout and concurrency must be positive")
211 if self.max_retries not in range(0, 4) or self.retry_backoff_seconds < 0:
212 raise ValueError("Yahoo MCP retry configuration is outside the safe bound")
213 if auth_type in {"BEARER", "HEADER"} and self.auth_token is None:
214 raise ValueError("Yahoo MCP configured authentication requires a secret reference")
215 if auth_type in {"NONE", "WORKLOAD_IDENTITY"} and self.auth_token is not None:
216 raise ValueError("Yahoo MCP token is not valid for the configured authentication type")
217 object.__setattr__(self, "transport", transport)
218 object.__setattr__(self, "auth_type", auth_type)
219
220
221 class YahooMcpClient(Protocol):
222 async def list_tools(self): ...
223
224 async def call_tool(self, name: str, arguments: dict[str, Any] | None = None, **kwargs): ...
225
226
227 class YahooMcpClientFactory(Protocol):
228 def connect(self, request_id: str) -> AsyncContextManager[YahooMcpClient]: ...
229
230
231 class OfficialYahooMcpClientFactory:
232 """Build official SDK clients without putting credentials in URLs or logs."""
233
234 def __init__(self, config: YahooFinanceMcpConfig) -> None:
235 self.config = config
236
237 @asynccontextmanager
238 async def connect(self, request_id: str):
239 if self.config.transport == "stdio":
240 environment = {
241 key: value
242 for key in ("PATH", "SYSTEMROOT", "WINDIR", "HOME", "TMP", "TEMP")
243 if (value := os.environ.get(key))
244 }
245 environment["AIP_REQUEST_ID"] = request_id
246 environment["AIP_CORRELATION_ID"] = request_id
247 if self.config.auth_token and self.config.stdio_token_env_name:
248 environment[self.config.stdio_token_env_name] = (
249 self.config.auth_token.get_secret_value()
250 )
251 parameters = StdioServerParameters(
252 command=str(self.config.stdio_command),
253 args=list(self.config.stdio_args),
254 env=environment,
255 )
256 async with Client(
257 parameters, read_timeout_seconds=self.config.timeout_seconds, cache=None
258 ) as client:
259 yield client
260 return
261
262 headers = {"X-Request-ID": request_id, "X-Correlation-ID": request_id}
263 inject(headers)
264 if self.config.auth_token:
265 token = self.config.auth_token.get_secret_value()
266 headers[self.config.auth_header_name] = (
267 f"Bearer {token}" if self.config.auth_type == "BEARER" else token
268 )
269 async with httpx2.AsyncClient(
270 headers=headers,
271 timeout=httpx2.Timeout(self.config.timeout_seconds),
272 ) as http_client:
273 transport = streamable_http_client(
274 str(self.config.endpoint), http_client=http_client
275 )
276 async with Client(
277 transport, read_timeout_seconds=self.config.timeout_seconds, cache=None
278 ) as client:
279 yield client
280
281
282 class YahooMcpRawFact(StrictContract):
283 metric: str = Field(min_length=1, max_length=100)
284 value: Any
285 unit: str | None = Field(default=None, max_length=30)
286 period_end: str | None = Field(default=None, alias="periodEnd")
287 period_type: str | None = Field(default=None, alias="periodType")
288 reporting_basis: str = Field(default="UNKNOWN", alias="reportingBasis")
289 as_of: datetime | None = Field(default=None, alias="asOf")
290 published_at: datetime | None = Field(default=None, alias="publishedAt")
291 confidence: float = Field(default=0.78, ge=0, le=1)
292 raw_field_origin: str | None = Field(default=None, alias="rawFieldOrigin", max_length=200)
293
294 @field_validator("value")
295 @classmethod
296 def finite_scalar(cls, value: Any) -> Any:
297 if isinstance(value, (dict, list, tuple)):
298 raise ValueError("Yahoo MCP fact values must be scalar")
299 if isinstance(value, float) and not math.isfinite(value):
300 raise ValueError("Yahoo MCP fact values must be finite")
301 if isinstance(value, Decimal) and not value.is_finite():
302 raise ValueError("Yahoo MCP fact values must be finite")
303 return value
304
305 @field_validator("period_end")
306 @classmethod
307 def valid_period_end(cls, value: str | None) -> str | None:
308 if value is None:
309 return None
310 try:
311 datetime.fromisoformat(value)
312 except ValueError as exc:
313 raise ValueError("Yahoo MCP periodEnd must be an ISO date") from exc
314 return value
315
316 @field_validator("period_type")
317 @classmethod
318 def valid_period_type(cls, value: str | None) -> str | None:
319 if value is None:
320 return None
321 normalized = value.strip().upper()
322 if normalized not in {"ANNUAL", "QUARTERLY", "AS_AT"}:
323 raise ValueError("Yahoo MCP periodType is unsupported")
324 return normalized
325
326
327 class YahooMcpRawPrice(StrictContract):
328 observed_at: datetime = Field(alias="observedAt")
329 close: Decimal
330 currency: str | None = None
331
332 @field_validator("close")
333 @classmethod
334 def valid_price(cls, value: Decimal) -> Decimal:
335 if not value.is_finite() or value <= 0:
336 raise ValueError("Yahoo MCP prices must be positive and finite")
337 return value
338
339
340 class YahooMcpRawArticle(StrictContract):
341 headline: str = Field(min_length=1, max_length=500)
342 url: str = Field(min_length=1, max_length=2048)
343 published_at: datetime = Field(alias="publishedAt")
344 publisher: str = Field(default="Yahoo Finance", max_length=200)
345 issuer_symbol: str | None = Field(default=None, alias="issuerSymbol")
346 related_symbols: tuple[str, ...] = Field(default=(), alias="relatedSymbols")
347 summary: str | None = Field(default=None, max_length=2000)
348 event_type: str | None = Field(default=None, alias="eventType", max_length=100)
349
350 @field_validator("url")
351 @classmethod
352 def http_url(cls, value: str) -> str:
353 if not value.startswith(("https://", "http://")):
354 raise ValueError("Yahoo MCP evidence URLs must use HTTP(S)")
355 return value
356
357
358 class YahooMcpRawProfile(StrictContract):
359 company_name: str | None = Field(default=None, alias="companyName", max_length=300)
360 sector: str | None = Field(default=None, max_length=200)
361 industry: str | None = Field(default=None, max_length=200)
362
363
364 class YahooMcpRawOwnership(StrictContract):
365 period_end: datetime = Field(alias="periodEnd")
366 promoter_holding_percent: Decimal | None = Field(default=None, alias="promoterHoldingPercent")
367 promoter_pledge_percent: Decimal | None = Field(default=None, alias="promoterPledgePercent")
368 promoter_pledge_basis: str | None = Field(default=None, alias="promoterPledgeBasis")
369 fii_fpi_percent: Decimal | None = Field(default=None, alias="fiiFpiPercent")
370 dii_percent: Decimal | None = Field(default=None, alias="diiPercent")
371 public_retail_percent: Decimal | None = Field(default=None, alias="publicRetailPercent")
372 institutional_ownership_percent: Decimal | None = Field(
373 default=None, alias="institutionalOwnershipPercent"
374 )
375
376 @field_validator(
377 "promoter_holding_percent",
378 "promoter_pledge_percent",
379 "fii_fpi_percent",
380 "dii_percent",
381 "public_retail_percent",
382 "institutional_ownership_percent",
383 )
384 @classmethod
385 def valid_percentage(cls, value: Decimal | None) -> Decimal | None:
386 if value is not None and (not value.is_finite() or value < 0 or value > 100):
387 raise ValueError("Yahoo MCP ownership percentages must be between 0 and 100")
388 return value
389
390
391 class YahooMcpRawPayload(StrictContract):
392 schema_version: str = Field(alias="schemaVersion")
393 adapter_version: str | None = Field(default=None, alias="adapterVersion", max_length=100)
394 source: str | None = Field(default=None, max_length=100)
395 global_instrument_id: str = Field(alias="globalInstrumentId", min_length=1, max_length=100)
396 symbol: str = Field(min_length=1, max_length=100)
397 exchange: str | None = Field(default=None, max_length=100)
398 currency: str | None = Field(default=None, max_length=20)
399 as_of: datetime | None = Field(default=None, alias="asOf")
400 retrieved_at: datetime | None = Field(default=None, alias="retrievedAt")
401 source_url: str | None = Field(default=None, alias="sourceUrl", max_length=2048)
402 price: Decimal | None = None
403 facts: tuple[YahooMcpRawFact, ...] = ()
404 prices: tuple[YahooMcpRawPrice, ...] = ()
405 profile: YahooMcpRawProfile | None = None
406 news: tuple[YahooMcpRawArticle, ...] = ()
407 news_query_succeeded: bool = Field(default=False, alias="newsQuerySucceeded")
408 events: tuple[YahooMcpRawArticle, ...] = ()
409 ownership: YahooMcpRawOwnership | None = None
410
411 @model_validator(mode="after")
412 def valid_contract(self):
413 if self.schema_version != TOOL_SCHEMA_VERSION:
414 raise ValueError("Unsupported Yahoo MCP tool schema version")
415 if self.price is not None and (not self.price.is_finite() or self.price <= 0):
416 raise ValueError("Yahoo MCP latest price must be positive and finite")
417 if self.source_url and not self.source_url.startswith(("https://", "http://")):
418 raise ValueError("Yahoo MCP source URL must use HTTP(S)")
419 return self
420
421
422 _STRUCTURED_FACTS = {
423 "trailingEps",
424 "forwardEps",
425 "trailingPE",
426 "forwardPE",
427 "priceToBook",
428 "evToEbitda",
429 "marketCap",
430 "freeCashFlow",
431 "operatingCashFlow",
432 "roe",
433 "roa",
434 "roce",
435 "profitMargin",
436 "operatingMargin",
437 "revenueGrowth",
438 "earningsGrowth",
439 "totalCash",
440 "totalDebt",
441 "debtToEquity",
442 "currentRatio",
443 "sector",
444 "industry",
445 "publicAnalystTargetMeanPrice",
446 "publicAnalystTargetLowPrice",
447 "publicAnalystTargetMedianPrice",
448 "publicAnalystTargetHighPrice",
449 "publicAnalystCount",
450 "publicAnalystRecommendationMean",
451 "publicAnalystConsensus",
452 }
453 _FINANCIAL_FACTS = {
454 "revenue",
455 "total_revenue",
456 "pat",
457 "net_income",
458 "net_profit",
459 "operating_income",
460 "operating_profit",
461 "ebit",
462 "ebitda",
463 "pbt",
464 "tax",
465 "finance_cost",
466 "interest_expense",
467 "eps",
468 "cash_and_equivalents",
469 "total_cash",
470 "debt_or_borrowings",
471 "total_debt",
472 "total_assets",
473 "total_liabilities",
474 "equity",
475 "current_assets",
476 "current_liabilities",
477 "receivables",
478 "inventory",
479 "operating_cash_flow",
480 "investing_cash_flow",
481 "financing_cash_flow",
482 "free_cash_flow",
483 "capex",
484 "operating_margin",
485 "profit_margin",
486 "ebitda_margin",
487 "roe",
488 "roce",
489 }
490
491
492 class YahooFinanceMcpProvider:
493 metadata: ExternalMcpProviderMetadata
494
495 def __init__(
496 self,
497 config: YahooFinanceMcpConfig,
498 capabilities: YahooFinanceCapabilityRegistry,
499 *,
500 client_factory: YahooMcpClientFactory | None = None,
501 circuit_breaker: McpCircuitBreaker | None = None,
502 clock: Callable[[], datetime] | None = None,
503 ) -> None:
504 self.config = config
505 self.capabilities = capabilities
506 self.client_factory = client_factory or OfficialYahooMcpClientFactory(config)
507 self.circuit_breaker = circuit_breaker
508 self.clock = clock or (lambda: datetime.now(timezone.utc))
509 self._concurrency = asyncio.Semaphore(config.max_concurrency)
510 supported = capabilities.supported
511 self.metadata = ExternalMcpProviderMetadata(
512 providerId=PROVIDER_ID,
513 regions=SUPPORTED_REGIONS,
514 supportedRequirements=tuple(sorted({item.requirement_id for item in supported})),
515 supportedTools=tuple(sorted({str(item.tool) for item in supported})),
516 riskClass=McpRiskClass.SAFE_READ,
517 authType=config.auth_type,
518 priority=1,
519 sourceTier="APPROVED_EXTERNAL_TOOL",
520 healthState=ExternalProviderHealthState.UNKNOWN,
521 timeoutSeconds=config.timeout_seconds,
522 timeoutBehavior="BOUNDED_RETRY_THEN_FALLBACK",
523 adapterVersion=ADAPTER_VERSION,
524 )
525
526 def capability_for(
527 self, *, region: str, requirement_id: str
528 ) -> ExternalMcpProviderCapability | None:
529 return self.capabilities.capability_for(region=region, requirement_id=requirement_id)
530
531 async def supports(self, *, region: str, requirement_id: str, tool: str) -> bool:
532 capability = self.capability_for(region=region, requirement_id=requirement_id)
533 return bool(
534 capability
535 and capability.state is ExternalCapabilityState.SUPPORTED
536 and capability.tool == tool
537 )
538
539 async def invoke(
540 self, *, tool: str, arguments: dict[str, Any], request_id: str
541 ) -> dict[str, Any]:
542 allowed = {
543 "globalInstrumentId",
544 "region",
545 "requirementId",
546 "providerSymbol",
547 "expectedExchange",
548 "expectedCurrency",
549 }
550 if set(arguments) - allowed:
551 raise McpGatewayError(McpErrorCode.INVALID_ARGUMENT)
552 region = str(arguments.get("region") or "").upper()
553 requirement = str(arguments.get("requirementId") or "").upper()
554 symbol = str(arguments.get("providerSymbol") or "").strip()
555 global_instrument_id = str(arguments.get("globalInstrumentId") or "").strip()
556 capability = self.capability_for(region=region, requirement_id=requirement)
557 if (
558 not symbol
559 or not global_instrument_id
560 or capability is None
561 or capability.state is not ExternalCapabilityState.SUPPORTED
562 or capability.tool != tool
563 ):
564 raise McpGatewayError(McpErrorCode.EXTERNAL_CAPABILITY_UNSUPPORTED)
565
566 outbound = {
567 **capability.tool_arguments,
568 "globalInstrumentId": global_instrument_id,
569 "verifiedYahooSymbol": symbol,
570 "region": region,
571 "exchange": arguments.get("expectedExchange"),
572 "currency": arguments.get("expectedCurrency"),
573 }
574 async with self._concurrency:
575 payload = await self._call(tool, outbound, request_id)
576 try:
577 raw = YahooMcpRawPayload.model_validate(payload)
578 except ValidationError as exc:
579 raise McpGatewayError(McpErrorCode.EXTERNAL_SCHEMA_INVALID) from exc
580 self._validate_identity(
581 raw,
582 global_instrument_id=global_instrument_id,
583 symbol=symbol,
584 expected_exchange=arguments.get("expectedExchange"),
585 expected_currency=arguments.get("expectedCurrency"),
586 )
587 normalized = self._normalize(
588 raw,
589 capability=capability,
590 global_instrument_id=global_instrument_id,
591 region=region,
592 requirement=requirement,
593 tool=tool,
594 )
595 self._validate_completeness(normalized, capability)
596 return normalized
597
598 async def _call(
599 self, tool: str, outbound: dict[str, Any], request_id: str
600 ) -> dict[str, Any]:
601 last_code = McpErrorCode.EXTERNAL_PROVIDER_UNAVAILABLE
602 for attempt in range(self.config.max_retries + 1):
603 try:
604 if self.circuit_breaker:
605 await self.circuit_breaker.before_call(dependency=PROVIDER_ID)
606 async with asyncio.timeout(self.config.timeout_seconds):
607 async with self.client_factory.connect(request_id) as client:
608 listing = await client.list_tools()
609 names = {item.name for item in listing.tools}
610 if tool not in names:
611 raise McpGatewayError(
612 McpErrorCode.EXTERNAL_CAPABILITY_UNSUPPORTED
613 )
614 result = await client.call_tool(tool, outbound)
615 if result.is_error:
616 code = _first_party_error_code(result)
617 if code in {
618 McpErrorCode.DOWNSTREAM_TIMEOUT,
619 McpErrorCode.EXTERNAL_PROVIDER_UNAVAILABLE,
620 McpErrorCode.EXTERNAL_PROVIDER_RATE_LIMITED,
621 }:
622 raise _RetryableProviderFailure(code)
623 raise McpGatewayError(code)
624 if not isinstance(result.structured_content, dict):
625 raise McpGatewayError(McpErrorCode.EXTERNAL_SCHEMA_INVALID)
626 if self.circuit_breaker:
627 await self.circuit_breaker.record_success(dependency=PROVIDER_ID)
628 return result.structured_content
629 except McpGatewayError:
630 if self.circuit_breaker:
631 await self.circuit_breaker.record_failure(dependency=PROVIDER_ID)
632 raise
633 except TimeoutError:
634 last_code = McpErrorCode.DOWNSTREAM_TIMEOUT
635 except _RetryableProviderFailure as exc:
636 last_code = exc.code
637 except asyncio.CancelledError:
638 raise
639 except Exception as exc:
640 response = getattr(exc, "response", None)
641 last_code = (
642 McpErrorCode.EXTERNAL_PROVIDER_RATE_LIMITED
643 if getattr(response, "status_code", None) == 429
644 else McpErrorCode.EXTERNAL_PROVIDER_UNAVAILABLE
645 )
646 if self.circuit_breaker:
647 await self.circuit_breaker.record_failure(dependency=PROVIDER_ID)
648 if attempt < self.config.max_retries:
649 await asyncio.sleep(self.config.retry_backoff_seconds * (2**attempt))
650 raise McpGatewayError(last_code)
651
652 async def health(self) -> dict[str, Any]:
653 required = {str(item.tool) for item in self.capabilities.supported}
654 try:
655 async with asyncio.timeout(self.config.timeout_seconds):
656 async with self.client_factory.connect("yahoo-mcp-health") as client:
657 listing = await client.list_tools()
658 discovered = {item.name for item in listing.tools}
659 except Exception:
660 return {"status": ExternalProviderHealthState.DOWN.value, "providerId": PROVIDER_ID}
661 missing = sorted(required - discovered)
662 state = (
663 ExternalProviderHealthState.DEGRADED
664 if missing
665 else ExternalProviderHealthState.UP
666 )
667 return {
668 "status": state.value,
669 "providerId": PROVIDER_ID,
670 "configuredCapabilityCount": len(required),
671 "missingConfiguredToolCount": len(missing),
672 }
673
674 def _validate_identity(
675 self,
676 raw: YahooMcpRawPayload,
677 *,
678 global_instrument_id: str,
679 symbol: str,
680 expected_exchange: Any,
681 expected_currency: Any,
682 ) -> None:
683 if raw.global_instrument_id != global_instrument_id:
684 raise McpGatewayError(McpErrorCode.EXTERNAL_IDENTITY_CONFLICT)
685 if raw.symbol.strip().upper() != symbol.upper():
686 raise McpGatewayError(McpErrorCode.EXTERNAL_IDENTITY_CONFLICT)
687 expected_exchange_text = str(expected_exchange or "").strip()
688 if expected_exchange_text:
689 if not raw.exchange or _exchange_family(raw.exchange) != _exchange_family(
690 expected_exchange_text
691 ):
692 raise McpGatewayError(McpErrorCode.EXTERNAL_IDENTITY_CONFLICT)
693 expected_currency_text = str(expected_currency or "").strip().upper()
694 if expected_currency_text and (
695 not raw.currency or raw.currency.strip().upper() != expected_currency_text
696 ):
697 raise McpGatewayError(McpErrorCode.EXTERNAL_IDENTITY_CONFLICT)
698
699 def _normalize(
700 self,
701 raw: YahooMcpRawPayload,
702 *,
703 capability: ExternalMcpProviderCapability,
704 global_instrument_id: str,
705 region: str,
706 requirement: str,
707 tool: str,
708 ) -> dict[str, Any]:
709 now = _aware(self.clock())
710 observed = _aware(raw.as_of) if raw.as_of else None
711 if capability.max_age_seconds:
712 if observed is None:
713 raise McpGatewayError(McpErrorCode.EXTERNAL_RESULT_INCOMPLETE)
714 if observed > now + timedelta(minutes=5) or now - observed > timedelta(
715 seconds=capability.max_age_seconds
716 ):
717 raise McpGatewayError(McpErrorCode.EXTERNAL_RESULT_STALE)
718
719 source_url = raw.source_url or (
720 f"https://finance.yahoo.com/quote/{quote(raw.symbol, safe='')}"
721 )
722 structured: list[dict[str, Any]] = []
723 financial: list[dict[str, Any]] = []
724 if raw.price is not None:
725 structured.append(
726 _wire_fact(
727 "latestPrice", raw.price, raw.currency, observed, None, source_url, None
728 )
729 )
730 for fact in raw.facts:
731 period_type = str(fact.period_type or "").upper()
732 if period_type in {"ANNUAL", "QUARTERLY", "AS_AT"}:
733 if fact.metric not in _FINANCIAL_FACTS or not fact.period_end:
734 raise McpGatewayError(McpErrorCode.EXTERNAL_SCHEMA_INVALID)
735 value = _decimal(fact.value)
736 if value is None:
737 raise McpGatewayError(McpErrorCode.EXTERNAL_SCHEMA_INVALID)
738 financial.append(
739 {
740 **_wire_fact(
741 fact.metric,
742 value,
743 fact.unit,
744 _aware(fact.as_of) if fact.as_of else None,
745 _aware(fact.published_at) if fact.published_at else None,
746 source_url,
747 fact.raw_field_origin,
748 confidence=fact.confidence,
749 ),
750 "periodEnd": fact.period_end,
751 "periodType": period_type,
752 "reportingBasis": fact.reporting_basis.strip().upper() or "UNKNOWN",
753 }
754 )
755 else:
756 if fact.metric not in _STRUCTURED_FACTS:
757 raise McpGatewayError(McpErrorCode.EXTERNAL_SCHEMA_INVALID)
758 structured.append(
759 _wire_fact(
760 fact.metric,
761 fact.value,
762 fact.unit,
763 _aware(fact.as_of) if fact.as_of else observed,
764 _aware(fact.published_at) if fact.published_at else None,
765 source_url,
766 fact.raw_field_origin,
767 confidence=fact.confidence,
768 )
769 )
770 if raw.profile:
771 for name, value in (("sector", raw.profile.sector), ("industry", raw.profile.industry)):
772 if value:
773 structured.append(
774 _wire_fact(name, value, None, observed, None, source_url, name)
775 )
776
777 prices = [
778 {
779 "observedAt": _aware(item.observed_at).isoformat(),
780 "price": str(item.close),
781 "currency": (item.currency or raw.currency),
782 }
783 for item in sorted(raw.prices, key=lambda item: item.observed_at)
784 ]
785 if raw.price is not None and observed is not None:
786 prices.append(
787 {
788 "observedAt": observed.isoformat(),
789 "price": str(raw.price),
790 "currency": raw.currency,
791 }
792 )
793 prices = list(
794 {
795 (item["observedAt"], item["price"]): item for item in prices
796 }.values()
797 )
798 news = _normalize_articles(raw.news, raw.symbol, now, current_news=True)
799 events = _normalize_articles(raw.events, raw.symbol, now, current_news=False)
800 ownership = _normalize_ownership(raw.ownership)
801 return {
802 "adapterVersion": ADAPTER_VERSION,
803 "providerId": PROVIDER_ID,
804 "sourceTier": "APPROVED_EXTERNAL_TOOL",
805 "sourceTool": tool,
806 "region": region,
807 "requirementId": requirement,
808 "globalInstrumentId": global_instrument_id,
809 "symbol": raw.symbol,
810 "exchange": raw.exchange,
811 "currency": raw.currency,
812 "retrievedAt": now.isoformat(),
813 "observedAt": observed.isoformat() if observed else None,
814 "sourceUrl": source_url,
815 "confidence": 0.80,
816 "freshness": "FRESH",
817 "structuredFacts": structured,
818 "financialFacts": financial,
819 "acquisitionOutcome": "SUCCESS_EMPTY" if requirement == "CURRENT_NEWS" and raw.news_query_succeeded and not news else "SUCCESS",
820 "marketObservations": prices,
821 "companyProfile": (
822 raw.profile.model_dump(mode="json", by_alias=True, exclude_none=True)
823 if raw.profile
824 else None
825 ),
826 "news": news,
827 "events": events,
828 "shareholding": ownership,
829 }
830
831 @staticmethod
832 def _validate_completeness(
833 result: dict[str, Any], capability: ExternalMcpProviderCapability
834 ) -> None:
835 requirement = capability.requirement_id
836 structured = {item["metric"] for item in result["structuredFacts"]}
837 financial = result["financialFacts"]
838 metrics = {item["metric"] for item in financial}
839 available = structured | metrics
840 missing_configured = set(capability.required_fields) - available
841 if missing_configured:
842 raise McpGatewayError(McpErrorCode.EXTERNAL_RESULT_INCOMPLETE)
843 complete = False
844 if requirement == "LATEST_PRICE":
845 complete = "latestPrice" in structured
846 elif requirement == "HISTORICAL_PRICE_SERIES":
847 complete = len(result["marketObservations"]) >= capability.minimum_items
848 elif requirement == "VALUATION_INPUTS":
849 complete = "latestPrice" in structured and bool(
850 {"trailingEps", "forwardEps", "eps"} & available
851 )
852 elif requirement == "BUSINESS_QUALITY_FACTS":
853 complete = (
854 _period_count(financial, {"revenue", "total_revenue"}, "ANNUAL") >= 2
855 and _period_count(
856 financial, {"pat", "net_income", "net_profit"}, "ANNUAL"
857 )
858 >= 2
859 and bool(
860 {
861 "operating_cash_flow",
862 "free_cash_flow",
863 "roe",
864 "profit_margin",
865 "operating_margin",
866 "ebitda",
867 }
868 & metrics
869 )
870 )
871 elif requirement == "GROWTH_FACTS":
872 complete = (
873 _period_count(financial, {"revenue", "total_revenue"}) >= 2
874 and _period_count(financial, {"pat", "net_income", "net_profit", "eps"}) >= 2
875 )
876 elif requirement == "BALANCE_SHEET_FACTS":
877 complete = bool({"debt_or_borrowings", "total_debt"} & metrics) and "equity" in metrics
878 elif requirement == "QUARTERLY_FINANCIALS":
879 complete = (
880 _period_count(financial, {"revenue", "total_revenue"}, "QUARTERLY") >= 2
881 and _period_count(financial, {"pat", "net_income", "net_profit"}, "QUARTERLY") >= 2
882 )
883 elif requirement == "CURRENT_NEWS":
884 complete = len(result["news"]) >= capability.minimum_items or result.get("acquisitionOutcome") == "SUCCESS_EMPTY"
885 elif requirement == "ORDER_BOOK_CAPEX_GUIDANCE":
886 complete = len(result["events"]) >= capability.minimum_items and all(
887 item.get("eventType") in _READINESS_CATALYST_EVENT_TYPES
888 for item in result["events"]
889 )
890 elif requirement == "SHAREHOLDING":
891 shareholding = result.get("shareholding") or {}
892 complete = all(
893 shareholding.get(key) is not None
894 for key in (
895 "promoterHoldingPercent",
896 "promoterPledgePercent",
897 "promoterPledgeBasis",
898 "fiiFpiPercent",
899 "diiPercent",
900 )
901 ) and _is_quarter_end(shareholding.get("periodEnd"))
902 elif requirement == "SECTOR_MACRO":
903 complete = "sector" in structured
904 elif requirement == "COMPANY_PROFILE":
905 complete = bool((result.get("companyProfile") or {}).get("companyName"))
906 elif requirement == "ANALYST_DATA":
907 complete = bool(_STRUCTURED_FACTS.intersection(structured) & {
908 "publicAnalystTargetMeanPrice",
909 "publicAnalystTargetLowPrice",
910 "publicAnalystTargetMedianPrice",
911 "publicAnalystTargetHighPrice",
912 "publicAnalystCount",
913 "publicAnalystRecommendationMean",
914 "publicAnalystConsensus",
915 })
916 if not complete:
917 raise McpGatewayError(McpErrorCode.EXTERNAL_RESULT_INCOMPLETE)
918
919
920 def _normalize_articles(
921 values: tuple[YahooMcpRawArticle, ...],
922 expected_symbol: str,
923 now: datetime,
924 *,
925 current_news: bool,
926 ) -> list[dict[str, Any]]:
927 result: dict[tuple[str, str, str], dict[str, Any]] = {}
928 for item in values:
929 published = _aware(item.published_at)
930 related = {value.strip().upper() for value in item.related_symbols}
931 issuer = str(item.issuer_symbol or "").strip().upper()
932 if expected_symbol.upper() not in ({issuer} | related):
933 continue
934 if published > now + timedelta(minutes=5):
935 continue
936 if current_news and now - published > timedelta(days=30):
937 continue
938 key = (item.url.casefold(), item.headline.strip().casefold(), published.date().isoformat())
939 result[key] = {
940 "headline": item.headline.strip(),
941 "url": item.url,
942 "publishedAt": published.isoformat(),
943 "publisher": item.publisher,
944 "issuerSymbol": expected_symbol,
945 "summary": item.summary,
946 "eventType": item.event_type.strip().upper() if item.event_type else None,
947 }
948 return sorted(result.values(), key=lambda item: item["publishedAt"], reverse=True)
949
950
951 def _normalize_ownership(value: YahooMcpRawOwnership | None) -> dict[str, Any] | None:
952 if value is None:
953 return None
954 return value.model_dump(mode="json", by_alias=True, exclude_none=True)
955
956
957 def _wire_fact(
958 metric: str,
959 value: Any,
960 unit: str | None,
961 as_of: datetime | None,
962 published_at: datetime | None,
963 source_url: str,
964 raw_field_origin: str | None,
965 *,
966 confidence: float = 0.80,
967 ) -> dict[str, Any]:
968 if isinstance(value, Decimal):
969 value = str(value)
970 return {
971 "metric": metric,
972 "value": value,
973 "unit": unit,
974 "asOf": as_of.isoformat() if as_of else None,
975 "publishedAt": published_at.isoformat() if published_at else None,
976 "sourceUrl": source_url,
977 "confidence": confidence,
978 "rawFieldOrigin": raw_field_origin,
979 }
980
981
982 def _period_count(
983 facts: list[dict[str, Any]], metrics: set[str], period_type: str | None = None
984 ) -> int:
985 return len(
986 {
987 item["periodEnd"]
988 for item in facts
989 if item["metric"] in metrics
990 and (period_type is None or item["periodType"] == period_type)
991 }
992 )
993
994
995 def _is_quarter_end(value: Any) -> bool:
996 if not isinstance(value, str):
997 return False
998 try:
999 parsed = date.fromisoformat(value[:10])
1000 except ValueError:
1001 return False
1002 return (parsed.month, parsed.day) in {(3, 31), (6, 30), (9, 30), (12, 31)}
1003
1004
1005 def _exchange_family(value: str) -> str:
1006 normalized = "".join(character for character in value.upper() if character.isalnum())
1007 aliases = {
1008 "XNSE": "NSE",
1009 "NSI": "NSE",
1010 "NASDAQ": "NASDAQ",
1011 "XNAS": "NASDAQ",
1012 "NMS": "NASDAQ",
1013 "NGM": "NASDAQ",
1014 "NCM": "NASDAQ",
1015 "XNYS": "NYSE",
1016 "NYQ": "NYSE",
1017 "XLON": "LSE",
1018 }
1019 return aliases.get(normalized, normalized)
1020
1021
1022 def _first_party_error_code(result: Any) -> McpErrorCode:
1023 """Map only the first-party server's allowlisted safe codes."""
1024 for item in getattr(result, "content", ()):
1025 message = str(getattr(item, "text", ""))
1026 for safe_code, gateway_code in _FIRST_PARTY_ERRORS.items():
1027 if safe_code in message:
1028 return gateway_code
1029 return McpErrorCode.EXTERNAL_SCHEMA_INVALID
1030
1031
1032 def _decimal(value: Any) -> Decimal | None:
1033 try:
1034 result = Decimal(str(value))
1035 except (InvalidOperation, TypeError, ValueError):
1036 return None
1037 return result if result.is_finite() else None
1038
1039
1040 def _aware(value: datetime) -> datetime:
1041 if value.tzinfo is None or value.utcoffset() is None:
1042 return value.replace(tzinfo=timezone.utc)
1043 return value.astimezone(timezone.utc)
1044
1045
1046 def _reject_sensitive_tool_arguments(value: Any) -> None:
1047 sensitive = (
1048 "password",
1049 "token",
1050 "secret",
1051 "authorization",
1052 "cookie",
1053 "credential",
1054 "api_key",
1055 "apikey",
1056 )
1057 if isinstance(value, dict):
1058 for key, item in value.items():
1059 normalized = str(key).replace("-", "_").lower()
1060 if any(part in normalized for part in sensitive):
1061 raise ValueError("Yahoo MCP static tool arguments cannot contain credentials")
1062 _reject_sensitive_tool_arguments(item)
1063 elif isinstance(value, (list, tuple)):
1064 for item in value:
1065 _reject_sensitive_tool_arguments(item)