diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 26e8efbe57..5010022b5d 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -186,15 +186,9 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: # Read method/params off `ctx` so a middleware that rewrote them via # `call_next(replace(ctx, ...))` reaches lookup and the handler. method, params = ctx.method, ctx.params - # Pinned compat: spec methods are surface-validated before lookup, - # so malformed params are INVALID_PARAMS even with no handler - # registered. Custom methods miss the monolith map and fall through - # to `entry.params_type` exactly as before. - if method in _methods.SPEC_CLIENT_METHODS: - try: - _methods.validate_client_request(method, version, params) - except KeyError: - raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None + # Spec version gate: if a spec method is not valid at this protocol version, reject it. + if method in _methods.SPEC_CLIENT_METHODS and (method, version) not in _methods.CLIENT_REQUESTS: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) # TODO(L29): the 2026-07-28 spec drops the handshake; this branch and # the gate become a per-version legacy path then. Initialize runs inline # (read loop parked), so awaiting the peer anywhere on this path deadlocks. @@ -211,6 +205,8 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: 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="") + if method in _methods.SPEC_CLIENT_METHODS: + _methods.validate_client_request(method, version, params) # 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_wire.py b/tests/interaction/lowlevel/test_wire.py index 8ee05fec38..159cc26db5 100644 --- a/tests/interaction/lowlevel/test_wire.py +++ b/tests/interaction/lowlevel/test_wire.py @@ -204,7 +204,12 @@ async def test_malformed_request_params_are_answered_with_invalid_params() -> No initialization handshake at the JSON-RPC layer and then sends a tools/call whose `name` is an integer. Reserve this pattern for behaviour the typed API cannot produce. """ + + async def dummy_handler(ctx: object, p: CallToolRequestParams) -> None: + pass + server = Server("strict") + server.add_request_handler("tools/call", CallToolRequestParams, dummy_handler) errors: list[ErrorData] = [] async with create_client_server_memory_streams() as (client_streams, server_streams): diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 50e77f7134..87a8fd6d12 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -295,12 +295,12 @@ async def test_runner_non_spec_method_with_no_handler_raises_method_not_found(se @pytest.mark.anyio -async def test_runner_malformed_params_for_unregistered_spec_method_raises_invalid_params(server: SrvT): - """A spec method with malformed params is INVALID_PARAMS even with no handler.""" +async def test_runner_unregistered_spec_method_raises_method_not_found(server: SrvT): + """An unregistered spec method returns METHOD_NOT_FOUND per JSON-RPC 2.0 (issue #3193).""" async with connected_runner(server) as (client, _): with pytest.raises(MCPError) as exc: await client.send_raw_request("tools/call", {"name": 123}) - assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") + assert exc.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="tools/call") @pytest.mark.anyio @@ -845,6 +845,10 @@ async def greet(ctx: Ctx, params: GreetParams) -> dict[str, Any]: @pytest.mark.anyio async def test_runner_spec_method_with_invalid_params_is_invalid_params_at_the_negotiated_version(server: SrvT): + async def dummy_handler(ctx: Ctx, p: CallToolRequestParams) -> None: + pass + + server.add_request_handler("tools/call", CallToolRequestParams, dummy_handler) async with connected_runner(server) as (client, runner): assert runner.connection.protocol_version == LATEST_HANDSHAKE_VERSION with pytest.raises(MCPError) as exc: