mcp 0.22.0 → 0.24.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.
@@ -74,13 +74,20 @@ module MCP
74
74
  )
75
75
 
76
76
  @provider.redirect_handler.call(authorization_url)
77
- code, returned_state = Array(@provider.callback_handler.call)
77
+ callback_result = Array(@provider.callback_handler.call)
78
+ code, returned_state, returned_iss = callback_result
78
79
  raise AuthorizationError, "Authorization callback did not return an authorization code." unless code
79
80
 
80
81
  unless states_match?(returned_state, state)
81
82
  raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
82
83
  end
83
84
 
85
+ validate_authorization_response_issuer!(
86
+ as_metadata: as_metadata,
87
+ iss: returned_iss,
88
+ iss_provided: callback_result.length >= 3,
89
+ )
90
+
84
91
  tokens = exchange_authorization_code(
85
92
  as_metadata: as_metadata,
86
93
  client_info: client_info,
@@ -101,6 +108,7 @@ module MCP
101
108
  # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
102
109
  def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
103
110
  client_info = client_credentials_client_info
111
+ ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
104
112
 
105
113
  form = { "grant_type" => "client_credentials" }
106
114
  effective_scope = resolve_scope(scope: scope, prm: prm)
@@ -127,6 +135,25 @@ module MCP
127
135
  info
128
136
  end
129
137
 
138
+ # Per SEP-2352, static machine-to-machine credentials are bound to their authorization
139
+ # server the same way registered ones are: when the stored `client_information` records
140
+ # an `issuer` that differs from the current authorization server, surface an error
141
+ # instead of silently sending another server's credentials (the spec's "SHOULD surface
142
+ # an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here).
143
+ # Re-registration is not an option for the `client_credentials` grant - the credentials
144
+ # are pre-registered, not DCR results - so the operator must update the stored credentials.
145
+ # Credentials without a recorded issuer keep working unchanged.
146
+ def ensure_client_credentials_issuer!(client_info, as_metadata:)
147
+ stored_issuer = client_info_required_value(client_info, "issuer")
148
+ return if stored_issuer.nil?
149
+ return if stored_issuer == as_metadata["issuer"]
150
+
151
+ raise AuthorizationError,
152
+ "Stored client credentials are bound to a different authorization server " \
153
+ "(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
154
+ "refusing to send them to the current authorization server (SEP-2352)."
155
+ end
156
+
130
157
  # Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
131
158
  # Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
132
159
  # checks before talking to it.
@@ -166,6 +193,7 @@ module MCP
166
193
  client_info = if have_stored_client_info
167
194
  # Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
168
195
  # do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
196
+ ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
169
197
  stored_client_info
170
198
  elsif as_metadata["client_id_metadata_document_supported"] == true
171
199
  { "client_id" => @provider.client_id_metadata_document_url }
@@ -412,8 +440,8 @@ module MCP
412
440
  end
413
441
 
414
442
  def ensure_client_registered(as_metadata:)
415
- existing = @provider.client_information
416
- return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
443
+ existing = stored_client_information_for(issuer: as_metadata["issuer"])
444
+ return existing if existing
417
445
 
418
446
  # Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
419
447
  # if the authorization server advertises Client ID Metadata Document support and the provider has
@@ -462,7 +490,12 @@ module MCP
462
490
  "Dynamic client registration response is missing `client_id`."
463
491
  end
464
492
 
465
- @provider.save_client_information(info)
493
+ # Per SEP-2352, persisted client credentials are keyed by the issuer identifier of
494
+ # the authorization server that minted them, so a later flow can detect an AS change and
495
+ # re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field;
496
+ # the SDK adds it to the opaque persisted hash.
497
+ @provider.save_client_information(info.merge("issuer" => as_metadata["issuer"]))
498
+
466
499
  info
467
500
  end
468
501
 
@@ -480,6 +513,60 @@ module MCP
480
513
  metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
481
514
  end
482
515
 
516
+ # Returns the stored `client_information` when it may be used against the authorization server
517
+ # identified by `issuer`, applying SEP-2352's authorization server binding rules:
518
+ #
519
+ # - Credentials persisted with an `"issuer"` binding MUST NOT be reused against
520
+ # a different authorization server. When the AS changed, the stale registration and its tokens
521
+ # are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so
522
+ # the flow re-registers.
523
+ # - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version,
524
+ # or user-supplied pre-registered credentials) are bound to the current issuer on first use.
525
+ # - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused
526
+ # and re-bound instead of discarded.
527
+ #
528
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352
529
+ def stored_client_information_for(issuer:)
530
+ existing = @provider.client_information
531
+ return unless existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
532
+
533
+ stored_issuer = client_info_required_value(existing, "issuer")
534
+ return existing if stored_issuer == issuer
535
+ return rebind_client_information(existing, issuer: issuer) if stored_issuer.nil?
536
+
537
+ client_id = client_info_required_value(existing, "client_id")
538
+ return rebind_client_information(existing, issuer: issuer) if Discovery.client_id_metadata_document_url?(client_id)
539
+
540
+ @provider.save_client_information(nil)
541
+ @provider.clear_tokens!
542
+ nil
543
+ end
544
+
545
+ def rebind_client_information(info, issuer:)
546
+ rebound = info.merge("issuer" => issuer)
547
+ @provider.save_client_information(rebound)
548
+ rebound
549
+ end
550
+
551
+ # Per SEP-2352, stored client credentials are bound to the authorization server that issued them;
552
+ # refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues
553
+ # the error and falls back to the full flow, which discards the stale registration and re-registers.
554
+ # Credentials without an `"issuer"` binding predate this check and are allowed through;
555
+ # CIMD `client_id`s are portable.
556
+ def ensure_refreshable_client_information!(client_info, as_metadata:)
557
+ stored_issuer = client_info_required_value(client_info, "issuer")
558
+ return if stored_issuer.nil?
559
+ return if stored_issuer == as_metadata["issuer"]
560
+
561
+ client_id = client_info_required_value(client_info, "client_id")
562
+ return if Discovery.client_id_metadata_document_url?(client_id)
563
+
564
+ raise AuthorizationError,
565
+ "Cannot refresh: stored client credentials were issued by a different authorization server " \
566
+ "(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
567
+ "re-registration is required (SEP-2352)."
568
+ end
569
+
483
570
  # Reads `key` from a `client_information` hash that may use either string or
484
571
  # symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
485
572
  # `{ client_id:, client_secret: }` and have both work.
@@ -534,6 +621,43 @@ module MCP
534
621
  raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
535
622
  end
536
623
 
624
+ # Validates the RFC 9207 `iss` authorization response parameter against the issuer of the authorization server
625
+ # the flow is talking to, per SEP-2468 (mix-up attack mitigation in multi-IdP setups):
626
+ #
627
+ # - When the callback supplied a non-empty `iss`, it MUST equal the AS metadata `issuer` exactly (simple string comparison,
628
+ # no normalization, per RFC 9207 Section 2.4); on mismatch the flow aborts before the authorization code is ever sent to
629
+ # a token endpoint.
630
+ # - When the callback returned a 3-element `[code, state, iss]` whose `iss` is nil (asserting it inspected the authorization response
631
+ # and found no `iss`) and the AS metadata advertises `authorization_response_iss_parameter_supported: true`, the missing parameter
632
+ # is treated as an attack per RFC 9207 Section 2.4 and the flow aborts.
633
+ # - A legacy 2-element `[code, state]` callback skips the check: the caller never looked for `iss`, so its absence carries no signal.
634
+ #
635
+ # `as_metadata["issuer"]` has already been byte-compared against the discovery URL by `ensure_issuer_matches!`,
636
+ # so it is the RFC 9207 anchor value. `iss` is not a secret; a plain `==` suffices.
637
+ #
638
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468
639
+ # - https://www.rfc-editor.org/rfc/rfc9207
640
+ def validate_authorization_response_issuer!(as_metadata:, iss:, iss_provided:)
641
+ expected = as_metadata["issuer"]
642
+
643
+ if iss && !iss.to_s.empty?
644
+ return if iss.to_s == expected.to_s
645
+
646
+ raise AuthorizationError,
647
+ "Authorization response `iss` does not match the authorization server issuer " \
648
+ "(expected #{expected.inspect}, got #{iss.inspect}) (RFC 9207); " \
649
+ "refusing to exchange the authorization code."
650
+ end
651
+
652
+ return unless iss_provided
653
+ return unless as_metadata["authorization_response_iss_parameter_supported"] == true
654
+
655
+ raise AuthorizationError,
656
+ "Authorization server advertises `authorization_response_iss_parameter_supported` but " \
657
+ "the authorization response carried no `iss` parameter (RFC 9207 Section 2.4); " \
658
+ "refusing to exchange the authorization code."
659
+ end
660
+
537
661
  # Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
538
662
  # of the expected value.
539
663
  # `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
@@ -681,7 +805,26 @@ module MCP
681
805
  client_secret = client_info_required_value(client_info, "client_secret")
682
806
  token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
683
807
 
684
- form = form.merge("client_id" => client_id)
808
+ form = if token_endpoint_auth_method == "private_key_jwt"
809
+ # RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of
810
+ # the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046).
811
+ # The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is
812
+ # omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity).
813
+ # The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated.
814
+ unless @provider.respond_to?(:client_assertion)
815
+ raise AuthorizationError,
816
+ "token_endpoint_auth_method is private_key_jwt but the provider does not " \
817
+ "implement `client_assertion(audience:)`."
818
+ end
819
+
820
+ form.merge(
821
+ "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
822
+ "client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
823
+ )
824
+ else
825
+ form.merge("client_id" => client_id)
826
+ end
827
+
685
828
  headers = {}
686
829
  if client_secret
687
830
  case token_endpoint_auth_method
@@ -12,7 +12,9 @@ module MCP
12
12
  # - `client_information`: the hash returned by Dynamic Client Registration
13
13
  # or supplied as pre-registered credentials
14
14
  # (`client_id`, optional `client_secret`, optional
15
- # `token_endpoint_auth_method`).
15
+ # `token_endpoint_auth_method`). The SDK additionally stamps an `"issuer"` member
16
+ # binding the credentials to the authorization server that issued them (SEP-2352);
17
+ # custom storages should treat the hash as opaque and persist it as-is.
16
18
  #
17
19
  # This class keeps everything in process memory, so the credentials live
18
20
  # only for the lifetime of the Ruby process. Applications that need
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+
8
+ module MCP
9
+ class Client
10
+ module OAuth
11
+ # Builds RFC 7523 Section 2.2 JWT client assertions for the `private_key_jwt`
12
+ # client authentication method used by the `client_credentials` grant
13
+ # (MCP extension `io.modelcontextprotocol/oauth-client-credentials`, SEP-1046).
14
+ #
15
+ # The JWS is assembled with openssl so the SDK stays free of a JWT gem dependency
16
+ # (the TypeScript and Python SDKs use jose and PyJWT for the same assertion).
17
+ # Claims follow SEP-1046 and RFC 7523: `iss` and `sub` carry the client_id,
18
+ # `aud` carries the authorization server's issuer identifier, plus `exp`, `iat`,
19
+ # and a unique `jti`.
20
+ #
21
+ # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046
22
+ # - https://www.rfc-editor.org/rfc/rfc7523#section-2.2
23
+ module JWTClientAssertion
24
+ # Raised when `signing_algorithm` is not supported.
25
+ class UnsupportedAlgorithmError < ArgumentError; end
26
+
27
+ # Raised when the private key cannot be parsed or does not match
28
+ # the requested signing algorithm (e.g. an RSA key with ES256).
29
+ class InvalidKeyError < ArgumentError; end
30
+
31
+ # RFC 7523 Section 2.2 `client_assertion_type` value.
32
+ ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
33
+
34
+ # Assertion lifetime in seconds; matches the TypeScript SDK's `jwtLifetimeSeconds`
35
+ # and the Python SDK's `lifetime_seconds` default.
36
+ DEFAULT_LIFETIME = 300
37
+
38
+ # ES256 produces a raw `r || s` JWS signature of two 32-byte integers.
39
+ ES256_COMPONENT_BYTES = 32
40
+ private_constant :ES256_COMPONENT_BYTES
41
+
42
+ SUPPORTED_ALGORITHMS = ["ES256", "RS256"].freeze
43
+
44
+ class << self
45
+ # Returns a signed compact-serialization JWT (`header.payload.signature`).
46
+ #
47
+ # @param client_id [String] The pre-registered OAuth client identifier.
48
+ # @param audience [String] The authorization server's issuer identifier.
49
+ # @param private_key [String, OpenSSL::PKey::PKey] PEM string (PKCS#8 or
50
+ # traditional encoding) or an already-parsed key.
51
+ # @param signing_algorithm [String] `"ES256"` (prime256v1 EC key) or
52
+ # `"RS256"` (RSA key).
53
+ # @param lifetime [Integer] Seconds until the `exp` claim expires.
54
+ def generate(client_id:, audience:, private_key:, signing_algorithm:, lifetime: DEFAULT_LIFETIME)
55
+ unless SUPPORTED_ALGORITHMS.include?(signing_algorithm)
56
+ raise UnsupportedAlgorithmError,
57
+ "signing_algorithm must be one of #{SUPPORTED_ALGORITHMS.inspect} (got #{signing_algorithm.inspect})."
58
+ end
59
+
60
+ key = parse_key(private_key)
61
+ validate_key!(key, signing_algorithm)
62
+
63
+ now = Time.now.to_i
64
+ header = { alg: signing_algorithm, typ: "JWT" }
65
+ claims = {
66
+ iss: client_id,
67
+ sub: client_id,
68
+ aud: audience,
69
+ exp: now + lifetime,
70
+ iat: now,
71
+ jti: SecureRandom.uuid,
72
+ }
73
+
74
+ signing_input = "#{base64url(JSON.generate(header))}.#{base64url(JSON.generate(claims))}"
75
+ "#{signing_input}.#{base64url(sign(key, signing_algorithm, signing_input))}"
76
+ end
77
+
78
+ private
79
+
80
+ def parse_key(private_key)
81
+ return private_key if private_key.is_a?(OpenSSL::PKey::PKey)
82
+
83
+ OpenSSL::PKey.read(private_key.to_s)
84
+ rescue OpenSSL::PKey::PKeyError => e
85
+ raise InvalidKeyError, "private_key could not be parsed as a PEM-encoded key: #{e.message}."
86
+ end
87
+
88
+ def validate_key!(key, signing_algorithm)
89
+ case signing_algorithm
90
+ when "ES256"
91
+ unless key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == "prime256v1"
92
+ raise InvalidKeyError, "ES256 requires an EC private key on the prime256v1 (P-256) curve."
93
+ end
94
+ when "RS256"
95
+ unless key.is_a?(OpenSSL::PKey::RSA)
96
+ raise InvalidKeyError, "RS256 requires an RSA private key."
97
+ end
98
+ end
99
+
100
+ return if key.private?
101
+
102
+ raise InvalidKeyError, "private_key must contain the private component to sign assertions."
103
+ end
104
+
105
+ def sign(key, signing_algorithm, signing_input)
106
+ der = key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
107
+ return der unless signing_algorithm == "ES256"
108
+
109
+ ecdsa_der_to_raw(der)
110
+ end
111
+
112
+ # `OpenSSL::PKey::EC#sign` returns an ASN.1 DER `SEQUENCE { r, s }`,
113
+ # while JWS ES256 (RFC 7518 Section 3.4) requires the raw 64-byte
114
+ # `r || s` concatenation with each integer left-padded to 32 bytes.
115
+ def ecdsa_der_to_raw(der)
116
+ OpenSSL::ASN1.decode(der).value.map do |integer|
117
+ integer.value.to_s(16).rjust(ES256_COMPONENT_BYTES * 2, "0")
118
+ end.join.then { |hex| [hex].pack("H*") }
119
+ end
120
+
121
+ def base64url(data)
122
+ Base64.urlsafe_encode64(data, padding: false)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -21,15 +21,23 @@ module MCP
21
21
  # - `redirect_handler` - Callable invoked with the fully-built authorization
22
22
  # URL (a `URI`). Implementations typically open the user's browser.
23
23
  # - `callback_handler` - Callable invoked after `redirect_handler`. Returns
24
- # `[code, state]` where `code` is the authorization code and `state` is
25
- # the `state` parameter received on the redirect URI.
24
+ # `[code, state]` or `[code, state, iss]`, where `code` is the authorization code,
25
+ # `state` is the `state` parameter received on the redirect URI, and `iss` is
26
+ # the RFC 9207 `iss` parameter (or nil when the authorization response carried none).
27
+ # Returning the 3-element form opts into SEP-2468 issuer validation: a present `iss`
28
+ # must match the authorization server's issuer, and a nil `iss` is
29
+ # rejected when the AS advertises `authorization_response_iss_parameter_supported`.
30
+ # The 2-element form skips the check for backward compatibility.
26
31
  #
27
32
  # Optional keyword arguments:
28
33
  # - `scope` - String of space-separated scopes to request when the server's
29
34
  # `WWW-Authenticate` does not specify one.
30
35
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
31
36
  # `client_information`, and `save_client_information(info)`. Defaults to
32
- # an `InMemoryStorage`.
37
+ # an `InMemoryStorage`. Persisted `client_information` is stamped with
38
+ # an `"issuer"` member binding it to the authorization server that
39
+ # issued it (SEP-2352); when the authorization server changes, the SDK discards
40
+ # the stale registration and tokens and re-registers.
33
41
  # - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
34
42
  # (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
35
43
  # When the authorization server advertises `client_id_metadata_document_supported: true`,
@@ -5,6 +5,7 @@ require_relative "oauth/flow"
5
5
  require_relative "oauth/in_memory_storage"
6
6
  require_relative "oauth/pkce"
7
7
  require_relative "oauth/storage_backed_provider"
8
+ require_relative "oauth/jwt_client_assertion"
8
9
  require_relative "oauth/provider"
9
10
  require_relative "oauth/client_credentials_provider"
10
11
 
@@ -4,10 +4,12 @@ module MCP
4
4
  class Client
5
5
  # Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
6
6
  # Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
7
- # and an optional `meta` hash mirroring the MCP `_meta` response field.
8
- ListToolsResult = Struct.new(:tools, :next_cursor, :meta, keyword_init: true)
9
- ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, keyword_init: true)
10
- ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, keyword_init: true)
11
- ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, keyword_init: true)
7
+ # an optional `meta` hash mirroring the MCP `_meta` response field, and the optional SEP-2549
8
+ # cache hints `ttl_ms` (freshness lifetime in milliseconds; 0 means do not cache) and
9
+ # `cache_scope` (`"public"` or `"private"`) mirroring the `ttlMs`/`cacheScope` response fields.
10
+ ListToolsResult = Struct.new(:tools, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
11
+ ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
12
+ ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
13
+ ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
12
14
  end
13
15
  end
@@ -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
@@ -128,9 +143,7 @@ module MCP
128
143
  @server_info
129
144
  end
130
145
 
131
- # Returns true once `connect` (or the implicit handshake on the first
132
- # `send_request`) has completed. Returns false before the handshake
133
- # and after `close`.
146
+ # Returns true once `connect` has completed the handshake. Returns false before the handshake and after `close`.
134
147
  def connected?
135
148
  @initialized
136
149
  end
@@ -140,11 +153,7 @@ module MCP
140
153
  # write does not race ahead of the request write on the wire. The yield happens inside `@write_mutex`,
141
154
  # so any subsequent `send_notification` write waits for the mutex and is guaranteed to land after the request.
142
155
  def send_request(request:)
143
- start unless @started
144
- unless @initialized
145
- warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1)
146
- connect
147
- end
156
+ raise "MCP::Client#connect must be called before sending requests." unless @initialized
148
157
 
149
158
  @write_mutex.synchronize do
150
159
  write_message(request)
@@ -245,12 +254,14 @@ module MCP
245
254
  loop do
246
255
  ensure_running!
247
256
  wait_for_readable!(method, params) if @read_timeout
248
- line = @stdout.gets
257
+ line = read_line(method, params)
249
258
  raise_connection_error!(method, params) if line.nil?
250
259
 
251
260
  parsed = JSON.parse(line.strip)
252
261
 
253
- next unless parsed.key?("id")
262
+ # A JSON-RPC message is an object; skip a non-object frame (array or scalar)
263
+ # the same way as a frame without an id.
264
+ next unless parsed.is_a?(Hash) && parsed.key?("id")
254
265
 
255
266
  return parsed if parsed["id"] == request_id
256
267
  end
@@ -284,6 +295,31 @@ module MCP
284
295
  )
285
296
  end
286
297
 
298
+ # Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`.
299
+ # Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit
300
+ # is reached before a newline arrives, which signals a server streaming an unbounded frame.
301
+ # A short final frame without a trailing newline (EOF) is still returned, since its length
302
+ # stays under the limit.
303
+ def read_line(method, params)
304
+ line = @stdout.gets("\n", @max_line_bytes)
305
+ return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
306
+
307
+ # The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and
308
+ # cannot be resumed. Close before raising so a later `send_request` fails cleanly instead
309
+ # of parsing a truncated frame.
310
+ begin
311
+ close
312
+ rescue StandardError
313
+ nil
314
+ end
315
+
316
+ raise RequestHandlerError.new(
317
+ "Server response frame exceeds #{@max_line_bytes} bytes without a newline",
318
+ { method: method, params: params },
319
+ error_type: :internal_error,
320
+ )
321
+ end
322
+
287
323
  def raise_connection_error!(method, params)
288
324
  raise RequestHandlerError.new(
289
325
  "Server process closed stdout unexpectedly",