Skip to content

Commit 660407a

Browse files
committed
Use spec error codes for unhandled elicitation/create and roots/list
A client constructed without an elicitation_callback or list_roots_callback still answers the server's request, via a default callback that returns a JSON-RPC error. Both defaults used -32600 (invalid request). The spec assigns a specific code to each case: - elicitation/create: -32602 (invalid params). A client with no callback declares no elicitation modes, so every incoming request names an undeclared mode, which clients MUST answer with -32602. - roots/list: -32601 (method not found), the code clients SHOULD use when they do not support roots. The default sampling callback keeps -32600: the spec assigns no code to a client that does not support sampling. Error messages are unchanged. Update the affected tests and the interaction-requirement entries that recorded the old codes, fix the code named in the client callbacks doc page, and add a migration note.
1 parent db9530c commit 660407a

8 files changed

Lines changed: 43 additions & 29 deletions

File tree

docs/client/callbacks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pas
101101
MCPError: Elicitation not supported
102102
```
103103

104-
That is a protocol error (`-32600`, *invalid request*), not a tool error: there is nothing for
104+
That is a protocol error (`-32602`, *invalid params*), not a tool error: there is nothing for
105105
the model to read and retry. It's why `client_features` is worth having: a well-behaved server
106106
checks before it asks.
107107

docs/migration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,12 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer`
410410

411411
`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
412412

413+
### Unhandled `elicitation/create` returns `-32602`; unhandled `roots/list` returns `-32601`
414+
415+
When a server sends `elicitation/create` to a client that registered no `elicitation_callback`, or `roots/list` to a client that registered no `list_roots_callback`, the SDK still answers on the client's behalf with a JSON-RPC error. In v1 both answers used code `-32600` (`INVALID_REQUEST`). They now use the code the spec assigns to each case: `elicitation/create` is answered with `-32602` (`INVALID_PARAMS`), per the [elicitation error-handling section](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#error-handling) (a client with no callback declared no elicitation modes, and a request for an undeclared mode MUST be answered with `-32602`), and `roots/list` is answered with `-32601` (`METHOD_NOT_FOUND`), per the [roots error-handling section](https://modelcontextprotocol.io/specification/2025-11-25/client/roots#error-handling). The error messages (`Elicitation not supported`, `List roots not supported`) are unchanged, and `sampling/createMessage` without a `sampling_callback` still answers `-32600` — the spec assigns no code to that case.
416+
417+
Server-side code that branched on `error.code == INVALID_REQUEST` to detect a client without elicitation or roots support should switch to `INVALID_PARAMS` and `METHOD_NOT_FOUND` respectively — or, better, check the client's declared capabilities before sending, which is the condition these codes describe.
418+
413419
### `call_tool` can return `InputRequiredResult` (opt-in)
414420

415421
For protocol 2026-07-28, a `tools/call` request may return an `InputRequiredResult` asking the client to supply additional input and retry. By default `call_tool` (on `ClientSession`, `Client`, and `ClientSessionGroup`) still returns `CallToolResult` and raises `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then retry with `input_responses=` / `request_state=`.

