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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user