From 6681cf0b3eb5200b0dd7120128d29ff4b5226722 Mon Sep 17 00:00:00 2001 From: ly-wang19 Date: Sat, 20 Jun 2026 18:16:53 +0800 Subject: [PATCH 1/2] fix(server): match Content-Type case-insensitively in StreamableHTTP Media types are case-insensitive (RFC 9110, section 8.3.1), and StreamableHTTPServerTransport._check_accept_headers already lowercases the Accept media types before comparing. _check_content_type did not, so a spec-valid request with a mixed/upper-case Content-Type (e.g. "Application/JSON") was rejected with 415 Unsupported Media Type. Lowercase the parsed Content-Type media type before comparing to CONTENT_TYPE_JSON, consistent with _check_accept_headers. Adds a unit test for case-insensitive matching (the _check_content_type path was previously no-cover). --- src/mcp/server/streamable_http.py | 4 +++- tests/shared/test_streamable_http.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) 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/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.""" From 7589d14f21bb0246f8d776498f8c7e229661264b Mon Sep 17 00:00:00 2001 From: ly-wang19 Date: Sat, 20 Jun 2026 20:19:34 +0800 Subject: [PATCH 2/2] fix(server): treat text/* mime types case-insensitively for FileResource FileResource.set_binary_from_mime_type decides whether to read a file as text or bytes via `mime_type.startswith("text/")`, but media types are case-insensitive (RFC 9110, section 8.3.1). A FileResource declared with an upper/mixed-case text type (e.g. "Text/Markdown") was misclassified as binary and read with read_bytes instead of read_text. Normalize with .lower() before the prefix check, consistent with the other media-type checks in the SDK (transport_security, client streamable_http). Adds a regression test. --- src/mcp/server/mcpserver/resources/types.py | 5 ++++- .../mcpserver/resources/test_file_resources.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) 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/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(