Class: RobotLab::MCP::Transports::Stdio

Inherits:
Base
  • Object
show all
Defined in:
lib/robot_lab/mcp/transports/stdio.rb

Overview

StdIO transport for local MCP servers

Spawns a subprocess and communicates via stdin/stdout. All blocking I/O is wrapped with a configurable timeout so a missing or hung server cannot block the caller forever.

Examples:

transport = Stdio.new(
  { command: "mcp-server-filesystem", args: ["--root", "/data"] },
  timeout: 10
)

Constant Summary

Constants inherited from Base

Base::DEFAULT_TIMEOUT

Instance Attribute Summary collapse

Attributes inherited from Base

#config, #timeout

Instance Method Summary collapse

Constructor Details

#initialize(config) ⇒ Stdio

Creates a new Stdio transport.

Parameters:

  • config (Hash)

    transport configuration

Options Hash (config):

  • :command (String)

    the command to execute

  • :args (Array<String>)

    command arguments

  • :env (Hash)

    environment variables

  • :timeout (Numeric)

    request timeout in seconds (default: 15)



30
31
32
33
34
35
36
37
# File 'lib/robot_lab/mcp/transports/stdio.rb', line 30

def initialize(config)
  super
  @stdin = nil
  @stdout = nil
  @stderr = nil
  @wait_thread = nil
  @connected = false
end

Instance Attribute Details

#stdinObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Expose the underlying IO objects so ConnectionPoller can register them in its IO.select loop.



127
128
129
# File 'lib/robot_lab/mcp/transports/stdio.rb', line 127

def stdin
  @stdin
end

#stdoutObject (readonly)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Expose the underlying IO objects so ConnectionPoller can register them in its IO.select loop.



127
128
129
# File 'lib/robot_lab/mcp/transports/stdio.rb', line 127

def stdout
  @stdout
end

Instance Method Details

#closeself

Close the connection to the MCP server.

Returns:

  • (self)


109
110
111
112
113
114
# File 'lib/robot_lab/mcp/transports/stdio.rb', line 109

def close
  return self unless @connected

  cleanup_process
  self
end

#connectself

Connect to the MCP server via stdio.

Returns:

  • (self)

Raises:

  • (MCPError)

    if the server process cannot be started or does not respond to the MCP initialize handshake within the timeout period



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/robot_lab/mcp/transports/stdio.rb', line 44

def connect
  return self if @connected

  command = @config[:command]
  args = @config[:args] || []
  env = @config[:env] || {}

  full_env = ENV.to_h.merge(env.transform_keys(&:to_s))

  @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(full_env, command, *args)

  # Verify the process actually started
  unless @wait_thread.alive?
    raise MCPError, "MCP server process exited immediately (command: #{command})"
  end

  @connected = true

  # Initialize MCP protocol — this must complete within the timeout
  send_initialize

  self
rescue Errno::ENOENT => e
  cleanup_process
  raise MCPError, "MCP server command not found: #{command} (#{e.message})"
rescue Timeout::Error
  cleanup_process
  raise MCPError, "MCP server '#{command}' did not respond within #{@timeout}s"
end

#connected?Boolean

Check if the transport is connected.

Returns:

  • (Boolean)

    true if connected and process is alive



119
120
121
# File 'lib/robot_lab/mcp/transports/stdio.rb', line 119

def connected?
  @connected && @wait_thread&.alive?
end

#send_request(message) ⇒ Hash

Send a JSON-RPC request to the MCP server.

Parameters:

  • message (Hash)

    JSON-RPC message

Returns:

  • (Hash)

    the response

Raises:

  • (MCPError)

    if not connected, no response, or timeout



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
# File 'lib/robot_lab/mcp/transports/stdio.rb', line 79

def send_request(message)
  raise MCPError, "Not connected" unless @connected

  Timeout.timeout(@timeout, Timeout::Error) do
    json = message.to_json
    @stdin.puts(json)
    @stdin.flush

    loop do
      response_line = @stdout.gets
      raise MCPError, "No response from MCP server (EOF on stdout)" unless response_line

      parsed = JSON.parse(response_line, symbolize_names: true)

      # Skip notifications (messages without an id)
      next if parsed[:method] && !parsed.key?(:id)

      return parsed
    end
  end
rescue Timeout::Error
  raise MCPError, "MCP server did not respond within #{@timeout}s"
rescue Errno::EPIPE, IOError => e
  @connected = false
  raise MCPError, "MCP server connection lost: #{e.message}"
end