mcp 0.22.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: c0e0cdbc945ee9ec5d178aeaedb1c8af84eca0bae8db8e264f523b47b8d67c52
4
- data.tar.gz: 6acb2d299d9ae215c26db733091ebbcbb3f6b1477c94d9b4047f9ab37c3a8f0c
3
+ metadata.gz: b3d294b5e8b52b58ba3d2a2752db94f3a0d836ed6003d10ba0220d9d31b2d267
4
+ data.tar.gz: 1f0e92d3a77fc36166a1f68c3443aa9d5174ee139491b784dfa3cdcb92caa8e1
5
5
  SHA512:
6
- metadata.gz: 8299a6cf0101d4b8b980a69ed3128636aae6f0497fb431d14fdc0f6b8b659fb18ab5d886cf1cb567c53f385a4f8c0cdaf5e91aff6d1a4fe4daefca25d17a7dc2
7
- data.tar.gz: dff4075f2a0fdf27329af3455b1db6960e38f557ea2436388f1a271d6915172e318b9630ccc8312fab16e3e8ac42e0dc84ef20e974b313056d377f3798d4a83c
6
+ metadata.gz: 975ebdaeab3e8dd8219c81c812fcbde232047a9baabf6924bea6fba7f4eae05b6eab96aa02efd601e83e443fc0ad62107f9379a1ab07924d1fcccedae48c6232
7
+ data.tar.gz: 624de6afb8859bce866dabef20c6357913ff8adf2e479f84d2472049f7f7f2853dc65f27ed1925f1c3f76eae880a3886806136dea6d94459988d24e6f973d304
data/README.md CHANGED
@@ -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:
@@ -1758,12 +1781,58 @@ Session-scoped standalone notifications (`resources/updated`, `elicitation/compl
1758
1781
  broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream.
1759
1782
  This mode is suitable for simple tool servers that do not need server-initiated requests.
1760
1783
 
1761
- By default, sessions do not expire. To mitigate session hijacking risks, you can set a `session_idle_timeout` (in seconds).
1762
- 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:
1763
1833
 
1764
1834
  ```ruby
1765
- # Session timeout of 30 minutes
1766
- 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)
1767
1836
  ```
1768
1837
 
1769
1838
  ### Pagination
@@ -1958,6 +2027,7 @@ Use the `MCP::Client::Stdio` transport to interact with MCP servers running as s
1958
2027
  | `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
