diff --git a/docs/building-clients.md b/docs/building-clients.md index 272ec32e..d39beda6 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -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: diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 9b85d7dc..c0d72471 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -94,6 +94,28 @@ def send_request(request:) ) 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 diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index a4dcbc8f..19917dd8 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -601,8 +601,137 @@ def test_clears_session_state_on_404 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