Skip to content

Commit 0d700d7

Browse files
authored
Merge pull request #282 from koic/support_sampling
Support `sampling/createMessage` per MCP specification
2 parents 6f94d64 + da5f922 commit 0d700d7

File tree

13 files changed

+1449
-13
lines changed

13 files changed

+1449
-13
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

@@ -51,6 +52,7 @@ It implements the Model Context Protocol specification, handling model context r
5152
- `resources/read` - Retrieves a specific resource by name
5253
- `resources/templates/list` - Lists all registered resource templates and their schemas
5354
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
55+
- `sampling/createMessage` - Requests LLM completion from the client (server-to-client)
5456

5557
### Custom Methods
5658

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

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

108267
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
@@ -54,6 +54,7 @@ def initialize(method_name)
5454
include Instrumentation
5555

5656
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
57+
attr_reader :client_capabilities
5758

5859
def initialize(
5960
description: nil,
@@ -92,6 +93,7 @@ def initialize(
9293
validate!
9394

9495
@capabilities = capabilities || default_capabilities
96+
@client_capabilities = nil
9597
@logging_message_notification = nil
9698

9799
@handlers = {
@@ -204,6 +206,43 @@ def notify_log_message(data:, level:, logger: nil)
204206
report_exception(e, { notification: "log_message" })
205207
end
206208

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

265+
def build_sampling_params(
266+
capabilities,
267+
messages:,
268+
max_tokens:,
269+
system_prompt: nil,
270+
model_preferences: nil,
271+
include_context: nil,
272+
temperature: nil,
273+
stop_sequences: nil,
274+
metadata: nil,
275+
tools: nil,
276+
tool_choice: nil
277+
)
278+
unless capabilities&.dig(:sampling)
279+
raise "Client does not support sampling."
280+
end
281+
282+
if tools && !capabilities.dig(:sampling, :tools)
283+
raise "Client does not support sampling with tools."
284+
end
285+
286+
if tool_choice && !capabilities.dig(:sampling, :tools)
287+
raise "Client does not support sampling with tool_choice."
288+
end
289+
290+
{
291+
messages: messages,
292+
maxTokens: max_tokens,
293+
systemPrompt: system_prompt,
294+
modelPreferences: model_preferences,
295+
includeContext: include_context,
296+
temperature: temperature,
297+
stopSequences: stop_sequences,
298+
metadata: metadata,
299+
tools: tools,
300+
toolChoice: tool_choice,
301+
}.compact
302+
end
303+
226304
private
227305

228306
def validate!
@@ -371,10 +449,11 @@ def init(params, session: nil)
371449
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
372450
else
373451
@client = params[:clientInfo]
452+
@client_capabilities = params[:capabilities]
374453
end
454+
protocol_version = params[:protocolVersion]
375455
end
376456

377-
protocol_version = params[:protocolVersion] if params
378457
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
379458
protocol_version
380459
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)