diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index d9e472e362..e7f2e047f3 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -139,7 +139,10 @@ def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> boo if is_binary: return True mime_type = info.data.get("mime_type", "text/plain") - return not mime_type.startswith("text/") + # Media types are case-insensitive (RFC 9110, section 8.3.1), so normalize + # before the prefix check — otherwise e.g. "Text/Markdown" is misclassified + # as binary and read as bytes instead of text. + return not mime_type.lower().startswith("text/") async def read(self) -> str | bytes: """Read the file content.""" diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 9103996a52..51e14cd7ce 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -411,7 +411,9 @@ def _check_accept_headers(self, request: Request) -> tuple[bool, bool]: def _check_content_type(self, request: Request) -> bool: """Check if the request has the correct Content-Type.""" content_type = request.headers.get("content-type", "") - content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")] + # Media types are case-insensitive (RFC 9110, section 8.3.1), so normalize + # to lower case before comparing — consistent with _check_accept_headers. + content_type_parts = [part.strip().lower() for part in content_type.split(";")[0].split(",")] return any(part == CONTENT_TYPE_JSON for part in content_type_parts) diff --git a/tests/server/mcpserver/resources/test_file_resources.py b/tests/server/mcpserver/resources/test_file_resources.py index 94885113a9..05a6d35d7f 100644 --- a/tests/server/mcpserver/resources/test_file_resources.py +++ b/tests/server/mcpserver/resources/test_file_resources.py @@ -42,6 +42,18 @@ def test_file_resource_creation(self, temp_file: Path): assert resource.path == temp_file assert resource.is_binary is False # default + def test_uppercase_text_mime_type_is_treated_as_text(self, temp_file: Path): + """Media types are case-insensitive (RFC 9110, section 8.3.1), so an + upper/mixed-case ``text/*`` mime type must still be treated as text + (``is_binary`` stays False) rather than misclassified as binary.""" + resource = FileResource( + uri=temp_file.as_uri(), + name="test", + path=temp_file, + mime_type="Text/Markdown", + ) + assert resource.is_binary is False + def test_file_resource_str_path_conversion(self, temp_file: Path): """Test FileResource handles string paths.""" resource = FileResource( diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 6aadf6ff88..195d677e56 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -597,6 +597,30 @@ def test_streamable_http_transport_init_validation() -> None: StreamableHTTPServerTransport(mcp_session_id="test\n") +def test_check_content_type_is_case_insensitive() -> None: + """Content-Type media types are case-insensitive (RFC 9110, section 8.3.1). + + A spec-valid request such as ``Content-Type: Application/JSON`` must be + accepted, consistent with ``_check_accept_headers`` (which already lowercases). + """ + transport = StreamableHTTPServerTransport(mcp_session_id=None) + + def request_with(content_type: str) -> Request: + return Request({"type": "http", "headers": [(b"content-type", content_type.encode())]}) + + for value in ( + "application/json", + "Application/JSON", + "APPLICATION/JSON", + "application/json; charset=utf-8", + "Application/Json; charset=utf-8", + ): + assert transport._check_content_type(request_with(value)) is True, value + + # A genuinely different media type is still rejected. + assert transport._check_content_type(request_with("text/plain")) is False + + @pytest.mark.anyio async def test_session_termination(basic_app: Starlette) -> None: """DELETE terminates the session, after which requests for it return 404."""