Skip to content

Commit d21fbed

Browse files
authored
Merge branch 'main' into FastMCP-and-structured-output
2 parents 771532b + 8ac11ec commit d21fbed

File tree

5 files changed

+91
-134
lines changed

5 files changed

+91
-134
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: Comment on Released PRs
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
pull-requests: write
9+
10+
jobs:
11+
comment:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: apexskier/github-release-commenter@v1
15+
with:
16+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
17+
comment-template: |
18+
This PR is included in version [{release_tag}]({release_link})

src/mcp/client/auth/oauth2.py

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import httpx
2020
from pydantic import BaseModel, Field, ValidationError
2121

22-
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
22+
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
2323
from mcp.client.auth.utils import (
2424
build_oauth_authorization_server_metadata_discovery_urls,
2525
build_protected_resource_metadata_discovery_urls,
@@ -299,44 +299,6 @@ async def _handle_protected_resource_response(self, response: httpx.Response) ->
299299
f"Protected Resource Metadata request failed: {response.status_code}"
300300
) # pragma: no cover
301301

302-
async def _register_client(self) -> httpx.Request | None:
303-
"""Build registration request or skip if already registered."""
304-
if self.context.client_info:
305-
return None
306-
307-
if self.context.oauth_metadata and self.context.oauth_metadata.registration_endpoint:
308-
registration_url = str(self.context.oauth_metadata.registration_endpoint) # pragma: no cover
309-
else:
310-
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
311-
registration_url = urljoin(auth_base_url, "/register")
312-
313-
registration_data = self.context.client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)
314-
315-
# If token_endpoint_auth_method is None, auto-select based on server support
316-
if self.context.client_metadata.token_endpoint_auth_method is None:
317-
preference_order = ["client_secret_basic", "client_secret_post", "none"]
318-
319-
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint_auth_methods_supported:
320-
supported = self.context.oauth_metadata.token_endpoint_auth_methods_supported
321-
for method in preference_order:
322-
if method in supported:
323-
registration_data["token_endpoint_auth_method"] = method
324-
break
325-
else:
326-
# No compatible methods between client and server
327-
raise OAuthRegistrationError(
328-
f"No compatible authentication methods. "
329-
f"Server supports: {supported}, "
330-
f"Client supports: {preference_order}"
331-
)
332-
else:
333-
# No server metadata available, use our default preference
334-
registration_data["token_endpoint_auth_method"] = preference_order[0]
335-
336-
return httpx.Request(
337-
"POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}
338-
)
339-
340302
async def _perform_authorization(self) -> httpx.Request:
341303
"""Perform the authorization flow."""
342304
auth_code, code_verifier = await self._perform_authorization_code_grant()

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class Resource(BaseModel, abc.ABC):
2828
mime_type: str = Field(
2929
default="text/plain",
3030
description="MIME type of the resource content",
31-
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+$",
31+
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+(;\s*[a-zA-Z0-9\-_.]+=[a-zA-Z0-9\-_.]+)*$",
3232
)
3333
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource")
3434
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource")

tests/client/test_auth.py

