Skip to content

Commit b56d607

Browse files
committed
Add HTTP client close for explicit session termination
Per MCP spec, clients that no longer need a session SHOULD send an HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header to explicitly terminate it. Servers MAY respond with 405 Method Not Allowed when they don't support client-initiated termination. `MCP::Client::HTTP#close` sends the DELETE with session headers, clears local session state regardless of outcome, and is a no-op when no session has been established. https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
1 parent 0770e27 commit b56d607

File tree

3 files changed

+123
-0
lines changed

3 files changed

+123
-0
lines changed

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 from servers that don't support client-initiated termination is treated as success; other transport errors are swallowed so local cleanup always succeeds. 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; this is not treated
103+
# as an error. Any Faraday error is swallowed so local cleanup always
104+
# succeeds.
105+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
106+
def close
107+
return unless @session_id
108+
109+
begin
110+
client.delete("", nil, session_headers)
111+
rescue Faraday::Error
112+
# Swallowed: spec allows 405, and other transport errors must not
113+
# block local cleanup.
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: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,8 +601,103 @@ 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_server_errors
646+
initialize_session
647+
stub_request(:delete, url).to_return(status: 500)
648+
649+
client.close
650+
651+
assert_nil(client.session_id)
652+
end
653+
654+
def test_close_is_idempotent
655+
initialize_session
656+
stub_request(:delete, url).to_return(status: 200)
657+
658+
client.close
659+
client.close
660+
661+
assert_requested(:delete, url, times: 1)
662+
end
663+
664+
def test_close_allows_reinitializing_a_fresh_session
665+
initialize_session
666+
stub_request(:delete, url).to_return(status: 200)
667+
client.close
668+
669+
stub_request(:post, url)
670+
.to_return(
671+
status: 200,
672+
headers: {
673+
"Content-Type" => "application/json",
674+
"Mcp-Session-Id" => "session-xyz",
675+
},
676+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
677+
)
678+
679+
client.send_request(request: { jsonrpc: "2.0", id: "2", method: "initialize" })
680+
681+
assert_equal("session-xyz", client.session_id)
682+
assert_equal("2025-11-25", client.protocol_version)
683+
end
684+
604685
private
605686

687+
def initialize_session
688+
stub_request(:post, url)
689+
.to_return(
690+
status: 200,
691+
headers: {
692+
"Content-Type" => "application/json",
693+
"Mcp-Session-Id" => "session-abc",
694+
},
695+
body: { result: { protocolVersion: "2025-11-25" } }.to_json,
696+
)
697+
698+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
699+
end
700+
606701
def stub_request(method, url)
607702
WebMock.stub_request(method, url)
608703
end

0 commit comments

Comments
 (0)