Skip to content

Commit a8426fc

Browse files
committed
Add experimental Server Cards support (SEP-2127)
Adds SDK support for MCP Server Cards: static metadata documents that describe a remote server's identity, transport endpoints, and supported protocol versions for pre-connection discovery. - mcp.shared.experimental.server_card: Pydantic models (ServerCard, Server, Remote, Package, ...) mirroring mcp.types conventions and validating purely through Pydantic. - mcp.server.experimental.server_card: build_server_card derives a card from a server's identity; server_card_route / mount_server_card serve it from a Starlette app at /.well-known/mcp/server-card. - mcp.client.experimental.server_card: fetch_server_card / load_server_card / well_known_url ingest and validate a card. Full test coverage for the new modules.
1 parent 3eb5799 commit a8426fc

7 files changed

Lines changed: 860 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Ingest MCP Server Cards (SEP-2127).
2+
3+
WARNING: These APIs are experimental and may change without notice.
4+
5+
A client discovers how to connect to a remote server by fetching its card from
6+
the conventional ``.well-known`` location before initializing a session::
7+
8+
from mcp.client.experimental.server_card import fetch_server_card
9+
10+
card = await fetch_server_card("https://dice.example.com")
11+
for remote in card.remotes or []:
12+
print(remote.type, remote.url, remote.supported_protocol_versions)
13+
14+
The returned :class:`ServerCard` is fully validated; malformed documents raise
15+
``pydantic.ValidationError``.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import json
21+
from pathlib import Path
22+
from urllib.parse import urljoin, urlsplit
23+
24+
import httpx
25+
26+
from mcp.shared.experimental.server_card.types import WELL_KNOWN_PATH, ServerCard
27+
28+
__all__ = ["well_known_url", "fetch_server_card", "load_server_card"]
29+
30+
31+
def well_known_url(url: str, *, well_known_path: str = WELL_KNOWN_PATH) -> str:
32+
"""Resolve the Server Card URL for a server's origin.
33+
34+
Accepts either a bare origin (``https://example.com``) or any URL on the
35+
server (e.g. its ``/mcp`` endpoint); the card always lives at the host root.
36+
37+
Raises:
38+
ValueError: If ``url`` is not an absolute http(s) URL.
39+
"""
40+
parts = urlsplit(url)
41+
if not parts.scheme or not parts.netloc:
42+
raise ValueError(f"Expected an absolute http(s) URL, got {url!r}")
43+
origin = f"{parts.scheme}://{parts.netloc}"
44+
return urljoin(origin, well_known_path)
45+
46+
47+
async def fetch_server_card(
48+
url: str,
49+
*,
50+
well_known_path: str = WELL_KNOWN_PATH,
51+
httpx_client: httpx.AsyncClient | None = None,
52+
) -> ServerCard:
53+
"""Fetch and validate the Server Card for the server at ``url``.
54+
55+
``url`` may be the server's origin or any URL on the same host; the card is
56+
resolved to ``<origin><well_known_path>``. Pass an existing ``httpx_client``
57+
to reuse connection pooling / auth, otherwise a short-lived client is used.
58+
59+
Raises:
60+
ValueError: If ``url`` is not an absolute http(s) URL.
61+
httpx.HTTPError: If the request fails or returns a non-2xx status.
62+
pydantic.ValidationError: If the document is not a valid Server Card.
63+
"""
64+
target = well_known_url(url, well_known_path=well_known_path)
65+
66+
if httpx_client is None:
67+
async with httpx.AsyncClient(follow_redirects=True) as client:
68+
response = await client.get(target, headers={"Accept": "application/json"})
69+
else:
70+
response = await httpx_client.get(target, headers={"Accept": "application/json"})
71+
response.raise_for_status()
72+
return ServerCard.model_validate(response.json())
73+
74+
75+
def load_server_card(path: str | Path) -> ServerCard:
76+
"""Load and validate a Server Card from a JSON file.
77+
78+
Raises:
79+
OSError: If the file cannot be read.
80+
json.JSONDecodeError: If the file is not valid JSON.
81+
pydantic.ValidationError: If the document is not a valid Server Card.
82+
"""
83+
text = Path(path).read_text(encoding="utf-8")
84+
return ServerCard.model_validate(json.loads(text))
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Generate and serve MCP Server Cards (SEP-2127).
2+
3+
WARNING: These APIs are experimental and may change without notice.
4+
5+
A server author builds a card from the server's identity and either serves it
6+
from the conventional ``.well-known`` path or hands it to their own Starlette
7+
app::
8+
9+
from mcp.server.experimental.server_card import build_server_card, mount_server_card
10+
from mcp.shared.experimental.server_card import Remote
11+
12+
card = build_server_card(
13+
server,
14+
name="io.modelcontextprotocol.examples/dice-roller",
15+
remotes=[Remote(type="streamable-http", url="https://dice.example.com/mcp")],
16+
)
17+
18+
app = server.streamable_http_app()
19+
mount_server_card(app, card) # GET /.well-known/mcp/server-card
20+
21+
To write a card to a file instead, serialize it with
22+
``card.model_dump_json(by_alias=True, exclude_none=True)``.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from typing import Any, Protocol
28+
29+
from starlette.applications import Starlette
30+
from starlette.requests import Request
31+
from starlette.responses import JSONResponse
32+
from starlette.routing import Route
33+
34+
from mcp.shared.experimental.server_card.types import (
35+
WELL_KNOWN_PATH,
36+
Icon,
37+
Remote,
38+
Repository,
39+
ServerCard,
40+
)
41+
42+
__all__ = ["build_server_card", "server_card_route", "mount_server_card"]
43+
44+
45+
class _ServerIdentity(Protocol):
46+
"""The identity attributes shared by the low-level ``Server`` and ``MCPServer``."""
47+
48+
name: str
49+
version: str | None
50+
title: str | None
51+
description: str | None
52+
website_url: str | None
53+
icons: list[Icon] | None
54+
55+
56+
def build_server_card(
57+
server: _ServerIdentity,
58+
*,
59+
name: str,
60+
remotes: list[Remote] | None = None,
61+
repository: Repository | None = None,
62+
meta: dict[str, Any] | None = None,
63+
) -> ServerCard:
64+
"""Build a Server Card from a running server's identity metadata.
65+
66+
``name`` is the card's reverse-DNS ``namespace/name`` identifier, passed
67+
explicitly because a server's display ``name`` is free-form. The version,
68+
title, description, website and icons are taken from ``server``.
69+
70+
Args:
71+
server: A low-level ``Server`` or high-level ``MCPServer`` (anything
72+
exposing the standard identity attributes).
73+
name: Reverse-DNS server name, e.g. ``"io.modelcontextprotocol/everything"``.
74+
remotes: Remote endpoints to advertise.
75+
repository: Optional source repository metadata.
76+
meta: Optional ``_meta`` extension metadata.
77+
78+
Returns:
79+
A validated :class:`ServerCard`.
80+
81+
Raises:
82+
ValueError: If ``server`` has no ``version`` or ``description`` set; both
83+
are required on a card.
84+
pydantic.ValidationError: If the resulting card is invalid (e.g. ``name``
85+
is not reverse-DNS).
86+
"""
87+
if server.version is None:
88+
raise ValueError("server.version must be set to build a Server Card")
89+
if not server.description:
90+
raise ValueError("server.description must be set to build a Server Card")
91+
return ServerCard(
92+
name=name,
93+
version=server.version,
94+
description=server.description,
95+
title=server.title,
96+
website_url=server.website_url,
97+
icons=server.icons,
98+
remotes=remotes,
99+
repository=repository,
100+
_meta=meta,
101+
)
102+
103+
104+
def server_card_route(card: ServerCard, *, path: str = WELL_KNOWN_PATH) -> Route:
105+
"""Build a Starlette GET route that serves ``card`` as JSON at ``path``.
106+
107+
Add it to a new app — ``Starlette(routes=[server_card_route(card)])`` — or an
108+
existing one via :func:`mount_server_card`. The payload is serialized once;
109+
a card is static metadata.
110+
"""
111+
payload = card.model_dump(mode="json", by_alias=True, exclude_none=True)
112+
113+
async def endpoint(_request: Request) -> JSONResponse:
114+
return JSONResponse(payload, media_type="application/json")
115+
116+
return Route(path, endpoint=endpoint, methods=["GET"], name="mcp_server_card")
117+
118+
119+
def mount_server_card(app: Starlette, card: ServerCard, *, path: str = WELL_KNOWN_PATH) -> None:
120+
"""Attach a Server Card route to an existing Starlette application.
121+
122+
The route is unauthenticated, which is what pre-connection discovery wants.
123+
"""
124+
app.router.routes.append(server_card_route(card, path=path))
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""MCP Server Cards (SEP-2127) — shared types.
2+
3+
WARNING: These APIs are experimental and may change without notice.
4+
5+
A Server Card is a static metadata document describing a remote MCP server,
6+
suitable for pre-connection discovery. See
7+
``mcp.shared.experimental.server_card.types`` for the model definitions.
8+
9+
* Servers generate and serve a card with ``mcp.server.experimental.server_card``.
10+
* Clients ingest one with ``mcp.client.experimental.server_card``.
11+
"""
12+
13+
from mcp.shared.experimental.server_card.types import (
14+
SERVER_CARD_SCHEMA_URL,
15+
SERVER_SCHEMA_URL,
16+
WELL_KNOWN_PATH,
17+
Argument,
18+
Icon,
19+
Input,
20+
InputWithVariables,
21+
KeyValueInput,
22+
NamedArgument,
23+
Package,
24+
PackageTransport,
25+
PositionalArgument,
26+
Remote,
27+
Repository,
28+
Server,
29+
ServerCard,
30+
SsePackageTransport,
31+
StdioTransport,
32+
StreamableHttpPackageTransport,
33+
)
34+
35+
__all__ = [
36+
"SERVER_CARD_SCHEMA_URL",
37+
"SERVER_SCHEMA_URL",
38+
"WELL_KNOWN_PATH",
39+
"Argument",
40+
"Icon",
41+
"Input",
42+
"InputWithVariables",
43+
"KeyValueInput",
44+
"NamedArgument",
45+
"Package",
46+
"PackageTransport",
47+
"PositionalArgument",
48+
"Remote",
49+
"Repository",
50+
"Server",
51+
"ServerCard",
52+
"SsePackageTransport",
53+
"StdioTransport",
54+
"StreamableHttpPackageTransport",
55+
]

0 commit comments

Comments
 (0)