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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7ace5af244e82b2df4f3fef449fd0942d3d9be46746d820f8207cfcee87af52
4
- data.tar.gz: ce4b53defad3ed24646806c2236a8d79c68597c7225fa4fcef20fd2d85031c24
3
+ metadata.gz: b3d294b5e8b52b58ba3d2a2752db94f3a0d836ed6003d10ba0220d9d31b2d267
4
+ data.tar.gz: 1f0e92d3a77fc36166a1f68c3443aa9d5174ee139491b784dfa3cdcb92caa8e1
5
5
  SHA512:
6
- metadata.gz: 2c2ef2a03fb1b989e691aea55d350f06b9f1f357b0acfcda4e227ca359c3a12990d06fe280ced39b1e6d620181aa9c7b8d355d5985c70d090512636da7c34f3a
7
- data.tar.gz: a75f884057bc318a98943fec8967c4725c63dd767552dc52591c8e08143474f8a28907e929cdbe0efb645426766b75314449b9df8c3c86aa90bb0a68247004ff
6
+ metadata.gz: 975ebdaeab3e8dd8219c81c812fcbde232047a9baabf6924bea6fba7f4eae05b6eab96aa02efd601e83e443fc0ad62107f9379a1ab07924d1fcccedae48c6232
7
+ data.tar.gz: 624de6afb8859bce866dabef20c6357913ff8adf2e479f84d2472049f7f7f2853dc65f27ed1925f1c3f76eae880a3886806136dea6d94459988d24e6f973d304
data/README.md CHANGED
@@ -41,7 +41,7 @@ It implements the Model Context Protocol specification, handling model context r
41
41
  - Supports roots (server-to-client filesystem boundary queries)
42
42
  - Supports sampling (server-to-client LLM completion requests)
43
43
  - Supports cursor-based pagination for list operations
44
- - Supports server-side cancellation of in-flight requests (notifications/cancelled)
44
+ - Supports cancellation of in-flight requests on both server and client (notifications/cancelled)
45
45
 
46
46
  ### Supported Methods
47
47
 
@@ -102,6 +102,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server)
102
102
  transport.open
103
103
  ```
104
104
 
105
+ `StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB).
106
+
105
107
  You can run this script and then type in requests to the server at the command line.
106
108
 
107
109
  ```console
@@ -128,6 +130,27 @@ The following examples show two common integration styles in Rails.
128
130
  >
129
131
  > Stateless mode (`stateless: true`) does not use sessions and works with any server configuration.
130
132
 
133
+ > [!IMPORTANT]
134
+ > Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to
135
+ > prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403.
136
+ > `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header,
137
+ > when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin`
138
+ > header are unaffected.
139
+ >
140
+ > Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists:
141
+ >
142
+ > ```ruby
143
+ > transport = MCP::Server::Transports::StreamableHTTPTransport.new(
144
+ > server,
145
+ > allowed_hosts: ["mcp.example.com"],
146
+ > allowed_origins: ["https://app.example.com"],
147
+ > )
148
+ > ```
149
+ >
150
+ > An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value,
151
+ > so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false`
152
+ > to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`).
153
+
131
154
  ##### Rails (mount)
132
155
 
133
156
  `StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes:
@@ -698,8 +721,28 @@ class WeatherTool < MCP::Tool
698
721
  end
