Skip to content

Commit 7747c2e

Browse files
committed
Support sampling/createMessage per MCP specification
## Motivation and Context The MCP specification defines `sampling/createMessage` to request LLM completions from the client. The Ruby SDK did not yet support server-to-client requests, so the server had no way to ask the client to sample an LLM. This caused the `tools-call-sampling` conformance test to fail. This adds `Server#create_sampling_message` to send a `sampling/createMessage` request to the client via the transport layer. The method validates that the client declared the `sampling` capability during initialization, and optionally checks the `sampling.tools` capability when tools or tool_choice parameters are provided. On the transport side, `Transport#send_request` is introduced as an abstract method, with implementations in both `StdioTransport` and `StreamableHTTPTransport`. Each transport assigns a unique request ID, sends the JSON-RPC request to the client, and blocks until the client responds. Client capabilities are stored per session via `ServerSession`, so validation is scoped to the originating client. `ServerSession#create_sampling_message` uses `build_sampling_params` for capability validation and sends the request directly through the transport with the session ID, following the same pattern as `notify_progress`. `Server#create_sampling_message` is the public API for single-client transports (e.g., StdioTransport). For multi-client transports (e.g., StreamableHTTPTransport), `ServerSession#create_sampling_message` must be used to route requests to the correct client. Tools using `server_context.create_sampling_message` automatically route to the correct session regardless of transport. `StreamableHTTPTransport#send_request` requires `session_id` to prevent broadcasting sampling requests to all connected clients. Missing or invalid session IDs raise explicit errors instead of silently failing. The `include_context` parameter is soft-deprecated in the MCP specification but is included for compatibility with existing clients that declare the `sampling.context` capability. Python and TypeScript SDKs also retain this parameter. Ref: https://modelcontextprotocol.io/specification/latest/server/utilities/sampling ## How Has This Been Tested? Added comprehensive tests in `test/mcp/server_sampling_test.rb` covering required and optional parameters, capability validation, error handling, nil-param omission, per-session capability isolation, and HTTP init capability scoping. Added transport tests in `test/mcp/server/transports/stdio_transport_test.rb` and `test/mcp/server/transports/streamable_http_transport_test.rb` for `send_request` round-trip behavior, error responses, stateless mode rejection, missing session_id, invalid session_id, and no-active-stream error. Conformance: `tools-call-sampling` is removed from expected failures. The conformance server's `TestSampling` tool now calls `server_context.create_sampling_message` instead of returning a stub error. This aligns the Ruby SDK with the MCP specification and other SDK implementations. ## Breaking Changes None for end users. `Transport#send_request` is a new abstract method. `ServerSession#create_sampling_message` is a new method.
1 parent 5631990 commit 7747c2e

File tree

13 files changed

+1446
-12
lines changed

13 files changed

+1446
-12
lines changed

README.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ It implements the Model Context Protocol specification, handling model context r
3838
- Supports resource registration and retrieval
3939
- Supports stdio & Streamable HTTP (including SSE) transports
4040
- Supports notifications for list changes (tools, prompts, resources)
41+
- Supports sampling (server-to-client LLM completion requests)
4142

4243
### Supported Methods
4344

@@ -50,6 +51,7 @@ It implements the Model Context Protocol specification, handling model context r
5051
- `resources/list` - Lists all registered resources and their schemas
5152
- `resources/read` - Retrieves a specific resource by name
5253
- `resources/templates/list` - Lists all registered resource templates and their schemas
54+
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
5355

5456
### Custom Methods
5557

@@ -102,6 +104,163 @@ end
102104
- Raises `MCP::Server::MethodAlreadyDefinedError` if trying to override an existing method
103105
- Supports the same exception reporting and instrumentation as standard methods
104106

107+
### Sampling
108+
109+
The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method.
110+
This enables servers to leverage the client's LLM capabilities without needing direct access to AI models.
111+
112+
**Key Concepts:**
113+
114+
- **Server-to-Client Request**: Unlike typical MCP methods (client→server), sampling is initiated by the server
115+
- **Client Capability**: Clients must declare `sampling` capability during initialization
116+
- **Tool Support**: When using tools in sampling requests, clients must declare `sampling.tools` capability
117+
- **Human-in-the-Loop**: Clients can implement user approval before forwarding requests to LLMs
118+
119+
**Usage Example (Stdio transport):**
120+
121+
`Server#create_sampling_message` is for single-client transports (e.g., `StdioTransport`).
122+
For multi-client transports (e.g., `StreamableHTTPTransport`), use `server_context.create_sampling_message` inside tools instead,
123+
which routes the request to the correct client session.
124+
125+
```ruby
126+
server = MCP::Server.new(name: "my_server")
127+
transport = MCP::Server::Transports::StdioTransport.new(server)
128+
server.transport = transport
129+
```
130+
131+
Client must declare sampling capability during initialization.
132+
This happens automatically when the client connects.
133+
134+
```ruby
135+
result = server.create_sampling_message(
136+
messages: [
137+
{ role: "user", content: { type: "text", text: "What is the capital of France?" } }
138+
],
139+
max_tokens: 100,
140+
system_prompt: "You are a helpful assistant.",
141+
temperature: 0.7
142+
)
143+
```
144+
145+
Result contains the LLM response:
146+
147+
```ruby
148+
{
149+
role: "assistant",
150+
content: { type: "text", text: "The capital of France is Paris." },
151+
model: "claude-3-sonnet-20240307",
152+
stopReason: "endTurn"
153+
}
154+
```
155+
156+
**Parameters:**
157+
158+
Required:
159+
160+
- `messages:` (Array) - Array of message objects with `role` and `content`
161+
- `max_tokens:` (Integer) - Maximum tokens in the response
162+
163+
Optional:
164+
165+
- `system_prompt:` (String) - System prompt for the LLM
166+
- `model_preferences:` (Hash) - Model selection preferences (e.g., `{ intelligencePriority: 0.8 }`)
167+
- `include_context:` (String) - Context inclusion: `"none"`, `"thisServer"`, or `"allServers"` (soft-deprecated)
168+
- `temperature:` (Float) - Sampling temperature
169+
- `stop_sequences:` (Array) - Sequences that stop generation
170+
- `metadata:` (Hash) - Additional metadata
171+
- `tools:` (Array) - Tools available to the LLM (requires `sampling.tools` capability)
172+
- `tool_choice:` (Hash) - Tool selection mode (e.g., `{ mode: "auto" }`)
173+
174+
**Using Sampling in Tools (works with both Stdio and HTTP transports):**
175+
176+
Tools that accept a `server_context:` parameter can call `create_sampling_message` on it.
177+
The request is automatically routed to the correct client session.
178+
Set `server.server_context = server` so that `server_context.create_sampling_message` delegates to the server:
179+
180+
```ruby
181+
class SummarizeTool < MCP::Tool
182+
description "Summarize text using LLM"
183+
input_schema(
184+
properties: {
185+
text: { type: "string" }
186+
},
187+
required: ["text"]
188+
)
189+
190+
def self.call(text:, server_context:)
191+
result = server_context.create_sampling_message(
192+
messages: [
193+
{ role: "user", content: { type: "text", text: "Please summarize: #{text}" } }
194+
],
195+
max_tokens: 500
196+
)
197+
198+
MCP::Tool::Response.new([{
199+
type: "text",
200+
text: result[:content][:text]
201+
}])
202+
end
203+
end
204+
205+
server = MCP::Server.new(name: "my_server", tools: [SummarizeTool])
206+
server.server_context = server
207+
```
208+
209+
**Tool Use in Sampling:**
210+
211+
When tools are provided in a sampling request, the LLM can call them during generation.
212+
The server must handle tool calls and continue the conversation with tool results:
213+
214+
```ruby
215+
result = server.create_sampling_message(
216+
messages: [
217+
{ role: "user", content: { type: "text", text: "What's the weather in Paris?" } }
218+
],
219+
max_tokens: 1000,
220+
tools: [
221+
{
222+
name: "get_weather",
223+
description: "Get weather for a city",
224+
inputSchema: {
225+
type: "object",
226+
properties: { city: { type: "string" } },
227+
required: ["city"]
228+
}
229+
}
230+
],
231+
tool_choice: { mode: "auto" }
232+
)
233+
234+
if result[:stopReason] == "toolUse"
235+
tool_results = result[:content].map do |tool_use|
236+
weather_data = get_weather(tool_use[:input][:city])
237+
238+
{
239+
type: "tool_result",
240+
toolUseId: tool_use[:id],
241+
content: [{ type: "text", text: weather_data.to_json }]
242+
}
243+
end
244+
245+
final_result = server.create_sampling_message(
246+
messages: [
247+
{ role: "user", content: { type: "text", text: "What's the weather in Paris?" } },
248+
{ role: "assistant", content: result[:content] },
249+
{ role: "user", content: tool_results }
250+
],
251+
max_tokens: 1000,
252+
tools: [...]
253+
)
254+
end
255+
```
256+
257+
**Error Handling:**
258+
259+
- Raises `RuntimeError` if transport is not set
260+
- Raises `RuntimeError` if client does not support `sampling` capability
261+
- Raises `RuntimeError` if `tools` are used but client lacks `sampling.tools` capability
262+
- Raises `StandardError` if client returns an error response
263+
105264
### Notifications
106265

