mcp 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/mcp/client.rb CHANGED
@@ -107,6 +107,8 @@ module MCP
107
107
  # @param cursor [String, nil] Cursor from a previous page response.
108
108
  # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
109
109
  # e.g. SEP-414 trace context (see {MCP::TraceContext}).
110
+ # @param cancellation [MCP::Cancellation, nil] Optional token; cancelling it sends
111
+ # `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call.
110
112
  # @return [MCP::Client::ListToolsResult] Result with `tools` (Array<MCP::Client::Tool>)
111
113
  # and `next_cursor` (String or nil).
112
114
  #
@@ -118,9 +120,9 @@ module MCP
118
120
  # cursor = page.next_cursor
119
121
  # break unless cursor
120
122
  # end
121
- def list_tools(cursor: nil, meta: nil)
123
+ def list_tools(cursor: nil, meta: nil, cancellation: nil)
122
124
  params = cursor ? { cursor: cursor } : nil
123
- response = request(method: "tools/list", params: params, meta: meta)
125
+ response = request(method: "tools/list", params: params, meta: meta, cancellation: cancellation)
124
126
  result = response["result"] || {}
125
127
 
126
128
  tools = (result["tools"] || []).map do |tool|
@@ -141,6 +143,9 @@ module MCP
141
143
  #
142
144
  # Each call will make a new request - the result is not cached.
143
145
  #
146
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
147
+ # Cancelling it aborts whichever page is currently in flight; pages already returned are kept,
148
+ # but the call raises `MCP::CancelledError` instead of returning the partial set.
144
149
  # @return [Array<MCP::Client::Tool>] An array of available tools.
145
150
  #
146
151
  # @example
@@ -148,9 +153,9 @@ module MCP
148
153
  # tools.each do |tool|
149
154
  # puts tool.name
150
155
  # end
151
- def tools
156
+ def tools(cancellation: nil)
152
157
  # TODO: consider renaming to `list_all_tools`.
153
- fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools)
158
+ fetch_all_pages { |cursor| list_tools(cursor: cursor, cancellation: cancellation) }.flat_map(&:tools)
154
159
  end
155
160
 
156
161
  # Returns a single page of resources from the server.
@@ -158,11 +163,12 @@ module MCP
158
163
  # @param cursor [String, nil] Cursor from a previous page response.
159
164
  # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
160
165
  # e.g. SEP-414 trace context (see {MCP::TraceContext}).
166
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
161
167
  # @return [MCP::Client::ListResourcesResult] Result with `resources` (Array<Hash>)
162
168
  # and `next_cursor` (String or nil).
163
- def list_resources(cursor: nil, meta: nil)
169
+ def list_resources(cursor: nil, meta: nil, cancellation: nil)
164
170
  params = cursor ? { cursor: cursor } : nil
165
- response = request(method: "resources/list", params: params, meta: meta)
171
+ response = request(method: "resources/list", params: params, meta: meta, cancellation: cancellation)
166
172
  result = response["result"] || {}
167
173
 
168
174
  ListResourcesResult.new(
@@ -178,10 +184,11 @@ module MCP
178
184
  #
179
185
  # Each call will make a new request - the result is not cached.
180
186
  #
187
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
181
188
  # @return [Array<Hash>] An array of available resources.
182
- def resources
189
+ def resources(cancellation: nil)
183
190
  # TODO: consider renaming to `list_all_resources`.
184
- fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources)
191
+ fetch_all_pages { |cursor| list_resources(cursor: cursor, cancellation: cancellation) }.flat_map(&:resources)
185
192
  end
186
193
 
187
194
  # Returns a single page of resource templates from the server.
@@ -189,11 +196,12 @@ module MCP
189
196
  # @param cursor [String, nil] Cursor from a previous page response.
190
197
  # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
191
198
  # e.g. SEP-414 trace context (see {MCP::TraceContext}).
199
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
192
200
  # @return [MCP::Client::ListResourceTemplatesResult] Result with `resource_templates`
193
201
  # (Array<Hash>) and `next_cursor` (String or nil).
194
- def list_resource_templates(cursor: nil, meta: nil)
202
+ def list_resource_templates(cursor: nil, meta: nil, cancellation: nil)
195
203
  params = cursor ? { cursor: cursor } : nil
196
- response = request(method: "resources/templates/list", params: params, meta: meta)
204
+ response = request(method: "resources/templates/list", params: params, meta: meta, cancellation: cancellation)
197
205
  result = response["result"] || {}
198
206
 
199
207
  ListResourceTemplatesResult.new(
@@ -209,10 +217,11 @@ module MCP
209
217
  #
210
218
  # Each call will make a new request - the result is not cached.
211
219
  #
220
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
212
221
  # @return [Array<Hash>] An array of available resource templates.
213
- def resource_templates
222
+ def resource_templates(cancellation: nil)
214
223
  # TODO: consider renaming to `list_all_resource_templates`.
215
- fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates)
224
+ fetch_all_pages { |cursor| list_resource_templates(cursor: cursor, cancellation: cancellation) }.flat_map(&:resource_templates)
216
225
  end
217
226
 
218
227
  # Returns a single page of prompts from the server.
@@ -220,11 +229,12 @@ module MCP
220
229
  # @param cursor [String, nil] Cursor from a previous page response.
221
230
  # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
222
231
  # e.g. SEP-414 trace context (see {MCP::TraceContext}).
232
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
223
233
  # @return [MCP::Client::ListPromptsResult] Result with `prompts` (Array<Hash>)
224
234
  # and `next_cursor` (String or nil).
225
- def list_prompts(cursor: nil, meta: nil)
235
+ def list_prompts(cursor: nil, meta: nil, cancellation: nil)
226
236
  params = cursor ? { cursor: cursor } : nil
227
- response = request(method: "prompts/list", params: params, meta: meta)
237
+ response = request(method: "prompts/list", params: params, meta: meta, cancellation: cancellation)
228
238
  result = response["result"] || {}
229
239
 
230
240
  ListPromptsResult.new(
@@ -240,10 +250,11 @@ module MCP
240
250
  #
241
251
  # Each call will make a new request - the result is not cached.
242
252
  #
253
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token (see {#tools}).
243
254
  # @return [Array<Hash>] An array of available prompts.
244
- def prompts
255
+ def prompts(cancellation: nil)
245
256
  # TODO: consider renaming to `list_all_prompts`.
246
- fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts)
257
+ fetch_all_pages { |cursor| list_prompts(cursor: cursor, cancellation: cancellation) }.flat_map(&:prompts)
247
258
  end
248
259
 
249
260
  # Calls a tool via the transport layer and returns the full response from the server.
@@ -256,6 +267,8 @@ module MCP
256
267
  # e.g. the W3C Trace Context keys reserved by SEP-414
257
268
  # (`MCP::TraceContext::TRACEPARENT_META_KEY`, `tracestate`, `baggage`).
258
269
  # `progress_token` takes precedence over a `progressToken` entry in `meta`.
270
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token. Cancelling it from another thread
271
+ # sends `notifications/cancelled` to the server and raises `MCP::CancelledError` from this call.
259
272
  # @return [Hash] The full JSON-RPC response from the transport.
260
273
  #
261
274
  # @example Call by name
@@ -267,10 +280,19 @@ module MCP
267
280
  # response = client.call_tool(tool: tool, arguments: { foo: "bar" })
268
281
  # structured_content = response.dig("result", "structuredContent")
269
282
  #
283
+ # @example Cancellable call
284
+ # cancellation = MCP::Cancellation.new
285
+ # Thread.new do
286
+ # client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
287
+ # rescue MCP::CancelledError
288
+ # # cleanup
289
+ # end
290
+ # cancellation.cancel(reason: "user pressed cancel")
291
+ #
270
292
  # @note
271
293
  # The exact requirements for `arguments` are determined by the transport layer in use.
272
294
  # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details.
273
- def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil)
295
+ def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil, meta: nil, cancellation: nil)
274
296
  tool_name = name || tool&.name
275
297
  raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name
276
298
 
@@ -282,7 +304,7 @@ module MCP
282
304
  end
283
305
  params[:_meta] = meta_entries unless meta_entries.empty?
284
306
 
285
- request(method: "tools/call", params: params)
307
+ request(method: "tools/call", params: params, cancellation: cancellation)
286
308
  end
287
309
 
288
310
  # Reads a resource from the server by URI and returns the contents.
@@ -290,9 +312,10 @@ module MCP
290
312
  # @param uri [String] The URI of the resource to read.
291
313
  # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
292
314
  # e.g. SEP-414 trace context (see {MCP::TraceContext}).
315
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
293
316
  # @return [Array<Hash>] An array of resource contents (text or blob).
294
- def read_resource(uri:, meta: nil)
295
- response = request(method: "resources/read", params: { uri: uri }, meta: meta)
317
+ def read_resource(uri:, meta: nil, cancellation: nil)
318
+ response = request(method: "resources/read", params: { uri: uri }, meta: meta, cancellation: cancellation)
296
319
 
297
320
  response.dig("result", "contents") || []
298
321
  end
@@ -302,9 +325,10 @@ module MCP
302
325
  # @param name [String] The name of the prompt to get.
303
326
  # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
304
327
  # e.g. SEP-414 trace context (see {MCP::TraceContext}).
328
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
305
329
  # @return [Hash] A hash containing the prompt details.
306
- def get_prompt(name:, meta: nil)
307
- response = request(method: "prompts/get", params: { name: name }, meta: meta)
330
+ def get_prompt(name:, meta: nil, cancellation: nil)
331
+ response = request(method: "prompts/get", params: { name: name }, meta: meta, cancellation: cancellation)
308
332
 
309
333
  response.fetch("result", {})
310
334
  end
@@ -317,12 +341,13 @@ module MCP
317
341
  # @param context [Hash, nil] Optional context with previously resolved arguments.
318
342
  # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
319
343
  # e.g. SEP-414 trace context (see {MCP::TraceContext}).
344
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
320
345
  # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
321
- def complete(ref:, argument:, context: nil, meta: nil)
346
+ def complete(ref:, argument:, context: nil, meta: nil, cancellation: nil)
322
347
  params = { ref: ref, argument: argument }
323
348
  params[:context] = context if context
324
349
 
325
- response = request(method: "completion/complete", params: params, meta: meta)
350
+ response = request(method: "completion/complete", params: params, meta: meta, cancellation: cancellation)
326
351
 
327
352
  response.dig("result", "completion") || { "values" => [], "hasMore" => false }
328
353
  end
@@ -330,6 +355,9 @@ module MCP
330
355
  # Sends a `ping` request to the server to verify the connection is alive.
331
356
  # Per the MCP spec, the server responds with an empty result.
332
357
  #
358
+ # @param meta [Hash, nil] Additional `_meta` entries to send with the request,
359
+ # e.g. SEP-414 trace context (see {MCP::TraceContext}).
360
+ # @param cancellation [MCP::Cancellation, nil] Optional cancellation token.
333
361
  # @return [Hash] An empty hash on success.
334
362
  # @raise [ServerError] If the server returns a JSON-RPC error.
335
363
  # @raise [ValidationError] If the response `result` is missing or not a Hash.
@@ -338,8 +366,8 @@ module MCP
338
366
  # client.ping # => {}
339
367
  #
340
368
  # @see https://modelcontextprotocol.io/specification/latest/basic/utilities/ping
341
- def ping(meta: nil)
342
- result = request(method: Methods::PING, meta: meta)["result"]
369
+ def ping(meta: nil, cancellation: nil)
370
+ result = request(method: Methods::PING, meta: meta, cancellation: cancellation)["result"]
343
371
  raise ValidationError, "Response validation failed: missing or invalid `result`" unless result.is_a?(Hash)
344
372
 
345
373
  result
@@ -372,17 +400,21 @@ module MCP
372
400
  # without mutating the caller's hashes. Per SEP-414, `_meta` carries
373
401
  # request-specific metadata such as W3C trace context (`traceparent`,
374
402
  # `tracestate`, `baggage`); see {MCP::TraceContext}.
375
- def request(method:, params: nil, meta: nil)
403
+ def request(method:, params: nil, meta: nil, cancellation: nil)
376
404
  params = (params || {}).merge(_meta: meta) if meta && !meta.empty?
377
405
 
378
406
  request_body = {
379
407
  jsonrpc: JsonRpcHandler::Version::V2_0,
380
- id: request_id,
408
+ id: generate_request_id,
381
409
  method: method,
382
410
  }
383
411
  request_body[:params] = params if params
384
412
 
385
- response = transport.send_request(request: request_body)
413
+ response = if cancellation
414
+ dispatch_with_cancellation(request_body, cancellation)
415
+ else
416
+ transport.send_request(request: request_body)
417
+ end
386
418
 
387
419
  # Guard with `is_a?(Hash)` because custom transports may return non-Hash values.
388
420
  if response.is_a?(Hash) && response.key?("error")
@@ -393,8 +425,140 @@ module MCP
393
425
  response
394
426
  end
395
427
 
396
- def request_id
428
+ # Generates a fresh JSON-RPC request id for an outgoing request.
429
+ # Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through
430
+ # an `MCP::Cancellation` token instead.
431
+ def generate_request_id
397
432
  SecureRandom.uuid
398
433
  end
434
+
435
+ # Sends `request_body` while watching `cancellation`. The actual blocking `transport.send_request` runs on
436
+ # a worker thread; the calling thread waits on a Queue that is woken either by the response or by a cancel signal
437
+ # (whichever arrives first - matching the server-side `StreamableHTTPTransport#cancel_pending_request` race contract).
438
+ #
439
+ # When a cancel wins the race, the calling thread raises `MCP::CancelledError` immediately and the `notifications/cancelled`
440
+ # dispatch runs fire-and-forget on its own thread. We deliberately do not wait for that dispatch here: the calling thread
441
+ # must not be blocked by a slow or stalled transport write on the cancel path.
442
+ # The worker thread is also not force-killed; it stays blocked on the underlying I/O until the server actually responds
443
+ # (or the transport closes). This is the same trade-off the server-side `StreamableHTTPTransport#send_request` accepts and
444
+ # is noted in the README's Cancellation section.
445
+ def dispatch_with_cancellation(request_body, cancellation)
446
+ unless transport.respond_to?(:send_notification)
447
+ raise NoMethodError, "Cancellation support requires a transport that responds to `send_notification(notification:)` " \
448
+ "so `notifications/cancelled` can be delivered to the peer. The bundled `MCP::Client::Stdio` and `MCP::Client::HTTP` transports " \
449
+ "implement this interface; custom transports must add it before passing `cancellation:` to a request method."
450
+ end
451
+
452
+ cancellation.raise_if_cancelled!
453
+
454
+ request_id = request_body[:id]
455
+ queue = Queue.new
456
+
457
+ # First-writer-wins gate. Whichever side (worker or on_cancel) flips `completed` first owns the queue's single slot; the loser bails.
458
+ # This closes the late-cancel window between the worker pushing `:response` and the main thread completing `dispatch_with_cancellation`,
459
+ # where a callback firing in that gap would otherwise emit a stray `notifications/cancelled` for a request that already succeeded.
460
+ completion_mutex = Mutex.new
461
+ completed = false
462
+ sent_mutex = Mutex.new
463
+ sent_cond = ConditionVariable.new
464
+ request_sent = false
465
+ signal_sent = lambda do
466
+ sent_mutex.synchronize do
467
+ unless request_sent
468
+ request_sent = true
469
+ sent_cond.broadcast
470
+ end
471
+ end
472
+ end
473
+
474
+ Thread.new do
475
+ Thread.current.report_on_exception = false
476
+ begin
477
+ result = transport.send_request(request: request_body, &signal_sent)
478
+ completion_mutex.synchronize do
479
+ next if completed
480
+
481
+ completed = true
482
+ queue.push([:response, result])
483
+ end
484
+ rescue StandardError => e
485
+ completion_mutex.synchronize do
486
+ next if completed
487
+
488
+ completed = true
489
+ queue.push([:error, e])
490
+ end
491
+ ensure
492
+ # Unblock any waiting cancel-dispatch thread on completion (or error)
493
+ # so it does not stall when the transport ignored the block.
494
+ signal_sent.call
495
+ end
496
+ end
497
+
498
+ cancel_hook = cancellation.on_cancel do |reason|
499
+ should_dispatch = completion_mutex.synchronize do
500
+ next false if completed
501
+
502
+ completed = true
503
+
504
+ # Wake the waiting thread first, then dispatch the `notifications/cancelled` send on a separate thread.
505
+ # The wake-first ordering matters because the cancellation callback can run on the worker thread itself
506
+ # (e.g. a tool that triggers cancel from within `transport.send_request`), and a synchronous `send_notification`
507
+ # here would deadlock when the worker holds a transport-level mutex.
508
+ queue.push([:cancelled, reason])
509
+ true
510
+ end
511
+
512
+ next unless should_dispatch
513
+
514
+ Thread.new do
515
+ Thread.current.report_on_exception = false
516
+ # Wait for the worker's send-boundary signal before issuing `notifications/cancelled`. Bundled transports raise
517
+ # the signal via `&on_sent` from inside `send_request`; custom transports that ignore the block still raise it
518
+ # via the worker's `ensure -> signal_sent.call`, so the loop is bounded by worker termination rather than by wall-clock time.
519
+ # The previous fixed-duration fallback could release this thread before the worker reached its send-boundary at all,
520
+ # allowing the cancel to be issued without any prior request commitment - which the spec only covers under
521
+ # the receiver's MAY-ignore-unknown-id clause and is therefore avoided here.
522
+ sent_mutex.synchronize do
523
+ sent_cond.wait(sent_mutex) until request_sent
524
+ end
525
+ cancel(request_id: request_id, reason: reason)
526
+ rescue StandardError
527
+ # Swallow notification-send failures: the calling thread has already been woken with `:cancelled` above and
528
+ # is on its way to raising `MCP::CancelledError`.
529
+ end
530
+ end
531
+
532
+ tag, payload = queue.pop
533
+
534
+ case tag
535
+ when :response
536
+ payload
537
+ when :error
538
+ raise payload
539
+ when :cancelled
540
+ raise MCP::CancelledError.new(request_id: request_id, reason: payload)
541
+ end
542
+ ensure
543
+ cancellation&.off_cancel(cancel_hook) if cancel_hook
544
+ end
545
+
546
+ # Sends `notifications/cancelled` to the server for an in-flight request.
547
+ # Per spec, this is fire-and-forget: the server is expected to stop processing and suppress its response,
548
+ # with no acknowledgement returned. Driven internally by {#dispatch_with_cancellation} when a request's
549
+ # `cancellation` token fires.
550
+ def cancel(request_id:, reason: nil)
551
+ params = { requestId: request_id }
552
+ params[:reason] = reason if reason
553
+
554
+ notification = {
555
+ jsonrpc: JsonRpcHandler::Version::V2_0,
556
+ method: Methods::NOTIFICATIONS_CANCELLED,
557
+ params: params,
558
+ }
559
+
560
+ transport.send_notification(notification: notification)
561
+ nil
562
+ end
399
563
  end
400
564
  end
data/lib/mcp/methods.rb CHANGED
@@ -47,6 +47,10 @@ module MCP
47
47
  end
48
48
 
49
49
  class << self
50
+ def notification?(method)
51
+ method.is_a?(String) && method.start_with?("notifications/")
52
+ end
53
+
50
54
  def ensure_capability!(method, capabilities)
51
55
  case method
52
56
  when PROMPTS_GET, PROMPTS_LIST
@@ -9,10 +9,25 @@ module MCP
9
9
  class StdioTransport < Transport
10
10
  STATUS_INTERRUPTED = Signal.list["INT"] + 128
11
11
 
12
- def initialize(server)
12
+ # Default upper bound on a single newline-delimited frame. CRuby's `IO#gets`
13
+ # without a limit accumulates bytes until a newline arrives, so a peer that
14
+ # never emits one can grow a single String until the process is OOM-killed.
15
+ # 4 MiB is large enough for any realistic JSON-RPC frame, including
16
+ # base64-embedded images.
17
+ MAX_LINE_BYTES = 4 * 1024 * 1024
18
+
19
+ def initialize(server, max_line_bytes: MAX_LINE_BYTES)
13
20
  super(server)
14
21
  @open = false
15
22
  @session = nil
23
+ # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
24
+ # limit read without an upper bound, which would silently disable the
25
+ # protection this option exists to provide.
26
+ unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
27
+ raise ArgumentError, "max_line_bytes must be a positive Integer"
28
+ end
29
+
30
+ @max_line_bytes = max_line_bytes
16
31
  $stdin.set_encoding("UTF-8")
17
32
  $stdout.set_encoding("UTF-8")
18
33
  end
@@ -20,7 +35,20 @@ module MCP
20
35
  def open
21
36
  @open = true
22
37
  @session = ServerSession.new(server: @server, transport: self)
23
- while @open && (line = $stdin.gets)
38
+ while @open
39
+ begin
40
+ line = read_line($stdin)
41
+ rescue RequestHandlerError => e
42
+ # Stop accumulating and end the connection gracefully rather than
43
+ # letting an unbounded read exhaust memory or escape as an uncaught
44
+ # backtrace. Scoped to the read so genuine request errors raised while
45
+ # handling a frame are not swallowed here.
46
+ @open = false
47
+ MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" })
48
+ break
49
+ end
50
+ break if line.nil?
51
+
24
52
  response = @session.handle_json(line.strip)
25
53
  send_response(response) if response
26
54
  end
@@ -73,7 +101,7 @@ module MCP
73
101
  raise
74
102
  end
75
103
 
76
- while @open && (line = $stdin.gets)
104
+ while @open && (line = read_line($stdin))
77
105
  begin
78
106
  parsed = JSON.parse(line.strip, symbolize_names: true)
79
107
  rescue JSON::ParserError => e
@@ -95,6 +123,26 @@ module MCP
95
123
 
96
124
  raise "Transport closed while waiting for response to #{method} request."
97
125
  end
126
+
127
+ private
128
+
129
+ # Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns
130
+ # the line (including its trailing newline) or `nil` at EOF. Raises when the
131
+ # limit is reached before a newline arrives, which signals a peer streaming
132
+ # an unbounded frame. A short final frame without a trailing newline (EOF) is
133
+ # still returned, since its length stays under the limit.
134
+ def read_line(io)
135
+ line = io.gets("\n", @max_line_bytes)
136
+ if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
137
+ raise RequestHandlerError.new(
138
+ "stdio frame exceeds #{@max_line_bytes} bytes without a newline",
139
+ nil,
140
+ error_type: :internal_error,
141
+ )
142
+ end
143
+
144
+ line
145
+ end
98
146
  end
99
147
  end
100
148
  end