Merge pull request #443 from koic/elicitation_sep1034_defaults
Support client-side elicitation with SEP-1034 schema defaults
This commit is contained in:
commit
be59660500
3 changed files with 748 additions and 9 deletions
100
test/mcp/client/elicitation_test.rb
Normal file
100
test/mcp/client/elicitation_test.rb
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
require "mcp/client/elicitation"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
class ElicitationTest < Minitest::Test
|
||||
REQUESTED_SCHEMA = {
|
||||
"type" => "object",
|
||||
"properties" => {
|
||||
"name" => { "type" => "string", "default" => "John Doe" },
|
||||
"age" => { "type" => "integer", "default" => 30 },
|
||||
"score" => { "type" => "number", "default" => 95.5 },
|
||||
"status" => { "type" => "string", "enum" => ["active", "inactive", "pending"], "default" => "active" },
|
||||
"verified" => { "type" => "boolean", "default" => true },
|
||||
},
|
||||
"required" => [],
|
||||
}.freeze
|
||||
|
||||
def test_apply_defaults_fills_all_omitted_fields
|
||||
content = Elicitation.apply_defaults(REQUESTED_SCHEMA)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"name" => "John Doe",
|
||||
"age" => 30,
|
||||
"score" => 95.5,
|
||||
"status" => "active",
|
||||
"verified" => true,
|
||||
},
|
||||
content,
|
||||
)
|
||||
end
|
||||
|
||||
def test_apply_defaults_does_not_overwrite_provided_values
|
||||
content = Elicitation.apply_defaults(REQUESTED_SCHEMA, { "name" => "Alice", "verified" => false })
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
"name" => "Alice",
|
||||
"verified" => false,
|
||||
"age" => 30,
|
||||
"score" => 95.5,
|
||||
"status" => "active",
|
||||
},
|
||||
content,
|
||||
)
|
||||
end
|
||||
|
||||
def test_apply_defaults_skips_properties_without_a_default
|
||||
schema = {
|
||||
"type" => "object",
|
||||
"properties" => {
|
||||
"name" => { "type" => "string", "default" => "John Doe" },
|
||||
"email" => { "type" => "string" },
|
||||
},
|
||||
}
|
||||
|
||||
content = Elicitation.apply_defaults(schema)
|
||||
|
||||
assert_equal({ "name" => "John Doe" }, content)
|
||||
refute(content.key?("email"))
|
||||
end
|
||||
|
||||
def test_apply_defaults_applies_a_false_default
|
||||
schema = {
|
||||
"type" => "object",
|
||||
"properties" => {
|
||||
"verified" => { "type" => "boolean", "default" => false },
|
||||
},
|
||||
}
|
||||
|
||||
content = Elicitation.apply_defaults(schema)
|
||||
|
||||
assert_equal({ "verified" => false }, content)
|
||||
end
|
||||
|
||||
def test_apply_defaults_supports_symbol_keyed_schemas_and_content
|
||||
schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", default: "John Doe" },
|
||||
age: { type: "integer", default: 30 },
|
||||
},
|
||||
}
|
||||
|
||||
content = Elicitation.apply_defaults(schema, { age: 42 })
|
||||
|
||||
assert_equal({ "age" => 42, "name" => "John Doe" }, content)
|
||||
end
|
||||
|
||||
def test_apply_defaults_returns_content_when_schema_has_no_properties
|
||||
content = Elicitation.apply_defaults({ "type" => "object" }, { "name" => "Alice" })
|
||||
|
||||
assert_equal({ "name" => "Alice" }, content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -853,6 +853,347 @@ module MCP
|
|||
assert_empty(stream.buffer)
|
||||
end
|
||||
|
||||
def test_send_request_dispatches_server_request_to_registered_handler
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/call",
|
||||
params: { name: "test_client_elicitation_defaults", arguments: {} },
|
||||
}
|
||||
|
||||
elicitation_request = {
|
||||
jsonrpc: "2.0",
|
||||
id: 0,
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "Please accept with defaults",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string", default: "John Doe" } },
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
||||
sse_body = "event: message\ndata: #{elicitation_request.to_json}\n\n" \
|
||||
"event: message\ndata: #{tool_result.to_json}\n\n"
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: sse_body,
|
||||
)
|
||||
|
||||
expected_response = {
|
||||
jsonrpc: "2.0",
|
||||
id: 0,
|
||||
result: { action: "accept", content: { name: "John Doe" } },
|
||||
}
|
||||
response_stub = stub_request(:post, url).with(
|
||||
body: expected_response.to_json,
|
||||
).to_return(
|
||||
status: 202, body: "",
|
||||
)
|
||||
|
||||
received_params = nil
|
||||
client.on_server_request("elicitation/create") do |params|
|
||||
received_params = params
|
||||
{
|
||||
action: "accept",
|
||||
content: { name: params.dig("requestedSchema", "properties", "name", "default") },
|
||||
}
|
||||
end
|
||||
|
||||
response = client.send_request(request: request)
|
||||
|
||||
assert_equal({ "content" => [] }, response["result"])
|
||||
assert_requested(response_stub)
|
||||
assert_equal("Please accept with defaults", received_params["message"])
|
||||
end
|
||||
|
||||
def test_send_request_answers_unregistered_server_request_with_method_not_found
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/call",
|
||||
params: { name: "some_tool", arguments: {} },
|
||||
}
|
||||
|
||||
server_request = {
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
method: "sampling/createMessage",
|
||||
params: {},
|
||||
}
|
||||
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
||||
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
|
||||
"event: message\ndata: #{tool_result.to_json}\n\n"
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: sse_body,
|
||||
)
|
||||
|
||||
expected_error_response = {
|
||||
jsonrpc: "2.0",
|
||||
id: 5,
|
||||
error: { code: -32601, message: "Method not found: sampling/createMessage" },
|
||||
}
|
||||
error_stub = stub_request(:post, url).with(
|
||||
body: expected_error_response.to_json,
|
||||
).to_return(
|
||||
status: 202, body: "",
|
||||
)
|
||||
|
||||
response = client.send_request(request: request)
|
||||
|
||||
assert_equal({ "content" => [] }, response["result"])
|
||||
assert_requested(error_stub)
|
||||
end
|
||||
|
||||
def test_send_request_answers_raising_handler_with_internal_error
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/call",
|
||||
params: { name: "some_tool", arguments: {} },
|
||||
}
|
||||
|
||||
server_request = {
|
||||
jsonrpc: "2.0",
|
||||
id: 9,
|
||||
method: "elicitation/create",
|
||||
params: {},
|
||||
}
|
||||
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
||||
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
|
||||
"event: message\ndata: #{tool_result.to_json}\n\n"
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: sse_body,
|
||||
)
|
||||
|
||||
expected_error_response = {
|
||||
jsonrpc: "2.0",
|
||||
id: 9,
|
||||
error: { code: -32603, message: "Internal error handling elicitation/create request: boom" },
|
||||
}
|
||||
error_stub = stub_request(:post, url).with(
|
||||
body: expected_error_response.to_json,
|
||||
).to_return(
|
||||
status: 202, body: "",
|
||||
)
|
||||
|
||||
client.on_server_request("elicitation/create") { raise "boom" }
|
||||
|
||||
response = client.send_request(request: request)
|
||||
|
||||
assert_equal({ "content" => [] }, response["result"])
|
||||
assert_requested(error_stub)
|
||||
end
|
||||
|
||||
def test_send_request_answers_server_request_error_with_its_code
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/call",
|
||||
params: { name: "ask_llm", arguments: {} },
|
||||
}
|
||||
|
||||
server_request = {
|
||||
jsonrpc: "2.0",
|
||||
id: 11,
|
||||
method: "sampling/createMessage",
|
||||
params: {},
|
||||
}
|
||||
tool_result = { jsonrpc: "2.0", id: "test_id", result: { content: [] } }
|
||||
sse_body = "event: message\ndata: #{server_request.to_json}\n\n" \
|
||||
"event: message\ndata: #{tool_result.to_json}\n\n"
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: sse_body,
|
||||
)
|
||||
|
||||
expected_error_response = {
|
||||
jsonrpc: "2.0",
|
||||
id: 11,
|
||||
error: { code: -1, message: "User rejected sampling request" },
|
||||
}
|
||||
error_stub = stub_request(:post, url).with(
|
||||
body: expected_error_response.to_json,
|
||||
).to_return(
|
||||
status: 202, body: "",
|
||||
)
|
||||
|
||||
client.on_server_request("sampling/createMessage") do
|
||||
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1)
|
||||
end
|
||||
|
||||
response = client.send_request(request: request)
|
||||
|
||||
assert_equal({ "content" => [] }, response["result"])
|
||||
assert_requested(error_stub)
|
||||
end
|
||||
|
||||
def test_on_server_request_requires_a_block
|
||||
assert_raises(ArgumentError) do
|
||||
client.on_server_request("elicitation/create")
|
||||
end
|
||||
end
|
||||
|
||||
def test_registering_a_handler_after_connect_listens_on_standalone_get_stream
|
||||
stub_initialize
|
||||
stub_notification
|
||||
stub_request(:delete, url).to_return(status: 200)
|
||||
|
||||
elicitation_request = {
|
||||
jsonrpc: "2.0",
|
||||
id: 7,
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
message: "Please accept with defaults",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { name: { type: "string", default: "John Doe" } },
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
stub_request(:get, url)
|
||||
.with(
|
||||
headers: { "Accept" => "text/event-stream", "Mcp-Session-Id" => "session-abc" },
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: "event: message\ndata: #{elicitation_request.to_json}\n\n",
|
||||
)
|
||||
|
||||
expected_response = {
|
||||
jsonrpc: "2.0",
|
||||
id: 7,
|
||||
result: { action: "accept", content: { name: "John Doe" } },
|
||||
}
|
||||
response_stub = stub_request(:post, url).with(
|
||||
body: expected_response.to_json,
|
||||
).to_return(
|
||||
status: 202, body: "",
|
||||
)
|
||||
|
||||
client.connect
|
||||
client.on_server_request("elicitation/create") do |params|
|
||||
{
|
||||
action: "accept",
|
||||
content: { name: params.dig("requestedSchema", "properties", "name", "default") },
|
||||
}
|
||||
end
|
||||
|
||||
wait_until { requested?(response_stub) }
|
||||
|
||||
assert_requested(response_stub)
|
||||
ensure
|
||||
client.close
|
||||
end
|
||||
|
||||
def test_connect_listens_on_standalone_get_stream_when_a_handler_is_registered
|
||||
stub_initialize
|
||||
stub_notification
|
||||
stub_request(:delete, url).to_return(status: 200)
|
||||
|
||||
get_stub = stub_request(:get, url).with(
|
||||
headers: { "Accept" => "text/event-stream" },
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: "",
|
||||
)
|
||||
|
||||
client.on_server_request("elicitation/create") { { action: "decline" } }
|
||||
|
||||
assert_not_requested(:get, url)
|
||||
|
||||
client.connect
|
||||
|
||||
wait_until { requested?(get_stub) }
|
||||
|
||||
assert_requested(get_stub)
|
||||
ensure
|
||||
client.close
|
||||
end
|
||||
|
||||
def test_listener_stops_after_consecutive_connection_failures
|
||||
stub_initialize
|
||||
stub_notification
|
||||
stub_request(:delete, url).to_return(status: 200)
|
||||
stub_request(:get, url).to_return(status: 500)
|
||||
|
||||
client.stubs(:sleep)
|
||||
client.connect
|
||||
client.on_server_request("elicitation/create") { { action: "decline" } }
|
||||
listener = client.instance_variable_get(:@listener_thread)
|
||||
|
||||
wait_until { !listener.alive? }
|
||||
|
||||
assert_requested(:get, url, times: HTTP::MAX_RECONNECTION_ATTEMPTS)
|
||||
ensure
|
||||
client.close
|
||||
end
|
||||
|
||||
def test_listener_failure_count_resets_after_a_successful_stream
|
||||
stub_initialize
|
||||
stub_notification
|
||||
stub_request(:delete, url).to_return(status: 200)
|
||||
stub_request(:get, url).to_return(
|
||||
{ status: 500 },
|
||||
{ status: 200, headers: { "Content-Type" => "text/event-stream" }, body: "" },
|
||||
{ status: 500 },
|
||||
{ status: 500 },
|
||||
)
|
||||
|
||||
client.stubs(:sleep)
|
||||
client.connect
|
||||
client.on_server_request("elicitation/create") { { action: "decline" } }
|
||||
listener = client.instance_variable_get(:@listener_thread)
|
||||
|
||||
wait_until { !listener.alive? }
|
||||
|
||||
# Without the reset, the second failure (the third request) would
|
||||
# already reach the cap and the fourth request would never be made.
|
||||
assert_requested(:get, url, times: 4)
|
||||
ensure
|
||||
client.close
|
||||
end
|
||||
|
||||
def test_listener_stops_immediately_when_server_does_not_offer_a_get_stream
|
||||
stub_initialize
|
||||
stub_notification
|
||||
stub_request(:delete, url).to_return(status: 200)
|
||||
stub_request(:get, url).to_return(status: 405)
|
||||
|
||||
client.stubs(:sleep)
|
||||
client.connect
|
||||
client.on_server_request("elicitation/create") { { action: "decline" } }
|
||||
listener = client.instance_variable_get(:@listener_thread)
|
||||
|
||||
wait_until { !listener.alive? }
|
||||
|
||||
assert_requested(:get, url, times: 1)
|
||||
ensure
|
||||
client.close
|
||||
end
|
||||
|
||||
def test_captures_session_id_and_protocol_version_on_initialize
|
||||
stub_request(:post, url)
|
||||
.to_return(
|
||||
|
|
@ -1404,6 +1745,20 @@ module MCP
|
|||
WebMock.stub_request(method, url)
|
||||
end
|
||||
|
||||
# Polls until the block is truthy; the listener runs on a background
|
||||
# thread, so its requests are observed asynchronously.
|
||||
def wait_until(timeout: 5)
|
||||
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
||||
until yield
|
||||
flunk("Timed out waiting for condition") if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
||||
sleep(0.01)
|
||||
end
|
||||
end
|
||||
|
||||
def requested?(stub)
|
||||
WebMock::RequestRegistry.instance.times_executed(stub.request_pattern).positive?
|
||||
end
|
||||
|
||||
def url
|
||||
"http://example.com"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -47,6 +47,25 @@ module MCP
|
|||
assert_nil(client.server_info)
|
||||
end
|
||||
|
||||
def test_on_elicitation_registers_handler_on_transport
|
||||
transport = mock
|
||||
handler = proc { { action: "accept", content: {} } }
|
||||
transport.expects(:on_server_request).with("elicitation/create")
|
||||
|
||||
Client.new(transport: transport).on_elicitation(&handler)
|
||||
end
|
||||
|
||||
def test_on_elicitation_raises_when_transport_does_not_support_server_requests
|
||||
transport = mock
|
||||
transport.stubs(:respond_to?).with(:on_server_request).returns(false)
|
||||
|
||||
error = assert_raises(ArgumentError) do
|
||||
Client.new(transport: transport).on_elicitation { { action: "accept", content: {} } }
|
||||
end
|
||||
|
||||
assert_includes(error.message, "does not support server-to-client requests")
|
||||
end
|
||||
|
||||
def test_connected_delegates_to_transport_when_supported
|
||||
transport = mock
|
||||
transport.expects(:connected?).returns(true)
|
||||
|
|
|
|||
Loading…
Reference in a new issue