Merge pull request #511 from koic/reject_modern_removed_methods_before_era_lock
Reject modern-removed methods before the connection era locks
This commit is contained in:
commit
451c2b3c89
3 changed files with 96 additions and 4 deletions
|
|
@ -599,9 +599,9 @@ module MCP
|
|||
end
|
||||
|
||||
# SEP-2575 removes these RPCs from the modern lifecycle, so they answer with Method not found
|
||||
# there regardless of the envelope or the server's declared capabilities: in this era the method
|
||||
# does not exist at all, which is why the check precedes `ensure_capability!`.
|
||||
if session.respond_to?(:era) && session.era == :modern && Methods::MODERN_REMOVED_METHODS.include?(method)
|
||||
# there regardless of the server's declared capabilities: in this era the method does not exist
|
||||
# at all, which is why the check precedes `ensure_capability!`.
|
||||
if Methods::MODERN_REMOVED_METHODS.include?(method) && modern_request?(request, session)
|
||||
raise RequestHandlerError.new(
|
||||
"Method not found: #{method} is not part of the modern lifecycle (SEP-2575)",
|
||||
request,
|
||||
|
|
@ -723,6 +723,23 @@ module MCP
|
|||
}
|
||||
end
|
||||
|
||||
# Whether this request belongs to the modern lifecycle, used to refuse the RPCs SEP-2575 removed from it.
|
||||
# A locked `ServerSession#era` is authoritative; until it locks, the request's own `_meta` envelope is
|
||||
# the signal. Both are needed because `StdioTransport` locks the era only after a response succeeds,
|
||||
# which would otherwise let the first request of a connection reach a method the modern lifecycle does not have.
|
||||
# The Python SDK gates the same way, on the envelope of each request rather than on connection state that
|
||||
# is only settled afterwards.
|
||||
#
|
||||
# A legacy-locked session is deliberately excluded: `lift_request_envelope` answers a modern envelope there
|
||||
# with the lifecycle violation, which names the cause better than Method not found.
|
||||
def modern_request?(request, session)
|
||||
era = session.respond_to?(:era) ? session.era : nil
|
||||
return true if era == :modern
|
||||
return false unless era.nil?
|
||||
|
||||
RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil)
|
||||
end
|
||||
|
||||
# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
|
||||
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
|
||||
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
|
||||
|
|
|
|||
|
|
@ -598,6 +598,29 @@ module MCP
|
|||
assert_equal :modern, session_era
|
||||
end
|
||||
|
||||
test "rejects a removed lifecycle method carrying the envelope as the connection's first frame" do
|
||||
# The era locks only after a response succeeds, so this frame reaches the server unlocked.
|
||||
# SEP-2575 removed `resources/subscribe`, and the envelope is what identifies the request as modern.
|
||||
responses = run_transport_session([
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "resources/subscribe",
|
||||
id: 1,
|
||||
params: {
|
||||
uri: "https://example.invalid/resource",
|
||||
_meta: {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, responses[0].dig(:error, :code)
|
||||
refute_equal :legacy, session_era
|
||||
end
|
||||
|
||||
test "does not lock an era when the era-distinctive request fails" do
|
||||
# An unsupported envelope version fails with -32022, so the connection stays unlocked
|
||||
# and a legacy initialize can still succeed afterwards.
|
||||
|
|
|
|||
|
|
@ -399,6 +399,56 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
test "#handle rejects removed lifecycle methods carrying the envelope before the era locks" do
|
||||
# `StdioTransport` locks the era only after a response succeeds, so the first request of a connection
|
||||
# arrives with `era == nil`. The envelope on the request is what identifies it as modern there.
|
||||
Methods::MODERN_REMOVED_METHODS.each do |method|
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
response = @server.handle(modern_request(method, {}), session: session)
|
||||
|
||||
assert_equal JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND, response.dig(:error, :code), "expected -32601 for #{method}"
|
||||
end
|
||||
end
|
||||
|
||||
test "#handle does not let an enveloped initialize lock the era before it is settled" do
|
||||
# Serving it would negotiate the legacy lifecycle for a request that declared the modern one,
|
||||
# pinning the connection to the wrong era for its whole lifetime.
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
@server.handle(modern_request("initialize", initialize_params), session: session)
|
||||
|
||||
assert_nil session.era
|
||||
refute_predicate session, :initialized?
|
||||
end
|
||||
|
||||
test "#handle serves an enveloped method the modern lifecycle keeps before the era locks" do
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
response = @server.handle(modern_request("tools/list", {}), session: session)
|
||||
|
||||
refute_nil response[:result]
|
||||
end
|
||||
|
||||
test "#handle keeps serving a legacy initialize that negotiates a dual-era version" do
|
||||
# Negotiating 2026-07-28 through the legacy handshake is not the same as carrying the modern envelope:
|
||||
# the handshake still belongs to the legacy lifecycle and locks that era.
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
response = @server.handle(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
id: 1,
|
||||
params: initialize_params(protocolVersion: "2026-07-28"),
|
||||
},
|
||||
session: session,
|
||||
)
|
||||
|
||||
assert_equal "2026-07-28", response.dig(:result, :protocolVersion)
|
||||
assert_equal :legacy, session.era
|
||||
end
|
||||
|
||||
test "#handle server/discover requires the envelope on a modern-locked session" do
|
||||
# The 2026-07-28 conformance requirements reject an envelope-less `server/discover`
|
||||
# on the modern era with -32602 (SEP-2575).
|
||||
|
|
@ -4281,9 +4331,11 @@ module MCP
|
|||
test "modern results carry resultType complete" do
|
||||
server = Server.new(name: "result_type_test", tools: [result_type_tool])
|
||||
|
||||
# Every method here has to be one the modern lifecycle still has. `ping` does not qualify:
|
||||
# SEP-2575 removed it, so a modern request naming it is answered with -32601, not a stamped result.
|
||||
[
|
||||
modern_request(Methods::TOOLS_LIST, {}),
|
||||
modern_request(Methods::PING, {}),
|
||||
modern_request(Methods::PROMPTS_LIST, {}),
|
||||
modern_request(Methods::TOOLS_CALL, { name: "result_type_tool", arguments: {} }),
|
||||
].each do |request|
|
||||
response = server.handle(request)
|
||||
|
|
|
|||
Loading…
Reference in a new issue