Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4ded25a
Implement SEP-1577: Sampling With Tools
ochafik Nov 7, 2025
b74bff2
Rename sampling capability types for clarity
ochafik Nov 7, 2025
224fc22
Simplify sampling types to match draft spec
ochafik Nov 14, 2025
03de21d
Merge branch 'main' into ochafik/sep-1577-sampling-tools
ochafik Nov 17, 2025
475df1f
Merge branch 'main' into ochafik/sep-1577-sampling-tools
ochafik Nov 20, 2025
2d24c3a
Merge branch 'main' into ochafik/sep-1577-sampling-tools
ochafik Nov 21, 2025
fd3e999
Fix ruff formatting for SamplingMessageContentBlock type alias
felixweinberger Nov 21, 2025
3459880
Fix pyright type errors for CreateMessageResult.content
felixweinberger Nov 21, 2025
607976e
Update README snippets
felixweinberger Nov 21, 2025
28d5e7b
Have ToolResultContent.content use ContentBlock type (forward ref)
ochafik Nov 22, 2025
722ca3e
Add tools and toolChoice parameters to create_message() in ServerSession
ochafik Nov 22, 2025
eeab4fe
Add {SamplingMessage,CreateMessageResult}.content_as_list + update us…
ochafik Nov 22, 2025
9e33007
Validate tool_use / tool_result blocks in create_message
ochafik Nov 22, 2025
57aef47
Update session.py
ochafik Nov 22, 2025
4a7bafd
Fix test to use ServerSession as context manager
ochafik Nov 22, 2025
287df90
update snippets
ochafik Nov 22, 2025
b1d944a
update snippets using content_as_list
ochafik Nov 22, 2025
f8b077d
Add tests for full coverage of tool validation branches
ochafik Nov 22, 2025
7eb4070
Add tests for full coverage of tool validation branches
ochafik Nov 22, 2025
90993b3
Merge branch 'ochafik/sep-1577-sampling-tools' of https://github.com/…
ochafik Nov 22, 2025
4b937f1
update check_client_capability
ochafik Nov 22, 2025
420ea0a
update check_client_capability
ochafik Nov 22, 2025
f832d99
update wording of include_context dependency on context capability
ochafik Nov 22, 2025
93213b4
Merge branch 'ochafik/sep-1577-sampling-tools' of https://github.com/…
ochafik Nov 22, 2025
fa409cf
Update session.py
ochafik Nov 22, 2025
4103c93
Add test for create_message without sampling tools capability
ochafik Nov 22, 2025
ee04b6d
apply review suggestions
ochafik Nov 23, 2025
b79ca4b
attempt to fix test coverage
ochafik Nov 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -886,9 +886,10 @@ async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str:
max_tokens=100,
)

if result.content.type == "text":
return result.content.text
return str(result.content)
content = result.content[0] if isinstance(result.content, list) else result.content
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want this as the example of how to use the new multiple content blocks :)

(same comment on other places where this pattern shows up)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a probably controversial content_as_list property + updated various usage places. WDYT? cc/ @felixweinberger

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems fine! The main issue was just dropping array items but a convenience method can't hurt.

if content.type == "text":
return content.text
return str(content)
```

_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,9 @@ async def test_sampling(prompt: str, ctx: Context[ServerSession, None]) -> str:
max_tokens=100,
)

if result.content.type == "text":
model_response = result.content.text
content = result.content[0] if isinstance(result.content, list) else result.content
if content.type == "text":
model_response = content.text
else:
model_response = "No response"

Expand Down
7 changes: 4 additions & 3 deletions examples/snippets/servers/sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str:
max_tokens=100,
)

if result.content.type == "text":
return result.content.text
return str(result.content)
content = result.content[0] if isinstance(result.content, list) else result.content
if content.type == "text":
return content.text
return str(content)
20 changes: 17 additions & 3 deletions src/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@
ResourcesCapability,
ResourceUpdatedNotification,
RootsCapability,
SamplingCapability,
SamplingContextCapability,
SamplingMessage,
SamplingMessageContentBlock,
SamplingToolsCapability,
ServerCapabilities,
ServerNotification,
ServerRequest,
Expand All @@ -50,7 +54,10 @@
StopReason,
SubscribeRequest,
Tool,
ToolChoice,
ToolResultContent,
ToolsCapability,
ToolUseContent,
UnsubscribeRequest,
)
from .types import (
Expand All @@ -65,6 +72,7 @@
"ClientResult",
"ClientSession",
"ClientSessionGroup",
"CompleteRequest",
"CreateMessageRequest",
"CreateMessageResult",
"ErrorData",
Expand All @@ -77,6 +85,7 @@
"InitializedNotification",
"JSONRPCError",
"JSONRPCRequest",
"JSONRPCResponse",
"ListPromptsRequest",
"ListPromptsResult",
"ListResourcesRequest",
Expand All @@ -91,12 +100,16 @@
"PromptsCapability",
"ReadResourceRequest",
"ReadResourceResult",
"Resource",
"ResourcesCapability",
"ResourceUpdatedNotification",
"Resource",
"RootsCapability",
"SamplingCapability",
"SamplingContextCapability",
"SamplingMessage",
"SamplingMessageContentBlock",
"SamplingRole",
"SamplingToolsCapability",
"ServerCapabilities",
"ServerNotification",
"ServerRequest",
Expand All @@ -107,10 +120,11 @@
"StopReason",
"SubscribeRequest",
"Tool",
"ToolChoice",
"ToolResultContent",
"ToolsCapability",
"ToolUseContent",
"UnsubscribeRequest",
"stdio_client",
"stdio_server",
"CompleteRequest",
"JSONRPCResponse",
]
168 changes: 160 additions & 8 deletions src/mcp/types.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Callable
from typing import Annotated, Any, Generic, Literal, TypeAlias, TypeVar
from typing import Annotated, Any, Generic, Literal, TypeAlias, TypeVar, Union

from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel
from pydantic.networks import AnyUrl, UrlConstraints
Expand Down Expand Up @@ -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")

Expand All @@ -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
Expand Down Expand Up @@ -742,11 +779,89 @@ 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[Union[TextContent, ImageContent, AudioContent, "ResourceLink", "EmbeddedResource"]] = []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just use ContentBlock here?

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


Expand Down Expand Up @@ -1035,6 +1150,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."""

Expand All @@ -1057,6 +1191,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")


Expand All @@ -1067,18 +1211,26 @@ 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.
"""


class ResourceTemplateReference(BaseModel):
Expand Down
12 changes: 10 additions & 2 deletions tests/shared/test_streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]
)

# Return the sampling result in the tool response
response = sampling_result.content.text if sampling_result.content.type == "text" else None
content = (
sampling_result.content[0] if isinstance(sampling_result.content, list) else sampling_result.content
)
response = content.text if content.type == "text" else None
return [
TextContent(
type="text",
Expand Down Expand Up @@ -1239,7 +1242,12 @@ async def sampling_callback(
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[0]
if isinstance(params.messages[0].content, list)
else params.messages[0].content
)
message_received = msg_content.text if msg_content.type == "text" else None

return types.CreateMessageResult(
role="assistant",
Expand Down
Loading