From c1b0396c0a47945208adab5645f8c35288572263 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:52:57 -0700 Subject: [PATCH 1/6] Block IPv6 transition SSRF bypasses (#4426) --- fastmcp_slim/fastmcp/server/auth/ssrf.py | 77 +++++++++++++++++------ tests/server/auth/test_ssrf_protection.py | 59 +++++++++++++---- 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/auth/ssrf.py b/fastmcp_slim/fastmcp/server/auth/ssrf.py index ae0bea3cc..1e240672c 100644 --- a/fastmcp_slim/fastmcp/server/auth/ssrf.py +++ b/fastmcp_slim/fastmcp/server/auth/ssrf.py @@ -22,7 +22,23 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) -NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network("64:ff9b::/96") +NAT64_PREFIXES: tuple[ + tuple[ipaddress.IPv6Network, tuple[tuple[int, int, int, int], ...]], ... +] = ( + (ipaddress.IPv6Network("64:ff9b::/96"), ((12, 13, 14, 15),)), + ( + ipaddress.IPv6Network("64:ff9b:1::/48"), + ( + (6, 7, 9, 10), + (7, 9, 10, 11), + (9, 10, 11, 12), + (12, 13, 14, 15), + ), + ), +) +LOW32_OFFSETS = (12, 13, 14, 15) +IPV4_TRANSLATED_PREFIX = ipaddress.IPv6Network("0:0:0:0:ffff:0:0:0/96") +ISATAP_INTERFACE_IDS = (b"\x00\x00\x5e\xfe", b"\x02\x00\x5e\xfe") def format_ip_for_url(ip_str: str) -> str: @@ -54,6 +70,39 @@ class SSRFFetchError(Exception): """Raised when SSRF-safe fetch fails.""" +def _embedded_ipv4_addresses( + ip: ipaddress.IPv6Address, +) -> set[ipaddress.IPv4Address]: + """Return IPv4 addresses embedded in known IPv6 transition forms.""" + candidates: set[ipaddress.IPv4Address] = set() + packed = ip.packed + + def from_offsets(offsets: tuple[int, int, int, int]) -> ipaddress.IPv4Address: + return ipaddress.IPv4Address(bytes(packed[i] for i in offsets)) + + if ip.ipv4_mapped: + candidates.add(ip.ipv4_mapped) + if ip.sixtofour: + candidates.add(ip.sixtofour) + if ip.teredo: + server, client = ip.teredo + candidates.update((server, client)) + if ip in IPV4_TRANSLATED_PREFIX: + candidates.add(from_offsets(LOW32_OFFSETS)) + + for prefix, offset_options in NAT64_PREFIXES: + if ip in prefix: + candidates.update(from_offsets(offsets) for offsets in offset_options) + + if int(ip) >> 32 == 0 and not ip.is_loopback and not ip.is_unspecified: + candidates.add(from_offsets(LOW32_OFFSETS)) + + if packed[8:12] in ISATAP_INTERFACE_IDS: + candidates.add(from_offsets(LOW32_OFFSETS)) + + return candidates + + def is_ip_allowed(ip_str: str) -> bool: """Check if an IP address is allowed (must be globally routable unicast). @@ -63,7 +112,7 @@ def is_ip_allowed(ip_str: str) -> bool: - Link-local (169.254.x, fe80::) - includes AWS metadata! - Reserved, unspecified - RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks - - NAT64 (64:ff9b::/96) - can point to internal networks + - IPv6 transition forms that embed blocked IPv4 targets Additionally blocks multicast addresses (not caught by is_global). @@ -78,26 +127,18 @@ def is_ip_allowed(ip_str: str) -> bool: except ValueError: return False + if isinstance(ip, ipaddress.IPv6Address): + if any( + not is_ip_allowed(str(embedded_ip)) + for embedded_ip in _embedded_ipv4_addresses(ip) + ): + return False + if not ip.is_global: return False # Block multicast (not caught by is_global for some ranges) - if ip.is_multicast: - return False - - # IPv6-specific checks for embedded IPv4 addresses - if isinstance(ip, ipaddress.IPv6Address): - if ip.ipv4_mapped: - return is_ip_allowed(str(ip.ipv4_mapped)) - if ip.sixtofour: - return is_ip_allowed(str(ip.sixtofour)) - if ip.teredo: - server, client = ip.teredo - return is_ip_allowed(str(server)) and is_ip_allowed(str(client)) - if ip in NAT64_WELL_KNOWN_PREFIX: - return is_ip_allowed(str(ipaddress.IPv4Address(ip.packed[-4:]))) - - return True + return not ip.is_multicast async def resolve_hostname(hostname: str, port: int = 443) -> list[str]: diff --git a/tests/server/auth/test_ssrf_protection.py b/tests/server/auth/test_ssrf_protection.py index 4c03b10fe..0236942f8 100644 --- a/tests/server/auth/test_ssrf_protection.py +++ b/tests/server/auth/test_ssrf_protection.py @@ -54,19 +54,44 @@ class TestIsIPAllowed: @pytest.mark.parametrize( "address", [ - pytest.param("64:ff9b::7f00:1", id="loopback"), - pytest.param("64:ff9b::0a00:1", id="private"), - pytest.param("64:ff9b::a9fe:a9fe", id="link-local"), - pytest.param("64:ff9b::6440:1", id="cgnat"), + pytest.param("64:ff9b::7f00:1", id="nat64-loopback"), + pytest.param("64:ff9b::0a00:1", id="nat64-private"), + pytest.param("64:ff9b::a9fe:a9fe", id="nat64-link-local"), + pytest.param("64:ff9b::6440:1", id="nat64-cgnat"), + pytest.param("64:ff9b:1::a9fe:a9fe", id="nat64-local-use-low32"), + pytest.param("64:ff9b:1:a9fe:a9:fe00::", id="nat64-local-use-48"), + pytest.param("::ffff:0:7f00:1", id="ipv4-translated-loopback"), + pytest.param("::ffff:0:0a00:1", id="ipv4-translated-private"), + pytest.param("::ffff:0:a9fe:a9fe", id="ipv4-translated-link-local"), + pytest.param("::ffff:0:6440:1", id="ipv4-translated-cgnat"), + pytest.param("::7f00:1", id="ipv4-compatible-loopback"), + pytest.param("::0a00:1", id="ipv4-compatible-private"), + pytest.param("::a9fe:a9fe", id="ipv4-compatible-link-local"), + pytest.param("::6440:1", id="ipv4-compatible-cgnat"), + pytest.param("2002:a9fe:a9fe::1", id="6to4-link-local"), + pytest.param("2606:4700::5efe:192.168.1.1", id="isatap-private"), + pytest.param( + "2606:4700::200:5efe:169.254.169.254", + id="isatap-link-local", + ), ], ) - def test_nat64_ipv6_blocked_if_embedded_ipv4_blocked(self, address: str): - """NAT64 IPv6 addresses should check the embedded IPv4.""" + def test_ipv6_transition_blocked_if_embedded_ipv4_blocked(self, address: str): + """IPv6 transition addresses should check the embedded IPv4.""" assert is_ip_allowed(address) is False - def test_nat64_ipv6_allowed_if_embedded_ipv4_allowed(self): - """NAT64 IPv6 addresses should stay allowed for public embedded IPv4.""" - assert is_ip_allowed("64:ff9b::0808:0808") is True + @pytest.mark.parametrize( + "address", + [ + pytest.param("64:ff9b::0808:0808", id="nat64"), + pytest.param("::ffff:0:0808:0808", id="ipv4-translated"), + pytest.param("::0808:0808", id="ipv4-compatible"), + pytest.param("2606:4700::5efe:8.8.8.8", id="isatap"), + ], + ) + def test_ipv6_transition_allowed_if_embedded_ipv4_allowed(self, address: str): + """IPv6 transition addresses should allow public embedded IPv4.""" + assert is_ip_allowed(address) is True class TestValidateURL: @@ -100,11 +125,21 @@ class TestValidateURL: with pytest.raises(SSRFError, match="blocked IP"): await validate_url("https://example.com/path") - async def test_nat64_private_ip_rejected(self): - """URLs resolving to NAT64-wrapped private IPs should be rejected.""" + @pytest.mark.parametrize( + "address", + [ + pytest.param("64:ff9b::0a00:1", id="nat64"), + pytest.param("64:ff9b:1:a9fe:a9:fe00::", id="nat64-local-use"), + pytest.param("::ffff:0:a9fe:a9fe", id="ipv4-translated"), + pytest.param("::a9fe:a9fe", id="ipv4-compatible"), + pytest.param("2606:4700::5efe:169.254.169.254", id="isatap"), + ], + ) + async def test_ipv6_transition_private_ip_rejected(self, address: str): + """URLs resolving to IPv6-wrapped private IPs should be rejected.""" with patch( "fastmcp.server.auth.ssrf.resolve_hostname", - return_value=["64:ff9b::0a00:1"], + return_value=[address], ): with pytest.raises(SSRFError, match="blocked IP"): await validate_url("https://example.com/path") From 47907e07675fb01f4fc895ae7f7f0d29d78108fb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:10:40 -0700 Subject: [PATCH 2/6] Fix ty 0.0.55 diagnostics and prefab-ui protocol version drift (#4428) --- fastmcp_slim/fastmcp/server/providers/base.py | 8 ++-- .../local_provider/local_provider.py | 8 ++-- .../server/providers/openapi/provider.py | 2 +- .../fastmcp/server/providers/proxy.py | 10 ++--- fastmcp_slim/fastmcp/server/server.py | 8 ++-- fastmcp_slim/fastmcp/utilities/json_schema.py | 4 +- fastmcp_slim/fastmcp/utilities/versions.py | 4 +- pyproject.toml | 2 +- tests/client/test_elicitation.py | 4 +- tests/client/test_elicitation_enums.py | 4 +- tests/client/test_streamable_http.py | 2 +- tests/test_apps_prefab.py | 14 +++---- uv.lock | 42 +++++++++---------- 13 files changed, 56 insertions(+), 56 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/providers/base.py b/fastmcp_slim/fastmcp/server/providers/base.py index 580e2821d..402dff351 100644 --- a/fastmcp_slim/fastmcp/server/providers/base.py +++ b/fastmcp_slim/fastmcp/server/providers/base.py @@ -388,7 +388,7 @@ class Provider: matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) async def _list_resources(self) -> Sequence[Resource]: """Return all available resources. @@ -419,7 +419,7 @@ class Provider: matching = [r for r in matching if version.matches(r.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Return all available resource templates. @@ -450,7 +450,7 @@ class Provider: matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) async def _list_prompts(self) -> Sequence[Prompt]: """Return all available prompts. @@ -481,7 +481,7 @@ class Provider: matching = [p for p in matching if version.matches(p.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) # ------------------------------------------------------------------------- # Task registration diff --git a/fastmcp_slim/fastmcp/server/providers/local_provider/local_provider.py b/fastmcp_slim/fastmcp/server/providers/local_provider/local_provider.py index 675ff0e63..f8d790a76 100644 --- a/fastmcp_slim/fastmcp/server/providers/local_provider/local_provider.py +++ b/fastmcp_slim/fastmcp/server/providers/local_provider/local_provider.py @@ -366,7 +366,7 @@ class LocalProvider( matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) async def _list_resources(self) -> Sequence[Resource]: """Return all resources.""" @@ -390,7 +390,7 @@ class LocalProvider( matching = [r for r in matching if version.matches(r.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Return all resource templates.""" @@ -416,7 +416,7 @@ class LocalProvider( matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) async def _list_prompts(self) -> Sequence[Prompt]: """Return all prompts.""" @@ -440,7 +440,7 @@ class LocalProvider( matching = [p for p in matching if version.matches(p.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) # ========================================================================= # Task registration diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py index dd9a73559..72e811d53 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/provider.py +++ b/fastmcp_slim/fastmcp/server/providers/openapi/provider.py @@ -425,7 +425,7 @@ class OpenAPIProvider(Provider): matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) async def _list_prompts(self) -> Sequence[Prompt]: """Return empty list - OpenAPI doesn't create prompts.""" diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index 1ff4db22e..3b3ccc265 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -656,7 +656,7 @@ class ProxyProvider(Provider): matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) # ------------------------------------------------------------------------- # Resource methods @@ -693,7 +693,7 @@ class ProxyProvider(Provider): matching = [r for r in matching if version.matches(r.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) # ------------------------------------------------------------------------- # Resource template methods @@ -730,7 +730,7 @@ class ProxyProvider(Provider): matching = [t for t in matching if version.matches(t.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) # ------------------------------------------------------------------------- # Prompt methods @@ -767,7 +767,7 @@ class ProxyProvider(Provider): matching = [p for p in matching if version.matches(p.version)] if not matching: return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] + return max(matching, key=version_sort_key) # ------------------------------------------------------------------------- # Task methods @@ -1103,7 +1103,7 @@ class ProxyClient(Client[ClientTransportT]): kwargs["log_handler"] = default_proxy_log_handler if "progress_handler" not in kwargs: kwargs["progress_handler"] = default_proxy_progress_handler - super().__init__(transport=transport, **kwargs) + super().__init__(transport=transport, **kwargs) # ty: ignore[no-matching-overload] # Enable forwarding of inbound HTTP headers (e.g. authorization) to # the upstream server. This is only appropriate for proxy clients, diff --git a/fastmcp_slim/fastmcp/server/server.py b/fastmcp_slim/fastmcp/server/server.py index d1e456e7e..b9ed14d6d 100644 --- a/fastmcp_slim/fastmcp/server/server.py +++ b/fastmcp_slim/fastmcp/server/server.py @@ -780,7 +780,7 @@ class FastMCP( if not authorized: return None - return cast(Tool, max(authorized, key=version_sort_key)) + return max(authorized, key=version_sort_key) async def list_resources( self, *, run_middleware: bool = True @@ -915,7 +915,7 @@ class FastMCP( if not authorized: return None - return cast(Resource, max(authorized, key=version_sort_key)) + return max(authorized, key=version_sort_key) async def list_resource_templates( self, *, run_middleware: bool = True @@ -1051,7 +1051,7 @@ class FastMCP( if not authorized: return None - return cast(ResourceTemplate, max(authorized, key=version_sort_key)) + return max(authorized, key=version_sort_key) async def list_prompts(self, *, run_middleware: bool = True) -> Sequence[Prompt]: """List all enabled prompts from providers. @@ -1173,7 +1173,7 @@ class FastMCP( if not authorized: return None - return cast(Prompt, max(authorized, key=version_sort_key)) + return max(authorized, key=version_sort_key) @overload async def call_tool( diff --git a/fastmcp_slim/fastmcp/utilities/json_schema.py b/fastmcp_slim/fastmcp/utilities/json_schema.py index ad78fdbf2..2cd604e78 100644 --- a/fastmcp_slim/fastmcp/utilities/json_schema.py +++ b/fastmcp_slim/fastmcp/utilities/json_schema.py @@ -531,7 +531,7 @@ def _single_pass_optimize( # this regardless of `in_schema` โ€” a $ref in a user extension # still pins the referenced $def as "used". if prune_defs: - ref = node.get("$ref") # type: ignore + ref = node.get("$ref") if isinstance(ref, str) and ref.startswith("#/$defs/"): referenced_def = ref.split("/")[-1] if current_def_name: @@ -565,7 +565,7 @@ def _single_pass_optimize( if ( prune_additional_properties - and node.get("additionalProperties") is False # type: ignore + and node.get("additionalProperties") is False ): node.pop("additionalProperties") # type: ignore diff --git a/fastmcp_slim/fastmcp/utilities/versions.py b/fastmcp_slim/fastmcp/utilities/versions.py index 028f687b5..a8a7a898b 100644 --- a/fastmcp_slim/fastmcp/utilities/versions.py +++ b/fastmcp_slim/fastmcp/utilities/versions.py @@ -17,7 +17,7 @@ from __future__ import annotations from collections.abc import Callable, Sequence from dataclasses import dataclass from functools import total_ordering -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar from packaging.version import InvalidVersion, Version @@ -325,7 +325,7 @@ def dedupe_with_versions( result: list[C] = [] for versions in by_key.values(): - highest: C = cast(C, max(versions, key=version_sort_key)) + highest: C = max(versions, key=version_sort_key) if any(c.version is not None for c in versions): all_versions = sorted( [c.version for c in versions if c.version is not None], diff --git a/pyproject.toml b/pyproject.toml index 32738a1e5..090e8488b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", "ruff>=0.12.8", - "ty>=0.0.39", + "ty>=0.0.55", "prek>=0.2.12", "loq>=0.1.0a3", "opentelemetry-exporter-otlp-proto-grpc>=1.39.0", diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index b5d16bf4d..2318efe98 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -319,7 +319,7 @@ class TestScalarResponseTypes: result = await context.elicit(message="", response_type=None) assert result.action == "accept" assert isinstance(result, AcceptedElicitation) - accepted = cast(AcceptedElicitation[dict[str, Any]], result) + accepted = result assert isinstance(accepted.data, dict) return accepted.data @@ -341,7 +341,7 @@ class TestScalarResponseTypes: result = await context.elicit(message="", response_type=None) assert result.action == "accept" assert isinstance(result, AcceptedElicitation) - accepted = cast(AcceptedElicitation[dict[str, Any]], result) + accepted = result assert isinstance(accepted.data, dict) return accepted.data diff --git a/tests/client/test_elicitation_enums.py b/tests/client/test_elicitation_enums.py index 6e8b1e7fd..d67e2b4f1 100644 --- a/tests/client/test_elicitation_enums.py +++ b/tests/client/test_elicitation_enums.py @@ -200,7 +200,7 @@ async def test_list_list_multi_select_untitled(): if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, list) - return ",".join(result.data) # type: ignore[no-matching-overload] # ty:ignore[no-matching-overload] + return ",".join(result.data) # type: ignore[no-matching-overload] return "declined" async def elicitation_handler(message, response_type, params, ctx): @@ -238,7 +238,7 @@ async def test_list_dict_multi_select_titled(): if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, list) - return ",".join(result.data) # type: ignore[no-matching-overload] # ty:ignore[no-matching-overload] + return ",".join(result.data) # type: ignore[no-matching-overload] return "declined" async def elicitation_handler(message, response_type, params, ctx): diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 388eaaa9b..9d5fd6616 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -31,7 +31,7 @@ def create_test_server() -> FastMCP: result = await ctx.elicit("What is your name?", response_type=str) if result.action == "accept": - return f"You said your name was: {result.data}!" # ty: ignore[unresolved-attribute] + return f"You said your name was: {result.data}!" else: return "No name provided" diff --git a/tests/test_apps_prefab.py b/tests/test_apps_prefab.py index 5f19e1982..72311b614 100644 --- a/tests/test_apps_prefab.py +++ b/tests/test_apps_prefab.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Annotated from mcp.types import TextContent -from prefab_ui.app import PrefabApp +from prefab_ui.app import PROTOCOL_VERSION, PrefabApp from prefab_ui.components import Column, Heading, Text from prefab_ui.components.base import Component @@ -39,7 +39,7 @@ class TestConvertResult: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "[Rendered Prefab UI]" assert result.structured_content is not None - assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION assert result.structured_content["state"] == {"name": "Alice"} # PrefabApp wraps view in a pf-app-root Div root = result.structured_content["view"] @@ -55,7 +55,7 @@ class TestConvertResult: assert isinstance(result, ToolResult) assert result.structured_content is not None - assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION assert result.structured_content["view"]["type"] == "Div" assert result.structured_content["view"]["children"][0]["type"] == "Heading" @@ -71,7 +71,7 @@ class TestConvertResult: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Custom fallback text" assert result.structured_content is not None - assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION assert result.structured_content["view"]["type"] == "Div" assert result.structured_content["view"]["children"][0]["type"] == "Heading" @@ -85,7 +85,7 @@ class TestConvertResult: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "My text" assert result.structured_content is not None - assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION assert result.structured_content["view"]["type"] == "Div" assert result.structured_content["view"]["children"][0]["type"] == "Heading" @@ -391,7 +391,7 @@ class TestIntegration: result = await client.call_tool("greet", {"name": "Alice"}) assert result.structured_content is not None - assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION assert result.structured_content["state"] == {"name": "Alice"} async def test_tool_call_with_custom_text(self): @@ -412,7 +412,7 @@ class TestIntegration: "Greeting for Alice" in c.text for c in result.content if hasattr(c, "text") ) assert result.structured_content is not None - assert result.structured_content["$prefab"]["version"] == "0.2" + assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION async def test_tools_list_includes_app_meta(self): mcp = FastMCP("test") diff --git a/uv.lock b/uv.lock index 7809504cd..02bfd8913 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-28T22:13:51.808463Z" exclude-newer-span = "P1W" [options.exclude-newer-package] @@ -936,7 +936,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.12.8" }, - { name = "ty", specifier = ">=0.0.39" }, + { name = "ty", specifier = ">=0.0.55" }, ] [[package]] @@ -3154,27 +3154,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.39" +version = "0.0.55" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/8d/7b5c74dc287fbcb37bae9853cec13bf44717c1735298500e4aeba31579a9/ty-0.0.39.tar.gz", hash = "sha256:f750277e76a01ecd86185960eca73823c26a53c51103568d56d4d904575159fd", size = 5702365, upload-time = "2026-05-22T21:09:56.403Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/17/9b89802c26d12d0f7a27bc25d4066d941d42891e8898f9f26499f0067e32/ty-0.0.39-py3-none-linux_armv6l.whl", hash = "sha256:c1bb7ac70f1f7d70cc6655fd96558039e4562b10f489fa49c7ebfd5fcee73ad1", size = 11360431, upload-time = "2026-05-22T21:09:18.689Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c6/663ded50e823dbf9fb9d002eca46b7cb1fb2c72b744b84f22ce732a0ee0b/ty-0.0.39-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3435b64c1e59c14c9aa39c20cc018823937cd38d55db853e74d95b8f420569b0", size = 11096281, upload-time = "2026-05-22T21:09:15.383Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ae/5d38ba9a6456ff4c78d212cf464fd8b9a25d8118465197b0b2dc891c0b19/ty-0.0.39-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5f136377ce46c73677701a9e1ad730bf72f699bcec046e422eb79d0886cac3ab", size = 10529674, upload-time = "2026-05-22T21:09:46.471Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/43638cb8106445d3c8817256a0731cde9dd7b6a53ae2e881294bc1930ca3/ty-0.0.39-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b65fb0cc17f03e851d40e210d420be94ab8bc52d041328ad1e45f616036a61", size = 11055561, upload-time = "2026-05-22T21:09:36.981Z" }, - { url = "https://files.pythonhosted.org/packages/91/17/95e62cf4458527ce78dc386eba18f8b10c3fb64cd8c9e7e59b262ff6029d/ty-0.0.39-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4967967bfadf3860ff84c3fccdbaec8edf8aa20d0d727521084733d853de6657", size = 11127185, upload-time = "2026-05-22T21:09:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c0/93666c213db5c71ab1b1f1a0db5f66bf8c7c0e0b0bf59859f5da8f0b3c36/ty-0.0.39-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e10ecb1297099ddf9a1f054f8bd921d1863ce85fb819a3c96ed27865a1ba6ed", size = 11608459, upload-time = "2026-05-22T21:09:12.862Z" }, - { url = "https://files.pythonhosted.org/packages/79/85/3b26585afc8b50230d6464bb0642feef4fab3f847e38b1f0ffa971a81446/ty-0.0.39-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b19cca70e465d71b0510656343883d62372bbe74b7845cae7c0e701d6d5264b", size = 12177101, upload-time = "2026-05-22T21:09:40.519Z" }, - { url = "https://files.pythonhosted.org/packages/49/4a/1039e4f6afc576dc1c3a4d22a6478904a1ad3766597cd0b93c077ab9dfce/ty-0.0.39-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56c6704b01b9b3d80ff26b2918423b742516d1e469bef830e9254dcedc9185bf", size = 11827815, upload-time = "2026-05-22T21:09:49.89Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c5/4688652870e350a76a8157f7ffb59ad54f37d5d10725aa7076f66ac94ec8/ty-0.0.39-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b7840ff46764b6a6757f4ade1cd0530fc3e8a0b435ca93e7602360e4cb90b6", size = 11694429, upload-time = "2026-05-22T21:09:21.568Z" }, - { url = "https://files.pythonhosted.org/packages/fc/72/8a1c4e823bb5bdc935a1c8140e100304e36a68a4139592f170aa9736fdb7/ty-0.0.39-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c62a3a87ce26b50819f0dbf03bd95f23f19eeb87bbc7aa732ec64277c77f1aa", size = 11869846, upload-time = "2026-05-22T21:09:28.053Z" }, - { url = "https://files.pythonhosted.org/packages/17/9f/cf982457b861ae22d657c5dcdbc631199f7f90264279db1d17230dfbc3ff/ty-0.0.39-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f8c34bc81a9c3516e49904e9d8330aac385377cca98390193ea02b903a40fcf0", size = 11029763, upload-time = "2026-05-22T21:09:06.791Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/95b64f6d43ae6e8f0b7e13dacf9c196d35819af22b1924171fba31383156/ty-0.0.39-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:66f5ab11586a64e79cb692ad685ee5469325c31b5f30bd3554f52f36dbe28cc4", size = 11146761, upload-time = "2026-05-22T21:09:10.178Z" }, - { url = "https://files.pythonhosted.org/packages/52/69/0a89cfb06f7632a05bf56c78e0affb4a40f81759e275376cea75c9c5abe9/ty-0.0.39-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e8d89732bcbbcb091f439e556dfc4932f198b118b47d5b85212c60662099670e", size = 11281843, upload-time = "2026-05-22T21:09:34.234Z" }, - { url = "https://files.pythonhosted.org/packages/0e/53/64c4a27067a46643fea2b3fcf21a8a2f838d91a65ffdd14f2e82945b9538/ty-0.0.39-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:eceb6c91dcd05a231119f82abdd9aa337513de23ca6ac990bc44f88791dc1799", size = 11792477, upload-time = "2026-05-22T21:09:24.923Z" }, - { url = "https://files.pythonhosted.org/packages/1a/e8/02f4dd4a12bcdbda0006f9c7ff3b99a4be06bd0d257d3bd4a5b66de074e6/ty-0.0.39-py3-none-win32.whl", hash = "sha256:891c3262314dbc80bf3e872634d23dd216306945daa9a9fcc206ce5ed21ac4c9", size = 10615377, upload-time = "2026-05-22T21:09:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/b5/5a/aaeb22faa8d4dae90a287d4c3636c671edcff3b99be5f4fc8b79ad71eef6/ty-0.0.39-py3-none-win_amd64.whl", hash = "sha256:ba7f2d54452535419e90f6f03ff39282999e87b43c21c00559f6d7ad711a36d5", size = 11710711, upload-time = "2026-05-22T21:09:53.179Z" }, - { url = "https://files.pythonhosted.org/packages/a3/17/ae7339651bfcaa5f54698c8c70eaf5031baa400ecb67baec31d03a56cbd4/ty-0.0.39-py3-none-win_arm64.whl", hash = "sha256:eb4cf0fefbbfedf9a352597bb2431ebdcb7eb3a595c0f825f228e897a0ec285d", size = 11081409, upload-time = "2026-05-22T21:09:03.741Z" }, + { url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" }, + { url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" }, + { url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" }, ] [[package]] From 691766b5d0393c3cd1158de2ed4465f3bf57b659 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:11:03 -0700 Subject: [PATCH 3/6] [codex] Fix OpenAPI resource template requests (#4407) --- .../server/providers/openapi/components.py | 66 ++-- .../fastmcp/utilities/openapi/schemas.py | 9 +- .../openapi/test_openapi_features.py | 310 ++++++++++++++++++ .../openapi/test_legacy_compatibility.py | 41 +++ 4 files changed, 393 insertions(+), 33 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/providers/openapi/components.py b/fastmcp_slim/fastmcp/server/providers/openapi/components.py index 5d8cee1f4..466c1bf1d 100644 --- a/fastmcp_slim/fastmcp/server/providers/openapi/components.py +++ b/fastmcp_slim/fastmcp/server/providers/openapi/components.py @@ -269,6 +269,7 @@ class OpenAPIResource(Resource): description: str, mime_type: str = "application/json", tags: set[str] | None = None, + arguments: dict[str, Any] | None = None, ): super().__init__( uri=AnyUrl(uri), @@ -280,6 +281,7 @@ class OpenAPIResource(Resource): self._client = client self._route = route self._director = director + self._arguments = dict(arguments or {}) def __repr__(self) -> str: return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})" @@ -287,40 +289,23 @@ class OpenAPIResource(Resource): async def read(self) -> ResourceResult: """Fetch the resource data by making an HTTP request.""" try: - path = self._route.path - resource_uri = str(self.uri) - - # If this is a templated resource, extract path parameters from the URI - if "{" in path and "}" in path: - parts = resource_uri.split("/") - - if len(parts) > 1: - path_params = {} - param_matches = re.findall(r"\{([^}]+)\}", path) - if param_matches: - param_matches.sort(reverse=True) - expected_param_count = len(parts) - 1 - for i, param_name in enumerate(param_matches): - if i < expected_param_count: - param_value = parts[-1 - i] - path_params[param_name] = param_value - - for param_name, param_value in path_params.items(): - path = path.replace(f"{{{param_name}}}", str(param_value)) - - # Build headers with correct precedence - headers: dict[str, str] = {} - if self._client.headers: - headers.update(self._client.headers) + base_url = str(self._client.base_url) or "http://localhost" + directed_request = self._director.build( + self._route, self._arguments, base_url + ) + request = self._client.build_request( + method=directed_request.method, + url=directed_request.url.copy_with(query=None), + params=directed_request.url.params, + headers=directed_request.headers, + content=directed_request.content, + extensions=directed_request.extensions, + ) mcp_headers = get_http_headers() if mcp_headers: - headers.update(mcp_headers) + request.headers.update(mcp_headers) - response = await self._client.request( - method=self._route.method, - url=path, - headers=headers, - ) + response = await self._client.send(request) response.raise_for_status() content_type = response.headers.get("content-type", "").lower() @@ -368,6 +353,13 @@ class OpenAPIResource(Resource): raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e +def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str: + for argument_name, mapping in route.parameter_map.items(): + if mapping["location"] == "path" and mapping["openapi_name"] == parameter_name: + return argument_name + return parameter_name + + class OpenAPIResourceTemplate(ResourceTemplate): """Resource template implementation for OpenAPI endpoints.""" @@ -408,6 +400,17 @@ class OpenAPIResourceTemplate(ResourceTemplate): ) -> Resource: """Create a resource with the given parameters.""" uri_parts = [f"{key}={value}" for key, value in params.items()] + arguments = {} + for parameter in self._route.parameters: + if parameter.location != "path": + continue + argument_name = _path_argument_name(self._route, parameter.name) + if parameter.name in params: + arguments[argument_name] = params[parameter.name] + continue + normalized_name = parameter.name.replace("-", "_") + if normalized_name in params: + arguments[argument_name] = params[normalized_name] return OpenAPIResource( client=self._client, @@ -418,4 +421,5 @@ class OpenAPIResourceTemplate(ResourceTemplate): description=self.description or f"Resource for {self._route.path}", mime_type=self.mime_type, tags=set(self._route.tags or []), + arguments=arguments, ) diff --git a/fastmcp_slim/fastmcp/utilities/openapi/schemas.py b/fastmcp_slim/fastmcp/utilities/openapi/schemas.py index dd2df6010..5980621c2 100644 --- a/fastmcp_slim/fastmcp/utilities/openapi/schemas.py +++ b/fastmcp_slim/fastmcp/utilities/openapi/schemas.py @@ -1,5 +1,6 @@ """Schema manipulation utilities for OpenAPI operations.""" +from collections import Counter from typing import Any from fastmcp.utilities.logging import get_logger @@ -294,13 +295,17 @@ def _combine_schemas_and_map_params( body_props = body_schema.get("properties", {}) - # Detect collisions: parameters that exist in both body and path/query/header + # Detect collisions: parameters that exist in multiple non-body locations + # or between body and path/query/header/cookie. all_non_body_params = set() for location_params in param_names_by_location.values(): all_non_body_params.update(location_params) body_param_names = set(body_props.keys()) - colliding_params = all_non_body_params & body_param_names + non_body_param_counts = Counter(param.name for param in route.parameters) + colliding_params = (all_non_body_params & body_param_names) | { + name for name, count in non_body_param_counts.items() if count > 1 + } # Add parameters with suffixes for collisions for param in route.parameters: diff --git a/tests/server/providers/openapi/test_openapi_features.py b/tests/server/providers/openapi/test_openapi_features.py index ac78f1b10..56b8adac2 100644 --- a/tests/server/providers/openapi/test_openapi_features.py +++ b/tests/server/providers/openapi/test_openapi_features.py @@ -1,5 +1,6 @@ """Tests for OpenAPI feature support in OpenAPIProvider.""" +from typing import Any from unittest.mock import AsyncMock, Mock import httpx @@ -705,6 +706,315 @@ class TestResourceTemplateMimeType: assert templates[0].mimeType == "application/json" +class TestResourceTemplateRequestBuilding: + @pytest.fixture + def path_param_spec(self) -> dict[str, Any]: + return { + "openapi": "3.0.0", + "info": {"title": "User API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com/api/v1"}], + "paths": { + "/users/{id}": { + "get": { + "operationId": "get_user", + "summary": "Get user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": { + "200": { + "description": "User data", + "content": { + "application/json": {"schema": {"type": "object"}} + }, + } + }, + } + } + }, + } + + async def test_resource_template_encodes_matched_path_params( + self, path_param_spec: dict[str, Any] + ): + seen_urls: list[httpx.URL] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen_urls.append(request.url) + return httpx.Response(200, json={"ok": True}) + + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient( + base_url="https://api.example.com/api/v1", + transport=httpx.MockTransport(handler), + ) as client: + provider = OpenAPIProvider( + openapi_spec=path_param_spec, + client=client, + route_maps=route_maps, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + await mcp_client.read_resource( + "resource://get_user/..%2F..%2Fadmin%2Fsecret" + ) + + assert seen_urls == [ + httpx.URL( + "https://api.example.com/api/v1/users/%2E%2E%2F%2E%2E%2Fadmin%2Fsecret" + ) + ] + + async def test_resource_template_ignores_unmatched_query_string( + self, path_param_spec: dict[str, Any] + ): + seen_urls: list[httpx.URL] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen_urls.append(request.url) + return httpx.Response(200, json={"ok": True}) + + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient( + base_url="https://api.example.com/api/v1", + transport=httpx.MockTransport(handler), + ) as client: + provider = OpenAPIProvider( + openapi_spec=path_param_spec, + client=client, + route_maps=route_maps, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + await mcp_client.read_resource("resource://get_user/alice?admin=true") + + assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/alice")] + + async def test_resource_template_preserves_hyphenated_path_params(self): + seen_urls: list[httpx.URL] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen_urls.append(request.url) + return httpx.Response(200, json={"ok": True}) + + spec = { + "openapi": "3.0.0", + "info": {"title": "User API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com/api/v1"}], + "paths": { + "/users/{user-id}": { + "get": { + "operationId": "get_user", + "parameters": [ + { + "name": "user-id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": { + "200": { + "description": "User data", + "content": { + "application/json": {"schema": {"type": "object"}} + }, + } + }, + } + } + }, + } + + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient( + base_url="https://api.example.com/api/v1", + transport=httpx.MockTransport(handler), + ) as client: + provider = OpenAPIProvider( + openapi_spec=spec, + client=client, + route_maps=route_maps, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + await mcp_client.read_resource("resource://get_user/abc") + + assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")] + + async def test_resource_template_preserves_client_defaults( + self, path_param_spec: dict[str, Any] + ): + seen_requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen_requests.append(request) + return httpx.Response(200, json={"ok": True}) + + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient( + base_url="https://api.example.com/api/v1", + params={"api-version": "2026-06-29"}, + cookies={"session": "abc123"}, + transport=httpx.MockTransport(handler), + ) as client: + provider = OpenAPIProvider( + openapi_spec=path_param_spec, + client=client, + route_maps=route_maps, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + await mcp_client.read_resource("resource://get_user/alice") + + assert seen_requests[0].url == httpx.URL( + "https://api.example.com/api/v1/users/alice?api-version=2026-06-29" + ) + assert seen_requests[0].headers["cookie"] == "session=abc123" + + async def test_resource_template_uses_mapped_path_argument_names(self): + seen_urls: list[httpx.URL] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen_urls.append(request.url) + return httpx.Response(200, json={"ok": True}) + + spec = { + "openapi": "3.0.0", + "info": {"title": "User API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com/api/v1"}], + "paths": { + "/users/{id}": { + "get": { + "operationId": "get_user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + }, + } + } + } + }, + "responses": { + "200": { + "description": "User data", + "content": { + "application/json": {"schema": {"type": "object"}} + }, + } + }, + } + } + }, + } + + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient( + base_url="https://api.example.com/api/v1", + transport=httpx.MockTransport(handler), + ) as client: + provider = OpenAPIProvider( + openapi_spec=spec, + client=client, + route_maps=route_maps, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + await mcp_client.read_resource("resource://get_user/abc") + + assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")] + + async def test_resource_template_uses_path_arg_when_query_param_has_same_name( + self, + ): + seen_urls: list[httpx.URL] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + seen_urls.append(request.url) + return httpx.Response(200, json={"ok": True}) + + spec = { + "openapi": "3.0.0", + "info": {"title": "User API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com/api/v1"}], + "paths": { + "/users/{id}": { + "get": { + "operationId": "get_user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + }, + { + "name": "id", + "in": "query", + "required": False, + "schema": {"type": "string"}, + }, + ], + "responses": { + "200": { + "description": "User data", + "content": { + "application/json": {"schema": {"type": "object"}} + }, + } + }, + } + } + }, + } + + route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)] + async with httpx.AsyncClient( + base_url="https://api.example.com/api/v1", + transport=httpx.MockTransport(handler), + ) as client: + provider = OpenAPIProvider( + openapi_spec=spec, + client=client, + route_maps=route_maps, + ) + mcp = FastMCP("Test") + mcp.add_provider(provider) + + async with Client(mcp) as mcp_client: + await mcp_client.read_resource("resource://get_user/abc") + + assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")] + + class TestResourceMimeType: """Test that OpenAPIResource uses inferred MIME types.""" diff --git a/tests/utilities/openapi/test_legacy_compatibility.py b/tests/utilities/openapi/test_legacy_compatibility.py index a40ee6331..227dca297 100644 --- a/tests/utilities/openapi/test_legacy_compatibility.py +++ b/tests/utilities/openapi/test_legacy_compatibility.py @@ -102,6 +102,47 @@ class TestSchemaGeneration: assert param_map["id"]["location"] == "body" assert param_map["name"]["location"] == "body" + def test_non_body_parameter_collision_handling(self): + """Test that same-named parameters in different locations use suffixes.""" + route = HTTPRoute( + method="GET", + path="/users/{id}", + operation_id="get_user", + parameters=[ + ParameterInfo( + name="id", + location="path", + required=True, + schema={"type": "string"}, + ), + ParameterInfo( + name="id", + location="query", + required=False, + schema={"type": "string"}, + ), + ParameterInfo( + name="id", + location="header", + required=False, + schema={"type": "string"}, + ), + ], + ) + + schema, param_map = _combine_schemas_and_map_params(route) + + properties = schema["properties"] + assert "id" not in properties + assert "id__path" in properties + assert "id__query" in properties + assert "id__header" in properties + + assert schema["required"] == ["id__path"] + assert param_map["id__path"] == {"location": "path", "openapi_name": "id"} + assert param_map["id__query"] == {"location": "query", "openapi_name": "id"} + assert param_map["id__header"] == {"location": "header", "openapi_name": "id"} + @pytest.mark.parametrize( "param_type", [ From 874425a113ba217f659b5e67cddd2e56adbd6320 Mon Sep 17 00:00:00 2001 From: "marvin-context-protocol[bot]" <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:16:03 -0400 Subject: [PATCH 4/6] chore: Update SDK documentation (#4427) --- docs/python-sdk/fastmcp-utilities-types.mdx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/python-sdk/fastmcp-utilities-types.mdx b/docs/python-sdk/fastmcp-utilities-types.mdx index e1379ae4b..7f1b03022 100644 --- a/docs/python-sdk/fastmcp-utilities-types.mdx +++ b/docs/python-sdk/fastmcp-utilities-types.mdx @@ -85,14 +85,13 @@ replace_type(type_, type_map: dict[type, type]) Given a (possibly generic, nested, or otherwise complex) type, replaces all -instances of old_type with new_type. +instances of keys in type_map with their corresponding values. This is useful for transforming types when creating tools. **Args:** -- `type_`: The type to replace instances of old_type with new_type. -- `old_type`: The type to replace. -- `new_type`: The type to replace old_type with. +- `type_`: The type to transform. +- `type_map`: A mapping of types to replace (keys are replaced by values). Examples: ```python @@ -166,4 +165,4 @@ Helper class for returning file data from tools. to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.EmbeddedResource ``` -### `ContextSamplingFallbackProtocol` +### `ContextSamplingFallbackProtocol` From 3b1afe6cf19c52f54aa6cb050f48594a6a658501 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:16:30 -0400 Subject: [PATCH 5/6] chore(deps): bump joserfc from 1.6.7 to 1.6.8 in the uv group across 1 directory (#4429) Signed-off-by: dependabot[bot] --- uv.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uv.lock b/uv.lock index 02bfd8913..a69f50d9f 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-06-28T22:13:51.808463Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P1W" [options.exclude-newer-package] @@ -1541,14 +1541,14 @@ wheels = [ [[package]] name = "joserfc" -version = "1.6.7" +version = "1.6.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/cb/52e479f20804904f5df20ac4539d292dcecd1287aaa33cba1d1def1d9d8e/joserfc-1.6.7.tar.gz", hash = "sha256:6999fe89457069ecacd8cc797c88a805f83054dd883333fa0409f74b46479fd7", size = 232158, upload-time = "2026-05-23T01:46:44.069Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/e4/bcf6718b5662894c6831f46296b73cd4b1a2e90c20b6d437e20c4997388c/joserfc-1.6.7-py3-none-any.whl", hash = "sha256:9e51e4a64840aa1734a058258e80a4480e2ff2d5686e480e7c92c954a92fbe05", size = 70603, upload-time = "2026-05-23T01:46:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" }, ] [[package]] From 1eedd1f6f1402d9b93a6c8197bfe3106c7292894 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:27:01 -0700 Subject: [PATCH 6/6] Docs: add v3.4.2 and v3.4.3 changelog entries (#4430) --- docs/changelog.mdx | 85 ++++++++++++++++++++++++++++++++++++++++++++++ docs/updates.mdx | 28 +++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 586de7715..373f76ab9 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,91 @@ rss: true tag: NEW --- + + +**[v3.4.3: The Fast and the Secure-ious](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.3)** + +FastMCP 3.4.3 closes out a month of SSRF and OAuth hardening: NAT64, 6to4, Teredo, and ISATAP transition addresses can no longer smuggle private IPv4 targets past the SSRF allow-list, Streamable HTTP now validates Host and Origin before session handling to block DNS rebinding against localhost-bound servers, and OAuth redirect validation rejects unsafe schemes and unregistered DCR redirect URIs. Alongside the security work, this release also fixes proxy session teardown races, discriminator-tag handling in JSON schema conversion, and several smaller reliability issues. + +### Enhancements โœจ +* Dedupe discriminator-required helper across schema converters by [@jlowin](https://github.com/jlowin) in [#4362](https://github.com/PrefectHQ/fastmcp/pull/4362) +* Add real Monty sandbox e2e coverage for CodeMode call_tool by [@AlexlaGuardia](https://github.com/AlexlaGuardia) in [#4274](https://github.com/PrefectHQ/fastmcp/pull/4274) +* Switch prettier hook to rbubley/mirrors-prettier by [@jlowin](https://github.com/jlowin) in [#4366](https://github.com/PrefectHQ/fastmcp/pull/4366) +* feat(remote): add --verify flag for TLS certificate verification by [@jlowin](https://github.com/jlowin) in [#4369](https://github.com/PrefectHQ/fastmcp/pull/4369) +### Security ๐Ÿ”’ +* fix(deps): clear Dependabot security alerts via lockfile bumps by [@jlowin](https://github.com/jlowin) in [#4393](https://github.com/PrefectHQ/fastmcp/pull/4393) +* Clarify resource path parameter safety by [@jlowin](https://github.com/jlowin) in [#4398](https://github.com/PrefectHQ/fastmcp/pull/4398) +* Fix dev apps launch escaping by [@jlowin](https://github.com/jlowin) in [#4399](https://github.com/PrefectHQ/fastmcp/pull/4399) +* Block NAT64 SSRF bypass by [@jlowin](https://github.com/jlowin) in [#4400](https://github.com/PrefectHQ/fastmcp/pull/4400) +* [codex] Fix event store replay isolation by [@jlowin](https://github.com/jlowin) in [#4402](https://github.com/PrefectHQ/fastmcp/pull/4402) +* Fix DCR redirect URI validation by [@jlowin](https://github.com/jlowin) in [#4408](https://github.com/PrefectHQ/fastmcp/pull/4408) +* Protect streamable HTTP from DNS rebinding by [@jlowin](https://github.com/jlowin) in [#4405](https://github.com/PrefectHQ/fastmcp/pull/4405) +* Block unsafe OAuth redirect schemes by [@jlowin](https://github.com/jlowin) in [#4419](https://github.com/PrefectHQ/fastmcp/pull/4419) +* Block IPv6 transition SSRF bypasses by [@jlowin](https://github.com/jlowin) in [#4426](https://github.com/PrefectHQ/fastmcp/pull/4426) +### Fixes ๐Ÿž +* fix: caching middleware TypeError on cache miss due to mismatched call_next parameter by [@gmenziesint](https://github.com/gmenziesint) in [#4301](https://github.com/PrefectHQ/fastmcp/pull/4301) +* Fix: async rate limiting middleware get_client_id callbacks by [@Chotom](https://github.com/Chotom) in [#4319](https://github.com/PrefectHQ/fastmcp/pull/4319) +* Recognize all GitHub issue-link forms in require-issue-link workflow by [@jlowin](https://github.com/jlowin) in [#4359](https://github.com/PrefectHQ/fastmcp/pull/4359) +* fix: preserve required discriminator tags by [@he-yufeng](https://github.com/he-yufeng) in [#4297](https://github.com/PrefectHQ/fastmcp/pull/4297) +* fix(proxy): shield stateful proxy disconnect during session teardown by [@jlowin](https://github.com/jlowin) in [#4363](https://github.com/PrefectHQ/fastmcp/pull/4363) +* fix(fs): isolate same-named package imports across providers by [@jlowin](https://github.com/jlowin) in [#4361](https://github.com/PrefectHQ/fastmcp/pull/4361) +* fix: StatefulProxyClient.clear() no longer causes KeyError on session teardown by [@tcconnally](https://github.com/tcconnally) in [#4328](https://github.com/PrefectHQ/fastmcp/pull/4328) +* fix: guard recursive refs in json_schema_to_type by [@Epochex](https://github.com/Epochex) in [#4312](https://github.com/PrefectHQ/fastmcp/pull/4312) +* Forward IdP auth errors to MCP client instead of showing HTML error page by [@bobbyjames839](https://github.com/bobbyjames839) in [#4293](https://github.com/PrefectHQ/fastmcp/pull/4293) +* fix(resources): round-trip path values with reserved characters in URI templates by [@jlowin](https://github.com/jlowin) in [#4368](https://github.com/PrefectHQ/fastmcp/pull/4368) +* fix: bracket IPv6 hosts in server startup log URL by [@jlowin](https://github.com/jlowin) in [#4372](https://github.com/PrefectHQ/fastmcp/pull/4372) +* fix: bound default OIDC discovery timeout and expose it on provider wrappers by [@jlowin](https://github.com/jlowin) in [#4374](https://github.com/PrefectHQ/fastmcp/pull/4374) +* fix: validate task tool arguments against declared types by [@jlowin](https://github.com/jlowin) in [#4373](https://github.com/PrefectHQ/fastmcp/pull/4373) +* fix(tools): honor serialize_by_alias in tool result serialization by [@jlowin](https://github.com/jlowin) in [#4391](https://github.com/PrefectHQ/fastmcp/pull/4391) +* Fix/cimd flow issue by [@twjackysu](https://github.com/twjackysu) in [#4206](https://github.com/PrefectHQ/fastmcp/pull/4206) +* Reject empty env var keys by [@CodingFeng101](https://github.com/CodingFeng101) in [#4410](https://github.com/PrefectHQ/fastmcp/pull/4410) +* fix: correct replace_type docstring parameter descriptions by [@hiSandog](https://github.com/hiSandog) in [#4375](https://github.com/PrefectHQ/fastmcp/pull/4375) +* Fix ty 0.0.55 diagnostics and prefab-ui protocol version drift by [@jlowin](https://github.com/jlowin) in [#4428](https://github.com/PrefectHQ/fastmcp/pull/4428) +* [codex] Fix OpenAPI resource template requests by [@jlowin](https://github.com/jlowin) in [#4407](https://github.com/PrefectHQ/fastmcp/pull/4407) +### Docs ๐Ÿ“š +* fix: RST docstrings in fastmcp.types render raw on gofastmcp.com by [@jlowin](https://github.com/jlowin) in [#4367](https://github.com/PrefectHQ/fastmcp/pull/4367) +* docs: fix 5 broken internal links (auth & providers pages) by [@Michael-WhiteCapData](https://github.com/Michael-WhiteCapData) in [#4344](https://github.com/PrefectHQ/fastmcp/pull/4344) +* docs: add audit/event-record recipe for tool-call middleware by [@AlexlaGuardia](https://github.com/AlexlaGuardia) in [#4345](https://github.com/PrefectHQ/fastmcp/pull/4345) +### Dependencies ๐Ÿ“ฆ +* chore(deps): bump actions/checkout from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4343](https://github.com/PrefectHQ/fastmcp/pull/4343) +* chore(deps): bump joserfc from 1.6.5 to 1.6.7 in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4394](https://github.com/PrefectHQ/fastmcp/pull/4394) +* chore(deps): bump joserfc from 1.6.7 to 1.6.8 in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4429](https://github.com/PrefectHQ/fastmcp/pull/4429) +### Other Changes ๐Ÿฆพ +* Raise fastmcp.ValidationError for invalid tool arguments by [@jlowin](https://github.com/jlowin) in [#4392](https://github.com/PrefectHQ/fastmcp/pull/4392) +* Fix versioned auth middleware checks by [@jlowin](https://github.com/jlowin) in [#4401](https://github.com/PrefectHQ/fastmcp/pull/4401) + +## New Contributors +* @gmenziesint made their first contribution in [#4301](https://github.com/PrefectHQ/fastmcp/pull/4301) +* @Chotom made their first contribution in [#4319](https://github.com/PrefectHQ/fastmcp/pull/4319) +* @he-yufeng made their first contribution in [#4297](https://github.com/PrefectHQ/fastmcp/pull/4297) +* @AlexlaGuardia made their first contribution in [#4274](https://github.com/PrefectHQ/fastmcp/pull/4274) +* @tcconnally made their first contribution in [#4328](https://github.com/PrefectHQ/fastmcp/pull/4328) +* @Epochex made their first contribution in [#4312](https://github.com/PrefectHQ/fastmcp/pull/4312) +* @Michael-WhiteCapData made their first contribution in [#4344](https://github.com/PrefectHQ/fastmcp/pull/4344) +* @bobbyjames839 made their first contribution in [#4293](https://github.com/PrefectHQ/fastmcp/pull/4293) +* @twjackysu made their first contribution in [#4206](https://github.com/PrefectHQ/fastmcp/pull/4206) +* @CodingFeng101 made their first contribution in [#4410](https://github.com/PrefectHQ/fastmcp/pull/4410) +* @hiSandog made their first contribution in [#4375](https://github.com/PrefectHQ/fastmcp/pull/4375) + +**Full Changelog**: [v3.4.2...v3.4.3](https://github.com/PrefectHQ/fastmcp/compare/v3.4.2...v3.4.3) + + + + + +**[v3.4.2: Heads Up](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.2)** + +FastMCP 3.4.2 restores JWT compatibility for providers that include private, non-critical JWS header parameters. Tokens from providers like Clerk can carry header metadata such as `cat` without being rejected before signature and claim validation, while unsupported critical headers are still rejected. + +### Fixes ๐Ÿž +* Allow private JWT headers by [@jlowin](https://github.com/jlowin) in [#4290](https://github.com/PrefectHQ/fastmcp/pull/4290) +### Docs ๐Ÿ“š +* Docs: add v3.4.1 changelog entries by [@jlowin](https://github.com/jlowin) in [#4289](https://github.com/PrefectHQ/fastmcp/pull/4289) + +**Full Changelog**: [v3.4.1...v3.4.2](https://github.com/PrefectHQ/fastmcp/compare/v3.4.1...v3.4.2) + + + **[v3.4.1: Floor It](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.1)** diff --git a/docs/updates.mdx b/docs/updates.mdx index 9bdd5c77e..18e8efe2f 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -5,6 +5,34 @@ icon: "sparkles" tag: NEW --- + + +A month of SSRF and OAuth hardening lands in one patch. NAT64, 6to4, Teredo, and ISATAP transition addresses can no longer smuggle private IPv4 targets past the SSRF allow-list, Streamable HTTP validates Host and Origin before session handling to block DNS rebinding, and OAuth redirect validation rejects unsafe schemes and unregistered DCR redirect URIs. + +๐Ÿ›ก๏ธ **SSRF allow-list hardening** โ€” every IPv6 transition form (NAT64, 6to4, Teredo, ISATAP) now unwraps to its embedded IPv4 target and gets checked against the same policy. + +๐ŸŒ **DNS rebinding protection** โ€” Streamable HTTP validates `Host` and browser `Origin` before session handling, closing a path to localhost-bound unauthenticated servers. + +๐Ÿ” **Stricter OAuth redirects** โ€” unsafe redirect schemes are rejected before registration, and DCR clients are bound to the redirect URIs they registered. + +๐Ÿงต **Reliability fixes** โ€” proxy session teardown races, discriminator-tag handling in JSON schema conversion, and several smaller fixes across middleware and resource templates. + + + + + +A compatibility patch. `JWTVerifier` now accepts JWTs carrying private, non-critical JWS header parameters (like Clerk's `cat`) instead of rejecting them before signature and claim validation, while unsupported critical headers are still rejected. + + +