Skip to content

Commit c599b9a

Browse files
committed
fix: reserve initialize registration; validate initialized before state-set
add_request_handler("initialize", ...) now raises ValueError pointing at Server.middleware (the handler could never fire; the runner answers initialize itself, as ServerSession did). notifications/initialized now validates params before flipping the init gate (malformed drops like any other malformed notification, as the receive-loop union check did) and then falls through to the registry so a registered handler observes the initialized connection.
1 parent 5a0127e commit c599b9a

3 files changed

Lines changed: 74 additions & 7 deletions

File tree

‎src/mcp/server/lowlevel/server.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,15 @@ def add_request_handler(
259259
`params_type` is the model incoming params are validated against
260260
before the handler is invoked. It should subclass `RequestParams` so
261261
`_meta` parses uniformly. Replaces any existing handler for the same
262-
method (no collision guard against spec methods).
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.
263265
"""
266+
if method == "initialize":
267+
raise ValueError(
268+
"'initialize' is handled by the server runner and cannot be overridden; "
269+
"use Server.middleware to observe or wrap initialization"
270+
)
264271
self._request_handlers[method] = HandlerEntry(params_type, handler)
265272

266273
def add_notification_handler(
@@ -272,7 +279,9 @@ def add_notification_handler(
272279
"""Register a notification handler for `method`.
273280
274281
`params_type` should subclass `NotificationParams` so `_meta`
275-
parses uniformly. Replaces any existing handler.
282+
parses uniformly. Replaces any existing handler. A handler for
283+
`notifications/initialized` runs after the runner has marked the
284+
connection initialized.
276285
"""
277286
self._notification_handlers[method] = HandlerEntry(params_type, handler)
278287

‎src/mcp/server/runner.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
Implementation,
4747
InitializeRequestParams,
4848
InitializeResult,
49+
NotificationParams,
4950
RequestParams,
5051
RequestParamsMeta,
5152
client_request_adapter,
@@ -315,10 +316,23 @@ async def _on_notify(
315316

316317
async def _inner() -> None:
317318
if method == "notifications/initialized":
319+
# Validate against the spec params model *before* flipping the
320+
# init state, so a malformed initialized notification drops
321+
# like any other malformed notification and leaves the
322+
# connection uninitialized (the existing server validated the
323+
# full `ClientNotification` union before dispatch). On
324+
# success, fall through to the registry so a handler
325+
# registered for this method observes an initialized
326+
# connection.
327+
if params is not None:
328+
try:
329+
NotificationParams.model_validate(params, by_name=False)
330+
except ValidationError:
331+
logger.warning("dropped %r: malformed params", method)
332+
return
318333
self._initialized = True
319334
self.connection.initialized.set()
320-
return
321-
if not self._initialized:
335+
elif not self._initialized:
322336
logger.debug("dropped %s: received before initialization", method)
323337
return
324338
entry = self.server.get_notification_handler(method)

‎tests/server/test_runner.py‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,48 @@ async def test_runner_on_notify_initialized_sets_flag_and_connection_event(serve
260260
assert runner._initialized is True
261261

262262

263+
@pytest.mark.anyio
264+
async def test_runner_on_notify_malformed_initialized_does_not_initialize(
265+
server: SrvT, caplog: pytest.LogCaptureFixture
266+
):
267+
"""A malformed `notifications/initialized` drops like any other malformed
268+
notification and leaves the connection uninitialized."""
269+
async with connected_runner(server, initialized=False) as (client, runner):
270+
await client.notify("notifications/initialized", {"_meta": 42})
271+
await anyio.wait_all_tasks_blocked()
272+
assert runner._initialized is False
273+
assert not runner.connection.initialized.is_set()
274+
assert "dropped 'notifications/initialized': malformed params" in caplog.text
275+
276+
277+
@pytest.mark.anyio
278+
async def test_runner_on_notify_initialized_routes_to_registered_handler_after_state_set(server: SrvT):
279+
"""A handler registered for `notifications/initialized` fires after the
280+
runner flips the init state, so it observes an initialized connection."""
281+
seen: list[bool] = []
282+
delivered = anyio.Event()
283+
284+
async def on_initialized(ctx: Ctx, params: NotificationParams | None) -> None:
285+
seen.append(runner._initialized and runner.connection.initialized.is_set())
286+
delivered.set()
287+
288+
server.add_notification_handler("notifications/initialized", NotificationParams, on_initialized)
289+
async with connected_runner(server, initialized=False) as (client, runner):
290+
await client.notify("notifications/initialized", {"_meta": {"k": "v"}})
291+
await delivered.wait()
292+
assert seen == [True]
293+
294+
295+
def test_server_add_request_handler_rejects_initialize():
296+
async def handler(ctx: Ctx, params: InitializeRequestParams) -> dict[str, Any]:
297+
raise NotImplementedError
298+
299+
server: SrvT = Server(name="s")
300+
with pytest.raises(ValueError, match="Server.middleware"):
301+
server.add_request_handler("initialize", InitializeRequestParams, handler)
302+
assert server.get_request_handler("initialize") is None
303+
304+
263305
@pytest.mark.anyio
264306
async def test_runner_on_notify_routes_to_registered_handler(server: SrvT):
265307
seen: list[tuple[Any, Any]] = []
@@ -792,14 +834,16 @@ async def test_runner_exit_stack_blocking_cleanup_abandoned_after_grace(
792834

793835
async def _abandoned() -> None:
794836
# LIFO unwind: pushed first, so it runs after the blocker. By then the
795-
# deadline has fired, so this checkpoint raises and the append never runs.
837+
# deadline has fired, so this checkpoint raises and the line below is
838+
# unreachable (if abandonment broke, the missing warning fails the
839+
# caplog assert).
796840
await anyio.sleep(0)
797-
ran.append("abandoned")
841+
raise NotImplementedError
798842

799843
async def _blocker() -> None:
800844
ran.append("blocker started")
801845
await release.wait()
802-
ran.append("blocker finished")
846+
raise NotImplementedError
803847

804848
async with connected_runner(server) as (client, runner):
805849
runner.connection.exit_stack.push_async_callback(_abandoned)

0 commit comments

Comments
 (0)