diff --git a/src/mcp/server/mcpserver/resources/base.py b/src/mcp/server/mcpserver/resources/base.py index d3ccc425e..e3773fb0f 100644 --- a/src/mcp/server/mcpserver/resources/base.py +++ b/src/mcp/server/mcpserver/resources/base.py @@ -26,12 +26,28 @@ class Resource(BaseModel, abc.ABC): mime_type: str = Field( default="text/plain", description="MIME type of the resource content", - pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+(;\s*[a-zA-Z0-9\-_.]+=[a-zA-Z0-9\-_.]+)*$", ) icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource") annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource") meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource") + @field_validator("mime_type") + @classmethod + def validate_mime_type(cls, value: str) -> str: + """Validate that mime_type has a basic type/subtype structure. + + The MCP spec defines mimeType as an optional string with no format + constraints. This validator only checks for the minimal type/subtype + structure to catch obvious mistakes, without restricting valid MIME + types per RFC 2045. + """ + if "/" not in value: + raise ValueError( + f"Invalid MIME type '{value}': must contain a '/' separating type and subtype " + f"(e.g. 'text/plain', 'application/json')" + ) + return value + @field_validator("name", mode="before") @classmethod def set_default_name(cls, name: str | None, info: ValidationInfo) -> str: diff --git a/tests/issues/test_1756_mime_type_relaxed.py b/tests/issues/test_1756_mime_type_relaxed.py new file mode 100644 index 000000000..8c5441a6d --- /dev/null +++ b/tests/issues/test_1756_mime_type_relaxed.py @@ -0,0 +1,114 @@ +"""Test for GitHub issue #1756: Relax MIME type validation in FastMCP resources. + +The previous MIME type validation used a restrictive regex pattern that rejected +valid MIME types per RFC 2045. For example, quoted parameter values like +'text/plain; charset="utf-8"' were rejected. + +The fix replaces the regex with a lightweight validator that only checks for the +minimal type/subtype structure (presence of '/'), aligning with the MCP spec +which defines mimeType as an optional string with no format constraints. +""" + +import pytest + +from mcp.server.mcpserver.resources.types import FunctionResource + + +def _dummy() -> str: # pragma: no cover + return "data" + + +class TestRelaxedMimeTypeValidation: + """Test that MIME type validation accepts all RFC 2045 valid types.""" + + def test_basic_mime_types(self): + """Standard MIME types should be accepted.""" + for mime in [ + "text/plain", + "application/json", + "application/octet-stream", + "image/png", + "text/html", + "text/csv", + "application/xml", + ]: + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_mime_type_with_quoted_parameter_value(self): + """Quoted parameter values are valid per RFC 2045 (the original issue).""" + mime = 'text/plain; charset="utf-8"' + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_mime_type_with_unquoted_parameter(self): + """Unquoted parameter values should still work.""" + mime = "text/plain; charset=utf-8" + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_mime_type_with_profile_parameter(self): + """Profile parameter used by MCP-UI (from issue #1754).""" + mime = "text/html;profile=mcp-app" + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_mime_type_with_multiple_parameters(self): + """Multiple parameters should be accepted.""" + mime = "text/plain; charset=utf-8; format=fixed" + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_mime_type_with_vendor_type(self): + """Vendor-specific MIME types (x- prefix, vnd.) should be accepted.""" + for mime in [ + "application/vnd.api+json", + "application/x-www-form-urlencoded", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ]: + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_mime_type_with_suffix(self): + """Structured syntax suffix types should be accepted.""" + for mime in [ + "application/ld+json", + "application/soap+xml", + "image/svg+xml", + ]: + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_mime_type_with_wildcard(self): + """Wildcard MIME types should be accepted.""" + for mime in [ + "application/*", + "*/*", + ]: + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_mime_type_with_complex_parameters(self): + """Complex parameter values per RFC 2045.""" + for mime in [ + 'multipart/form-data; boundary="----WebKitFormBoundary"', + "text/html; charset=ISO-8859-1", + 'application/json; profile="https://example.com/schema"', + ]: + r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime) + assert r.mime_type == mime + + def test_invalid_mime_type_no_slash(self): + """MIME types without '/' should be rejected.""" + with pytest.raises(ValueError, match="must contain a '/'"): + FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type="plaintext") + + def test_invalid_mime_type_empty_string(self): + """Empty string should be rejected (no '/').""" + with pytest.raises(ValueError, match="must contain a '/'"): + FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type="") + + def test_default_mime_type(self): + """Default MIME type should be text/plain.""" + r = FunctionResource(uri="test://x", name="t", fn=_dummy) + assert r.mime_type == "text/plain" diff --git a/tests/server/mcpserver/resources/test_resources.py b/tests/server/mcpserver/resources/test_resources.py index 93dc438d5..096d9e4f1 100644 --- a/tests/server/mcpserver/resources/test_resources.py +++ b/tests/server/mcpserver/resources/test_resources.py @@ -91,6 +91,19 @@ def dummy_func() -> str: # pragma: no cover ) assert resource.mime_type == "application/json" + def test_resource_mime_type_validation(self): + """Test that MIME types without '/' are rejected.""" + + def dummy_func() -> str: # pragma: no cover + return "data" + + with pytest.raises(ValueError, match="must contain a '/'"): + FunctionResource( + uri="resource://test", + fn=dummy_func, + mime_type="invalid", + ) + @pytest.mark.anyio async def test_resource_read_abstract(self): """Test that Resource.read() is abstract."""