1959
2028
  | `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
1960
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). |
1961
2031
 
1962
2032
  Example usage:
1963
2033
 
@@ -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,7 +254,7 @@ 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)
@@ -284,6 +293,31 @@ module MCP
284
293
  )
285
294
  end
286
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
+
287
321
  def raise_connection_error!(method, params)
288
322
  raise RequestHandlerError.new(
289
323
  "Server process closed stdout unexpectedly",
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
@@ -24,17 +24,97 @@ module MCP
24
24
  "Connection" => "keep-alive",
25
25
  }.freeze
26
26
 
27
- def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
27
+ # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
28
+ # so an unauthenticated `initialize` flood retains unbounded `ServerSession` objects until memory is exhausted.
29
+ # These defaults expire idle sessions and cap the concurrent count, like the C# SDK (the only reference SDK that
30
+ # hardens this by default, with a 2h idle timeout and a 10k idle-session count). One difference: at the cap this transport
31
+ # rejects a new `initialize` with 503 (after reclaiming any already-expired slots), whereas the C# SDK evicts
32
+ # the oldest idle session. Rejecting keeps established sessions stable and avoids evicting a legitimate idle session on
33
+ # an attacker's behalf, at the cost of refusing new sessions while genuinely full. Pass `session_idle_timeout: nil` to
34
+ # opt out of expiry and `max_sessions: nil` to opt out of the cap.
35
+ DEFAULT_SESSION_IDLE_TIMEOUT = 1800
36
+ DEFAULT_MAX_SESSIONS = 10_000
37
+
38
+ # Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
39
+ UNSET_IDLE_TIMEOUT = Object.new.freeze
40
+ private_constant :UNSET_IDLE_TIMEOUT
41
+
42
+ # Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
43
+ # body into memory and parses it, so without a cap a single unauthenticated POST
44
+ # can allocate gigabytes and OOM the worker. 4 MiB comfortably
45
+ # fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64
46
+ # payload); raise `max_request_bytes:` for unusually large payloads. Matches the
47
+ # TypeScript SDK's 4 MB default.
48
+ DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024
49
+
50
+ # Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust
51
+ # the stack or amplify parse cost (complements the byte cap).
52
+ MAX_JSON_NESTING = 64
53
+
54
+ # Creates a Streamable HTTP transport that can be mounted as a Rack app.
55
+ #
56
+ # @param server [MCP::Server] the server whose requests this transport dispatches.
57
+ # @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained.
58
+ # @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON
59
+ # object instead of an SSE stream.
60
+ # @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; defaults
61
+ # to `DEFAULT_SESSION_IDLE_TIMEOUT` (1800) in stateful mode, and an explicit `nil` disables
62
+ # expiry. Not supported in stateless mode.
63
+ # @param max_sessions [Integer, nil] cap on the concurrent session count in stateful mode; a new
64
+ # `initialize` past the cap is rejected with HTTP 503, and `nil` disables the cap.
65
+ # @param allowed_origins [Array<String>, nil] extra `Origin` values accepted in addition to
66
+ # same-origin requests, for DNS rebinding protection.
67
+ # @param allowed_hosts [Array<String>, nil] extra `Host` values accepted beyond the loopback
68
+ # defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port)
69
+ # or a full `host:port`.
70
+ # @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and
71
+ # `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already
72
+ # validates them.
73
+ # @param session_request_validator [#call, nil] An optional
74
+ # `->(request, session_id) { true | false }` invoked on every non-`initialize` POST, GET, and DELETE
75
+ # against an existing session (regular requests, notifications, and client responses alike).
76
+ # Returning a falsy value rejects the request with HTTP 403. The SDK issues a random `SecureRandom.uuid`
77
+ # session ID and otherwise only checks existence/idle-timeout, so binding a session to a user is
78
+ # the deploying application's responsibility (the transport never receives the authenticated identity
79
+ # on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator,
80
+ # ownership is not enforced.
81
+ # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
82
+ # requests are rejected with HTTP 413. Defaults to 4 MiB.
83
+ def initialize(
84
+ server,
85
+ stateless: false,
86
+ enable_json_response: false,
87
+ session_idle_timeout: UNSET_IDLE_TIMEOUT,
88
+ max_sessions: DEFAULT_MAX_SESSIONS,
89
+ allowed_origins: nil,
90
+ allowed_hosts: nil,
91
+ dns_rebinding_protection: true,
92
+ session_request_validator: nil,
93
+ max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
94
+ )
28
95
  super(server)
29
- # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
96
+ # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
30
97
  @sessions = {}
31
98
  @mutex = Mutex.new
32
99
 
33
100
  @stateless = stateless
34
101
  @enable_json_response = enable_json_response
35
- @session_idle_timeout = session_idle_timeout
102
+ @session_request_validator = session_request_validator
103
+ @dns_rebinding_protection = dns_rebinding_protection
104
+
105
+ # Host names are case-insensitive, so the allow lists are compared down-cased.
106
+ @allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze
107
+ @allowed_origins = Array(allowed_origins).map(&:downcase).freeze
36
108
  @pending_responses = {}
37
109
 
110
+ # Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
111
+ # which does not apply to stateless mode since it retains no sessions.
112
+ @session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
113
+ stateless ? nil : DEFAULT_SESSION_IDLE_TIMEOUT
114
+ else
115
+ session_idle_timeout
116
+ end
117
+
38
118
  if @session_idle_timeout
39
119
  if @stateless
40
120
  raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
@@ -43,6 +123,19 @@ module MCP
43
123
  end
44
124
  end
45
125
 
126
+ unless max_sessions.nil? || (max_sessions.is_a?(Integer) && max_sessions > 0)
127
+ raise ArgumentError, "max_sessions must be a positive Integer or nil"
128
+ end
129
+
130
+ # The cap guards the stateful session store; stateless mode keeps none.
131
+ @max_sessions = stateless ? nil : max_sessions
132
+
133
+ unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0
134
+ raise ArgumentError, "max_request_bytes must be a positive Integer"
135
+ end
136
+
137
+ @max_request_bytes = max_request_bytes
138
+
46
139
  start_reaper_thread if @session_idle_timeout
47
140
  end
48
141
 
@@ -52,12 +145,19 @@ module MCP
52
145
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
53
146
  SESSION_REAP_INTERVAL = 60
54
147
 
148
+ # Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
149
+ # protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
150
+ DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
151
+
55
152
  # Rack app interface. This transport can be mounted as a Rack app.
56
153
  def call(env)
57
154
  handle_request(Rack::Request.new(env))
58
155
  end
59
156
 
60
157
  def handle_request(request)
158
+ rebinding_error = validate_dns_rebinding(request)
159
+ return rebinding_error if rebinding_error
160
+
61
161
  case request.env["REQUEST_METHOD"]
62
162
  when "POST"
63
163
  handle_post(request)
@@ -340,7 +440,9 @@ module MCP
340
440
  content_type_error = validate_content_type(request)
341
441
  return content_type_error if content_type_error
342
442
 
343
- body_string = request.body.read
443
+ body_string = read_bounded_body(request)
444
+ return payload_too_large_response if body_string.nil?
445
+
344
446
  session_id = extract_session_id(request)
345
447
 
346
448
  begin
@@ -378,16 +480,25 @@ module MCP
378
480
  return session_not_found_response
379
481
  end
380
482
 
381
- handle_initialization(body_string, body)
382
- elsif notification?(body)
383
- dispatch_notification(body_string, session_id)
384
- handle_accepted
385
- elsif response?(body)
386
- return session_not_found_response if !@stateless && !session_exists?(session_id)
387
-
388
- handle_response(body, session_id: session_id)
483
+ handle_initialization(request, body_string, body)
389
484
  else
390
- handle_regular_request(body_string, session_id, related_request_id: body[:id])
485
+ # Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
486
+ # and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
487
+ # otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
488
+ if !@stateless && session_id && !validate_session_request(request, session_id)
489
+ return forbidden_response
490
+ end
491
+
492
+ if notification?(body)
493
+ dispatch_notification(body_string, session_id)
494
+ handle_accepted
495
+ elsif response?(body)
496
+ return session_not_found_response if !@stateless && !session_exists?(session_id)
497
+
498
+ handle_response(body, session_id: session_id)
499
+ else
500
+ handle_regular_request(body_string, session_id, related_request_id: body[:id])
501
+ end
391
502
  end
392
503
  rescue StandardError => e
393
504
  MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -412,6 +523,7 @@ module MCP
412
523
 
413
524
  error_response = validate_and_touch_session(session_id)
414
525
  return error_response if error_response
526
+ return forbidden_response unless validate_session_request(request, session_id)
415
527
 
416
528
  protocol_version_error = validate_protocol_version_header(request)
417
529
  return protocol_version_error if protocol_version_error
@@ -434,6 +546,7 @@ module MCP
434
546
 
435
547
  return missing_session_id_response unless (session_id = extract_session_id(request))
436
548
  return session_not_found_response unless session_exists?(session_id)
549
+ return forbidden_response unless validate_session_request(request, session_id)
437
550
 
438
551
  protocol_version_error = validate_protocol_version_header(request)
439
552
  return protocol_version_error if protocol_version_error
@@ -495,6 +608,27 @@ module MCP
495
608
  request.env["HTTP_MCP_SESSION_ID"]
496
609
  end
497
610
 
611
+ # Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
612
+ # The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
613
+ # Two layers, both returning `false` to trigger a 403:
614
+ #
615
+ # - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
616
+ # at `initialize` and this request carries a different one, reject. Both must be present to compare,
617
+ # so non-browser clients that send no `Origin` are unaffected.
618
+ # - The application-supplied `session_request_validator`, which can enforce true ownership when it has
619
+ # an authenticated principal.
620
+ def validate_session_request(request, session_id)
621
+ session = @mutex.synchronize { @sessions[session_id] }
622
+ return true unless session
623
+
624
+ session_origin = session[:origin]
625
+ request_origin = request.env["HTTP_ORIGIN"]
626
+ return false if session_origin && request_origin && session_origin != request_origin
627
+ return @session_request_validator.call(request, session_id) if @session_request_validator
628
+
629
+ true
630
+ end
631
+
498
632
  def validate_accept_header(request, required_types)
499
633
  accept_header = request.env["HTTP_ACCEPT"]
500
634
  return not_acceptable_response(required_types) unless accept_header
@@ -534,8 +668,34 @@ module MCP
534
668
  )
535
669
  end
536
670
 
671
+ # Reads the request body with a hard byte cap so an unbounded POST cannot exhaust
672
+ # memory. A declared `Content-Length` over the cap is rejected
673
+ # without reading; the actual read is also bounded to one byte past the cap, so
674
+ # a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught.
675
+ # Returns `nil` when the body exceeds the cap.
676
+ def read_bounded_body(request)
677
+ content_length = request.content_length
678
+ return if content_length && content_length.to_i > @max_request_bytes
679
+
680
+ body = request.body.read(@max_request_bytes + 1)
681
+ return "" if body.nil?
682
+ return if body.bytesize > @max_request_bytes
683
+
684
+ body
685
+ end
686
+
687
+ def payload_too_large_response
688
+ json_rpc_error_response(
689
+ status: 413,
690
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
691
+ message: "Payload too large: request body exceeds #{@max_request_bytes} bytes",
692
+ )
693
+ end
694
+
537
695
  def parse_request_body(body_string)
538
- JSON.parse(body_string, symbolize_names: true)
696
+ # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
697
+ # a subclass of `JSON::ParserError`, so it is caught below as a parse error.
698
+ JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
539
699
  rescue JSON::ParserError, TypeError
540
700
  raise InvalidJsonError
541
701
  end
@@ -613,7 +773,7 @@ module MCP
613
773
  handle_accepted
614
774
  end
615
775
 
616
- def handle_initialization(body_string, body)
776
+ def handle_initialization(request, body_string, body)
617
777
  session_id = nil
618
778
 
619
779
  if @stateless
@@ -622,13 +782,33 @@ module MCP
622
782
  session_id = SecureRandom.uuid
623
783
  server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
624
784
 
625
- @mutex.synchronize do
785
+ # Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted.
786
+ # The check and insert share the mutex so concurrent initializes cannot race past the limit.
787
+ reclaimed = []
788
+ inserted = @mutex.synchronize do
789
+ # When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected,
790
+ # so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired.
791
+ if @max_sessions && @sessions.size >= @max_sessions
792
+ @sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id|
793
+ cleanup_and_collect_stream(id, reclaimed)
794
+ end
795
+ end
796
+
797
+ next false if @max_sessions && @sessions.size >= @max_sessions
798
+
626
799
  @sessions[session_id] = {
627
800
  get_sse_stream: nil,
628
801
  server_session: server_session,
629
802
  last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
803
+ # Captured for the built-in Origin-consistency defense in `validate_session_request`.
804
+ # Not authentication.
805
+ origin: request.env["HTTP_ORIGIN"],
630
806
  }
807
+ true
631
808
  end
