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
19 changes: 17 additions & 2 deletions docs/building-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses
```ruby
http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
client = MCP::Client.new(transport: http_transport)
client.connect

tools = client.tools
tools.each do |tool|
Expand All @@ -74,16 +75,30 @@ response = client.call_tool(
)
```

### Handshake

Call `MCP::Client#connect` to perform the MCP [initialization handshake](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization): the client sends an `initialize` request through the transport, followed by the required `notifications/initialized` notification, and caches the server's `InitializeResult` (protocol version, capabilities, server info, instructions):

```ruby
client.connect
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }

client.connected? # => true
client.server_info # => cached InitializeResult
```

`connect` accepts optional `client_info:`, `protocol_version:`, and `capabilities:` keyword arguments. It is idempotent — a second call returns the cached result without contacting the server. After `close`, state is cleared and `connect` will handshake again.

### Sessions

After a successful `initialize` request, the transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes the session ID on subsequent requests. Both are exposed on the transport:
After `connect` succeeds, the HTTP transport captures the `Mcp-Session-Id` header and `protocolVersion` from the response and includes them on subsequent requests. Both are exposed on the transport as transport-specific state:

```ruby
http_transport.session_id # => "abc123..."
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.
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 calling `connect` again.

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.

Expand Down
42 changes: 42 additions & 0 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,48 @@ def initialize(transport:)
# So keeping it public
attr_reader :transport

# The server's `InitializeResult` (protocol version, capabilities, server info,
# instructions), as reported by the transport after a successful `connect`.
# Returns `nil` before `connect`, after `close`, or when the transport manages
# the handshake implicitly and does not expose it (e.g. stdio).
def server_info
transport.server_info if transport.respond_to?(:server_info)
end

# Performs the MCP `initialize` handshake by delegating to the transport when
# it exposes a `connect` method (e.g. `MCP::Client::HTTP`). Returns the
# server's `InitializeResult`.
#
# When the transport does not respond to `:connect` (e.g. `MCP::Client::Stdio`
# manages the handshake implicitly on the first request), this is a no-op and
# returns `nil`.
#
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
# @param protocol_version [String, nil] Protocol version to offer.
# @param capabilities [Hash] Capabilities advertised by the client.
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
# does not expose an explicit handshake.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
def connect(client_info: nil, protocol_version: nil, capabilities: {})
return unless transport.respond_to?(:connect)

transport.connect(
client_info: client_info,
protocol_version: protocol_version,
capabilities: capabilities,
)
end

# Returns true once `connect` has completed the handshake on transports that
# expose connection state. Transports that manage the handshake implicitly
# (e.g. stdio) always report `true`, since the first request will initialize
# on demand.
def connected?
return transport.connected? if transport.respond_to?(:connected?)

true
end

# Returns a single page of tools from the server.
#
# @param cursor [String, nil] Cursor from a previous page response.
Expand Down
101 changes: 99 additions & 2 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# frozen_string_literal: true

require "securerandom"
require_relative "../../json_rpc_handler"
require_relative "../configuration"
require_relative "../methods"
require_relative "../version"

module MCP
class Client
Expand All @@ -13,14 +17,102 @@ class HTTP
SESSION_ID_HEADER = "Mcp-Session-Id"
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"

attr_reader :url, :session_id, :protocol_version
attr_reader :url, :session_id, :protocol_version, :server_info

def initialize(url:, headers: {}, &block)
@url = url
@headers = headers
@faraday_customizer = block
@session_id = nil
@protocol_version = nil
@server_info = nil
@connected = false
end

# Performs the MCP `initialize` handshake: sends an `initialize` request
# followed by the required `notifications/initialized` notification. The
# server's `InitializeResult` (protocol version, capabilities, server
# info, instructions) is cached on the transport and returned.
#
# Idempotent: a second call returns the cached `InitializeResult` without
# contacting the server. After `close`, state is cleared and `connect`
# will handshake again.
#
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
# Defaults to `{ name: "mcp-ruby-client", version: MCP::VERSION }`.
# @param protocol_version [String, nil] Protocol version to offer. Defaults
# to `MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION`.
# @param capabilities [Hash] Capabilities advertised by the client. Defaults to `{}`.
# @return [Hash] The server's `InitializeResult`.
# @raise [RequestHandlerError] If the server responds with a JSON-RPC error
# or a malformed result.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
def connect(client_info: nil, protocol_version: nil, capabilities: {})
return @server_info if connected?

client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION }
protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION

response = send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
id: SecureRandom.uuid,
method: MCP::Methods::INITIALIZE,
params: {
protocolVersion: protocol_version,
capabilities: capabilities,
clientInfo: client_info,
},
})

if response.is_a?(Hash) && response.key?("error")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems reasonable to clear the session when an error occurs. The following is an example.

         if response.is_a?(Hash) && response.key?("error")
+          # `send_request` has already invoked `capture_session_info`, which may have
+          # stored a session id from the response headers even though the server then
+          # returned a JSON-RPC error. Clear it so a retry starts fresh.
+          clear_session
           error = response["error"]
           raise RequestHandlerError.new(
             "Server initialization failed: #{error["message"]}",
@@ -74,6 +78,7 @@ module MCP
         end

         unless response.is_a?(Hash) && response["result"].is_a?(Hash)
+          clear_session
           raise RequestHandlerError.new(
             "Server initialization failed: missing result in response",
             { method: MCP::Methods::INITIALIZE },
@@ -97,10 +102,15 @@ module MCP
           )
         end

-        send_request(request: {
-          jsonrpc: JsonRpcHandler::Version::V2_0,
-          method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
-        })
+        begin
+          send_request(request: {
+            jsonrpc: JsonRpcHandler::Version::V2_0,
+            method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
+          })
+        rescue
+          clear_session
+          raise
+        end

         @connected = true
         @server_info

clear_session
error = response["error"]
raise RequestHandlerError.new(
"Server initialization failed: #{error["message"]}",
{ method: MCP::Methods::INITIALIZE },
error_type: :internal_error,
)
end

unless response.is_a?(Hash) && response["result"].is_a?(Hash)
clear_session
raise RequestHandlerError.new(
"Server initialization failed: missing result in response",
{ method: MCP::Methods::INITIALIZE },
error_type: :internal_error,
)
end

@server_info = response["result"]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the MCP specification, it seems advisable to validate the negotiated protocol version. The following is an example.

+
+        negotiated_protocol_version = @server_info["protocolVersion"]
+        unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
+          # Per spec, if the client does not support the server's returned protocol version,
+          # it SHOULD disconnect. Roll back state captured on the initialize response before raising,
+          # so a retry starts from a clean slate.
+          # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
+          clear_session
+          raise RequestHandlerError.new(
+            "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
+            { method: MCP::Methods::INITIALIZE },
+            error_type: :internal_error,
+          )
+        end

negotiated_protocol_version = @server_info["protocolVersion"]
unless MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(negotiated_protocol_version)
clear_session
raise RequestHandlerError.new(
"Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}",
{ method: MCP::Methods::INITIALIZE },
error_type: :internal_error,
)
end

begin
send_request(request: {
jsonrpc: JsonRpcHandler::Version::V2_0,
method: MCP::Methods::NOTIFICATIONS_INITIALIZED,
})
rescue StandardError
clear_session
raise
end

@connected = true
@server_info
end

# Returns true once `connect` has completed the full handshake
# (`initialize` response received and `notifications/initialized` sent).
# Returns false before the first handshake and after `close`.
def connected?
@connected
end

# Sends a JSON-RPC request and returns the parsed response body.
Expand Down Expand Up @@ -105,7 +197,10 @@ def send_request(request:)
# session state is cleared either way.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
def close
return unless @session_id
unless @session_id
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@koic This was added since you reviewed:

  -        return unless @session_id
  +        unless @session_id
  +          clear_session
  +          return
  +        end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I confirmed it using the "Compare" link on GitHub.

clear_session
return
end

begin
client.delete("", nil, session_headers)
Expand Down Expand Up @@ -159,6 +254,8 @@ def capture_session_info(method, response, body)
def clear_session
@session_id = nil
@protocol_version = nil
@server_info = nil
@connected = false
end

def require_faraday!
Expand Down
Loading