src/mcp/client/session.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ async def _default_sampling_callback(
149149
context: ClientRequestContext,
150150
params: types.CreateMessageRequestParams,
151151
) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData:
152+
# Unlike elicitation (INVALID_PARAMS) and roots (METHOD_NOT_FOUND) below, the spec assigns no
153+
# error code to a client that does not support sampling; INVALID_REQUEST is the SDK's choice.
152154
return types.ErrorData(
153155
code=types.INVALID_REQUEST,
154156
message="Sampling not supported",
@@ -160,7 +162,7 @@ async def _default_elicitation_callback(
160162
params: types.ElicitRequestParams,
161163
) -> types.ElicitResult | types.ErrorData:
162164
return types.ErrorData(
163-
code=types.INVALID_REQUEST,
165+
code=types.INVALID_PARAMS,
164166
message="Elicitation not supported",
165167
)
166168

@@ -169,7 +171,7 @@ async def _default_list_roots_callback(
169171
context: ClientRequestContext,
170172
) -> types.ListRootsResult | types.ErrorData:
171173
return types.ErrorData(
172-
code=types.INVALID_REQUEST,
174+
code=types.METHOD_NOT_FOUND,
173175
message="List roots not supported",
174176
)
175177

tests/client/test_list_roots_callback.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import pytest
2-
from mcp_types import INVALID_REQUEST, ListRootsResult, Root, TextContent
2+
from mcp_types import METHOD_NOT_FOUND, ListRootsResult, Root, TextContent
33
from pydantic import FileUrl
44

55
from mcp import Client
@@ -44,4 +44,4 @@ async def test_list_roots(context: Context, message: str):
4444
async with Client(server, mode="legacy") as client:
4545
with pytest.raises(MCPError) as exc_info:
4646
await client.call_tool("test_list_roots", {"message": "test message"})
47-
assert exc_info.value.error.code == INVALID_REQUEST
47+
assert exc_info.value.error.code == METHOD_NOT_FOUND

tests/docs_src/test_client_callbacks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from inline_snapshot import snapshot
55
from mcp_types import (
6+
INVALID_PARAMS,
67
INVALID_REQUEST,
78
CreateMessageRequestParams,
89
CreateMessageResult,
@@ -74,7 +75,7 @@ async def test_without_the_callback_the_servers_request_is_refused() -> None:
7475
async with Client(tutorial001.mcp, mode="legacy") as client:
7576
with pytest.raises(MCPError, match="Elicitation not supported") as exc_info:
7677
await client.call_tool("issue_card")
77-
assert exc_info.value.error.code == INVALID_REQUEST
78+
assert exc_info.value.error.code == INVALID_PARAMS
7879

7980

8081
async def test_registering_the_callback_declares_the_capability() -> None:

tests/interaction/_requirements.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1757,9 +1757,6 @@ def __post_init__(self) -> None:
17571757
"An elicitation request to a client that did not declare the elicitation capability is "
17581758
"answered with -32602 Invalid params."
17591759
),
1760-
divergence=Divergence(
1761-
note="The client's default callback answers with -32600 Invalid request instead of -32602.",
1762-
),
17631760
arm_exclusions=(
17641761
ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),
17651762
ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"),
@@ -1967,9 +1964,6 @@ def __post_init__(self) -> None:
19671964
"A roots/list request to a client that did not declare the roots capability is answered with "
19681965
"-32601 Method not found."
19691966
),
1970-
divergence=Divergence(
1971-
note="The client's default callback answers with -32600 Invalid request instead of -32601.",
1972-
),
19731967
arm_exclusions=(
19741968
ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),
19751969
ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"),

