Skip to content

Commit f2d46d3

Browse files
committed
feat: add experimental Server Card and AI Catalog models (SEP-2127)
Shared-tier Pydantic models for the experimental-ext-server-card extension: ServerCard and its Input/KeyValueInput/Repository/Remote family, resolve_remote for template substitution, and a minimal typed AICatalog subset. Wire-format constraints (v1 $schema URL, namespaced name pattern, version-range rejection, url-xor-data catalog entries) are enforced in validators and pinned by the extension repo's vendored conformance fixtures.
1 parent 3a6f299 commit f2d46d3

14 files changed

Lines changed: 823 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Experimental: tracks SEP-2127 (the Server Card extension).
2+
3+
Nothing under `mcp.shared.experimental` carries a stability guarantee. Any
4+
symbol here may change or be removed in any release without a deprecation
5+
cycle.
6+
"""
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Internals shared by the experimental Server Card and AI Catalog modules."""
2+
3+
import ipaddress
4+
5+
from pydantic import BaseModel, ConfigDict
6+
from pydantic.alias_generators import to_camel
7+
8+
SERVER_CARD_NAME_PATTERN = r"^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$"
9+
"""Reverse-DNS namespace, one slash, then the server name (`com.example/weather`)."""
10+
11+
12+
class CardModel(BaseModel):
13+
"""Base for all card and catalog models.
14+
15+
The wire format is camelCase and the schema objects are open, so extra
16+
(vendor) fields must survive a parse and re-serialize round trip.
17+
"""
18+
19+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="allow")
20+
21+
22+
def is_loopback_host(host: str) -> bool:
23+
"""Whether `host` is `localhost` or a loopback IP literal (`127.0.0.0/8`, `::1`)."""
24+
if host == "localhost":
25+
return True
26+
try:
27+
return ipaddress.ip_address(host.strip("[]")).is_loopback
28+
except ValueError:
29+
return False
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""AI Catalog models (experimental, minimal typed subset).
2+
3+
An AI Catalog is a JSON document advertising AI artifacts, including Server
4+
Cards. The format is owned by the AI Catalog working group
5+
(https://github.com/Agent-Card/ai-catalog). Only the fields the SDK helpers
6+
consume are typed here. Trust and publisher shapes pass through as plain
7+
dictionaries for the host's consent UI.
8+
"""
9+
10+
from typing import Any, Final
11+
12+
from pydantic import model_validator
13+
14+
from mcp.shared.experimental._base import CardModel as _CardModel
15+
16+
__all__ = [
17+
"AI_CATALOG_MEDIA_TYPE",
18+
"AI_CATALOG_WELL_KNOWN_PATH",
19+
"MAX_CATALOG_NESTING_DEPTH",
20+
"CatalogEntry",
21+
"AICatalog",
22+
]
23+
24+
AI_CATALOG_MEDIA_TYPE: Final = "application/ai-catalog+json"
25+
"""Media type for AI Catalog documents."""
26+
27+
AI_CATALOG_WELL_KNOWN_PATH: Final = "/.well-known/ai-catalog.json"
28+
"""Well-known path for domain-level catalog discovery."""
29+
30+
MAX_CATALOG_NESTING_DEPTH: Final = 4
31+
"""The AI Catalog spec's cap on nested catalog depth."""
32+
33+
34+
class CatalogEntry(_CardModel):
35+
"""One advertised artifact: a Server Card, a nested catalog, or anything else.
36+
37+
Exactly one of `url` and `data` must be set. Violations raise
38+
`pydantic.ValidationError`.
39+
"""
40+
41+
identifier: str
42+
type: str
43+
url: str | None = None
44+
data: dict[str, Any] | None = None
45+
display_name: str | None = None
46+
description: str | None = None
47+
tags: list[str] | None = None
48+
version: str | None = None
49+
updated_at: str | None = None
50+
publisher: dict[str, Any] | None = None
51+
trust_manifest: dict[str, Any] | None = None
52+
metadata: dict[str, Any] | None = None
53+
54+
@model_validator(mode="after")
55+
def _exactly_one_of_url_or_data(self) -> "CatalogEntry":
56+
if (self.url is None) == (self.data is None):
57+
raise ValueError("a catalog entry carries exactly one of url or data")
58+
return self
59+
60+
61+
class AICatalog(_CardModel):
62+
"""An AI Catalog document (`application/ai-catalog+json`)."""
63+
64+
spec_version: str
65+
entries: list[CatalogEntry]
66+
host: dict[str, Any] | None = None
67+
metadata: dict[str, Any] | None = None
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""Server Card models and pure helpers (experimental, tracks SEP-2127).
2+
3+
A Server Card is a static JSON document that describes a single remote MCP
4+
server well enough for a client to discover and connect to it before any
5+
protocol exchange. See
6+
https://github.com/modelcontextprotocol/experimental-ext-server-card for the
7+
authoritative schema.
8+
9+
Card contents are unverified and advisory. Clients MUST NOT treat them as
10+
authoritative for security or access-control decisions, and SHOULD prefer
11+
runtime values (`serverInfo`) whenever the two disagree.
12+
"""
13+
14+
import re
15+
from collections.abc import Mapping
16+
from dataclasses import dataclass
17+
from typing import Any, Final, Literal
18+
19+
from mcp_types import Icon
20+
from pydantic import Field, field_validator
21+
22+
from mcp.shared.experimental._base import SERVER_CARD_NAME_PATTERN
23+
from mcp.shared.experimental._base import CardModel as _CardModel
24+
25+
__all__ = [
26+
"SERVER_CARD_SCHEMA_URL",
27+
"SERVER_CARD_MEDIA_TYPE",
28+
"RESERVED_SERVER_CARD_SUFFIX",
29+
"Input",
30+
"KeyValueInput",
31+
"Repository",
32+
"Remote",
33+
"ServerCard",
34+
"ResolvedRemote",
35+
"resolve_remote",
36+
]
37+
38+
SERVER_CARD_SCHEMA_URL: Final = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json"
39+
"""The only `$schema` value a v1 Server Card may carry."""
40+
41+
SERVER_CARD_MEDIA_TYPE: Final = "application/mcp-server-card+json"
42+
"""Media type for Server Card documents."""
43+
44+
RESERVED_SERVER_CARD_SUFFIX: Final = "/server-card"
45+
"""Path suffix the spec reserves under the streamable HTTP URL."""
46+
47+
_REMOTE_URL_PATTERN = r"^(https?://[^\s]+|\{[a-zA-Z_][a-zA-Z0-9_]*\}[^\s]*)$"
48+
_TEMPLATE_VARIABLE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
49+
_VERSION_RANGE_PREFIXES = ("^", "~", ">=", "<=", ">", "<")
50+
_VERSION_WILDCARD_SEGMENTS = frozenset({"x", "X", "*"})
51+
52+
53+
class Input(_CardModel):
54+
"""A user-suppliable value referenced by a `Remote` URL or header template."""
55+
56+
description: str | None = None
57+
is_required: bool | None = None
58+
is_secret: bool | None = None
59+
format: Literal["string", "number", "boolean", "filepath"] | None = None
60+
default: str | None = None
61+
placeholder: str | None = None
62+
value: str | None = None
63+
choices: list[str] | None = None
64+
65+
66+
class KeyValueInput(Input):
67+
"""A named input, used for HTTP headers on a `Remote`."""
68+
69+
name: str
70+
variables: dict[str, Input] | None = None
71+
72+
73+
class Repository(_CardModel):
74+
"""Source repository metadata for the server implementation."""
75+
76+
url: str
77+
source: str
78+
subfolder: str | None = None
79+
id: str | None = None
80+
81+
82+
class Remote(_CardModel):
83+
"""Connection metadata for one remote transport endpoint."""
84+
85+
type: Literal["streamable-http", "sse"]
86+
url: str = Field(pattern=_REMOTE_URL_PATTERN)
87+
headers: list[KeyValueInput] | None = None
88+
variables: dict[str, Input] | None = None
89+
supported_protocol_versions: list[str] | None = None
90+
91+
@property
92+
def required_variables(self) -> frozenset[str]:
93+
"""Template variable names a host must prompt the user for.
94+
95+
Covers the URL variables and each header's nested variables, keeping
96+
the ones declared `isRequired` with no `default` and no pre-set
97+
`value`.
98+
"""
99+
names: set[str] = set()
100+
header_variables = [header.variables for header in self.headers or []]
101+
for variables in [self.variables, *header_variables]:
102+
for name, spec in (variables or {}).items():
103+
if spec.is_required and spec.default is None and spec.value is None:
104+
names.add(name)
105+
return frozenset(names)
106+
107+
108+
class ServerCard(_CardModel):
109+
"""A Server Card document (`application/mcp-server-card+json`).
110+
111+
Malformed documents raise `pydantic.ValidationError` at construction or
112+
`model_validate*` time.
113+
"""
114+
115+
schema_: str = Field(default=SERVER_CARD_SCHEMA_URL, alias="$schema")
116+
name: str = Field(min_length=3, max_length=200, pattern=SERVER_CARD_NAME_PATTERN)
117+
version: str = Field(max_length=255)
118+
description: str = Field(min_length=1, max_length=100)
119+
title: str | None = Field(default=None, min_length=1, max_length=100)
120+
website_url: str | None = None
121+
repository: Repository | None = None
122+
icons: list[Icon] | None = None
123+
remotes: list[Remote] | None = None
124+
meta: dict[str, Any] | None = Field(default=None, alias="_meta")
125+
126+
@field_validator("schema_")
127+
@classmethod
128+
def _schema_url_is_the_v1_url(cls, value: str) -> str:
129+
if value != SERVER_CARD_SCHEMA_URL:
130+
raise ValueError(f"$schema must be exactly {SERVER_CARD_SCHEMA_URL!r}")
131+
return value
132+
133+
@field_validator("version")
134+
@classmethod
135+
def _version_is_not_a_range(cls, value: str) -> str:
136+
# Range syntax is rejected at the prose level of the spec. It is not
137+
# expressible in the published JSON Schema.
138+
if value.startswith(_VERSION_RANGE_PREFIXES) or "||" in value:
139+
raise ValueError("version must be a single version, never a range")
140+
if any(segment in _VERSION_WILDCARD_SEGMENTS for segment in value.split(".")):
141+
raise ValueError("version must not contain wildcard segments")
142+
return value
143+
144+
def endpoint_urls(self) -> frozenset[str]:
145+
"""The raw `remotes[].url` values, templates unresolved.
146+
147+
This is the dedup key for discovered servers: hosts MUST de-duplicate
148+
on endpoints, never on the self-asserted name or catalog identifier.
149+
"""
150+
return frozenset(remote.url for remote in self.remotes or [])
151+
152+
153+
@dataclass(frozen=True, slots=True)
154+
class ResolvedRemote:
155+
"""A `Remote` with every template variable substituted, ready to connect."""
156+
157+
type: Literal["streamable-http", "sse"]
158+
url: str
159+
headers: dict[str, str]
160+
161+
162+
def _substitute(template: str, mapping: Mapping[str, str]) -> tuple[str, set[str]]:
163+
"""Replace `{name}` placeholders from `mapping`, collecting unresolved names."""
164+
unresolved: set[str] = set()
165+
166+
def replace(match: re.Match[str]) -> str:
167+
name = match.group(1)
168+
if name in mapping:
169+
return mapping[name]
170+
unresolved.add(name)
171+
return match.group(0)
172+
173+
return _TEMPLATE_VARIABLE.sub(replace, template), unresolved
174+
175+
176+
def _resolve_input(name: str, spec: Input, values: Mapping[str, str] | None) -> str | None:
177+
"""The effective value for one input, or None when nothing supplies it.
178+
179+
A pre-set `value` is not end-user configurable, so it wins. Caller values
180+
override declared defaults.
181+
"""
182+
if spec.value is not None:
183+
return spec.value
184+
if values is not None and name in values:
185+
return values[name]
186+
return spec.default
187+
188+
189+
def _resolve_variables(
190+
declared: Mapping[str, Input] | None,
191+
values: Mapping[str, str] | None,
192+
missing: set[str],
193+
bad_choices: list[str],
194+
) -> dict[str, str]:
195+
"""Build the substitution mapping for one template scope.
196+
197+
Caller values apply to undeclared names too. Missing required names and
198+
values outside a declared `choices` list are collected, never raised here.
199+
"""
200+
mapping: dict[str, str] = dict(values or {})
201+
for name, spec in (declared or {}).items():
202+
resolved = _resolve_input(name, spec, values)
203+
if resolved is None:
204+
if spec.is_required:
205+
missing.add(name)
206+
continue
207+
if spec.choices is not None and resolved not in spec.choices:
208+
bad_choices.append(f"{name}={resolved!r} (choices: {spec.choices})")
209+
continue
210+
mapping[name] = resolved
211+
return mapping
212+
213+
214+
def resolve_remote(remote: Remote, values: Mapping[str, str] | None = None) -> ResolvedRemote:
215+
"""Substitute every `{curly_brace}` variable in `remote`, purely in memory.
216+
217+
Declared `default`s and pre-set `value`s apply automatically. Caller
218+
`values` override defaults and also supply header values by header name.
219+
No prompting, no persistence and no `isSecret` handling happens here.
220+
Those belong to the host application.
221+
222+
Raises:
223+
ValueError: Naming every missing `isRequired` variable, any value
224+
outside a declared `choices` list, any placeholder left
225+
unresolved, or a resolved URL that is not http(s).
226+
"""
227+
missing: set[str] = set()
228+
bad_choices: list[str] = []
229+
unresolved: set[str] = set()
230+
231+
url_mapping = _resolve_variables(remote.variables, values, missing, bad_choices)
232+
url, url_unresolved = _substitute(remote.url, url_mapping)
233+
unresolved |= url_unresolved
234+
235+
headers: dict[str, str] = {}
236+
for header in remote.headers or []:
237+
template = _resolve_input(header.name, header, values)
238+
if template is None:
239+
if header.is_required:
240+
missing.add(header.name)
241+
continue
242+
if header.choices is not None and template not in header.choices:
243+
bad_choices.append(f"{header.name}={template!r} (choices: {header.choices})")
244+
continue
245+
header_mapping = _resolve_variables(header.variables, values, missing, bad_choices)
246+
resolved_value, value_unresolved = _substitute(template, header_mapping)
247+
unresolved |= value_unresolved
248+
headers[header.name] = resolved_value
249+
250+
if missing:
251+
raise ValueError(f"missing required variables: {', '.join(sorted(missing))}")
252+
if bad_choices:
253+
raise ValueError(f"values outside declared choices: {'; '.join(bad_choices)}")
254+
if unresolved:
255+
raise ValueError(f"unresolved template variables: {', '.join(sorted(unresolved))}")
256+
if not url.startswith(("http://", "https://")):
257+
raise ValueError(f"resolved remote URL must be http(s), got {url!r}")
258+
return ResolvedRemote(type=remote.type, url=url, headers=headers)

‎tests/shared/experimental/__init__.py‎

Whitespace-only changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
3+
"name": "no-slash-in-name",
4+
"version": "1.0.0",
5+
"description": "Name must contain exactly one slash separating namespace and server name."
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-11-25/server-card.schema.json",
3+
"name": "example-org/wrong-schema-url",
4+
"version": "1.0.0",
5+
"description": "Uses date-versioned schema URL; only /v1/ URLs are valid."
6+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
3+
"version": "1.0.0",
4+
"description": "Missing required `name` field."
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "example-org/no-schema",
3+
"version": "1.0.0",
4+
"description": "Missing the required `$schema` field."
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/v1/server.schema.json",
3+
"name": "example-org/wrong-schema-name",
4+
"version": "1.0.0",
5+
"description": "References the removed registry server.json schema; only server-card.schema.json is valid in v1."
6+
}

0 commit comments

Comments
 (0)