chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Run every story's ``main`` over the in-process (transport × era × variant) matrix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from tests.examples.conftest import MANIFEST, STORIES, STORIES_DIR, Hosted, Leg, story_cfg
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_story(leg: Leg, cfg: dict[str, Any], hosted: Hosted, client_module: Any) -> None:
|
||||
kwargs: dict[str, Any] = {"mode": leg.mode}
|
||||
if cfg["needs_http"]:
|
||||
kwargs["http"] = hosted.http
|
||||
with anyio.fail_after(cfg["timeout_s"]):
|
||||
if cfg["multi_connection"]:
|
||||
await client_module.main(hosted.targets, **kwargs)
|
||||
else:
|
||||
await client_module.main(hosted.targets(), **kwargs)
|
||||
|
||||
|
||||
def test_manifest_matches_filesystem() -> None:
|
||||
"""Manifest [story.*] / [deferred] keys and on-disk story directories agree exactly."""
|
||||
dirs = {d.name for d in STORIES_DIR.iterdir() if d.is_dir() and not d.name.startswith(("_", "."))}
|
||||
runnable = {d for d in dirs if (STORIES_DIR / d / "client.py").exists()}
|
||||
in_manifest = set(STORIES)
|
||||
assert runnable == in_manifest, {"only_on_disk": runnable - in_manifest, "only_in_manifest": in_manifest - runnable}
|
||||
# README-only stub dirs must be exactly the [deferred] table.
|
||||
deferred_manifest = set(MANIFEST.get("deferred", {}))
|
||||
assert dirs - runnable == deferred_manifest, {
|
||||
"stub_dirs_missing_from_manifest": (dirs - runnable) - deferred_manifest,
|
||||
"deferred_entries_missing_dir": deferred_manifest - (dirs - runnable),
|
||||
}
|
||||
assert runnable.isdisjoint(deferred_manifest), "deferred stories must not have a client.py"
|
||||
|
||||
|
||||
_ERAS = {"dual", "modern", "legacy", "dual-in-body"}
|
||||
_TRANSPORTS = {"in-memory", "http-asgi"}
|
||||
_SERVER_EXPORTS = {"factory", "app"}
|
||||
|
||||
|
||||
def test_manifest_schema_valid() -> None:
|
||||
"""Declared manifest values are mutually consistent with the story files."""
|
||||
for name in STORIES:
|
||||
cfg = story_cfg(name)
|
||||
assert "-" not in name, f"{name!r}: story directories must be underscored"
|
||||
assert cfg["era"] in _ERAS, f"{name!r}: era={cfg['era']!r} not in {_ERAS}"
|
||||
assert cfg["server_export"] in _SERVER_EXPORTS, f"{name!r}: server_export={cfg['server_export']!r}"
|
||||
assert set(cfg["transports"]) <= _TRANSPORTS, f"{name!r}: transports={cfg['transports']!r}"
|
||||
assert (STORIES_DIR / name / "__init__.py").exists(), f"{name!r}: missing __init__.py"
|
||||
if cfg["server_export"] == "factory":
|
||||
assert (STORIES_DIR / name / "server.py").exists(), f"{name!r}: missing server.py"
|
||||
else:
|
||||
assert "in-memory" not in cfg["transports"], f"{name!r}: server_export='app' cannot run in-memory"
|
||||
if cfg["needs_http"]:
|
||||
assert cfg["transports"] == ["http-asgi"], f"{name!r}: needs_http requires transports=['http-asgi']"
|
||||
ll = STORIES_DIR / name / "server_lowlevel.py"
|
||||
assert cfg["lowlevel"] == ll.exists(), f"{name!r}: lowlevel={cfg['lowlevel']} vs server_lowlevel.py on disk"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(STORIES))
|
||||
def test_main_signature_matches_manifest(name: str) -> None:
|
||||
"""``main``'s first parameter is ``target``/``targets`` per ``multi_connection``; ``http`` iff ``needs_http``."""
|
||||
cfg = story_cfg(name)
|
||||
params = list(inspect.signature(importlib.import_module(f"stories.{name}.client").main).parameters)
|
||||
first = "targets" if cfg["multi_connection"] else "target"
|
||||
assert params[0] == first, f"{name}: first param is {params[0]!r}, expected {first!r}"
|
||||
assert ("http" in params) == cfg["needs_http"], f"{name}: 'http' param vs needs_http={cfg['needs_http']}"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Subprocess smoke for the story ``__main__`` paths.
|
||||
|
||||
The in-process matrix in ``test_stories.py`` never executes a story's
|
||||
``if __name__ == "__main__"`` block, so ``run_client`` / ``run_server_from_args`` /
|
||||
``run_app_from_args`` and the real stdio + uvicorn entries are unverified by
|
||||
construction. This file proves that plumbing by running the literal commands the
|
||||
story READMEs print: stdio (``run_client`` spawns the server over stdio) and bare
|
||||
``--http`` (``run_client`` self-hosts the server on a real uvicorn socket on a
|
||||
port it owns, then terminates it).
|
||||
|
||||
lax no cover: gated on ``MCP_EXAMPLES_SMOKE=1``, which CI sets on exactly one
|
||||
matrix cell (ubuntu / 3.12 / locked — see ``shared.yml``). Every other cell
|
||||
skips at collection, so the test body is uncovered there and the per-job 100%
|
||||
gate would otherwise fail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.anyio,
|
||||
pytest.mark.skipif(
|
||||
os.environ.get("MCP_EXAMPLES_SMOKE") != "1",
|
||||
reason="subprocess smoke runs on one CI cell only; set MCP_EXAMPLES_SMOKE=1",
|
||||
),
|
||||
]
|
||||
|
||||
_REPO_ROOT = Path(__file__).parents[2]
|
||||
# httpx in the spawned client honours these and tries to mount a SOCKS transport even for
|
||||
# 127.0.0.1; strip them so the smoke run is hermetic regardless of the caller's shell.
|
||||
_PROXY_VARS = {v for base in ("all_proxy", "http_proxy", "https_proxy", "ftp_proxy") for v in (base, base.upper())}
|
||||
_ENV = {k: v for k, v in os.environ.items() if k not in _PROXY_VARS}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argv",
|
||||
[
|
||||
("stories.tools.client",),
|
||||
("stories.tools.client", "--http"),
|
||||
("stories.bearer_auth.client", "--http"),
|
||||
],
|
||||
ids=["tools-stdio", "tools-http", "bearer_auth-http"],
|
||||
)
|
||||
async def test_story_main_runs_end_to_end(argv: tuple[str, ...]) -> None: # pragma: lax no cover
|
||||
"""``python -m <story>.client [--http]`` (the README command) exits 0 over a real subprocess."""
|
||||
with anyio.fail_after(60):
|
||||
async with await anyio.open_process(
|
||||
[sys.executable, "-m", *argv], cwd=_REPO_ROOT, env=_ENV, stdout=None, stderr=None
|
||||
) as proc:
|
||||
await proc.wait()
|
||||
assert proc.returncode == 0
|
||||
@@ -0,0 +1,122 @@
|
||||
"""AST shape-check: stories keep the SDK construction visible and the harness contained.
|
||||
|
||||
The python analogue of typescript-sdk's eslint import-allowlist over its examples,
|
||||
strictly stronger: it also asserts each ``main`` constructs ``Client(...)`` itself —
|
||||
the regression the harness inversion exists to prevent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.examples.conftest import STORIES, STORIES_DIR, story_cfg
|
||||
|
||||
_HARNESS_ALLOWLIST = frozenset({"run_client", "target_from_args", "Target", "TargetFactory"})
|
||||
"""The only ``stories._harness`` names a ``client.py`` may use. ``AuthBuilder`` is
|
||||
additionally allowed in a ``client.py`` that defines ``build_auth`` (the auth seam
|
||||
``run_client`` and the conftest both look up by name)."""
|
||||
|
||||
_MCPSERVER_TIER = ("mcp.server.mcpserver", "mcp.server.MCPServer")
|
||||
"""Both spellings of the high-level tier: the ``mcpserver`` module and its ``mcp.server`` re-export."""
|
||||
|
||||
_LOWLEVEL_STORIES = [name for name in sorted(STORIES) if story_cfg(name)["lowlevel"]]
|
||||
|
||||
|
||||
def _parse(path: Path) -> ast.Module:
|
||||
"""Parse ``path`` into an AST module."""
|
||||
return ast.parse(path.read_text(), filename=str(path))
|
||||
|
||||
|
||||
def _resolve(node: ast.ImportFrom, package: str) -> str:
|
||||
"""The absolute module path ``node`` imports from, resolving a relative import against ``package``."""
|
||||
parents = package.split(".")[: -(node.level - 1) or None] if node.level else []
|
||||
return ".".join([*parents, *([node.module] if node.module else [])])
|
||||
|
||||
|
||||
def _module_paths(tree: ast.Module, package: str) -> set[str]:
|
||||
"""Every dotted module path the file (a module in ``package``) references — imports, with relative
|
||||
ones resolved to absolute, plus attribute chains rooted at an import-bound name (``import mcp.shared``
|
||||
+ ``mcp.shared._memory.f()``), so a reach-in is caught however it is spelled."""
|
||||
paths: set[str] = set()
|
||||
bound: dict[str, str] = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
paths.add(alias.name)
|
||||
local = alias.asname or alias.name.partition(".")[0]
|
||||
bound[local] = alias.name if alias.asname else local
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
module = _resolve(node, package)
|
||||
for alias in node.names:
|
||||
paths.add(f"{module}.{alias.name}")
|
||||
bound[alias.asname or alias.name] = f"{module}.{alias.name}"
|
||||
for node in ast.walk(tree):
|
||||
attrs: list[str] = []
|
||||
expr: ast.AST = node
|
||||
while isinstance(expr, ast.Attribute):
|
||||
attrs.append(expr.attr)
|
||||
expr = expr.value
|
||||
if attrs and isinstance(expr, ast.Name) and expr.id in bound:
|
||||
paths.add(".".join([bound[expr.id], *reversed(attrs)]))
|
||||
return paths
|
||||
|
||||
|
||||
def _is_private_mcp(path: str) -> bool:
|
||||
"""True when ``path`` crosses a ``_``-private segment inside the ``mcp`` package."""
|
||||
head, *rest = path.split(".")
|
||||
return head == "mcp" and any(part.startswith("_") for part in rest)
|
||||
|
||||
|
||||
def _is_story_module(path: str) -> bool:
|
||||
"""True for ``stories.<story>...`` — a story package, not a ``stories._*`` scaffold."""
|
||||
head, _, rest = path.partition(".")
|
||||
return head == "stories" and bool(rest) and not rest.startswith("_")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(STORIES))
|
||||
def test_main_constructs_client_inline(name: str) -> None:
|
||||
"""``main``'s body contains a literal ``Client(...)`` call; the construction is never hidden in a helper."""
|
||||
tree = _parse(STORIES_DIR / name / "client.py")
|
||||
mains = [n for n in tree.body if isinstance(n, ast.AsyncFunctionDef) and n.name == "main"]
|
||||
assert mains, f"{name}/client.py defines no top-level async `main`"
|
||||
calls = {n.func.id for n in ast.walk(mains[0]) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)}
|
||||
assert "Client" in calls, f"{name}/client.py: main() never calls Client(...) itself"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(STORIES))
|
||||
def test_client_harness_imports_within_allowlist(name: str) -> None:
|
||||
"""``client.py`` takes nothing from ``stories._harness`` beyond the allowlist, bounding the harness surface."""
|
||||
tree = _parse(STORIES_DIR / name / "client.py")
|
||||
defines_build_auth = any(isinstance(n, ast.FunctionDef) and n.name == "build_auth" for n in tree.body)
|
||||
allowed = _HARNESS_ALLOWLIST | {"AuthBuilder"} if defines_build_auth else _HARNESS_ALLOWLIST
|
||||
paths = _module_paths(tree, package=f"stories.{name}")
|
||||
used = {p.removeprefix("stories._harness.").partition(".")[0] for p in paths if p.startswith("stories._harness.")}
|
||||
assert used <= allowed, f"{name}/client.py uses {sorted(used - allowed)} from stories._harness"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(STORIES))
|
||||
def test_story_files_import_no_private_mcp_module(name: str) -> None:
|
||||
"""No file in a story directory references a ``_``-private ``mcp.*`` module."""
|
||||
for path in sorted((STORIES_DIR / name).glob("*.py")):
|
||||
private = sorted(p for p in _module_paths(_parse(path), package=f"stories.{name}") if _is_private_mcp(p))
|
||||
assert not private, f"{path.relative_to(STORIES_DIR)} reaches into private mcp module(s): {private}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", _LOWLEVEL_STORIES)
|
||||
def test_server_lowlevel_imports_no_mcpserver_tier(name: str) -> None:
|
||||
"""``server_lowlevel.py`` stays on the lowlevel tier; it never references ``MCPServer`` or its module."""
|
||||
paths = _module_paths(_parse(STORIES_DIR / name / "server_lowlevel.py"), package=f"stories.{name}")
|
||||
high = sorted(p for p in paths if any(f"{p}.".startswith(f"{tier}.") for tier in _MCPSERVER_TIER))
|
||||
assert not high, f"{name}/server_lowlevel.py references the MCPServer tier: {high}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scaffold", ["_harness.py", "_hosting.py"])
|
||||
def test_scaffold_imports_no_story_module(scaffold: str) -> None:
|
||||
"""The dependency is one-way: ``_harness.py`` / ``_hosting.py`` import no ``stories.<story>`` module."""
|
||||
story_refs = sorted(
|
||||
p for p in _module_paths(_parse(STORIES_DIR / scaffold), package="stories") if _is_story_module(p)
|
||||
)
|
||||
assert not story_refs, f"{scaffold} imports a story module: {story_refs}"
|
||||
Reference in New Issue
Block a user