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, #serves_subscriptions_listen?

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



63
64
65
# File 'lib/mcp/server/transports/stdio_transport.rb', line 63

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
60
61
# 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?

    line = line.strip
    parsed = parse_line(line)
    response = parsed ? dispatch_with_era(parsed) : @session.handle_json(line)
    send_response(response) if response
  end
rescue Interrupt
  warn("\nExiting...")

  exit(STATUS_INTERRUPTED)
end

#send_notification(method, params = nil) ⇒ Object



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

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.



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

def send_request(method, params = nil)
  # The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests;
  # multi round-trip `input_required` results (SEP-2322) replace them.
  if @session && @session.era == :modern
    raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)."
  end

  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 ? dispatch_with_era(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



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

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