Lines changed: 1 addition & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"""
44

55
import base64
6-
import json
76
import time
87
from unittest import mock
98
from urllib.parse import unquote
@@ -13,7 +12,7 @@
1312
from inline_snapshot import Is, snapshot
1413
from pydantic import AnyHttpUrl, AnyUrl
1514

16-
from mcp.client.auth import OAuthClientProvider, OAuthRegistrationError, PKCEParameters
15+
from mcp.client.auth import OAuthClientProvider, PKCEParameters
1716
from mcp.client.auth.utils import (
1817
build_oauth_authorization_server_metadata_discovery_urls,
1918
build_protected_resource_metadata_discovery_urls,
@@ -581,98 +580,6 @@ async def test_omit_scope_when_no_prm_scopes_or_www_auth(
581580
# Verify that scope is omitted
582581
assert scopes is None
583582

584-
@pytest.mark.anyio
585-
async def test_register_client_request(self, oauth_provider: OAuthClientProvider):
586-
"""Test client registration request building."""
587-
request = await oauth_provider._register_client()
588-
589-
assert request is not None
590-
assert request.method == "POST"
591-
assert str(request.url) == "https://api.example.com/register"
592-
assert request.headers["Content-Type"] == "application/json"
593-
594-
@pytest.mark.anyio
595-
async def test_register_client_skip_if_registered(self, oauth_provider: OAuthClientProvider):
596-
"""Test client registration is skipped if already registered."""
597-
# Set existing client info
598-
client_info = OAuthClientInformationFull(
599-
client_id="existing_client",
600-
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
601-
)
602-
oauth_provider.context.client_info = client_info
603-
604-
# Should return None (skip registration)
605-
request = await oauth_provider._register_client()
606-
assert request is None
607-
608-
@pytest.mark.anyio
609-
async def test_register_client_explicit_auth_method(self, mock_storage: MockTokenStorage):
610-
"""Test that explicitly set token_endpoint_auth_method is used without auto-selection."""
611-
612-
async def redirect_handler(url: str) -> None:
613-
pass # pragma: no cover
614-
615-
async def callback_handler() -> tuple[str, str | None]:
616-
return "test_auth_code", "test_state" # pragma: no cover
617-
618-
# Create client metadata with explicit auth method
619-
explicit_metadata = OAuthClientMetadata(
620-
client_name="Test Client",
621-
client_uri=AnyHttpUrl("https://example.com"),
622-
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
623-
scope="read write",
624-
token_endpoint_auth_method="client_secret_basic",
625-
)
626-
provider = OAuthClientProvider(
627-
server_url="https://api.example.com/v1/mcp",
628-
client_metadata=explicit_metadata,
629-
storage=mock_storage,
630-
redirect_handler=redirect_handler,
631-
callback_handler=callback_handler,
632-
)
633-
634-
request = await provider._register_client()
635-
assert request is not None
636-
637-
body = json.loads(request.content)
638-
# Should use the explicitly set method, not auto-select
639-
assert body["token_endpoint_auth_method"] == "client_secret_basic"
640-
641-
@pytest.mark.anyio
642-
async def test_register_client_none_auth_method_with_server_metadata(self, oauth_provider: OAuthClientProvider):
643-
"""Test that token_endpoint_auth_method=None selects from server's supported methods."""
644-
# Set server metadata with specific supported methods
645-
oauth_provider.context.oauth_metadata = OAuthMetadata(
646-
issuer=AnyHttpUrl("https://auth.example.com"),
647-
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
648-
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
649-
token_endpoint_auth_methods_supported=["client_secret_post"],
650-
)
651-
# Ensure client_metadata has None for token_endpoint_auth_method
652-
653-
request = await oauth_provider._register_client()
654-
assert request is not None
655-
656-
body = json.loads(request.content)
657-
assert body["token_endpoint_auth_method"] == "client_secret_post"
658-
659-
@pytest.mark.anyio
660-
async def test_register_client_none_auth_method_no_compatible(self, oauth_provider: OAuthClientProvider):
661-
"""Test that registration raises error when no compatible auth methods."""
662-
# Set server metadata with unsupported methods only
663-
oauth_provider.context.oauth_metadata = OAuthMetadata(
664-
issuer=AnyHttpUrl("https://auth.example.com"),
665-
authorization_endpoint=AnyHttpUrl("https://auth.example.com/authorize"),
666-
token_endpoint=AnyHttpUrl("https://auth.example.com/token"),
667-
token_endpoint_auth_methods_supported=["private_key_jwt", "client_secret_jwt"],
668-
)
669-
670-
with pytest.raises(OAuthRegistrationError) as exc_info:
671-
await oauth_provider._register_client()
672-
673-
assert "No compatible authentication methods" in str(exc_info.value)
674-
assert "private_key_jwt" in str(exc_info.value)
675-
676583
@pytest.mark.anyio
677584
async def test_token_exchange_request_authorization_code(self, oauth_provider: OAuthClientProvider):
678585
"""Test token exchange request building."""
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Test for GitHub issue #1754: MIME type validation rejects valid RFC 2045 parameters.
2+
3+
The MIME type validation regex was too restrictive and rejected valid MIME types
4+
with parameters like 'text/html;profile=mcp-app' which are valid per RFC 2045.
5+
"""
6+
7+
import pytest
8+
from pydantic import AnyUrl
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+
async def test_mime_type_with_parameters():
19+
"""Test that MIME types with parameters are accepted (RFC 2045)."""
20+
mcp = FastMCP("test")
21+
22+
# This should NOT raise a validation error
23+
@mcp.resource("ui://widget", mime_type="text/html;profile=mcp-app")
24+
def widget() -> str:
25+
raise NotImplementedError()
26+
27+
resources = await mcp.list_resources()
28+
assert len(resources) == 1
29+
assert resources[0].mimeType == "text/html;profile=mcp-app"
30+
31+
32+
async def test_mime_type_with_parameters_and_space():
33+
"""Test MIME type with space after semicolon."""
34+
mcp = FastMCP("test")
35+
36+
@mcp.resource("data://json", mime_type="application/json; charset=utf-8")
37+
def data() -> str:
38+
raise NotImplementedError()
39+
40+
resources = await mcp.list_resources()
41+
assert len(resources) == 1
42+
assert resources[0].mimeType == "application/json; charset=utf-8"
43+
44+
45+
async def test_mime_type_with_multiple_parameters():
46+
"""Test MIME type with multiple parameters."""
47+
mcp = FastMCP("test")
48+
49+
@mcp.resource("data://multi", mime_type="text/plain; charset=utf-8; format=fixed")
50+
def data() -> str:
51+
raise NotImplementedError()
52+
53+
resources = await mcp.list_resources()
54+
assert len(resources) == 1
55+
assert resources[0].mimeType == "text/plain; charset=utf-8; format=fixed"
56+
57+
58+
async def test_mime_type_preserved_in_read_resource():
59+
"""Test that MIME type with parameters is preserved when reading resource."""
60+
mcp = FastMCP("test")
61+
62+
@mcp.resource("ui://my-widget", mime_type="text/html;profile=mcp-app")
63+
def my_widget() -> str:
64+
return "<html><body>Hello MCP-UI</body></html>"
65+
66+
async with client_session(mcp._mcp_server) as client:
67+
# Read the resource
68+
result = await client.read_resource(AnyUrl("ui://my-widget"))
69+
assert len(result.contents) == 1
70+
assert result.contents[0].mimeType == "text/html;profile=mcp-app"

0 commit comments

Comments
 (0)