809
+
810
+ reclaimed.each { |stream| close_stream_safely(stream) }
811
+ return too_many_sessions_response unless inserted
632
812
  end
633
813
 
634
814
  response = server_session.handle_json(body_string)
@@ -655,6 +835,14 @@ module MCP
655
835
  [202, {}, []]
656
836
  end
657
837
 
838
+ def too_many_sessions_response
839
+ json_rpc_error_response(
840
+ status: 503,
841
+ code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
842
+ message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached",
843
+ )
844
+ end
845
+
658
846
  def handle_regular_request(body_string, session_id, related_request_id: nil)
659
847
  server_session = nil
660
848
 
@@ -808,6 +996,74 @@ module MCP
808
996
  active
809
997
  end
810
998
 
999
+ # Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
1000
+ # to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
1001
+ # pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
1002
+ # performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`,
1003
+ # and the `Origin` header, when present, must be same-origin or in `allowed_origins:`.
1004
+ def validate_dns_rebinding(request)
1005
+ return unless @dns_rebinding_protection
1006
+
1007
+ validate_host(request) || validate_origin(request)
1008
+ end
1009
+
1010
+ # Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
1011
+ # A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
1012
+ def validate_host(request)
1013
+ host = request.env["HTTP_HOST"]
1014
+ return if host.nil?
1015
+
1016
+ # An `allowed_hosts:` entry matches either the bare host name (any port)
1017
+ # or the full `host:port` value, so both `"app.example.com"` and
1018
+ # `"app.example.com:8443"` can be configured.
1019
+ normalized = host.downcase
1020
+ return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized)
1021
+
1022
+ forbidden_response("Forbidden: Invalid Host header")
1023
+ end
1024
+
1025
+ # A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
1026
+ # rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
1027
+ def validate_origin(request)
1028
+ origin = request.env["HTTP_ORIGIN"]
1029
+ return if origin.nil?
1030
+ return if same_origin?(origin, request)
1031
+ return if @allowed_origins.include?(origin.downcase)
1032
+
1033
+ forbidden_response("Forbidden: Invalid Origin header")
1034
+ end
1035
+
1036
+ # Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets
1037
+ # (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`).
1038
+ def request_hostname(host)
1039
+ return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[")
1040
+
1041
+ host.split(":").first
1042
+ end
1043
+
1044
+ # Compares the `Origin` authority (host:port) against the request's own `Host`.
1045
+ # Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies),
1046
+ # but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
1047
+ # both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
1048
+ def same_origin?(origin, request)
1049
+ host = request.env["HTTP_HOST"]
1050
+ return false if host.nil?
1051
+
1052
+ normalized = origin.downcase
1053
+ default_port = normalized.start_with?("https://") ? ":443" : ":80"
1054
+ authority = normalized.sub(%r{\Ahttps?://}, "")
1055
+
1056
+ authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
1057
+ end
1058
+
1059
+ def forbidden_response(message = "Forbidden: session request validation failed")
1060
+ json_rpc_error_response(
1061
+ status: 403,
1062
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1063
+ message: message,
1064
+ )
1065
+ end
1066
+
811
1067
  def method_not_allowed_response
812
1068
  json_rpc_error_response(
813
1069
  status: 405,
data/lib/mcp/server.rb CHANGED
@@ -256,6 +256,9 @@ module MCP
256
256
  report_exception(e, { notification: "resources_list_changed" })
257
257
  end
258
258
 
259
+ # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
260
+ # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
261
+ # Use stderr or OpenTelemetry instead.
259
262
  def notify_log_message(data:, level:, logger: nil)
260
263
  return unless @transport
261
264
  return unless logging_message_notification&.should_notify?(level)
@@ -272,6 +275,10 @@ module MCP
272
275
  # Called when a client notifies the server that its filesystem roots have changed.
273
276
  #
274
277
  # @yield [params] The notification params (typically `nil`).
278
+ # @deprecated MCP Roots (`roots/list` and
279
+ # `notifications/roots/list_changed`) is deprecated as of MCP protocol
280
+ # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
281
+ # server configuration, or environment variables instead.
275
282
  def roots_list_changed_handler(&block)
276
283
  @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
277
284
  end
@@ -423,6 +430,13 @@ module MCP
423
430
  end
424
431
 
425
432
  def handle_request(request, method, session: nil, related_request_id: nil)
433
+ # A well-formed notification carries no JSON-RPC id and receives no response.
434
+ # If a client erroneously sends a notification-only method with an id, the message
435
+ # is framed as a request; since notification methods have no request handler,
436
+ # returning `nil` here makes `JsonRpcHandler` report "Method not found", matching
437
+ # the TypeScript and Python SDKs rather than emitting a spurious `result: null`.
438
+ return if Methods.notification?(method) && !related_request_id.nil?
439
+
426
440
  # `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
427
441
  # and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
428
442
  # and rescue blocks below.
@@ -35,6 +35,9 @@ module MCP
35
35
  # @param data [Object] The log data to send.
36
36
  # @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`).
37
37
  # @param logger [String, nil] Logger name.
38
+ # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
39
+ # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
40
+ # Use stderr or OpenTelemetry instead.
38
41
  def notify_log_message(data:, level:, logger: nil)
39
42
  return unless @notification_target
40
43
 
@@ -51,6 +54,10 @@ module MCP
51
54
  end
52
55
 
53
56
  # Delegates to the session so the request is scoped to the originating client.
57
+ # @deprecated MCP Roots (`roots/list` and
58
+ # `notifications/roots/list_changed`) is deprecated as of MCP protocol
59
+ # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
60
+ # server configuration, or environment variables instead.
54
61
  def list_roots
55
62
  if @notification_target.respond_to?(:list_roots)
56
63
  @notification_target.list_roots(related_request_id: @related_request_id)
@@ -84,6 +91,9 @@ module MCP
84
91
  # Delegates to the session so the request is scoped to the originating client.
85
92
  # Falls back to `@context` (via `method_missing`) when `@notification_target`
86
93
  # does not support sampling.
94
+ # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
95
+ # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
96
+ # APIs instead.
87
97
  def create_sampling_message(**kwargs)
88
98
  if @notification_target.respond_to?(:create_sampling_message)
89
99
  @notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id)
@@ -98,6 +98,10 @@ module MCP
98
98
  end
99
99
 
100
100
  # Sends a `roots/list` request scoped to this session.
101
+ # @deprecated MCP Roots (`roots/list` and
102
+ # `notifications/roots/list_changed`) is deprecated as of MCP protocol
103
+ # version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
104
+ # server configuration, or environment variables instead.
101
105
  def list_roots(related_request_id: nil)
102
106
  unless client_capabilities&.dig(:roots)
103
107
  raise "Client does not support roots."
@@ -115,6 +119,9 @@ module MCP
115
119
  end
116
120
 
117
121
  # Sends a `sampling/createMessage` request scoped to this session.
122
+ # @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
123
+ # MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
124
+ # APIs instead.
118
125
  def create_sampling_message(related_request_id: nil, **kwargs)
119
126
  params = @server.build_sampling_params(client_capabilities, **kwargs)
120
127
  send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
@@ -188,6 +195,9 @@ module MCP
188
195
  end
189
196
 
190
197
  # Sends a log message notification to this session only.
198
+ # @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
199
+ # is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
200
+ # Use stderr or OpenTelemetry instead.
191
201
  def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
192
202
  effective_logging = @logging_message_notification || @server.logging_message_notification
193
203
  return unless effective_logging&.should_notify?(level)
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.22.0"
4
+ VERSION = "0.23.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.0
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -90,7 +90,7 @@ licenses:
90
90
  - Apache-2.0
91
91
  metadata:
92
92
  allowed_push_host: https://rubygems.org
93
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.22.0
93
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.23.0
94
94
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
95
95
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
96
96
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues