## 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.
94 lines
2.7 KiB
Ruby
94 lines
2.7 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require_relative "../../transport"
|
|
|
|
module MCP
|
|
class Server
|
|
module Transports
|
|
class StdioTransport < Transport
|
|
STATUS_INTERRUPTED = Signal.list["INT"] + 128
|
|
|
|
def initialize(server)
|
|
super(server)
|
|
@open = false
|
|
@session = nil
|
|
$stdin.set_encoding("UTF-8")
|
|
$stdout.set_encoding("UTF-8")
|
|
end
|
|
|
|
def open
|
|
@open = true
|
|
@session = ServerSession.new(server: @server, transport: self)
|
|
while @open && (line = $stdin.gets)
|
|
response = @session.handle_json(line.strip)
|
|
send_response(response) if response
|
|
end
|
|
rescue Interrupt
|
|
warn("\nExiting...")
|
|
|
|
exit(STATUS_INTERRUPTED)
|
|
end
|
|
|
|
def close
|
|
@open = false
|
|
end
|
|
|
|
def send_response(message)
|
|
json_message = message.is_a?(String) ? message : JSON.generate(message)
|
|
$stdout.puts(json_message)
|
|
$stdout.flush
|
|
end
|
|
|
|
def send_notification(method, params = nil)
|
|
notification = {
|
|
jsonrpc: "2.0",
|
|
method: method,
|
|
}
|
|
notification[:params] = params if params
|
|
|
|
send_response(notification)
|
|
true
|
|
rescue => e
|
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
|
|
false
|
|
end
|
|
|
|
def send_request(method, params = nil)
|
|
request_id = generate_request_id
|
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
request[:params] = params if params
|
|
|
|
begin
|
|
send_response(request)
|
|
rescue => e
|
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to send request" })
|
|
raise
|
|
end
|
|
|
|
while @open && (line = $stdin.gets)
|
|
begin
|
|
parsed = JSON.parse(line.strip, symbolize_names: true)
|
|
rescue JSON::ParserError => e
|
|
MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" })
|
|
raise
|
|
end
|
|
|
|
if parsed[:id] == request_id && !parsed.key?(:method)
|
|
if parsed[:error]
|
|
raise StandardError, "Client returned an error for #{method} request (code: #{parsed[:error][:code]}): #{parsed[:error][:message]}"
|
|
end
|
|
|
|
return parsed[:result]
|
|
else
|
|
response = @session ? @session.handle(parsed) : @server.handle(parsed)
|
|
send_response(response) if response
|
|
end
|
|
end
|
|
|
|
raise "Transport closed while waiting for response to #{method} request."
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|