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
- DEFAULT_DISCOVER_PROBE_TIMEOUT =
Seconds the
server/discoverprobe may wait when noread_timeoutwas configured. A compliant legacy server answers the probe with-32601immediately, but a non-compliant one that silently drops unknown methods would otherwise blockconnect(mode: :auto)forever. Matches the C# SDK'sDiscoverProbeTimeoutdefault. 5
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: {}, mode: :legacy) ⇒ Hash
Performs the MCP
initializehandshake: sends aninitializerequest followed by the requirednotifications/initializednotification. -
#connected? ⇒ Boolean
Returns true once
connecthas completed the handshake or adopted the modern lifecycle. -
#initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES) ⇒ Stdio
constructor
A new instance of Stdio.
-
#modern? ⇒ Boolean
Whether the transport operates in the stateless modern lifecycle (SEP-2575): no handshake was performed and every request carries the
_metaenvelope. -
#protocol_version ⇒ Object
The protocol version in use on this connection, independent of its era: negotiated by
initialize(legacy) or adopted viaserver/discover(modern). -
#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.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/mcp/client/stdio.rb', line 39 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 @modern_protocol_version = nil @modern_client_info = nil @modern_capabilities = 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.
37 38 39 |
# File 'lib/mcp/client/stdio.rb', line 37 def args @args end |
#command ⇒ Object (readonly)
Returns the value of attribute command.
37 38 39 |
# File 'lib/mcp/client/stdio.rb', line 37 def command @command end |
#env ⇒ Object (readonly)
Returns the value of attribute env.
37 38 39 |
# File 'lib/mcp/client/stdio.rb', line 37 def env @env end |
#server_info ⇒ Object (readonly)
Returns the value of attribute server_info.
37 38 39 |
# File 'lib/mcp/client/stdio.rb', line 37 def server_info @server_info end |
Instance Method Details
#close ⇒ Object
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 |
# File 'lib/mcp/client/stdio.rb', line 194 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 leave_modern_mode end |
#connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) ⇒ 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
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/mcp/client/stdio.rb', line 92 def connect(client_info: nil, protocol_version: nil, capabilities: {}, mode: :legacy) return @server_info if connected? # Validated before `start` so a pure argument error never spawns the server process. MCP::Configuration.reject_modern_handshake_version!(protocol_version) if mode == :legacy start unless @started client_info ||= { name: "mcp-ruby-client", version: MCP::VERSION } case mode when :legacy connect_legacy(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) when :modern connect_modern(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) when :auto connect_auto(client_info: client_info, protocol_version: protocol_version, capabilities: capabilities) else raise ArgumentError, "mode must be :legacy, :modern, or :auto" end end |
#connected? ⇒ Boolean
Returns true once connect has completed the handshake or adopted the modern lifecycle.
Returns false before the handshake and after close.
129 130 131 |
# File 'lib/mcp/client/stdio.rb', line 129 def connected? @initialized || modern? end |
#modern? ⇒ Boolean
Whether the transport operates in the stateless modern lifecycle (SEP-2575):
no handshake was performed and every request carries the _meta envelope.
116 117 118 |
# File 'lib/mcp/client/stdio.rb', line 116 def modern? !@modern_client_info.nil? end |
#protocol_version ⇒ Object
The protocol version in use on this connection, independent of its era:
negotiated by initialize (legacy) or adopted via server/discover (modern).
Returns nil before connect and after close.
123 124 125 |
# File 'lib/mcp/client/stdio.rb', line 123 def protocol_version @modern_protocol_version || (@server_info && @server_info["protocolVersion"]) 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.
158 159 160 161 162 163 164 |
# File 'lib/mcp/client/stdio.rb', line 158 def send_notification(notification:) start unless @started connect unless connected? @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.
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/mcp/client/stdio.rb', line 137 def send_request(request:) method = request[:method] || request["method"] if method == MCP::Methods::SERVER_DISCOVER # `server/discover` (SEP-2575) is sessionless capability discovery that # works before (or instead of) `connect`. start unless @started elsif !connected? raise "MCP::Client#connect must be called before sending requests." end request = stamp_modern(request) @write_mutex.synchronize do (request) yield if block_given? end read_response(request) end |
#start ⇒ Object
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/mcp/client/stdio.rb', line 166 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 |