mcp 0.10.0 → 0.14.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 +1267 -709
- data/lib/json_rpc_handler.rb +17 -10
- data/lib/mcp/client/http.rb +137 -15
- data/lib/mcp/client/paginated_result.rb +13 -0
- data/lib/mcp/client.rb +253 -66
- data/lib/mcp/configuration.rb +38 -2
- data/lib/mcp/content.rb +16 -12
- data/lib/mcp/instrumentation.rb +23 -2
- data/lib/mcp/methods.rb +4 -5
- data/lib/mcp/progress.rb +3 -1
- data/lib/mcp/prompt/result.rb +4 -3
- data/lib/mcp/resource/contents.rb +8 -7
- data/lib/mcp/resource.rb +4 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/pagination.rb +42 -0
- data/lib/mcp/server/transports/stdio_transport.rb +35 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +321 -88
- data/lib/mcp/server.rb +226 -46
- data/lib/mcp/server_context.rb +70 -2
- data/lib/mcp/server_session.rb +80 -7
- data/lib/mcp/tool/response.rb +4 -3
- data/lib/mcp/tool/schema.rb +1 -14
- data/lib/mcp/transport.rb +14 -0
- data/lib/mcp/version.rb +1 -1
- metadata +9 -5
|
@@ -1,21 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "securerandom"
|
|
5
4
|
require_relative "../../transport"
|
|
6
5
|
|
|
6
|
+
# This file is autoloaded only when `StreamableHTTPTransport` is referenced,
|
|
7
|
+
# so the `rack` dependency does not affect `StdioTransport` users.
|
|
8
|
+
begin
|
|
9
|
+
require "rack"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
raise LoadError, "The 'rack' gem is required to use the StreamableHTTPTransport. " \
|
|
12
|
+
"Add it to your Gemfile: gem 'rack'"
|
|
13
|
+
end
|
|
14
|
+
|
|
7
15
|
module MCP
|
|
8
16
|
class Server
|
|
9
17
|
module Transports
|
|
10
18
|
class StreamableHTTPTransport < Transport
|
|
11
|
-
|
|
19
|
+
SSE_HEADERS = {
|
|
20
|
+
"Content-Type" => "text/event-stream",
|
|
21
|
+
"Cache-Control" => "no-cache",
|
|
22
|
+
"Connection" => "keep-alive",
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(server, stateless: false, enable_json_response: false, session_idle_timeout: nil)
|
|
12
26
|
super(server)
|
|
13
|
-
# Maps `session_id` to `{
|
|
27
|
+
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
|
|
14
28
|
@sessions = {}
|
|
15
29
|
@mutex = Mutex.new
|
|
16
30
|
|
|
17
31
|
@stateless = stateless
|
|
32
|
+
@enable_json_response = enable_json_response
|
|
18
33
|
@session_idle_timeout = session_idle_timeout
|
|
34
|
+
@pending_responses = {}
|
|
19
35
|
|
|
20
36
|
if @session_idle_timeout
|
|
21
37
|
if @stateless
|
|
@@ -28,11 +44,17 @@ module MCP
|
|
|
28
44
|
start_reaper_thread if @session_idle_timeout
|
|
29
45
|
end
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
REQUIRED_POST_ACCEPT_TYPES_SSE = ["application/json", "text/event-stream"].freeze
|
|
48
|
+
REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
|
|
32
49
|
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
|
|
33
50
|
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
34
51
|
SESSION_REAP_INTERVAL = 60
|
|
35
52
|
|
|
53
|
+
# Rack app interface. This transport can be mounted as a Rack app.
|
|
54
|
+
def call(env)
|
|
55
|
+
handle_request(Rack::Request.new(env))
|
|
56
|
+
end
|
|
57
|
+
|
|
36
58
|
def handle_request(request)
|
|
37
59
|
case request.env["REQUEST_METHOD"]
|
|
38
60
|
when "POST"
|
|
@@ -50,12 +72,17 @@ module MCP
|
|
|
50
72
|
@reaper_thread&.kill
|
|
51
73
|
@reaper_thread = nil
|
|
52
74
|
|
|
53
|
-
@mutex.synchronize do
|
|
54
|
-
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
|
|
75
|
+
removed_sessions = @mutex.synchronize do
|
|
76
|
+
@sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
removed_sessions.each do |session|
|
|
80
|
+
close_stream_safely(session[:get_sse_stream])
|
|
81
|
+
close_post_request_streams(session)
|
|
55
82
|
end
|
|
56
83
|
end
|
|
57
84
|
|
|
58
|
-
def send_notification(method, params = nil, session_id: nil)
|
|
85
|
+
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
59
86
|
# Stateless mode doesn't support notifications
|
|
60
87
|
raise "Stateless mode does not support notifications" if @stateless
|
|
61
88
|
|
|
@@ -65,26 +92,41 @@ module MCP
|
|
|
65
92
|
}
|
|
66
93
|
notification[:params] = params if params
|
|
67
94
|
|
|
68
|
-
|
|
95
|
+
streams_to_close = []
|
|
96
|
+
|
|
97
|
+
result = @mutex.synchronize do
|
|
69
98
|
if session_id
|
|
99
|
+
# JSON response mode returns a single JSON object as the POST response,
|
|
100
|
+
# so request-scoped notifications (e.g. progress, log) cannot be delivered
|
|
101
|
+
# alongside it. Session-scoped standalone notifications
|
|
102
|
+
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
|
|
103
|
+
next false if @enable_json_response && related_request_id
|
|
104
|
+
|
|
70
105
|
# Send to specific session
|
|
71
|
-
session = @sessions[session_id]
|
|
72
|
-
|
|
106
|
+
if (session = @sessions[session_id])
|
|
107
|
+
stream = active_stream(session, related_request_id: related_request_id)
|
|
108
|
+
end
|
|
109
|
+
next false unless stream
|
|
73
110
|
|
|
74
111
|
if session_expired?(session)
|
|
75
|
-
|
|
76
|
-
|
|
112
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
113
|
+
next false
|
|
77
114
|
end
|
|
78
115
|
|
|
79
116
|
begin
|
|
80
|
-
send_to_stream(
|
|
117
|
+
send_to_stream(stream, notification)
|
|
81
118
|
true
|
|
82
119
|
rescue *STREAM_WRITE_ERRORS => e
|
|
83
120
|
MCP.configuration.exception_reporter.call(
|
|
84
121
|
e,
|
|
85
122
|
{ session_id: session_id, error: "Failed to send notification" },
|
|
86
123
|
)
|
|
87
|
-
|
|
124
|
+
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
|
|
125
|
+
session[:post_request_streams].delete(related_request_id)
|
|
126
|
+
streams_to_close << stream
|
|
127
|
+
else
|
|
128
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
129
|
+
end
|
|
88
130
|
false
|
|
89
131
|
end
|
|
90
132
|
else
|
|
@@ -93,7 +135,7 @@ module MCP
|
|
|
93
135
|
failed_sessions = []
|
|
94
136
|
|
|
95
137
|
@sessions.each do |sid, session|
|
|
96
|
-
next unless session[:
|
|
138
|
+
next unless (stream = session[:get_sse_stream])
|
|
97
139
|
|
|
98
140
|
if session_expired?(session)
|
|
99
141
|
failed_sessions << sid
|
|
@@ -101,7 +143,7 @@ module MCP
|
|
|
101
143
|
end
|
|
102
144
|
|
|
103
145
|
begin
|
|
104
|
-
send_to_stream(
|
|
146
|
+
send_to_stream(stream, notification)
|
|
105
147
|
sent_count += 1
|
|
106
148
|
rescue *STREAM_WRITE_ERRORS => e
|
|
107
149
|
MCP.configuration.exception_reporter.call(
|
|
@@ -113,11 +155,97 @@ module MCP
|
|
|
113
155
|
end
|
|
114
156
|
|
|
115
157
|
# Clean up failed sessions
|
|
116
|
-
failed_sessions.each { |sid|
|
|
158
|
+
failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
|
|
117
159
|
|
|
118
160
|
sent_count
|
|
119
161
|
end
|
|
120
162
|
end
|
|
163
|
+
|
|
164
|
+
streams_to_close.each do |stream|
|
|
165
|
+
close_stream_safely(stream)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
result
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
|
|
172
|
+
# blocks until the client responds.
|
|
173
|
+
#
|
|
174
|
+
# Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`,
|
|
175
|
+
# sends the request via SSE stream, then blocks on `queue.pop`.
|
|
176
|
+
# When the client POSTs a response, `handle_response` matches it by `request_id`
|
|
177
|
+
# and pushes the result onto the queue, unblocking this thread.
|
|
178
|
+
def send_request(method, params = nil, session_id: nil, related_request_id: nil)
|
|
179
|
+
if @stateless
|
|
180
|
+
raise "Stateless mode does not support server-to-client requests."
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if @enable_json_response
|
|
184
|
+
raise "JSON response mode does not support server-to-client requests."
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
unless session_id
|
|
188
|
+
raise "session_id is required for server-to-client requests."
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
request_id = generate_request_id
|
|
192
|
+
queue = Queue.new
|
|
193
|
+
|
|
194
|
+
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
195
|
+
request[:params] = params if params
|
|
196
|
+
|
|
197
|
+
sent = false
|
|
198
|
+
|
|
199
|
+
@mutex.synchronize do
|
|
200
|
+
unless (session = @sessions[session_id])
|
|
201
|
+
raise "Session not found: #{session_id}."
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
@pending_responses[request_id] = { queue: queue, session_id: session_id }
|
|
205
|
+
|
|
206
|
+
if (stream = active_stream(session, related_request_id: related_request_id))
|
|
207
|
+
begin
|
|
208
|
+
send_to_stream(stream, request)
|
|
209
|
+
sent = true
|
|
210
|
+
rescue *STREAM_WRITE_ERRORS
|
|
211
|
+
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
|
|
212
|
+
session[:post_request_streams].delete(related_request_id)
|
|
213
|
+
close_stream_safely(stream)
|
|
214
|
+
else
|
|
215
|
+
cleanup_session_unsafe(session_id)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# TODO: Replace with event store + replay when resumability is implemented.
|
|
222
|
+
# Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay,
|
|
223
|
+
# event store management) independent of sampling.
|
|
224
|
+
# See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery
|
|
225
|
+
#
|
|
226
|
+
# The TypeScript and Python SDKs buffer messages and replay on reconnect.
|
|
227
|
+
# Until then, raise to prevent queue.pop from blocking indefinitely.
|
|
228
|
+
unless sent
|
|
229
|
+
raise "No active stream for #{method} request."
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
response = queue.pop
|
|
233
|
+
|
|
234
|
+
if response.is_a?(Hash) && response.key?(:error)
|
|
235
|
+
raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
if response == :session_closed
|
|
239
|
+
raise "SSE session closed while waiting for #{method} response."
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
response
|
|
243
|
+
ensure
|
|
244
|
+
if request_id
|
|
245
|
+
@mutex.synchronize do
|
|
246
|
+
@pending_responses.delete(request_id)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
121
249
|
end
|
|
122
250
|
|
|
123
251
|
private
|
|
@@ -136,55 +264,58 @@ module MCP
|
|
|
136
264
|
def reap_expired_sessions
|
|
137
265
|
return unless @session_idle_timeout
|
|
138
266
|
|
|
139
|
-
|
|
140
|
-
@sessions.
|
|
141
|
-
next unless session_expired?(
|
|
267
|
+
removed_sessions = @mutex.synchronize do
|
|
268
|
+
@sessions.each_key.filter_map do |session_id|
|
|
269
|
+
next unless session_expired?(@sessions[session_id])
|
|
142
270
|
|
|
143
|
-
|
|
144
|
-
@sessions.delete(session_id)
|
|
271
|
+
cleanup_session_unsafe(session_id)
|
|
145
272
|
end
|
|
146
273
|
end
|
|
147
274
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# and will not attempt to close the same stream.
|
|
152
|
-
stream.close
|
|
153
|
-
rescue
|
|
154
|
-
nil
|
|
275
|
+
removed_sessions.each do |session|
|
|
276
|
+
close_stream_safely(session[:get_sse_stream])
|
|
277
|
+
close_post_request_streams(session)
|
|
155
278
|
end
|
|
156
279
|
end
|
|
157
280
|
|
|
158
281
|
def send_to_stream(stream, data)
|
|
159
282
|
message = data.is_a?(String) ? data : data.to_json
|
|
160
283
|
stream.write("data: #{message}\n\n")
|
|
161
|
-
stream.flush
|
|
284
|
+
stream.flush
|
|
162
285
|
end
|
|
163
286
|
|
|
164
287
|
def send_ping_to_stream(stream)
|
|
165
288
|
stream.write(": ping #{Time.now.iso8601}\n\n")
|
|
166
|
-
stream.flush
|
|
289
|
+
stream.flush
|
|
167
290
|
end
|
|
168
291
|
|
|
169
292
|
def handle_post(request)
|
|
170
|
-
|
|
293
|
+
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
|
|
294
|
+
accept_error = validate_accept_header(request, required_types)
|
|
171
295
|
return accept_error if accept_error
|
|
172
296
|
|
|
297
|
+
content_type_error = validate_content_type(request)
|
|
298
|
+
return content_type_error if content_type_error
|
|
299
|
+
|
|
173
300
|
body_string = request.body.read
|
|
174
301
|
session_id = extract_session_id(request)
|
|
175
302
|
|
|
176
303
|
body = parse_request_body(body_string)
|
|
177
304
|
return body unless body.is_a?(Hash) # Error response
|
|
178
305
|
|
|
179
|
-
if body[
|
|
306
|
+
if body[:method] == "initialize"
|
|
180
307
|
handle_initialization(body_string, body)
|
|
181
308
|
else
|
|
182
309
|
return missing_session_id_response if !@stateless && !session_id
|
|
183
310
|
|
|
184
|
-
if notification?(body)
|
|
311
|
+
if notification?(body)
|
|
185
312
|
handle_accepted
|
|
313
|
+
elsif response?(body)
|
|
314
|
+
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
315
|
+
|
|
316
|
+
handle_response(body, session_id: session_id)
|
|
186
317
|
else
|
|
187
|
-
handle_regular_request(body_string, session_id)
|
|
318
|
+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
|
188
319
|
end
|
|
189
320
|
end
|
|
190
321
|
rescue StandardError => e
|
|
@@ -228,21 +359,51 @@ module MCP
|
|
|
228
359
|
end
|
|
229
360
|
|
|
230
361
|
def cleanup_session(session_id)
|
|
231
|
-
@mutex.synchronize do
|
|
362
|
+
session = @mutex.synchronize do
|
|
232
363
|
cleanup_session_unsafe(session_id)
|
|
233
364
|
end
|
|
365
|
+
|
|
366
|
+
if session
|
|
367
|
+
close_stream_safely(session[:get_sse_stream])
|
|
368
|
+
close_post_request_streams(session)
|
|
369
|
+
end
|
|
234
370
|
end
|
|
235
371
|
|
|
372
|
+
# Removes a session from `@sessions` and returns it. Does not close the stream.
|
|
373
|
+
# Callers must close the stream outside the mutex to avoid holding the lock during
|
|
374
|
+
# potentially blocking I/O.
|
|
236
375
|
def cleanup_session_unsafe(session_id)
|
|
237
|
-
session = @sessions
|
|
238
|
-
return unless session
|
|
376
|
+
session = @sessions.delete(session_id)
|
|
239
377
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
378
|
+
# Unblock threads waiting on pending responses for this session.
|
|
379
|
+
@pending_responses.each_value do |pending_response|
|
|
380
|
+
if pending_response[:session_id] == session_id
|
|
381
|
+
pending_response[:queue].push(:session_closed)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
session
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def cleanup_and_collect_stream(session_id, streams_to_close)
|
|
389
|
+
return unless (removed = cleanup_session_unsafe(session_id))
|
|
390
|
+
|
|
391
|
+
streams_to_close << removed[:get_sse_stream]
|
|
392
|
+
removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def close_stream_safely(stream)
|
|
396
|
+
stream&.close
|
|
397
|
+
rescue StandardError
|
|
398
|
+
# Ignore close-related errors from already closed/broken streams.
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def close_post_request_streams(session)
|
|
402
|
+
return unless (post_request_streams = session[:post_request_streams])
|
|
403
|
+
|
|
404
|
+
post_request_streams.each_value do |stream|
|
|
405
|
+
close_stream_safely(stream)
|
|
244
406
|
end
|
|
245
|
-
@sessions.delete(session_id)
|
|
246
407
|
end
|
|
247
408
|
|
|
248
409
|
def extract_session_id(request)
|
|
@@ -268,6 +429,18 @@ module MCP
|
|
|
268
429
|
end
|
|
269
430
|
end
|
|
270
431
|
|
|
432
|
+
def validate_content_type(request)
|
|
433
|
+
content_type = request.env["CONTENT_TYPE"]
|
|
434
|
+
media_type = content_type&.split(";")&.first&.strip&.downcase
|
|
435
|
+
return if media_type == "application/json"
|
|
436
|
+
|
|
437
|
+
[
|
|
438
|
+
415,
|
|
439
|
+
{ "Content-Type" => "application/json" },
|
|
440
|
+
[{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
|
|
441
|
+
]
|
|
442
|
+
end
|
|
443
|
+
|
|
271
444
|
def not_acceptable_response(required_types)
|
|
272
445
|
[
|
|
273
446
|
406,
|
|
@@ -277,17 +450,35 @@ module MCP
|
|
|
277
450
|
end
|
|
278
451
|
|
|
279
452
|
def parse_request_body(body_string)
|
|
280
|
-
JSON.parse(body_string)
|
|
453
|
+
JSON.parse(body_string, symbolize_names: true)
|
|
281
454
|
rescue JSON::ParserError, TypeError
|
|
282
455
|
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
|
283
456
|
end
|
|
284
457
|
|
|
285
458
|
def notification?(body)
|
|
286
|
-
!body[
|
|
459
|
+
!body[:id] && !!body[:method]
|
|
287
460
|
end
|
|
288
461
|
|
|
289
462
|
def response?(body)
|
|
290
|
-
!!body[
|
|
463
|
+
!!body[:id] && !body[:method]
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Verifies that the response came from the expected session to prevent
|
|
467
|
+
# cross-session response injection if request IDs are ever leaked.
|
|
468
|
+
def handle_response(body, session_id:)
|
|
469
|
+
request_id = body[:id]
|
|
470
|
+
@mutex.synchronize do
|
|
471
|
+
if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
|
|
472
|
+
if body.key?(:error)
|
|
473
|
+
error = body[:error]
|
|
474
|
+
pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
|
|
475
|
+
else
|
|
476
|
+
pending_response[:queue].push(body[:result])
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
handle_accepted
|
|
291
482
|
end
|
|
292
483
|
|
|
293
484
|
def handle_initialization(body_string, body)
|
|
@@ -300,7 +491,7 @@ module MCP
|
|
|
300
491
|
|
|
301
492
|
@mutex.synchronize do
|
|
302
493
|
@sessions[session_id] = {
|
|
303
|
-
|
|
494
|
+
get_sse_stream: nil,
|
|
304
495
|
server_session: server_session,
|
|
305
496
|
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
306
497
|
}
|
|
@@ -326,9 +517,8 @@ module MCP
|
|
|
326
517
|
[202, {}, []]
|
|
327
518
|
end
|
|
328
519
|
|
|
329
|
-
def handle_regular_request(body_string, session_id)
|
|
520
|
+
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
330
521
|
server_session = nil
|
|
331
|
-
stream = nil
|
|
332
522
|
|
|
333
523
|
unless @stateless
|
|
334
524
|
if session_id
|
|
@@ -338,55 +528,104 @@ module MCP
|
|
|
338
528
|
@mutex.synchronize do
|
|
339
529
|
session = @sessions[session_id]
|
|
340
530
|
server_session = session[:server_session] if session
|
|
341
|
-
stream = session[:stream] if session
|
|
342
531
|
end
|
|
343
532
|
end
|
|
344
533
|
end
|
|
345
534
|
|
|
346
|
-
|
|
347
|
-
|
|
535
|
+
if session_id && !@stateless && !@enable_json_response
|
|
536
|
+
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
348
537
|
else
|
|
349
|
-
|
|
538
|
+
response = dispatch_handle_json(body_string, server_session)
|
|
539
|
+
[200, { "Content-Type" => "application/json" }, [response]]
|
|
350
540
|
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Returns the POST response as an SSE stream so the server can send
|
|
544
|
+
# JSON-RPC requests and notifications during request processing.
|
|
545
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
546
|
+
def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
|
|
547
|
+
body = proc do |stream|
|
|
548
|
+
@mutex.synchronize do
|
|
549
|
+
session = @sessions[session_id]
|
|
550
|
+
if session && related_request_id
|
|
551
|
+
session[:post_request_streams] ||= {}
|
|
552
|
+
session[:post_request_streams][related_request_id] = stream
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
begin
|
|
557
|
+
response = dispatch_handle_json(body_string, server_session)
|
|
558
|
+
|
|
559
|
+
send_to_stream(stream, response) if response
|
|
560
|
+
ensure
|
|
561
|
+
if related_request_id
|
|
562
|
+
@mutex.synchronize do
|
|
563
|
+
session = @sessions[session_id]
|
|
564
|
+
session[:post_request_streams]&.delete(related_request_id) if session
|
|
565
|
+
end
|
|
566
|
+
end
|
|
351
567
|
|
|
352
|
-
|
|
353
|
-
|
|
568
|
+
begin
|
|
569
|
+
stream.close
|
|
570
|
+
rescue StandardError
|
|
571
|
+
# Ignore close-related errors from already closed/broken streams.
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
[200, SSE_HEADERS.dup, body]
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Returns the SSE stream available for server-to-client messages.
|
|
580
|
+
# When `related_request_id` is given, returns only the POST response
|
|
581
|
+
# stream for that request (no fallback to GET SSE). This prevents
|
|
582
|
+
# request-scoped messages from leaking to the wrong stream.
|
|
583
|
+
# When `related_request_id` is nil, returns the GET SSE stream.
|
|
584
|
+
def active_stream(session, related_request_id: nil)
|
|
585
|
+
if related_request_id
|
|
586
|
+
session.dig(:post_request_streams, related_request_id)
|
|
354
587
|
else
|
|
355
|
-
[
|
|
588
|
+
session[:get_sse_stream]
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def dispatch_handle_json(body_string, server_session)
|
|
593
|
+
if server_session
|
|
594
|
+
server_session.handle_json(body_string)
|
|
595
|
+
else
|
|
596
|
+
@server.handle_json(body_string)
|
|
356
597
|
end
|
|
357
598
|
end
|
|
358
599
|
|
|
359
600
|
def validate_and_touch_session(session_id)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
601
|
+
removed = nil
|
|
602
|
+
|
|
603
|
+
response = @mutex.synchronize do
|
|
604
|
+
next session_not_found_response unless (session = @sessions[session_id])
|
|
605
|
+
next unless @session_idle_timeout
|
|
363
606
|
|
|
364
607
|
if session_expired?(session)
|
|
365
|
-
cleanup_session_unsafe(session_id)
|
|
366
|
-
|
|
608
|
+
removed = cleanup_session_unsafe(session_id)
|
|
609
|
+
next session_not_found_response
|
|
367
610
|
end
|
|
368
611
|
|
|
369
612
|
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
613
|
+
nil
|
|
370
614
|
end
|
|
371
615
|
|
|
372
|
-
|
|
373
|
-
|
|
616
|
+
if removed
|
|
617
|
+
close_stream_safely(removed[:get_sse_stream])
|
|
374
618
|
|
|
375
|
-
|
|
376
|
-
|
|
619
|
+
removed[:post_request_streams]&.each_value do |stream|
|
|
620
|
+
close_stream_safely(stream)
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
response
|
|
377
625
|
end
|
|
378
626
|
|
|
379
|
-
def
|
|
380
|
-
|
|
381
|
-
send_to_stream(stream, message)
|
|
382
|
-
handle_accepted
|
|
383
|
-
rescue *STREAM_WRITE_ERRORS => e
|
|
384
|
-
MCP.configuration.exception_reporter.call(
|
|
385
|
-
e,
|
|
386
|
-
{ session_id: session_id, error: "Stream closed during response" },
|
|
387
|
-
)
|
|
388
|
-
cleanup_session(session_id)
|
|
389
|
-
[200, { "Content-Type" => "application/json" }, [response]]
|
|
627
|
+
def get_session_stream(session_id)
|
|
628
|
+
@mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
|
|
390
629
|
end
|
|
391
630
|
|
|
392
631
|
def session_exists?(session_id)
|
|
@@ -416,13 +655,7 @@ module MCP
|
|
|
416
655
|
def setup_sse_stream(session_id)
|
|
417
656
|
body = create_sse_body(session_id)
|
|
418
657
|
|
|
419
|
-
|
|
420
|
-
"Content-Type" => "text/event-stream",
|
|
421
|
-
"Cache-Control" => "no-cache",
|
|
422
|
-
"Connection" => "keep-alive",
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
[200, headers, body]
|
|
658
|
+
[200, SSE_HEADERS.dup, body]
|
|
426
659
|
end
|
|
427
660
|
|
|
428
661
|
def create_sse_body(session_id)
|
|
@@ -435,8 +668,8 @@ module MCP
|
|
|
435
668
|
def store_stream_for_session(session_id, stream)
|
|
436
669
|
@mutex.synchronize do
|
|
437
670
|
session = @sessions[session_id]
|
|
438
|
-
if session && !session[:
|
|
439
|
-
session[:
|
|
671
|
+
if session && !session[:get_sse_stream]
|
|
672
|
+
session[:get_sse_stream] = stream
|
|
440
673
|
else
|
|
441
674
|
# Either session was removed, or another request already established a stream.
|
|
442
675
|
stream.close
|
|
@@ -461,13 +694,13 @@ module MCP
|
|
|
461
694
|
end
|
|
462
695
|
|
|
463
696
|
def session_active_with_stream?(session_id)
|
|
464
|
-
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:
|
|
697
|
+
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
|
|
465
698
|
end
|
|
466
699
|
|
|
467
700
|
def send_keepalive_ping(session_id)
|
|
468
701
|
@mutex.synchronize do
|
|
469
|
-
if @sessions[session_id] && @sessions[session_id][:
|
|
470
|
-
send_ping_to_stream(@sessions[session_id][:
|
|
702
|
+
if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
|
|
703
|
+
send_ping_to_stream(@sessions[session_id][:get_sse_stream])
|
|
471
704
|
end
|
|
472
705
|
end
|
|
473
706
|
rescue *STREAM_WRITE_ERRORS => e
|