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.
- checksums.yaml +4 -4
- data/README.md +324 -10
- data/lib/json_rpc_handler.rb +17 -0
- data/lib/mcp/apps.rb +109 -0
- data/lib/mcp/client/elicitation.rb +51 -0
- data/lib/mcp/client/http.rb +326 -31
- data/lib/mcp/client/oauth/client_credentials_provider.rb +72 -14
- data/lib/mcp/client/oauth/flow.rb +148 -5
- data/lib/mcp/client/oauth/in_memory_storage.rb +3 -1
- data/lib/mcp/client/oauth/jwt_client_assertion.rb +128 -0
- data/lib/mcp/client/oauth/provider.rb +11 -3
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/client/paginated_result.rb +7 -5
- data/lib/mcp/client/stdio.rb +47 -11
- data/lib/mcp/client.rb +89 -1
- data/lib/mcp/error_codes.rb +21 -0
- data/lib/mcp/methods.rb +7 -1
- data/lib/mcp/resource.rb +135 -0
- data/lib/mcp/resource_template.rb +149 -0
- data/lib/mcp/result_type.rb +18 -0
- data/lib/mcp/server/transports/stdio_transport.rb +51 -3
- data/lib/mcp/server/transports/streamable_http_transport.rb +385 -94
- data/lib/mcp/server.rb +205 -20
- data/lib/mcp/server_context.rb +22 -0
- data/lib/mcp/server_session.rb +50 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +7 -2
|
@@ -24,17 +24,97 @@ module MCP
|
|
|
24
24
|
"Connection" => "keep-alive",
|
|
25
25
|
}.freeze
|
|
26
26
|
|
|
27
|
-
|
|
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
|
-
@
|
|
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)
|
|
@@ -96,80 +196,107 @@ module MCP
|
|
|
96
196
|
}
|
|
97
197
|
notification[:params] = params if params
|
|
98
198
|
|
|
199
|
+
if session_id
|
|
200
|
+
deliver_targeted_notification(notification, session_id, related_request_id)
|
|
201
|
+
else
|
|
202
|
+
deliver_broadcast_notification(notification)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def deliver_targeted_notification(notification, session_id, related_request_id)
|
|
207
|
+
# JSON response mode returns a single JSON object as the POST response,
|
|
208
|
+
# so request-scoped notifications (e.g. progress, log) cannot be delivered
|
|
209
|
+
# alongside it. Session-scoped standalone notifications
|
|
210
|
+
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
|
|
211
|
+
return false if @enable_json_response && related_request_id
|
|
212
|
+
|
|
99
213
|
streams_to_close = []
|
|
100
214
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
215
|
+
# Resolve the target stream under the lock, then write outside it: a stalled SSE reader
|
|
216
|
+
# must not block every other session that needs `@mutex`.
|
|
217
|
+
stream = @mutex.synchronize do
|
|
218
|
+
next unless (session = @sessions[session_id])
|
|
219
|
+
|
|
220
|
+
if session_expired?(session)
|
|
221
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
222
|
+
next
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
active_stream(session, related_request_id: related_request_id)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
close_streams(streams_to_close)
|
|
229
|
+
return false unless stream
|
|
230
|
+
|
|
231
|
+
write_notification(stream, notification, session_id, related_request_id)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def deliver_broadcast_notification(notification)
|
|
235
|
+
streams_to_close = []
|
|
236
|
+
|
|
237
|
+
# Snapshot the connected streams under the lock, then write to each outside it.
|
|
238
|
+
targets = @mutex.synchronize do
|
|
239
|
+
expired_session_ids = []
|
|
240
|
+
|
|
241
|
+
collected = @sessions.filter_map do |session_id, session|
|
|
242
|
+
next unless (stream = session[:get_sse_stream])
|
|
114
243
|
|
|
115
244
|
if session_expired?(session)
|
|
116
|
-
|
|
117
|
-
next
|
|
245
|
+
expired_session_ids << session_id
|
|
246
|
+
next
|
|
118
247
|
end
|
|
119
248
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
true
|
|
123
|
-
rescue *STREAM_WRITE_ERRORS => e
|
|
124
|
-
MCP.configuration.exception_reporter.call(
|
|
125
|
-
e,
|
|
126
|
-
{ session_id: session_id, error: "Failed to send notification" },
|
|
127
|
-
)
|
|
128
|
-
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
|
|
129
|
-
session[:post_request_streams].delete(related_request_id)
|
|
130
|
-
streams_to_close << stream
|
|
131
|
-
else
|
|
132
|
-
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
133
|
-
end
|
|
134
|
-
false
|
|
135
|
-
end
|
|
136
|
-
else
|
|
137
|
-
# Broadcast to all connected SSE sessions
|
|
138
|
-
sent_count = 0
|
|
139
|
-
failed_sessions = []
|
|
249
|
+
[session_id, stream]
|
|
250
|
+
end
|
|
140
251
|
|
|
141
|
-
|
|
142
|
-
|
|
252
|
+
expired_session_ids.each do |session_id|
|
|
253
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
254
|
+
end
|
|
143
255
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
next
|
|
147
|
-
end
|
|
256
|
+
collected
|
|
257
|
+
end
|
|
148
258
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
259
|
+
close_streams(streams_to_close)
|
|
260
|
+
|
|
261
|
+
targets.count do |session_id, stream|
|
|
262
|
+
write_notification(stream, notification, session_id, nil)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Writes a notification to an SSE stream without holding `@mutex`. On a write error,
|
|
267
|
+
# drops the broken stream and returns false; on success returns true.
|
|
268
|
+
def write_notification(stream, notification, session_id, related_request_id)
|
|
269
|
+
send_to_stream(stream, notification)
|
|
270
|
+
true
|
|
271
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
272
|
+
MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" })
|
|
273
|
+
drop_broken_stream(session_id, stream, related_request_id)
|
|
274
|
+
false
|
|
275
|
+
end
|
|
160
276
|
|
|
161
|
-
|
|
162
|
-
|
|
277
|
+
# Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
|
|
278
|
+
# a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
|
|
279
|
+
# under `@mutex`, and the affected streams are closed outside it.
|
|
280
|
+
def drop_broken_stream(session_id, stream, related_request_id)
|
|
281
|
+
streams_to_close = []
|
|
163
282
|
|
|
164
|
-
|
|
283
|
+
@mutex.synchronize do
|
|
284
|
+
session = @sessions[session_id]
|
|
285
|
+
if related_request_id && session&.dig(:post_request_streams, related_request_id)
|
|
286
|
+
session[:post_request_streams].delete(related_request_id)
|
|
287
|
+
streams_to_close << stream
|
|
288
|
+
else
|
|
289
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
165
290
|
end
|
|
166
291
|
end
|
|
167
292
|
|
|
168
|
-
streams_to_close
|
|
293
|
+
close_streams(streams_to_close)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def close_streams(streams)
|
|
297
|
+
streams.each do |stream|
|
|
169
298
|
close_stream_safely(stream)
|
|
170
299
|
end
|
|
171
|
-
|
|
172
|
-
result
|
|
173
300
|
end
|
|
174
301
|
|
|
175
302
|
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
|
|
@@ -199,27 +326,25 @@ module MCP
|
|
|
199
326
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
200
327
|
request[:params] = params if params
|
|
201
328
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
@mutex.synchronize do
|
|
329
|
+
# Register the pending response and resolve the stream under the lock, but perform
|
|
330
|
+
# the write outside it so a stalled reader cannot block every other session on `@mutex`.
|
|
331
|
+
stream = @mutex.synchronize do
|
|
205
332
|
unless (session = @sessions[session_id])
|
|
206
333
|
raise "Session not found: #{session_id}."
|
|
207
334
|
end
|
|
208
335
|
|
|
209
336
|
@pending_responses[request_id] = { queue: queue, session_id: session_id }
|
|
210
337
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
end
|
|
222
|
-
end
|
|
338
|
+
active_stream(session, related_request_id: related_request_id)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
sent = false
|
|
342
|
+
if stream
|
|
343
|
+
begin
|
|
344
|
+
send_to_stream(stream, request)
|
|
345
|
+
sent = true
|
|
346
|
+
rescue *STREAM_WRITE_ERRORS
|
|
347
|
+
drop_broken_stream(session_id, stream, related_request_id)
|
|
223
348
|
end
|
|
224
349
|
end
|
|
225
350
|
|
|
@@ -340,7 +465,9 @@ module MCP
|
|
|
340
465
|
content_type_error = validate_content_type(request)
|
|
341
466
|
return content_type_error if content_type_error
|
|
342
467
|
|
|
343
|
-
body_string = request
|
|
468
|
+
body_string = read_bounded_body(request)
|
|
469
|
+
return payload_too_large_response if body_string.nil?
|
|
470
|
+
|
|
344
471
|
session_id = extract_session_id(request)
|
|
345
472
|
|
|
346
473
|
begin
|
|
@@ -359,7 +486,9 @@ module MCP
|
|
|
359
486
|
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
|
|
360
487
|
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
|
|
361
488
|
# This matches the TypeScript and Python SDKs.
|
|
362
|
-
|
|
489
|
+
# `server/discover` (SEP-2575) is likewise exempt: it is sessionless capability discovery that
|
|
490
|
+
# happens before (or instead of) negotiation.
|
|
491
|
+
unless initialize_request?(body) || discover_request?(body)
|
|
363
492
|
return missing_session_id_response if !@stateless && !session_id
|
|
364
493
|
|
|
365
494
|
protocol_version_error = validate_protocol_version_header(request)
|
|
@@ -378,16 +507,25 @@ module MCP
|
|
|
378
507
|
return session_not_found_response
|
|
379
508
|
end
|
|
380
509
|
|
|
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)
|
|
510
|
+
handle_initialization(request, body_string, body)
|
|
389
511
|
else
|
|
390
|
-
|
|
512
|
+
# Ownership gate for every request against an existing session, applied uniformly to notifications, client responses,
|
|
513
|
+
# and regular requests. This covers write paths beyond tool calls - notably `notifications/cancelled`, which would
|
|
514
|
+
# otherwise let a stolen session ID cancel a victim's in-flight request. `initialize` is exempt (it establishes the session).
|
|
515
|
+
if !@stateless && session_id && !validate_session_request(request, session_id)
|
|
516
|
+
return forbidden_response
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
if notification?(body)
|
|
520
|
+
dispatch_notification(body_string, session_id)
|
|
521
|
+
handle_accepted
|
|
522
|
+
elsif response?(body)
|
|
523
|
+
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
524
|
+
|
|
525
|
+
handle_response(body, session_id: session_id)
|
|
526
|
+
else
|
|
527
|
+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
|
528
|
+
end
|
|
391
529
|
end
|
|
392
530
|
rescue StandardError => e
|
|
393
531
|
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
@@ -412,6 +550,7 @@ module MCP
|
|
|
412
550
|
|
|
413
551
|
error_response = validate_and_touch_session(session_id)
|
|
414
552
|
return error_response if error_response
|
|
553
|
+
return forbidden_response unless validate_session_request(request, session_id)
|
|
415
554
|
|
|
416
555
|
protocol_version_error = validate_protocol_version_header(request)
|
|
417
556
|
return protocol_version_error if protocol_version_error
|
|
@@ -434,6 +573,7 @@ module MCP
|
|
|
434
573
|
|
|
435
574
|
return missing_session_id_response unless (session_id = extract_session_id(request))
|
|
436
575
|
return session_not_found_response unless session_exists?(session_id)
|
|
576
|
+
return forbidden_response unless validate_session_request(request, session_id)
|
|
437
577
|
|
|
438
578
|
protocol_version_error = validate_protocol_version_header(request)
|
|
439
579
|
return protocol_version_error if protocol_version_error
|
|
@@ -495,6 +635,27 @@ module MCP
|
|
|
495
635
|
request.env["HTTP_MCP_SESSION_ID"]
|
|
496
636
|
end
|
|
497
637
|
|
|
638
|
+
# Session-ownership gate for requests against an existing session (the spec's session-binding guidance).
|
|
639
|
+
# The session ID alone is unguessable but not proof of ownership, so a stolen ID must not silently grant access.
|
|
640
|
+
# Two layers, both returning `false` to trigger a 403:
|
|
641
|
+
#
|
|
642
|
+
# - Built-in Origin consistency (defense in depth, not authentication): if the session recorded an `Origin`
|
|
643
|
+
# at `initialize` and this request carries a different one, reject. Both must be present to compare,
|
|
644
|
+
# so non-browser clients that send no `Origin` are unaffected.
|
|
645
|
+
# - The application-supplied `session_request_validator`, which can enforce true ownership when it has
|
|
646
|
+
# an authenticated principal.
|
|
647
|
+
def validate_session_request(request, session_id)
|
|
648
|
+
session = @mutex.synchronize { @sessions[session_id] }
|
|
649
|
+
return true unless session
|
|
650
|
+
|
|
651
|
+
session_origin = session[:origin]
|
|
652
|
+
request_origin = request.env["HTTP_ORIGIN"]
|
|
653
|
+
return false if session_origin && request_origin && session_origin != request_origin
|
|
654
|
+
return @session_request_validator.call(request, session_id) if @session_request_validator
|
|
655
|
+
|
|
656
|
+
true
|
|
657
|
+
end
|
|
658
|
+
|
|
498
659
|
def validate_accept_header(request, required_types)
|
|
499
660
|
accept_header = request.env["HTTP_ACCEPT"]
|
|
500
661
|
return not_acceptable_response(required_types) unless accept_header
|
|
@@ -534,8 +695,34 @@ module MCP
|
|
|
534
695
|
)
|
|
535
696
|
end
|
|
536
697
|
|
|
698
|
+
# Reads the request body with a hard byte cap so an unbounded POST cannot exhaust
|
|
699
|
+
# memory. A declared `Content-Length` over the cap is rejected
|
|
700
|
+
# without reading; the actual read is also bounded to one byte past the cap, so
|
|
701
|
+
# a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught.
|
|
702
|
+
# Returns `nil` when the body exceeds the cap.
|
|
703
|
+
def read_bounded_body(request)
|
|
704
|
+
content_length = request.content_length
|
|
705
|
+
return if content_length && content_length.to_i > @max_request_bytes
|
|
706
|
+
|
|
707
|
+
body = request.body.read(@max_request_bytes + 1)
|
|
708
|
+
return "" if body.nil?
|
|
709
|
+
return if body.bytesize > @max_request_bytes
|
|
710
|
+
|
|
711
|
+
body
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def payload_too_large_response
|
|
715
|
+
json_rpc_error_response(
|
|
716
|
+
status: 413,
|
|
717
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
718
|
+
message: "Payload too large: request body exceeds #{@max_request_bytes} bytes",
|
|
719
|
+
)
|
|
720
|
+
end
|
|
721
|
+
|
|
537
722
|
def parse_request_body(body_string)
|
|
538
|
-
|
|
723
|
+
# `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`,
|
|
724
|
+
# a subclass of `JSON::ParserError`, so it is caught below as a parse error.
|
|
725
|
+
JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING)
|
|
539
726
|
rescue JSON::ParserError, TypeError
|
|
540
727
|
raise InvalidJsonError
|
|
541
728
|
end
|
|
@@ -552,6 +739,10 @@ module MCP
|
|
|
552
739
|
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
|
|
553
740
|
end
|
|
554
741
|
|
|
742
|
+
def discover_request?(body)
|
|
743
|
+
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
|
|
744
|
+
end
|
|
745
|
+
|
|
555
746
|
def validate_protocol_version_header(request)
|
|
556
747
|
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
|
|
557
748
|
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
|
@@ -613,7 +804,7 @@ module MCP
|
|
|
613
804
|
handle_accepted
|
|
614
805
|
end
|
|
615
806
|
|
|
616
|
-
def handle_initialization(body_string, body)
|
|
807
|
+
def handle_initialization(request, body_string, body)
|
|
617
808
|
session_id = nil
|
|
618
809
|
|
|
619
810
|
if @stateless
|
|
@@ -622,13 +813,33 @@ module MCP
|
|
|
622
813
|
session_id = SecureRandom.uuid
|
|
623
814
|
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
|
624
815
|
|
|
625
|
-
|
|
816
|
+
# Cap the concurrent session count so an `initialize` flood cannot retain unbounded sessions until memory is exhausted.
|
|
817
|
+
# The check and insert share the mutex so concurrent initializes cannot race past the limit.
|
|
818
|
+
reclaimed = []
|
|
819
|
+
inserted = @mutex.synchronize do
|
|
820
|
+
# When at the cap, first reclaim slots held by already-expired sessions the 60s reaper has not yet collected,
|
|
821
|
+
# so the cap rejects only when genuinely full rather than up to a reaper interval after sessions expired.
|
|
822
|
+
if @max_sessions && @sessions.size >= @max_sessions
|
|
823
|
+
@sessions.each_key.select { |id| session_expired?(@sessions[id]) }.each do |id|
|
|
824
|
+
cleanup_and_collect_stream(id, reclaimed)
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
next false if @max_sessions && @sessions.size >= @max_sessions
|
|
829
|
+
|
|
626
830
|
@sessions[session_id] = {
|
|
627
831
|
get_sse_stream: nil,
|
|
628
832
|
server_session: server_session,
|
|
629
833
|
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
834
|
+
# Captured for the built-in Origin-consistency defense in `validate_session_request`.
|
|
835
|
+
# Not authentication.
|
|
836
|
+
origin: request.env["HTTP_ORIGIN"],
|
|
630
837
|
}
|
|
838
|
+
true
|
|
631
839
|
end
|
|
840
|
+
|
|
841
|
+
reclaimed.each { |stream| close_stream_safely(stream) }
|
|
842
|
+
return too_many_sessions_response unless inserted
|
|
632
843
|
end
|
|
633
844
|
|
|
634
845
|
response = server_session.handle_json(body_string)
|
|
@@ -655,6 +866,14 @@ module MCP
|
|
|
655
866
|
[202, {}, []]
|
|
656
867
|
end
|
|
657
868
|
|
|
869
|
+
def too_many_sessions_response
|
|
870
|
+
json_rpc_error_response(
|
|
871
|
+
status: 503,
|
|
872
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
873
|
+
message: "Service unavailable: maximum concurrent sessions (#{@max_sessions}) reached",
|
|
874
|
+
)
|
|
875
|
+
end
|
|
876
|
+
|
|
658
877
|
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
659
878
|
server_session = nil
|
|
660
879
|
|
|
@@ -808,6 +1027,74 @@ module MCP
|
|
|
808
1027
|
active
|
|
809
1028
|
end
|
|
810
1029
|
|
|
1030
|
+
# Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
|
|
1031
|
+
# to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
|
|
1032
|
+
# pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
|
|
1033
|
+
# performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`,
|
|
1034
|
+
# and the `Origin` header, when present, must be same-origin or in `allowed_origins:`.
|
|
1035
|
+
def validate_dns_rebinding(request)
|
|
1036
|
+
return unless @dns_rebinding_protection
|
|
1037
|
+
|
|
1038
|
+
validate_host(request) || validate_origin(request)
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
# Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
|
|
1042
|
+
# A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
|
|
1043
|
+
def validate_host(request)
|
|
1044
|
+
host = request.env["HTTP_HOST"]
|
|
1045
|
+
return if host.nil?
|
|
1046
|
+
|
|
1047
|
+
# An `allowed_hosts:` entry matches either the bare host name (any port)
|
|
1048
|
+
# or the full `host:port` value, so both `"app.example.com"` and
|
|
1049
|
+
# `"app.example.com:8443"` can be configured.
|
|
1050
|
+
normalized = host.downcase
|
|
1051
|
+
return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized)
|
|
1052
|
+
|
|
1053
|
+
forbidden_response("Forbidden: Invalid Host header")
|
|
1054
|
+
end
|
|
1055
|
+
|
|
1056
|
+
# A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
|
|
1057
|
+
# rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
|
|
1058
|
+
def validate_origin(request)
|
|
1059
|
+
origin = request.env["HTTP_ORIGIN"]
|
|
1060
|
+
return if origin.nil?
|
|
1061
|
+
return if same_origin?(origin, request)
|
|
1062
|
+
return if @allowed_origins.include?(origin.downcase)
|
|
1063
|
+
|
|
1064
|
+
forbidden_response("Forbidden: Invalid Origin header")
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
# Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets
|
|
1068
|
+
# (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`).
|
|
1069
|
+
def request_hostname(host)
|
|
1070
|
+
return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[")
|
|
1071
|
+
|
|
1072
|
+
host.split(":").first
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# Compares the `Origin` authority (host:port) against the request's own `Host`.
|
|
1076
|
+
# Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies),
|
|
1077
|
+
# but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
|
|
1078
|
+
# both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
|
|
1079
|
+
def same_origin?(origin, request)
|
|
1080
|
+
host = request.env["HTTP_HOST"]
|
|
1081
|
+
return false if host.nil?
|
|
1082
|
+
|
|
1083
|
+
normalized = origin.downcase
|
|
1084
|
+
default_port = normalized.start_with?("https://") ? ":443" : ":80"
|
|
1085
|
+
authority = normalized.sub(%r{\Ahttps?://}, "")
|
|
1086
|
+
|
|
1087
|
+
authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
def forbidden_response(message = "Forbidden: session request validation failed")
|
|
1091
|
+
json_rpc_error_response(
|
|
1092
|
+
status: 403,
|
|
1093
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
1094
|
+
message: message,
|
|
1095
|
+
)
|
|
1096
|
+
end
|
|
1097
|
+
|
|
811
1098
|
def method_not_allowed_response
|
|
812
1099
|
json_rpc_error_response(
|
|
813
1100
|
status: 405,
|
|
@@ -902,11 +1189,15 @@ module MCP
|
|
|
902
1189
|
end
|
|
903
1190
|
|
|
904
1191
|
def send_keepalive_ping(session_id)
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1192
|
+
# Resolve the stream under the lock, then write outside it so a stalled reader
|
|
1193
|
+
# cannot block every other session on `@mutex`.
|
|
1194
|
+
stream = @mutex.synchronize do
|
|
1195
|
+
session = @sessions[session_id]
|
|
1196
|
+
session && session[:get_sse_stream]
|
|
909
1197
|
end
|
|
1198
|
+
return unless stream
|
|
1199
|
+
|
|
1200
|
+
send_ping_to_stream(stream)
|
|
910
1201
|
rescue *STREAM_WRITE_ERRORS => e
|
|
911
1202
|
MCP.configuration.exception_reporter.call(
|
|
912
1203
|
e,
|