Skip to content

Commit 654442b

Browse files
chrisguidryclaude
andcommitted
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>
1 parent 54692c3 commit 654442b

7 files changed

Lines changed: 68 additions & 34 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ dependencies = [
77
"python-dotenv>=1.1.0",
88
"exceptiongroup>=1.2.2",
99
"httpx>=0.28.1",
10-
"mcp>=1.19.0,<2.0.0,!=1.21.1",
10+
"mcp>=1.23.1",
1111
"openapi-pydantic>=0.5.1",
1212
"platformdirs>=4.0.0",
1313
"rich>=13.9.4",

src/fastmcp/server/auth/oauth_proxy.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -522,14 +522,17 @@ def create_error_html(
522522
class TokenHandler(_SDKTokenHandler):
523523
"""TokenHandler that returns OAuth 2.1 compliant error responses.
524524
525-
The MCP SDK always returns HTTP 400 for all client authentication issues.
526-
However, OAuth 2.1 Section 5.3 and the MCP specification require that
527-
invalid or expired tokens MUST receive a HTTP 401 response.
525+
The MCP SDK returns `unauthorized_client` for client authentication failures.
526+
However, per RFC 6749 Section 5.2, authentication failures should return
527+
`invalid_client` with HTTP 401, not `unauthorized_client`.
528528
529-
This handler extends the base MCP SDK TokenHandler to transform client
530-
authentication failures into OAuth 2.1 compliant responses:
531-
- Changes 'unauthorized_client' to 'invalid_client' error code
532-
- Returns HTTP 401 status code instead of 400 for client auth failures
529+
This distinction matters: `unauthorized_client` means "client exists but
530+
can't do this", while `invalid_client` means "client doesn't exist or
531+
credentials are wrong". Claude's OAuth client uses this to decide whether
532+
to re-register.
533+
534+
This handler transforms 401 responses with `unauthorized_client` to use
535+
`invalid_client` instead, making the error semantics correct per OAuth spec.
533536
534537
Per OAuth 2.1 Section 5.3: "The authorization server MAY return an HTTP 401
535538
(Unauthorized) status code to indicate which HTTP authentication schemes
@@ -538,6 +541,31 @@ class TokenHandler(_SDKTokenHandler):
538541
Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response."
539542
"""
540543

544+
async def handle(self, request: Any):
545+
"""Wrap SDK handle() and transform auth error responses."""
546+
response = await super().handle(request)
547+
548+
# Transform 401 unauthorized_client -> invalid_client
549+
if response.status_code == 401:
550+
try:
551+
body = json.loads(response.body)
552+
if body.get("error") == "unauthorized_client":
553+
return PydanticJSONResponse(
554+
content=TokenErrorResponse(
555+
error="invalid_client",
556+
error_description=body.get("error_description"),
557+
),
558+
status_code=401,
559+
headers={
560+
"Cache-Control": "no-store",
561+
"Pragma": "no-cache",
562+
},
563+
)
564+
except (json.JSONDecodeError, AttributeError):
565+
pass # Not JSON or unexpected format, return as-is
566+
567+
return response
568+
541569
def response(self, obj: TokenSuccessResponse | TokenErrorResponse):
542570
"""Override response method to provide OAuth 2.1 compliant error handling."""
543571
# Check if this is a client authentication failure (not just unauthorized for grant type)

src/fastmcp/server/context.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,16 @@
1818
from mcp.server.lowlevel.server import request_ctx
1919
from mcp.shared.context import RequestContext
2020
from mcp.types import (
21-
AudioContent,
2221
ClientCapabilities,
2322
CreateMessageResult,
2423
GetPromptResult,
25-
ImageContent,
2624
IncludeContext,
2725
ModelHint,
2826
ModelPreferences,
2927
Root,
3028
SamplingCapability,
3129
SamplingMessage,
30+
SamplingMessageContentBlock,
3231
TextContent,
3332
)
3433
from mcp.types import CreateMessageRequestParams as SamplingParams
@@ -59,6 +58,7 @@
5958

6059

6160
T = TypeVar("T", default=Any)
61+
6262
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
6363
_flush_lock = anyio.Lock()
6464

@@ -479,7 +479,7 @@ async def sample(
479479
temperature: float | None = None,
480480
max_tokens: int | None = None,
481481
model_preferences: ModelPreferences | str | list[str] | None = None,
482-
) -> TextContent | ImageContent | AudioContent:
482+
) -> SamplingMessageContentBlock | list[SamplingMessageContentBlock]:
483483
"""
484484
Send a sampling request to the client and await the response.
485485

tests/client/test_sampling.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def sampling_handler(
149149
"annotations": None,
150150
"_meta": None,
151151
},
152+
"_meta": None,
152153
},
153154
{
154155
"role": "user",
@@ -159,5 +160,6 @@ def sampling_handler(
159160
"annotations": None,
160161
"_meta": None,
161162
},
163+
"_meta": None,
162164
},
163165
]

tests/server/middleware/test_logging.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def test_create_message_with_payloads(
144144
"event": "request_start",
145145
"source": "client",
146146
"method": "test_method",
147-
"payload": '{"method":"tools/call","params":{"_meta":null,"name":"test_method","arguments":{"param":"value"}}}',
147+
"payload": '{"method":"tools/call","params":{"task":null,"_meta":null,"name":"test_method","arguments":{"param":"value"}}}',
148148
"payload_type": "CallToolRequest",
149149
}
150150
)
@@ -159,7 +159,7 @@ def test_calculate_response_size(self, mock_context: MiddlewareContext[Any]):
159159
"event": "request_start",
160160
"source": "client",
161161
"method": "test_method",
162-
"payload_length": 98,
162+
"payload_length": 110,
163163
}
164164
)
165165

@@ -177,8 +177,8 @@ def test_calculate_response_size_with_token_estimation(
177177
"event": "request_start",
178178
"source": "client",
179179
"method": "test_method",
180-
"payload_tokens": 24,
181-
"payload_length": 98,
180+
"payload_tokens": 27,
181+
"payload_length": 110,
182182
}
183183
)
184184

@@ -303,7 +303,7 @@ async def test_on_message_with_pydantic_types_in_payload(
303303

304304
assert get_log_lines(caplog) == snapshot(
305305
[
306-
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"resources/read\\",\\"params\\":{\\"_meta\\":null,\\"uri\\":\\"test://example/1\\"}}", "payload_type": "ReadResourceRequest"}',
306+
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"method\\":\\"resources/read\\",\\"params\\":{\\"task\\":null,\\"_meta\\":null,\\"uri\\":\\"test://example/1\\"}}", "payload_type": "ReadResourceRequest"}',
307307
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
308308
]
309309
)
@@ -365,7 +365,7 @@ def __str__(self) -> str:
365365

366366
assert get_log_lines(caplog) == snapshot(
367367
[
368-
'{"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"}',
368+
'{"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"}',
369369
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
370370
]
371371
)
@@ -546,7 +546,7 @@ async def test_logging_middleware_with_payloads(
546546

547547
assert get_log_lines(caplog) == snapshot(
548548
[
549-
'event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams',
549+
'event=request_start method=tools/call source=client payload={"task":null,"_meta":null,"name":"simple_operation","arguments":{"data":"payload_test"}} payload_type=CallToolRequestParams',
550550
"event=request_success method=tools/call source=client duration_ms=0.02",
551551
]
552552
)
@@ -570,7 +570,7 @@ async def test_structured_logging_middleware_produces_json(
570570

571571
assert get_log_lines(caplog) == snapshot(
572572
[
573-
'{"event": "request_start", "method": "tools/call", "source": "client", "payload": "{\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"}',
573+
'{"event": "request_start", "method": "tools/call", "source": "client", "payload": "{\\"task\\":null,\\"_meta\\":null,\\"name\\":\\"simple_operation\\",\\"arguments\\":{\\"data\\":\\"json_test\\"}}", "payload_type": "CallToolRequestParams"}',
574574
'{"event": "request_success", "method": "tools/call", "source": "client", "duration_ms": 0.02}',
575575
]
576576
)
@@ -665,6 +665,6 @@ async def test_logging_middleware_custom_configuration(
665665
# Check that our custom logger captured the logs
666666
log_output = log_buffer.getvalue()
667667
assert log_output == snapshot("""\
668-
event=request_start method=tools/call source=client payload={"_meta":null,"name":"simple_operation","arguments":{"data":"custom_test"}} payload_type=CallToolRequestParams
668+
event=request_start method=tools/call source=client payload={"task":null,"_meta":null,"name":"simple_operation","arguments":{"data":"custom_test"}} payload_type=CallToolRequestParams
669669
event=request_success method=tools/call source=client duration_ms=0.02
670670
""")

tests/server/test_auth_integration.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -366,18 +366,19 @@ async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
366366
assert metadata["revocation_endpoint"] == "https://auth.example.com/revoke"
367367
assert metadata["response_types_supported"] == ["code"]
368368
assert metadata["code_challenge_methods_supported"] == ["S256"]
369-
assert metadata["token_endpoint_auth_methods_supported"] == [
370-
"client_secret_post"
371-
]
369+
assert set(metadata["token_endpoint_auth_methods_supported"]) == {
370+
"client_secret_post",
371+
"client_secret_basic",
372+
}
372373
assert metadata["grant_types_supported"] == [
373374
"authorization_code",
374375
"refresh_token",
375376
]
376377
assert metadata["service_documentation"] == "https://docs.example.com/"
377378

378379
async def test_token_validation_error(self, test_client: httpx.AsyncClient):
379-
"""Test token endpoint error - validation error."""
380-
# Missing required fields
380+
"""Test token endpoint error - missing client_id returns auth error."""
381+
# Missing required fields - SDK validates client_id first
381382
response = await test_client.post(
382383
"/token",
383384
data={
@@ -386,10 +387,11 @@ async def test_token_validation_error(self, test_client: httpx.AsyncClient):
386387
},
387388
)
388389
error_response = response.json()
389-
assert error_response["error"] == "invalid_request"
390-
assert (
391-
"error_description" in error_response
392-
) # Contains validation error messages
390+
# SDK validates client_id before other fields, returning unauthorized_client
391+
# (FastMCP's OAuthProxy transforms this to invalid_client, but this test
392+
# uses the SDK's create_auth_routes directly)
393+
assert error_response["error"] == "unauthorized_client"
394+
assert "error_description" in error_response
393395

394396
async def test_token_invalid_auth_code(
395397
self, test_client, registered_client, pkce_challenge

uv.lock

Lines changed: 7 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)