699
722
  ```
700
723
 
701
- Please note: in this case, you must provide `type: "array"`. The default type
702
- for output schemas is `object`.
724
+ Please note: in this case, you must provide `type: "array"`. The default type for output schemas is `object`,
725
+ applied only when the schema declares no root keyword (`type`, `$ref`, `oneOf`, `anyOf`, `allOf`, `not`, `if`, `const`, `enum`).
726
+
727
+ Per SEP-2106, an output schema may be any valid JSON Schema 2020-12 document, including a primitive root
728
+ (`{ type: "string" }`) or a root-level composition:
729
+
730
+ ```ruby
731
+ class FlexibleTool < MCP::Tool
732
+ output_schema(
733
+ oneOf: [
734
+ { type: "string" },
735
+ { type: "array", items: { type: "number" } }
736
+ ]
737
+ )
738
+ end
739
+ ```
740
+
741
+ Input schemas keep `type: "object"` at the root but accept the full 2020-12 vocabulary below it
742
+ (`$defs`/`$ref`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`). Two resource bounds apply to
743
+ all tool schemas: only same-document `$ref`s (starting with `#`) are accepted, and documents are
744
+ capped at `MCP::Tool::Schema::MAX_SCHEMA_DEPTH` nesting levels and `MCP::Tool::Schema::MAX_SUBSCHEMA_COUNT` subschema objects;
745
+ violations raise `ArgumentError` at construction time.
703
746
 
