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
42 changes: 41 additions & 1 deletion src/mcp/server/fastmcp/resources/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Base classes and interfaces for FastMCP resources."""

import abc
import re
from email.message import Message
from typing import Annotated

from pydantic import (
Expand Down Expand Up @@ -28,7 +30,6 @@ 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")
Expand All @@ -43,6 +44,45 @@ def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
return str(uri)
raise ValueError("Either name or uri must be provided")

@field_validator("mime_type")
@classmethod
def validate_mimetype(cls, mime_type: str) -> str:
"""Validate MIME type. The default mime type is 'text/plain'"""
print(f"The mime type received is: {mime_type}")
_mime_type = mime_type.strip()
if not _mime_type or "/" not in _mime_type:
raise ValueError(
f"Invalid MIME type: '{mime_type}'. Must follow 'type/subtype' format. "
"It looks like you provided a parameter without a type."
)

m = Message() # RFC 2045 compliant parser
m["Content-Type"] = _mime_type
main_type, sub_type, params = m.get_content_maintype(), m.get_content_subtype(), m.get_params()
print(f"Main type and subtype and params are: {main_type} and {sub_type} and {params}")

# RFC 2045 tokens allow alphanumeric plus !#$%&'*+-.^_`|~
token_pattern = r"^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$"
if (
not main_type
or not re.match(token_pattern, main_type)
or not sub_type
or not re.match(token_pattern, sub_type)
# The first element of params is usually the type/subtype itself.
or not params
or params[0] != (f"{main_type}/{sub_type}", "")
):
raise ValueError(f"Invalid MIME type: {mime_type}. The main type or sub type is invalid.")

# No format validation on parameter key/value.
if params and len(params) > 1:
for key, val in params[1:]:
# An attribute MUST have a name. The value CAN be empty.
if not key.strip():
raise ValueError(f"Malformed parameter in '{val}': missing attribute name.")

return mime_type

@abc.abstractmethod
async def read(self) -> str | bytes:
"""Read the resource content."""
Expand Down
114 changes: 114 additions & 0 deletions tests/issues/test_1756_mime_type_relaxed_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Test for Github issue #1756: Consider removing or relaxing MIME type validation in FastMCP resources.

The validation regex for FastMCP Resource's mime_type field is too strict and does not allow valid MIME types.
Ex: parameter values with quotes strings and valid token characters (e.g. !, #, *, +, etc.) were rejected.
"""

import pytest
from pydantic import AnyUrl, ValidationError

from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)

pytestmark = pytest.mark.anyio


# Exhaustive list of valid mime types formats.
# https://www.iana.org/assignments/media-types/media-types.xhtml
def _test_data_mime_type_with_valid_rfc2045_formats():
"""Test data for valid mime types with rfc2045 formats."""
return [
# Standard types
("application/json", "Simple application type"),
("text/html", "Simple text type"),
("image/png", "Simple image type"),
("audio/mpeg", "Simple audio type"),
("video/mp4", "Simple video type"),
("font/woff2", "Simple font type"),
("model/gltf+json", "Model type"),
# Vendor specific (vnd)
("application/vnd.api+json", "Vendor specific JSON api"),
("application/vnd.ms-excel", "Vendor specific Excel"),
("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "Complex vendor type"),
# Parameters
('text/plain; charset="utf-8"', "MIME type with quotes in parameter value"),
('text/plain; charset="utf!8"', "MIME type with exclamation mark in parameter value"),
('text/plain; charset="utf*8"', "MIME type with asterisk in parameter value"),
('text/plain; charset="utf#8"', "MIME type with hash in parameter value"),
('text/plain; charset="utf+8"', "MIME type with plus in parameter value"),
("text/plain; charset=utf-8; format=flowed", "Multiple parameters"),
("multipart/form-data; boundary=---1234", "Multipart with boundary"),
# Special characters in subtype
("image/svg+xml", "Subtype with plus"),
# Parmeter issues.
("text/plain; charset=utf 8", "Unquoted space in parameter"),
('text/plain; charset="utf-8', "Unbalanced quotes"),
("text/plain; charset", "Parameter missing value"),
]


@pytest.mark.parametrize("mime_type, description", _test_data_mime_type_with_valid_rfc2045_formats())
async def test_mime_type_with_valid_rfc2045_formats(mime_type: str, description: str):
"""Test that MIME type with valid RFC 2045 token characters are accepted."""
mcp = FastMCP("test")

@mcp.resource("ui://widget", mime_type=mime_type)
def widget() -> str:
raise NotImplementedError()

resources = await mcp.list_resources()
assert len(resources) == 1
assert resources[0].mimeType == mime_type


@pytest.mark.parametrize("mime_type, description", _test_data_mime_type_with_valid_rfc2045_formats())
async def test_mime_type_preserved_in_read_resource(mime_type: str, description: str):
"""Test that MIME type with parameters is preserved when reading resource."""
mcp = FastMCP("test")

@mcp.resource("ui://my-widget", mime_type=mime_type)
def my_widget() -> str:
return "<html><body>Hello MCP-UI</body></html>"

async with client_session(mcp._mcp_server) as client:
# Read the resource
result = await client.read_resource(AnyUrl("ui://my-widget"))
assert len(result.contents) == 1
assert result.contents[0].mimeType == mime_type


def _test_data_mime_type_with_invalid_rfc2045_formats():
"""Test data for invalid mime types with rfc2045 formats."""
return [
("charset=utf-8", "MIME type with no main and subtype but only parameters."),
("text", "Missing subtype"),
("text/", "Empty subtype"),
("/html", "Missing type"),
(" ", "Whitespace"),
# --- Structural ---
("text//plain", "Double slash"),
("application/json/", "Trailing slash"),
("text / plain", "Spaces around primary slash"),
# --- Illegal Characters ---
("image/jp@g", "Illegal character in subtype"),
("text(comment)/plain", "Comments inside type name"),
# --- Parameter Issues ---
("text/plain; =utf-8", "Parameter missing key"),
("text/plain charset=utf-8", "Missing semicolon separator"),
# --- Encoding/Non-ASCII ---
("text/plâin", "Non-ASCII character in subtype"),
]


@pytest.mark.parametrize("mime_type, description", _test_data_mime_type_with_invalid_rfc2045_formats())
async def test_mime_type_with_invalid_rfc2045_formats(mime_type: str, description: str):
"""Test that MIME type with invalid RFC 2045 token characters are rejected."""
mcp = FastMCP("test")

with pytest.raises(ValidationError):

@mcp.resource("ui://widget", mime_type=mime_type)
def widget() -> str:
raise NotImplementedError()