ruby-sdk/examples/stdio_client.rb
Koichi ITO 46b738e945 Add client-level connect handshake to stdio transport
## Motivation and Context

This commit implements Phase 1 of 3 for #334.

The MCP specification requires an `initialize` request followed by
a `notifications/initialized` notification before any other interaction.
PR #327 added `MCP::Client#connect` and `MCP::Client::HTTP#connect`
to expose this handshake explicitly for the HTTP transport,
but stdio remained on its private `initialize_session` lazy-init path,
inconsistent with the Python SDK (`ClientSession.initialize()`) and
TypeScript SDK (`Client.connect(transport)`),
both of which require an explicit handshake call regardless of transport.

The implicit init path on the first `send_request` is
preserved as a non-breaking compatibility shim. Public API added on `Stdio`:

- `connect(client_info:, protocol_version:, capabilities:)` performs the
  handshake, validates the negotiated `protocolVersion` against
  `MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS`,
  caches the `InitializeResult`, and is idempotent.
- `connected?` reports handshake completion.
- `server_info` exposes the cached `InitializeResult`.

Documentation and examples are updated so new users learn the explicit pattern
from the start.

## How Has This Been Tested?

Fifteen regression tests cover: explicit handshake, `server_info` caching,
idempotence, custom keyword arguments, JSON-RPC error response, missing
or non-Hash `result`, unsupported protocol version with state rollback,
state rollback when the `notifications/initialized` write fails,
retry after a failed handshake, and the `connected?` / `server_info` lifecycle.

The pre-existing stdio tests pass without modification, verifying that
the implicit-init compatibility path still works.

## Breaking Changes

None for the typical "implicit init via `client.tools` / `client.call_tool`" usage.
Existing stdio clients keep working unchanged.

Three observable behavior changes for code that inspects transport state directly:

- `Client#connected?` for a never-initialized stdio client returns `false`
  (previously fell through to a `true` default via the `respond_to?` fallback).
- `Client#server_info` for stdio returns the cached `InitializeResult`
  after the handshake (previously always `nil` via the fallback).
- The implicit handshake path now validates the server's negotiated
  protocol version, raising `RequestHandlerError` for unsupported versions.
  Production servers that return one of the supported versions are unaffected;
  this closes a pre-existing spec-conformance gap on stdio.
2026-05-03 15:57:22 +09:00

61 lines
1.9 KiB
Ruby

# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "mcp"
require "json"
# Simple stdio client example that connects to the stdio_server.rb example
# Usage: ruby examples/stdio_client.rb
server_script = File.expand_path("stdio_server.rb", __dir__)
transport = MCP::Client::Stdio.new(command: "ruby", args: [server_script])
client = MCP::Client.new(transport: transport)
begin
# Perform the MCP initialization handshake before sending any requests.
client.connect
# List available tools
puts "=== Listing tools ==="
tools = client.tools
tools.each do |tool|
puts " Tool: #{tool.name} - #{tool.description}"
end
# Call the example_tool (adds two numbers)
puts "\n=== Calling tool: example_tool ==="
tool = tools.find { |t| t.name == "example_tool" }
response = client.call_tool(tool: tool, arguments: { a: 5, b: 3 })
puts " Response: #{JSON.pretty_generate(response.dig("result", "content"))}"
# Call the echo tool
puts "\n=== Calling tool: echo ==="
tool = tools.find { |t| t.name == "echo" }
response = client.call_tool(tool: tool, arguments: { message: "Hello from stdio client!" })
puts " Response: #{JSON.pretty_generate(response.dig("result", "content"))}"
# List prompts
puts "\n=== Listing prompts ==="
prompts = client.prompts
prompts.each do |prompt|
puts " Prompt: #{prompt["name"]} - #{prompt["description"]}"
end
# List resources
puts "\n=== Listing resources ==="
resources = client.resources
resources.each do |resource|
puts " Resource: #{resource["name"]} (#{resource["uri"]})"
end
# Read a resource
puts "\n=== Reading resource: https://test_resource.invalid ==="
contents = client.read_resource(uri: "https://test_resource.invalid")
puts " Response: #{JSON.pretty_generate(contents)}"
rescue => e
puts "Error: #{e.message}"
puts e.backtrace.first(5).join("\n")
ensure
transport.close
end