diff --git a/examples/server-card/README.md b/examples/server-card/README.md new file mode 100644 index 000000000..bfc978376 --- /dev/null +++ b/examples/server-card/README.md @@ -0,0 +1,116 @@ +# MCP Server Cards — example implementation + +A self-contained, runnable example of what **Server Card** ([SEP-2127][sep]) +support could look like in the Python SDK. It mirrors the TypeScript source of +truth in [`experimental-ext-server-card`][ext] and follows the SDK's +`mcp.types` conventions, so the `mcp_server_card/` library could be lifted into +`mcp/experimental/server_card/` largely unchanged. + +A Server Card is a static metadata document — typically published at +`https:///.well-known/mcp/server-card` — that describes a remote MCP +server's identity, transport endpoints, and supported protocol versions, so a +client can discover and connect to it *before* initialization. + +``` +mcp_server_card/ + types.py # Pydantic models — 1:1 port of schema.ts (camelCase wire format) + schema.json # bundled JSON Schema (from experimental-ext-server-card) + validation.py # JSON Schema + semantic validation -> typed models + client.py # fetch_server_card / load_server_card / well_known_url + server.py # build_server_card / write_server_card / mount_server_card / ... + cli.py # `mcp-server-card` — validate / fetch / schema +examples/ + serve_card.py # server: generate, then WRITE a file OR SERVE at .well-known + consume_card.py # client: fetch + validate + act on a card +tests/ + test_server_card.py +``` + +## Design at a glance + +**One type port, two consumers.** `types.py` is the only place the schema is +expressed. `Icon` is reused from `mcp.types` (it already exists in the core +spec). The `_meta` and `$schema` fields keep their literal JSON keys via +explicit aliases; everything else is camelCased by the same `to_camel` +generator the rest of the SDK uses. + +### Clients: consume + validate + +```python +from mcp_server_card import fetch_server_card + +# resolves /.well-known/mcp/server-card, fetches, validates +card = await fetch_server_card("https://dice.example.com") +for remote in card.remotes or []: + print(remote.type, remote.url, remote.supported_protocol_versions) +``` + +Validation is two layers, both run by `parse_server_card` / `parse_server`: + +1. **JSON Schema** against the bundled `schema.json` (the same artifact the + experimental repo validates its examples against) — authoritative structure. +2. **Pydantic** field constraints + semantic guards JSON Schema can't express + (e.g. rejecting version *ranges* like `^1.2.3`). + +Failures raise `ServerCardValidationError` carrying every problem at once. + +### Servers: generate, then publish + +Build the card once from the server's identity, then pick a publishing path: + +```python +from mcp_server_card import server_card_from_implementation, streamable_http_remote + +card = server_card_from_implementation( + "io.modelcontextprotocol.examples/dice-roller", # reverse-DNS card name + mcp, # an MCPServer (or any Implementation) + remotes=[streamable_http_remote("https://dice.example.com/mcp")], +) +``` + +- **Write a static file** (publish to a CDN / `.well-known`): + `write_server_card(card, "server-card.json")` +- **Serve from a live MCPServer**: `add_server_card_route(mcp, card)` — adds the + unauthenticated `GET /.well-known/mcp/server-card` route to its Starlette app. +- **Serve from any Starlette app**: `mount_server_card(app, card)`. + +## Running + +```bash +uv sync + +# tests +uv run pytest + +# server: write a static card file +uv run python examples/serve_card.py write ./server-card.json + +# server: serve it live (Ctrl-C to stop) +uv run python examples/serve_card.py serve --port 8000 + +# client: fetch + validate it (in another shell) +uv run python examples/consume_card.py http://127.0.0.1:8000 + +# CLI +uv run mcp-server-card validate ./server-card.json +uv run mcp-server-card fetch http://127.0.0.1:8000 +uv run mcp-server-card schema +``` + +## Notes / open questions + +- **Well-known path.** Uses `/.well-known/mcp/server-card` per the experimental + repo's README. The `schema.ts` doc comment says `mcp-server-card` (no + subpath) — worth reconciling upstream. The path is a parameter everywhere. +- **`ServerCard` vs `Server`.** `.well-known` serves `ServerCard` (no + `packages`); the registry `server.json` shape is the `Server` superset, parsed + with `parse_server`. +- **Media type.** Served as `application/json`; SEP-2127 defines no dedicated + media type. +- **Schema distribution.** `schema.json` is bundled for offline validation. A + real SDK integration would track the version published at + `static.modelcontextprotocol.io` and regenerate it the same way the SDK + generates its own types. + +[sep]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127 +[ext]: https://github.com/modelcontextprotocol/experimental-ext-server-card diff --git a/examples/server-card/examples/consume_card.py b/examples/server-card/examples/consume_card.py new file mode 100644 index 000000000..8aada3339 --- /dev/null +++ b/examples/server-card/examples/consume_card.py @@ -0,0 +1,46 @@ +"""Client side: fetch and validate a Server Card, then act on it. + + python examples/consume_card.py http://127.0.0.1:8000 + +Given a server URL (origin or any URL on the host), this resolves the +``.well-known`` location, fetches the card, validates it against the JSON Schema ++ semantic rules, and prints what a client would use to connect. +""" + +from __future__ import annotations + +import asyncio +import sys + +from mcp_server_card import ServerCardValidationError, fetch_server_card, well_known_url + + +async def main(server_url: str) -> int: + print(f"Resolving card: {well_known_url(server_url)}") + try: + card = await fetch_server_card(server_url) + except ServerCardValidationError as exc: + print("Card failed validation:") + for error in exc.errors: + print(f" - {error}") + return 1 + + print(f"\n{card.title or card.name} ({card.name} v{card.version})") + print(f" {card.description}") + if card.repository: + print(f" source: {card.repository.url}") + for remote in card.remotes or []: + versions = ", ".join(remote.supported_protocol_versions or []) or "unspecified" + print(f" remote [{remote.type}]: {remote.url} (protocols: {versions})") + for header in remote.headers or []: + flags = "required" if header.is_required else "optional" + secret = ", secret" if header.is_secret else "" + print(f" header {header.name} ({flags}{secret})") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + sys.exit(asyncio.run(main(sys.argv[1]))) diff --git a/examples/server-card/examples/serve_card.py b/examples/server-card/examples/serve_card.py new file mode 100644 index 000000000..afa97b2b4 --- /dev/null +++ b/examples/server-card/examples/serve_card.py @@ -0,0 +1,85 @@ +"""Server side: generate a Server Card, then write it OR serve it. + +One card definition, two publishing paths (exactly what the SDK should make easy): + + # 1. Hand it to the CLI to publish a static file: + python examples/serve_card.py write ./server-card.json + + # 2. Serve it from the live server at /.well-known/mcp/server-card: + python examples/serve_card.py serve --port 8000 + +The card is derived from the MCPServer's own identity metadata via +``server_card_from_implementation`` and points a remote at this server's +streamable-HTTP endpoint. +""" + +from __future__ import annotations + +import click +import uvicorn +from mcp.server.mcpserver import MCPServer + +from mcp_server_card import ( + Repository, + ServerCard, + add_server_card_route, + server_card_from_implementation, + streamable_http_remote, + write_server_card, +) + +# A normal high-level MCP server with a single tool. +mcp: MCPServer = MCPServer( + name="dice-roller", + title="Dice Roller", + description="Rolls dice for tabletop games.", + version="1.0.0", + website_url="https://example.com/dice", +) + + +@mcp.tool() +def roll(sides: int = 6) -> int: + """Roll a single die with the given number of sides.""" + return (sides + 1) // 2 # deterministic stand-in so the example stays reproducible + + +def build_card(public_url: str) -> ServerCard: + """Build the Server Card for this server, advertising its remote endpoint.""" + return server_card_from_implementation( + # Card names are reverse-DNS; the server's display name lives in `title`. + "io.modelcontextprotocol.examples/dice-roller", + mcp, + remotes=[streamable_http_remote(f"{public_url}/mcp", supported_protocol_versions=["2025-11-25"])], + repository=Repository(url="https://github.com/example-org/dice-roller", source="github"), + ) + + +@click.group() +def cli() -> None: + """Generate, write, or serve the dice-roller Server Card.""" + + +@cli.command() +@click.argument("path", type=click.Path(dir_okay=False)) +@click.option("--public-url", default="https://dice.example.com", help="Public origin used in the card's remote URL.") +def write(path: str, public_url: str) -> None: + """Generate the card and write it to PATH (static publishing).""" + out = write_server_card(build_card(public_url), path) + click.echo(f"Wrote {out}") + + +@cli.command() +@click.option("--host", default="127.0.0.1") +@click.option("--port", default=8000, type=int) +def serve(host: str, port: int) -> None: + """Serve the MCP server with its card at /.well-known/mcp/server-card.""" + card = build_card(f"http://{host}:{port}") + add_server_card_route(mcp, card) # registers the well-known GET route + app = mcp.streamable_http_app(stateless_http=True, host=host) + click.echo(f"Serving card at http://{host}:{port}/.well-known/mcp/server-card") + uvicorn.run(app, host=host, port=port) + + +if __name__ == "__main__": + cli() diff --git a/examples/server-card/mcp_server_card/__init__.py b/examples/server-card/mcp_server_card/__init__.py new file mode 100644 index 000000000..c3ae294c3 --- /dev/null +++ b/examples/server-card/mcp_server_card/__init__.py @@ -0,0 +1,97 @@ +"""MCP Server Cards — example Python implementation (SEP-2127, experimental). + +A small, self-contained library showing what Server Card support could look like +in the Python SDK. It mirrors the TypeScript source of truth in +``experimental-ext-server-card/schema.ts`` and follows the SDK's ``mcp.types`` +conventions, so the library portion could be lifted into +``mcp/experimental/server_card/`` largely unchanged. + +* **Clients** consume and validate a card: + :func:`fetch_server_card`, :func:`load_server_card`, :func:`parse_server_card`. +* **Servers** generate a card and publish it: + :func:`build_server_card`, :func:`write_server_card`, :func:`mount_server_card`, + :func:`add_server_card_route`. +""" + +from .client import WELL_KNOWN_PATH, fetch_server_card, load_server_card, well_known_url +from .server import ( + add_server_card_route, + build_server_card, + card_to_dict, + card_to_json, + mount_server_card, + server_card_from_implementation, + server_card_route, + streamable_http_remote, + write_server_card, +) +from .types import ( + SERVER_CARD_SCHEMA_URL, + SERVER_SCHEMA_URL, + Argument, + Icon, + Input, + InputWithVariables, + KeyValueInput, + NamedArgument, + Package, + PackageTransport, + PositionalArgument, + Remote, + Repository, + Server, + ServerCard, + SsePackageTransport, + StdioTransport, + StreamableHttpPackageTransport, +) +from .validation import ( + ServerCardValidationError, + load_bundled_schema, + parse_server, + parse_server_card, + validate_against_schema, +) + +__all__ = [ + # types + "SERVER_CARD_SCHEMA_URL", + "SERVER_SCHEMA_URL", + "Argument", + "Icon", + "Input", + "InputWithVariables", + "KeyValueInput", + "NamedArgument", + "Package", + "PackageTransport", + "PositionalArgument", + "Remote", + "Repository", + "Server", + "ServerCard", + "SsePackageTransport", + "StdioTransport", + "StreamableHttpPackageTransport", + # client + "WELL_KNOWN_PATH", + "fetch_server_card", + "load_server_card", + "well_known_url", + # server + "add_server_card_route", + "build_server_card", + "card_to_dict", + "card_to_json", + "mount_server_card", + "server_card_from_implementation", + "server_card_route", + "streamable_http_remote", + "write_server_card", + # validation + "ServerCardValidationError", + "load_bundled_schema", + "parse_server", + "parse_server_card", + "validate_against_schema", +] diff --git a/examples/server-card/mcp_server_card/cli.py b/examples/server-card/mcp_server_card/cli.py new file mode 100644 index 000000000..022587a4e --- /dev/null +++ b/examples/server-card/mcp_server_card/cli.py @@ -0,0 +1,67 @@ +"""``mcp-server-card`` — a small CLI for validating and inspecting Server Cards. + +This is the "hand it to a CLI for writing it" half of the workflow, from the +*consumer/ops* side: validate a card file, fetch and validate a live card, or +print the bundled JSON Schema. Servers generate cards in code (see +``mcp_server_card.build_server_card`` / ``write_server_card``); the example +``examples/serve_card.py`` shows generating + writing + serving from one +definition. + +Run with: ``python -m mcp_server_card.cli --help`` +""" + +from __future__ import annotations + +import asyncio +import json +import sys + +import click + +from .client import fetch_server_card, load_server_card +from .server import card_to_json +from .validation import ServerCardValidationError, load_bundled_schema + + +@click.group() +def cli() -> None: + """Validate and inspect MCP Server Cards.""" + + +@cli.command() +@click.argument("path", type=click.Path(exists=True, dir_okay=False)) +def validate(path: str) -> None: + """Validate a Server Card file against the schema and semantic rules.""" + try: + card = load_server_card(path) + except ServerCardValidationError as exc: + click.echo(click.style(f"INVALID: {path}", fg="red"), err=True) + for error in exc.errors: + click.echo(f" - {error}", err=True) + sys.exit(1) + click.echo(click.style(f"OK: {card.name} {card.version}", fg="green")) + + +@cli.command() +@click.argument("url") +@click.option("--no-validate", is_flag=True, help="Skip schema validation.") +def fetch(url: str, no_validate: bool) -> None: + """Fetch a Server Card from a server URL and print it.""" + try: + card = asyncio.run(fetch_server_card(url, validate=not no_validate)) + except ServerCardValidationError as exc: + click.echo(click.style(f"INVALID card at {url}", fg="red"), err=True) + for error in exc.errors: + click.echo(f" - {error}", err=True) + sys.exit(1) + click.echo(card_to_json(card)) + + +@cli.command() +def schema() -> None: + """Print the bundled JSON Schema.""" + click.echo(json.dumps(load_bundled_schema(), indent=2)) + + +if __name__ == "__main__": + cli() diff --git a/examples/server-card/mcp_server_card/client.py b/examples/server-card/mcp_server_card/client.py new file mode 100644 index 000000000..f84c35d90 --- /dev/null +++ b/examples/server-card/mcp_server_card/client.py @@ -0,0 +1,84 @@ +"""Client-side consumption of MCP Server Cards. + +A client typically knows a server's base URL and wants to discover how to +connect *before* initializing a session. These helpers fetch the card from the +conventional ``.well-known`` location (or load it from disk/string), validate +it, and hand back typed models. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from urllib.parse import urljoin, urlsplit + +import httpx + +from .types import ServerCard +from .validation import parse_server_card + +__all__ = [ + "WELL_KNOWN_PATH", + "well_known_url", + "fetch_server_card", + "load_server_card", +] + +#: Conventional path a Server Card is published at, relative to the host root. +WELL_KNOWN_PATH = "/.well-known/mcp/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. + """ + 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, + client: httpx.AsyncClient | None = None, + validate: bool = True, +) -> ServerCard: + """Fetch and validate a Server Card for the server at ``url``. + + ``url`` may be the server's origin or any URL on the same host. Pass an + existing ``httpx.AsyncClient`` to reuse connection pooling / auth; otherwise + a short-lived client is created. Set ``validate=False`` to skip JSON Schema + validation (still parses into the typed model). + """ + target = well_known_url(url, well_known_path=well_known_path) + + owns_client = client is None + client = client or httpx.AsyncClient(follow_redirects=True) + try: + response = await client.get(target, headers={"Accept": "application/json"}) + response.raise_for_status() + data = response.json() + finally: + if owns_client: + await client.aclose() + + if validate: + return parse_server_card(data) + return ServerCard.model_validate(data) + + +def load_server_card(source: str | Path, *, validate: bool = True) -> ServerCard: + """Load a Server Card from a file path or a JSON string.""" + if isinstance(source, Path) or (isinstance(source, str) and not source.lstrip().startswith("{")): + text = Path(source).read_text(encoding="utf-8") + else: + text = source + data = json.loads(text) + if validate: + return parse_server_card(data) + return ServerCard.model_validate(data) diff --git a/examples/server-card/mcp_server_card/schema.json b/examples/server-card/mcp_server_card/schema.json new file mode 100644 index 000000000..af69faadd --- /dev/null +++ b/examples/server-card/mcp_server_card/schema.json @@ -0,0 +1,651 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Argument": { + "anyOf": [ + { + "$ref": "#/$defs/PositionalArgument" + }, + { + "$ref": "#/$defs/NamedArgument" + } + ], + "description": "A command-line argument supplied to a package's binary or runtime." + }, + "Icon": { + "description": "An optionally-sized icon that can be displayed in a user interface.", + "properties": { + "mimeType": { + "description": "Optional MIME type override if the source MIME type is missing or generic.\nFor example: `\"image/png\"`, `\"image/jpeg\"`, or `\"image/svg+xml\"`.", + "type": "string" + }, + "sizes": { + "description": "Optional array of strings that specify sizes at which the icon can be used.\nEach string should be in WxH format (e.g., `\"48x48\"`, `\"96x96\"`) or `\"any\"` for scalable formats like SVG.\n\nIf not provided, the client should assume that the icon can be used at any size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "src": { + "description": "A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a\n`data:` URI with Base64-encoded image data.\n\nConsumers SHOULD take steps to ensure URLs serving icons are from the\nsame domain as the client/server or a trusted domain.\n\nConsumers SHOULD take appropriate precautions when consuming SVGs as they can contain\nexecutable JavaScript.", + "format": "uri", + "type": "string" + }, + "theme": { + "description": "Optional specifier for the theme this icon is designed for. `\"light\"` indicates\nthe icon is designed to be used with a light background, and `\"dark\"` indicates\nthe icon is designed to be used with a dark background.\n\nIf not provided, the client should assume the icon can be used with any theme.", + "enum": ["dark", "light"], + "type": "string" + } + }, + "required": ["src"], + "type": "object" + }, + "Input": { + "description": "A user-supplied or pre-set input value, used in {@link Package} argument\nand environment-variable definitions.", + "properties": { + "choices": { + "description": "Allowed values for the input. If provided, the user must select one.", + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "Default value for the input. SHOULD be a valid value for the input.", + "type": "string" + }, + "description": { + "description": "Human-readable explanation of the input. Clients can use this to provide\ncontext to the user.", + "type": "string" + }, + "format": { + "description": "Specifies the input format. `\"filepath\"` should be interpreted as a file\non the user's filesystem. When the input is converted to a string,\nbooleans should be represented by `\"true\"`/`\"false\"`, and numbers by\ndecimal values.", + "enum": ["boolean", "filepath", "number", "string"], + "type": "string" + }, + "isRequired": { + "description": "Whether the input must be supplied for the package to run.", + "type": "boolean" + }, + "isSecret": { + "description": "Whether the input is a secret value (e.g., password, token). If true,\nclients should handle the value securely.", + "type": "boolean" + }, + "placeholder": { + "description": "Placeholder displayed during configuration to provide examples or\nguidance about the expected form of the input.", + "type": "string" + }, + "value": { + "description": "Pre-set value for the input. If set, the value should not be configurable\nby end users. Identifiers wrapped in `{curly_braces}` will be replaced\nwith the corresponding entries from the input's `variables` map (if any).", + "type": "string" + } + }, + "type": "object" + }, + "InputWithVariables": { + "description": "An {@link Input} whose `value` may reference variables for substitution.", + "properties": { + "choices": { + "description": "Allowed values for the input. If provided, the user must select one.", + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "Default value for the input. SHOULD be a valid value for the input.", + "type": "string" + }, + "description": { + "description": "Human-readable explanation of the input. Clients can use this to provide\ncontext to the user.", + "type": "string" + }, + "format": { + "description": "Specifies the input format. `\"filepath\"` should be interpreted as a file\non the user's filesystem. When the input is converted to a string,\nbooleans should be represented by `\"true\"`/`\"false\"`, and numbers by\ndecimal values.", + "enum": ["boolean", "filepath", "number", "string"], + "type": "string" + }, + "isRequired": { + "description": "Whether the input must be supplied for the package to run.", + "type": "boolean" + }, + "isSecret": { + "description": "Whether the input is a secret value (e.g., password, token). If true,\nclients should handle the value securely.", + "type": "boolean" + }, + "placeholder": { + "description": "Placeholder displayed during configuration to provide examples or\nguidance about the expected form of the input.", + "type": "string" + }, + "value": { + "description": "Pre-set value for the input. If set, the value should not be configurable\nby end users. Identifiers wrapped in `{curly_braces}` will be replaced\nwith the corresponding entries from the input's `variables` map (if any).", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/Input" + }, + "description": "Variables referenced by `{curly_braces}` identifiers in `value`. The map\nkey is the variable name; the value defines the variable's properties.", + "type": "object" + } + }, + "type": "object" + }, + "KeyValueInput": { + "description": "A named input — used for environment variables and HTTP headers.", + "properties": { + "choices": { + "description": "Allowed values for the input. If provided, the user must select one.", + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "Default value for the input. SHOULD be a valid value for the input.", + "type": "string" + }, + "description": { + "description": "Human-readable explanation of the input. Clients can use this to provide\ncontext to the user.", + "type": "string" + }, + "format": { + "description": "Specifies the input format. `\"filepath\"` should be interpreted as a file\non the user's filesystem. When the input is converted to a string,\nbooleans should be represented by `\"true\"`/`\"false\"`, and numbers by\ndecimal values.", + "enum": ["boolean", "filepath", "number", "string"], + "type": "string" + }, + "isRequired": { + "description": "Whether the input must be supplied for the package to run.", + "type": "boolean" + }, + "isSecret": { + "description": "Whether the input is a secret value (e.g., password, token). If true,\nclients should handle the value securely.", + "type": "boolean" + }, + "name": { + "description": "Name of the header or environment variable.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder displayed during configuration to provide examples or\nguidance about the expected form of the input.", + "type": "string" + }, + "value": { + "description": "Pre-set value for the input. If set, the value should not be configurable\nby end users. Identifiers wrapped in `{curly_braces}` will be replaced\nwith the corresponding entries from the input's `variables` map (if any).", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/Input" + }, + "description": "Variables referenced by `{curly_braces}` identifiers in `value`. The map\nkey is the variable name; the value defines the variable's properties.", + "type": "object" + } + }, + "required": ["name"], + "type": "object" + }, + "MetaObject": { + "description": "Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions.\n\nCertain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions.\n\nValid keys have two segments:\n\n**Prefix:**\n- Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`).\n- Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`).\n- Any prefix consisting of zero or more labels, followed by `modelcontextprotocol` or `mcp`, followed by any label, is **reserved** for MCP use. For example: `modelcontextprotocol.io/`, `mcp.dev/`, `api.modelcontextprotocol.org/`, and `tools.mcp.com/` are all reserved.\n\n**Name:**\n- Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`).\n- Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`).", + "type": "object" + }, + "NamedArgument": { + "description": "A named command-line input — a `--flag={value}` parameter.", + "properties": { + "choices": { + "description": "Allowed values for the input. If provided, the user must select one.", + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "Default value for the input. SHOULD be a valid value for the input.", + "type": "string" + }, + "description": { + "description": "Human-readable explanation of the input. Clients can use this to provide\ncontext to the user.", + "type": "string" + }, + "format": { + "description": "Specifies the input format. `\"filepath\"` should be interpreted as a file\non the user's filesystem. When the input is converted to a string,\nbooleans should be represented by `\"true\"`/`\"false\"`, and numbers by\ndecimal values.", + "enum": ["boolean", "filepath", "number", "string"], + "type": "string" + }, + "isRepeated": { + "description": "Whether the argument can be repeated multiple times.", + "type": "boolean" + }, + "isRequired": { + "description": "Whether the input must be supplied for the package to run.", + "type": "boolean" + }, + "isSecret": { + "description": "Whether the input is a secret value (e.g., password, token). If true,\nclients should handle the value securely.", + "type": "boolean" + }, + "name": { + "description": "The flag name, including any leading dashes (e.g., `\"--port\"`).", + "type": "string" + }, + "placeholder": { + "description": "Placeholder displayed during configuration to provide examples or\nguidance about the expected form of the input.", + "type": "string" + }, + "type": { + "const": "named", + "type": "string" + }, + "value": { + "description": "Pre-set value for the input. If set, the value should not be configurable\nby end users. Identifiers wrapped in `{curly_braces}` will be replaced\nwith the corresponding entries from the input's `variables` map (if any).", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/Input" + }, + "description": "Variables referenced by `{curly_braces}` identifiers in `value`. The map\nkey is the variable name; the value defines the variable's properties.", + "type": "object" + } + }, + "required": ["name", "type"], + "type": "object" + }, + "Package": { + "description": "Metadata for installing and running a packaged MCP server locally.", + "properties": { + "environmentVariables": { + "description": "Environment variables to be set when running the package.", + "items": { + "$ref": "#/$defs/KeyValueInput" + }, + "type": "array" + }, + "fileSha256": { + "description": "SHA-256 hash of the package file for integrity verification. Required for\nMCPB packages and optional for other package types. If present, MCP\nclients MUST validate the downloaded file matches the hash before running\npackages to ensure file integrity.", + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "identifier": { + "description": "Package identifier — either a package name (for registries)\nor a URL (for direct downloads).", + "type": "string" + }, + "packageArguments": { + "description": "Arguments passed to the package's binary.", + "items": { + "$ref": "#/$defs/Argument" + }, + "type": "array" + }, + "registryBaseUrl": { + "description": "Base URL of the package registry.", + "format": "uri", + "type": "string" + }, + "registryType": { + "description": "Registry type indicating how to download packages\n(e.g., `\"npm\"`, `\"pypi\"`, `\"oci\"`, `\"nuget\"`, `\"mcpb\"`).", + "type": "string" + }, + "runtimeArguments": { + "description": "Arguments passed to the package's runtime command (such as `docker` or\n`npx`). The `runtimeHint` field should be provided when `runtimeArguments`\nare present.", + "items": { + "$ref": "#/$defs/Argument" + }, + "type": "array" + }, + "runtimeHint": { + "description": "A hint to help clients determine the appropriate runtime for the package\n(e.g., `\"npx\"`, `\"uvx\"`, `\"docker\"`, `\"dnx\"`). Should be provided when\n`runtimeArguments` are present.", + "type": "string" + }, + "supportedProtocolVersions": { + "description": "MCP protocol versions actively supported by this package.", + "items": { + "type": "string" + }, + "type": "array" + }, + "transport": { + "$ref": "#/$defs/PackageTransport", + "description": "Transport configuration for invoking this package after installation." + }, + "version": { + "description": "Package version.", + "minLength": 1, + "type": "string" + } + }, + "required": ["identifier", "registryType", "transport"], + "type": "object" + }, + "PackageTransport": { + "anyOf": [ + { + "$ref": "#/$defs/StdioTransport" + }, + { + "$ref": "#/$defs/StreamableHttpPackageTransport" + }, + { + "$ref": "#/$defs/SsePackageTransport" + } + ], + "description": "Transport protocol configuration for a locally-runnable package." + }, + "PositionalArgument": { + "description": "A positional command-line input — a value inserted verbatim into the\ncommand line.", + "properties": { + "choices": { + "description": "Allowed values for the input. If provided, the user must select one.", + "items": { + "type": "string" + }, + "type": "array" + }, + "default": { + "description": "Default value for the input. SHOULD be a valid value for the input.", + "type": "string" + }, + "description": { + "description": "Human-readable explanation of the input. Clients can use this to provide\ncontext to the user.", + "type": "string" + }, + "format": { + "description": "Specifies the input format. `\"filepath\"` should be interpreted as a file\non the user's filesystem. When the input is converted to a string,\nbooleans should be represented by `\"true\"`/`\"false\"`, and numbers by\ndecimal values.", + "enum": ["boolean", "filepath", "number", "string"], + "type": "string" + }, + "isRepeated": { + "description": "Whether the argument can be repeated multiple times in the command line.", + "type": "boolean" + }, + "isRequired": { + "description": "Whether the input must be supplied for the package to run.", + "type": "boolean" + }, + "isSecret": { + "description": "Whether the input is a secret value (e.g., password, token). If true,\nclients should handle the value securely.", + "type": "boolean" + }, + "placeholder": { + "description": "Placeholder displayed during configuration to provide examples or\nguidance about the expected form of the input.", + "type": "string" + }, + "type": { + "const": "positional", + "type": "string" + }, + "value": { + "description": "Pre-set value for the input. If set, the value should not be configurable\nby end users. Identifiers wrapped in `{curly_braces}` will be replaced\nwith the corresponding entries from the input's `variables` map (if any).", + "type": "string" + }, + "valueHint": { + "description": "Identifier for the positional argument. It is not part of the command\nline; it may be used by client configuration as a label identifying the\nargument, and it identifies the value in transport URL variable\nsubstitution.\n\nImplementations SHOULD ensure that at least one of `valueHint` or\n`value` is set so the positional argument resolves to a concrete value.", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/Input" + }, + "description": "Variables referenced by `{curly_braces}` identifiers in `value`. The map\nkey is the variable name; the value defines the variable's properties.", + "type": "object" + } + }, + "required": ["type"], + "type": "object" + }, + "Remote": { + "description": "Metadata for connecting to a remote (HTTP-based) MCP server endpoint.", + "properties": { + "headers": { + "description": "HTTP headers required or accepted when connecting to this remote\nendpoint. Each header is described as a {@link KeyValueInput} so that\nclients can prompt users for required values, mark secrets, surface\ndefaults, and constrain to a list of choices.", + "items": { + "$ref": "#/$defs/KeyValueInput" + }, + "type": "array" + }, + "supportedProtocolVersions": { + "description": "MCP protocol versions actively supported by this remote endpoint. Allows\nclients to negotiate a compatible protocol version before initialization.", + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "description": "The transport type for this remote endpoint.", + "enum": ["sse", "streamable-http"], + "type": "string" + }, + "url": { + "description": "URL template for the remote endpoint. Must start with `http://`,\n`https://`, or a `{template-variable}`. Variables in `{curly_braces}`\nare substituted from the {@link Remote.variables} map before the\nclient connects.", + "pattern": "^(https?://[^\\s]+|\\{[a-zA-Z_][a-zA-Z0-9_]*\\}[^\\s]*)$", + "type": "string" + }, + "variables": { + "additionalProperties": { + "$ref": "#/$defs/Input" + }, + "description": "Configuration variables that can be referenced as `{curly_braces}`\nplaceholders in `url` (and inside header values via\n{@link InputWithVariables.variables}). The map key is the variable\nname; the value defines the variable's properties (e.g., human-readable\ndescription, default, whether it is required or secret).", + "type": "object" + } + }, + "required": ["type", "url"], + "type": "object" + }, + "Repository": { + "description": "Repository metadata for the MCP server source code. Enables users and\nsecurity experts to inspect the code, improving transparency.", + "properties": { + "id": { + "description": "Repository identifier from the hosting service (e.g., GitHub repo ID).\nOwned and determined by the source forge. Should remain stable across\nrepository renames and may be used to detect repository resurrection\nattacks — if a repository is deleted and recreated, the ID should change.", + "type": "string" + }, + "source": { + "description": "Repository hosting service identifier (e.g., `\"github\"`). Used by registries\nto determine validation and API access methods.", + "type": "string" + }, + "subfolder": { + "description": "Optional relative path from repository root to the server location within a\nmonorepo or nested package structure. Must be a clean relative path.", + "type": "string" + }, + "url": { + "description": "Repository URL for browsing source code. Should support both web browsing\nand `git clone` operations.", + "format": "uri", + "type": "string" + } + }, + "required": ["source", "url"], + "type": "object" + }, + "Server": { + "description": "A superset of {@link ServerCard} that additionally describes locally-runnable\npackages. This is the shape used by the MCP Registry's `server.json`.\n\n`Server` documents are typically published to a registry rather than served\nfrom a `.well-known` URI, since they may include instructions for installing\nand executing a server on a client's local machine.", + "properties": { + "$schema": { + "description": "The Server Card JSON Schema URI that this document conforms to. Required.\n\nMust be a `/v1/` URL under `static.modelcontextprotocol.io/schemas/`,\nnaming a Server Card / `server.json` schema (e.g.,\n`https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json`\nor `https://static.modelcontextprotocol.io/schemas/v1/server.schema.json`).\nSchema URLs are versioned by the `vN` segment rather than by date so that\nminor, additive revisions of the v1 shape don't bump every published\ndocument's `$schema` URL.", + "format": "uri", + "pattern": "^https://static\\.modelcontextprotocol\\.io/schemas/v1/[^/]+\\.schema\\.json$", + "type": "string" + }, + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Extension metadata using reverse-DNS namespacing for vendor-specific data.\n\nFollows the protocol's standard `_meta` definition." + }, + "description": { + "description": "Clear human-readable explanation of server functionality. Should focus on\ncapabilities, not implementation details.", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following\nMIME types: `image/png` and `image/jpeg` (safe, universal compatibility).\nClients SHOULD also support: `image/svg+xml` (scalable but requires security\nprecautions) and `image/webp` (modern, efficient format).", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash\nseparating namespace from server name.", + "maxLength": 200, + "minLength": 3, + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "type": "string" + }, + "packages": { + "description": "Metadata helpful for running and connecting to local instances of this MCP server.", + "items": { + "$ref": "#/$defs/Package" + }, + "type": "array" + }, + "remotes": { + "description": "Metadata helpful for making HTTP-based connections to this MCP server.", + "items": { + "$ref": "#/$defs/Remote" + }, + "type": "array" + }, + "repository": { + "$ref": "#/$defs/Repository", + "description": "Optional repository metadata for the MCP server source code.\nRecommended for transparency and security inspection." + }, + "title": { + "description": "Optional human-readable title or display name for the MCP server.\nMCP subregistries or clients MAY choose to use this for display purposes.", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version string for this server. SHOULD follow semantic versioning\n(e.g., '1.0.2', '2.1.0-alpha'). Equivalent of `Implementation.version`\nin the MCP specification. Non-semantic versions are allowed but may not\nsort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3',\n'>=1.2.3', '1.x', '1.*').", + "maxLength": 255, + "type": "string" + }, + "websiteUrl": { + "description": "Optional URL to the server's homepage, documentation, or project website.\nProvides a central link for users to learn more about the server.\nParticularly useful when the server has custom installation instructions\nor setup requirements.", + "format": "uri", + "type": "string" + } + }, + "required": ["$schema", "description", "name", "version"], + "type": "object" + }, + "ServerCard": { + "description": "A static metadata document describing a remote MCP server, suitable for\npublishing at a `.well-known/mcp-server-card` URI for pre-connection discovery.\n\nServer Cards intentionally describe only what is needed to discover and\nconnect to a remote server: identity, transport, and protocol versions.\nThey do not enumerate primitives (tools, resources, prompts) — those remain\nsubject to runtime listing via the protocol's standard list operations.\n\nThe companion {@link Server} shape is a strict superset that adds local\npackage metadata for use cases like the MCP Registry's `server.json`.", + "properties": { + "$schema": { + "description": "The Server Card JSON Schema URI that this document conforms to. Required.\n\nMust be a `/v1/` URL under `static.modelcontextprotocol.io/schemas/`,\nnaming a Server Card / `server.json` schema (e.g.,\n`https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json`\nor `https://static.modelcontextprotocol.io/schemas/v1/server.schema.json`).\nSchema URLs are versioned by the `vN` segment rather than by date so that\nminor, additive revisions of the v1 shape don't bump every published\ndocument's `$schema` URL.", + "format": "uri", + "pattern": "^https://static\\.modelcontextprotocol\\.io/schemas/v1/[^/]+\\.schema\\.json$", + "type": "string" + }, + "_meta": { + "$ref": "#/$defs/MetaObject", + "description": "Extension metadata using reverse-DNS namespacing for vendor-specific data.\n\nFollows the protocol's standard `_meta` definition." + }, + "description": { + "description": "Clear human-readable explanation of server functionality. Should focus on\ncapabilities, not implementation details.", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "icons": { + "description": "Optional set of sized icons that the client can display in a user interface.\n\nClients that support rendering icons MUST support at least the following\nMIME types: `image/png` and `image/jpeg` (safe, universal compatibility).\nClients SHOULD also support: `image/svg+xml` (scalable but requires security\nprecautions) and `image/webp` (modern, efficient format).", + "items": { + "$ref": "#/$defs/Icon" + }, + "type": "array" + }, + "name": { + "description": "Server name in reverse-DNS format. Must contain exactly one forward slash\nseparating namespace from server name.", + "maxLength": 200, + "minLength": 3, + "pattern": "^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$", + "type": "string" + }, + "remotes": { + "description": "Metadata helpful for making HTTP-based connections to this MCP server.", + "items": { + "$ref": "#/$defs/Remote" + }, + "type": "array" + }, + "repository": { + "$ref": "#/$defs/Repository", + "description": "Optional repository metadata for the MCP server source code.\nRecommended for transparency and security inspection." + }, + "title": { + "description": "Optional human-readable title or display name for the MCP server.\nMCP subregistries or clients MAY choose to use this for display purposes.", + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "version": { + "description": "Version string for this server. SHOULD follow semantic versioning\n(e.g., '1.0.2', '2.1.0-alpha'). Equivalent of `Implementation.version`\nin the MCP specification. Non-semantic versions are allowed but may not\nsort predictably. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3',\n'>=1.2.3', '1.x', '1.*').", + "maxLength": 255, + "type": "string" + }, + "websiteUrl": { + "description": "Optional URL to the server's homepage, documentation, or project website.\nProvides a central link for users to learn more about the server.\nParticularly useful when the server has custom installation instructions\nor setup requirements.", + "format": "uri", + "type": "string" + } + }, + "required": ["$schema", "description", "name", "version"], + "type": "object" + }, + "SsePackageTransport": { + "description": "Server-sent events (SSE) transport for a locally-runnable package.", + "properties": { + "headers": { + "description": "HTTP headers to include when connecting to the package's local endpoint.", + "items": { + "$ref": "#/$defs/KeyValueInput" + }, + "type": "array" + }, + "type": { + "const": "sse", + "type": "string" + }, + "url": { + "description": "SSE endpoint URL template. See {@link StreamableHttpPackageTransport.url}\nfor variable-substitution semantics.", + "pattern": "^(https?://[^\\s]+|\\{[a-zA-Z_][a-zA-Z0-9_]*\\}[^\\s]*)$", + "type": "string" + } + }, + "required": ["type", "url"], + "type": "object" + }, + "StdioTransport": { + "description": "Stdio transport — the client launches the package as a subprocess and\ncommunicates over standard input and output.", + "properties": { + "type": { + "const": "stdio", + "type": "string" + } + }, + "required": ["type"], + "type": "object" + }, + "StreamableHttpPackageTransport": { + "description": "Streamable-HTTP transport for a locally-runnable package that exposes\nitself over HTTP after launch.", + "properties": { + "headers": { + "description": "HTTP headers to include when connecting to the package's local endpoint.", + "items": { + "$ref": "#/$defs/KeyValueInput" + }, + "type": "array" + }, + "type": { + "const": "streamable-http", + "type": "string" + }, + "url": { + "description": "URL template for the streamable-http transport. Must start with\n`http://`, `https://`, or a `{template-variable}`. Variables in\n`{curly_braces}` reference argument value-hints, argument names, or\nenvironment variable names from the parent {@link Package}.", + "pattern": "^(https?://[^\\s]+|\\{[a-zA-Z_][a-zA-Z0-9_]*\\}[^\\s]*)$", + "type": "string" + } + }, + "required": ["type", "url"], + "type": "object" + } + } +} diff --git a/examples/server-card/mcp_server_card/server.py b/examples/server-card/mcp_server_card/server.py new file mode 100644 index 000000000..f4fa62515 --- /dev/null +++ b/examples/server-card/mcp_server_card/server.py @@ -0,0 +1,178 @@ +"""Server-side generation and serving of MCP Server Cards. + +A server author builds a :class:`~mcp_server_card.types.ServerCard` once (from +identity + remote endpoints), then either: + +* hands it to the CLI / :func:`write_server_card` to publish a static file, or +* serves it from the conventional ``.well-known`` path via :func:`mount_server_card` + (any Starlette app) or :func:`add_server_card_route` (an ``MCPServer``). +""" + +from __future__ import annotations + +from pathlib import Path +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 .client import WELL_KNOWN_PATH +from .types import ( + SERVER_CARD_SCHEMA_URL, + Icon, + Remote, + Repository, + ServerCard, +) + +__all__ = [ + "card_to_dict", + "card_to_json", + "build_server_card", + "streamable_http_remote", + "server_card_from_implementation", + "write_server_card", + "server_card_route", + "mount_server_card", + "add_server_card_route", +] + + +def card_to_dict(card: ServerCard) -> dict[str, Any]: + """Serialize a card to a JSON-ready dict (camelCase keys, ``None`` dropped).""" + return card.model_dump(mode="json", by_alias=True, exclude_none=True) + + +def card_to_json(card: ServerCard, *, indent: int | None = 2) -> str: + """Serialize a card to a JSON string.""" + return card.model_dump_json(by_alias=True, exclude_none=True, indent=indent) + + +def build_server_card( + *, + name: str, + version: str, + description: str, + title: str | None = None, + website_url: str | None = None, + repository: Repository | None = None, + icons: list[Icon] | None = None, + remotes: list[Remote] | None = None, + meta: dict[str, Any] | None = None, + schema_uri: str = SERVER_CARD_SCHEMA_URL, +) -> ServerCard: + """Build (and validate) a Server Card from its parts. + + Construction runs the model's field validators, so an invalid ``name`` / + ``version`` / ``description`` fails fast here rather than at publish time. + """ + return ServerCard( + schema_uri=schema_uri, + name=name, + version=version, + description=description, + title=title, + website_url=website_url, + repository=repository, + icons=icons, + remotes=remotes, + meta=meta, + ) + + +def streamable_http_remote( + url: str, + *, + headers: list[Any] | None = None, + variables: dict[str, Any] | None = None, + supported_protocol_versions: list[str] | None = None, +) -> Remote: + """Convenience constructor for the common streamable-HTTP remote endpoint.""" + return Remote( + type="streamable-http", + url=url, + headers=headers, + variables=variables, + supported_protocol_versions=supported_protocol_versions, + ) + + +class _ImplementationLike(Protocol): + name: str + version: str + title: str | None + description: str | None + website_url: str | None + icons: list[Icon] | None + + +def server_card_from_implementation( + name: str, + implementation: _ImplementationLike, + *, + remotes: list[Remote] | None = None, + repository: Repository | None = None, + meta: dict[str, Any] | None = None, +) -> ServerCard: + """Build a card, pulling display/version metadata from an SDK ``Implementation``. + + ``Implementation.name`` is a free-form display name, while a card's ``name`` + must be reverse-DNS (``namespace/name``), so it is passed explicitly. The + rest (version, title, description, website, icons) is carried over. + """ + return build_server_card( + name=name, + version=implementation.version, + description=implementation.description or (implementation.title or name), + title=implementation.title, + website_url=implementation.website_url, + icons=implementation.icons, + repository=repository, + remotes=remotes, + meta=meta, + ) + + +def write_server_card(card: ServerCard, path: str | Path, *, indent: int | None = 2) -> Path: + """Write a card to ``path`` as JSON and return the resolved path. + + This is the primitive the CLI uses to publish a static card file. + """ + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(card_to_json(card, indent=indent) + "\n", encoding="utf-8") + return out.resolve() + + +def server_card_route(card: ServerCard, *, path: str = WELL_KNOWN_PATH, name: str = "mcp_server_card") -> Route: + """Build a Starlette GET route that serves ``card`` at ``path``. + + The payload is serialized once up front; the card is static metadata. + """ + payload = card_to_dict(card) + + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse(payload, media_type="application/json") + + return Route(path, endpoint=endpoint, methods=["GET"], name=name) + + +def mount_server_card(app: Starlette, card: ServerCard, *, path: str = WELL_KNOWN_PATH) -> None: + """Attach a Server Card route to an existing Starlette application.""" + app.router.routes.append(server_card_route(card, path=path)) + + +def add_server_card_route(mcp_server: Any, card: ServerCard, *, path: str = WELL_KNOWN_PATH) -> None: + """Register a Server Card route on an ``MCPServer`` via its ``custom_route``. + + Duck-typed so the example doesn't hard-depend on the high-level server API. + Routes added this way are unauthenticated, which is what discovery wants. + """ + payload = card_to_dict(card) + + async def endpoint(_request: Request) -> JSONResponse: + return JSONResponse(payload, media_type="application/json") + + mcp_server.custom_route(path, methods=["GET"])(endpoint) diff --git a/examples/server-card/mcp_server_card/types.py b/examples/server-card/mcp_server_card/types.py new file mode 100644 index 000000000..192fea1e2 --- /dev/null +++ b/examples/server-card/mcp_server_card/types.py @@ -0,0 +1,298 @@ +"""Pydantic models for MCP Server Cards (SEP-2127, experimental). + +This is a 1:1 port of the TypeScript source of truth in +``experimental-ext-server-card/schema.ts``. It follows the conventions used by +the Python SDK's ``mcp.types`` module (camelCase JSON via ``to_camel`` alias +generator, ``populate_by_name`` so fields can be set by their Python names), and +reuses ``Icon`` / ``Implementation`` from the SDK rather than re-declaring them. + +The module is deliberately shaped so it could be lifted into the SDK at +``mcp/experimental/server_card/types.py`` with no changes other than the import +of the shared ``MCPModel`` base. +""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from mcp.types import Icon # reused: already exists in the core spec +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +__all__ = [ + "SERVER_CARD_SCHEMA_URL", + "SERVER_SCHEMA_URL", + "Icon", + "Input", + "InputWithVariables", + "KeyValueInput", + "PositionalArgument", + "NamedArgument", + "Argument", + "Repository", + "Remote", + "StdioTransport", + "StreamableHttpPackageTransport", + "SsePackageTransport", + "PackageTransport", + "Package", + "ServerCard", + "Server", +] + +#: 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" + +# Constraints copied verbatim from schema.ts JSDoc annotations. +_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}$" + + +class _CardModel(BaseModel): + """Base for Server Card types. + + Identical configuration to the SDK's ``mcp.types._types.MCPModel`` so the + JSON wire format matches the rest of the protocol (camelCase keys, settable + by Python field name). + """ + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class Input(_CardModel): + """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. If set, end users should not be able to change it. + + ``{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(_CardModel): + """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(_CardModel): + """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(_CardModel): + """Stdio transport — the client launches the package as a subprocess.""" + + type: Literal["stdio"] = "stdio" + + +class StreamableHttpPackageTransport(_CardModel): + """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(_CardModel): + """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(_CardModel): + """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(_CardModel): + """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).""" + + +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/examples/server-card/mcp_server_card/validation.py b/examples/server-card/mcp_server_card/validation.py new file mode 100644 index 000000000..9e36516b5 --- /dev/null +++ b/examples/server-card/mcp_server_card/validation.py @@ -0,0 +1,118 @@ +"""Validation for MCP Server Card documents. + +Two layers, both surfaced through :func:`parse_server_card` / :func:`parse_server`: + +1. **JSON Schema** — the document is checked against the generated + ``schema.json`` (the same artifact CI in ``experimental-ext-server-card`` + validates examples against). This is the authoritative structural check. +2. **Pydantic** — the validated dict is parsed into the typed models, applying + the field constraints and the extra semantic guards (e.g. version ranges) + that JSON Schema can't express. + +Clients consuming an untrusted card should always go through these functions +rather than constructing the models directly. +""" + +from __future__ import annotations + +import json +import re +from functools import lru_cache +from importlib import resources +from typing import Any + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import ValidationError as JSONSchemaValidationError +from pydantic import ValidationError as PydanticValidationError + +from .types import Server, ServerCard + +__all__ = [ + "ServerCardValidationError", + "load_bundled_schema", + "validate_against_schema", + "parse_server_card", + "parse_server", +] + +# Version strings that look like ranges/wildcards. The spec allows non-semantic +# versions but rejects ranges; JSON Schema only bounds length, so we guard here. +_VERSION_RANGE_RE = re.compile(r"[\^~]|[<>]=?|\.\*|\bx\b", re.IGNORECASE) + + +class ServerCardValidationError(Exception): + """Raised when a document fails Server Card validation. + + ``errors`` holds one human-readable string per problem found, so a client + can show the user everything that is wrong at once. + """ + + def __init__(self, message: str, errors: list[str]): + super().__init__(message + "\n - " + "\n - ".join(errors)) + self.errors = errors + + +@lru_cache(maxsize=1) +def load_bundled_schema() -> dict[str, Any]: + """Load the JSON Schema bundled alongside this package.""" + text = resources.files(__package__).joinpath("schema.json").read_text(encoding="utf-8") + return json.loads(text) + + +@lru_cache(maxsize=4) +def _validator_for(definition: str) -> Draft202012Validator: + """Build a validator scoped to a single ``$defs`` entry of the bundled schema.""" + schema = load_bundled_schema() + if definition not in schema.get("$defs", {}): + raise KeyError(f"No '{definition}' definition in bundled schema") + # The bundled schema has no root type (it is generated with `*`), so point a + # tiny root schema at the wanted definition while keeping its $defs in scope. + scoped = {"$schema": schema.get("$schema"), "$ref": f"#/$defs/{definition}", "$defs": schema["$defs"]} + return Draft202012Validator(scoped) + + +def _format_error(error: JSONSchemaValidationError) -> str: + location = "/".join(str(p) for p in error.absolute_path) or "" + return f"{location}: {error.message}" + + +def validate_against_schema(data: dict[str, Any], definition: str = "ServerCard") -> list[str]: + """Validate ``data`` against a ``$defs`` entry; return error strings (empty == valid).""" + validator = _validator_for(definition) + return [_format_error(e) for e in sorted(validator.iter_errors(data), key=str)] + + +def _semantic_errors(data: dict[str, Any]) -> list[str]: + errors: list[str] = [] + version = data.get("version") + if isinstance(version, str) and _VERSION_RANGE_RE.search(version): + errors.append(f"version: '{version}' looks like a range/wildcard; an exact version is required") + return errors + + +def _parse(data: dict[str, Any], model: type[ServerCard], definition: str) -> ServerCard: + if not isinstance(data, dict): + raise ServerCardValidationError("Document is not a JSON object", [": expected an object"]) + + errors = validate_against_schema(data, definition) + errors += _semantic_errors(data) + if errors: + raise ServerCardValidationError(f"Invalid {definition} document", errors) + + try: + return model.model_validate(data) + except PydanticValidationError as exc: # pragma: no cover - schema should catch first + raise ServerCardValidationError( + f"Invalid {definition} document", + [f"{'/'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in exc.errors()], + ) from exc + + +def parse_server_card(data: dict[str, Any]) -> ServerCard: + """Validate and parse a Server Card document (the ``.well-known`` shape).""" + return _parse(data, ServerCard, "ServerCard") + + +def parse_server(data: dict[str, Any]) -> Server: + """Validate and parse a registry-shaped Server document (adds ``packages``).""" + return _parse(data, Server, "Server") # type: ignore[return-value] diff --git a/examples/server-card/pyproject.toml b/examples/server-card/pyproject.toml new file mode 100644 index 000000000..bef31abc5 --- /dev/null +++ b/examples/server-card/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "mcp-server-card-example" +version = "0.1.0" +description = "Example Python implementation of MCP Server Cards (SEP-2127, experimental)" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "server-card", "discovery"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = [ + "click>=8.2.0", + "httpx>=0.27", + "jsonschema>=4.20.0", + "mcp", + "starlette>=0.27", + "uvicorn>=0.31.1", +] + +[project.scripts] +mcp-server-card = "mcp_server_card.cli:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_server_card"] + +[tool.hatch.build.targets.wheel.force-include] +"mcp_server_card/schema.json" = "mcp_server_card/schema.json" + +[tool.pyright] +include = ["mcp_server_card", "examples", "tests"] +venvPath = "." +venv = ".venv" + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/server-card/tests/test_server_card.py b/examples/server-card/tests/test_server_card.py new file mode 100644 index 000000000..7dcabf0d0 --- /dev/null +++ b/examples/server-card/tests/test_server_card.py @@ -0,0 +1,124 @@ +"""Tests for the example Server Card implementation.""" + +from __future__ import annotations + +import pytest + +from mcp_server_card import ( + SERVER_CARD_SCHEMA_URL, + SERVER_SCHEMA_URL, + ServerCardValidationError, + build_server_card, + card_to_dict, + parse_server, + parse_server_card, + streamable_http_remote, + validate_against_schema, +) +from mcp_server_card.types import KeyValueInput, Repository + +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"], + } + ], +} + +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"}, + "packages": [ + { + "registryType": "npm", + "identifier": "@example-org/with-package", + "version": "0.4.2", + "runtimeHint": "npx", + "transport": {"type": "stdio"}, + "environmentVariables": [ + {"name": "EXAMPLE_API_KEY", "description": "Example API key.", "isRequired": True, "isSecret": True} + ], + } + ], +} + + +@pytest.mark.parametrize("doc", [MINIMAL, TEMPLATED_REMOTE]) +def test_valid_cards_parse(doc: dict) -> None: + card = parse_server_card(doc) + assert card.name == doc["name"] + # Round-trips back to exactly the input (modulo key ordering). + assert card_to_dict(card) == doc + + +def test_server_with_package_parses_and_discriminates_transport() -> None: + server = parse_server(WITH_PACKAGE) + assert server.packages is not None + transport = server.packages[0].transport + assert transport.type == "stdio" + assert card_to_dict(server) == WITH_PACKAGE + + +def test_build_server_card_round_trips_through_schema() -> None: + card = build_server_card( + name="io.modelcontextprotocol.examples/dice", + version="1.0.0", + description="Rolls dice.", + title="Dice", + repository=Repository(url="https://github.com/example/dice", source="github"), + remotes=[streamable_http_remote("https://dice.example.com/mcp", supported_protocol_versions=["2025-11-25"])], + ) + assert validate_against_schema(card_to_dict(card)) == [] + assert parse_server_card(card_to_dict(card)) == card + + +def test_header_value_with_variables_serializes_camelcase() -> None: + header = KeyValueInput(name="Authorization", is_required=True, value="Bearer {t}") + dumped = header.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"name": "Authorization", "isRequired": True, "value": "Bearer {t}"} + + +@pytest.mark.parametrize( + "doc, needle", + [ + ({**MINIMAL, "name": "no-slash"}, "name"), + ({k: v for k, v in MINIMAL.items() if k != "$schema"}, "$schema"), + ({k: v for k, v in MINIMAL.items() if k != "name"}, "name"), + ({**MINIMAL, "$schema": "https://static.modelcontextprotocol.io/schemas/2025-11-25/server-card.schema.json"}, + "$schema"), + ({**MINIMAL, "version": "^1.2.3"}, "version"), # semantic guard (not in JSON Schema) + ], +) +def test_invalid_cards_rejected(doc: dict, needle: str) -> None: + with pytest.raises(ServerCardValidationError) as excinfo: + parse_server_card(doc) + assert any(needle in error for error in excinfo.value.errors)