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: github.com/modelcontextprotocol/python-sdk/blob/v1.26.0/src/mcp/client/stdio/__init__.py#L48 github.com/modelcontextprotocol/typescript-sdk/blob/v1.27.1/src/client/stdio.ts#L221
2- STDERR_READ_SIZE =
4096
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 ‘initialize` handshake: sends an `initialize` request followed by the required `notifications/initialized` notification.
-
#connected? ⇒ Boolean
Returns true once ‘connect` (or the implicit handshake on the first `send_request`) has completed.
-
#initialize(command:, args: [], env: nil, read_timeout: nil) ⇒ Stdio
constructor
A new instance of Stdio.
- #send_request(request:) ⇒ Object
- #start ⇒ Object
Constructor Details
#initialize(command:, args: [], env: nil, read_timeout: nil) ⇒ Stdio
Returns a new instance of Stdio.
24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
# File 'lib/mcp/client/stdio.rb', line 24 def initialize(command:, args: [], env: nil, read_timeout: nil) @command = command @args = args @env = env @read_timeout = read_timeout @stdin = nil @stdout = nil @stderr = nil @wait_thread = nil @stderr_thread = nil @started = false @initialized = false @server_info = nil end |
Instance Attribute Details
#args ⇒ Object (readonly)
Returns the value of attribute args.
22 23 24 |
# File 'lib/mcp/client/stdio.rb', line 22 def args @args end |
#command ⇒ Object (readonly)
Returns the value of attribute command.
22 23 24 |
# File 'lib/mcp/client/stdio.rb', line 22 def command @command end |
#env ⇒ Object (readonly)
Returns the value of attribute env.
22 23 24 |
# File 'lib/mcp/client/stdio.rb', line 22 def env @env end |
#server_info ⇒ Object (readonly)
Returns the value of attribute server_info.
22 23 24 |
# File 'lib/mcp/client/stdio.rb', line 22 def server_info @server_info end |
Instance Method Details
#close ⇒ Object
171 172 173 174 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 |
# File 'lib/mcp/client/stdio.rb', line 171 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.
modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 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 |
# File 'lib/mcp/client/stdio.rb', line 58 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` (or the implicit handshake on the first `send_request`) has completed. Returns false before the handshake and after `close`.
131 132 133 |
# File 'lib/mcp/client/stdio.rb', line 131 def connected? @initialized end |
#send_request(request:) ⇒ Object
135 136 137 138 139 140 141 |
# File 'lib/mcp/client/stdio.rb', line 135 def send_request(request:) start unless @started connect unless @initialized (request) read_response(request) end |
#start ⇒ Object
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/mcp/client/stdio.rb', line 143 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 |