Skip to content

Commit 419c1d5

Browse files
BabyChrist666claude
andcommitted
fix: relax MIME type validation to accept all RFC 2045 types (#1756)
Replace restrictive regex pattern with lightweight validator that only checks for type/subtype structure (presence of '/'), aligning with the MCP spec which defines mimeType as an optional string with no format constraints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2fe56e5 commit 419c1d5

File tree

3 files changed

+144
-1
lines changed

3 files changed

+144
-1
lines changed

src/mcp/server/mcpserver/resources/base.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,28 @@ class Resource(BaseModel, abc.ABC):
2626
mime_type: str = Field(
2727
default="text/plain",
2828
description="MIME type of the resource content",
29-
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+(;\s*[a-zA-Z0-9\-_.]+=[a-zA-Z0-9\-_.]+)*$",
3029
)
3130
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource")
3231
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource")
3332
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource")
3433

34+
@field_validator("mime_type")
35+
@classmethod
36+
def validate_mime_type(cls, value: str) -> str:
37+
"""Validate that mime_type has a basic type/subtype structure.
38+
39+
The MCP spec defines mimeType as an optional string with no format
40+
constraints. This validator only checks for the minimal type/subtype
41+
structure to catch obvious mistakes, without restricting valid MIME
42+
types per RFC 2045.
43+
"""
44+
if "/" not in value:
45+
raise ValueError(
46+
f"Invalid MIME type '{value}': must contain a '/' separating type and subtype "
47+
f"(e.g. 'text/plain', 'application/json')"
48+
)
49+
return value
50+
3551
@field_validator("name", mode="before")
3652
@classmethod
3753
def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Test for GitHub issue #1756: Relax MIME type validation in FastMCP resources.
2+
3+
The previous MIME type validation used a restrictive regex pattern that rejected
4+
valid MIME types per RFC 2045. For example, quoted parameter values like
5+
'text/plain; charset="utf-8"' were rejected.
6+
7+
The fix replaces the regex with a lightweight validator that only checks for the
8+
minimal type/subtype structure (presence of '/'), aligning with the MCP spec
9+
which defines mimeType as an optional string with no format constraints.
10+
"""
11+
12+
import pytest
13+
14+
from mcp.server.mcpserver.resources.types import FunctionResource
15+
16+
17+
def _dummy() -> str: # pragma: no cover
18+
return "data"
19+
20+
21+
class TestRelaxedMimeTypeValidation:
22+
"""Test that MIME type validation accepts all RFC 2045 valid types."""
23+
24+
def test_basic_mime_types(self):
25+
"""Standard MIME types should be accepted."""
26+
for mime in [
27+
"text/plain",
28+
"application/json",
29+
"application/octet-stream",
30+
"image/png",
31+
"text/html",
32+
"text/csv",
33+
"application/xml",
34+
]:
35+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
36+
assert r.mime_type == mime
37+
38+
def test_mime_type_with_quoted_parameter_value(self):
39+
"""Quoted parameter values are valid per RFC 2045 (the original issue)."""
40+
mime = 'text/plain; charset="utf-8"'
41+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
42+
assert r.mime_type == mime
43+
44+
def test_mime_type_with_unquoted_parameter(self):
45+
"""Unquoted parameter values should still work."""
46+
mime = "text/plain; charset=utf-8"
47+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
48+
assert r.mime_type == mime
49+
50+
def test_mime_type_with_profile_parameter(self):
51+
"""Profile parameter used by MCP-UI (from issue #1754)."""
52+
mime = "text/html;profile=mcp-app"
53+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
54+
assert r.mime_type == mime
55+
56+
def test_mime_type_with_multiple_parameters(self):
57+
"""Multiple parameters should be accepted."""
58+
mime = "text/plain; charset=utf-8; format=fixed"
59+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
60+
assert r.mime_type == mime
61+
62+
def test_mime_type_with_vendor_type(self):
63+
"""Vendor-specific MIME types (x- prefix, vnd.) should be accepted."""
64+
for mime in [
65+
"application/vnd.api+json",
66+
"application/x-www-form-urlencoded",
67+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
68+
]:
69+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
70+
assert r.mime_type == mime
71+
72+
def test_mime_type_with_suffix(self):
73+
"""Structured syntax suffix types should be accepted."""
74+
for mime in [
75+
"application/ld+json",
76+
"application/soap+xml",
77+
"image/svg+xml",
78+
]:
79+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
80+
assert r.mime_type == mime
81+
82+
def test_mime_type_with_wildcard(self):
83+
"""Wildcard MIME types should be accepted."""
84+
for mime in [
85+
"application/*",
86+
"*/*",
87+
]:
88+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
89+
assert r.mime_type == mime
90+
91+
def test_mime_type_with_complex_parameters(self):
92+
"""Complex parameter values per RFC 2045."""
93+
for mime in [
94+
'multipart/form-data; boundary="----WebKitFormBoundary"',
95+
"text/html; charset=ISO-8859-1",
96+
'application/json; profile="https://example.com/schema"',
97+
]:
98+
r = FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type=mime)
99+
assert r.mime_type == mime
100+
101+
def test_invalid_mime_type_no_slash(self):
102+
"""MIME types without '/' should be rejected."""
103+
with pytest.raises(ValueError, match="must contain a '/'"):
104+
FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type="plaintext")
105+
106+
def test_invalid_mime_type_empty_string(self):
107+
"""Empty string should be rejected (no '/')."""
108+
with pytest.raises(ValueError, match="must contain a '/'"):
109+
FunctionResource(uri="test://x", name="t", fn=_dummy, mime_type="")
110+
111+
def test_default_mime_type(self):
112+
"""Default MIME type should be text/plain."""
113+
r = FunctionResource(uri="test://x", name="t", fn=_dummy)
114+
assert r.mime_type == "text/plain"

tests/server/mcpserver/resources/test_resources.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ def dummy_func() -> str: # pragma: no cover
9191
)
9292
assert resource.mime_type == "application/json"
9393

94+
def test_resource_mime_type_validation(self):
95+
"""Test that MIME types without '/' are rejected."""
96+
97+
def dummy_func() -> str: # pragma: no cover
98+
return "data"
99+
100+
with pytest.raises(ValueError, match="must contain a '/'"):
101+
FunctionResource(
102+
uri="resource://test",
103+
fn=dummy_func,
104+
mime_type="invalid",
105+
)
106+
94107
@pytest.mark.anyio
95108
async def test_resource_read_abstract(self):
96109
"""Test that Resource.read() is abstract."""

0 commit comments

Comments
 (0)