704
747
  MCP spec for the [Output Schema](https://modelcontextprotocol.io/specification/latest/server/tools#output-schema) specifies that:
705
748
 
@@ -729,6 +772,10 @@ Tools can return structured data alongside text content using the `structured_co
729
772
 
730
773
  The structured content will be included in the JSON-RPC response as the `structuredContent` field.
731
774
 
775
+ Per SEP-2106, `structured_content` may be any JSON value, not only an object. When a tool returns a non-object value (e.g. an array)
776
+ without providing any content blocks, the server automatically mirrors it into `content` as serialized JSON text so older clients
777
+ that only read `content` still receive the data.
778
+
732
779
  ```ruby
733
780
  class WeatherTool < MCP::Tool
734
781
  description "Get current weather and return structured data"
@@ -1205,12 +1252,7 @@ poll it to exit early. When a tool returns after cancellation has been observed,
1205
1252
  the server suppresses the JSON-RPC response, matching the spec. The `initialize` request
1206
1253
  is never cancellable per the spec.
1207
1254
 
1208
- > [!NOTE]
1209
- > Client-initiated cancellation (`Client#cancel` equivalent that would also abort
1210
- > the calling thread's wait) is not yet implemented. Sending `notifications/cancelled`
1211
- > from the client side can be done by constructing the notification payload and writing it
1212
- > directly through the transport, but the calling thread does not yet unwind automatically.
1213
- > This is tracked as a follow-up.
1255
+ Client-initiated cancellation is also supported: see [Client-Side: Cancelling an In-Flight Request](#client-side-cancelling-an-in-flight-request) below.
1214
1256
 
1215
1257
  #### Server-Side: Handlers that Check for Cancellation
1216
1258
 
@@ -1319,6 +1361,60 @@ Nested cancellation propagation is supported on `StreamableHTTPTransport` only.
1319
1361
  the parent `tools/call` is cancelled. The parent tool itself still observes cancellation
1320
1362
  via `server_context.cancelled?` between nested calls.
1321
1363
 
1364
+ #### Client-Side: Cancelling an In-Flight Request
1365
+
1366
+ `MCP::Client` lets the caller cancel a request it has already issued. The recommended pattern is to pass
1367
+ an `MCP::Cancellation` token into the request method, run the request on a worker thread, and call
1368
+ `cancellation.cancel(reason:)` from another thread. The cancelling thread sends `notifications/cancelled` to
1369
+ the server, and the calling thread is woken up with `MCP::CancelledError`:
1370
+
1371
+ ```ruby
1372
+ client = MCP::Client.new(transport: transport)
1373
+ cancellation = MCP::Cancellation.new
1374
+
1375
+ Thread.new do
1376
+ client.call_tool(name: "slow_tool", arguments: {}, cancellation: cancellation)
1377
+ rescue MCP::CancelledError
1378
+ # cleanup
1379
+ end
1380
+
1381
+ # Later, from another thread:
1382
+ cancellation.cancel(reason: "user pressed cancel")
1383
+ ```
1384
+
1385
+ All request methods (`tools`, `list_tools`, `resources`, `list_resources`, `resource_templates`, `list_resource_templates`,
1386
+ `prompts`, `list_prompts`, `call_tool`, `read_resource`, `get_prompt`, `complete`, `ping`) accept the `cancellation:` keyword.
1387
+ Request ids are managed internally, so the token is the only thing a caller needs to cancel a request.
1388
+
1389
+ > [!NOTE]
1390
+ > When a cancel wins the race, the SDK's worker thread that is blocked on the underlying I/O is *not* force-killed;
1391
+ > it stays blocked until the transport actually returns (or the user closes the transport). This matches the server-side
1392
+ > `StreamableHTTPTransport#send_request` trade-off. For `StreamableHTTPTransport#send_request` trade-off. For `Client::HTTP`
1393
+ > the leak resolves as soon as the server sends any response; for `Client::Stdio` you may need to call `client.transport.close`
1394
+ > to free the thread if the server stops responding entirely. The cancel-dispatch thread waits for the worker's send-boundary signal
1395
+ > (`&on_sent` from `send_request`) before issuing `notifications/cancelled`, so the cancel is held until the worker has at
1396
+ > least committed to writing the request; while the worker is wedged the cancel notification is deferred along with it.
1397
+
1398
+ ##### Wire-order guarantees
1399
+
1400
+ `Client::Stdio` serializes the request write and any subsequent `notifications/cancelled` write through a single `@write_mutex`,
1401
+ so the server is guaranteed to read the request line before the cancel line.
1402
+
1403
+ `Client::HTTP` cannot offer the same wire-arrival guarantee. Faraday's synchronous `post` does not expose a post-write / pre-response hook,
1404
+ so the SDK yields just before the request POST is dispatched. After the yield, the cancel-dispatch thread issues a separate `notifications/cancelled` POST
1405
+ on its own connection, and the two POSTs may overlap on the network. The spec is satisfied either way: the sender has already issued the request and
1406
+ still believes it to be in-progress when issuing the cancel ([MCP cancellation spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)),
1407
+ and on the receiver side, "receivers MAY ignore a cancellation notification whose `requestId` is unknown" covers the case where the cancel POST
1408
+ happens to arrive first. The calling thread raises `MCP::CancelledError` regardless of network ordering.
1409
+
1410
+ ##### Custom transports
1411
+
1412
+ Custom transports that want to support `cancellation:` must implement `send_notification(notification:)` so `notifications/cancelled` can be delivered.
1413
+ They should also accept the optional block passed to `send_request(request:, &on_sent)` and call it once the request bytes have been handed off to the wire
1414
+ (under a write-side mutex for stdio-style transports, immediately before the synchronous round-trip for HTTP-style transports).
1415
+ The cancel-dispatch thread waits on this signal before sending `notifications/cancelled`. Transports that do not invoke the block fall back to waiting for
1416
+ the worker thread to terminate, which preserves wire-order at the cost of delaying the cancel notification until the request has fully completed.
1417
+
1322
1418
  ### Ping
1323
1419
 
1324
1420
  The MCP Ruby SDK supports the
@@ -1685,12 +1781,58 @@ Session-scoped standalone notifications (`resources/updated`, `elicitation/compl
1685
1781
  broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream.
1686
1782
  This mode is suitable for simple tool servers that do not need server-initiated requests.
1687
1783
 
1688
- By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
1689
- When configured, sessions that receive no HTTP requests for this duration are automatically expired and cleaned up:
1784
+ By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted:
1785
+ they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent
1786
+ session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it
1787
+ (a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute,
1788
+ so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots
1789
+ and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session).
1790
+
1791
+ ```ruby
1792
+ # Tune the limits
1793
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000)
1794
+
1795
+ # Opt out of expiry and/or the cap (not recommended on internet-facing deployments)
1796
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil)
1797
+ ```
1798
+
1799
+ Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it.
1800
+
1801
+ #### Session Ownership
1802
+
1803
+ `StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session
1804
+ existence and idle timeout only. It does not bind a session to a user, because the transport never receives
1805
+ an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session,
1806
+ so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD).
1807
+
1808
+ The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }`
1809
+ on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs,
1810
+ so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return
1811
+ rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded
1812
+ when the session was created:
1813
+
1814
+ ```ruby
1815
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(
1816
+ server,
1817
+ session_request_validator: ->(request, session_id) { owns_session?(request, session_id) },
1818
+ )
1819
+ ```
1820
+
1821
+ Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication),
1822
+ it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only
1823
+ when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check.
1824
+ Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal.
1825
+
1826
+ #### Request Size Limits
1827
+
1828
+ `StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory
1829
+ with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413,
1830
+ and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON
1831
+ string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only
1832
+ if you exchange unusually large payloads:
1690
1833
 
