mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 05:24:18 +02:00
Update FastMCP for MCP SDK 1.23.1 auth changes
- Bump mcp SDK to >=1.23.1 - Add `client_secret_basic` authentication support (SDK PR #1334) - TokenHandler now wraps SDK's handle() to transform `unauthorized_client` to `invalid_client` on 401 responses per OAuth 2.1 spec - Update `sample()` return type to use SDK's SamplingMessageContentBlock - Update test expectations for new SDK fields (`task`, `_meta`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
54692c361e
commit
654442bfd3
7 changed files with 68 additions and 34 deletions
|
|
@ -7,7 +7,7 @@ dependencies = [
|
|||
"python-dotenv>=1.1.0",
|
||||
"exceptiongroup>=1.2.2",
|
||||
"httpx>=0.28.1",
|
||||
"mcp>=1.19.0,<2.0.0,!=1.21.1",
|
||||
"mcp>=1.23.1",
|
||||
"openapi-pydantic>=0.5.1",
|
||||
"platformdirs>=4.0.0",
|
||||
"rich>=13.9.4",
|
||||
|
|
|
|||
|
|
@ -522,14 +522,17 @@ def create_error_html(
|
|||
class TokenHandler(_SDKTokenHandler):
|
||||
"""TokenHandler that returns OAuth 2.1 compliant error responses.
|
||||
|
||||
The MCP SDK always returns HTTP 400 for all client authentication issues.
|
||||
However, OAuth 2.1 Section 5.3 and the MCP specification require that
|
||||
invalid or expired tokens MUST receive a HTTP 401 response.
|
||||
The MCP SDK returns `unauthorized_client` for client authentication failures.
|
||||
However, per RFC 6749 Section 5.2, authentication failures should return
|
||||
`invalid_client` with HTTP 401, not `unauthorized_client`.
|
||||
|
||||
This handler extends the base MCP SDK TokenHandler to transform client
|
||||
authentication failures into OAuth 2.1 compliant responses:
|
||||
- Changes 'unauthorized_client' to 'invalid_client' error code
|
||||
- Returns HTTP 401 status code instead of 400 for client auth failures
|
||||
This distinction matters: `unauthorized_client` means "client exists but
|
||||
can't do this", while `invalid_client` means "client doesn't exist or
|
||||
credentials are wrong". Claude's OAuth client uses this to decide whether
|
||||
to re-register.
|
||||
|
||||
This handler transforms 401 responses with `unauthorized_client` to use
|
||||
`invalid_client` instead, making the error semantics correct per OAuth spec.
|
||||
|
||||
Per OAuth 2.1 Section 5.3: "The authorization server MAY return an HTTP 401
|
||||
(Unauthorized) status code to indicate which HTTP authentication schemes
|
||||
|
|
@ -538,6 +541,31 @@ class TokenHandler(_SDKTokenHandler):
|
|||
Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response."
|
||||
"""
|
||||
|
||||
async def handle(self, request: Any):
|
||||
"""Wrap SDK handle() and transform auth error responses."""
|
||||
response = await super().handle(request)
|
||||
|
||||
# Transform 401 unauthorized_client -> invalid_client
|
||||
if response.status_code == 401:
|
||||
try:
|
||||
body = json.loads(response.body)
|
||||
if body.get("error") == "unauthorized_client":
|
||||
return PydanticJSONResponse(
|
||||
content=TokenErrorResponse(
|
||||
error="invalid_client",
|
||||
error_description=body.get("error_description"),
|
||||
),
|
||||
status_code=401,
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass # Not JSON or unexpected format, return as-is
|
||||
|
||||
return response
|
||||
|
||||
def response(self, obj: TokenSuccessResponse | TokenErrorResponse):
|
||||
"""Override response method to provide OAuth 2.1 compliant error handling."""
|
||||
# Check if this is a client authentication failure (not just unauthorized for grant type)
|
||||
|
|
|
|||
|
|
@ -18,17 +18,16 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|||
from mcp.server.lowlevel.server import request_ctx
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.types import (
|
||||
AudioContent,
|
||||
ClientCapabilities,
|
||||
CreateMessageResult,
|
||||
GetPromptResult,
|
||||
ImageContent,
|
||||
IncludeContext,
|
||||
ModelHint,
|
||||
ModelPreferences,
|
||||
Root,
|
||||
SamplingCapability,
|
||||
SamplingMessage,
|
||||
SamplingMessageContentBlock,
|
||||
TextContent,
|
||||
)
|
||||
from mcp.types import CreateMessageRequestParams as SamplingParams
|
||||
|
|
@ -59,6 +58,7 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG")
|
|||
|
||||
|
||||
T = TypeVar("T", default=Any)
|
||||
|
||||
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
|
||||
_flush_lock = anyio.Lock()
|
||||
|
||||
|
|
@ -479,7 +479,7 @@ class Context:
|
|||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
model_preferences: ModelPreferences | str | list[str] | None = None,
|
||||
) -> TextContent | ImageContent | AudioContent:
|
||||
) -> SamplingMessageContentBlock | list[SamplingMessageContentBlock]:
|
||||
"""
|
||||
Send a sampling request to the client and await the response.
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ async def test_sampling_with_image(fastmcp_server: FastMCP):
|
|||
"annotations": None,
|
||||
"_meta": None,
|
||||
},
|
||||
"_meta": None,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
|
|
@ -159,5 +160,6 @@ async def test_sampling_with_image(fastmcp_server: FastMCP):
|
|||
"annotations": None,
|
||||
"_meta": None,
|
||||
},
|
||||
"_meta": None,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ class TestStructuredLoggingMiddleware:
|
|||
"event": "request_start",
|
||||
"source": "client",
|
||||
"method": "test_method",
|
||||
"payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}',
|
||||
"payload": '{"method":"tools/call","params":{"task":null,"_meta":null,"name":"test_method","arguments":{"param":"value"}}}',
|
||||
"payload_type": "CallToolRequest",
|
||||
}
|
||||
)
|
||||
|
|
@ -159,7 +159,7 @@ class TestStructuredLoggingMiddleware:
|
|||
"event": "request_start",
|
||||
"source": "client",
|
||||
"method": "test_method",
|
||||
"payload_length": 98,
|
||||
"payload_length": 110,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -177,8 +177,8 @@ class TestStructuredLoggingMiddleware:
|
|||
"event": "request_start",
|
||||
"source": "client",
|
||||
"method": "test_method",
|
||||
"payload_tokens": 24,
|
||||
"payload_length": 98,
|
||||
"payload_tokens": 27,
|
||||
"payload_length": 110,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -303,7 +303,7 @@ class TestLoggingMiddleware:
|
|||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"resources/read\\",\\"params\\":{\\"_meta\\":null,\\"uri\\":\\"test://example/1\\"}}", "payload_type": "ReadResourceRequest"}',
|
||||
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"resources/read\\",\\"params\\":{\\"task\\":null,\\"_meta\\":null,\\"uri\\":\\"test://example/1\\"}}", "payload_type": "ReadResourceRequest"}',
|
||||
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
|
||||
]
|
||||
)
|
||||
|
|
@ -365,7 +365,7 @@ class TestLoggingMiddleware:
|
|||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"tools/call\\",\\"params\\":{\\"_meta\\":null,\\"name\\":\\"test_method\\",\\"arguments\\":{\\"obj\\":\\"NON_SERIALIZABLE\\"}}}", "payload_type": "CallToolRequest"}',
|
||||
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"tools/call\\",\\"params\\":{\\"task\\":null,\\"_meta\\":null,\\"name\\":\\"test_method\\",\\"arguments\\":{\\"obj\\":\\"NON_SERIALIZABLE\\"}}}", "payload_type": "CallToolRequest"}',
|
||||
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
|
||||
]
|
||||
)
|
||||
|
|
@ -546,7 +546,7 @@ class TestLoggingMiddlewareIntegration:
|
|||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
'event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams',
|
||||
'event=request_start method=tools/call source=client payload={"task":null,"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams',
|
||||
"event=request_success method=tools/call source=client duration_ms=0.02",
|
||||
]
|
||||
)
|
||||
|
|
@ -570,7 +570,7 @@ class TestLoggingMiddlewareIntegration:
|
|||
|
||||
assert get_log_lines(caplog) == snapshot(
|
||||
[
|
||||
'{"event": "request_start", "method": "tools/call", "source": "client", "payload": "{\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"}',
|
||||
'{"event": "request_start", "method": "tools/call", "source": "client", "payload": "{\\"task\\":null,\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"}',
|
||||
'{"event": "request_success", "method": "tools/call", "source": "client", "duration_ms": 0.02}',
|
||||
]
|
||||
)
|
||||
|
|
@ -665,6 +665,6 @@ class TestLoggingMiddlewareIntegration:
|
|||
# Check that our custom logger captured the logs
|
||||
log_output = log_buffer.getvalue()
|
||||
assert log_output == snapshot("""\
|
||||
event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"custom_test"}} payload_type=CallToolRequestParams
|
||||
event=request_start method=tools/call source=client payload={"task":null,"_meta":null,"name":"simple_operation","arguments":{"data":"custom_test"}} payload_type=CallToolRequestParams
|
||||
event=request_success method=tools/call source=client duration_ms=0.02
|
||||
""")
|
||||
|
|
|
|||
|
|
@ -366,9 +366,10 @@ class TestAuthEndpoints:
|
|||
assert metadata["revocation_endpoint"] == "https://auth.example.com/revoke"
|
||||
assert metadata["response_types_supported"] == ["code"]
|
||||
assert metadata["code_challenge_methods_supported"] == ["S256"]
|
||||
assert metadata["token_endpoint_auth_methods_supported"] == [
|
||||
"client_secret_post"
|
||||
]
|
||||
assert set(metadata["token_endpoint_auth_methods_supported"]) == {
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
}
|
||||
assert metadata["grant_types_supported"] == [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
|
|
@ -376,8 +377,8 @@ class TestAuthEndpoints:
|
|||
assert metadata["service_documentation"] == "https://docs.example.com/"
|
||||
|
||||
async def test_token_validation_error(self, test_client: httpx.AsyncClient):
|
||||
"""Test token endpoint error - validation error."""
|
||||
# Missing required fields
|
||||
"""Test token endpoint error - missing client_id returns auth error."""
|
||||
# Missing required fields - SDK validates client_id first
|
||||
response = await test_client.post(
|
||||
"/token",
|
||||
data={
|
||||
|
|
@ -386,10 +387,11 @@ class TestAuthEndpoints:
|
|||
},
|
||||
)
|
||||
error_response = response.json()
|
||||
assert error_response["error"] == "invalid_request"
|
||||
assert (
|
||||
"error_description" in error_response
|
||||
) # Contains validation error messages
|
||||
# SDK validates client_id before other fields, returning unauthorized_client
|
||||
# (FastMCP's OAuthProxy transforms this to invalid_client, but this test
|
||||
# uses the SDK's create_auth_routes directly)
|
||||
assert error_response["error"] == "unauthorized_client"
|
||||
assert "error_description" in error_response
|
||||
|
||||
async def test_token_invalid_auth_code(
|
||||
self, test_client, registered_client, pkce_challenge
|
||||
|
|
|
|||
12
uv.lock
generated
12
uv.lock
generated
|
|
@ -1,5 +1,5 @@
|
|||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.11'",
|
||||
|
|
@ -610,7 +610,7 @@ requires-dist = [
|
|||
{ name = "exceptiongroup", specifier = ">=1.2.2" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "jsonschema-path", specifier = ">=0.3.4" },
|
||||
{ name = "mcp", specifier = ">=1.19.0,!=1.21.1,<2.0.0" },
|
||||
{ name = "mcp", specifier = ">=1.23.1" },
|
||||
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" },
|
||||
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
|
||||
{ name = "platformdirs", specifier = ">=4.0.0" },
|
||||
|
|
@ -949,7 +949,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.21.0"
|
||||
version = "1.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -963,11 +963,13 @@ dependencies = [
|
|||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/54/dd2330ef4611c27ae59124820863c34e1d3edb1133c58e6375e2d938c9c5/mcp-1.21.0.tar.gz", hash = "sha256:bab0a38e8f8c48080d787233343f8d301b0e1e95846ae7dead251b2421d99855", size = 452697, upload-time = "2025-11-06T23:19:58.432Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/42/10c0c09ca27aceacd8c428956cfabdd67e3d328fe55c4abc16589285d294/mcp-1.23.1.tar.gz", hash = "sha256:7403e053e8e2283b1e6ae631423cb54736933fea70b32422152e6064556cd298", size = 596519, upload-time = "2025-12-02T18:41:12.807Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/47/850b6edc96c03bd44b00de9a0ca3c1cc71e0ba1cd5822955bc9e4eb3fad3/mcp-1.21.0-py3-none-any.whl", hash = "sha256:598619e53eb0b7a6513db38c426b28a4bdf57496fed04332100d2c56acade98b", size = 173672, upload-time = "2025-11-06T23:19:56.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/9e/26e1d2d2c6afe15dfba5ca6799eeeea7656dce625c22766e4c57305e9cc2/mcp-1.23.1-py3-none-any.whl", hash = "sha256:3ce897fcc20a41bd50b4c58d3aa88085f11f505dcc0eaed48930012d34c731d8", size = 231433, upload-time = "2025-12-02T18:41:11.195Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue