Class: MCP::Client::Stdio
- Inherits:
-
Object
- Object
- MCP::Client::Stdio
- Defined in:
- lib/mcp/client/stdio.rb
Constant Summary collapse
- CLOSE_TIMEOUT =
Seconds to wait for the server process to exit before sending SIGTERM. Matches the Python and TypeScript SDKs' shutdown timeout: https://github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/stdio/init.py#L48 https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/stdio.ts#L221
2- STDERR_READ_SIZE =
4096- MAX_LINE_BYTES =
Default upper bound on a single newline-delimited frame read from the server's stdout. CRuby's
IO#getswithout a limit accumulates bytes until a newline arrives, so a spawned server that never emits one can grow a single String until the host process is OOM-killed. 4 MiB is large enough for any realistic JSON-RPC frame, including base64-embedded images. 4 * 1024 * 1024
Instance Attribute Summary collapse
-
#args ⇒ Object
readonly
Returns the value of attribute args.
-
#command ⇒ Object
readonly
Returns the value of attribute command.
-
#env ⇒ Object
readonly
Returns the value of attribute env.
-
#server_info ⇒ Object
readonly
Returns the value of attribute server_info.
Instance Method Summary collapse
- #close ⇒ Object
-
#connect(client_info: nil, protocol_version: nil, capabilities: {}) ⇒ Hash
Performs the MCP
initializehandshake: sends aninitializerequest followed by the requirednotifications/initializednotification. -
#connected? ⇒ Boolean
Returns true once
connecthas completed the handshake. -
#initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) ⇒ Stdio
constructor
A new instance of Stdio.
-
#send_notification(notification:) ⇒ Object
Sends a JSON-RPC notification (no response expected).
-
#send_request(request:) ⇒ Object
Transports may yield once the request line has been written to
@stdin. - #start ⇒ Object
Constructor Details
#initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) ⇒ Stdio
Returns a new instance of Stdio.
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/mcp/client/stdio.rb', line 31 def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative # limit read without an upper bound, which would silently disable the # protection this option exists to provide. unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0 raise ArgumentError, "max_line_bytes must be a positive Integer" end @command = command @args = args @env = env @read_timeout = read_timeout @max_line_bytes = max_line_bytes @stdin = nil @stdout = nil @stderr = nil @wait_thread = nil @stderr_thread = nil @started = false @initialized = false @server_info = nil # Serializes writes to `@stdin` so a request line and a notification line emitted from # different threads (e.g. cancellation) cannot interleave on the wire. @write_mutex = Mutex.new end |
Instance Attribute Details
#args ⇒ Object (readonly)
Returns the value of attribute args.
29 30 31 |
# File 'lib/mcp/client/stdio.rb', line 29 def args @args end |
#command ⇒ Object (readonly)
Returns the value of attribute command.
29 30 31 |
# File 'lib/mcp/client/stdio.rb', line 29 def command @command end |
#env ⇒ Object (readonly)
Returns the value of attribute env.
29 30 31 |
# File 'lib/mcp/client/stdio.rb', line 29 def env @env end |
#server_info ⇒ Object (readonly)
Returns the value of attribute server_info.
29 30 31 |
# File 'lib/mcp/client/stdio.rb', line 29 def server_info @server_info end |
Instance Method Details
#close ⇒ Object
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/mcp/client/stdio.rb', line 203 def close return unless @started @stdin.close @stdout.close @stderr.close begin Timeout.timeout(CLOSE_TIMEOUT) { @wait_thread.value } rescue Timeout::Error begin Process.kill("TERM", @wait_thread.pid) Timeout.timeout(CLOSE_TIMEOUT) { @wait_thread.value } rescue Timeout::Error begin Process.kill("KILL", @wait_thread.pid) rescue Errno::ESRCH nil end rescue Errno::ESRCH nil end end @stderr_thread.join(CLOSE_TIMEOUT) @started = false @initialized = false @server_info = nil end |
#connect(client_info: nil, protocol_version: nil, capabilities: {}) ⇒ Hash
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. Spawns the subprocess via start if it has not
been started yet.
https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/mcp/client/stdio.rb', line 76 def connect(client_info: nil, protocol_version: nil, capabilities: {}) return @server_info if @initialized start unless @started client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } protocol_version ||= MCP::Configuration::LATEST_STABLE_PROTOCOL_VERSION init_request = { jsonrpc: JsonRpcHandler::Version::V2_0, id: SecureRandom.uuid, method: MCP::Methods::INITIALIZE, params: { protocolVersion: protocol_version, capabilities: capabilities, clientInfo: client_info, }, } (init_request) response = read_response(init_request) if response.key?("error") error = response["error"] raise RequestHandlerError.new( "Server initialization failed: #{error["message"]}", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end unless response["result"].is_a?(Hash) raise RequestHandlerError.new( "Server initialization failed: missing result in response", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end @server_info = response["result"] 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, # the client SHOULD disconnect. Roll back the cached `InitializeResult` before # raising so a retry starts without a stale `server_info`. # https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation @server_info = nil raise RequestHandlerError.new( "Server initialization failed: unsupported protocol version #{negotiated_protocol_version.inspect}", { method: MCP::Methods::INITIALIZE }, error_type: :internal_error, ) end begin notification = { jsonrpc: JsonRpcHandler::Version::V2_0, method: MCP::Methods::NOTIFICATIONS_INITIALIZED, } (notification) rescue StandardError @server_info = nil raise end @initialized = true @server_info end |
#connected? ⇒ Boolean
Returns true once connect has completed the handshake. Returns false before the handshake and after close.
147 148 149 |
# File 'lib/mcp/client/stdio.rb', line 147 def connected? @initialized end |
#send_notification(notification:) ⇒ Object
Sends a JSON-RPC notification (no response expected). Used by Client#cancel to deliver
notifications/cancelled for an in-flight request.
167 168 169 170 171 172 173 |
# File 'lib/mcp/client/stdio.rb', line 167 def send_notification(notification:) start unless @started connect unless @initialized @write_mutex.synchronize { (notification) } nil end |
#send_request(request:) ⇒ Object
Transports may yield once the request line has been written to @stdin.
MCP::Client#dispatch_with_cancellation uses this signal to ensure a notifications/cancelled
write does not race ahead of the request write on the wire. The yield happens inside @write_mutex,
so any subsequent send_notification write waits for the mutex and is guaranteed to land after the request.
155 156 157 158 159 160 161 162 163 |
# File 'lib/mcp/client/stdio.rb', line 155 def send_request(request:) raise "MCP::Client#connect must be called before sending requests." unless @initialized @write_mutex.synchronize do (request) yield if block_given? end read_response(request) end |
#start ⇒ Object
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/mcp/client/stdio.rb', line 175 def start raise "MCP::Client::Stdio already started" if @started spawn_env = @env || {} @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(spawn_env, @command, *@args) @stdout.set_encoding("UTF-8") @stdin.set_encoding("UTF-8") # Drain stderr in the background to prevent the pipe buffer from filling up, # which would cause the server process to block and deadlock. @stderr_thread = Thread.new do loop do @stderr.readpartial(STDERR_READ_SIZE) end rescue IOError nil end @started = true rescue Errno::ENOENT, Errno::EACCES, Errno::ENOEXEC => e raise RequestHandlerError.new( "Failed to spawn server process: #{e.}", {}, error_type: :internal_error, original_error: e, ) end |