mcp 0.25.0 → 1.4.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 +50 -2548
- data/lib/json_rpc_handler.rb +3 -2
- data/lib/mcp/client/http.rb +338 -63
- data/lib/mcp/client/mcp_param_headers.rb +242 -0
- data/lib/mcp/client/modern_envelope.rb +32 -0
- data/lib/mcp/client/oauth/bounded_body.rb +67 -0
- data/lib/mcp/client/oauth/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +139 -17
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/client/stdio.rb +243 -66
- data/lib/mcp/client/tool.rb +3 -2
- data/lib/mcp/client.rb +364 -41
- data/lib/mcp/configuration.rb +84 -7
- data/lib/mcp/elicitation/enum_schema.rb +121 -0
- data/lib/mcp/elicitation.rb +10 -0
- data/lib/mcp/error_codes.rb +14 -8
- data/lib/mcp/instrumentation.rb +6 -0
- data/lib/mcp/methods.rb +21 -0
- data/lib/mcp/prompt.rb +8 -1
- data/lib/mcp/protocol_deprecations.rb +61 -0
- data/lib/mcp/request_envelope.rb +117 -0
- data/lib/mcp/server/input_required_result.rb +163 -0
- data/lib/mcp/server/pending_response.rb +62 -0
- data/lib/mcp/server/request_state_security.rb +131 -0
- data/lib/mcp/server/transports/stdio_transport.rb +39 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +820 -24
- data/lib/mcp/server.rb +623 -54
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +104 -20
- data/lib/mcp/tool/response.rb +8 -2
- data/lib/mcp/transport.rb +7 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +12 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "../../result_type"
|
|
4
5
|
require_relative "../../transport"
|
|
5
6
|
|
|
6
7
|
# This file is autoloaded only when `StreamableHTTPTransport` is referenced,
|
|
@@ -18,10 +19,16 @@ module MCP
|
|
|
18
19
|
class StreamableHTTPTransport < Transport
|
|
19
20
|
class InvalidJsonError < StandardError; end
|
|
20
21
|
|
|
22
|
+
# `x-accel-buffering: no` tells reverse proxies (nginx and friends) not to buffer the response,
|
|
23
|
+
# which the spec asks of every SSE stream: a buffering proxy holds events back instead of
|
|
24
|
+
# delivering them as they are written, and on a long-lived `subscriptions/listen` stream that
|
|
25
|
+
# also swallows the keepalive frames a dropped peer would otherwise be detected by.
|
|
26
|
+
# The TypeScript and Python SDKs send it on their SSE responses for the same reason.
|
|
21
27
|
SSE_HEADERS = {
|
|
22
28
|
"content-type" => "text/event-stream",
|
|
23
29
|
"cache-control" => "no-cache",
|
|
24
30
|
"connection" => "keep-alive",
|
|
31
|
+
"x-accel-buffering" => "no",
|
|
25
32
|
}.freeze
|
|
26
33
|
|
|
27
34
|
# Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
|
|
@@ -35,10 +42,31 @@ module MCP
|
|
|
35
42
|
DEFAULT_SESSION_IDLE_TIMEOUT = 1800
|
|
36
43
|
DEFAULT_MAX_SESSIONS = 10_000
|
|
37
44
|
|
|
45
|
+
# Cap on concurrent `subscriptions/listen` streams (SEP-2575). Each stream holds an open SSE connection
|
|
46
|
+
# for its lifetime, so without a bound an unauthenticated client can retain unbounded connections,
|
|
47
|
+
# like the session-flood case `DEFAULT_MAX_SESSIONS` guards. A listen request past the cap is rejected with HTTP 503;
|
|
48
|
+
# pass `max_listen_subscriptions: nil` to opt out.
|
|
49
|
+
DEFAULT_MAX_LISTEN_SUBSCRIPTIONS = 1_000
|
|
50
|
+
|
|
38
51
|
# Distinguishes "argument omitted, apply the secure default" from an explicit `nil` (opt out of expiry).
|
|
39
52
|
UNSET_IDLE_TIMEOUT = Object.new.freeze
|
|
40
53
|
private_constant :UNSET_IDLE_TIMEOUT
|
|
41
54
|
|
|
55
|
+
# Default deadline in seconds for a server-to-client request (sampling, elicitation, `roots/list`, `ping`).
|
|
56
|
+
# The spec asks implementations to bound every sent request so a peer that never answers cannot exhaust
|
|
57
|
+
# the sender's resources; without one, a client that opens a session and simply never replies parks
|
|
58
|
+
# a worker thread for good.
|
|
59
|
+
#
|
|
60
|
+
# Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds
|
|
61
|
+
# for the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default
|
|
62
|
+
# is wrong". Every request this transport can send is that kind of leg: someone answering an elicitation
|
|
63
|
+
# prompt, or the client's own model producing a sample. (The Python SDK leaves the deadline unset and bounds
|
|
64
|
+
# nothing by default.) Deployments that want a tighter bound pass a smaller value here; a single handler
|
|
65
|
+
# that legitimately waits longer passes `timeout:`.
|
|
66
|
+
#
|
|
67
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
|
|
68
|
+
DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT = 600
|
|
69
|
+
|
|
42
70
|
# Default upper bound on the JSON-RPC request body. `handle_post` reads the whole
|
|
43
71
|
# body into memory and parses it, so without a cap a single unauthenticated POST
|
|
44
72
|
# can allocate gigabytes and OOM the worker. 4 MiB comfortably
|
|
@@ -51,6 +79,20 @@ module MCP
|
|
|
51
79
|
# the stack or amplify parse cost (complements the byte cap).
|
|
52
80
|
MAX_JSON_NESTING = 64
|
|
53
81
|
|
|
82
|
+
# Cap on the notifications buffered for one modern request (SEP-2575). The sink holds them
|
|
83
|
+
# in memory until the handler returns, so a handler that emits notifications in proportion to
|
|
84
|
+
# client-supplied input would otherwise let one request grow memory without bound.
|
|
85
|
+
# Notifications past the cap are not delivered and the notify helpers report `false`,
|
|
86
|
+
# the same non-delivery degradation they have on every other undeliverable path.
|
|
87
|
+
MAX_MODERN_REQUEST_NOTIFICATIONS = 1_000
|
|
88
|
+
|
|
89
|
+
# Interval in seconds between SSE keepalive comment frames on a `subscriptions/listen` stream.
|
|
90
|
+
# Without them a silently dropped connection holds its slot until the next fan-out write fails,
|
|
91
|
+
# so on a quiet server a dead peer would occupy a `max_listen_subscriptions` slot indefinitely.
|
|
92
|
+
# The periodic write detects the dead peer and frees the slot. Matches the TypeScript SDK's
|
|
93
|
+
# 15-second default; pass `listen_keepalive_interval: nil` when an upstream proxy pings the stream.
|
|
94
|
+
DEFAULT_LISTEN_KEEPALIVE_INTERVAL = 15
|
|
95
|
+
|
|
54
96
|
# Creates a Streamable HTTP transport that can be mounted as a Rack app.
|
|
55
97
|
#
|
|
56
98
|
# @param server [MCP::Server] the server whose requests this transport dispatches.
|
|
@@ -80,6 +122,22 @@ module MCP
|
|
|
80
122
|
# ownership is not enforced.
|
|
81
123
|
# @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger
|
|
82
124
|
# requests are rejected with HTTP 413. Defaults to 4 MiB.
|
|
125
|
+
# @param max_listen_subscriptions [Integer, nil] cap on concurrent `subscriptions/listen`
|
|
126
|
+
# streams; a listen request past the cap is rejected with HTTP 503, and `nil` disables
|
|
127
|
+
# the cap.
|
|
128
|
+
# @param listen_keepalive_interval [Numeric, nil] seconds between SSE keepalive comment frames
|
|
129
|
+
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
|
|
130
|
+
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
|
|
131
|
+
# when an upstream proxy already keeps the stream alive.
|
|
132
|
+
# @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream.
|
|
133
|
+
# A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern,
|
|
134
|
+
# which builds a fresh transport per request and renders the body) passes `false`:
|
|
135
|
+
# the method then answers 404 with JSON-RPC `-32601` like any unimplemented method,
|
|
136
|
+
# and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags,
|
|
137
|
+
# keeping the advertisement and the actual behavior in agreement. Defaults to `true`.
|
|
138
|
+
# @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
|
|
139
|
+
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
|
|
140
|
+
# Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
|
|
83
141
|
def initialize(
|
|
84
142
|
server,
|
|
85
143
|
stateless: false,
|
|
@@ -90,7 +148,11 @@ module MCP
|
|
|
90
148
|
allowed_hosts: nil,
|
|
91
149
|
dns_rebinding_protection: true,
|
|
92
150
|
session_request_validator: nil,
|
|
93
|
-
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
|
|
151
|
+
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
|
152
|
+
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
|
|
153
|
+
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
|
154
|
+
serve_subscriptions_listen: true,
|
|
155
|
+
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
|
|
94
156
|
)
|
|
95
157
|
super(server)
|
|
96
158
|
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
|
|
@@ -107,6 +169,16 @@ module MCP
|
|
|
107
169
|
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
|
|
108
170
|
@pending_responses = {}
|
|
109
171
|
|
|
172
|
+
# Maps a `subscriptions/listen` request id to
|
|
173
|
+
# `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575).
|
|
174
|
+
# In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
|
|
175
|
+
# which is a follow-up.
|
|
176
|
+
@listen_subscriptions = {}
|
|
177
|
+
|
|
178
|
+
# Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits;
|
|
179
|
+
# `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575).
|
|
180
|
+
@modern_request_sinks = {}
|
|
181
|
+
|
|
110
182
|
# Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
|
|
111
183
|
# which does not apply to stateless mode since it retains no sessions.
|
|
112
184
|
@session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
|
|
@@ -136,6 +208,25 @@ module MCP
|
|
|
136
208
|
|
|
137
209
|
@max_request_bytes = max_request_bytes
|
|
138
210
|
|
|
211
|
+
if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
|
|
212
|
+
raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
@max_listen_subscriptions = max_listen_subscriptions
|
|
216
|
+
|
|
217
|
+
if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
|
|
218
|
+
raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
@listen_keepalive_interval = listen_keepalive_interval
|
|
222
|
+
@serve_subscriptions_listen = serve_subscriptions_listen
|
|
223
|
+
|
|
224
|
+
unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
|
|
225
|
+
raise ArgumentError, "server_to_client_request_timeout must be a positive number"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
@server_to_client_request_timeout = server_to_client_request_timeout
|
|
229
|
+
|
|
139
230
|
start_reaper_thread if @session_idle_timeout
|
|
140
231
|
end
|
|
141
232
|
|
|
@@ -149,15 +240,82 @@ module MCP
|
|
|
149
240
|
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
|
|
150
241
|
DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
|
|
151
242
|
|
|
243
|
+
# JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575).
|
|
244
|
+
NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze
|
|
245
|
+
|
|
246
|
+
# Maps broadcast notification methods to the `SubscriptionFilter` field that opts in to them on
|
|
247
|
+
# a `subscriptions/listen` stream (SEP-2575). `notifications/resources/updated` is matched by URI
|
|
248
|
+
# against `resourceSubscriptions` instead.
|
|
249
|
+
LISTEN_FILTER_FIELDS = {
|
|
250
|
+
Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED => :toolsListChanged,
|
|
251
|
+
Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED => :promptsListChanged,
|
|
252
|
+
Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED => :resourcesListChanged,
|
|
253
|
+
}.freeze
|
|
254
|
+
|
|
255
|
+
# JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404
|
|
256
|
+
# (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors,
|
|
257
|
+
# stays 200, matching the Python SDK's status ladder.
|
|
258
|
+
MODERN_BAD_REQUEST_CODES = [
|
|
259
|
+
ErrorCodes::HEADER_MISMATCH,
|
|
260
|
+
ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
|
|
261
|
+
ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
|
|
262
|
+
JsonRpcHandler::ErrorCode::PARSE_ERROR,
|
|
263
|
+
JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
264
|
+
JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
265
|
+
].freeze
|
|
266
|
+
|
|
152
267
|
# Rack app interface. This transport can be mounted as a Rack app.
|
|
153
268
|
def call(env)
|
|
154
269
|
handle_request(Rack::Request.new(env))
|
|
155
270
|
end
|
|
156
271
|
|
|
272
|
+
# Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575).
|
|
273
|
+
# Gates both the route (a refusing transport answers the method as unimplemented) and
|
|
274
|
+
# the `listChanged`/`subscribe` capability flags `Server#discover` advertises,
|
|
275
|
+
# so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword.
|
|
276
|
+
def serves_subscriptions_listen?
|
|
277
|
+
@serve_subscriptions_listen
|
|
278
|
+
end
|
|
279
|
+
|
|
157
280
|
def handle_request(request)
|
|
158
281
|
rebinding_error = validate_dns_rebinding(request)
|
|
159
282
|
return rebinding_error if rebinding_error
|
|
160
283
|
|
|
284
|
+
# Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
|
|
285
|
+
# every supported list routes to the sessionless modern path, so an unknown future version receives
|
|
286
|
+
# the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
|
|
287
|
+
# Requests without the header, or with a stable-only version, take the existing paths untouched.
|
|
288
|
+
# An empty header value is malformed rather than a version claim, so it stays on the legacy path
|
|
289
|
+
# and fails legacy header validation as before.
|
|
290
|
+
#
|
|
291
|
+
# A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it,
|
|
292
|
+
# and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it):
|
|
293
|
+
# an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream,
|
|
294
|
+
# and DELETE termination keep working), and a session-less POST whose body is `initialize` is
|
|
295
|
+
# the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic
|
|
296
|
+
# (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
|
|
297
|
+
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
298
|
+
if header_version && !header_version.empty? && !stable_only_version?(header_version)
|
|
299
|
+
unless MCP::Configuration.modern_protocol_version?(header_version)
|
|
300
|
+
return handle_modern(request, header_version)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
if extract_session_id(request).nil?
|
|
304
|
+
return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST"
|
|
305
|
+
|
|
306
|
+
# The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here,
|
|
307
|
+
# bounded, and hands the string to whichever path serves the request.
|
|
308
|
+
body_string = read_bounded_body(request)
|
|
309
|
+
return payload_too_large_response if body_string.nil?
|
|
310
|
+
|
|
311
|
+
unless legacy_handshake_body?(body_string)
|
|
312
|
+
return handle_modern(request, header_version, body_string: body_string)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
return handle_post(request, body_string: body_string)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
161
319
|
case request.env["REQUEST_METHOD"]
|
|
162
320
|
when "POST"
|
|
163
321
|
handle_post(request)
|
|
@@ -174,6 +332,8 @@ module MCP
|
|
|
174
332
|
@reaper_thread&.kill
|
|
175
333
|
@reaper_thread = nil
|
|
176
334
|
|
|
335
|
+
teardown_listen_subscriptions
|
|
336
|
+
|
|
177
337
|
removed_sessions = @mutex.synchronize do
|
|
178
338
|
@sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
|
|
179
339
|
end
|
|
@@ -185,10 +345,11 @@ module MCP
|
|
|
185
345
|
end
|
|
186
346
|
|
|
187
347
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
|
|
348
|
+
# `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below:
|
|
349
|
+
# a resource updated by one session's tool call changed globally, so modern subscribers hear about it too.
|
|
350
|
+
# Runs before the per-request sink and the stateless guard because the listen registry does not depend on sessions,
|
|
351
|
+
# and a sink capturing the notification for its own response stream must not hide it from other subscriptions.
|
|
352
|
+
deliver_to_listen_subscriptions(method, params)
|
|
192
353
|
|
|
193
354
|
notification = {
|
|
194
355
|
jsonrpc: "2.0",
|
|
@@ -196,6 +357,24 @@ module MCP
|
|
|
196
357
|
}
|
|
197
358
|
notification[:params] = params if params
|
|
198
359
|
|
|
360
|
+
# A modern request's notifications ride its own response stream (SEP-2575): `handle_modern` registers
|
|
361
|
+
# a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response.
|
|
362
|
+
# Checked before the stateless guard, since modern requests are served in stateless deployments too.
|
|
363
|
+
# The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as
|
|
364
|
+
# non-delivery (`false`), never handed to the legacy paths below.
|
|
365
|
+
sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] }
|
|
366
|
+
if sink
|
|
367
|
+
return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS
|
|
368
|
+
|
|
369
|
+
sink << notification
|
|
370
|
+
return true
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
|
|
374
|
+
# so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
|
|
375
|
+
# a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
|
|
376
|
+
return false if @stateless
|
|
377
|
+
|
|
199
378
|
if session_id
|
|
200
379
|
deliver_targeted_notification(notification, session_id, related_request_id)
|
|
201
380
|
else
|
|
@@ -282,8 +461,13 @@ module MCP
|
|
|
282
461
|
|
|
283
462
|
@mutex.synchronize do
|
|
284
463
|
session = @sessions[session_id]
|
|
285
|
-
if related_request_id
|
|
286
|
-
|
|
464
|
+
if related_request_id
|
|
465
|
+
# Unregister only our own stream: removing on the id alone would drop whichever stream currently holds it,
|
|
466
|
+
# which is not necessarily the one that failed. The failed stream is closed either way, and a request-scoped
|
|
467
|
+
# failure never reaches the session teardown below.
|
|
468
|
+
registered = session&.dig(:post_request_streams, related_request_id)
|
|
469
|
+
session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
|
|
470
|
+
|
|
287
471
|
streams_to_close << stream
|
|
288
472
|
else
|
|
289
473
|
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
@@ -299,14 +483,28 @@ module MCP
|
|
|
299
483
|
end
|
|
300
484
|
end
|
|
301
485
|
|
|
302
|
-
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
|
|
303
|
-
#
|
|
486
|
+
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until
|
|
487
|
+
# the client responds.
|
|
488
|
+
#
|
|
489
|
+
# Uses a `PendingResponse` for cross-thread synchronization: this method registers one,
|
|
490
|
+
# sends the request via SSE stream, then waits on it. When the client POSTs a response,
|
|
491
|
+
# `handle_response` matches it by `request_id` and resolves the pending response,
|
|
492
|
+
# unblocking this thread. A cancellation and session teardown resolve it the same way.
|
|
304
493
|
#
|
|
305
|
-
#
|
|
306
|
-
#
|
|
307
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
494
|
+
# The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`),
|
|
495
|
+
# so a client that never answers cannot park the calling thread for good. On expiry the peer is
|
|
496
|
+
# sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised.
|
|
497
|
+
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil)
|
|
498
|
+
# The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests;
|
|
499
|
+
# multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached
|
|
500
|
+
# the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`,
|
|
501
|
+
# but that is incidental: without the rule stated here a modern handler that reaches for elicitation
|
|
502
|
+
# or sampling is told "Session not found: <uuid>", which points at everything except the actual reason.
|
|
503
|
+
# `StdioTransport#send_request` refuses the same way.
|
|
504
|
+
if server_session&.era == :modern
|
|
505
|
+
raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)."
|
|
506
|
+
end
|
|
507
|
+
|
|
310
508
|
if @stateless
|
|
311
509
|
raise "Stateless mode does not support server-to-client requests."
|
|
312
510
|
end
|
|
@@ -320,7 +518,8 @@ module MCP
|
|
|
320
518
|
end
|
|
321
519
|
|
|
322
520
|
request_id = generate_request_id
|
|
323
|
-
|
|
521
|
+
pending_response = PendingResponse.new
|
|
522
|
+
wait_timeout = timeout || @server_to_client_request_timeout
|
|
324
523
|
cancel_hook = nil
|
|
325
524
|
|
|
326
525
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
@@ -333,7 +532,7 @@ module MCP
|
|
|
333
532
|
raise "Session not found: #{session_id}."
|
|
334
533
|
end
|
|
335
534
|
|
|
336
|
-
@pending_responses[request_id] = { queue:
|
|
535
|
+
@pending_responses[request_id] = { queue: pending_response, session_id: session_id }
|
|
337
536
|
|
|
338
537
|
active_stream(session, related_request_id: related_request_id)
|
|
339
538
|
end
|
|
@@ -369,7 +568,27 @@ module MCP
|
|
|
369
568
|
end
|
|
370
569
|
end
|
|
371
570
|
|
|
372
|
-
response =
|
|
571
|
+
response = pending_response.pop(timeout: wait_timeout) do
|
|
572
|
+
# Expiry cancels as well as stops waiting, so a client that answers late does not act on
|
|
573
|
+
# a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here:
|
|
574
|
+
# the modern lifecycle forbids server-to-client requests outright, and its sessionless requests
|
|
575
|
+
# never register the session this method looks up. Those revisions ask the sender to "issue
|
|
576
|
+
# a cancellation notification for that request and stop waiting", letting either side send one.
|
|
577
|
+
# (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown
|
|
578
|
+
# governs the era that has no such requests to cancel.) Both reference SDKs send this same
|
|
579
|
+
# courtesy cancel on timeout.
|
|
580
|
+
server_session&.send_peer_cancellation(
|
|
581
|
+
nested_request_id: request_id,
|
|
582
|
+
related_request_id: related_request_id,
|
|
583
|
+
reason: "Timed out after #{wait_timeout} seconds",
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
raise RequestTimeoutError.new(
|
|
587
|
+
"#{method} request timed out after #{wait_timeout} seconds",
|
|
588
|
+
request_id: request_id,
|
|
589
|
+
timeout: wait_timeout,
|
|
590
|
+
)
|
|
591
|
+
end
|
|
373
592
|
|
|
374
593
|
if response.is_a?(Hash) && response.key?(:error)
|
|
375
594
|
raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
|
|
@@ -457,7 +676,510 @@ module MCP
|
|
|
457
676
|
stream.flush
|
|
458
677
|
end
|
|
459
678
|
|
|
460
|
-
|
|
679
|
+
# Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
|
|
680
|
+
# a single POST/JSON exchange with no session. The modern path never consults
|
|
681
|
+
# `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
|
|
682
|
+
# an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`)
|
|
683
|
+
# and DELETE (session termination) have no modern meaning.
|
|
684
|
+
def handle_modern(request, header_version, body_string: nil)
|
|
685
|
+
return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST"
|
|
686
|
+
|
|
687
|
+
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE)
|
|
688
|
+
return accept_error if accept_error
|
|
689
|
+
|
|
690
|
+
content_type_error = validate_content_type(request)
|
|
691
|
+
return content_type_error if content_type_error
|
|
692
|
+
|
|
693
|
+
if body_string.nil?
|
|
694
|
+
body_string = read_bounded_body(request)
|
|
695
|
+
return payload_too_large_response if body_string.nil?
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
begin
|
|
699
|
+
body = parse_request_body(body_string)
|
|
700
|
+
rescue InvalidJsonError
|
|
701
|
+
return invalid_json_response
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
unless body.is_a?(Hash)
|
|
705
|
+
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# The version check precedes everything else that depends on request content,
|
|
709
|
+
# so a client probing with an unknown future version always receives the `-32022` signal
|
|
710
|
+
# (with the supported list to select from) rather than an incidental error.
|
|
711
|
+
unless MCP::Configuration.modern_protocol_version?(header_version)
|
|
712
|
+
return json_rpc_error_response(
|
|
713
|
+
status: 400,
|
|
714
|
+
code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
|
|
715
|
+
message: "Unsupported protocol version",
|
|
716
|
+
data: {
|
|
717
|
+
supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
|
|
718
|
+
requested: header_version,
|
|
719
|
+
},
|
|
720
|
+
id: body[:id],
|
|
721
|
+
)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
if extract_session_id(request)
|
|
725
|
+
return json_rpc_error_response(
|
|
726
|
+
status: 400,
|
|
727
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
728
|
+
message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
|
|
729
|
+
id: body[:id],
|
|
730
|
+
)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
mismatch_error = validate_modern_headers(request, body, header_version)
|
|
734
|
+
return mismatch_error if mismatch_error
|
|
735
|
+
|
|
736
|
+
# `subscriptions/listen` is a long-lived notification stream served at the transport layer;
|
|
737
|
+
# it never dispatches through `Server#handle`. A transport constructed with
|
|
738
|
+
# `serve_subscriptions_listen: false` skips the interception, so the method falls through
|
|
739
|
+
# to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
|
|
740
|
+
# serve an open SSE stream needs, instead of a `Proc` body it can never call.
|
|
741
|
+
if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
|
|
742
|
+
return handle_subscriptions_listen(body)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
session = modern_session
|
|
746
|
+
notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
|
|
747
|
+
begin
|
|
748
|
+
response = @server.handle(body, session: session)
|
|
749
|
+
ensure
|
|
750
|
+
@mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
|
|
754
|
+
return handle_accepted if response.nil?
|
|
755
|
+
|
|
756
|
+
# Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
|
|
757
|
+
# the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
|
|
758
|
+
# Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
|
|
759
|
+
# (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
|
|
760
|
+
# Live streaming can follow without changing the wire shape.
|
|
761
|
+
if notifications.empty?
|
|
762
|
+
[modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
|
|
763
|
+
else
|
|
764
|
+
frames = notifications + [response]
|
|
765
|
+
sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
|
|
766
|
+
[200, SSE_HEADERS.dup, [sse_body]]
|
|
767
|
+
end
|
|
768
|
+
rescue StandardError => e
|
|
769
|
+
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
770
|
+
json_rpc_error_response(
|
|
771
|
+
status: 500,
|
|
772
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
773
|
+
message: "Internal server error",
|
|
774
|
+
)
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header
|
|
778
|
+
# MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match
|
|
779
|
+
# the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks
|
|
780
|
+
# converge on enforcement.
|
|
781
|
+
def validate_modern_headers(request, body, header_version)
|
|
782
|
+
params = body[:params]
|
|
783
|
+
meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
|
|
784
|
+
if meta_version && meta_version != header_version
|
|
785
|
+
return header_mismatch_response(
|
|
786
|
+
"MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
|
|
787
|
+
body[:id],
|
|
788
|
+
)
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
# `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods:
|
|
792
|
+
# the spec lists a missing required standard header among the validation failures that MUST be rejected,
|
|
793
|
+
# and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare":
|
|
794
|
+
# the headers exist so intermediaries can route without parsing bodies, and a client that omits them
|
|
795
|
+
# defeats that contract silently.
|
|
796
|
+
method_header = request.env["HTTP_MCP_METHOD"]
|
|
797
|
+
if method_header.to_s.empty?
|
|
798
|
+
return header_mismatch_response(
|
|
799
|
+
"Mcp-Method header is required on the modern path",
|
|
800
|
+
body[:id],
|
|
801
|
+
)
|
|
802
|
+
end
|
|
803
|
+
if method_header != body[:method]
|
|
804
|
+
return header_mismatch_response(
|
|
805
|
+
"Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
|
|
806
|
+
body[:id],
|
|
807
|
+
)
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
if NAME_BEARING_METHODS.include?(body[:method])
|
|
811
|
+
body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
|
|
812
|
+
name_header = request.env["HTTP_MCP_NAME"]
|
|
813
|
+
if body_name
|
|
814
|
+
if name_header.to_s.empty?
|
|
815
|
+
return header_mismatch_response(
|
|
816
|
+
"Mcp-Name header is required for `#{body[:method]}`",
|
|
817
|
+
body[:id],
|
|
818
|
+
)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
decoded_name = decode_header_value(name_header)
|
|
822
|
+
if decoded_name != body_name
|
|
823
|
+
return header_mismatch_response(
|
|
824
|
+
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
|
|
825
|
+
body[:id],
|
|
826
|
+
)
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
nil
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
# Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is
|
|
835
|
+
# `notifications/subscriptions/acknowledged` with the subset of requested notification types
|
|
836
|
+
# the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId`
|
|
837
|
+
# (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
|
|
838
|
+
# response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
|
|
839
|
+
# `listen_keepalive_interval` seconds so a dropped connection frees its slot.
|
|
840
|
+
def handle_subscriptions_listen(body)
|
|
841
|
+
request_id = body[:id]
|
|
842
|
+
params = body[:params]
|
|
843
|
+
|
|
844
|
+
# A listen frame without an id could never receive stream teardown correlation.
|
|
845
|
+
unless request_id
|
|
846
|
+
return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
begin
|
|
850
|
+
if RequestEnvelope.modern?(params)
|
|
851
|
+
RequestEnvelope.parse!(params, request: params)
|
|
852
|
+
else
|
|
853
|
+
return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
|
|
854
|
+
end
|
|
855
|
+
rescue Server::RequestHandlerError => e
|
|
856
|
+
return json_rpc_error_response(
|
|
857
|
+
status: 400,
|
|
858
|
+
code: e.error_code || JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
859
|
+
message: e.message,
|
|
860
|
+
data: e.error_data,
|
|
861
|
+
id: request_id,
|
|
862
|
+
)
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
filter = params[:notifications]
|
|
866
|
+
unless filter.is_a?(Hash)
|
|
867
|
+
return json_rpc_error_response(
|
|
868
|
+
status: 400,
|
|
869
|
+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
870
|
+
message: "Invalid params: subscriptions/listen requires a `notifications` filter object",
|
|
871
|
+
id: request_id,
|
|
872
|
+
)
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
# Best-effort cap check before committing to the SSE response; the registration inside
|
|
876
|
+
# `listen_sse_body` re-checks atomically for the race between two concurrent listens
|
|
877
|
+
# crossing the cap together.
|
|
878
|
+
if listen_subscriptions_full?
|
|
879
|
+
return too_many_listen_subscriptions_response(request_id)
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
[200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))]
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def listen_subscriptions_full?
|
|
886
|
+
return false unless @max_listen_subscriptions
|
|
887
|
+
|
|
888
|
+
@mutex.synchronize { @listen_subscriptions.size >= @max_listen_subscriptions }
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def too_many_listen_subscriptions_response(request_id)
|
|
892
|
+
json_rpc_error_response(
|
|
893
|
+
status: 503,
|
|
894
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
895
|
+
message: "Service unavailable: maximum concurrent subscriptions/listen streams (#{@max_listen_subscriptions}) reached",
|
|
896
|
+
id: request_id,
|
|
897
|
+
)
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
# The Rack streaming body of a `subscriptions/listen` response. It responds to `call`
|
|
901
|
+
# and deliberately not to `each`, so Rack keeps classifying it as a streaming body;
|
|
902
|
+
# `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)`
|
|
903
|
+
# in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix.
|
|
904
|
+
class ListenStreamBody
|
|
905
|
+
def initialize(&block)
|
|
906
|
+
@block = block
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def call(stream)
|
|
910
|
+
@block.call(stream)
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def first
|
|
914
|
+
raise <<~MESSAGE
|
|
915
|
+
subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \
|
|
916
|
+
A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \
|
|
917
|
+
so the method is answered as unimplemented instead.
|
|
918
|
+
See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns.
|
|
919
|
+
MESSAGE
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
private_constant :ListenStreamBody
|
|
923
|
+
|
|
924
|
+
# The body registers the stream and returns, leaving the response open like
|
|
925
|
+
# the legacy GET stream (`create_sse_body`).
|
|
926
|
+
#
|
|
927
|
+
# Registration and activation are split on purpose: the entry is inserted inactive
|
|
928
|
+
# (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock,
|
|
929
|
+
# and only then does the entry become eligible for delivery. A concurrent notification between
|
|
930
|
+
# the insert and the acknowledgement write skips the inactive entry,
|
|
931
|
+
# enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
|
|
932
|
+
def listen_sse_body(request_id, honored)
|
|
933
|
+
ListenStreamBody.new do |stream|
|
|
934
|
+
rejected = false
|
|
935
|
+
@mutex.synchronize do
|
|
936
|
+
if @listen_subscriptions.key?(request_id) ||
|
|
937
|
+
(@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
|
|
938
|
+
rejected = true
|
|
939
|
+
else
|
|
940
|
+
@listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new }
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
if rejected
|
|
945
|
+
close_stream_safely(stream)
|
|
946
|
+
else
|
|
947
|
+
acknowledgement = {
|
|
948
|
+
jsonrpc: "2.0",
|
|
949
|
+
method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
|
|
950
|
+
params: {
|
|
951
|
+
notifications: honored,
|
|
952
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
953
|
+
},
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
begin
|
|
957
|
+
send_to_stream(stream, acknowledgement)
|
|
958
|
+
activate_listen_subscription(request_id)
|
|
959
|
+
start_listen_keepalive_thread(request_id)
|
|
960
|
+
rescue *STREAM_WRITE_ERRORS
|
|
961
|
+
remove_listen_subscription(request_id)
|
|
962
|
+
close_stream_safely(stream)
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
# Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
|
|
969
|
+
# The entry may already be gone when the transport closed concurrently.
|
|
970
|
+
def activate_listen_subscription(request_id)
|
|
971
|
+
@mutex.synchronize do
|
|
972
|
+
subscription = @listen_subscriptions[request_id]
|
|
973
|
+
subscription[:active] = true if subscription
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
|
|
978
|
+
# connection is detected and its slot freed, rather than held until the next fan-out write.
|
|
979
|
+
# Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
|
|
980
|
+
# cannot corrupt an interleaved notification's JSON.
|
|
981
|
+
def start_listen_keepalive_thread(request_id)
|
|
982
|
+
return unless @listen_keepalive_interval
|
|
983
|
+
|
|
984
|
+
Thread.new do
|
|
985
|
+
while listen_subscription_active?(request_id)
|
|
986
|
+
sleep(@listen_keepalive_interval)
|
|
987
|
+
send_listen_keepalive_ping(request_id)
|
|
988
|
+
end
|
|
989
|
+
rescue *STREAM_WRITE_ERRORS
|
|
990
|
+
# The peer went away; the ensure frees the slot. A dropped listen stream is the normal
|
|
991
|
+
# way this loop ends, so it is not reported.
|
|
992
|
+
rescue StandardError => e
|
|
993
|
+
MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
|
|
994
|
+
ensure
|
|
995
|
+
stream = @mutex.synchronize do
|
|
996
|
+
subscription = @listen_subscriptions.delete(request_id)
|
|
997
|
+
subscription && subscription[:stream]
|
|
998
|
+
end
|
|
999
|
+
close_stream_safely(stream) if stream
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
def listen_subscription_active?(request_id)
|
|
1004
|
+
@mutex.synchronize { @listen_subscriptions.key?(request_id) }
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
# Resolves the stream under the lock, then writes outside it so a stalled reader cannot block
|
|
1008
|
+
# every other subscription on `@mutex`. A write error propagates to end the keepalive loop.
|
|
1009
|
+
def send_listen_keepalive_ping(request_id)
|
|
1010
|
+
stream = @mutex.synchronize do
|
|
1011
|
+
subscription = @listen_subscriptions[request_id]
|
|
1012
|
+
subscription && subscription[:stream]
|
|
1013
|
+
end
|
|
1014
|
+
return unless stream
|
|
1015
|
+
|
|
1016
|
+
send_ping_to_stream(stream)
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
|
|
1020
|
+
# and the acknowledgement only includes types the server actually supports
|
|
1021
|
+
# (derived from its declared capabilities).
|
|
1022
|
+
def honored_filter(filter)
|
|
1023
|
+
capabilities = @server.capabilities
|
|
1024
|
+
honored = {}
|
|
1025
|
+
honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
|
|
1026
|
+
honored[:promptsListChanged] = true if filter[:promptsListChanged] && capability_flag?(capabilities, :prompts, :listChanged)
|
|
1027
|
+
honored[:resourcesListChanged] = true if filter[:resourcesListChanged] && capability_flag?(capabilities, :resources, :listChanged)
|
|
1028
|
+
|
|
1029
|
+
subscriptions = filter[:resourceSubscriptions]
|
|
1030
|
+
if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
|
|
1031
|
+
honored[:resourceSubscriptions] = subscriptions
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
honored
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
# Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
|
|
1038
|
+
# in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
|
|
1039
|
+
# `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
|
|
1040
|
+
def capability_flag?(capabilities, name, flag)
|
|
1041
|
+
value = capabilities[name] || capabilities[name.to_s]
|
|
1042
|
+
return false unless value.is_a?(Hash)
|
|
1043
|
+
|
|
1044
|
+
!!(value[flag] || value[flag.to_s])
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# Fans a notification out to every `subscriptions/listen` stream whose honored filter opted in to it,
|
|
1048
|
+
# stamping the correlating `subscriptionId` into `_meta`. Matching against the honored filter
|
|
1049
|
+
# (not the requested one) enforces the MUST NOT-send-unrequested-types rule.
|
|
1050
|
+
def deliver_to_listen_subscriptions(method, params)
|
|
1051
|
+
field = LISTEN_FILTER_FIELDS[method]
|
|
1052
|
+
return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
|
|
1053
|
+
|
|
1054
|
+
# The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
|
|
1055
|
+
# a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
|
|
1056
|
+
matched = @mutex.synchronize do
|
|
1057
|
+
@listen_subscriptions.filter_map do |request_id, subscription|
|
|
1058
|
+
# An inactive entry has not finished writing its acknowledgement yet;
|
|
1059
|
+
# delivering to it would put a notification ahead of the acknowledgement.
|
|
1060
|
+
next unless subscription[:active]
|
|
1061
|
+
|
|
1062
|
+
hit = if field
|
|
1063
|
+
subscription[:filter][field]
|
|
1064
|
+
else
|
|
1065
|
+
uris = subscription[:filter][:resourceSubscriptions]
|
|
1066
|
+
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1067
|
+
uris.is_a?(Array) && uris.include?(uri)
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
[request_id, subscription] if hit
|
|
1071
|
+
end
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
matched.each do |request_id, subscription|
|
|
1075
|
+
meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
|
|
1076
|
+
notification_params = (params || {}).merge(_meta: meta)
|
|
1077
|
+
notification = { jsonrpc: "2.0", method: method, params: notification_params }
|
|
1078
|
+
|
|
1079
|
+
begin
|
|
1080
|
+
# The per-stream write mutex orders this write against a concurrent graceful teardown:
|
|
1081
|
+
# once teardown has marked the entry closed and written its `SubscriptionsListenResult`,
|
|
1082
|
+
# a delivery that snapshotted the entry before the registry was cleared skips it instead
|
|
1083
|
+
# of writing after the final message.
|
|
1084
|
+
subscription[:write_mutex].synchronize do
|
|
1085
|
+
next if subscription[:closed]
|
|
1086
|
+
|
|
1087
|
+
send_to_stream(subscription[:stream], notification)
|
|
1088
|
+
end
|
|
1089
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
1090
|
+
MCP.configuration.exception_reporter.call(
|
|
1091
|
+
e,
|
|
1092
|
+
{ subscription_id: request_id, error: "Failed to send notification" },
|
|
1093
|
+
)
|
|
1094
|
+
remove_listen_subscription(request_id)
|
|
1095
|
+
close_stream_safely(subscription[:stream])
|
|
1096
|
+
end
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
def remove_listen_subscription(request_id)
|
|
1101
|
+
@mutex.synchronize { @listen_subscriptions.delete(request_id) }
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
|
|
1105
|
+
# before the stream closes.
|
|
1106
|
+
def teardown_listen_subscriptions
|
|
1107
|
+
removed = @mutex.synchronize do
|
|
1108
|
+
subscriptions = @listen_subscriptions.dup
|
|
1109
|
+
@listen_subscriptions.clear
|
|
1110
|
+
subscriptions
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
removed.each do |request_id, subscription|
|
|
1114
|
+
# Marking the entry closed and writing the result under the stream's write mutex orders
|
|
1115
|
+
# this against in-flight deliveries: each one either lands before the result or observes
|
|
1116
|
+
# `closed` and skips, keeping the graceful result the stream's final message.
|
|
1117
|
+
subscription[:write_mutex].synchronize do
|
|
1118
|
+
subscription[:closed] = true
|
|
1119
|
+
|
|
1120
|
+
begin
|
|
1121
|
+
send_to_stream(subscription[:stream], {
|
|
1122
|
+
jsonrpc: "2.0",
|
|
1123
|
+
id: request_id,
|
|
1124
|
+
result: {
|
|
1125
|
+
# `SubscriptionsListenResult` is served at the transport layer and never
|
|
1126
|
+
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
|
|
1127
|
+
# stamped at its construction site.
|
|
1128
|
+
resultType: ResultType::COMPLETE,
|
|
1129
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
1130
|
+
},
|
|
1131
|
+
})
|
|
1132
|
+
rescue *STREAM_WRITE_ERRORS
|
|
1133
|
+
nil
|
|
1134
|
+
end
|
|
1135
|
+
end
|
|
1136
|
+
close_stream_safely(subscription[:stream])
|
|
1137
|
+
end
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1140
|
+
def header_mismatch_response(message, id)
|
|
1141
|
+
json_rpc_error_response(
|
|
1142
|
+
status: 400,
|
|
1143
|
+
code: ErrorCodes::HEADER_MISMATCH,
|
|
1144
|
+
message: "Header mismatch: #{message}",
|
|
1145
|
+
id: id,
|
|
1146
|
+
)
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64?<base64>?=` decodes to
|
|
1150
|
+
# its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport
|
|
1151
|
+
# requires faraday, which servers do not depend on.
|
|
1152
|
+
def decode_header_value(value)
|
|
1153
|
+
match = value.match(/\A=\?base64\?(.*)\?=\z/m)
|
|
1154
|
+
return value unless match
|
|
1155
|
+
|
|
1156
|
+
match[1].unpack1("m0").force_encoding(Encoding::UTF_8)
|
|
1157
|
+
rescue ArgumentError
|
|
1158
|
+
value
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to
|
|
1162
|
+
# the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing
|
|
1163
|
+
# keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions
|
|
1164
|
+
# via the `session_id.nil?` branch.
|
|
1165
|
+
def modern_session
|
|
1166
|
+
ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern)
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
def modern_http_status(response)
|
|
1170
|
+
error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil
|
|
1171
|
+
if error_code.nil?
|
|
1172
|
+
200
|
|
1173
|
+
elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND
|
|
1174
|
+
404
|
|
1175
|
+
elsif MODERN_BAD_REQUEST_CODES.include?(error_code)
|
|
1176
|
+
400
|
|
1177
|
+
else
|
|
1178
|
+
200
|
|
1179
|
+
end
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
def handle_post(request, body_string: nil)
|
|
461
1183
|
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
|
|
462
1184
|
accept_error = validate_accept_header(request, required_types)
|
|
463
1185
|
return accept_error if accept_error
|
|
@@ -465,8 +1187,10 @@ module MCP
|
|
|
465
1187
|
content_type_error = validate_content_type(request)
|
|
466
1188
|
return content_type_error if content_type_error
|
|
467
1189
|
|
|
468
|
-
body_string
|
|
469
|
-
|
|
1190
|
+
if body_string.nil?
|
|
1191
|
+
body_string = read_bounded_body(request)
|
|
1192
|
+
return payload_too_large_response if body_string.nil?
|
|
1193
|
+
end
|
|
470
1194
|
|
|
471
1195
|
session_id = extract_session_id(request)
|
|
472
1196
|
|
|
@@ -483,6 +1207,28 @@ module MCP
|
|
|
483
1207
|
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
|
|
484
1208
|
end
|
|
485
1209
|
|
|
1210
|
+
# Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs,
|
|
1211
|
+
# so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only.
|
|
1212
|
+
# Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session
|
|
1213
|
+
# already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras
|
|
1214
|
+
# (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version,
|
|
1215
|
+
# which violates the header/body match requirement and would fall through the legacy path via
|
|
1216
|
+
# the header default; reject that as a header mismatch.
|
|
1217
|
+
if RequestEnvelope.modern?(body[:params])
|
|
1218
|
+
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
1219
|
+
if header_version && MCP::Configuration.modern_protocol_version?(header_version)
|
|
1220
|
+
return invalid_request_response(
|
|
1221
|
+
"Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
|
|
1222
|
+
request_id: body[:id],
|
|
1223
|
+
)
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
return header_mismatch_response(
|
|
1227
|
+
"MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope",
|
|
1228
|
+
body[:id],
|
|
1229
|
+
)
|
|
1230
|
+
end
|
|
1231
|
+
|
|
486
1232
|
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
|
|
487
1233
|
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
|
|
488
1234
|
# This matches the TypeScript and Python SDKs.
|
|
@@ -748,6 +1494,28 @@ module MCP
|
|
|
748
1494
|
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
|
|
749
1495
|
end
|
|
750
1496
|
|
|
1497
|
+
# A version with no modern meaning, whose header can only accompany legacy traffic.
|
|
1498
|
+
# A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it,
|
|
1499
|
+
# but requests of an established legacy session may stamp it - so that value needs further disambiguation.
|
|
1500
|
+
def stable_only_version?(version)
|
|
1501
|
+
MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
|
|
1502
|
+
!MCP::Configuration.modern_protocol_version?(version)
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body
|
|
1506
|
+
# WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying
|
|
1507
|
+
# the envelope comes from a modern client naming a method its lifecycle removed, so it stays
|
|
1508
|
+
# on the modern path and answers with -32601/404 (SEP-2575).
|
|
1509
|
+
# Unparsable or non-object bodies go to the modern path, whose error responses cover them.
|
|
1510
|
+
def legacy_handshake_body?(body_string)
|
|
1511
|
+
body = parse_request_body(body_string)
|
|
1512
|
+
return false unless initialize_request?(body)
|
|
1513
|
+
|
|
1514
|
+
!RequestEnvelope.modern?(body[:params])
|
|
1515
|
+
rescue InvalidJsonError
|
|
1516
|
+
false
|
|
1517
|
+
end
|
|
1518
|
+
|
|
751
1519
|
def validate_protocol_version_header(request)
|
|
752
1520
|
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
|
|
753
1521
|
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
|
@@ -760,8 +1528,10 @@ module MCP
|
|
|
760
1528
|
)
|
|
761
1529
|
end
|
|
762
1530
|
|
|
763
|
-
def json_rpc_error_response(status:, code:, message:)
|
|
764
|
-
|
|
1531
|
+
def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
|
|
1532
|
+
error = { code: code, message: message }
|
|
1533
|
+
error[:data] = data if data
|
|
1534
|
+
body = { jsonrpc: "2.0", id: id, error: error }
|
|
765
1535
|
[status, { "content-type" => "application/json" }, [body.to_json]]
|
|
766
1536
|
end
|
|
767
1537
|
|
|
@@ -905,6 +1675,14 @@ module MCP
|
|
|
905
1675
|
end
|
|
906
1676
|
end
|
|
907
1677
|
|
|
1678
|
+
# `Server` refuses a duplicate id as well, but only once the request reaches it. The SSE branch below
|
|
1679
|
+
# registers this request's stream under that id first, so without this check the colliding request
|
|
1680
|
+
# would take over the routing entry for the moment it takes to be rejected, and its `ensure` would then
|
|
1681
|
+
# clear the entry the original request still needs.
|
|
1682
|
+
if related_request_id && server_session&.in_flight?(related_request_id)
|
|
1683
|
+
return request_id_conflict_response
|
|
1684
|
+
end
|
|
1685
|
+
|
|
908
1686
|
if session_id && !@stateless && !@enable_json_response
|
|
909
1687
|
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
910
1688
|
else
|
|
@@ -928,7 +1706,11 @@ module MCP
|
|
|
928
1706
|
session = @sessions[session_id]
|
|
929
1707
|
if session && related_request_id
|
|
930
1708
|
session[:post_request_streams] ||= {}
|
|
931
|
-
|
|
1709
|
+
|
|
1710
|
+
# Claim the id only while it is free. `handle_regular_request` already refused the colliding request,
|
|
1711
|
+
# so reaching an occupied slot means a race got past that check; leaving the first stream in place keeps
|
|
1712
|
+
# its messages going where they belong.
|
|
1713
|
+
session[:post_request_streams][related_request_id] ||= stream
|
|
932
1714
|
end
|
|
933
1715
|
end
|
|
934
1716
|
|
|
@@ -940,7 +1722,11 @@ module MCP
|
|
|
940
1722
|
if related_request_id
|
|
941
1723
|
@mutex.synchronize do
|
|
942
1724
|
session = @sessions[session_id]
|
|
943
|
-
|
|
1725
|
+
# Only retire our own registration: a request that never claimed the id, or one whose claim has
|
|
1726
|
+
# already been replaced, must not unregister the stream that owns it.
|
|
1727
|
+
registered = session&.dig(:post_request_streams, related_request_id)
|
|
1728
|
+
|
|
1729
|
+
session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
|
|
944
1730
|
end
|
|
945
1731
|
end
|
|
946
1732
|
|
|
@@ -1159,6 +1945,16 @@ module MCP
|
|
|
1159
1945
|
)
|
|
1160
1946
|
end
|
|
1161
1947
|
|
|
1948
|
+
# The POST counterpart of the GET conflict above. A request id already in flight cannot be given
|
|
1949
|
+
# a stream of its own, because the id is what routes request-scoped messages back.
|
|
1950
|
+
def request_id_conflict_response
|
|
1951
|
+
json_rpc_error_response(
|
|
1952
|
+
status: 409,
|
|
1953
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
1954
|
+
message: "Conflict: Request id is already in flight for this session",
|
|
1955
|
+
)
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1162
1958
|
def setup_sse_stream(session_id)
|
|
1163
1959
|
body = create_sse_body(session_id)
|
|
1164
1960
|
|