Class: MCP::Server::Transports::StdioTransport

Inherits:
Transport
  • Object
show all
Defined in:
lib/mcp/server/transports/stdio_transport.rb

Constant Summary collapse

STATUS_INTERRUPTED =
Signal.list["INT"] + 128
MAX_LINE_BYTES =

Default upper bound on a single newline-delimited frame. CRuby's IO#gets without a limit accumulates bytes until a newline arrives, so a peer that never emits one can grow a single String until the process is OOM-killed. 4 MiB is large enough for any realistic JSON-RPC frame, including base64-embedded images.

4 * 1024 * 1024

Instance Method Summary collapse

Methods inherited from Transport

#handle_json_request, #handle_request

Constructor Details

#initialize(server, max_line_bytes: MAX_LINE_BYTES) ⇒ StdioTransport

Returns a new instance of StdioTransport.



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/mcp/server/transports/stdio_transport.rb', line 19

def initialize(server, max_line_bytes: MAX_LINE_BYTES)
  super(server)
  @open = false
  @session = nil
  # 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

  @max_line_bytes = max_line_bytes
  $stdin.set_encoding("UTF-8")
  $stdout.set_encoding("UTF-8")
end

Instance Method Details

#closeObject



61
62
63
# File 'lib/mcp/server/transports/stdio_transport.rb', line 61

def close
  @open = false
end

#openObject



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/mcp/server/transports/stdio_transport.rb', line 35

def open
  @open = true
  @session = ServerSession.new(server: @server, transport: self)
  while @open
    begin
      line = read_line($stdin)
    rescue RequestHandlerError => e
      # Stop accumulating and end the connection gracefully rather than
      # letting an unbounded read exhaust memory or escape as an uncaught
      # backtrace. Scoped to the read so genuine request errors raised while
      # handling a frame are not swallowed here.
      @open = false
      MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" })
      break
    end
    break if line.nil?

    response = @session.handle_json(line.strip)
    send_response(response) if response
  end
rescue Interrupt
  warn("\nExiting...")

  exit(STATUS_INTERRUPTED)
end

#send_notification(method, params = nil) ⇒ Object



71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/mcp/server/transports/stdio_transport.rb', line 71

def send_notification(method, params = nil)
  notification = {
    jsonrpc: "2.0",
    method: method,
  }
  notification[:params] = params if params

  send_response(notification)
  true
rescue => e
  MCP.configuration.exception_reporter.call(e, { error: "Failed to send notification" })
  false
end

#send_request(method, params = nil) ⇒ Object

NOTE: This signature deliberately matches the abstract Transport#send_request contract (method, params = nil) without the cancellation kwargs that StreamableHTTPTransport#send_request accepts. On Ruby 2.7 the project's supported minimum a method that mixes a positional params Hash with explicit keyword arguments cannot be called as send_request(method, { ... }) - the trailing Hash would be auto-promoted to keyword arguments. Stdio is single-threaded and blocks on $stdin.gets, so nested-request cancellation has very limited value here regardless; servers that need cancellation propagation for nested server-to-client requests should use StreamableHTTPTransport.



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
# File 'lib/mcp/server/transports/stdio_transport.rb', line 92

def send_request(method, params = nil)
  request_id = generate_request_id
  request = { jsonrpc: "2.0", id: request_id, method: method }
  request[:params] = params if params

  begin
    send_response(request)
  rescue => e
    MCP.configuration.exception_reporter.call(e, { error: "Failed to send request" })
    raise
  end

  while @open && (line = read_line($stdin))
    begin
      parsed = JSON.parse(line.strip, symbolize_names: true)
    rescue JSON::ParserError => e
      MCP.configuration.exception_reporter.call(e, { error: "Failed to parse response" })
      raise
    end

    if parsed[:id] == request_id && !parsed.key?(:method)
      if parsed[:error]
        raise StandardError, "Client returned an error for #{method} request (code: #{parsed[:error][:code]}): #{parsed[:error][:message]}"
      end

      return parsed[:result]
    else
      response = @session ? @session.handle(parsed) : @server.handle(parsed)
      send_response(response) if response
    end
  end

  raise "Transport closed while waiting for response to #{method} request."
end

#send_response(message) ⇒ Object



65
66
67
68
69
# File 'lib/mcp/server/transports/stdio_transport.rb', line 65

def send_response(message)
  json_message = message.is_a?(String) ? message : JSON.generate(message)
  $stdout.puts(json_message)
  $stdout.flush
end