Skip to content

Commit a2706cc

Browse files
authored
Merge pull request #326 from atesgoral/ag/client-session-termination
Add HTTP client close for explicit session termination
2 parents 0770e27 + ff3a42f commit a2706cc

3 files changed

Lines changed: 157 additions & 0 deletions

File tree

docs/building-clients.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ http_transport.protocol_version # => "2025-11-25"
8585

8686
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.
8787

88+
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.
89+
90+
```ruby
91+
http_transport.close
92+
```
93+
8894
### Authorization
8995

9096
Provide custom headers for authentication:

lib/mcp/client/http.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,28 @@ def send_request(request:)
9494
)
9595
end
9696

97+
# Terminates the session by sending an HTTP DELETE to the MCP endpoint
98+
# with the current `Mcp-Session-Id` header, and clears locally tracked
99+
# session state afterward. No-op when no session has been established.
100+
#
101+
# Per spec, the server MAY respond with HTTP 405 Method Not Allowed when
102+
# it does not support client-initiated termination, and returns 404 for
103+
# a session it has already terminated. Both mean the session is gone —
104+
# the desired end state. Other errors surface to the caller; local
105+
# session state is cleared either way.
106+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
107+
def close
108+
return unless @session_id
109+
110+
begin
111+
client.delete("", nil, session_headers)
112+
rescue Faraday::ClientError => e
113+
raise unless [404, 405].include?(e.response&.dig(:status))
114+
ensure
115+
clear_session
116+
end
117+
end
118+
97119
private
98120

99121
attr_reader :headers

test/mcp/client/http_test.rb

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,8 +601,137 @@ def test_clears_session_state_on_404
601601
assert_nil(client.protocol_version)
602602
end
603603

604+
def test_close_sends_delete_with_session_headers
605+
initialize_session
606+
607+
stub_request(:delete, url)
608+
.with(
609+
headers: {
610+
"Mcp-Session-Id" => "session-abc",
611+
"MCP-Protocol-Version" => "2025-11-25",
612+
},
613+
)
614+
.to_return(status: 200)
615+
616+
client.close
617+
end
618+
619+
def test_close_clears_session_state
620+
initialize_session
621+
stub_request(:delete, url).to_return(status: 200)
622+
623+
client.close
624+
625+
assert_nil(client.session_id)
626+
assert_nil(client.protocol_version)
627+
end
628+
629+
def test_close_without_session_is_noop
630+
client.close
631+
632+
assert_not_requested(:delete, url)
633+
assert_nil(client.session_id)
634+
end
635+
636+
def test_close_tolerates_405_response
637+
initialize_session
638+
stub_request(:delete, url).to_return(status: 405)
639+
640+
client.close
641+
642+
assert_nil(client.session_id)
643+
end
644+
645+
def test_close_tolerates_404_response
646+
initialize_session
647+
stub_request(:delete, url).to_return(status: 404)
648+
649+
client.close
650+
651+
assert_nil(client.session_id)
652+
end
653+
654+
def test_close_propagates_server_error_and_still_clears_state
655+
initialize_session
656+
stub_request(:delete, url).to_return(status: 500)
657+
658+
assert_raises(Faraday::ServerError) do
659+
client.close
660+
end
661+
662+
assert_nil(client.session_id)
663+
assert_nil(client.protocol_version)
664+
end
665+
666+
def test_close_propagates_unauthorized_and_still_clears_state
667+
initialize_session
668+
stub_request(:delete, url).to_return(status: 401)
669+
670+
assert_raises(Faraday::UnauthorizedError) do
671+
client.close
672+
end
673+
674+
assert_nil(client.session_id)
675+
end
676+
677+
def test_close_propagates_connection_failure_and_still_clears_state
678+
initialize_session
679+
stub_request(:delete, url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
680+
681+
assert_raises(Faraday::ConnectionFailed) do
682+
client.close
683+
end
684+
685+
assert_nil(client.session_id)
686+
end
687+
688+
def test_close_is_idempotent
689+
initialize_session
690+
stub_request(:delete, url).to_return(status: 200)
691+
692+
client.close
693+
client.close
694+
695+
assert_requested(:delete, url, times: 1)
696+
end
697+
698+
def test_close_allows_reinitializing_a_fresh_session
699+
initialize_session
700+
stub_request(:delete, url).to_return(status: 200)
701+
client.close
702+
703+
stub_request(:post, url)
704+
.to_return(
705+
status: 200,
706+
headers: {
707+
"Content-Type" => "application/json",
708+
"Mcp-Session-Id" => "session-xyz",
709+
},
710+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
711+
)
712+
713+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "initialize" })
714+
715+
assert_equal("session-xyz", client.session_id)
716+
assert_equal("2025-11-25", client.protocol_version)
717+
end
718+
604719
private
605720

721+
def initialize_session
722+
stub_request(:post, url)
723+
.to_return(
724+
status: 200,
725+
headers: {
726+
"Content-Type" => "application/json",
727+
"Mcp-Session-Id" => "session-abc",
728+
},
729+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
730+
)
731+
732+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
733+
end
734+
606735
def stub_request(method, url)
607736
WebMock.stub_request(method, url)
608737
end

0 commit comments

Comments
 (0)