Merge pull request #326 from atesgoral/ag/client-session-termination
Add HTTP client close for explicit session termination
This commit is contained in:
commit
a2706cc7ef
3 changed files with 157 additions and 0 deletions
|
|
@ -85,6 +85,12 @@ http_transport.protocol_version # => "2025-11-25"
|
|||
|
||||
If the server terminates the session, subsequent requests return HTTP 404 and the transport raises `MCP::Client::SessionExpiredError` (a subclass of `RequestHandlerError`). Session state is cleared automatically; callers should start a new session by sending a fresh `initialize` request.
|
||||
|
||||
To explicitly terminate a session (e.g., when the client application is shutting down), call `close`. The transport sends an HTTP DELETE to the MCP endpoint with the session header and clears local session state. A `405 Method Not Allowed` response (server doesn't support client-initiated termination) or `404 Not Found` (session already terminated server-side) is treated as success. Other errors — 5xx, authentication failures, connection errors — propagate to the caller. Local session state is cleared either way. Calling `close` without an active session is a no-op.
|
||||
|
||||
```ruby
|
||||
http_transport.close
|
||||
```
|
||||
|
||||
### Authorization
|
||||
|
||||
Provide custom headers for authentication:
|
||||
|
|
|
|||
|
|
@ -94,6 +94,28 @@ module MCP
|
|||
)
|
||||
end
|
||||
|
||||
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
|
||||
# with the current `Mcp-Session-Id` header, and clears locally tracked
|
||||
# session state afterward. No-op when no session has been established.
|
||||
#
|
||||
# Per spec, the server MAY respond with HTTP 405 Method Not Allowed when
|
||||
# it does not support client-initiated termination, and returns 404 for
|
||||
# a session it has already terminated. Both mean the session is gone —
|
||||
# the desired end state. Other errors surface to the caller; local
|
||||
# session state is cleared either way.
|
||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
|
||||
def close
|
||||
return unless @session_id
|
||||
|
||||
begin
|
||||
client.delete("", nil, session_headers)
|
||||
rescue Faraday::ClientError => e
|
||||
raise unless [404, 405].include?(e.response&.dig(:status))
|
||||
ensure
|
||||
clear_session
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :headers
|
||||
|
|
|
|||
|
|
@ -601,8 +601,137 @@ module MCP
|
|||
assert_nil(client.protocol_version)
|
||||
end
|
||||
|
||||
def test_close_sends_delete_with_session_headers
|
||||
initialize_session
|
||||
|
||||
stub_request(:delete, url)
|
||||
.with(
|
||||
headers: {
|
||||
"Mcp-Session-Id" => "session-abc",
|
||||
"MCP-Protocol-Version" => "2025-11-25",
|
||||
},
|
||||
)
|
||||
.to_return(status: 200)
|
||||
|
||||
client.close
|
||||
end
|
||||
|
||||
def test_close_clears_session_state
|
||||
initialize_session
|
||||
stub_request(:delete, url).to_return(status: 200)
|
||||
|
||||
client.close
|
||||
|
||||
assert_nil(client.session_id)
|
||||
assert_nil(client.protocol_version)
|
||||
end
|
||||
|
||||
def test_close_without_session_is_noop
|
||||
client.close
|
||||
|
||||
assert_not_requested(:delete, url)
|
||||
assert_nil(client.session_id)
|
||||
end
|
||||
|
||||
def test_close_tolerates_405_response
|
||||
initialize_session
|
||||
stub_request(:delete, url).to_return(status: 405)
|
||||
|
||||
client.close
|
||||
|
||||
assert_nil(client.session_id)
|
||||
end
|
||||
|
||||
def test_close_tolerates_404_response
|
||||
initialize_session
|
||||
stub_request(:delete, url).to_return(status: 404)
|
||||
|
||||
client.close
|
||||
|
||||
assert_nil(client.session_id)
|
||||
end
|
||||
|
||||
def test_close_propagates_server_error_and_still_clears_state
|
||||
initialize_session
|
||||
stub_request(:delete, url).to_return(status: 500)
|
||||
|
||||
assert_raises(Faraday::ServerError) do
|
||||
client.close
|
||||
end
|
||||
|
||||
assert_nil(client.session_id)
|
||||
assert_nil(client.protocol_version)
|
||||
end
|
||||
|
||||
def test_close_propagates_unauthorized_and_still_clears_state
|
||||
initialize_session
|
||||
stub_request(:delete, url).to_return(status: 401)
|
||||
|
||||
assert_raises(Faraday::UnauthorizedError) do
|
||||
client.close
|
||||
end
|
||||
|
||||
assert_nil(client.session_id)
|
||||
end
|
||||
|
||||
def test_close_propagates_connection_failure_and_still_clears_state
|
||||
initialize_session
|
||||
stub_request(:delete, url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
|
||||
|
||||
assert_raises(Faraday::ConnectionFailed) do
|
||||
client.close
|
||||
end
|
||||
|
||||
assert_nil(client.session_id)
|
||||
end
|
||||
|
||||
def test_close_is_idempotent
|
||||
initialize_session
|
||||
stub_request(:delete, url).to_return(status: 200)
|
||||
|
||||
client.close
|
||||
client.close
|
||||
|
||||
assert_requested(:delete, url, times: 1)
|
||||
end
|
||||
|
||||
def test_close_allows_reinitializing_a_fresh_session
|
||||
initialize_session
|
||||
stub_request(:delete, url).to_return(status: 200)
|
||||
client.close
|
||||
|
||||
stub_request(:post, url)
|
||||
.to_return(
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type" => "application/json",
|
||||
"Mcp-Session-Id" => "session-xyz",
|
||||
},
|
||||
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
||||
)
|
||||
|
||||
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "initialize" })
|
||||
|
||||
assert_equal("session-xyz", client.session_id)
|
||||
assert_equal("2025-11-25", client.protocol_version)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_session
|
||||
stub_request(:post, url)
|
||||
.to_return(
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type" => "application/json",
|
||||
"Mcp-Session-Id" => "session-abc",
|
||||
},
|
||||
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
|
||||
)
|
||||
|
||||
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
|
||||
end
|
||||
|
||||
def stub_request(method, url)
|
||||
WebMock.stub_request(method, url)
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue