Skip to content

Commit f3d6da2

Browse files
committed
Support client-side sampling via MCP::Client#on_sampling
## Motivation and Context The MCP specification defines `sampling/createMessage` as a server-to-client request that lets a server ask the client to generate an LLM completion, so the server can leverage the client's model access without its own API keys. The Ruby SDK already supports the server side (sending the request) and, on the client side, the generic server-to-client request infrastructure introduced for elicitation (the standalone GET SSE listening stream and `on_server_request` dispatch). This adds the client-side `sampling/createMessage` handler on top of that infrastructure. ## Changes - Add `MCP::Client#on_sampling`, a thin wrapper that registers a `sampling/createMessage` handler via `transport.on_server_request`, mirroring `on_elicitation`. The handler receives the request params and returns a `CreateMessageResult`-shaped Hash sent back as the JSON-RPC result. - Document client-side sampling in the README, including the `sampling` (and `sampling.tools`) capability declaration and the human-in-the-loop guidance from the specification. ## How Has This Been Tested? - Unit tests for `on_sampling` covering handler registration on the transport and the error raised when the transport does not support server-to-client requests. - An HTTP transport test that dispatches a `sampling/createMessage` server request delivered on the SSE stream and returns a `CreateMessageResult`. ## Deprecation Note SEP-2577 deprecates sampling (along with roots and logging) starting with protocol version `2026-07-28`. The deprecation warnings are added in #406. Sampling remains fully supported under the current `2025-11-25` protocol version and stays available throughout the spec's deprecation window, so the client side is implemented here for parity with the server side and with the Python and TypeScript SDKs. `MCP::Client#on_sampling` carries the same `@deprecated` annotation as the server-side senders annotated in #429, tailored to the receiving side: the handler exists to interoperate with servers that still send sampling requests during the deprecation window. The README section carries the same note. Ref: https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
1 parent 1101900 commit f3d6da2

4 files changed

Lines changed: 156 additions & 0 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2342,6 +2342,44 @@ since servers deliver requests that are not tied to a client request on that str
23422342
a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
23432343
`http_transport.on_server_request("method/name") { |params| ... }`.
23442344

2345+
#### Server-to-Client Requests (Sampling)
2346+
2347+
Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling),
2348+
letting a server leverage the client's model access without its own API keys.
2349+
2350+
> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`.
2351+
> Register this handler to interoperate with servers that still send sampling requests during the deprecation window;
2352+
> new servers should call LLM provider APIs directly.
2353+
2354+
Register a handler and advertise the capability on `connect`:
2355+
2356+
```ruby
2357+
client.connect(capabilities: { sampling: {} })
2358+
2359+
client.on_sampling do |params|
2360+
completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
2361+
{
2362+
role: "assistant",
2363+
content: { type: "text", text: completion.text },
2364+
model: completion.model,
2365+
stopReason: "endTurn",
2366+
}
2367+
end
2368+
```
2369+
2370+
For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response.
2371+
To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`:
2372+
2373+
```ruby
2374+
client.on_sampling do |params|
2375+
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
2376+
2377+
generate_completion(params)
2378+
end
2379+
```
2380+
2381+
Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
2382+
23452383
#### HTTP Authorization
23462384

23472385
By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:

lib/mcp/client.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,44 @@ def on_elicitation(&handler)
423423
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
424424
end
425425

426+
# Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight.
427+
# The handler receives the request `params` (`messages`, `maxTokens`, optionally `systemPrompt`, `modelPreferences`, `tools`,
428+
# `toolChoice`, ...; string keys) and must return a `CreateMessageResult`-shaped Hash:
429+
# `{ role: "assistant", content: { type: "text", text: "..." }, model: "...", stopReason: "..." }`.
430+
#
431+
# For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response
432+
# before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`.
433+
#
434+
# Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to
435+
# `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them.
436+
#
437+
# @example Forward the request to an LLM and return its completion
438+
#
439+
# client.on_sampling do |params|
440+
# raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
441+
#
442+
# completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
443+
# {
444+
# role: "assistant",
445+
# content: { type: "text", text: completion.text },
446+
# model: completion.model,
447+
# stopReason: "endTurn",
448+
# }
449+
# end
450+
#
451+
# https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
452+
#
453+
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
454+
# Register this handler only to interoperate with servers that still send sampling requests during the deprecation window;
455+
# new servers should call LLM provider APIs directly.
456+
def on_sampling(&handler)
457+
unless transport.respond_to?(:on_server_request)
458+
raise ArgumentError, "The transport does not support server-to-client requests"
459+
end
460+
461+
transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler)
462+
end
463+
426464
# Sends a `ping` request to the server to verify the connection is alive.
427465
# Per the MCP spec, the server responds with an empty result.
428466
#

