Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion src/mcp/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import re
from collections.abc import Callable
from typing import Annotated, Any, Generic, Literal, TypeAlias, TypeVar

from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel
from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel, model_validator
from pydantic.networks import AnyUrl, UrlConstraints
from typing_extensions import deprecated

Expand Down Expand Up @@ -52,6 +53,49 @@ class Meta(BaseModel):

model_config = ConfigDict(extra="allow")

@model_validator(mode="before")
@classmethod
def validate_metadata_keys(cls, data: Any) -> Any:
"""
Validate if metadata keys follows the protocol specification
See section "General fields" at https://modelcontextprotocol.io/specification/
"""
for metadata_key in data.keys():
key_parts = metadata_key.split("/")

match len(key_parts):
case 1:
cls._validate_metadata_name(key_parts[0])

case 2:
cls._validate_metadata_prefix(key_parts[0])
cls._validate_metadata_name(key_parts[1])

case _:
raise ValueError(f"The metadata key {metadata_key} does not comply with MCP specification")

return data

@classmethod
def _validate_metadata_prefix(cls, prefix: str):
if len(prefix) == 0:
raise ValueError(
"One of the metadata keys is empty, and therefore does not comply with MCP specification"
)

for label in prefix.split("."):
cls._validate_prefix_label(prefix=prefix, label=label)

@classmethod
def _validate_metadata_name(cls, name: str):
if re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-._]*[a-zA-Z0-9])?$", name) is None:
raise ValueError(f"The metadata name {name} does not comply with MCP specification")

@classmethod
def _validate_prefix_label(cls, label: str, prefix: str):
if re.match(r"^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$", label) is None:
raise ValueError(f"The label {label} inside of prefix {prefix} does not comply with MCP specification")

meta: Meta | None = Field(alias="_meta", default=None)


Expand Down
Empty file added tests/types/__init__.py
Empty file.
65 changes: 65 additions & 0 deletions tests/types/test_meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import pytest

from mcp import types


@pytest.mark.parametrize(
argnames="key",
argvalues=[
# Simple keys without reserved prefix
"clientId",
"request-id",
"api_version",
"product-id",
"x-correlation-id",
"my-key",
"info",
"data-1",
"label-key",
# Keys with reserved prefix
"modelcontextprotocol.io/request-id",
"mcp.dev/debug-mode",
"api.modelcontextprotocol.org/api-version",
"tools.mcp.com/validation-status",
"my-company.mcp.io/internal-flag",
"modelcontextprotocol.io/a",
"mcp.dev/b-c",
# Keys with non-reserved prefix
"my-app.com/user-preferences",
"internal.api/tracking-id",
"org.example/resource-type",
"custom.domain/status",
],
)
def test_metadata_valid_keys(key: str):
"""
Asserts that valid metadata keys does not raise ValueErrors
"""
types.RequestParams.Meta(**{key: "value"})


@pytest.mark.parametrize(
argnames="key",
argvalues=[
# Invalid key names (without prefix)
"-leading-hyphen",
"trailing-hyphen-",
"with space",
"key/with/slash",
"no@special-chars",
"...",
# Invalid prefixes
"mcp.123/key",
"my.custom./key",
"my-app.com//key",
# Invalid combination of prefix and name
"mcp.dev/-invalid",
"org.example/invalid-name-",
],
)
def test_metadata_invalid_keys(key: str):
"""
Asserts that invalid metadata keys raise ValueErrors
"""
with pytest.raises(ValueError):
types.RequestParams.Meta(**{key: "value"})
Loading