tests/interaction/lowlevel/test_elicitation.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010
from inline_snapshot import snapshot
1111
from mcp_types import (
12+
INVALID_PARAMS,
1213
CallToolResult,
1314
ElicitCompleteNotification,
1415
ElicitCompleteNotificationParams,
@@ -160,14 +161,13 @@ async def answer_form(context: ClientRequestContext, params: types.ElicitRequest
160161

161162
@requirement("elicitation:form:not-supported")
162163
@requirement("elicitation:capability:server-respects-mode")
163-
async def test_elicit_form_without_callback_is_error(connect: Connect) -> None:
164-
"""Eliciting from a client that configured no elicitation callback fails with an error.
165-
166-
The client's default callback answers with an Invalid request error, which the server-side
167-
elicit call raises as an MCPError; the tool reports the code and message it caught. The spec
168-
requires -32602 for an undeclared mode (see the divergence note on the requirement). The
169-
request reaching the client also shows the server does not check the client's declared
170-
elicitation capability before sending (see the divergence on `server-respects-mode`).
164+
async def test_elicit_form_without_callback_fails_with_invalid_params(connect: Connect) -> None:
165+
"""Eliciting from a client that configured no elicitation callback fails with `INVALID_PARAMS`.
166+
167+
Spec-mandated (MUST): an elicitation/create whose mode the client never declared is answered
168+
with -32602 Invalid params, and a client with no callback declared no elicitation modes at all.
169+
The request reaching the client at all also shows the server does not check the client's
170+
declared elicitation capability before sending (see the divergence on `server-respects-mode`).
171171
"""
172172

173173
async def list_tools(
@@ -177,20 +177,25 @@ async def list_tools(
177177
tools=[types.Tool(name="ask", description="Ask the user.", input_schema={"type": "object"})]
178178
)
179179

180+
errors: list[ErrorData] = []
181+
180182
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
181183
assert params.name == "ask"
182184
try:
183185
await ctx.session.elicit_form("Anyone there?", {"type": "object", "properties": {}})
184186
except MCPError as exc:
185-
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
187+
errors.append(exc.error)
188+
return CallToolResult(content=[TextContent(text=exc.error.message)])
186189
raise NotImplementedError # elicit_form cannot succeed without a client callback
187190

188191
server = Server("asker", on_list_tools=list_tools, on_call_tool=call_tool)
189192

190193
async with connect(server) as client:
191194
result = await client.call_tool("ask", {})
192195

193-
assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: Elicitation not supported")]))
196+
assert result == snapshot(CallToolResult(content=[TextContent(text="Elicitation not supported")]))
197+
(error,) = errors
198+
assert error.code == INVALID_PARAMS
194199

195200

196201
@requirement("elicitation:url:action:accept-no-content")

tests/interaction/lowlevel/test_roots.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import mcp_types as types
55
import pytest
66
from inline_snapshot import snapshot
7-
from mcp_types import INTERNAL_ERROR, CallToolResult, ErrorData, ListRootsResult, Root, TextContent
7+
from mcp_types import INTERNAL_ERROR, METHOD_NOT_FOUND, CallToolResult, ErrorData, ListRootsResult, Root, TextContent
88
from pydantic import FileUrl
99

1010
from mcp import MCPError
@@ -80,32 +80,38 @@ async def list_roots(context: ClientRequestContext) -> ListRootsResult:
8080

8181

8282
@requirement("roots:list:not-supported")
83-
async def test_list_roots_without_callback_is_error(connect: Connect) -> None:
84-
"""A roots/list request to a client with no roots callback fails with an error the handler can observe.
83+
async def test_list_roots_without_callback_fails_with_method_not_found(connect: Connect) -> None:
84+
"""A roots/list request to a client with no roots callback fails with `METHOD_NOT_FOUND`.
8585
86-
The client's default callback answers with INVALID_REQUEST rather than leaving the server
87-
hanging; the spec names -32601 for this case (see the divergence note on the requirement).
86+
Spec-recommended (SHOULD): a client that does not support roots answers roots/list with
87+
-32601 Method not found. The error reaches the requesting server handler as an `MCPError`
88+
rather than leaving it hanging.
8889
"""
8990

9091
async def list_tools(
9192
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
9293
) -> types.ListToolsResult:
9394
return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})])
9495

96+
errors: list[ErrorData] = []
97+
9598
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
9699
assert params.name == "show_roots"
97100
try:
98101
await ctx.session.list_roots() # pyright: ignore[reportDeprecated]
99102
except MCPError as exc:
100-
return CallToolResult(content=[TextContent(text=f"{exc.error.code}: {exc.error.message}")])
103+
errors.append(exc.error)
104+
return CallToolResult(content=[TextContent(text=exc.error.message)])
101105
raise NotImplementedError # list_roots cannot succeed without a client callback
102106

103107
server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool)
104108

105109
async with connect(server) as client:
106110
result = await client.call_tool("show_roots", {})
107111

108-
assert result == snapshot(CallToolResult(content=[TextContent(text="-32600: List roots not supported")]))
112+
assert result == snapshot(CallToolResult(content=[TextContent(text="List roots not supported")]))
113+
(error,) = errors
114+
assert error.code == METHOD_NOT_FOUND
109115

110116

111117
@requirement("roots:list:client-error")

0 commit comments

Comments
 (0)