test/mcp/client/http_test.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,67 @@ def test_send_request_dispatches_server_request_to_registered_handler
913913
assert_equal("Please accept with defaults", received_params["message"])
914914
end
915915

916+
def test_send_request_dispatches_sampling_request_to_registered_handler
917+
request = {
918+
jsonrpc: "2.0",
919+
id: "test_id",
920+
method: "tools/call",
921+
params: { name: "ask_llm", arguments: {} },
922+
}
923+
924+
sampling_request = {
925+
jsonrpc: "2.0",
926+
id: 0,
927+
method: "sampling/createMessage",
928+
params: {
929+
messages: [{ role: "user", content: { type: "text", text: "Hi" } }],
930+
maxTokens: 100,
931+
},
932+
}
933+
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
934+
sse_body = "event: message\ndata: #{sampling_request.to_json}\n\n" \
935+
"event: message\ndata: #{tool_result.to_json}\n\n"
936+
937+
stub_request(:post, url).with(
938+
body: request.to_json,
939+
).to_return(
940+
status: 200,
941+
headers: { "Content-Type" => "text/event-stream" },
942+
body: sse_body,
943+
)
944+
945+
expected_response = {
946+
jsonrpc: "2.0",
947+
id: 0,
948+
result: {
949+
role: "assistant",
950+
content: { type: "text", text: "Hello there" },
951+
model: "test-model",
952+
stopReason: "endTurn",
953+
},
954+
}
955+
response_stub = stub_request(:post, url).with(
956+
body: expected_response.to_json,
957+
).to_return(status: 202, body: "")
958+
959+
received_params = nil
960+
client.on_server_request("sampling/createMessage") do |params|
961+
received_params = params
962+
{
963+
role: "assistant",
964+
content: { type: "text", text: "Hello there" },
965+
model: "test-model",
966+
stopReason: "endTurn",
967+
}
968+
end
969+
970+
response = client.send_request(request: request)
971+
972+
assert_equal({ "content" => [] }, response["result"])
973+
assert_requested(response_stub)
974+
assert_equal(100, received_params["maxTokens"])
975+
end
976+
916977
def test_send_request_answers_unregistered_server_request_with_method_not_found
917978
request = {
918979
jsonrpc: "2.0",

test/mcp/client_test.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,25 @@ def test_on_elicitation_raises_when_transport_does_not_support_server_requests
6666
assert_includes(error.message, "does not support server-to-client requests")
6767
end
6868

69+
def test_on_sampling_registers_handler_on_transport
70+
transport = mock
71+
handler = proc { { role: "assistant", content: { type: "text", text: "hi" } } }
72+
transport.expects(:on_server_request).with("sampling/createMessage")
73+
74+
Client.new(transport: transport).on_sampling(&handler)
75+
end
76+
77+
def test_on_sampling_raises_when_transport_does_not_support_server_requests
78+
transport = mock
79+
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
80+
81+
error = assert_raises(ArgumentError) do
82+
Client.new(transport: transport).on_sampling { { role: "assistant", content: { type: "text", text: "hi" } } }
83+
end
84+
85+
assert_includes(error.message, "does not support server-to-client requests")
86+
end
87+
6988
def test_connected_delegates_to_transport_when_supported
7089
transport = mock
7190
transport.expects(:connected?).returns(true)

0 commit comments

Comments
 (0)