Skip to content

Commit 33130a9

Browse files
committed
feat: absent params reach handlers as the model with defaults, never None
A message with no params member now validates {} against the registered params_type: models with required fields reject it as INVALID_PARAMS before the handler runs, and all-optional models reach the handler with their defaults. Previously the validated instance was discarded and the handler got None, which contradicted the registered handler type for all-optional models. Matches the Go SDK's non-nil params guarantee. Intentional behavior change: the roots list_changed interaction snapshot is updated from None to NotificationParams().
1 parent 53e531c commit 33130a9

5 files changed

Lines changed: 22 additions & 25 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,7 @@ If you need to check whether a handler is registered, track this yourself — th
817817

818818
### Lowlevel `Server`: `add_request_handler` is now public and takes `params_type`
819819

820-
The private `_add_request_handler(method, handler)` escape hatch is now the public `add_request_handler(method, params_type, handler)`, alongside a matching `add_notification_handler`. Each takes a `params_type` model that incoming params are validated against before the handler runs.
820+
The private `_add_request_handler(method, handler)` escape hatch is now the public `add_request_handler(method, params_type, handler)`, alongside a matching `add_notification_handler`. Each takes a `params_type` model that incoming params are validated against before the handler runs. A message with no `params` member validates `{}` against the model, so handlers never receive `None`: all-optional models arrive with their defaults, and models with required fields reject the message as `INVALID_PARAMS` before the handler runs (matching the Go SDK).
821821

822822
```python
823823
# Before (v1 / earlier v2 prereleases)

src/mcp/server/lowlevel/server.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,13 @@ def add_request_handler(
258258
259259
`params_type` is the model incoming params are validated against
260260
before the handler is invoked. It should subclass `RequestParams` so
261-
`_meta` parses uniformly. Replaces any existing handler for the same
262-
method, except `initialize`, which is reserved: the runner owns the
263-
handshake, so registering it raises `ValueError`. Use
264-
`Server.middleware` to observe or wrap initialization.
261+
`_meta` parses uniformly. A message with no `params` member validates
262+
`{}` against `params_type`: models with required fields reject it as
263+
INVALID_PARAMS, all-optional models reach the handler with their
264+
defaults - the handler never receives `None`. Replaces any existing
265+
handler for the same method, except `initialize`, which is reserved:
266+
the runner owns the handshake, so registering it raises `ValueError`.
267+
Use `Server.middleware` to observe or wrap initialization.
265268
"""
266269
if method == "initialize":
267270
raise ValueError(
@@ -279,7 +282,9 @@ def add_notification_handler(
279282
"""Register a notification handler for `method`.
280283
281284
`params_type` should subclass `NotificationParams` so `_meta`
282-
parses uniformly. Replaces any existing handler. A handler for
285+
parses uniformly. Absent params follow the same contract as requests:
286+
`{}` is validated, so the handler receives the model with its defaults,
287+
never `None`. Replaces any existing handler. A handler for
283288
`notifications/initialized` runs after the runner has marked the
284289
connection initialized.
285290
"""

src/mcp/server/runner.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,9 @@ async def _inner() -> HandlerResult:
245245
entry = self.server.get_request_handler(method)
246246
if entry is None:
247247
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found")
248-
# Absent params reach the handler as None; the empty-dict validate
249-
# still enforces required fields (pinned compat).
250-
if params is None:
251-
entry.params_type.model_validate({}, by_name=False)
252-
typed_params = None
253-
else:
254-
typed_params = entry.params_type.model_validate(params, by_name=False)
248+
# Absent params validate as {} (required fields still reject), so
249+
# the handler receives the model with its defaults, never None.
250+
typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False)
255251
result = await entry.handler(ctx, typed_params)
256252
if isinstance(result, ErrorData):
257253
# Raise inside the chain so middleware observes the failure.
@@ -295,11 +291,7 @@ async def _inner() -> None:
295291
return
296292
# Same absent-params contract as requests.
297293
try:
298-
if params is None:
299-
entry.params_type.model_validate({}, by_name=False)
300-
typed_params = None
301-
else:
302-
typed_params = entry.params_type.model_validate(params, by_name=False)
294+
typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False)
303295
except ValidationError:
304296
logger.warning("dropped %r: malformed params", method)
305297
return

tests/interaction/lowlevel/test_roots.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,4 @@ async def list_roots(context: ClientRequestContext) -> ListRootsResult:
163163
with anyio.fail_after(5):
164164
await delivered.wait()
165165

166-
assert received == snapshot([None])
166+
assert received == snapshot([types.NotificationParams()])

tests/server/test_runner.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,8 @@ async def on_roots_changed(ctx: Ctx, params: NotificationParams | None) -> None:
332332
await client.notify("notifications/roots/list_changed", {})
333333
await delivered.wait()
334334
assert isinstance(seen[0][0], ServerRequestContext)
335-
# Absent wire params reach the handler as None; present-but-empty validates.
336-
assert seen[0][1] is None
335+
# Absent and present-but-empty wire params both validate to the defaults model.
336+
assert seen[0][1] == NotificationParams()
337337
assert isinstance(seen[1][1], NotificationParams)
338338

339339

@@ -394,9 +394,9 @@ async def on_progress(ctx: Ctx, params: ProgressNotificationParams) -> None:
394394

395395

396396
@pytest.mark.anyio
397-
async def test_runner_absent_wire_params_reaches_request_handler_as_none():
397+
async def test_runner_absent_wire_params_reaches_request_handler_as_defaults_model():
398398
"""A request with no `params` member on the wire reaches the handler as
399-
`None`, matching the previous server and the `| None` handler annotation.
399+
the params model with its defaults, never `None`.
400400
401401
The in-SDK client always attaches `_meta`, so a dispatch middleware
402402
forwards `params=None` to model what an external client sends.
@@ -416,7 +416,7 @@ async def wrapped(dctx: DispatchContext[Any], method: str, params: Any) -> dict[
416416
server: SrvT = Server(name="s", on_list_tools=list_tools)
417417
async with connected_runner(server, dispatch_middleware=[drop_params]) as (client, _):
418418
await client.send_raw_request("tools/list", None)
419-
assert seen == [None]
419+
assert seen == [PaginatedRequestParams()]
420420

421421

422422
@pytest.mark.anyio
@@ -458,7 +458,7 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
458458
await client.notify("notifications/unknown", None) # no handler: dropped
459459
await client.notify("notifications/roots/list_changed", None) # post-init: delivered
460460
await anyio.wait_all_tasks_blocked()
461-
assert seen == [None] # only the post-init one reached the handler
461+
assert seen == [NotificationParams()] # only the post-init one reached the handler
462462

463463

464464
@pytest.mark.anyio

0 commit comments

Comments
 (0)