diff --git a/src/mcp/client/experimental/server_card.py b/src/mcp/client/experimental/server_card.py new file mode 100644 index 0000000000..f208164c7d --- /dev/null +++ b/src/mcp/client/experimental/server_card.py @@ -0,0 +1,84 @@ +"""Ingest MCP Server Cards (SEP-2127). + +WARNING: These APIs are experimental and may change without notice. + +A client discovers how to connect to a remote server by fetching its card from +the conventional ``.well-known`` location before initializing a session:: + + from mcp.client.experimental.server_card import fetch_server_card + + card = await fetch_server_card("https://dice.example.com") + for remote in card.remotes or []: + print(remote.type, remote.url, remote.supported_protocol_versions) + +The returned :class:`ServerCard` is fully validated; malformed documents raise +``pydantic.ValidationError``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from urllib.parse import urljoin, urlsplit + +import httpx + +from mcp.shared.experimental.server_card.types import WELL_KNOWN_PATH, ServerCard + +__all__ = ["well_known_url", "fetch_server_card", "load_server_card"] + + +def well_known_url(url: str, *, well_known_path: str = WELL_KNOWN_PATH) -> str: + """Resolve the Server Card URL for a server's origin. + + Accepts either a bare origin (``https://example.com``) or any URL on the + server (e.g. its ``/mcp`` endpoint); the card always lives at the host root. + + Raises: + ValueError: If ``url`` is not an absolute http(s) URL. + """ + parts = urlsplit(url) + if not parts.scheme or not parts.netloc: + raise ValueError(f"Expected an absolute http(s) URL, got {url!r}") + origin = f"{parts.scheme}://{parts.netloc}" + return urljoin(origin, well_known_path) + + +async def fetch_server_card( + url: str, + *, + well_known_path: str = WELL_KNOWN_PATH, + httpx_client: httpx.AsyncClient | None = None, +) -> ServerCard: + """Fetch and validate the Server Card for the server at ``url``. + + ``url`` may be the server's origin or any URL on the same host; the card is + resolved to ````. Pass an existing ``httpx_client`` + to reuse connection pooling / auth, otherwise a short-lived client is used. + + Raises: + ValueError: If ``url`` is not an absolute http(s) URL. + httpx.HTTPError: If the request fails or returns a non-2xx status. + pydantic.ValidationError: If the document is not a valid Server Card. + """ + target = well_known_url(url, well_known_path=well_known_path) + + if httpx_client is None: + async with httpx.AsyncClient(follow_redirects=True) as client: + response = await client.get(target, headers={"Accept": "application/json"}) + else: + response = await httpx_client.get(target, headers={"Accept": "application/json"}) + response.raise_for_status() + return ServerCard.model_validate(response.json()) + + +def load_server_card(path: str | Path) -> ServerCard: + """Load and validate a Server Card from a JSON file. + + Raises: + OSError: If the file cannot be read. + json.JSONDecodeError: If the file is not valid JSON. + pydantic.ValidationError: If the document is not a valid Server Card. + """ + text = Path(path).read_text(encoding="utf-8") + return ServerCard.model_validate(json.loads(text)) diff --git a/src/mcp/server/experimental/server_card.py b/src/mcp/server/experimental/server_card.py new file mode 100644 index 0000000000..197c0addb4 --- /dev/null +++ b/src/mcp/server/experimental/server_card.py @@ -0,0 +1,124 @@ +"""Generate and serve MCP Server Cards (SEP-2127). + +WARNING: These APIs are experimental and may change without notice. + +A server author builds a card from the server's identity and either serves it +from the conventional ``.well-known`` path or hands it to their own Starlette +app:: + + from mcp.server.experimental.server_card import build_server_card, mount_server_card + from mcp.shared.experimental.server_card import Remote + + card = build_server_card( + server, + name="io.modelcontextprotocol.examples/dice-roller", + remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")], + ) + + app = server.streamable_http_app() + mount_server_card(app, card) # GET /.well-known/mcp/server-card + +To write a card to a file instead, serialize it with +``card.model_dump_json(by_alias=True, exclude_none=True)``. +""" + +from __future__ import annotations + +from typing import Any, Protocol + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from mcp.shared.experimental.server_card.types import ( + WELL_KNOWN_PATH, + Icon, + Remote, + Repository, + ServerCard, +) + +__all__ = ["build_server_card", "server_card_route", "mount_server_card"] + + +class _ServerIdentity(Protocol): + """The identity attributes shared by the low-level ``Server`` and ``MCPServer``.""" + + name: str + version: str | None + title: str | None + description: str | None + website_url: str | None + icons: list[Icon] | None + + +def build_server_card( + server: _ServerIdentity, + *, + name: str, + remotes: list[Remote] | None = None, + repository: Repository | None = None, + meta: dict[str, Any] | None = None, +) -> ServerCard: + """Build a Server Card from a running server's identity metadata. + + ``name`` is the card's reverse-DNS ``namespace/name`` identifier, passed + explicitly because a server's display ``name`` is free-form. The version, + title, description, website and icons are taken from ``server``. + + Args: + server: A low-level ``Server`` or high-level ``MCPServer`` (anything + exposing the standard identity attributes). + name: Reverse-DNS server name, e.g. ``"io.modelcontextprotocol/everything"``. + remotes: Remote endpoints to advertise. + repository: Optional source repository metadata. + meta: Optional ``_meta`` extension metadata. + + Returns: + A validated :class:`ServerCard`. + + Raises: + ValueError: If ``server`` has no ``version`` or ``description`` set; both + are required on a card. + pydantic.ValidationError: If the resulting card is invalid (e.g. ``name`` + is not reverse-DNS). + """ + if server.version is None: + raise ValueError("server.version must be set to build a Server Card") + if not server.description: + raise ValueError("server.description must be set to build a Server Card") + return ServerCard( + name=name, + version=server.version, + description=server.description, + title=server.title, + website_url=server.website_url, + icons=server.icons, + remotes=remotes, + repository=repository, + _meta=meta, + ) + + +def server_card_route(card: ServerCard, *, path: str = WELL_KNOWN_PATH) -> Route: + """Build a Starlette GET route that serves ``card`` as JSON at ``path``. + + Add it to a new app — ``Starlette(routes=[server_card_route(card)])`` — or an + existing one via :func:`mount_server_card`. The payload is serialized once; + a card is static metadata. + """ + payload = card.model_dump(mode="json", by_alias=True, exclude_none=True) + + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse(payload, media_type="application/json") + + return Route(path, endpoint=endpoint, methods=["GET"], name="mcp_server_card") + + +def mount_server_card(app: Starlette, card: ServerCard, *, path: str = WELL_KNOWN_PATH) -> None: + """Attach a Server Card route to an existing Starlette application. + + The route is unauthenticated, which is what pre-connection discovery wants. + """ + app.router.routes.append(server_card_route(card, path=path)) diff --git a/src/mcp/shared/experimental/server_card/__init__.py b/src/mcp/shared/experimental/server_card/__init__.py new file mode 100644 index 0000000000..ac7e7f611b --- /dev/null +++ b/src/mcp/shared/experimental/server_card/__init__.py @@ -0,0 +1,55 @@ +"""MCP Server Cards (SEP-2127) — shared types. + +WARNING: These APIs are experimental and may change without notice. + +A Server Card is a static metadata document describing a remote MCP server, +suitable for pre-connection discovery. See +``mcp.shared.experimental.server_card.types`` for the model definitions. + +* Servers generate and serve a card with ``mcp.server.experimental.server_card``. +* Clients ingest one with ``mcp.client.experimental.server_card``. +""" + +from mcp.shared.experimental.server_card.types import ( + SERVER_CARD_SCHEMA_URL, + SERVER_SCHEMA_URL, + WELL_KNOWN_PATH, + Argument, + Icon, + Input, + InputWithVariables, + KeyValueInput, + NamedArgument, + Package, + PackageTransport, + PositionalArgument, + Remote, + Repository, + Server, + ServerCard, + SsePackageTransport, + StdioTransport, + StreamableHttpPackageTransport, +) + +__all__ = [ + "SERVER_CARD_SCHEMA_URL", + "SERVER_SCHEMA_URL", + "WELL_KNOWN_PATH", + "Argument", + "Icon", + "Input", + "InputWithVariables", + "KeyValueInput", + "NamedArgument", + "Package", + "PackageTransport", + "PositionalArgument", + "Remote", + "Repository", + "Server", + "ServerCard", + "SsePackageTransport", + "StdioTransport", + "StreamableHttpPackageTransport", +] diff --git a/src/mcp/shared/experimental/server_card/types.py b/src/mcp/shared/experimental/server_card/types.py new file mode 100644 index 0000000000..ffe7b98a5d --- /dev/null +++ b/src/mcp/shared/experimental/server_card/types.py @@ -0,0 +1,284 @@ +"""Pydantic models for MCP Server Cards (SEP-2127). + +WARNING: These APIs are experimental and may change without notice. + +A Server Card is a static metadata document describing a remote MCP server — +its identity, transport endpoints, and supported protocol versions — suitable +for publishing at ``/.well-known/mcp/server-card`` so a client can discover and +connect to it before initialization. The companion ``Server`` shape is a strict +superset that adds locally-runnable ``packages`` (the MCP Registry ``server.json`` +shape). + +These models mirror the protocol types in ``mcp.types`` (camelCase wire format, +``Icon`` reused from the core spec) and validate purely through Pydantic, like +the rest of the SDK. + +See https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127. +""" + +from __future__ import annotations + +import re +from typing import Annotated, Any, Literal + +from pydantic import Field, field_validator + +from mcp.types import Icon +from mcp.types._types import MCPModel + +#: Canonical ``$schema`` value for a Server Card document. +SERVER_CARD_SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json" +#: Canonical ``$schema`` value for a registry-shaped Server document. +SERVER_SCHEMA_URL = "https://static.modelcontextprotocol.io/schemas/v1/server.schema.json" +#: Conventional path a Server Card is published at, relative to the host root. +WELL_KNOWN_PATH = "/.well-known/mcp/server-card" + +# Constraints copied verbatim from the schema source of truth. +_SCHEMA_URL_PATTERN = r"^https://static\.modelcontextprotocol\.io/schemas/v1/[^/]+\.schema\.json$" +_NAME_PATTERN = r"^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$" +_URL_TEMPLATE_PATTERN = r"^(https?://[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$" +_SHA256_PATTERN = r"^[a-f0-9]{64}$" + +# Version strings that look like ranges/wildcards. The spec allows non-semantic +# versions but rejects ranges; this is the one constraint not expressible as a +# field pattern, so it is enforced with a validator. +_VERSION_RANGE_RE = re.compile(r"[\^~]|[<>]=?|\.\*|\bx\b", re.IGNORECASE) + + +class Input(MCPModel): + """A user-supplied or pre-set input value (header value, env var, argument).""" + + description: str | None = None + """Human-readable explanation of the input.""" + + is_required: bool | None = None + """Whether the input must be supplied for the server to run.""" + + is_secret: bool | None = None + """Whether the input is a secret value (password, token, ...).""" + + format: Literal["string", "number", "boolean", "filepath"] | None = None + """Input format. ``"filepath"`` is a path on the user's filesystem.""" + + default: str | None = None + """Default value for the input.""" + + placeholder: str | None = None + """Placeholder shown during configuration.""" + + value: str | None = None + """Pre-set value. ``{curly_braces}`` identifiers are replaced from ``variables``.""" + + choices: list[str] | None = None + """Allowed values. If provided, the user must select one.""" + + +class InputWithVariables(Input): + """An ``Input`` whose ``value`` may reference ``{curly_braces}`` variables.""" + + variables: dict[str, Input] | None = None + """Variables referenced by ``{curly_braces}`` identifiers in ``value``.""" + + +class KeyValueInput(InputWithVariables): + """A named input — used for environment variables and HTTP headers.""" + + name: str + """Name of the header or environment variable.""" + + +class PositionalArgument(InputWithVariables): + """A positional command-line input — inserted verbatim into the command line.""" + + type: Literal["positional"] = "positional" + + value_hint: str | None = None + """Label / value-hint identifying the argument in URL variable substitution.""" + + is_repeated: bool | None = None + """Whether the argument can be repeated multiple times.""" + + +class NamedArgument(InputWithVariables): + """A named command-line input — a ``--flag={value}`` parameter.""" + + type: Literal["named"] = "named" + + name: str + """The flag name, including any leading dashes (e.g. ``"--port"``).""" + + is_repeated: bool | None = None + """Whether the argument can be repeated multiple times.""" + + +Argument = Annotated[PositionalArgument | NamedArgument, Field(discriminator="type")] +"""A command-line argument supplied to a package's binary or runtime.""" + + +class Repository(MCPModel): + """Repository metadata for the MCP server source code.""" + + url: str + """Repository URL for browsing source and ``git clone``.""" + + source: str + """Hosting service identifier (e.g. ``"github"``).""" + + subfolder: str | None = None + """Relative path from repo root to the server in a monorepo.""" + + id: str | None = None + """Stable repository identifier from the hosting service.""" + + +class Remote(MCPModel): + """Metadata for connecting to a remote (HTTP-based) MCP server endpoint.""" + + type: Literal["streamable-http", "sse"] + """The transport type for this remote endpoint.""" + + url: Annotated[str, Field(pattern=_URL_TEMPLATE_PATTERN)] + """URL template. ``{curly_braces}`` variables are substituted before connecting.""" + + headers: list[KeyValueInput] | None = None + """HTTP headers required or accepted when connecting.""" + + variables: dict[str, Input] | None = None + """Variables referenceable as ``{curly_braces}`` in ``url`` and header values.""" + + supported_protocol_versions: list[str] | None = None + """MCP protocol versions actively supported by this endpoint.""" + + +class StdioTransport(MCPModel): + """Stdio transport — the client launches the package as a subprocess.""" + + type: Literal["stdio"] = "stdio" + + +class StreamableHttpPackageTransport(MCPModel): + """Streamable-HTTP transport for a locally-runnable package.""" + + type: Literal["streamable-http"] = "streamable-http" + + url: Annotated[str, Field(pattern=_URL_TEMPLATE_PATTERN)] + """URL template for the streamable-http transport.""" + + headers: list[KeyValueInput] | None = None + """HTTP headers to include when connecting to the local endpoint.""" + + +class SsePackageTransport(MCPModel): + """Server-sent events (SSE) transport for a locally-runnable package.""" + + type: Literal["sse"] = "sse" + + url: Annotated[str, Field(pattern=_URL_TEMPLATE_PATTERN)] + """SSE endpoint URL template.""" + + headers: list[KeyValueInput] | None = None + """HTTP headers to include when connecting to the local endpoint.""" + + +PackageTransport = Annotated[ + StdioTransport | StreamableHttpPackageTransport | SsePackageTransport, + Field(discriminator="type"), +] +"""Transport protocol configuration for a locally-runnable package.""" + + +class Package(MCPModel): + """Metadata for installing and running a packaged MCP server locally.""" + + registry_type: str + """How to download the package (``"npm"``, ``"pypi"``, ``"oci"``, ...).""" + + identifier: str + """Package name (for registries) or URL (for direct downloads).""" + + transport: PackageTransport + """Transport configuration for invoking this package after installation.""" + + registry_base_url: str | None = None + """Base URL of the package registry.""" + + version: Annotated[str, Field(min_length=1)] | None = None + """Package version.""" + + supported_protocol_versions: list[str] | None = None + """MCP protocol versions actively supported by this package.""" + + runtime_hint: str | None = None + """Hint for the runtime to use (``"npx"``, ``"uvx"``, ``"docker"``, ...).""" + + runtime_arguments: list[Argument] | None = None + """Arguments passed to the package's runtime command.""" + + package_arguments: list[Argument] | None = None + """Arguments passed to the package's binary.""" + + environment_variables: list[KeyValueInput] | None = None + """Environment variables to set when running the package.""" + + file_sha256: Annotated[str, Field(pattern=_SHA256_PATTERN)] | None = None + """SHA-256 of the package file. Required for MCPB packages.""" + + +class ServerCard(MCPModel): + """A static metadata document describing a remote MCP server. + + Suitable for publishing at ``/.well-known/mcp/server-card`` for + pre-connection discovery. Describes only identity, transport and protocol + versions — never the primitive listings (tools/resources/prompts), which + remain subject to runtime listing. + """ + + schema_uri: Annotated[str, Field(alias="$schema", pattern=_SCHEMA_URL_PATTERN)] = SERVER_CARD_SCHEMA_URL + """The Server Card JSON Schema URI this document conforms to (the ``$schema`` key).""" + + name: Annotated[str, Field(min_length=3, max_length=200, pattern=_NAME_PATTERN)] + """Server name in reverse-DNS ``namespace/name`` format.""" + + version: Annotated[str, Field(max_length=255)] + """Server version. SHOULD follow semantic versioning; ranges are rejected.""" + + description: Annotated[str, Field(min_length=1, max_length=100)] + """Clear human-readable explanation of server functionality.""" + + title: Annotated[str, Field(min_length=1, max_length=100)] | None = None + """Optional human-readable display name.""" + + website_url: str | None = None + """Optional URL to the server's homepage / documentation.""" + + repository: Repository | None = None + """Optional repository metadata for source inspection.""" + + icons: list[Icon] | None = None + """Optional set of sized icons for display in a UI.""" + + remotes: list[Remote] | None = None + """Metadata for making HTTP-based connections to this server.""" + + meta: dict[str, Any] | None = Field(alias="_meta", default=None) + """Extension metadata using reverse-DNS namespacing (the ``_meta`` key).""" + + @field_validator("version") + @classmethod + def _reject_version_ranges(cls, value: str) -> str: + if _VERSION_RANGE_RE.search(value): + raise ValueError(f"version must be an exact version, not a range/wildcard: {value!r}") + return value + + +class Server(ServerCard): + """A superset of ``ServerCard`` that also describes locally-runnable packages. + + This is the shape used by the MCP Registry's ``server.json``. Typically + published to a registry rather than served from a ``.well-known`` URI. + """ + + schema_uri: Annotated[str, Field(alias="$schema", pattern=_SCHEMA_URL_PATTERN)] = SERVER_SCHEMA_URL + + packages: list[Package] | None = None + """Metadata for running and connecting to local instances of this server.""" diff --git a/tests/experimental/server_card/test_client.py b/tests/experimental/server_card/test_client.py new file mode 100644 index 0000000000..a6783b8709 --- /dev/null +++ b/tests/experimental/server_card/test_client.py @@ -0,0 +1,91 @@ +"""Tests for client-side Server Card ingestion.""" + +from __future__ import annotations + +import functools +import json +from pathlib import Path + +import httpx +import pytest +from pydantic import ValidationError +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route + +import mcp.client.experimental.server_card as client_module +from mcp.client.experimental.server_card import fetch_server_card, load_server_card, well_known_url +from mcp.server.experimental.server_card import server_card_route +from mcp.shared.experimental.server_card import ServerCard + +pytestmark = pytest.mark.anyio + +CARD = ServerCard(name="example/dice", version="1.0.0", description="Rolls dice.") + + +def test_well_known_url_from_origin() -> None: + assert well_known_url("https://example.com") == "https://example.com/.well-known/mcp/server-card" + + +def test_well_known_url_from_endpoint_url() -> None: + assert well_known_url("https://example.com:8443/mcp?x=1") == ( + "https://example.com:8443/.well-known/mcp/server-card" + ) + + +def test_well_known_url_custom_path() -> None: + assert well_known_url("https://example.com", well_known_path="/.well-known/mcp-server-card") == ( + "https://example.com/.well-known/mcp-server-card" + ) + + +def test_well_known_url_rejects_relative() -> None: + with pytest.raises(ValueError, match="absolute"): + well_known_url("example.com/mcp") + + +async def test_fetch_with_provided_client() -> None: + app = Starlette(routes=[server_card_route(CARD)]) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport) as client: + card = await fetch_server_card("https://example.com", httpx_client=client) + assert card == CARD + + +async def test_fetch_with_default_client(monkeypatch: pytest.MonkeyPatch) -> None: + # Cover the branch that creates its own client, without touching the network: + # patch httpx.AsyncClient to one bound to an in-memory ASGI transport. + app = Starlette(routes=[server_card_route(CARD)]) + transport = httpx.ASGITransport(app=app) + monkeypatch.setattr( + client_module.httpx, + "AsyncClient", + functools.partial(httpx.AsyncClient, transport=transport), + ) + card = await fetch_server_card("https://example.com") + assert card == CARD + + +async def test_fetch_invalid_card_raises_validation_error() -> None: + async def bad(_request: object) -> JSONResponse: + return JSONResponse({"name": "missing-required-fields"}) + + app = Starlette(routes=[Route("/.well-known/mcp/server-card", bad, methods=["GET"])]) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(ValidationError): + await fetch_server_card("https://example.com", httpx_client=client) + + +async def test_fetch_raises_for_http_error() -> None: + app = Starlette(routes=[]) # nothing at the well-known path -> 404 + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport) as client: + with pytest.raises(httpx.HTTPStatusError): + await fetch_server_card("https://example.com", httpx_client=client) + + +def test_load_server_card_from_file(tmp_path: Path) -> None: + path = tmp_path / "server-card.json" + path.write_text(json.dumps(CARD.model_dump(mode="json", by_alias=True, exclude_none=True)), encoding="utf-8") + assert load_server_card(path) == CARD diff --git a/tests/experimental/server_card/test_server.py b/tests/experimental/server_card/test_server.py new file mode 100644 index 0000000000..853591eeab --- /dev/null +++ b/tests/experimental/server_card/test_server.py @@ -0,0 +1,95 @@ +"""Tests for server-side Server Card generation and serving.""" + +from __future__ import annotations + +import httpx +import pytest +from starlette.applications import Starlette + +from mcp.client.experimental.server_card import fetch_server_card +from mcp.server.experimental.server_card import ( + build_server_card, + mount_server_card, + server_card_route, +) +from mcp.server.lowlevel import Server +from mcp.shared.experimental.server_card import Remote, Repository, ServerCard + +pytestmark = pytest.mark.anyio + + +def make_server() -> Server: + return Server( + "dice-roller", + version="1.0.0", + title="Dice Roller", + description="Rolls dice for tabletop games.", + website_url="https://example.com/dice", + ) + + +def test_build_server_card_from_server_identity() -> None: + card = build_server_card( + make_server(), + name="io.modelcontextprotocol.examples/dice-roller", + remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")], + repository=Repository(url="https://github.com/example/dice", source="github"), + meta={"com.example/x": 1}, + ) + assert card.name == "io.modelcontextprotocol.examples/dice-roller" + assert card.version == "1.0.0" + assert card.title == "Dice Roller" + assert card.description == "Rolls dice for tabletop games." + assert card.website_url == "https://example.com/dice" + assert card.remotes is not None and card.remotes[0].url == "https://dice.example.com/mcp" + assert card.meta == {"com.example/x": 1} + + +def test_build_server_card_requires_version() -> None: + server = Server("no-version", description="desc") # version defaults to None + with pytest.raises(ValueError, match="version"): + build_server_card(server, name="example/no-version") + + +def test_build_server_card_requires_description() -> None: + server = Server("no-desc", version="1.0.0") # description defaults to None + with pytest.raises(ValueError, match="description"): + build_server_card(server, name="example/no-desc") + + +async def _get(app: Starlette, path: str) -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="https://dice.example.com") as client: + return await client.get(path) + + +async def test_server_card_route_serves_json() -> None: + card = build_server_card(make_server(), name="example/dice") + app = Starlette(routes=[server_card_route(card)]) + response = await _get(app, "/.well-known/mcp/server-card") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + assert ServerCard.model_validate(response.json()) == card + + +async def test_mount_server_card_on_existing_app_and_client_fetch() -> None: + card = build_server_card( + make_server(), + name="example/dice", + remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")], + ) + app = Starlette() + mount_server_card(app, card) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport) as client: + fetched = await fetch_server_card("https://dice.example.com", httpx_client=client) + assert fetched == card + + +async def test_mount_server_card_custom_path() -> None: + card = build_server_card(make_server(), name="example/dice") + app = Starlette() + mount_server_card(app, card, path="/custom/card.json") + response = await _get(app, "/custom/card.json") + assert response.status_code == 200 diff --git a/tests/experimental/server_card/test_types.py b/tests/experimental/server_card/test_types.py new file mode 100644 index 0000000000..3baca1d348 --- /dev/null +++ b/tests/experimental/server_card/test_types.py @@ -0,0 +1,127 @@ +"""Tests for Server Card models.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import ValidationError + +from mcp.shared.experimental.server_card import ( + SERVER_CARD_SCHEMA_URL, + SERVER_SCHEMA_URL, + KeyValueInput, + Server, + ServerCard, +) + +MINIMAL = { + "$schema": SERVER_CARD_SCHEMA_URL, + "name": "example-org/minimal", + "version": "1.0.0", + "description": "Smallest valid Server Card.", +} + +TEMPLATED_REMOTE = { + "$schema": SERVER_CARD_SCHEMA_URL, + "name": "example-org/with-remote", + "version": "2.1.0", + "description": "Server Card with a templated remote endpoint and headers.", + "title": "Example Remote Server", + "websiteUrl": "https://example.com", + "remotes": [ + { + "type": "streamable-http", + "url": "https://{tenant}.example.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer token for the remote endpoint.", + "isRequired": True, + "isSecret": True, + "value": "Bearer {token}", + "variables": {"token": {"isRequired": True, "isSecret": True}}, + } + ], + "variables": {"tenant": {"isRequired": True, "default": "default"}}, + "supportedProtocolVersions": ["2025-06-18", "2025-11-25"], + } + ], + "_meta": {"com.example/internal": {"tier": "gold"}}, +} + +WITH_PACKAGE = { + "$schema": SERVER_SCHEMA_URL, + "name": "example-org/with-package", + "version": "0.4.2", + "description": "Server document with a locally-runnable npm package.", + "repository": {"url": "https://github.com/example-org/with-package", "source": "github"}, + "icons": [{"src": "https://example.com/icon.png", "mimeType": "image/png", "sizes": ["48x48"]}], + "packages": [ + { + "registryType": "npm", + "identifier": "@example-org/with-package", + "version": "0.4.2", + "runtimeHint": "npx", + "transport": {"type": "stdio"}, + "packageArguments": [{"type": "positional", "valueHint": "config", "value": "config.json"}], + "runtimeArguments": [{"type": "named", "name": "--prefix", "value": "/opt"}], + "environmentVariables": [ + {"name": "EXAMPLE_API_KEY", "description": "Example API key.", "isRequired": True, "isSecret": True} + ], + "fileSha256": "a" * 64, + } + ], +} + + +@pytest.mark.parametrize("doc", [MINIMAL, TEMPLATED_REMOTE]) +def test_server_card_round_trips(doc: dict[str, Any]) -> None: + card = ServerCard.model_validate(doc) + assert card.model_dump(mode="json", by_alias=True, exclude_none=True) == doc + + +def test_server_with_packages_round_trips_and_discriminates() -> None: + server = Server.model_validate(WITH_PACKAGE) + assert server.packages is not None + assert server.packages[0].transport.type == "stdio" + assert server.packages[0].package_arguments is not None + assert server.packages[0].package_arguments[0].type == "positional" + assert server.packages[0].runtime_arguments is not None + assert server.packages[0].runtime_arguments[0].type == "named" + assert server.model_dump(mode="json", by_alias=True, exclude_none=True) == WITH_PACKAGE + + +def test_default_schema_urls() -> None: + assert ServerCard(name="a/b", version="1.0.0", description="d").schema_uri == SERVER_CARD_SCHEMA_URL + assert Server(name="a/b", version="1.0.0", description="d").schema_uri == SERVER_SCHEMA_URL + + +def test_fields_settable_by_python_name_and_serialize_camelcase() -> None: + header = KeyValueInput(name="Authorization", is_required=True, value="Bearer {t}") + assert header.model_dump(by_alias=True, exclude_none=True) == { + "name": "Authorization", + "isRequired": True, + "value": "Bearer {t}", + } + + +@pytest.mark.parametrize("version", ["^1.2.3", "~1.2.3", ">=1.2.3", "1.x", "1.*"]) +def test_version_ranges_rejected(version: str) -> None: + with pytest.raises(ValidationError, match="exact version"): + ServerCard(name="a/b", version=version, description="d") + + +@pytest.mark.parametrize( + "doc, field", + [ + ({**MINIMAL, "name": "no-slash"}, "name"), + ({**MINIMAL, "$schema": "https://static.modelcontextprotocol.io/schemas/2025-11-25/server-card.schema.json"}, + "$schema"), + ({**MINIMAL, "description": ""}, "description"), + ], +) +def test_invalid_cards_rejected(doc: dict[str, Any], field: str) -> None: + with pytest.raises(ValidationError) as excinfo: + ServerCard.model_validate(doc) + assert field in str(excinfo.value)