"""Discovery + parametrization for the example-stories matrix. Reads ``examples/stories/manifest.toml`` and expands each story across (server_variant × transport × era). The story modules are imported as real packages (the ``mcp-example-stories`` workspace member installs ``stories`` editable), so pyright sees them and a signature change red-lines every story. The HTTP-ASGI leg reuses the interaction suite's in-process bridge directly from ``tests.interaction.transports._bridge`` (both live under ``tests/``); the move to ``stories._shared.bridge`` is a later batch. """ from __future__ import annotations import importlib import sys from collections.abc import AsyncIterator from dataclasses import dataclass from pathlib import Path from typing import Any import httpx import pytest import stories from mcp_types.version import LATEST_MODERN_VERSION from starlette.applications import Starlette from stories._harness import AuthBuilder, TargetFactory from stories._hosting import asgi_from from mcp.client.streamable_http import streamable_http_client from tests.interaction.transports._bridge import StreamingASGITransport if sys.version_info >= (3, 11): # pragma: lax no cover import tomllib else: # pragma: lax no cover import tomli as tomllib STORIES_DIR = Path(stories.__file__).parent BASE_URL = "http://127.0.0.1:8000" MANIFEST = tomllib.loads((STORIES_DIR / "manifest.toml").read_text()) DEFAULTS: dict[str, Any] = MANIFEST["defaults"] STORIES: dict[str, dict[str, Any]] = MANIFEST["story"] _ERA_TO_MODE = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"} """``Client`` rejects handshake-era version strings, so ``legacy`` resolves to ``mode='legacy'`` rather than ``LATEST_HANDSHAKE_VERSION``. ``in-body`` legs pin their connection modes inside ``main`` themselves, so they get ``"auto"`` — the ``Client`` default; the era axis still passes every ``mode=`` explicitly.""" def story_cfg(name: str) -> dict[str, Any]: return DEFAULTS | STORIES.get(name, {}) def _expand_era(era: str) -> tuple[str, ...]: if era == "dual": return ("modern", "legacy") if era == "dual-in-body": return ("in-body",) return (era,) @dataclass(frozen=True) class Leg: story: str server_variant: str transport: str era: str @property def id(self) -> str: return "-".join((self.story, self.server_variant, self.transport, self.era)) @property def mode(self) -> str: """The explicit ``mode=`` this leg passes to the story's ``main``.""" return _ERA_TO_MODE[self.era] def _legs() -> list[tuple[Leg, dict[str, Any]]]: out: list[tuple[Leg, dict[str, Any]]] = [] for name in STORIES: cfg = story_cfg(name) variants = ["server"] + (["server_lowlevel"] if cfg["lowlevel"] else []) out.extend( (Leg(name, variant, transport, era), cfg) for variant in variants for transport in cfg["transports"] for era in _expand_era(cfg["era"]) ) return out def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: if "leg" not in metafunc.fixturenames: return params: list[Any] = [] for leg, cfg in _legs(): marks: list[pytest.MarkDecorator] = [] if f"{leg.transport}:{leg.era}" in cfg["xfail"]: marks.append(pytest.mark.xfail(strict=True, reason="manifest xfail")) # pragma: lax no cover params.append(pytest.param(leg, marks=marks, id=leg.id)) metafunc.parametrize("leg", params) @pytest.fixture def cfg(leg: Leg) -> dict[str, Any]: return story_cfg(leg.story) @pytest.fixture def server_module(leg: Leg) -> Any: return importlib.import_module(f"stories.{leg.story}.{leg.server_variant}") @pytest.fixture def client_module(leg: Leg) -> Any: return importlib.import_module(f"stories.{leg.story}.client") @dataclass class Hosted: """One server/app instance hosted for the leg's whole duration. ``targets`` yields a fresh connection target against that single instance on every call, so state observed by one connection is visible to the next. ``http`` is the shared raw ``httpx.AsyncClient`` bound to the same ASGI app, or ``None`` on the in-memory leg. """ targets: TargetFactory http: httpx.AsyncClient | None @pytest.fixture async def hosted( leg: Leg, cfg: dict[str, Any], server_module: Any, client_module: Any, monkeypatch: pytest.MonkeyPatch ) -> AsyncIterator[Hosted]: """Build the leg's server/app once and keep it running for the test. The story's ``main`` owns the ``Client(target, mode=...)`` construction; this fixture only decides what ``target`` is. Auth stories thread an ``httpx.Auth`` onto the bridge client via a module-level ``build_auth(http)`` export. """ for key, value in cfg["env"].items(): monkeypatch.setenv(key, value) path = cfg["mcp_path"] if leg.transport == "in-memory": server = server_module.build_server() yield Hosted(lambda: server, None) return # http-asgi: one Starlette app per leg. ``server_export="app"`` stories hand us the # app directly; ``"factory"`` stories are wrapped via ``asgi_from``. Either way the # app's own lifespan is what brings the session manager up, and the in-process # bridge never fires ASGI lifespan events itself, so enter it explicitly. if cfg["server_export"] == "app": app: Starlette = server_module.build_app() else: app = asgi_from(server_module.build_server(), path=path) build_auth: AuthBuilder | None = getattr(client_module, "build_auth", None) async with ( app.router.lifespan_context(app), httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, ): if build_auth is not None: http_client.auth = build_auth(http_client) yield Hosted(lambda: streamable_http_client(f"{BASE_URL}{path}", http_client=http_client), http_client)