Skip to content

Commit 7672848

Browse files
committed
Added custom validator for Resource mime type which is less retrictive yet RFC 2025 comliant.
1 parent a9cc822 commit 7672848

File tree

2 files changed

+155
-1
lines changed

2 files changed

+155
-1
lines changed

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Base classes and interfaces for FastMCP resources."""
22

33
import abc
4+
import re
5+
from email.message import Message
46
from typing import Annotated
57

68
from pydantic import (
@@ -28,7 +30,6 @@ class Resource(BaseModel, abc.ABC):
2830
mime_type: str = Field(
2931
default="text/plain",
3032
description="MIME type of the resource content",
31-
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+(;\s*[a-zA-Z0-9\-_.]+=[a-zA-Z0-9\-_.]+)*$",
3233
)
3334
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource")
3435
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource")
@@ -43,6 +44,45 @@ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
4344
return str(uri)
4445
raise ValueError("Either name or uri must be provided")
4546

47+
@field_validator("mime_type")
48+
@classmethod
49+
def validate_mimetype(cls, mime_type: str) -> str:
50+
"""Validate MIME type. The default mime type is 'text/plain'"""
51+
print(f"The mime type received is: {mime_type}")
52+
_mime_type = mime_type.strip()
53+
if not _mime_type or "/" not in _mime_type:
54+
raise ValueError(
55+
f"Invalid MIME type: '{mime_type}'. Must follow 'type/subtype' format. "
56+
"It looks like you provided a parameter without a type."
57+
)
58+
59+
m = Message() # RFC 2045 compliant parser
60+
m["Content-Type"] = _mime_type
61+
main_type, sub_type, params = m.get_content_maintype(), m.get_content_subtype(), m.get_params()
62+
print(f"Main type and subtype and params are: {main_type} and {sub_type} and {params}")
63+
64+
# RFC 2045 tokens allow alphanumeric plus !#$%&'*+-.^_`|~
65+
token_pattern = r"^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$"
66+
if (
67+
not main_type
68+
or not re.match(token_pattern, main_type)
69+
or not sub_type
70+
or not re.match(token_pattern, sub_type)
71+
# The first element of params is usually the type/subtype itself.
72+
or not params
73+
or params[0] != (f"{main_type}/{sub_type}", "")
74+
):
75+
raise ValueError(f"Invalid MIME type: {mime_type}. The main type or sub type is invalid.")
76+
77+
# No format validation on parameter key/value.
78+
if params and len(params) > 1:
79+
for key, val in params[1:]:
80+
# An attribute MUST have a name. The value CAN be empty.
81+
if not key.strip():
82+
raise ValueError(f"Malformed parameter in '{val}': missing attribute name.")
83+
84+
return mime_type
85+
4686
@abc.abstractmethod
4787
async def read(self) -> str | bytes:
4888
"""Read the resource content."""
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Test for Github issue #1756: Consider removing or relaxing MIME type validation in FastMCP resources.
2+
3+
The validation regex for FastMCP Resource's mime_type field is too strict and does not allow valid MIME types.
4+
Ex: parameter values with quotes strings and valid token characters (e.g. !, #, *, +, etc.) were rejected.
5+
"""
6+
7+
import pytest
8+
from pydantic import AnyUrl, ValidationError
9+
10+
from mcp.server.fastmcp import FastMCP
11+
from mcp.shared.memory import (
12+
create_connected_server_and_client_session as client_session,
13+
)
14+
15+
pytestmark = pytest.mark.anyio
16+
17+
18+
# Exhaustive list of valid mime types formats.
19+
# https://www.iana.org/assignments/media-types/media-types.xhtml
20+
def _test_data_mime_type_with_valid_rfc2045_formats():
21+
"""Test data for valid mime types with rfc2045 formats."""
22+
return [
23+
# Standard types
24+
("application/json", "Simple application type"),
25+
("text/html", "Simple text type"),
26+
("image/png", "Simple image type"),
27+
("audio/mpeg", "Simple audio type"),
28+
("video/mp4", "Simple video type"),
29+
("font/woff2", "Simple font type"),
30+
("model/gltf+json", "Model type"),
31+
# Vendor specific (vnd)
32+
("application/vnd.api+json", "Vendor specific JSON api"),
33+
("application/vnd.ms-excel", "Vendor specific Excel"),
34+
("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "Complex vendor type"),
35+
# Parameters
36+
('text/plain; charset="utf-8"', "MIME type with quotes in parameter value"),
37+
('text/plain; charset="utf!8"', "MIME type with exclamation mark in parameter value"),
38+
('text/plain; charset="utf*8"', "MIME type with asterisk in parameter value"),
39+
('text/plain; charset="utf#8"', "MIME type with hash in parameter value"),
40+
('text/plain; charset="utf+8"', "MIME type with plus in parameter value"),
41+
("text/plain; charset=utf-8; format=flowed", "Multiple parameters"),
42+
("multipart/form-data; boundary=---1234", "Multipart with boundary"),
43+
# Special characters in subtype
44+
("image/svg+xml", "Subtype with plus"),
45+
# Parmeter issues.
46+
("text/plain; charset=utf 8", "Unquoted space in parameter"),
47+
('text/plain; charset="utf-8', "Unbalanced quotes"),
48+
("text/plain; charset", "Parameter missing value"),
49+
]
50+
51+
52+
@pytest.mark.parametrize("mime_type, description", _test_data_mime_type_with_valid_rfc2045_formats())
53+
async def test_mime_type_with_valid_rfc2045_formats(mime_type: str, description: str):
54+
"""Test that MIME type with valid RFC 2045 token characters are accepted."""
55+
mcp = FastMCP("test")
56+
57+
@mcp.resource("ui://widget", mime_type=mime_type)
58+
def widget() -> str:
59+
raise NotImplementedError()
60+
61+
resources = await mcp.list_resources()
62+
assert len(resources) == 1
63+
assert resources[0].mimeType == mime_type
64+
65+
66+
@pytest.mark.parametrize("mime_type, description", _test_data_mime_type_with_valid_rfc2045_formats())
67+
async def test_mime_type_preserved_in_read_resource(mime_type: str, description: str):
68+
"""Test that MIME type with parameters is preserved when reading resource."""
69+
mcp = FastMCP("test")
70+
71+
@mcp.resource("ui://my-widget", mime_type=mime_type)
72+
def my_widget() -> str:
73+
return "<html><body>Hello MCP-UI</body></html>"
74+
75+
async with client_session(mcp._mcp_server) as client:
76+
# Read the resource
77+
result = await client.read_resource(AnyUrl("ui://my-widget"))
78+
assert len(result.contents) == 1
79+
assert result.contents[0].mimeType == mime_type
80+
81+
82+
def _test_data_mime_type_with_invalid_rfc2045_formats():
83+
"""Test data for invalid mime types with rfc2045 formats."""
84+
return [
85+
("charset=utf-8", "MIME type with no main and subtype but only parameters."),
86+
("text", "Missing subtype"),
87+
("text/", "Empty subtype"),
88+
("/html", "Missing type"),
89+
(" ", "Whitespace"),
90+
# --- Structural ---
91+
("text//plain", "Double slash"),
92+
("application/json/", "Trailing slash"),
93+
("text / plain", "Spaces around primary slash"),
94+
# --- Illegal Characters ---
95+
("image/jp@g", "Illegal character in subtype"),
96+
("text(comment)/plain", "Comments inside type name"),
97+
# --- Parameter Issues ---
98+
("text/plain; =utf-8", "Parameter missing key"),
99+
("text/plain charset=utf-8", "Missing semicolon separator"),
100+
# --- Encoding/Non-ASCII ---
101+
("text/plâin", "Non-ASCII character in subtype"),
102+
]
103+
104+
105+
@pytest.mark.parametrize("mime_type, description", _test_data_mime_type_with_invalid_rfc2045_formats())
106+
async def test_mime_type_with_invalid_rfc2045_formats(mime_type: str, description: str):
107+
"""Test that MIME type with invalid RFC 2045 token characters are rejected."""
108+
mcp = FastMCP("test")
109+
110+
with pytest.raises(ValidationError):
111+
112+
@mcp.resource("ui://widget", mime_type=mime_type)
113+
def widget() -> str:
114+
raise NotImplementedError()

0 commit comments

Comments
 (0)