Skip to content

Commit 557f36b

Browse files
committed
Share the tools-mode predicate across all sampling senders
The resolver path, the deprecated session.create_message, and the peer sample API each hand-rolled the tools-mode test; the latter two still keyed the answer model on tools alone, so a tool_choice-only request was gated as tools-mode but its array-capable answer failed validation. One wants_sampling_tools predicate in mcp.server.validation now drives the capability check and the result model everywhere reachable, with the overloads updated to match and tests on each surface.
1 parent 5fbf766 commit 557f36b

7 files changed

Lines changed: 58 additions & 22 deletions

File tree

src/mcp/server/mcpserver/resolve.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
from mcp.server.mcpserver.context import Context
8181
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
8282
from mcp.server.request_state import compact_json
83-
from mcp.server.validation import validate_tool_use_result_messages
83+
from mcp.server.validation import validate_tool_use_result_messages, wants_sampling_tools
8484
from mcp.shared._callable_inspection import is_async_callable
8585
from mcp.shared.exceptions import MCPError
8686
from mcp.shared.message import ServerMessageMetadata
@@ -680,7 +680,7 @@ def _require_capability(context: Context[Any, Any], marker: _Marker, key: str) -
680680
name = "form elicitation"
681681
elif isinstance(marker, Sample):
682682
sampling = capabilities.sampling if capabilities is not None else None
683-
wants_tools = _wants_tools(marker.params)
683+
wants_tools = wants_sampling_tools(marker.params.tools, marker.params.tool_choice)
684684
if sampling is not None and (not wants_tools or sampling.tools is not None):
685685
return
686686
required = ClientCapabilities(
@@ -710,18 +710,17 @@ def _render_request(marker: _Marker) -> InputRequest:
710710
return ListRootsRequest()
711711

712712

713-
def _wants_tools(params: CreateMessageRequestParams) -> bool:
714-
"""Whether a sampling request is tools-mode: `sampling.tools` gated, array-capable answer."""
715-
return params.tools is not None or params.tool_choice is not None
716-
717-
718713
def _result_type(
719714
marker: Sample | ListRoots,
720715
) -> type[CreateMessageResult] | type[CreateMessageResultWithTools] | type[ListRootsResult]:
721716
"""The result model a `Sample`/`ListRoots` response must validate against."""
722717
if isinstance(marker, ListRoots):
723718
return ListRootsResult
724-
return CreateMessageResultWithTools if _wants_tools(marker.params) else CreateMessageResult
719+
return (
720+
CreateMessageResultWithTools
721+
if wants_sampling_tools(marker.params.tools, marker.params.tool_choice)
722+
else CreateMessageResult
723+
)
725724

726725

727726
class _StateEntry(BaseModel):

src/mcp/server/session.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from typing_extensions import deprecated
1515

1616
from mcp.server.connection import Connection
17-
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
17+
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages, wants_sampling_tools
1818
from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT
1919
from mcp.shared.exceptions import MCPDeprecationWarning
2020
from mcp.shared.message import ServerMessageMetadata
@@ -146,10 +146,10 @@ async def create_message(
146146
metadata: dict[str, Any] | None = None,
147147
model_preferences: types.ModelPreferences | None = None,
148148
tools: None = None,
149-
tool_choice: types.ToolChoice | None = None,
149+
tool_choice: None = None,
150150
related_request_id: types.RequestId | None = None,
151151
) -> types.CreateMessageResult:
152-
"""Overload: Without tools, returns single content."""
152+
"""Overload: Without tools or tool_choice, returns single content."""
153153
...
154154

155155
@overload
@@ -165,11 +165,11 @@ async def create_message(
165165
stop_sequences: list[str] | None = None,
166166
metadata: dict[str, Any] | None = None,
167167
model_preferences: types.ModelPreferences | None = None,
168-
tools: list[types.Tool],
168+
tools: list[types.Tool] | None = None,
169169
tool_choice: types.ToolChoice | None = None,
170170
related_request_id: types.RequestId | None = None,
171171
) -> types.CreateMessageResultWithTools:
172-
"""Overload: With tools, returns array-capable content."""
172+
"""Overload: With tools or tool_choice, returns array-capable content."""
173173
...
174174

175175
@deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
@@ -236,7 +236,7 @@ async def create_message(
236236
)
237237
metadata_obj = ServerMessageMetadata(related_request_id=related_request_id)
238238

239-
if tools is not None:
239+
if wants_sampling_tools(tools, tool_choice):
240240
return await self.send_request(
241241
request=request,
242242
result_type=types.CreateMessageResultWithTools,

src/mcp/server/validation.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> b
2626
return True
2727

2828

29+
def wants_sampling_tools(tools: list[Tool] | None, tool_choice: ToolChoice | None) -> bool:
30+
"""Whether a sampling request is tools-mode: `sampling.tools` gated, array-capable answer."""
31+
return tools is not None or tool_choice is not None
32+
33+
2934
def validate_sampling_tools(
3035
client_caps: ClientCapabilities | None,
3136
tools: list[Tool] | None,
@@ -41,7 +46,7 @@ def validate_sampling_tools(
4146
Raises:
4247
MCPError: If tools/tool_choice are provided but client doesn't support them
4348
"""
44-
if tools is not None or tool_choice is not None:
49+
if wants_sampling_tools(tools, tool_choice):
4550
if not check_sampling_tools_capability(client_caps):
4651
raise MCPError(code=INVALID_PARAMS, message="Client does not support sampling tools capability")
4752

src/mcp/shared/peer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ async def sample(
9898
metadata: dict[str, Any] | None = None,
9999
model_preferences: ModelPreferences | None = None,
100100
tools: None = None,
101-
tool_choice: ToolChoice | None = None,
101+
tool_choice: None = None,
102102
meta: Meta | None = None,
103103
opts: CallOptions | None = None,
104104
) -> CreateMessageResult: ...
@@ -115,7 +115,7 @@ async def sample(
115115
stop_sequences: list[str] | None = None,
116116
metadata: dict[str, Any] | None = None,
117117
model_preferences: ModelPreferences | None = None,
118-
tools: list[Tool],
118+
tools: list[Tool] | None = None,
119119
tool_choice: ToolChoice | None = None,
120120
meta: Meta | None = None,
121121
opts: CallOptions | None = None,
@@ -157,7 +157,7 @@ async def sample(
157157
tool_choice=tool_choice,
158158
)
159159
result = await self.send_raw_request("sampling/createMessage", dump_params(params, meta), opts)
160-
if tools is not None:
160+
if tools is not None or tool_choice is not None:
161161
return CreateMessageResultWithTools.model_validate(result, by_name=False)
162162
return CreateMessageResult.model_validate(result, by_name=False)
163163

tests/server/mcpserver/test_resolve.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2757,7 +2757,8 @@ async def tool(login: Annotated[Login, Resolve(ask)]) -> str:
27572757

27582758

27592759
@pytest.mark.anyio
2760-
async def test_tool_choice_only_sample_validates_as_tools_mode():
2760+
@pytest.mark.parametrize("mode", ["legacy", "auto"])
2761+
async def test_tool_choice_only_sample_validates_as_tools_mode(mode: Literal["legacy", "auto"]):
27612762
# Gate and answer model share one predicate: tool_choice alone is tools-mode,
27622763
# so a single-content answer still validates (WithTools accepts both shapes).
27632764
mcp = MCPServer(name="ToolChoiceOnly", request_state_security=RequestStateSecurity.ephemeral())
@@ -2769,12 +2770,12 @@ async def sampler(context: ClientRequestContext, params: CreateMessageRequestPar
27692770
@mcp.tool()
27702771
async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tool_choice)]) -> str:
27712772
assert isinstance(answer, CreateMessageResultWithTools)
2772-
content = answer.content[0] if isinstance(answer.content, list) else answer.content
2773-
assert isinstance(content, TextContent)
2774-
return content.text
2773+
assert isinstance(answer.content, TextContent)
2774+
return answer.content.text
27752775

27762776
async with Client(
27772777
mcp,
2778+
mode=mode,
27782779
sampling_callback=sampler,
27792780
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
27802781
) as client:

tests/server/test_session.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,21 @@ async def test_create_message_with_tools_returns_with_tools_result():
225225
assert params is not None and params["tools"][0]["name"] == "t"
226226

227227

228+
@pytest.mark.anyio
229+
async def test_create_message_with_tool_choice_only_returns_with_tools_result():
230+
# tool_choice alone is tools-mode: the answer may carry array content.
231+
outbound = StubOutbound(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"})
232+
session = _make_session(
233+
outbound, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability()))
234+
)
235+
result = await session.create_message( # pyright: ignore[reportDeprecated]
236+
messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))],
237+
max_tokens=10,
238+
tool_choice=types.ToolChoice(mode="none"),
239+
)
240+
assert isinstance(result, types.CreateMessageResultWithTools)
241+
242+
228243
def test_check_client_capability_delegates_to_connection():
229244
outbound = StubOutbound()
230245
session = _make_session(outbound, capabilities=ClientCapabilities(sampling=SamplingCapability()))

tests/shared/test_peer.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
SamplingMessage,
1919
TextContent,
2020
Tool,
21+
ToolChoice,
2122
)
2223

2324
from mcp.shared.dispatcher import DispatchContext
@@ -91,6 +92,21 @@ async def test_peer_sample_with_tools_returns_with_tools_result():
9192
assert isinstance(result, CreateMessageResultWithTools)
9293

9394

95+
@pytest.mark.anyio
96+
async def test_peer_sample_with_tool_choice_only_returns_with_tools_result():
97+
# tool_choice alone is tools-mode: the answer may carry array content.
98+
rec = _Recorder({"role": "assistant", "content": [{"type": "text", "text": "x"}], "model": "m"})
99+
async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_):
100+
peer = ClientPeer(client)
101+
with anyio.fail_after(5):
102+
result = await peer.sample( # pyright: ignore[reportDeprecated]
103+
[SamplingMessage(role="user", content=TextContent(type="text", text="q"))],
104+
max_tokens=5,
105+
tool_choice=ToolChoice(mode="none"),
106+
)
107+
assert isinstance(result, CreateMessageResultWithTools)
108+
109+
94110
@pytest.mark.anyio
95111
async def test_peer_elicit_form_sends_elicitation_create_with_form_params():
96112
rec = _Recorder({"action": "accept", "content": {"name": "Max"}})

0 commit comments

Comments
 (0)