Implement SEP-1577 - Sampling With Tools (#1594)

Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Olivier Chafik
2025-11-23 04:58:14 +00:00
committed by GitHub
parent c51936f61f
commit 71c475588f
9 changed files with 674 additions and 19 deletions
+2 -2
View File
@@ -886,8 +886,8 @@ async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str:
max_tokens=100,
)
if result.content.type == "text":
return result.content.text
if all(c.type == "text" for c in result.content_as_list):
return "\n".join(c.text for c in result.content_as_list if c.type == "text")
return str(result.content)
```
@@ -134,8 +134,8 @@ async def test_sampling(prompt: str, ctx: Context[ServerSession, None]) -> str:
max_tokens=100,
)
if result.content.type == "text":
model_response = result.content.text
if any(c.type == "text" for c in result.content_as_list):
model_response = "\n".join(c.text for c in result.content_as_list if c.type == "text")
else:
model_response = "No response"
+2 -2
View File
@@ -20,6 +20,6 @@ async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str:
max_tokens=100,
)
if result.content.type == "text":
return result.content.text
if all(c.type == "text" for c in result.content_as_list):
return "\n".join(c.text for c in result.content_as_list if c.type == "text")
return str(result.content)
+17 -3
View File
@@ -41,7 +41,11 @@ from .types import (
ResourcesCapability,
ResourceUpdatedNotification,
RootsCapability,
SamplingCapability,
SamplingContextCapability,
SamplingMessage,
SamplingMessageContentBlock,
SamplingToolsCapability,
ServerCapabilities,
ServerNotification,
ServerRequest,
@@ -50,7 +54,10 @@ from .types import (
StopReason,
SubscribeRequest,
Tool,
ToolChoice,
ToolResultContent,
ToolsCapability,
ToolUseContent,
UnsubscribeRequest,
)
from .types import (
@@ -65,6 +72,7 @@ __all__ = [
"ClientResult",
"ClientSession",
"ClientSessionGroup",
"CompleteRequest",
"CreateMessageRequest",
"CreateMessageResult",
"ErrorData",
@@ -77,6 +85,7 @@ __all__ = [
"InitializedNotification",
"JSONRPCError",
"JSONRPCRequest",
"JSONRPCResponse",
"ListPromptsRequest",
"ListPromptsResult",
"ListResourcesRequest",
@@ -91,12 +100,16 @@ __all__ = [
"PromptsCapability",
"ReadResourceRequest",
"ReadResourceResult",
"Resource",
"ResourcesCapability",
"ResourceUpdatedNotification",
"Resource",
"RootsCapability",
"SamplingCapability",
"SamplingContextCapability",
"SamplingMessage",
"SamplingMessageContentBlock",
"SamplingRole",
"SamplingToolsCapability",
"ServerCapabilities",
"ServerNotification",
"ServerRequest",
@@ -107,10 +120,11 @@ __all__ = [
"StopReason",
"SubscribeRequest",
"Tool",
"ToolChoice",
"ToolResultContent",
"ToolsCapability",
"ToolUseContent",
"UnsubscribeRequest",
"stdio_client",
"stdio_server",
"CompleteRequest",
"JSONRPCResponse",
]
+76 -1
View File
@@ -47,6 +47,7 @@ from pydantic import AnyUrl
import mcp.types as types
from mcp.server.models import InitializationOptions
from mcp.shared.exceptions import McpError
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.session import (
BaseSession,
@@ -120,6 +121,12 @@ class ServerSession(
if capability.sampling is not None:
if client_caps.sampling is None:
return False
if capability.sampling.context is not None:
if client_caps.sampling.context is None:
return False
if capability.sampling.tools is not None:
if client_caps.sampling.tools is None:
return False
if capability.elicitation is not None:
if client_caps.elicitation is None:
@@ -223,9 +230,75 @@ class ServerSession(
stop_sequences: list[str] | None = None,
metadata: dict[str, Any] | None = None,
model_preferences: types.ModelPreferences | None = None,
tools: list[types.Tool] | None = None,
tool_choice: types.ToolChoice | None = None,
related_request_id: types.RequestId | None = None,
) -> types.CreateMessageResult:
"""Send a sampling/create_message request."""
"""Send a sampling/create_message request.
Args:
messages: The conversation messages to send.
max_tokens: Maximum number of tokens to generate.
system_prompt: Optional system prompt.
include_context: Optional context inclusion setting.
Should only be set to "thisServer" or "allServers"
if the client has sampling.context capability.
temperature: Optional sampling temperature.
stop_sequences: Optional stop sequences.
metadata: Optional metadata to pass through to the LLM provider.
model_preferences: Optional model selection preferences.
tools: Optional list of tools the LLM can use during sampling.
Requires client to have sampling.tools capability.
tool_choice: Optional control over tool usage behavior.
Requires client to have sampling.tools capability.
related_request_id: Optional ID of a related request.
Returns:
The sampling result from the client.
Raises:
McpError: If tool_use or tool_result blocks are misused when tools are provided.
"""
if tools is not None or tool_choice is not None:
has_tools_cap = self.check_client_capability(
types.ClientCapabilities(sampling=types.SamplingCapability(tools=types.SamplingToolsCapability()))
)
if not has_tools_cap:
raise McpError(
types.ErrorData(
code=types.INVALID_PARAMS,
message="Client does not support sampling tools capability",
)
)
# Validate tool_use/tool_result message structure per SEP-1577:
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577
# This validation runs regardless of whether `tools` is in this request,
# since a tool loop continuation may omit `tools` while still containing
# tool_result content that must match previous tool_use.
if messages:
last_content = messages[-1].content_as_list
has_tool_results = any(c.type == "tool_result" for c in last_content)
previous_content = messages[-2].content_as_list if len(messages) >= 2 else None
has_previous_tool_use = previous_content and any(c.type == "tool_use" for c in previous_content)
if has_tool_results:
# Per spec: "SamplingMessage with tool result content blocks
# MUST NOT contain other content types."
if any(c.type != "tool_result" for c in last_content):
raise ValueError("The last message must contain only tool_result content if any is present")
if previous_content is None:
raise ValueError("tool_result requires a previous message containing tool_use")
if not has_previous_tool_use:
raise ValueError("tool_result blocks do not match any tool_use in the previous message")
if has_previous_tool_use and previous_content:
tool_use_ids = {c.id for c in previous_content if c.type == "tool_use"}
tool_result_ids = {c.toolUseId for c in last_content if c.type == "tool_result"}
if tool_use_ids != tool_result_ids:
raise ValueError("ids of tool_result blocks and tool_use blocks from previous message do not match")
return await self.send_request(
request=types.ServerRequest(
types.CreateMessageRequest(
@@ -238,6 +311,8 @@ class ServerSession(
stopSequences=stop_sequences,
metadata=metadata,
modelPreferences=model_preferences,
tools=tools,
toolChoice=tool_choice,
),
)
),
+171 -7
View File
@@ -250,8 +250,24 @@ class RootsCapability(BaseModel):
model_config = ConfigDict(extra="allow")
class SamplingCapability(BaseModel):
"""Capability for sampling operations."""
class SamplingContextCapability(BaseModel):
"""
Capability for context inclusion during sampling.
Indicates support for non-'none' values in the includeContext parameter.
SOFT-DEPRECATED: New implementations should use tools parameter instead.
"""
model_config = ConfigDict(extra="allow")
class SamplingToolsCapability(BaseModel):
"""
Capability indicating support for tool calling during sampling.
When present in ClientCapabilities.sampling, indicates that the client
supports the tools and toolChoice parameters in sampling requests.
"""
model_config = ConfigDict(extra="allow")
@@ -262,13 +278,34 @@ class ElicitationCapability(BaseModel):
model_config = ConfigDict(extra="allow")
class SamplingCapability(BaseModel):
"""
Sampling capability structure, allowing fine-grained capability advertisement.
"""
context: SamplingContextCapability | None = None
"""
Present if the client supports non-'none' values for includeContext parameter.
SOFT-DEPRECATED: New implementations should use tools parameter instead.
"""
tools: SamplingToolsCapability | None = None
"""
Present if the client supports tools and toolChoice parameters in sampling requests.
Presence indicates full tool calling support during sampling.
"""
model_config = ConfigDict(extra="allow")
class ClientCapabilities(BaseModel):
"""Capabilities a client may support."""
experimental: dict[str, dict[str, Any]] | None = None
"""Experimental, non-standard capabilities that the client supports."""
sampling: SamplingCapability | None = None
"""Present if the client supports sampling from an LLM."""
"""
Present if the client supports sampling from an LLM.
Can contain fine-grained capabilities like context and tools support.
"""
elicitation: ElicitationCapability | None = None
"""Present if the client supports elicitation from the user."""
roots: RootsCapability | None = None
@@ -742,13 +779,97 @@ class AudioContent(BaseModel):
model_config = ConfigDict(extra="allow")
class ToolUseContent(BaseModel):
"""
Content representing an assistant's request to invoke a tool.
This content type appears in assistant messages when the LLM wants to call a tool
during sampling. The server should execute the tool and return a ToolResultContent
in the next user message.
"""
type: Literal["tool_use"]
"""Discriminator for tool use content."""
name: str
"""The name of the tool to invoke. Must match a tool name from the request's tools array."""
id: str
"""Unique identifier for this tool call, used to correlate with ToolResultContent."""
input: dict[str, Any]
"""Arguments to pass to the tool. Must conform to the tool's inputSchema."""
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
"""
See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
for notes on _meta usage.
"""
model_config = ConfigDict(extra="allow")
class ToolResultContent(BaseModel):
"""
Content representing the result of a tool execution.
This content type appears in user messages as a response to a ToolUseContent
from the assistant. It contains the output of executing the requested tool.
"""
type: Literal["tool_result"]
"""Discriminator for tool result content."""
toolUseId: str
"""The unique identifier that corresponds to the tool call's id field."""
content: list["ContentBlock"] = []
"""
A list of content objects representing the tool result.
Defaults to empty list if not provided.
"""
structuredContent: dict[str, Any] | None = None
"""
Optional structured tool output that matches the tool's outputSchema (if defined).
"""
isError: bool | None = None
"""Whether the tool execution resulted in an error."""
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
"""
See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
for notes on _meta usage.
"""
model_config = ConfigDict(extra="allow")
SamplingMessageContentBlock: TypeAlias = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent
"""Content block types allowed in sampling messages."""
class SamplingMessage(BaseModel):
"""Describes a message issued to or received from an LLM API."""
role: Role
content: TextContent | ImageContent | AudioContent
content: SamplingMessageContentBlock | list[SamplingMessageContentBlock]
"""
Message content. Can be a single content block or an array of content blocks
for multi-modal messages and tool interactions.
"""
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
"""
See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
for notes on _meta usage.
"""
model_config = ConfigDict(extra="allow")
@property
def content_as_list(self) -> list[SamplingMessageContentBlock]:
"""Returns the content as a list of content blocks, regardless of whether
it was originally a single block or a list."""
return self.content if isinstance(self.content, list) else [self.content]
class EmbeddedResource(BaseModel):
"""
@@ -1035,6 +1156,25 @@ class ModelPreferences(BaseModel):
model_config = ConfigDict(extra="allow")
class ToolChoice(BaseModel):
"""
Controls tool usage behavior during sampling.
Allows the server to specify whether and how the LLM should use tools
in its response.
"""
mode: Literal["auto", "required", "none"] | None = None
"""
Controls when tools are used:
- "auto": Model decides whether to use tools (default)
- "required": Model MUST use at least one tool before completing
- "none": Model should not use tools
"""
model_config = ConfigDict(extra="allow")
class CreateMessageRequestParams(RequestParams):
"""Parameters for creating a message."""
@@ -1057,6 +1197,16 @@ class CreateMessageRequestParams(RequestParams):
stopSequences: list[str] | None = None
metadata: dict[str, Any] | None = None
"""Optional metadata to pass through to the LLM provider."""
tools: list["Tool"] | None = None
"""
Tool definitions for the LLM to use during sampling.
Requires clientCapabilities.sampling.tools to be present.
"""
toolChoice: ToolChoice | None = None
"""
Controls tool usage behavior.
Requires clientCapabilities.sampling.tools and the tools parameter to be present.
"""
model_config = ConfigDict(extra="allow")
@@ -1067,18 +1217,32 @@ class CreateMessageRequest(Request[CreateMessageRequestParams, Literal["sampling
params: CreateMessageRequestParams
StopReason = Literal["endTurn", "stopSequence", "maxTokens"] | str
StopReason = Literal["endTurn", "stopSequence", "maxTokens", "toolUse"] | str
class CreateMessageResult(Result):
"""The client's response to a sampling/create_message request from the server."""
role: Role
content: TextContent | ImageContent | AudioContent
"""The role of the message sender (typically 'assistant' for LLM responses)."""
content: SamplingMessageContentBlock | list[SamplingMessageContentBlock]
"""
Response content. May be a single content block or an array.
May include ToolUseContent if stopReason is 'toolUse'.
"""
model: str
"""The name of the model that generated the message."""
stopReason: StopReason | None = None
"""The reason why sampling stopped, if known."""
"""
The reason why sampling stopped, if known.
'toolUse' indicates the model wants to use a tool.
"""
@property
def content_as_list(self) -> list[SamplingMessageContentBlock]:
"""Returns the content as a list of content blocks, regardless of whether
it was originally a single block or a list."""
return self.content if isinstance(self.content, list) else [self.content]
class ResourceTemplateReference(BaseModel):
+179
View File
@@ -9,6 +9,7 @@ from mcp.server import Server
from mcp.server.lowlevel import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.shared.exceptions import McpError
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import (
@@ -288,6 +289,184 @@ async def test_ping_request_before_initialization():
assert ping_response_id == 42
@pytest.mark.anyio
async def test_create_message_tool_result_validation():
"""Test tool_use/tool_result validation in create_message."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1)
async with (
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test",
server_version="0.1.0",
capabilities=ServerCapabilities(),
),
) as session:
# Set up client params with sampling.tools capability for the test
session._client_params = types.InitializeRequestParams(
protocolVersion=types.LATEST_PROTOCOL_VERSION,
capabilities=types.ClientCapabilities(
sampling=types.SamplingCapability(tools=types.SamplingToolsCapability())
),
clientInfo=types.Implementation(name="test", version="1.0"),
)
tool = types.Tool(name="test_tool", inputSchema={"type": "object"})
text = types.TextContent(type="text", text="hello")
tool_use = types.ToolUseContent(type="tool_use", id="call_1", name="test_tool", input={})
tool_result = types.ToolResultContent(type="tool_result", toolUseId="call_1", content=[])
# Case 1: tool_result mixed with other content
with pytest.raises(ValueError, match="only tool_result content"):
await session.create_message(
messages=[
types.SamplingMessage(role="user", content=text),
types.SamplingMessage(role="assistant", content=tool_use),
types.SamplingMessage(role="user", content=[tool_result, text]), # mixed!
],
max_tokens=100,
tools=[tool],
)
# Case 2: tool_result without previous message
with pytest.raises(ValueError, match="requires a previous message"):
await session.create_message(
messages=[types.SamplingMessage(role="user", content=tool_result)],
max_tokens=100,
tools=[tool],
)
# Case 3: tool_result without previous tool_use
with pytest.raises(ValueError, match="do not match any tool_use"):
await session.create_message(
messages=[
types.SamplingMessage(role="user", content=text),
types.SamplingMessage(role="user", content=tool_result),
],
max_tokens=100,
tools=[tool],
)
# Case 4: mismatched tool IDs
with pytest.raises(ValueError, match="ids of tool_result blocks and tool_use blocks"):
await session.create_message(
messages=[
types.SamplingMessage(role="user", content=text),
types.SamplingMessage(role="assistant", content=tool_use),
types.SamplingMessage(
role="user",
content=types.ToolResultContent(type="tool_result", toolUseId="wrong_id", content=[]),
),
],
max_tokens=100,
tools=[tool],
)
# Case 5: text-only message with tools (no tool_results) - passes validation
# Covers has_tool_results=False branch.
# We use move_on_after because validation happens synchronously before
# send_request, which would block indefinitely waiting for a response.
# The timeout lets validation pass, then cancels the blocked send.
with anyio.move_on_after(0.01):
await session.create_message(
messages=[types.SamplingMessage(role="user", content=text)],
max_tokens=100,
tools=[tool],
)
# Case 6: valid matching tool_result/tool_use IDs - passes validation
# Covers tool_use_ids == tool_result_ids branch.
# (see Case 5 comment for move_on_after explanation)
with anyio.move_on_after(0.01):
await session.create_message(
messages=[
types.SamplingMessage(role="user", content=text),
types.SamplingMessage(role="assistant", content=tool_use),
types.SamplingMessage(role="user", content=tool_result),
],
max_tokens=100,
tools=[tool],
)
# Case 7: validation runs even without `tools` parameter
# (tool loop continuation may omit tools while containing tool_result)
with pytest.raises(ValueError, match="do not match any tool_use"):
await session.create_message(
messages=[
types.SamplingMessage(role="user", content=text),
types.SamplingMessage(role="user", content=tool_result),
],
max_tokens=100,
# Note: no tools parameter
)
# Case 8: empty messages list - skips validation entirely
# Covers the `if messages:` branch (line 280->302)
with anyio.move_on_after(0.01):
await session.create_message(
messages=[],
max_tokens=100,
)
@pytest.mark.anyio
async def test_create_message_without_tools_capability():
"""Test that create_message raises McpError when tools are provided without capability."""
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream[SessionMessage](1)
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream[SessionMessage | Exception](1)
async with (
client_to_server_send,
client_to_server_receive,
server_to_client_send,
server_to_client_receive,
):
async with ServerSession(
client_to_server_receive,
server_to_client_send,
InitializationOptions(
server_name="test",
server_version="0.1.0",
capabilities=ServerCapabilities(),
),
) as session:
# Set up client params WITHOUT sampling.tools capability
session._client_params = types.InitializeRequestParams(
protocolVersion=types.LATEST_PROTOCOL_VERSION,
capabilities=types.ClientCapabilities(sampling=types.SamplingCapability()),
clientInfo=types.Implementation(name="test", version="1.0"),
)
tool = types.Tool(name="test_tool", inputSchema={"type": "object"})
text = types.TextContent(type="text", text="hello")
# Should raise McpError when tools are provided but client lacks capability
with pytest.raises(McpError) as exc_info:
await session.create_message(
messages=[types.SamplingMessage(role="user", content=text)],
max_tokens=100,
tools=[tool],
)
assert "does not support sampling tools capability" in exc_info.value.error.message
# Should also raise McpError when tool_choice is provided
with pytest.raises(McpError) as exc_info:
await session.create_message(
messages=[types.SamplingMessage(role="user", content=text)],
max_tokens=100,
tool_choice=types.ToolChoice(mode="auto"),
)
assert "does not support sampling tools capability" in exc_info.value.error.message
@pytest.mark.anyio
async def test_other_requests_blocked_before_initialization():
"""Test that non-ping requests are still blocked before initialization."""
+6 -2
View File
@@ -210,7 +210,10 @@ class ServerTest(Server): # pragma: no cover
)
# Return the sampling result in the tool response
response = sampling_result.content.text if sampling_result.content.type == "text" else None
if all(c.type == "text" for c in sampling_result.content_as_list):
response = "\n".join(c.text for c in sampling_result.content_as_list if c.type == "text")
else:
response = str(sampling_result.content)
return [
TextContent(
type="text",
@@ -1239,7 +1242,8 @@ async def test_streamablehttp_server_sampling(basic_server: None, basic_server_u
nonlocal sampling_callback_invoked, captured_message_params
sampling_callback_invoked = True
captured_message_params = params
message_received = params.messages[0].content.text if params.messages[0].content.type == "text" else None
msg_content = params.messages[0].content_as_list[0]
message_received = msg_content.text if msg_content.type == "text" else None
return types.CreateMessageResult(
role="assistant",
+219
View File
@@ -1,16 +1,26 @@
from typing import Any
import pytest
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
ClientRequest,
CreateMessageRequestParams,
CreateMessageResult,
Implementation,
InitializeRequest,
InitializeRequestParams,
JSONRPCMessage,
JSONRPCRequest,
ListToolsResult,
SamplingCapability,
SamplingMessage,
TextContent,
Tool,
ToolChoice,
ToolResultContent,
ToolUseContent,
)
@@ -60,6 +70,215 @@ async def test_method_initialization():
assert initialize_request.params.protocolVersion == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
async def test_tool_use_content():
"""Test ToolUseContent type for SEP-1577."""
tool_use_data = {
"type": "tool_use",
"name": "get_weather",
"id": "call_abc123",
"input": {"location": "San Francisco", "unit": "celsius"},
}
tool_use = ToolUseContent.model_validate(tool_use_data)
assert tool_use.type == "tool_use"
assert tool_use.name == "get_weather"
assert tool_use.id == "call_abc123"
assert tool_use.input == {"location": "San Francisco", "unit": "celsius"}
# Test serialization
serialized = tool_use.model_dump(by_alias=True, exclude_none=True)
assert serialized["type"] == "tool_use"
assert serialized["name"] == "get_weather"
@pytest.mark.anyio
async def test_tool_result_content():
"""Test ToolResultContent type for SEP-1577."""
tool_result_data = {
"type": "tool_result",
"toolUseId": "call_abc123",
"content": [{"type": "text", "text": "It's 72°F in San Francisco"}],
"isError": False,
}
tool_result = ToolResultContent.model_validate(tool_result_data)
assert tool_result.type == "tool_result"
assert tool_result.toolUseId == "call_abc123"
assert len(tool_result.content) == 1
assert tool_result.isError is False
# Test with empty content (should default to [])
minimal_result_data = {"type": "tool_result", "toolUseId": "call_xyz"}
minimal_result = ToolResultContent.model_validate(minimal_result_data)
assert minimal_result.content == []
@pytest.mark.anyio
async def test_tool_choice():
"""Test ToolChoice type for SEP-1577."""
# Test with mode
tool_choice_data = {"mode": "required"}
tool_choice = ToolChoice.model_validate(tool_choice_data)
assert tool_choice.mode == "required"
# Test with minimal data (all fields optional)
minimal_choice = ToolChoice.model_validate({})
assert minimal_choice.mode is None
# Test different modes
auto_choice = ToolChoice.model_validate({"mode": "auto"})
assert auto_choice.mode == "auto"
none_choice = ToolChoice.model_validate({"mode": "none"})
assert none_choice.mode == "none"
@pytest.mark.anyio
async def test_sampling_message_with_user_role():
"""Test SamplingMessage with user role for SEP-1577."""
# Test with single content
user_msg_data = {"role": "user", "content": {"type": "text", "text": "Hello"}}
user_msg = SamplingMessage.model_validate(user_msg_data)
assert user_msg.role == "user"
assert isinstance(user_msg.content, TextContent)
# Test with array of content including tool result
multi_content_data: dict[str, Any] = {
"role": "user",
"content": [
{"type": "text", "text": "Here's the result:"},
{"type": "tool_result", "toolUseId": "call_123", "content": []},
],
}
multi_msg = SamplingMessage.model_validate(multi_content_data)
assert multi_msg.role == "user"
assert isinstance(multi_msg.content, list)
assert len(multi_msg.content) == 2
@pytest.mark.anyio
async def test_sampling_message_with_assistant_role():
"""Test SamplingMessage with assistant role for SEP-1577."""
# Test with tool use content
assistant_msg_data = {
"role": "assistant",
"content": {
"type": "tool_use",
"name": "search",
"id": "call_456",
"input": {"query": "MCP protocol"},
},
}
assistant_msg = SamplingMessage.model_validate(assistant_msg_data)
assert assistant_msg.role == "assistant"
assert isinstance(assistant_msg.content, ToolUseContent)
# Test with array of mixed content
multi_content_data: dict[str, Any] = {
"role": "assistant",
"content": [
{"type": "text", "text": "Let me search for that..."},
{"type": "tool_use", "name": "search", "id": "call_789", "input": {}},
],
}
multi_msg = SamplingMessage.model_validate(multi_content_data)
assert isinstance(multi_msg.content, list)
assert len(multi_msg.content) == 2
@pytest.mark.anyio
async def test_sampling_message_backward_compatibility():
"""Test that SamplingMessage maintains backward compatibility."""
# Old-style message (single content, no tools)
old_style_data = {"role": "user", "content": {"type": "text", "text": "Hello"}}
old_msg = SamplingMessage.model_validate(old_style_data)
assert old_msg.role == "user"
assert isinstance(old_msg.content, TextContent)
# New-style message with tool content
new_style_data: dict[str, Any] = {
"role": "assistant",
"content": {"type": "tool_use", "name": "test", "id": "call_1", "input": {}},
}
new_msg = SamplingMessage.model_validate(new_style_data)
assert new_msg.role == "assistant"
assert isinstance(new_msg.content, ToolUseContent)
# Array content
array_style_data: dict[str, Any] = {
"role": "user",
"content": [{"type": "text", "text": "Result:"}, {"type": "tool_result", "toolUseId": "call_1", "content": []}],
}
array_msg = SamplingMessage.model_validate(array_style_data)
assert isinstance(array_msg.content, list)
@pytest.mark.anyio
async def test_create_message_request_params_with_tools():
"""Test CreateMessageRequestParams with tools for SEP-1577."""
tool = Tool(
name="get_weather",
description="Get weather information",
inputSchema={"type": "object", "properties": {"location": {"type": "string"}}},
)
params = CreateMessageRequestParams(
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="What's the weather?"))],
maxTokens=1000,
tools=[tool],
toolChoice=ToolChoice(mode="auto"),
)
assert params.tools is not None
assert len(params.tools) == 1
assert params.tools[0].name == "get_weather"
assert params.toolChoice is not None
assert params.toolChoice.mode == "auto"
@pytest.mark.anyio
async def test_create_message_result_with_tool_use():
"""Test CreateMessageResult with tool use content for SEP-1577."""
result_data = {
"role": "assistant",
"content": {"type": "tool_use", "name": "search", "id": "call_123", "input": {"query": "test"}},
"model": "claude-3",
"stopReason": "toolUse",
}
result = CreateMessageResult.model_validate(result_data)
assert result.role == "assistant"
assert isinstance(result.content, ToolUseContent)
assert result.stopReason == "toolUse"
assert result.model == "claude-3"
# Test content_as_list with single content (covers else branch)
content_list = result.content_as_list
assert len(content_list) == 1
assert content_list[0] == result.content
@pytest.mark.anyio
async def test_client_capabilities_with_sampling_tools():
"""Test ClientCapabilities with nested sampling capabilities for SEP-1577."""
# New structured format
capabilities_data: dict[str, Any] = {
"sampling": {"tools": {}},
}
capabilities = ClientCapabilities.model_validate(capabilities_data)
assert capabilities.sampling is not None
assert isinstance(capabilities.sampling, SamplingCapability)
assert capabilities.sampling.tools is not None
# With both context and tools
full_capabilities_data: dict[str, Any] = {"sampling": {"context": {}, "tools": {}}}
full_caps = ClientCapabilities.model_validate(full_capabilities_data)
assert isinstance(full_caps.sampling, SamplingCapability)
assert full_caps.sampling.context is not None
assert full_caps.sampling.tools is not None
def test_tool_preserves_json_schema_2020_12_fields():
"""Verify that JSON Schema 2020-12 keywords are preserved in Tool.inputSchema.