diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 26e8efbe57..57589078bc 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -28,7 +28,6 @@ CLIENT_INFO_META_KEY, CORE_RESULT_TYPES, INTERNAL_ERROR, - INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, @@ -209,8 +208,15 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: if entry is None: raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) if not self.connection.initialize_accepted and method not in _INIT_EXEMPT: - # Pinned compat: the same error shape the union validation produced. - raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") + # A request before the handshake is a lifecycle error, not a + # parameter error: answer -32600 with the reason, so a client + # on a stale session (e.g. after a server restart) learns it + # must (re-)run `initialize` instead of debugging its params. + raise MCPError( + code=INVALID_REQUEST, + message="Received request before initialization was complete", + data="The client must send an 'initialize' request before any other request.", + ) # Absent params validate as {} (required fields still reject), so # the handler receives the model with its defaults, never None. typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False) diff --git a/tests/interaction/lowlevel/test_initialize.py b/tests/interaction/lowlevel/test_initialize.py index 1ebc9e2681..b4b625b6df 100644 --- a/tests/interaction/lowlevel/test_initialize.py +++ b/tests/interaction/lowlevel/test_initialize.py @@ -12,7 +12,7 @@ import pytest from inline_snapshot import snapshot from mcp_types import ( - INVALID_PARAMS, + INVALID_REQUEST, CallToolResult, ClientCapabilities, CompletionsCapability, @@ -228,11 +228,12 @@ async def list_roots(context: ClientRequestContext) -> types.ListRootsResult: @requirement("lifecycle:requests-before-initialized") async def test_request_before_initialization_is_rejected() -> None: - """A feature request sent before the handshake completes is rejected; ping is exempt. + """A feature request sent before the handshake completes is rejected with a + descriptive error; ping is exempt. Client always initializes on entry, so this drives a bare ClientSession that never sends - initialize. The server's stated reason for the rejection never reaches the client: the error - is reported as a generic invalid-params failure. + initialize. The rejection names the missing handshake (-32600), so a client on a stale + session learns it must (re-)run `initialize` instead of debugging its params. """ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: @@ -253,7 +254,11 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa pong = await session.send_ping() assert exc_info.value.error == snapshot( - ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") + ErrorData( + code=INVALID_REQUEST, + message="Received request before initialization was complete", + data="The client must send an 'initialize' request before any other request.", + ) ) assert pong == snapshot(EmptyResult()) diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 1e2ab741b7..f211adb42e 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -208,7 +208,11 @@ async def test_runner_gates_requests_before_initialize(server: SrvT): async with connected_runner(server, initialized=False) as (client, _): with pytest.raises(MCPError) as exc: await client.send_raw_request("tools/list", None) - assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") + assert exc.value.error == ErrorData( + code=INVALID_REQUEST, + message="Received request before initialization was complete", + data="The client must send an 'initialize' request before any other request.", + ) # ping is exempt from the gate assert await client.send_raw_request("ping", None) == {} @@ -249,7 +253,11 @@ async def greet(ctx: Ctx, params: RequestParams | None) -> Any: async with connected_runner(server, initialized=False) as (client, _): with pytest.raises(MCPError) as exc: await client.send_raw_request("custom/greet", None) - assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") + assert exc.value.error == ErrorData( + code=INVALID_REQUEST, + message="Received request before initialization was complete", + data="The client must send an 'initialize' request before any other request.", + ) @pytest.mark.anyio @@ -632,7 +640,11 @@ async def reject_initialize(ctx: Ctx, call_next: Any) -> Any: assert exc.value.error.message == "rejected by middleware" with pytest.raises(MCPError) as gate_exc: await client.send_raw_request("tools/list", None) - assert gate_exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") + assert gate_exc.value.error == ErrorData( + code=INVALID_REQUEST, + message="Received request before initialization was complete", + data="The client must send an 'initialize' request before any other request.", + ) # ping passes through the middleware untouched assert await client.send_raw_request("ping", None) == {} assert runner.connection.initialize_accepted is False