Merge pull request #440 from koic/mcp_2026_07_28_spec_associate_server_requests
Associate server-to-client requests with the originating client request per SEP-2260
This commit is contained in:
commit
a793dde6cd
5 changed files with 323 additions and 16 deletions
|
|
@ -2891,6 +2891,67 @@ module MCP
|
|||
assert_equal "Hi there", result[:content][:text]
|
||||
end
|
||||
|
||||
test "send_request with related_request_id rides the originating POST stream, not the GET stream" do
|
||||
# Per SEP-2260, server-to-client requests associated with a client request are delivered on
|
||||
# that request's POST SSE response stream rather than the standalone GET stream.
|
||||
init_request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
|
||||
)
|
||||
init_response = @transport.handle_request(init_request)
|
||||
session_id = init_response[1]["Mcp-Session-Id"]
|
||||
|
||||
# Connect GET SSE.
|
||||
get_io = StringIO.new
|
||||
get_request = create_rack_request(
|
||||
"GET",
|
||||
"/",
|
||||
{ "HTTP_MCP_SESSION_ID" => session_id },
|
||||
)
|
||||
response = @transport.handle_request(get_request)
|
||||
response[2].call(get_io) if response[2].is_a?(Proc)
|
||||
sleep(0.1)
|
||||
|
||||
# Register an in-flight POST request stream for "req-1".
|
||||
post_io = StringIO.new
|
||||
@transport.instance_variable_get(:@sessions)[session_id][:post_request_streams] = { "req-1" => post_io }
|
||||
|
||||
result_queue = Queue.new
|
||||
Thread.new do
|
||||
result = @transport.send_request(
|
||||
"roots/list",
|
||||
nil,
|
||||
session_id: session_id,
|
||||
related_request_id: "req-1",
|
||||
)
|
||||
result_queue.push(result)
|
||||
end
|
||||
|
||||
sleep(0.1)
|
||||
|
||||
post_io.rewind
|
||||
post_output = post_io.read
|
||||
assert_includes post_output, "roots/list"
|
||||
|
||||
get_io.rewind
|
||||
refute_includes get_io.read, "roots/list"
|
||||
|
||||
# Respond so the background send_request completes.
|
||||
data_lines = post_output.lines.select { |line| line.start_with?("data: ") }
|
||||
request_id = JSON.parse(data_lines.first.sub("data: ", ""))["id"]
|
||||
client_response = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id },
|
||||
{ jsonrpc: "2.0", id: request_id, result: { roots: [] } }.to_json,
|
||||
)
|
||||
@transport.handle_request(client_response)
|
||||
|
||||
assert_equal({ roots: [] }, result_queue.pop)
|
||||
end
|
||||
|
||||
test "send_request ignores response from wrong session" do
|
||||
# Create two sessions.
|
||||
init_a = create_rack_request(
|
||||
|
|
|
|||
|
|
@ -57,6 +57,87 @@ module MCP
|
|||
assert_equal [{ uri: "file:///project", name: "Project" }], result[:roots]
|
||||
end
|
||||
|
||||
test "ServerContext#list_roots stamps the originating request id" do
|
||||
# Per SEP-2260, server-to-client requests are associated with the originating client request automatically.
|
||||
notification_target = mock
|
||||
notification_target.expects(:list_roots).with(related_request_id: "req-1").returns({ roots: [] })
|
||||
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
server_context = ServerContext.new(
|
||||
mock,
|
||||
progress: progress,
|
||||
notification_target: notification_target,
|
||||
related_request_id: "req-1",
|
||||
)
|
||||
|
||||
server_context.list_roots
|
||||
end
|
||||
|
||||
test "ServerContext#create_sampling_message stamps related_request_id non-overridably" do
|
||||
# A caller-supplied `related_request_id:` must not detach the request from its originating client request (SEP-2260):
|
||||
# the literal keyword after `**kwargs` wins.
|
||||
notification_target = mock
|
||||
notification_target.expects(:create_sampling_message).with(
|
||||
messages: [],
|
||||
max_tokens: 5,
|
||||
related_request_id: "req-1",
|
||||
).returns({})
|
||||
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
server_context = ServerContext.new(
|
||||
mock,
|
||||
progress: progress,
|
||||
notification_target: notification_target,
|
||||
related_request_id: "req-1",
|
||||
)
|
||||
|
||||
server_context.create_sampling_message(messages: [], max_tokens: 5, related_request_id: "attacker")
|
||||
end
|
||||
|
||||
test "ServerContext#create_form_elicitation stamps related_request_id non-overridably" do
|
||||
notification_target = mock
|
||||
notification_target.expects(:create_form_elicitation).with(
|
||||
message: "hi",
|
||||
requested_schema: {},
|
||||
related_request_id: "req-1",
|
||||
).returns({})
|
||||
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
server_context = ServerContext.new(
|
||||
mock,
|
||||
progress: progress,
|
||||
notification_target: notification_target,
|
||||
related_request_id: "req-1",
|
||||
)
|
||||
|
||||
server_context.create_form_elicitation(message: "hi", requested_schema: {}, related_request_id: "attacker")
|
||||
end
|
||||
|
||||
test "ServerContext#create_url_elicitation stamps related_request_id non-overridably" do
|
||||
notification_target = mock
|
||||
notification_target.expects(:create_url_elicitation).with(
|
||||
message: "hi",
|
||||
url: "https://example.com",
|
||||
elicitation_id: "el-1",
|
||||
related_request_id: "req-1",
|
||||
).returns({})
|
||||
|
||||
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||
server_context = ServerContext.new(
|
||||
mock,
|
||||
progress: progress,
|
||||
notification_target: notification_target,
|
||||
related_request_id: "req-1",
|
||||
)
|
||||
|
||||
server_context.create_url_elicitation(
|
||||
message: "hi",
|
||||
url: "https://example.com",
|
||||
elicitation_id: "el-1",
|
||||
related_request_id: "attacker",
|
||||
)
|
||||
end
|
||||
|
||||
test "ServerContext#list_roots raises NoMethodError when notification_target does not respond" do
|
||||
notification_target = mock
|
||||
context = mock
|
||||
|
|
|
|||
|
|
@ -48,8 +48,52 @@ module MCP
|
|||
@session.instance_variable_set(:@client_capabilities, { elicitation: {} })
|
||||
end
|
||||
|
||||
test "#create_form_elicitation warns when called without related_request_id" do
|
||||
# Per SEP-2260, server-to-client requests must be associated with an originating client request.
|
||||
# `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing.
|
||||
original_verbose = $VERBOSE
|
||||
$VERBOSE = false
|
||||
|
||||
assert_output(nil, /SEP-2260/) do
|
||||
@session.create_form_elicitation(
|
||||
message: "Please provide your name",
|
||||
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
||||
)
|
||||
end
|
||||
ensure
|
||||
$VERBOSE = original_verbose
|
||||
end
|
||||
|
||||
test "#create_url_elicitation warns when called without related_request_id" do
|
||||
@session.instance_variable_set(:@client_capabilities, { elicitation: { url: true } })
|
||||
|
||||
original_verbose = $VERBOSE
|
||||
$VERBOSE = false
|
||||
|
||||
assert_output(nil, /SEP-2260/) do
|
||||
@session.create_url_elicitation(
|
||||
message: "Please sign in",
|
||||
url: "https://example.com/signin",
|
||||
elicitation_id: "el-1",
|
||||
)
|
||||
end
|
||||
ensure
|
||||
$VERBOSE = original_verbose
|
||||
end
|
||||
|
||||
test "#create_form_elicitation does not warn when related_request_id is given" do
|
||||
assert_silent do
|
||||
@session.create_form_elicitation(
|
||||
related_request_id: "req-1",
|
||||
message: "Please provide your name",
|
||||
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "#create_form_elicitation sends request through transport" do
|
||||
result = @session.create_form_elicitation(
|
||||
related_request_id: "req-1",
|
||||
message: "Please provide your name",
|
||||
requested_schema: {
|
||||
type: "object",
|
||||
|
|
@ -76,6 +120,7 @@ module MCP
|
|||
|
||||
error = assert_raises(RuntimeError) do
|
||||
@session.create_form_elicitation(
|
||||
related_request_id: "req-1",
|
||||
message: "Please provide your name",
|
||||
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
||||
)
|
||||
|
|
@ -93,6 +138,7 @@ module MCP
|
|||
|
||||
error = assert_raises(RuntimeError) do
|
||||
@session.create_form_elicitation(
|
||||
related_request_id: "req-1",
|
||||
message: "Please provide your name",
|
||||
requested_schema: { type: "object", properties: { name: { type: "string" } } },
|
||||
)
|
||||
|
|
@ -108,6 +154,7 @@ module MCP
|
|||
@session.instance_variable_set(:@client_capabilities, { elicitation: { url: {} } })
|
||||
|
||||
result = @session.create_url_elicitation(
|
||||
related_request_id: "req-1",
|
||||
message: "Please authorize access",
|
||||
url: "https://example.com/oauth",
|
||||
elicitation_id: "abc-123",
|
||||
|
|
@ -128,6 +175,7 @@ module MCP
|
|||
test "#create_url_elicitation raises error when client does not support url mode" do
|
||||
error = assert_raises(RuntimeError) do
|
||||
@session.create_url_elicitation(
|
||||
related_request_id: "req-1",
|
||||
message: "Please authorize access",
|
||||
url: "https://example.com/oauth",
|
||||
elicitation_id: "abc-123",
|
||||
|
|
@ -146,6 +194,7 @@ module MCP
|
|||
|
||||
error = assert_raises(RuntimeError) do
|
||||
@session.create_url_elicitation(
|
||||
related_request_id: "req-1",
|
||||
message: "Please authorize access",
|
||||
url: "https://example.com/oauth",
|
||||
elicitation_id: "abc-123",
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ module MCP
|
|||
transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session_with_roots }
|
||||
|
||||
error_with_roots = assert_raises(RuntimeError) do
|
||||
session_with_roots.list_roots
|
||||
capture_io { session_with_roots.list_roots }
|
||||
end
|
||||
assert_equal("No active stream for roots/list request.", error_with_roots.message)
|
||||
|
||||
|
|
@ -119,11 +119,31 @@ module MCP
|
|||
session_without_roots.store_client_info(client: { name: "incapable" }, capabilities: {})
|
||||
|
||||
error = assert_raises(RuntimeError) do
|
||||
session_without_roots.list_roots
|
||||
capture_io { session_without_roots.list_roots }
|
||||
end
|
||||
assert_equal("Client does not support roots.", error.message)
|
||||
end
|
||||
|
||||
test "ServerSession#list_roots warns when called without related_request_id" do
|
||||
# Per SEP-2260, server-to-client requests must be associated with an originating client request.
|
||||
# `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing.
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
||||
session.store_client_info(client: { name: "capable" }, capabilities: { roots: {} })
|
||||
|
||||
original_verbose = $VERBOSE
|
||||
$VERBOSE = false
|
||||
assert_output(nil, /SEP-2260/) { session.list_roots }
|
||||
ensure
|
||||
$VERBOSE = original_verbose
|
||||
end
|
||||
|
||||
test "ServerSession#list_roots does not warn when related_request_id is given" do
|
||||
session = ServerSession.new(server: @server, transport: @mock_transport)
|
||||
session.store_client_info(client: { name: "capable" }, capabilities: { roots: {} })
|
||||
|
||||
assert_silent { session.list_roots(related_request_id: "req-1") }
|
||||
end
|
||||
|
||||
test "ServerSession#client_capabilities falls back to server global capabilities" do
|
||||
transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server)
|
||||
|
||||
|
|
@ -131,7 +151,7 @@ module MCP
|
|||
transport.instance_variable_get(:@sessions)["s3"] = { stream: nil, server_session: session }
|
||||
|
||||
error = assert_raises(RuntimeError) do
|
||||
session.list_roots
|
||||
capture_io { session.list_roots }
|
||||
end
|
||||
assert_equal("No active stream for roots/list request.", error.message)
|
||||
end
|
||||
|
|
@ -195,7 +215,7 @@ module MCP
|
|||
|
||||
transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session }
|
||||
error = assert_raises(RuntimeError) do
|
||||
session.list_roots
|
||||
capture_io { session.list_roots }
|
||||
end
|
||||
assert_equal("No active stream for roots/list request.", error.message)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ module MCP
|
|||
|
||||
test "create_sampling_message sends request with required params" do
|
||||
result = @session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
|
|
@ -64,6 +65,7 @@ module MCP
|
|||
|
||||
test "create_sampling_message sends all optional params" do
|
||||
@session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
system_prompt: "You are helpful",
|
||||
|
|
@ -90,6 +92,7 @@ module MCP
|
|||
|
||||
error = assert_raises(RuntimeError) do
|
||||
@session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
|
|
@ -101,6 +104,7 @@ module MCP
|
|||
test "create_sampling_message raises error when tools used but client lacks sampling.tools" do
|
||||
error = assert_raises(RuntimeError) do
|
||||
@session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
tools: [{ name: "test_tool", inputSchema: { type: "object" } }],
|
||||
|
|
@ -113,6 +117,7 @@ module MCP
|
|||
test "create_sampling_message raises error when tool_choice used alone but client lacks sampling.tools" do
|
||||
error = assert_raises(RuntimeError) do
|
||||
@session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
tool_choice: { mode: "auto" },
|
||||
|
|
@ -126,6 +131,7 @@ module MCP
|
|||
@session.store_client_info(client: { name: "test-client" }, capabilities: { sampling: { tools: {} } })
|
||||
|
||||
result = @session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
tools: [{ name: "test_tool", inputSchema: { type: "object" } }],
|
||||
|
|
@ -140,6 +146,31 @@ module MCP
|
|||
assert_equal "Response from LLM", result[:content][:text]
|
||||
end
|
||||
|
||||
test "create_sampling_message warns when called without related_request_id" do
|
||||
# Per SEP-2260, server-to-client requests must be associated with an originating client request.
|
||||
# `$VERBOSE = false` because the rake test task runs with `-W0`, under which `Kernel#warn` emits nothing.
|
||||
original_verbose = $VERBOSE
|
||||
$VERBOSE = false
|
||||
assert_output(nil, /SEP-2260/) do
|
||||
@session.create_sampling_message(
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
end
|
||||
ensure
|
||||
$VERBOSE = original_verbose
|
||||
end
|
||||
|
||||
test "create_sampling_message does not warn when related_request_id is given" do
|
||||
assert_silent do
|
||||
@session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test "create_sampling_message uses per-session capabilities via ServerSession" do
|
||||
transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server)
|
||||
|
||||
|
|
@ -149,10 +180,12 @@ module MCP
|
|||
transport.instance_variable_get(:@sessions)["s1"] = { stream: nil, server_session: session_with_sampling }
|
||||
|
||||
error_with_sampling = assert_raises(RuntimeError) do
|
||||
session_with_sampling.create_sampling_message(
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
capture_io do
|
||||
session_with_sampling.create_sampling_message(
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
end
|
||||
end
|
||||
assert_equal("No active stream for sampling/createMessage request.", error_with_sampling.message)
|
||||
|
||||
|
|
@ -161,10 +194,12 @@ module MCP
|
|||
session_without_sampling.store_client_info(client: { name: "incapable" }, capabilities: {})
|
||||
|
||||
error = assert_raises(RuntimeError) do
|
||||
session_without_sampling.create_sampling_message(
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
capture_io do
|
||||
session_without_sampling.create_sampling_message(
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
end
|
||||
end
|
||||
assert_equal("Client does not support sampling.", error.message)
|
||||
end
|
||||
|
|
@ -190,10 +225,12 @@ module MCP
|
|||
|
||||
# Server was initialized with sampling capability, so fallback should pass validation.
|
||||
error = assert_raises(RuntimeError) do
|
||||
session.create_sampling_message(
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
capture_io do
|
||||
session.create_sampling_message(
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
)
|
||||
end
|
||||
end
|
||||
assert_equal("No active stream for sampling/createMessage request.", error.message)
|
||||
end
|
||||
|
|
@ -243,6 +280,7 @@ module MCP
|
|||
|
||||
test "create_sampling_message omits nil optional params" do
|
||||
@session.create_sampling_message(
|
||||
related_request_id: "req-1",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello" } }],
|
||||
max_tokens: 100,
|
||||
system_prompt: nil,
|
||||
|
|
|
|||
Loading…
Reference in a new issue