From 8d4c2f5834f9925b969809d4db415bba130ee4e8 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:09:19 +0100 Subject: [PATCH 1/4] [v1.x] fix: catch PydanticUserError when generating output schema (pydantic 2.13 compat) (#2435) --- src/mcp/server/fastmcp/utilities/func_metadata.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index fa443d2fcb..241100d317 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -10,6 +10,7 @@ BaseModel, ConfigDict, Field, + PydanticUserError, RootModel, WithJsonSchema, create_model, @@ -411,9 +412,16 @@ def _try_create_model_and_schema( # Use StrictJsonSchema to raise exceptions instead of warnings try: schema = model.model_json_schema(schema_generator=StrictJsonSchema) - except (TypeError, ValueError, pydantic_core.SchemaError, pydantic_core.ValidationError) as e: + except ( + PydanticUserError, + TypeError, + ValueError, + pydantic_core.SchemaError, + pydantic_core.ValidationError, + ) as e: # These are expected errors when a type can't be converted to a Pydantic schema - # TypeError: When Pydantic can't handle the type + # PydanticUserError: When Pydantic can't handle the type (e.g. PydanticInvalidForJsonSchema); + # subclasses TypeError on pydantic <2.13 and RuntimeError on pydantic >=2.13 # ValueError: When there are issues with the type definition (including our custom warnings) # SchemaError: When Pydantic can't build a schema # ValidationError: When validation fails From 73d458baac207cecf77d17e64c7ce3902f4bce04 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:53:58 +0100 Subject: [PATCH 2/4] [v1.x] fix(auth): coerce empty-string optional URL fields to None in OAuthClientMetadata (#2405) --- src/mcp/shared/auth.py | 18 +++++++++ tests/shared/test_auth.py | 82 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index d3290997e5..a985bef3f1 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -71,6 +71,24 @@ class OAuthClientMetadata(BaseModel): software_id: str | None = None software_version: str | None = None + @field_validator( + "client_uri", + "logo_uri", + "tos_uri", + "policy_uri", + "jwks_uri", + mode="before", + ) + @classmethod + def _empty_string_optional_url_to_none(cls, v: object) -> object: + # RFC 7591 §2 marks these URL fields OPTIONAL. Some authorization servers + # echo omitted metadata back as "" instead of dropping the keys, which + # AnyHttpUrl would otherwise reject — throwing away an otherwise valid + # registration response. Treat "" as absent. + if v == "": + return None + return v + def validate_scope(self, requested_scope: str | None) -> list[str] | None: if requested_scope is None: return None diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index bd9f5a934d..e22659721f 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,6 +1,9 @@ """Tests for OAuth 2.0 shared code.""" -from mcp.shared.auth import OAuthMetadata +import pytest +from pydantic import ValidationError + +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata class TestOAuthMetadata: @@ -59,3 +62,80 @@ def test_oauth_with_jarm(self): "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], } ) + + +# RFC 7591 §2 marks client_uri/logo_uri/tos_uri/policy_uri/jwks_uri as OPTIONAL. +# Some authorization servers echo the client's omitted metadata back as "" +# instead of dropping the keys; without coercion, AnyHttpUrl rejects "" and +# the whole registration response is thrown away even though the server +# returned a valid client_id. + + +@pytest.mark.parametrize( + "empty_field", + ["client_uri", "logo_uri", "tos_uri", "policy_uri", "jwks_uri"], +) +def test_optional_url_empty_string_coerced_to_none(empty_field: str): + data = { + "redirect_uris": ["https://example.com/callback"], + empty_field: "", + } + metadata = OAuthClientMetadata.model_validate(data) + assert getattr(metadata, empty_field) is None + + +def test_all_optional_urls_empty_together(): + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "", + "logo_uri": "", + "tos_uri": "", + "policy_uri": "", + "jwks_uri": "", + } + metadata = OAuthClientMetadata.model_validate(data) + assert metadata.client_uri is None + assert metadata.logo_uri is None + assert metadata.tos_uri is None + assert metadata.policy_uri is None + assert metadata.jwks_uri is None + + +def test_valid_url_passes_through_unchanged(): + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "https://udemy.com/", + } + metadata = OAuthClientMetadata.model_validate(data) + assert str(metadata.client_uri) == "https://udemy.com/" + + +def test_information_full_inherits_coercion(): + """OAuthClientInformationFull subclasses OAuthClientMetadata, so the + same coercion applies to DCR responses parsed via the full model.""" + data = { + "client_id": "abc123", + "redirect_uris": ["https://example.com/callback"], + "client_uri": "", + "logo_uri": "", + "tos_uri": "", + "policy_uri": "", + "jwks_uri": "", + } + info = OAuthClientInformationFull.model_validate(data) + assert info.client_id == "abc123" + assert info.client_uri is None + assert info.logo_uri is None + assert info.tos_uri is None + assert info.policy_uri is None + assert info.jwks_uri is None + + +def test_invalid_non_empty_url_still_rejected(): + """Coercion must only touch empty strings — garbage URLs still raise.""" + data = { + "redirect_uris": ["https://example.com/callback"], + "client_uri": "not a url", + } + with pytest.raises(ValidationError): + OAuthClientMetadata.model_validate(data) From 2034cae340e5991a39018f3ab4496ca35fa29698 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 8 May 2026 14:18:01 +0100 Subject: [PATCH 3/4] [v1.x] build: restrict httpx to <1.0.0 (#2559) --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3d3e7a72cc..9b28741aeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "anyio>=4.5", - "httpx>=0.27.1", + "httpx>=0.27.1,<1.0.0", "httpx-sse>=0.4", "pydantic>=2.11.0,<3.0.0", "starlette>=0.27", diff --git a/uv.lock b/uv.lock index e42301b674..4b01712966 100644 --- a/uv.lock +++ b/uv.lock @@ -819,7 +819,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "anyio", specifier = ">=4.5" }, - { name = "httpx", specifier = ">=0.27.1" }, + { name = "httpx", specifier = ">=0.27.1,<1.0.0" }, { name = "httpx-sse", specifier = ">=0.4" }, { name = "jsonschema", specifier = ">=4.20.0" }, { name = "pydantic", specifier = ">=2.11.0,<3.0.0" }, From 77431ebe7dda9ed0c61451b22d3e7f8d981bc092 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 8 May 2026 17:42:45 +0100 Subject: [PATCH 4/4] [v1.x] refactor: import SSEError from httpx_sse public API (#2561) --- src/mcp/client/sse.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/mcp/client/sse.py b/src/mcp/client/sse.py index b2ac67744e..0d7fa0fb46 100644 --- a/src/mcp/client/sse.py +++ b/src/mcp/client/sse.py @@ -8,8 +8,7 @@ import httpx from anyio.abc import TaskStatus from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from httpx_sse import aconnect_sse -from httpx_sse._exceptions import SSEError +from httpx_sse import SSEError, aconnect_sse import mcp.types as types from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client