mcp 1.0.0 → 1.2.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 +428 -9
- 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/discovery.rb +108 -14
- data/lib/mcp/client/oauth/flow.rb +95 -7
- 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 +710 -20
- data/lib/mcp/server.rb +541 -43
- data/lib/mcp/server_context.rb +92 -5
- data/lib/mcp/server_session.rb +77 -15
- 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 +11 -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,16 @@ 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 server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
|
|
133
|
+
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
|
|
134
|
+
# Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
|
|
83
135
|
def initialize(
|
|
84
136
|
server,
|
|
85
137
|
stateless: false,
|
|
@@ -90,7 +142,10 @@ module MCP
|
|
|
90
142
|
allowed_hosts: nil,
|
|
91
143
|
dns_rebinding_protection: true,
|
|
92
144
|
session_request_validator: nil,
|
|
93
|
-
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES
|
|
145
|
+
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
|
146
|
+
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
|
|
147
|
+
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
|
148
|
+
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
|
|
94
149
|
)
|
|
95
150
|
super(server)
|
|
96
151
|
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`.
|
|
@@ -107,6 +162,15 @@ module MCP
|
|
|
107
162
|
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
|
|
108
163
|
@pending_responses = {}
|
|
109
164
|
|
|
165
|
+
# Maps a `subscriptions/listen` request id to `{ stream: stream_object, filter: honored_subscription_filter }` (SEP-2575).
|
|
166
|
+
# In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
|
|
167
|
+
# which is a follow-up.
|
|
168
|
+
@listen_subscriptions = {}
|
|
169
|
+
|
|
170
|
+
# Maps a modern request's ephemeral session id to the Array collecting the notifications its handler emits;
|
|
171
|
+
# `handle_modern` registers the sink and flushes it as SSE frames ahead of the final response (SEP-2575).
|
|
172
|
+
@modern_request_sinks = {}
|
|
173
|
+
|
|
110
174
|
# Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
|
|
111
175
|
# which does not apply to stateless mode since it retains no sessions.
|
|
112
176
|
@session_idle_timeout = if session_idle_timeout.equal?(UNSET_IDLE_TIMEOUT)
|
|
@@ -136,6 +200,24 @@ module MCP
|
|
|
136
200
|
|
|
137
201
|
@max_request_bytes = max_request_bytes
|
|
138
202
|
|
|
203
|
+
if !max_listen_subscriptions.nil? && !(max_listen_subscriptions.is_a?(Integer) && max_listen_subscriptions > 0)
|
|
204
|
+
raise ArgumentError, "max_listen_subscriptions must be a positive Integer or nil"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
@max_listen_subscriptions = max_listen_subscriptions
|
|
208
|
+
|
|
209
|
+
if !listen_keepalive_interval.nil? && !(listen_keepalive_interval.is_a?(Numeric) && listen_keepalive_interval > 0)
|
|
210
|
+
raise ArgumentError, "listen_keepalive_interval must be a positive number or nil"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
@listen_keepalive_interval = listen_keepalive_interval
|
|
214
|
+
|
|
215
|
+
unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
|
|
216
|
+
raise ArgumentError, "server_to_client_request_timeout must be a positive number"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
@server_to_client_request_timeout = server_to_client_request_timeout
|
|
220
|
+
|
|
139
221
|
start_reaper_thread if @session_idle_timeout
|
|
140
222
|
end
|
|
141
223
|
|
|
@@ -149,15 +231,80 @@ module MCP
|
|
|
149
231
|
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
|
|
150
232
|
DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
|
|
151
233
|
|
|
234
|
+
# JSON-RPC methods whose target name is mirrored into the `Mcp-Name` header (SEP-2575).
|
|
235
|
+
NAME_BEARING_METHODS = [Methods::TOOLS_CALL, Methods::RESOURCES_READ, Methods::PROMPTS_GET].freeze
|
|
236
|
+
|
|
237
|
+
# Maps broadcast notification methods to the `SubscriptionFilter` field that opts in to them on
|
|
238
|
+
# a `subscriptions/listen` stream (SEP-2575). `notifications/resources/updated` is matched by URI
|
|
239
|
+
# against `resourceSubscriptions` instead.
|
|
240
|
+
LISTEN_FILTER_FIELDS = {
|
|
241
|
+
Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED => :toolsListChanged,
|
|
242
|
+
Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED => :promptsListChanged,
|
|
243
|
+
Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED => :resourcesListChanged,
|
|
244
|
+
}.freeze
|
|
245
|
+
|
|
246
|
+
# JSON-RPC error codes that surface as HTTP 400 on the modern path. `-32601` maps to 404
|
|
247
|
+
# (disambiguating an unknown method from a legacy HTTP+SSE 404) and everything else, including internal errors,
|
|
248
|
+
# stays 200, matching the Python SDK's status ladder.
|
|
249
|
+
MODERN_BAD_REQUEST_CODES = [
|
|
250
|
+
ErrorCodes::HEADER_MISMATCH,
|
|
251
|
+
ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
|
|
252
|
+
ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
|
|
253
|
+
JsonRpcHandler::ErrorCode::PARSE_ERROR,
|
|
254
|
+
JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
255
|
+
JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
256
|
+
].freeze
|
|
257
|
+
|
|
152
258
|
# Rack app interface. This transport can be mounted as a Rack app.
|
|
153
259
|
def call(env)
|
|
154
260
|
handle_request(Rack::Request.new(env))
|
|
155
261
|
end
|
|
156
262
|
|
|
263
|
+
# The `subscriptions/listen` notification stream (SEP-2575) is served on the modern path,
|
|
264
|
+
# so `Server#discover` may advertise `listChanged`/`subscribe` capability flags.
|
|
265
|
+
def serves_subscriptions_listen?
|
|
266
|
+
true
|
|
267
|
+
end
|
|
268
|
+
|
|
157
269
|
def handle_request(request)
|
|
158
270
|
rebinding_error = validate_dns_rebinding(request)
|
|
159
271
|
return rebinding_error if rebinding_error
|
|
160
272
|
|
|
273
|
+
# Header-primary era routing (SEP-2575). An `MCP-Protocol-Version` header naming a version outside
|
|
274
|
+
# every supported list routes to the sessionless modern path, so an unknown future version receives
|
|
275
|
+
# the spec-mandated `-32022` with the supported list instead of the legacy path's generic invalid-request error.
|
|
276
|
+
# Requests without the header, or with a stable-only version, take the existing paths untouched.
|
|
277
|
+
# An empty header value is malformed rather than a version claim, so it stays on the legacy path
|
|
278
|
+
# and fails legacy header validation as before.
|
|
279
|
+
#
|
|
280
|
+
# A modern header value (2026-07-28) alone cannot decide the era: sessionless modern traffic carries it,
|
|
281
|
+
# and requests of an established legacy session may stamp it as well (the handshake itself never negotiates it):
|
|
282
|
+
# an `Mcp-Session-Id` binds the request to an established legacy session (POST requests, the GET SSE stream,
|
|
283
|
+
# and DELETE termination keep working), and a session-less POST whose body is `initialize` is
|
|
284
|
+
# the legacy-distinctive handshake. Everything else under a dual-era header is sessionless modern traffic
|
|
285
|
+
# (`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
|
|
286
|
+
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
287
|
+
if header_version && !header_version.empty? && !stable_only_version?(header_version)
|
|
288
|
+
unless MCP::Configuration.modern_protocol_version?(header_version)
|
|
289
|
+
return handle_modern(request, header_version)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
if extract_session_id(request).nil?
|
|
293
|
+
return handle_modern(request, header_version) unless request.env["REQUEST_METHOD"] == "POST"
|
|
294
|
+
|
|
295
|
+
# The body is readable only once (Rack 3 inputs need not be rewindable), so the era sniff reads it here,
|
|
296
|
+
# bounded, and hands the string to whichever path serves the request.
|
|
297
|
+
body_string = read_bounded_body(request)
|
|
298
|
+
return payload_too_large_response if body_string.nil?
|
|
299
|
+
|
|
300
|
+
unless legacy_handshake_body?(body_string)
|
|
301
|
+
return handle_modern(request, header_version, body_string: body_string)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
return handle_post(request, body_string: body_string)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
161
308
|
case request.env["REQUEST_METHOD"]
|
|
162
309
|
when "POST"
|
|
163
310
|
handle_post(request)
|
|
@@ -174,6 +321,8 @@ module MCP
|
|
|
174
321
|
@reaper_thread&.kill
|
|
175
322
|
@reaper_thread = nil
|
|
176
323
|
|
|
324
|
+
teardown_listen_subscriptions
|
|
325
|
+
|
|
177
326
|
removed_sessions = @mutex.synchronize do
|
|
178
327
|
@sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
|
|
179
328
|
end
|
|
@@ -185,10 +334,11 @@ module MCP
|
|
|
185
334
|
end
|
|
186
335
|
|
|
187
336
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
|
|
337
|
+
# `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below:
|
|
338
|
+
# a resource updated by one session's tool call changed globally, so modern subscribers hear about it too.
|
|
339
|
+
# Runs before the per-request sink and the stateless guard because the listen registry does not depend on sessions,
|
|
340
|
+
# and a sink capturing the notification for its own response stream must not hide it from other subscriptions.
|
|
341
|
+
deliver_to_listen_subscriptions(method, params)
|
|
192
342
|
|
|
193
343
|
notification = {
|
|
194
344
|
jsonrpc: "2.0",
|
|
@@ -196,6 +346,24 @@ module MCP
|
|
|
196
346
|
}
|
|
197
347
|
notification[:params] = params if params
|
|
198
348
|
|
|
349
|
+
# A modern request's notifications ride its own response stream (SEP-2575): `handle_modern` registers
|
|
350
|
+
# a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response.
|
|
351
|
+
# Checked before the stateless guard, since modern requests are served in stateless deployments too.
|
|
352
|
+
# The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as
|
|
353
|
+
# non-delivery (`false`), never handed to the legacy paths below.
|
|
354
|
+
sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] }
|
|
355
|
+
if sink
|
|
356
|
+
return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS
|
|
357
|
+
|
|
358
|
+
sink << notification
|
|
359
|
+
return true
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Stateless mode has no streams to deliver notifications on. Report non-delivery instead of raising
|
|
363
|
+
# so the ephemeral per-request session's notify_* helpers (e.g. progress or log notifications from
|
|
364
|
+
# a tool handler) degrade gracefully rather than spamming the exception reporter on every call.
|
|
365
|
+
return false if @stateless
|
|
366
|
+
|
|
199
367
|
if session_id
|
|
200
368
|
deliver_targeted_notification(notification, session_id, related_request_id)
|
|
201
369
|
else
|
|
@@ -299,14 +467,28 @@ module MCP
|
|
|
299
467
|
end
|
|
300
468
|
end
|
|
301
469
|
|
|
302
|
-
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
|
|
303
|
-
#
|
|
470
|
+
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and blocks until
|
|
471
|
+
# the client responds.
|
|
472
|
+
#
|
|
473
|
+
# Uses a `PendingResponse` for cross-thread synchronization: this method registers one,
|
|
474
|
+
# sends the request via SSE stream, then waits on it. When the client POSTs a response,
|
|
475
|
+
# `handle_response` matches it by `request_id` and resolves the pending response,
|
|
476
|
+
# unblocking this thread. A cancellation and session teardown resolve it the same way.
|
|
304
477
|
#
|
|
305
|
-
#
|
|
306
|
-
#
|
|
307
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
478
|
+
# The wait is bounded by `timeout` (defaulting to the transport's `server_to_client_request_timeout`),
|
|
479
|
+
# so a client that never answers cannot park the calling thread for good. On expiry the peer is
|
|
480
|
+
# sent `notifications/cancelled` and `MCP::Server::RequestTimeoutError` is raised.
|
|
481
|
+
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil, timeout: nil)
|
|
482
|
+
# The modern lifecycle (SEP-2575) forbids server-initiated JSON-RPC requests;
|
|
483
|
+
# multi round-trip `input_required` results (SEP-2322) replace them. A modern session has never reached
|
|
484
|
+
# the rest of this method anyway, since `handle_modern` mints its session without registering it in `@sessions`,
|
|
485
|
+
# but that is incidental: without the rule stated here a modern handler that reaches for elicitation
|
|
486
|
+
# or sampling is told "Session not found: <uuid>", which points at everything except the actual reason.
|
|
487
|
+
# `StdioTransport#send_request` refuses the same way.
|
|
488
|
+
if server_session&.era == :modern
|
|
489
|
+
raise "Server-initiated requests are not available in the modern lifecycle (SEP-2575)."
|
|
490
|
+
end
|
|
491
|
+
|
|
310
492
|
if @stateless
|
|
311
493
|
raise "Stateless mode does not support server-to-client requests."
|
|
312
494
|
end
|
|
@@ -320,7 +502,8 @@ module MCP
|
|
|
320
502
|
end
|
|
321
503
|
|
|
322
504
|
request_id = generate_request_id
|
|
323
|
-
|
|
505
|
+
pending_response = PendingResponse.new
|
|
506
|
+
wait_timeout = timeout || @server_to_client_request_timeout
|
|
324
507
|
cancel_hook = nil
|
|
325
508
|
|
|
326
509
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
@@ -333,7 +516,7 @@ module MCP
|
|
|
333
516
|
raise "Session not found: #{session_id}."
|
|
334
517
|
end
|
|
335
518
|
|
|
336
|
-
@pending_responses[request_id] = { queue:
|
|
519
|
+
@pending_responses[request_id] = { queue: pending_response, session_id: session_id }
|
|
337
520
|
|
|
338
521
|
active_stream(session, related_request_id: related_request_id)
|
|
339
522
|
end
|
|
@@ -369,7 +552,27 @@ module MCP
|
|
|
369
552
|
end
|
|
370
553
|
end
|
|
371
554
|
|
|
372
|
-
response =
|
|
555
|
+
response = pending_response.pop(timeout: wait_timeout) do
|
|
556
|
+
# Expiry cancels as well as stops waiting, so a client that answers late does not act on
|
|
557
|
+
# a request the server has abandoned. Only connections speaking 2025-11-25 or earlier get here:
|
|
558
|
+
# the modern lifecycle forbids server-to-client requests outright, and its sessionless requests
|
|
559
|
+
# never register the session this method looks up. Those revisions ask the sender to "issue
|
|
560
|
+
# a cancellation notification for that request and stop waiting", letting either side send one.
|
|
561
|
+
# (The 2026-07-28 rule reserving `notifications/cancelled` for `subscriptions/listen` teardown
|
|
562
|
+
# governs the era that has no such requests to cancel.) Both reference SDKs send this same
|
|
563
|
+
# courtesy cancel on timeout.
|
|
564
|
+
server_session&.send_peer_cancellation(
|
|
565
|
+
nested_request_id: request_id,
|
|
566
|
+
related_request_id: related_request_id,
|
|
567
|
+
reason: "Timed out after #{wait_timeout} seconds",
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
raise RequestTimeoutError.new(
|
|
571
|
+
"#{method} request timed out after #{wait_timeout} seconds",
|
|
572
|
+
request_id: request_id,
|
|
573
|
+
timeout: wait_timeout,
|
|
574
|
+
)
|
|
575
|
+
end
|
|
373
576
|
|
|
374
577
|
if response.is_a?(Hash) && response.key?(:error)
|
|
375
578
|
raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
|
|
@@ -457,7 +660,446 @@ module MCP
|
|
|
457
660
|
stream.flush
|
|
458
661
|
end
|
|
459
662
|
|
|
460
|
-
|
|
663
|
+
# Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
|
|
664
|
+
# a single POST/JSON exchange with no session. The modern path never consults
|
|
665
|
+
# `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
|
|
666
|
+
# an `Mcp-Session-Id`. GET (the legacy listening stream, replaced by `subscriptions/listen`)
|
|
667
|
+
# and DELETE (session termination) have no modern meaning.
|
|
668
|
+
def handle_modern(request, header_version, body_string: nil)
|
|
669
|
+
return method_not_allowed_response unless request.env["REQUEST_METHOD"] == "POST"
|
|
670
|
+
|
|
671
|
+
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES_SSE)
|
|
672
|
+
return accept_error if accept_error
|
|
673
|
+
|
|
674
|
+
content_type_error = validate_content_type(request)
|
|
675
|
+
return content_type_error if content_type_error
|
|
676
|
+
|
|
677
|
+
if body_string.nil?
|
|
678
|
+
body_string = read_bounded_body(request)
|
|
679
|
+
return payload_too_large_response if body_string.nil?
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
begin
|
|
683
|
+
body = parse_request_body(body_string)
|
|
684
|
+
rescue InvalidJsonError
|
|
685
|
+
return invalid_json_response
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
unless body.is_a?(Hash)
|
|
689
|
+
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# The version check precedes everything else that depends on request content,
|
|
693
|
+
# so a client probing with an unknown future version always receives the `-32022` signal
|
|
694
|
+
# (with the supported list to select from) rather than an incidental error.
|
|
695
|
+
unless MCP::Configuration.modern_protocol_version?(header_version)
|
|
696
|
+
return json_rpc_error_response(
|
|
697
|
+
status: 400,
|
|
698
|
+
code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
|
|
699
|
+
message: "Unsupported protocol version",
|
|
700
|
+
data: {
|
|
701
|
+
supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
|
|
702
|
+
requested: header_version,
|
|
703
|
+
},
|
|
704
|
+
id: body[:id],
|
|
705
|
+
)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
if extract_session_id(request)
|
|
709
|
+
return json_rpc_error_response(
|
|
710
|
+
status: 400,
|
|
711
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
712
|
+
message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
|
|
713
|
+
id: body[:id],
|
|
714
|
+
)
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
mismatch_error = validate_modern_headers(request, body, header_version)
|
|
718
|
+
return mismatch_error if mismatch_error
|
|
719
|
+
|
|
720
|
+
# `subscriptions/listen` is a long-lived notification stream served at the transport layer;
|
|
721
|
+
# it never dispatches through `Server#handle`.
|
|
722
|
+
return handle_subscriptions_listen(body) if body[:method] == Methods::SUBSCRIPTIONS_LISTEN
|
|
723
|
+
|
|
724
|
+
session = modern_session
|
|
725
|
+
notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
|
|
726
|
+
begin
|
|
727
|
+
response = @server.handle(body, session: session)
|
|
728
|
+
ensure
|
|
729
|
+
@mutex.synchronize { @modern_request_sinks.delete(session.session_id) }
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
|
|
733
|
+
return handle_accepted if response.nil?
|
|
734
|
+
|
|
735
|
+
# Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
|
|
736
|
+
# the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
|
|
737
|
+
# Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
|
|
738
|
+
# (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
|
|
739
|
+
# Live streaming can follow without changing the wire shape.
|
|
740
|
+
if notifications.empty?
|
|
741
|
+
[modern_http_status(response), { "content-type" => "application/json" }, [response.to_json]]
|
|
742
|
+
else
|
|
743
|
+
frames = notifications + [response]
|
|
744
|
+
sse_body = frames.map { |frame| "event: message\ndata: #{frame.to_json}\n\n" }.join
|
|
745
|
+
[200, SSE_HEADERS.dup, [sse_body]]
|
|
746
|
+
end
|
|
747
|
+
rescue StandardError => e
|
|
748
|
+
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
749
|
+
json_rpc_error_response(
|
|
750
|
+
status: 500,
|
|
751
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
752
|
+
message: "Internal server error",
|
|
753
|
+
)
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Enforces the SEP-2575 header/body match rules (`-32020`, HTTP 400): the `MCP-Protocol-Version` header
|
|
757
|
+
# MUST match the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers MUST match
|
|
758
|
+
# the body when sent. Absent mirror headers are tolerated for interoperability while other SDK serving stacks
|
|
759
|
+
# converge on enforcement.
|
|
760
|
+
def validate_modern_headers(request, body, header_version)
|
|
761
|
+
params = body[:params]
|
|
762
|
+
meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
|
|
763
|
+
if meta_version && meta_version != header_version
|
|
764
|
+
return header_mismatch_response(
|
|
765
|
+
"MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
|
|
766
|
+
body[:id],
|
|
767
|
+
)
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods:
|
|
771
|
+
# the spec lists a missing required standard header among the validation failures that MUST be rejected,
|
|
772
|
+
# and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare":
|
|
773
|
+
# the headers exist so intermediaries can route without parsing bodies, and a client that omits them
|
|
774
|
+
# defeats that contract silently.
|
|
775
|
+
method_header = request.env["HTTP_MCP_METHOD"]
|
|
776
|
+
if method_header.to_s.empty?
|
|
777
|
+
return header_mismatch_response(
|
|
778
|
+
"Mcp-Method header is required on the modern path",
|
|
779
|
+
body[:id],
|
|
780
|
+
)
|
|
781
|
+
end
|
|
782
|
+
if method_header != body[:method]
|
|
783
|
+
return header_mismatch_response(
|
|
784
|
+
"Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
|
|
785
|
+
body[:id],
|
|
786
|
+
)
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
if NAME_BEARING_METHODS.include?(body[:method])
|
|
790
|
+
body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
|
|
791
|
+
name_header = request.env["HTTP_MCP_NAME"]
|
|
792
|
+
if body_name
|
|
793
|
+
if name_header.to_s.empty?
|
|
794
|
+
return header_mismatch_response(
|
|
795
|
+
"Mcp-Name header is required for `#{body[:method]}`",
|
|
796
|
+
body[:id],
|
|
797
|
+
)
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
decoded_name = decode_header_value(name_header)
|
|
801
|
+
if decoded_name != body_name
|
|
802
|
+
return header_mismatch_response(
|
|
803
|
+
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
|
|
804
|
+
body[:id],
|
|
805
|
+
)
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
nil
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is
|
|
814
|
+
# `notifications/subscriptions/acknowledged` with the subset of requested notification types
|
|
815
|
+
# the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId`
|
|
816
|
+
# (= the listen request id) in `_meta`. A graceful teardown (transport `close`) sends a `SubscriptionsListenResult`
|
|
817
|
+
# response; an abrupt disconnect sends nothing. A keepalive comment frame is written every
|
|
818
|
+
# `listen_keepalive_interval` seconds so a dropped connection frees its slot.
|
|
819
|
+
def handle_subscriptions_listen(body)
|
|
820
|
+
request_id = body[:id]
|
|
821
|
+
params = body[:params]
|
|
822
|
+
|
|
823
|
+
# A listen frame without an id could never receive stream teardown correlation.
|
|
824
|
+
unless request_id
|
|
825
|
+
return invalid_request_response("Invalid Request: subscriptions/listen requires an id")
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
begin
|
|
829
|
+
if RequestEnvelope.modern?(params)
|
|
830
|
+
RequestEnvelope.parse!(params, request: params)
|
|
831
|
+
else
|
|
832
|
+
return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
|
|
833
|
+
end
|
|
834
|
+
rescue Server::RequestHandlerError => e
|
|
835
|
+
return json_rpc_error_response(
|
|
836
|
+
status: 400,
|
|
837
|
+
code: e.error_code || JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
838
|
+
message: e.message,
|
|
839
|
+
data: e.error_data,
|
|
840
|
+
id: request_id,
|
|
841
|
+
)
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
filter = params[:notifications]
|
|
845
|
+
unless filter.is_a?(Hash)
|
|
846
|
+
return json_rpc_error_response(
|
|
847
|
+
status: 400,
|
|
848
|
+
code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
849
|
+
message: "Invalid params: subscriptions/listen requires a `notifications` filter object",
|
|
850
|
+
id: request_id,
|
|
851
|
+
)
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# Best-effort cap check before committing to the SSE response; the registration inside
|
|
855
|
+
# `listen_sse_body` re-checks atomically for the race between two concurrent listens
|
|
856
|
+
# crossing the cap together.
|
|
857
|
+
if listen_subscriptions_full?
|
|
858
|
+
return too_many_listen_subscriptions_response(request_id)
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
[200, SSE_HEADERS.dup, listen_sse_body(request_id, honored_filter(filter))]
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def listen_subscriptions_full?
|
|
865
|
+
return false unless @max_listen_subscriptions
|
|
866
|
+
|
|
867
|
+
@mutex.synchronize { @listen_subscriptions.size >= @max_listen_subscriptions }
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def too_many_listen_subscriptions_response(request_id)
|
|
871
|
+
json_rpc_error_response(
|
|
872
|
+
status: 503,
|
|
873
|
+
code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
874
|
+
message: "Service unavailable: maximum concurrent subscriptions/listen streams (#{@max_listen_subscriptions}) reached",
|
|
875
|
+
id: request_id,
|
|
876
|
+
)
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# The proc registers the stream and returns, leaving the response open like
|
|
880
|
+
# the legacy GET stream (`create_sse_body`).
|
|
881
|
+
def listen_sse_body(request_id, honored)
|
|
882
|
+
proc do |stream|
|
|
883
|
+
rejected = false
|
|
884
|
+
@mutex.synchronize do
|
|
885
|
+
if @listen_subscriptions.key?(request_id) ||
|
|
886
|
+
(@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
|
|
887
|
+
rejected = true
|
|
888
|
+
else
|
|
889
|
+
@listen_subscriptions[request_id] = { stream: stream, filter: honored }
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
if rejected
|
|
894
|
+
close_stream_safely(stream)
|
|
895
|
+
else
|
|
896
|
+
acknowledgement = {
|
|
897
|
+
jsonrpc: "2.0",
|
|
898
|
+
method: Methods::NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED,
|
|
899
|
+
params: {
|
|
900
|
+
notifications: honored,
|
|
901
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
902
|
+
},
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
begin
|
|
906
|
+
send_to_stream(stream, acknowledgement)
|
|
907
|
+
start_listen_keepalive_thread(request_id)
|
|
908
|
+
rescue *STREAM_WRITE_ERRORS
|
|
909
|
+
remove_listen_subscription(request_id)
|
|
910
|
+
close_stream_safely(stream)
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
# Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
|
|
917
|
+
# connection is detected and its slot freed, rather than held until the next fan-out write.
|
|
918
|
+
# Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
|
|
919
|
+
# cannot corrupt an interleaved notification's JSON.
|
|
920
|
+
def start_listen_keepalive_thread(request_id)
|
|
921
|
+
return unless @listen_keepalive_interval
|
|
922
|
+
|
|
923
|
+
Thread.new do
|
|
924
|
+
while listen_subscription_active?(request_id)
|
|
925
|
+
sleep(@listen_keepalive_interval)
|
|
926
|
+
send_listen_keepalive_ping(request_id)
|
|
927
|
+
end
|
|
928
|
+
rescue *STREAM_WRITE_ERRORS
|
|
929
|
+
# The peer went away; the ensure frees the slot. A dropped listen stream is the normal
|
|
930
|
+
# way this loop ends, so it is not reported.
|
|
931
|
+
rescue StandardError => e
|
|
932
|
+
MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
|
|
933
|
+
ensure
|
|
934
|
+
stream = @mutex.synchronize do
|
|
935
|
+
subscription = @listen_subscriptions.delete(request_id)
|
|
936
|
+
subscription && subscription[:stream]
|
|
937
|
+
end
|
|
938
|
+
close_stream_safely(stream) if stream
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def listen_subscription_active?(request_id)
|
|
943
|
+
@mutex.synchronize { @listen_subscriptions.key?(request_id) }
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# Resolves the stream under the lock, then writes outside it so a stalled reader cannot block
|
|
947
|
+
# every other subscription on `@mutex`. A write error propagates to end the keepalive loop.
|
|
948
|
+
def send_listen_keepalive_ping(request_id)
|
|
949
|
+
stream = @mutex.synchronize do
|
|
950
|
+
subscription = @listen_subscriptions[request_id]
|
|
951
|
+
subscription && subscription[:stream]
|
|
952
|
+
end
|
|
953
|
+
return unless stream
|
|
954
|
+
|
|
955
|
+
send_ping_to_stream(stream)
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
|
|
959
|
+
# and the acknowledgement only includes types the server actually supports
|
|
960
|
+
# (derived from its declared capabilities).
|
|
961
|
+
def honored_filter(filter)
|
|
962
|
+
capabilities = @server.capabilities
|
|
963
|
+
honored = {}
|
|
964
|
+
honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
|
|
965
|
+
honored[:promptsListChanged] = true if filter[:promptsListChanged] && capability_flag?(capabilities, :prompts, :listChanged)
|
|
966
|
+
honored[:resourcesListChanged] = true if filter[:resourcesListChanged] && capability_flag?(capabilities, :resources, :listChanged)
|
|
967
|
+
|
|
968
|
+
subscriptions = filter[:resourceSubscriptions]
|
|
969
|
+
if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
|
|
970
|
+
honored[:resourceSubscriptions] = subscriptions
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
honored
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
# Reads a nested capability flag tolerating both symbol and string keys, since user-supplied capability hashes arrive
|
|
977
|
+
# in either form. The flag that promises delivery (`listChanged` / `subscribe`) decides honoring, the same derivation
|
|
978
|
+
# `Server#discover` uses for its era-aware capability stripping; the mere presence of the primitive's capability is not enough.
|
|
979
|
+
def capability_flag?(capabilities, name, flag)
|
|
980
|
+
value = capabilities[name] || capabilities[name.to_s]
|
|
981
|
+
return false unless value.is_a?(Hash)
|
|
982
|
+
|
|
983
|
+
!!(value[flag] || value[flag.to_s])
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
# Fans a notification out to every `subscriptions/listen` stream whose honored filter opted in to it,
|
|
987
|
+
# stamping the correlating `subscriptionId` into `_meta`. Matching against the honored filter
|
|
988
|
+
# (not the requested one) enforces the MUST NOT-send-unrequested-types rule.
|
|
989
|
+
def deliver_to_listen_subscriptions(method, params)
|
|
990
|
+
field = LISTEN_FILTER_FIELDS[method]
|
|
991
|
+
return if field.nil? && method != Methods::NOTIFICATIONS_RESOURCES_UPDATED
|
|
992
|
+
|
|
993
|
+
# The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
|
|
994
|
+
# a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
|
|
995
|
+
matched = @mutex.synchronize do
|
|
996
|
+
@listen_subscriptions.filter_map do |request_id, subscription|
|
|
997
|
+
hit = if field
|
|
998
|
+
subscription[:filter][field]
|
|
999
|
+
else
|
|
1000
|
+
uris = subscription[:filter][:resourceSubscriptions]
|
|
1001
|
+
uri = params.is_a?(Hash) ? params[:uri] || params["uri"] : nil
|
|
1002
|
+
uris.is_a?(Array) && uris.include?(uri)
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
[request_id, subscription[:stream]] if hit
|
|
1006
|
+
end
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
matched.each do |request_id, stream|
|
|
1010
|
+
meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
|
|
1011
|
+
notification_params = (params || {}).merge(_meta: meta)
|
|
1012
|
+
notification = { jsonrpc: "2.0", method: method, params: notification_params }
|
|
1013
|
+
|
|
1014
|
+
begin
|
|
1015
|
+
send_to_stream(stream, notification)
|
|
1016
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
1017
|
+
MCP.configuration.exception_reporter.call(
|
|
1018
|
+
e,
|
|
1019
|
+
{ subscription_id: request_id, error: "Failed to send notification" },
|
|
1020
|
+
)
|
|
1021
|
+
remove_listen_subscription(request_id)
|
|
1022
|
+
close_stream_safely(stream)
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
def remove_listen_subscription(request_id)
|
|
1028
|
+
@mutex.synchronize { @listen_subscriptions.delete(request_id) }
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
|
|
1032
|
+
# before the stream closes.
|
|
1033
|
+
def teardown_listen_subscriptions
|
|
1034
|
+
removed = @mutex.synchronize do
|
|
1035
|
+
subscriptions = @listen_subscriptions.dup
|
|
1036
|
+
@listen_subscriptions.clear
|
|
1037
|
+
subscriptions
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
removed.each do |request_id, subscription|
|
|
1041
|
+
begin
|
|
1042
|
+
send_to_stream(subscription[:stream], {
|
|
1043
|
+
jsonrpc: "2.0",
|
|
1044
|
+
id: request_id,
|
|
1045
|
+
result: {
|
|
1046
|
+
# `SubscriptionsListenResult` is served at the transport layer and never
|
|
1047
|
+
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
|
|
1048
|
+
# stamped at its construction site.
|
|
1049
|
+
resultType: ResultType::COMPLETE,
|
|
1050
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
1051
|
+
},
|
|
1052
|
+
})
|
|
1053
|
+
rescue *STREAM_WRITE_ERRORS
|
|
1054
|
+
nil
|
|
1055
|
+
end
|
|
1056
|
+
close_stream_safely(subscription[:stream])
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
def header_mismatch_response(message, id)
|
|
1061
|
+
json_rpc_error_response(
|
|
1062
|
+
status: 400,
|
|
1063
|
+
code: ErrorCodes::HEADER_MISMATCH,
|
|
1064
|
+
message: "Header mismatch: #{message}",
|
|
1065
|
+
id: id,
|
|
1066
|
+
)
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
# Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64?<base64>?=` decodes to
|
|
1070
|
+
# its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport
|
|
1071
|
+
# requires faraday, which servers do not depend on.
|
|
1072
|
+
def decode_header_value(value)
|
|
1073
|
+
match = value.match(/\A=\?base64\?(.*)\?=\z/m)
|
|
1074
|
+
return value unless match
|
|
1075
|
+
|
|
1076
|
+
match[1].unpack1("m0").force_encoding(Encoding::UTF_8)
|
|
1077
|
+
rescue ArgumentError
|
|
1078
|
+
value
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
# Each modern request is self-contained: handlers run against an ephemeral per-request `ServerSession` locked to
|
|
1082
|
+
# the modern era. The session carries a fresh unregistered `session_id` so notification and server-initiated-request plumbing
|
|
1083
|
+
# keyed by session lookup degrades gracefully (delivery returns `false`) instead of broadcasting to unrelated legacy sessions
|
|
1084
|
+
# via the `session_id.nil?` branch.
|
|
1085
|
+
def modern_session
|
|
1086
|
+
ServerSession.new(server: @server, transport: self, session_id: SecureRandom.uuid, era: :modern)
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
def modern_http_status(response)
|
|
1090
|
+
error_code = response.is_a?(Hash) ? response.dig(:error, :code) : nil
|
|
1091
|
+
if error_code.nil?
|
|
1092
|
+
200
|
|
1093
|
+
elsif error_code == JsonRpcHandler::ErrorCode::METHOD_NOT_FOUND
|
|
1094
|
+
404
|
|
1095
|
+
elsif MODERN_BAD_REQUEST_CODES.include?(error_code)
|
|
1096
|
+
400
|
|
1097
|
+
else
|
|
1098
|
+
200
|
|
1099
|
+
end
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
def handle_post(request, body_string: nil)
|
|
461
1103
|
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
|
|
462
1104
|
accept_error = validate_accept_header(request, required_types)
|
|
463
1105
|
return accept_error if accept_error
|
|
@@ -465,8 +1107,10 @@ module MCP
|
|
|
465
1107
|
content_type_error = validate_content_type(request)
|
|
466
1108
|
return content_type_error if content_type_error
|
|
467
1109
|
|
|
468
|
-
body_string
|
|
469
|
-
|
|
1110
|
+
if body_string.nil?
|
|
1111
|
+
body_string = read_bounded_body(request)
|
|
1112
|
+
return payload_too_large_response if body_string.nil?
|
|
1113
|
+
end
|
|
470
1114
|
|
|
471
1115
|
session_id = extract_session_id(request)
|
|
472
1116
|
|
|
@@ -483,6 +1127,28 @@ module MCP
|
|
|
483
1127
|
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
|
|
484
1128
|
end
|
|
485
1129
|
|
|
1130
|
+
# Header-primary routing sends sessionless modern traffic to `handle_modern` before this method runs,
|
|
1131
|
+
# so a body carrying the modern `_meta` triple (SEP-2575) arrives here in two shapes only.
|
|
1132
|
+
# Bound to a session under a dual-era header (2026-07-28), it is a lifecycle violation: the session
|
|
1133
|
+
# already negotiated the legacy lifecycle via `initialize`, and a connection can never change eras
|
|
1134
|
+
# (mirroring the stdio era lock). Otherwise the header is missing or names a stable-only version,
|
|
1135
|
+
# which violates the header/body match requirement and would fall through the legacy path via
|
|
1136
|
+
# the header default; reject that as a header mismatch.
|
|
1137
|
+
if RequestEnvelope.modern?(body[:params])
|
|
1138
|
+
header_version = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
1139
|
+
if header_version && MCP::Configuration.modern_protocol_version?(header_version)
|
|
1140
|
+
return invalid_request_response(
|
|
1141
|
+
"Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
|
|
1142
|
+
request_id: body[:id],
|
|
1143
|
+
)
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
return header_mismatch_response(
|
|
1147
|
+
"MCP-Protocol-Version header is missing or legacy while the body carries the modern _meta envelope",
|
|
1148
|
+
body[:id],
|
|
1149
|
+
)
|
|
1150
|
+
end
|
|
1151
|
+
|
|
486
1152
|
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
|
|
487
1153
|
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
|
|
488
1154
|
# This matches the TypeScript and Python SDKs.
|
|
@@ -748,6 +1414,28 @@ module MCP
|
|
|
748
1414
|
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
|
|
749
1415
|
end
|
|
750
1416
|
|
|
1417
|
+
# A version with no modern meaning, whose header can only accompany legacy traffic.
|
|
1418
|
+
# A modern version's header (2026-07-28) can accompany either era's traffic - the handshake never negotiates it,
|
|
1419
|
+
# but requests of an established legacy session may stamp it - so that value needs further disambiguation.
|
|
1420
|
+
def stable_only_version?(version)
|
|
1421
|
+
MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version) &&
|
|
1422
|
+
!MCP::Configuration.modern_protocol_version?(version)
|
|
1423
|
+
end
|
|
1424
|
+
|
|
1425
|
+
# Era sniff for a sessionless POST under a dual-era header version: only an `initialize` body
|
|
1426
|
+
# WITHOUT the modern `_meta` envelope is legacy-distinctive. An `initialize` carrying
|
|
1427
|
+
# the envelope comes from a modern client naming a method its lifecycle removed, so it stays
|
|
1428
|
+
# on the modern path and answers with -32601/404 (SEP-2575).
|
|
1429
|
+
# Unparsable or non-object bodies go to the modern path, whose error responses cover them.
|
|
1430
|
+
def legacy_handshake_body?(body_string)
|
|
1431
|
+
body = parse_request_body(body_string)
|
|
1432
|
+
return false unless initialize_request?(body)
|
|
1433
|
+
|
|
1434
|
+
!RequestEnvelope.modern?(body[:params])
|
|
1435
|
+
rescue InvalidJsonError
|
|
1436
|
+
false
|
|
1437
|
+
end
|
|
1438
|
+
|
|
751
1439
|
def validate_protocol_version_header(request)
|
|
752
1440
|
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
|
|
753
1441
|
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
|
@@ -760,8 +1448,10 @@ module MCP
|
|
|
760
1448
|
)
|
|
761
1449
|
end
|
|
762
1450
|
|
|
763
|
-
def json_rpc_error_response(status:, code:, message:)
|
|
764
|
-
|
|
1451
|
+
def json_rpc_error_response(status:, code:, message:, data: nil, id: nil)
|
|
1452
|
+
error = { code: code, message: message }
|
|
1453
|
+
error[:data] = data if data
|
|
1454
|
+
body = { jsonrpc: "2.0", id: id, error: error }
|
|
765
1455
|
[status, { "content-type" => "application/json" }, [body.to_json]]
|
|
766
1456
|
end
|
|
767
1457
|
|