-
Notifications
You must be signed in to change notification settings - Fork 110
Add client-level connect for initialize handshake #327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
@@ -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") | ||
| 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"] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
|
@@ -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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @koic This was added since you reviewed:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
@@ -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! | ||
|
|
||
There was a problem hiding this comment.
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