Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/mcp/server/mcpserver/resources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
114 changes: 114 additions & 0 deletions tests/issues/test_1756_mime_type_relaxed.py
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 13 additions & 0 deletions tests/server/mcpserver/resources/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down