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
commit 71c475588f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 674 additions and 19 deletions

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",
]

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,
),
)
),

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):