1691
1834
  ```ruby
1692
- # Session timeout of 30 minutes
1693
- transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 1800)
1835
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024)
1694
1836
  ```
1695
1837
 
1696
1838
  ### Pagination
@@ -1885,6 +2027,7 @@ Use the `MCP::Client::Stdio` transport to interact with MCP servers running as s
1885
2027
  | `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
1886
2028
  | `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
1887
2029
  | `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). |
2030
+ | `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
1888
2031
 
1889
2032
  Example usage:
1890
2033
 
@@ -2,9 +2,14 @@
2
2
 
3
3
  module MCP
4
4
  class Annotations
5
+ SUPPORTED_AUDIENCES = ["user", "assistant"].freeze
6
+
5
7
  attr_reader :audience, :priority, :last_modified
6
8
 
7
9
  def initialize(audience: nil, priority: nil, last_modified: nil)
10
+ if audience && !(audience.is_a?(Array) && audience.all? { |role| SUPPORTED_AUDIENCES.include?(role) })
11
+ raise ArgumentError, 'The value of audience must be an array of "user" or "assistant".'
12
+ end
8
13
  raise ArgumentError, "The value of priority must be between 0 and 1." if priority && !priority.between?(0, 1)
9
14
 
10
15
  @audience = audience
@@ -16,6 +16,8 @@ module MCP
16
16
  ACCEPT_HEADER = "application/json, text/event-stream"
17
17
  SESSION_ID_HEADER = "Mcp-Session-Id"
18
18
  PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
19
+ METHOD_HEADER = "Mcp-Method"
20
+ NAME_HEADER = "Mcp-Name"
19
21
 
20
22
  # Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
21
23
  # a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
@@ -178,9 +180,18 @@ module MCP
178
180
  end
179
181
 
180
182
  # Sends a JSON-RPC request and returns the parsed response body.
181
- # After a successful `initialize` handshake, the session ID and protocol
182
- # version returned by the server are captured and automatically included
183
- # on subsequent requests.
183
+ # After a successful `initialize` handshake, the session ID and protocol version returned by
184
+ # the server are captured and automatically included on subsequent requests.
185
+ #
186
+ # If a block is given, it is invoked just before Faraday's `post` is called.
187
+ # Faraday's synchronous `post` does not expose a post-write / pre-response hook,
188
+ # so this is the latest send-boundary signal the adapter exposes; the actual TCP write happens
189
+ # inside `post`. `MCP::Client#dispatch_with_cancellation` uses this yield to release
190
+ # the cancel-dispatch thread, which then issues a separate `notifications/cancelled` POST
191
+ # that may overlap with the original request on the network. The spec covers this:
192
+ # the sender has issued the request and still believes it in-progress, and receivers MAY ignore
193
+ # a cancellation referring to an unknown request id when the cancel POST happens to arrive first.
194
+ # https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
184
195
  def send_request(request:)
185
196
  method = request[:method] || request["method"]
186
197
  params = request[:params] || request["params"]
@@ -188,7 +199,8 @@ module MCP
188
199
  step_up_retried = false
189
200
 
190
201
  begin
191
- response = client.post("", request, session_headers)
202
+ yield if block_given?
203
+ response = client.post("", request, session_headers.merge(request_metadata_headers(method, params)))
192
204
  body = parse_response_body(response, method, params)
193
205
 
194
206
  capture_session_info(method, response, body)
@@ -274,6 +286,23 @@ module MCP
274
286
  end
275
287
  end
276
288
 
289
+ # Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
290
+ # `notifications/cancelled` for an in-flight request. The server acknowledges with HTTP 202 Accepted
291
+ # per the Streamable HTTP spec.
292
+ def send_notification(notification:)
293
+ method = notification[:method] || notification["method"]
294
+
295
+ client.post("", notification, session_headers)
296
+ nil
297
+ rescue Faraday::Error => e
298
+ raise RequestHandlerError.new(
299
+ "Failed to send #{method} notification",
300
+ { method: method },
301
+ error_type: :internal_error,
302
+ original_error: e,
303
+ )
304
+ end
305
+
277
306
  # Terminates the session by sending an HTTP DELETE to the MCP endpoint
278
307
  # with the current `Mcp-Session-Id` header, and clears locally tracked
279
308
  # session state afterward. No-op when no session has been established.
@@ -341,6 +370,39 @@ module MCP
341
370
  request_headers
342
371
  end
343
372
 
373
+ # Per SEP-2243, mirror the JSON-RPC method and target name/uri into HTTP headers so intermediaries
374
+ # can route and inspect requests without parsing the body. `Mcp-Name` comes from `params.name`
375
+ # (tools, prompts) or, when absent, `params.uri` (resources).
376
+ #
377
+ # https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#request-metadata
378
+ def request_metadata_headers(method, params)
379
+ return {} unless method
380
+
381
+ metadata_headers = { METHOD_HEADER => method }
382
+ if params.is_a?(Hash)
383
+ name = params[:name] || params["name"]
384
+ name = params[:uri] || params["uri"] unless name.is_a?(String)
385
+ metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String)
386
+ end
387
+
388
+ metadata_headers
389
+ end
390
+
391
+ # A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF,
392
+ # which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as
393
+ # `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
394
+ def encode_header_value(value)
395
+ return value if safe_header_value?(value)
396
+
397
+ "=?base64?#{[value].pack("m0")}?="
398
+ end
399
+
400
+ def safe_header_value?(value)
401
+ value.bytes.all? { |byte| byte.between?(0x20, 0x7e) } &&
402
+ (value.empty? || (!value.start_with?(" ", "\t") && !value.end_with?(" ", "\t"))) &&
403
+ !(value.start_with?("=?base64?") && value.end_with?("?="))
404
+ end
405
+
344
406
  # Drives the OAuth orchestrator on a 401 from the MCP endpoint.
345
407
  # The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
346
408
  # URL and an optional `scope` challenge per RFC 9728 Section 5.1.
@@ -19,13 +19,28 @@ module MCP
19
19
  CLOSE_TIMEOUT = 2
20
20
  STDERR_READ_SIZE = 4096
21
21
 
22
+ # Default upper bound on a single newline-delimited frame read from the
23
+ # server's stdout. CRuby's `IO#gets` without a limit accumulates bytes until a
24
+ # newline arrives, so a spawned server that never emits one can grow a single
25
+ # String until the host process is OOM-killed. 4 MiB is large enough for any
26
+ # realistic JSON-RPC frame, including base64-embedded images.
27
+ MAX_LINE_BYTES = 4 * 1024 * 1024
28
+
22
29
  attr_reader :command, :args, :env, :server_info
23
30
 
24
- def initialize(command:, args: [], env: nil, read_timeout: nil)
31
+ def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
32
+ # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
33
+ # limit read without an upper bound, which would silently disable the
34
+ # protection this option exists to provide.
35
+ unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
36
+ raise ArgumentError, "max_line_bytes must be a positive Integer"
37
+ end
38
+
25
39
  @command = command
26
40
  @args = args
27
41
  @env = env
28
42
  @read_timeout = read_timeout
43
+ @max_line_bytes = max_line_bytes
29
44
  @stdin = nil
30
45
  @stdout = nil
31
46
  @stderr = nil
@@ -34,6 +49,9 @@ module MCP
34
49
  @started = false
35
50
  @initialized = false
36
51
  @server_info = nil
52
+ # Serializes writes to `@stdin` so a request line and a notification line emitted from
53
+ # different threads (e.g. cancellation) cannot interleave on the wire.
54
+ @write_mutex = Mutex.new
37
55
  end
38
56
 
39
57
  # Performs the MCP `initialize` handshake: sends an `initialize` request
@@ -125,24 +143,35 @@ module MCP
125
143
  @server_info
126
144
  end
127
145
 
128
- # Returns true once `connect` (or the implicit handshake on the first
129
- # `send_request`) has completed. Returns false before the handshake
130
- # and after `close`.
146
+ # Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`.
131
147
  def connected?
132
148
  @initialized
133
149
  end
134
150
 
151
+ # Transports may yield once the request line has been written to `@stdin`.
152
+ # `MCP::Client#dispatch_with_cancellation` uses this signal to ensure a `notifications/cancelled`
153
+ # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
154
+ # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
135
155
  def send_request(request:)
136
- start unless @started
137
- unless @initialized
138
- warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1)
139
- connect
140
- end
156
+ raise "MCP::Client#connect must be called before sending requests." unless @initialized
141
157
 
142
- write_message(request)
158
+ @write_mutex.synchronize do
159
+ write_message(request)
160
+ yield if block_given?
161
+ end
143
162
  read_response(request)
144
163
  end
145
164
 
165
+ # Sends a JSON-RPC notification (no response expected). Used by `Client#cancel` to deliver
166
+ # `notifications/cancelled` for an in-flight request.
167
+ def send_notification(notification:)
168
+ start unless @started
169
+ connect unless @initialized
170
+
171
+ @write_mutex.synchronize { write_message(notification) }
172
+ nil
173
+ end
174
+
146
175
  def start
147
176
  raise "MCP::Client::Stdio already started" if @started
148
177
 
@@ -225,7 +254,7 @@ module MCP
225
254
  loop do
226
255
  ensure_running!
227
256
  wait_for_readable!(method, params) if @read_timeout
228
- line = @stdout.gets
257
+ line = read_line(method, params)
229
258
  raise_connection_error!(method, params) if line.nil?
230
259
 
231
260
  parsed = JSON.parse(line.strip)
@@ -264,6 +293,31 @@ module MCP
264
293
  )
265
294
  end
266
295
 
296
+ # Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`.
297
+ # Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit
298
+ # is reached before a newline arrives, which signals a server streaming an unbounded frame.
299
+ # A short final frame without a trailing newline (EOF) is still returned, since its length
300
+ # stays under the limit.
301
+ def read_line(method, params)
302
+ line = @stdout.gets("\n", @max_line_bytes)
303
+ return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
304
+
305
+ # The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and
306
+ # cannot be resumed. Close before raising so a later `send_request` fails cleanly instead
307
+ # of parsing a truncated frame.
308
+ begin
309
+ close
310
+ rescue StandardError
311
+ nil
312
+ end
313
+
314
+ raise RequestHandlerError.new(
315
+ "Server response frame exceeds #{@max_line_bytes} bytes without a newline",
316
+ { method: method, params: params },
317
+ error_type: :internal_error,
318
+ )
319
+ end
320
+
267
321
  def raise_connection_error!(method, params)
268
322
  raise RequestHandlerError.new(
269
323
  "Server process closed stdout unexpectedly",