107266
The server supports sending notifications to clients when lists of tools, prompts, or resources change. This enables real-time updates without polling.

conformance/expected_failures.yml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
server:
2-
# TODO: Server-to-client requests (sampling/createMessage, elicitation/create) are not implemented.
3-
# `Transport#send_request` does not exist in the current SDK.
4-
- tools-call-sampling
2+
# TODO: Server-to-client requests (elicitation/create) are not implemented.
53
- tools-call-elicitation
64
- elicitation-sep1034-defaults
75
- elicitation-sep1330-enums

conformance/server.rb

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ def call(server_context:, **_args)
156156
end
157157
end
158158

159-
# TODO: Implement when `Transport` supports server-to-client requests.
160159
class TestSampling < MCP::Tool
161160
tool_name "test_sampling"
162161
description "A tool that requests LLM sampling from the client"
@@ -166,11 +165,15 @@ class TestSampling < MCP::Tool
166165
)
167166

168167
class << self
169-
def call(prompt:)
170-
MCP::Tool::Response.new(
171-
[MCP::Content::Text.new("Sampling not supported in this SDK version").to_h],
172-
error: true,
168+
def call(prompt:, server_context:)
169+
result = server_context.create_sampling_message(
170+
messages: [{ role: "user", content: { type: "text", text: prompt } }],
171+
max_tokens: 100,
173172
)
173+
model = result[:model] || "unknown"
174+
text = result.dig(:content, :text) || ""
175+
176+
MCP::Tool::Response.new([MCP::Content::Text.new("LLM response: #{text} (model: #{model})").to_h])
174177
end
175178
end
176179
end

lib/mcp/server.rb

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def initialize(method_name)
4848
include Instrumentation
4949

5050
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
51+
attr_reader :client_capabilities
5152

5253
def initialize(
5354
description: nil,
@@ -86,6 +87,7 @@ def initialize(
8687
validate!
8788

8889
@capabilities = capabilities || default_capabilities
90+
@client_capabilities = nil
8991
@logging_message_notification = nil
9092

9193
@handlers = {
@@ -198,6 +200,43 @@ def notify_log_message(data:, level:, logger: nil)
198200
report_exception(e, { notification: "log_message" })
199201
end
200202

203+
# Sends a `sampling/createMessage` request to the client.
204+
# For single-client transports (e.g., `StdioTransport`). For multi-client transports
205+
# (e.g., `StreamableHTTPTransport`), use `ServerSession#create_sampling_message` instead
206+
# to ensure the request is routed to the correct client.
207+
def create_sampling_message(
208+
messages:,
209+
max_tokens:,
210+
system_prompt: nil,
211+
model_preferences: nil,
212+
include_context: nil,
213+
temperature: nil,
214+
stop_sequences: nil,
215+
metadata: nil,
216+
tools: nil,
217+
tool_choice: nil
218+
)
219+
unless @transport
220+
raise "Cannot send sampling request without a transport."
221+
end
222+
223+
params = build_sampling_params(
224+
@client_capabilities,
225+
messages: messages,
226+
max_tokens: max_tokens,
227+
system_prompt: system_prompt,
228+
model_preferences: model_preferences,
229+
include_context: include_context,
230+
temperature: temperature,
231+
stop_sequences: stop_sequences,
232+
metadata: metadata,
233+
tools: tools,
234+
tool_choice: tool_choice,
235+
)
236+
237+
@transport.send_request(Methods::SAMPLING_CREATE_MESSAGE, params)
238+
end
239+
201240
# Sets a custom handler for `resources/read` requests.
202241
# The block receives the parsed request params and should return resource
203242
# contents. The return value is set as the `contents` field of the response.
@@ -208,6 +247,45 @@ def resources_read_handler(&block)
208247
@handlers[Methods::RESOURCES_READ] = block
209248
end
210249

250+
def build_sampling_params(
251+
capabilities,
252+
messages:,
253+
max_tokens:,
254+
system_prompt: nil,
255+
model_preferences: nil,
256+
include_context: nil,
257+
temperature: nil,
258+
stop_sequences: nil,
259+
metadata: nil,
260+
tools: nil,
261+
tool_choice: nil
262+
)
263+
unless capabilities&.dig(:sampling)
264+
raise "Client does not support sampling."
265+
end
266+
267+
if tools && !capabilities.dig(:sampling, :tools)
268+
raise "Client does not support sampling with tools."
269+
end
270+
271+
if tool_choice && !capabilities.dig(:sampling, :tools)
272+
raise "Client does not support sampling with tool_choice."
273+
end
274+
275+
{
276+
messages: messages,
277+
maxTokens: max_tokens,
278+
systemPrompt: system_prompt,
279+
modelPreferences: model_preferences,
280+
includeContext: include_context,
281+
temperature: temperature,
282+
stopSequences: stop_sequences,
283+
metadata: metadata,
284+
tools: tools,
285+
toolChoice: tool_choice,
286+
}.compact
287+
end
288+
211289
private
212290

213291
def validate!
@@ -355,10 +433,11 @@ def init(params, session: nil)
355433
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
356434
else
357435
@client = params[:clientInfo]
436+
@client_capabilities = params[:capabilities]
358437
end
438+
protocol_version = params[:protocolVersion]
359439
end
360440

361-
protocol_version = params[:protocolVersion] if params
362441
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
363442
protocol_version
364443
else

lib/mcp/server/transports/stdio_transport.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,41 @@ def send_notification(method, params = nil)
5353
MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
5454
false
5555
end
56+
57+
def send_request(method, params = nil)
58+
request_id = generate_request_id
59+
request = { jsonrpc: "2.0", id: request_id, method: method }
60+
request[:params] = params if params
61+
62+
begin
63+
send_response(request)
64+
rescue => e
65+
MCP.configuration.exception_reporter.call(e, { error: "Failed to send request" })
66+
raise
67+
end
68+
69+
while @open && (line = $stdin.gets)
70+
begin
71+
parsed = JSON.parse(line.strip, symbolize_names: true)
72+
rescue JSON::ParserError => e
73+
MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" })
74+
raise
75+
end
76+
77+
if parsed[:id] == request_id && !parsed.key?(:method)
78+
if parsed[:error]
79+
raise StandardError, "Client returned an error for #{method} request (code: #{parsed[:error][:code]}): #{parsed[:error][:message]}"
80+
end
81+
82+
return parsed[:result]
83+
else
84+
response = @session ? @session.handle(parsed) : @server.handle(parsed)
85+
send_response(response) if response
86+
end
87+
end
88+
89+
raise "Transport closed while waiting for response to #{method} request."
90+
end
5691
end
5792
end
5893
end

0 commit comments

Comments
 (0)