| 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 |