Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/building-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions test/mcp/client/http_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down