main
py 551 lines 19.8 KB
Raw
1 from __future__ import annotations
2
3 import asyncio
4 import json
5 from contextlib import asynccontextmanager
6 from datetime import datetime, timedelta, timezone
7 from types import SimpleNamespace
8
9 import pytest
10 from mcp import Client
11 from pydantic import SecretStr
12 from starlette.testclient import TestClient
13
14 from app.contracts import McpErrorCode, McpGatewayError
15 from app.external import ExternalCapabilityState, McpServerRegistry
16 from app.server import create_container, create_mcp_server
17 from app.settings import McpGatewaySettings
18 from app.yahoo_finance_mcp import (
19 PROVIDER_ID,
20 TOOL_SCHEMA_VERSION,
21 OfficialYahooMcpClientFactory,
22 YahooFinanceCapabilityRegistry,
23 YahooFinanceMcpConfig,
24 YahooFinanceMcpProvider,
25 )
26 from conftest import FakeApplicationReader, INSTRUMENT_ID, NOW, RecordingAudit
27 from fake_yahoo_mcp_server import create_fake_yahoo_mcp_server
28
29
30 def mapping(requirement: str, capability: str, tool: str, **overrides):
31 value = {
32 "region": "INDIA",
33 "requirementId": requirement,
34 "capability": capability,
35 "tool": tool,
36 "state": "SUPPORTED",
37 }
38 value.update(overrides)
39 return value
40
41
42 def payload(**overrides):
43 value = {
44 "schemaVersion": TOOL_SCHEMA_VERSION,
45 "globalInstrumentId": str(INSTRUMENT_ID),
46 "symbol": "READY.NS",
47 "exchange": "NSE",
48 "currency": "INR",
49 "asOf": NOW.isoformat(),
50 "sourceUrl": "https://finance.yahoo.com/quote/READY.NS",
51 "price": "250.50",
52 }
53 value.update(overrides)
54 return value
55
56
57 class FakeClient:
58 def __init__(self, response, *, tools=("get_quote",), delay=0):
59 self.response = response
60 self.tools = tools
61 self.delay = delay
62 self.calls = []
63
64 async def list_tools(self):
65 return SimpleNamespace(tools=[SimpleNamespace(name=name) for name in self.tools])
66
67 async def call_tool(self, name, arguments=None, **_kwargs):
68 self.calls.append((name, arguments))
69 if self.delay:
70 await asyncio.sleep(self.delay)
71 return SimpleNamespace(is_error=False, structured_content=self.response)
72
73
74 class FakeFactory:
75 def __init__(self, client):
76 self.client = client
77 self.request_ids = []
78
79 @asynccontextmanager
80 async def connect(self, request_id):
81 self.request_ids.append(request_id)
82 yield self.client
83
84
85 class InMemoryServerFactory:
86 def __init__(self, server):
87 self.server = server
88
89 @asynccontextmanager
90 async def connect(self, _request_id):
91 async with Client(self.server) as client:
92 yield client
93
94
95 def provider(capability, response=None, *, delay=0, clock=lambda: NOW):
96 capabilities = YahooFinanceCapabilityRegistry.from_json(json.dumps([capability]))
97 client = FakeClient(response or payload(), tools=(capability["tool"],), delay=delay)
98 adapter = YahooFinanceMcpProvider(
99 YahooFinanceMcpConfig(
100 transport="stdio",
101 endpoint=None,
102 stdio_command="fake-yahoo-mcp",
103 stdio_args=(),
104 auth_type="NONE",
105 auth_header_name="Authorization",
106 auth_token=None,
107 stdio_token_env_name=None,
108 timeout_seconds=0.05,
109 max_retries=0,
110 retry_backoff_seconds=0,
111 max_concurrency=2,
112 ),
113 capabilities,
114 client_factory=FakeFactory(client),
115 clock=clock,
116 )
117 return adapter, client
118
119
120 def arguments(requirement="LATEST_PRICE", **overrides):
121 value = {
122 "globalInstrumentId": str(INSTRUMENT_ID),
123 "region": "INDIA",
124 "requirementId": requirement,
125 "providerSymbol": "READY.NS",
126 "expectedExchange": "XNSE",
127 "expectedCurrency": "INR",
128 }
129 value.update(overrides)
130 return value
131
132
133 def test_yahoo_provider_registration_is_explicit_and_contains_no_cancelled_provider() -> None:
134 adapter, _ = provider(mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote"))
135 registry = McpServerRegistry()
136 registry.register(adapter)
137 assert registry.provider_ids == (PROVIDER_ID,)
138 assert "ZERODHA" not in registry.provider_ids
139 assert "ALPHA_VANTAGE" not in registry.provider_ids
140 assert adapter.metadata.priority == 1
141 assert adapter.metadata.risk_class.value == "SAFE_READ"
142
143
144 @pytest.mark.asyncio
145 async def test_http_client_propagates_request_correlation_and_trace_context(monkeypatch) -> None:
146 captured = {}
147
148 class FakeHttpClient:
149 def __init__(self, *, headers, timeout):
150 captured["headers"] = headers
151 captured["timeout"] = timeout
152
153 async def __aenter__(self):
154 return self
155
156 async def __aexit__(self, *_args):
157 return None
158
159 class FakeSdkClient:
160 def __init__(self, transport, **_kwargs):
161 captured["transport"] = transport
162
163 async def __aenter__(self):
164 return self
165
166 async def __aexit__(self, *_args):
167 return None
168
169 monkeypatch.setattr("app.yahoo_finance_mcp.httpx2.AsyncClient", FakeHttpClient)
170 monkeypatch.setattr(
171 "app.yahoo_finance_mcp.streamable_http_client",
172 lambda endpoint, **_kwargs: ("transport", endpoint),
173 )
174 monkeypatch.setattr("app.yahoo_finance_mcp.Client", FakeSdkClient)
175 monkeypatch.setattr(
176 "app.yahoo_finance_mcp.inject",
177 lambda headers: headers.update(
178 {"traceparent": "00-00000000000000000000000000001234-0000000000005678-01"}
179 ),
180 )
181 factory = OfficialYahooMcpClientFactory(
182 YahooFinanceMcpConfig(
183 transport="streamable-http",
184 endpoint="http://yahoo-finance-mcp/mcp",
185 stdio_command=None,
186 stdio_args=(),
187 auth_type="NONE",
188 auth_header_name="Authorization",
189 auth_token=None,
190 stdio_token_env_name=None,
191 timeout_seconds=3,
192 max_retries=0,
193 retry_backoff_seconds=0,
194 max_concurrency=1,
195 )
196 )
197
198 async with factory.connect("correlation-5c"):
199 pass
200
201 assert captured["headers"]["X-Request-ID"] == "correlation-5c"
202 assert captured["headers"]["X-Correlation-ID"] == "correlation-5c"
203 assert captured["headers"]["traceparent"].endswith("-01")
204
205
206 def test_capability_defaults_are_unknown_and_shareholding_is_unsupported() -> None:
207 registry = YahooFinanceCapabilityRegistry.from_json("[]")
208 latest = registry.capability_for(region="INDIA", requirement_id="LATEST_PRICE")
209 shareholding = registry.capability_for(region="INDIA", requirement_id="SHAREHOLDING")
210 assert latest.state is ExternalCapabilityState.UNKNOWN
211 assert shareholding.state is ExternalCapabilityState.UNSUPPORTED
212 assert registry.supported == ()
213
214
215 def test_supported_capability_requires_exact_tool_and_rejects_secret_static_arguments() -> None:
216 with pytest.raises(ValueError, match="exact tool"):
217 YahooFinanceCapabilityRegistry.from_json(
218 json.dumps([{"region": "INDIA", "requirementId": "LATEST_PRICE", "capability": "LATEST_PRICE", "state": "SUPPORTED"}])
219 )
220 with pytest.raises(ValueError, match="credentials"):
221 YahooFinanceCapabilityRegistry.from_json(
222 json.dumps([mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote", toolArguments={"apiKey": "hidden"})])
223 )
224
225
226 @pytest.mark.asyncio
227 async def test_valid_quote_is_normalized_with_provenance_and_correlation() -> None:
228 adapter, client = provider(
229 mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote", maxAgeSeconds=600)
230 )
231 result = await adapter.invoke(
232 tool="get_quote", arguments=arguments(), request_id="request-yahoo-1"
233 )
234 assert result["providerId"] == PROVIDER_ID
235 assert result["globalInstrumentId"] == str(INSTRUMENT_ID)
236 assert result["structuredFacts"][0]["metric"] == "latestPrice"
237 assert client.calls == [(
238 "get_quote",
239 {
240 "globalInstrumentId": str(INSTRUMENT_ID),
241 "verifiedYahooSymbol": "READY.NS",
242 "region": "INDIA",
243 "exchange": "XNSE",
244 "currency": "INR",
245 },
246 )]
247
248
249 @pytest.mark.asyncio
250 async def test_official_sdk_contract_against_offline_fake_mcp_server() -> None:
251 configured = mapping(
252 "LATEST_PRICE", "LATEST_PRICE", "yahoo_latest_price", maxAgeSeconds=600
253 )
254 capabilities = YahooFinanceCapabilityRegistry.from_json(json.dumps([configured]))
255 adapter = YahooFinanceMcpProvider(
256 YahooFinanceMcpConfig(
257 transport="stdio",
258 endpoint=None,
259 stdio_command="unused-in-memory",
260 stdio_args=(),
261 auth_type="NONE",
262 auth_header_name="Authorization",
263 auth_token=None,
264 stdio_token_env_name=None,
265 timeout_seconds=1,
266 max_retries=0,
267 retry_backoff_seconds=0,
268 max_concurrency=1,
269 ),
270 capabilities,
271 client_factory=InMemoryServerFactory(
272 create_fake_yahoo_mcp_server(now=NOW)
273 ),
274 clock=lambda: NOW,
275 )
276 normalized = await adapter.invoke(
277 tool="yahoo_latest_price",
278 arguments=arguments(),
279 request_id="official-sdk-fake-server",
280 )
281 assert normalized["structuredFacts"][0]["value"] == "250.50"
282
283
284 @pytest.mark.asyncio
285 @pytest.mark.parametrize(
286 "override",
287 [
288 {"globalInstrumentId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"},
289 {"symbol": "WRONG.NS"},
290 {"exchange": "NASDAQ"},
291 {"currency": "USD"},
292 ],
293 )
294 async def test_canonical_identity_conflicts_fail_closed(override) -> None:
295 adapter, _ = provider(
296 mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote"), payload(**override)
297 )
298 with pytest.raises(McpGatewayError) as error:
299 await adapter.invoke(tool="get_quote", arguments=arguments(), request_id="identity")
300 assert error.value.code is McpErrorCode.EXTERNAL_IDENTITY_CONFLICT
301
302
303 @pytest.mark.asyncio
304 async def test_provider_response_cannot_create_identity_or_mapping() -> None:
305 adapter, _ = provider(
306 mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote"),
307 payload(providerMappings=[{"symbol": "NEW"}]),
308 )
309 with pytest.raises(McpGatewayError) as error:
310 await adapter.invoke(tool="get_quote", arguments=arguments(), request_id="mapping")
311 assert error.value.code is McpErrorCode.EXTERNAL_SCHEMA_INVALID
312
313
314 @pytest.mark.asyncio
315 async def test_timeout_is_deterministic() -> None:
316 adapter, _ = provider(
317 mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote"), delay=0.2
318 )
319 with pytest.raises(McpGatewayError) as error:
320 await adapter.invoke(tool="get_quote", arguments=arguments(), request_id="timeout")
321 assert error.value.code is McpErrorCode.DOWNSTREAM_TIMEOUT
322 assert "fake-yahoo" not in str(error.value)
323
324
325 @pytest.mark.asyncio
326 async def test_absent_discovered_tool_fails_closed() -> None:
327 adapter, client = provider(mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote"))
328 client.tools = ("different_tool",)
329 with pytest.raises(McpGatewayError) as error:
330 await adapter.invoke(tool="get_quote", arguments=arguments(), request_id="tool-list")
331 assert error.value.code is McpErrorCode.EXTERNAL_CAPABILITY_UNSUPPORTED
332 assert client.calls == []
333
334
335 @pytest.mark.asyncio
336 async def test_stale_and_incomplete_results_are_distinct() -> None:
337 stale, _ = provider(
338 mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote", maxAgeSeconds=60),
339 payload(asOf=(NOW - timedelta(minutes=2)).isoformat()),
340 )
341 with pytest.raises(McpGatewayError) as stale_error:
342 await stale.invoke(tool="get_quote", arguments=arguments(), request_id="stale")
343 assert stale_error.value.code is McpErrorCode.EXTERNAL_RESULT_STALE
344
345 incomplete, _ = provider(
346 mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote"), payload(price=None)
347 )
348 with pytest.raises(McpGatewayError) as incomplete_error:
349 await incomplete.invoke(tool="get_quote", arguments=arguments(), request_id="incomplete")
350 assert incomplete_error.value.code is McpErrorCode.EXTERNAL_RESULT_INCOMPLETE
351
352
353 @pytest.mark.asyncio
354 async def test_annual_and_quarterly_periods_remain_distinct() -> None:
355 facts = [
356 {"metric": metric, "value": value, "periodEnd": period, "periodType": kind, "reportingBasis": "CONSOLIDATED"}
357 for kind, period, value in (
358 ("ANNUAL", "2025-03-31", "100"),
359 ("ANNUAL", "2024-03-31", "90"),
360 ("QUARTERLY", "2026-06-30", "30"),
361 ("QUARTERLY", "2026-03-31", "25"),
362 )
363 for metric in ("revenue", "pat")
364 ]
365 adapter, _ = provider(
366 mapping("QUARTERLY_FINANCIALS", "QUARTERLY_FINANCIALS", "get_financials"),
367 payload(facts=facts),
368 )
369 result = await adapter.invoke(
370 tool="get_financials",
371 arguments=arguments("QUARTERLY_FINANCIALS"),
372 request_id="periods",
373 )
374 identities = {(item["periodEnd"], item["periodType"]) for item in result["financialFacts"]}
375 assert ("2025-03-31", "ANNUAL") in identities
376 assert ("2026-06-30", "QUARTERLY") in identities
377
378
379 @pytest.mark.asyncio
380 async def test_news_filters_old_and_unrelated_articles_without_keyword_scoring() -> None:
381 news = [
382 {"headline": "Issuer update", "url": "https://news.test/current", "publishedAt": (NOW - timedelta(days=2)).isoformat(), "issuerSymbol": "READY.NS"},
383 {"headline": "Old story", "url": "https://news.test/old", "publishedAt": (NOW - timedelta(days=31)).isoformat(), "issuerSymbol": "READY.NS"},
384 {"headline": "Other issuer", "url": "https://news.test/other", "publishedAt": NOW.isoformat(), "issuerSymbol": "OTHER.NS"},
385 ]
386 adapter, _ = provider(
387 mapping("CURRENT_NEWS", "NEWS", "get_news"), payload(news=news)
388 )
389 result = await adapter.invoke(
390 tool="get_news", arguments=arguments("CURRENT_NEWS"), request_id="news"
391 )
392 assert [item["headline"] for item in result["news"]] == ["Issuer update"]
393 assert result["news"][0]["publishedAt"]
394 assert "materiality" not in result["news"][0]
395
396
397 @pytest.mark.asyncio
398 async def test_successful_empty_news_requires_explicit_provider_scan_success():
399 configured = mapping("CURRENT_NEWS", "NEWS", "get_news")
400 adapter, _ = provider(configured, payload(news=[], newsQuerySucceeded=True))
401 result = await adapter.invoke(tool="get_news", arguments=arguments("CURRENT_NEWS"), request_id="empty-news")
402 assert result["acquisitionOutcome"] == "SUCCESS_EMPTY"
403 assert result["news"] == []
404 assert result["events"] == []
405 adapter, _ = provider(configured, payload(news=[]))
406 with pytest.raises(McpGatewayError):
407 await adapter.invoke(tool="get_news", arguments=arguments("CURRENT_NEWS"), request_id="absent-news")
408
409
410 @pytest.mark.asyncio
411 async def test_generic_institutional_ownership_is_not_shareholding_equivalence() -> None:
412 configured = mapping("SHAREHOLDING", "SHAREHOLDING", "get_ownership")
413 response = payload(
414 ownership={
415 "periodEnd": NOW.isoformat(),
416 "institutionalOwnershipPercent": "42",
417 }
418 )
419 adapter, _ = provider(configured, response)
420 with pytest.raises(McpGatewayError) as error:
421 await adapter.invoke(
422 tool="get_ownership", arguments=arguments("SHAREHOLDING"), request_id="ownership"
423 )
424 assert error.value.code is McpErrorCode.EXTERNAL_RESULT_INCOMPLETE
425
426
427 @pytest.mark.asyncio
428 async def test_shareholding_requires_an_exact_quarter_end_period() -> None:
429 configured = mapping("SHAREHOLDING", "SHAREHOLDING", "get_ownership")
430 response = payload(
431 ownership={
432 "periodEnd": "2026-06-29T00:00:00Z",
433 "promoterHoldingPercent": "51",
434 "promoterPledgePercent": "0",
435 "promoterPledgeBasis": "PROMOTER_HOLDING",
436 "fiiFpiPercent": "14",
437 "diiPercent": "9",
438 }
439 )
440 adapter, _ = provider(configured, response)
441 with pytest.raises(McpGatewayError) as error:
442 await adapter.invoke(
443 tool="get_ownership",
444 arguments=arguments("SHAREHOLDING"),
445 request_id="ownership-period",
446 )
447 assert error.value.code is McpErrorCode.EXTERNAL_RESULT_INCOMPLETE
448
449
450 @pytest.mark.asyncio
451 async def test_catalyst_requires_a_readiness_supported_event_type() -> None:
452 configured = mapping(
453 "ORDER_BOOK_CAPEX_GUIDANCE", "CATALYSTS_EVENTS", "get_events"
454 )
455 response = payload(
456 events=[
457 {
458 "headline": "Generic corporate update",
459 "url": "https://news.test/generic-update",
460 "publishedAt": NOW.isoformat(),
461 "issuerSymbol": "READY.NS",
462 "eventType": "OTHER",
463 }
464 ]
465 )
466 adapter, _ = provider(configured, response)
467 with pytest.raises(McpGatewayError) as error:
468 await adapter.invoke(
469 tool="get_events",
470 arguments=arguments("ORDER_BOOK_CAPEX_GUIDANCE"),
471 request_id="unsupported-event-type",
472 )
473 assert error.value.code is McpErrorCode.EXTERNAL_RESULT_INCOMPLETE
474
475
476 @pytest.mark.asyncio
477 async def test_health_does_not_return_endpoint_or_credentials() -> None:
478 adapter, client = provider(mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote"))
479 adapter.config = YahooFinanceMcpConfig(
480 **{
481 **adapter.config.__dict__,
482 "auth_type": "HEADER",
483 "auth_token": SecretStr("super-secret-token"),
484 }
485 )
486 health = await adapter.health()
487 assert health["status"] == "UP"
488 serialized = str(health)
489 assert "super-secret-token" not in serialized
490 client.tools = ()
491 assert (await adapter.health())["status"] == "DEGRADED"
492
493
494 def test_internal_acquisition_route_requires_caller_and_emits_argument_free_audit() -> None:
495 capabilities = json.dumps(
496 [mapping("LATEST_PRICE", "LATEST_PRICE", "get_quote", maxAgeSeconds=600)]
497 )
498 settings = McpGatewaySettings(
499 AIP_ENVIRONMENT="TEST",
500 AIP_MCP_AUTHENTICATION_TYPE="TEST",
501 AIP_MCP_EXTERNAL_PROVIDERS_ENABLED="true",
502 AIP_MCP_YAHOO_ENABLED="true",
503 AIP_MCP_YAHOO_TRANSPORT="stdio",
504 AIP_MCP_YAHOO_STDIO_COMMAND="fake-yahoo-mcp",
505 AIP_MCP_YAHOO_CAPABILITIES_JSON=capabilities,
506 AIP_MCP_YAHOO_MAX_RETRIES="0",
507 AIP_MCP_EXTERNAL_CALLER_IDENTITIES="research-engine",
508 )
509 audit = RecordingAudit()
510 container = create_container(settings, reader=FakeApplicationReader(), audit=audit)
511 adapter = container.external_registry.get(PROVIDER_ID)
512 fake_client = FakeClient(payload(), tools=("get_quote",))
513 adapter.client_factory = FakeFactory(fake_client)
514 adapter.clock = lambda: NOW
515 app = create_mcp_server(container).streamable_http_app(
516 stateless_http=True, json_response=True
517 )
518 authorization = {
519 "authorized": True,
520 "issuedBy": "ProviderFallbackPolicy",
521 "globalInstrumentId": str(INSTRUMENT_ID),
522 "requirementId": "LATEST_PRICE",
523 "permittedProviderIds": [PROVIDER_ID],
524 "issuedAt": NOW.isoformat(),
525 }
526 body = {
527 "providerId": PROVIDER_ID,
528 "region": "INDIA",
529 "requirementId": "LATEST_PRICE",
530 "globalInstrumentId": str(INSTRUMENT_ID),
531 "providerSymbol": "READY.NS",
532 "expectedExchange": "NSE",
533 "expectedCurrency": "INR",
534 "authorization": authorization,
535 }
536 with TestClient(app) as client:
537 assert client.post("/internal/v1/external-research/acquire", json=body).status_code == 401
538 response = client.post(
539 "/internal/v1/external-research/acquire",
540 json=body,
541 headers={"X-AIP-Service-Identity": "research-engine", "X-Request-ID": "route-5b"},
542 )
543 assert response.status_code == 200
544 assert response.json()["data"]["providerId"] == PROVIDER_ID
545 assert [event["event"] for event in audit.events] == [
546 "MCP_TOOL_INVOKED",
547 "MCP_TOOL_SUCCEEDED",
548 ]
549 serialized_audit = str(audit.events)
550 assert "READY.NS" not in serialized_audit
551 assert "structuredFacts" not in serialized_audit