Skip to content

Add client-level connect for initialize handshake#327

Open
atesgoral wants to merge 1 commit intomodelcontextprotocol:mainfrom
atesgoral:ag/client-connect-handshake
Open

Add client-level connect for initialize handshake#327
atesgoral wants to merge 1 commit intomodelcontextprotocol:mainfrom
atesgoral:ag/client-connect-handshake

Conversation

@atesgoral
Copy link
Copy Markdown
Contributor

@atesgoral atesgoral commented Apr 20, 2026

Summary

Adds MCP::Client#connect to perform the MCP initialization handshake: sends an initialize request through the transport, followed by the required notifications/initialized notification, and returns the server's InitializeResult (protocol version, capabilities, server info, instructions).

  • client.connect(client_info:, protocol_version:, capabilities:) — idempotent. Delegates to transport.connect(...) when the transport exposes an explicit handshake (e.g. MCP::Client::HTTP); no-op when it doesn't (e.g. MCP::Client::Stdio, which still initializes implicitly on the first send_request).
  • client.connected? — reports handshake completion; delegates to the transport when supported, otherwise returns true.
  • client.server_info — returns the transport's cached InitializeResult, or nil when the transport doesn't expose it.

Why on the client

initialize is a protocol method — same level as ping, list_tools, call_tool — and the client already owns those. Python (ClientSession.initialize()) and TypeScript (Client.connect(transport)) both put the handshake on the client. Aligning Ruby with those SDKs gives users a unified API regardless of transport and keeps the transport layer focused on byte transport plus transport-specific state (e.g. HTTP session headers).

HTTP transport retains connect, connected?, server_info, session_id, and protocol_version — the client delegates through. The existing capture_session_info hook still fires during the handshake because initialize goes through transport.send_request, so session headers and negotiated protocol version continue to get captured automatically.

Stdio

Stdio currently initializes implicitly on the first send_request via a private initialize_session — unchanged in this PR. client.connect is a no-op for stdio, and existing stdio clients continue working without modification.

Open question for SDK contributors: should we fully unify by removing stdio's implicit init and requiring client.connect universally? It would tighten the contract (one explicit handshake path) at the cost of a breaking change for existing stdio users. Happy to do that in a follow-up if there's consensus.

Example

transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp")
client = MCP::Client.new(transport: transport)
client.connect
# => { "protocolVersion" => "2025-11-25", "capabilities" => {...}, "serverInfo" => {...} }

client.connected?  # => true
client.server_info # => cached InitializeResult
client.tools
transport.close

Test plan

  • Client-level unit tests: delegation, keyword-arg forwarding, no-op when transport lacks :connect, connected? delegation and default, server_info delegation and default
  • Transport-level unit tests (from the previous revision of this PR): happy path, defaults, custom parameters, idempotence, JSON-RPC error response, missing result, connected? lifecycle, reconnect after close
  • rake test — 782 runs, 1938 assertions, 0 failures
  • rubocop clean on touched files
  • E2E against examples/streamable_http_server.rb: client.connect → tools → call_tool → transport.close → reconnect via client.connect (new session id issued); client.server_info correctly clears on close
  • examples/stdio_client.rb still runs end-to-end without an explicit client.connect call (implicit-init path unchanged)

@atesgoral atesgoral marked this pull request as draft April 20, 2026 20:11
@atesgoral atesgoral force-pushed the ag/client-connect-handshake branch from 4ebea00 to aee9398 Compare April 21, 2026 02:12
@atesgoral atesgoral marked this pull request as ready for review April 21, 2026 02:14
Per MCP spec, clients must send an `initialize` request followed by a
`notifications/initialized` notification before issuing any other
requests. The server's `InitializeResult` (protocol version,
capabilities, server info, instructions) negotiates the session for the
lifetime of the connection.

`MCP::Client#connect` performs the handshake, returns the server's
`InitializeResult`, and exposes `connected?` and `server_info` readers.
It delegates to `transport.connect(...)` when the transport exposes an
explicit handshake (e.g. `MCP::Client::HTTP`) and is a no-op otherwise
(e.g. `MCP::Client::Stdio`, which manages the handshake implicitly on
the first request).

`initialize` is a protocol method, so the public API lives on the
client. This matches the Python SDK (`ClientSession.initialize()`) and
the TypeScript SDK (`Client.connect(transport)`). Transports retain
their transport-specific concerns: HTTP captures the `Mcp-Session-Id`
header and negotiated protocol version via the existing
`capture_session_info` hook that fires when an `initialize` request
passes through `send_request`.

https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
@atesgoral atesgoral force-pushed the ag/client-connect-handshake branch from aee9398 to 31892a5 Compare April 21, 2026 04:02
@atesgoral atesgoral changed the title Add HTTP client connect for initialize handshake Add client-level connect for initialize handshake Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant