mcp 0.10.0 → 0.12.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 +577 -355
- data/lib/json_rpc_handler.rb +1 -1
- data/lib/mcp/client/http.rb +4 -1
- data/lib/mcp/client.rb +62 -48
- data/lib/mcp/progress.rb +3 -1
- data/lib/mcp/server/transports/stdio_transport.rb +35 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +289 -83
- data/lib/mcp/server.rb +181 -23
- data/lib/mcp/server_context.rb +16 -2
- data/lib/mcp/server_session.rb +35 -7
- data/lib/mcp/tool/schema.rb +1 -14
- data/lib/mcp/transport.rb +14 -0
- data/lib/mcp/version.rb +1 -1
- metadata +5 -3
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "securerandom"
|
|
5
4
|
require_relative "../../transport"
|
|
6
5
|
|
|
7
6
|
module MCP
|
|
8
7
|
class Server
|
|
9
8
|
module Transports
|
|
10
9
|
class StreamableHTTPTransport < Transport
|
|
10
|
+
SSE_HEADERS = {
|
|
11
|
+
"Content-Type" => "text/event-stream",
|
|
12
|
+
"Cache-Control" => "no-cache",
|
|
13
|
+
"Connection" => "keep-alive",
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
11
16
|
def initialize(server, stateless: false, session_idle_timeout: nil)
|
|
12
17
|
super(server)
|
|
13
|
-
# Maps `session_id` to `{
|
|
18
|
+
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
|
|
14
19
|
@sessions = {}
|
|
15
20
|
@mutex = Mutex.new
|
|
16
21
|
|
|
17
22
|
@stateless = stateless
|
|
18
23
|
@session_idle_timeout = session_idle_timeout
|
|
24
|
+
@pending_responses = {}
|
|
19
25
|
|
|
20
26
|
if @session_idle_timeout
|
|
21
27
|
if @stateless
|
|
@@ -50,12 +56,17 @@ module MCP
|
|
|
50
56
|
@reaper_thread&.kill
|
|
51
57
|
@reaper_thread = nil
|
|
52
58
|
|
|
53
|
-
@mutex.synchronize do
|
|
54
|
-
@sessions.each_key { |session_id| cleanup_session_unsafe(session_id) }
|
|
59
|
+
removed_sessions = @mutex.synchronize do
|
|
60
|
+
@sessions.each_key.filter_map { |session_id| cleanup_session_unsafe(session_id) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
removed_sessions.each do |session|
|
|
64
|
+
close_stream_safely(session[:get_sse_stream])
|
|
65
|
+
close_post_request_streams(session)
|
|
55
66
|
end
|
|
56
67
|
end
|
|
57
68
|
|
|
58
|
-
def send_notification(method, params = nil, session_id: nil)
|
|
69
|
+
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
59
70
|
# Stateless mode doesn't support notifications
|
|
60
71
|
raise "Stateless mode does not support notifications" if @stateless
|
|
61
72
|
|
|
@@ -65,26 +76,35 @@ module MCP
|
|
|
65
76
|
}
|
|
66
77
|
notification[:params] = params if params
|
|
67
78
|
|
|
68
|
-
|
|
79
|
+
streams_to_close = []
|
|
80
|
+
|
|
81
|
+
result = @mutex.synchronize do
|
|
69
82
|
if session_id
|
|
70
83
|
# Send to specific session
|
|
71
|
-
session = @sessions[session_id]
|
|
72
|
-
|
|
84
|
+
if (session = @sessions[session_id])
|
|
85
|
+
stream = active_stream(session, related_request_id: related_request_id)
|
|
86
|
+
end
|
|
87
|
+
next false unless stream
|
|
73
88
|
|
|
74
89
|
if session_expired?(session)
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
91
|
+
next false
|
|
77
92
|
end
|
|
78
93
|
|
|
79
94
|
begin
|
|
80
|
-
send_to_stream(
|
|
95
|
+
send_to_stream(stream, notification)
|
|
81
96
|
true
|
|
82
97
|
rescue *STREAM_WRITE_ERRORS => e
|
|
83
98
|
MCP.configuration.exception_reporter.call(
|
|
84
99
|
e,
|
|
85
100
|
{ session_id: session_id, error: "Failed to send notification" },
|
|
86
101
|
)
|
|
87
|
-
|
|
102
|
+
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
|
|
103
|
+
session[:post_request_streams].delete(related_request_id)
|
|
104
|
+
streams_to_close << stream
|
|
105
|
+
else
|
|
106
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
107
|
+
end
|
|
88
108
|
false
|
|
89
109
|
end
|
|
90
110
|
else
|
|
@@ -93,7 +113,7 @@ module MCP
|
|
|
93
113
|
failed_sessions = []
|
|
94
114
|
|
|
95
115
|
@sessions.each do |sid, session|
|
|
96
|
-
next unless session[:
|
|
116
|
+
next unless (stream = session[:get_sse_stream])
|
|
97
117
|
|
|
98
118
|
if session_expired?(session)
|
|
99
119
|
failed_sessions << sid
|
|
@@ -101,7 +121,7 @@ module MCP
|
|
|
101
121
|
end
|
|
102
122
|
|
|
103
123
|
begin
|
|
104
|
-
send_to_stream(
|
|
124
|
+
send_to_stream(stream, notification)
|
|
105
125
|
sent_count += 1
|
|
106
126
|
rescue *STREAM_WRITE_ERRORS => e
|
|
107
127
|
MCP.configuration.exception_reporter.call(
|
|
@@ -113,11 +133,93 @@ module MCP
|
|
|
113
133
|
end
|
|
114
134
|
|
|
115
135
|
# Clean up failed sessions
|
|
116
|
-
failed_sessions.each { |sid|
|
|
136
|
+
failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
|
|
117
137
|
|
|
118
138
|
sent_count
|
|
119
139
|
end
|
|
120
140
|
end
|
|
141
|
+
|
|
142
|
+
streams_to_close.each do |stream|
|
|
143
|
+
close_stream_safely(stream)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
result
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
|
|
150
|
+
# blocks until the client responds.
|
|
151
|
+
#
|
|
152
|
+
# Uses a `Queue` for cross-thread synchronization. This method creates a `Queue`,
|
|
153
|
+
# sends the request via SSE stream, then blocks on `queue.pop`.
|
|
154
|
+
# When the client POSTs a response, `handle_response` matches it by `request_id`
|
|
155
|
+
# and pushes the result onto the queue, unblocking this thread.
|
|
156
|
+
def send_request(method, params = nil, session_id: nil, related_request_id: nil)
|
|
157
|
+
if @stateless
|
|
158
|
+
raise "Stateless mode does not support server-to-client requests."
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
unless session_id
|
|
162
|
+
raise "session_id is required for server-to-client requests."
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
request_id = generate_request_id
|
|
166
|
+
queue = Queue.new
|
|
167
|
+
|
|
168
|
+
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
169
|
+
request[:params] = params if params
|
|
170
|
+
|
|
171
|
+
sent = false
|
|
172
|
+
|
|
173
|
+
@mutex.synchronize do
|
|
174
|
+
unless (session = @sessions[session_id])
|
|
175
|
+
raise "Session not found: #{session_id}."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@pending_responses[request_id] = { queue: queue, session_id: session_id }
|
|
179
|
+
|
|
180
|
+
if (stream = active_stream(session, related_request_id: related_request_id))
|
|
181
|
+
begin
|
|
182
|
+
send_to_stream(stream, request)
|
|
183
|
+
sent = true
|
|
184
|
+
rescue *STREAM_WRITE_ERRORS
|
|
185
|
+
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
|
|
186
|
+
session[:post_request_streams].delete(related_request_id)
|
|
187
|
+
close_stream_safely(stream)
|
|
188
|
+
else
|
|
189
|
+
cleanup_session_unsafe(session_id)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# TODO: Replace with event store + replay when resumability is implemented.
|
|
196
|
+
# Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay,
|
|
197
|
+
# event store management) independent of sampling.
|
|
198
|
+
# See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery
|
|
199
|
+
#
|
|
200
|
+
# The TypeScript and Python SDKs buffer messages and replay on reconnect.
|
|
201
|
+
# Until then, raise to prevent queue.pop from blocking indefinitely.
|
|
202
|
+
unless sent
|
|
203
|
+
raise "No active stream for #{method} request."
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
response = queue.pop
|
|
207
|
+
|
|
208
|
+
if response.is_a?(Hash) && response.key?(:error)
|
|
209
|
+
raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
if response == :session_closed
|
|
213
|
+
raise "SSE session closed while waiting for #{method} response."
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
response
|
|
217
|
+
ensure
|
|
218
|
+
if request_id
|
|
219
|
+
@mutex.synchronize do
|
|
220
|
+
@pending_responses.delete(request_id)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
121
223
|
end
|
|
122
224
|
|
|
123
225
|
private
|
|
@@ -136,22 +238,17 @@ module MCP
|
|
|
136
238
|
def reap_expired_sessions
|
|
137
239
|
return unless @session_idle_timeout
|
|
138
240
|
|
|
139
|
-
|
|
140
|
-
@sessions.
|
|
141
|
-
next unless session_expired?(
|
|
241
|
+
removed_sessions = @mutex.synchronize do
|
|
242
|
+
@sessions.each_key.filter_map do |session_id|
|
|
243
|
+
next unless session_expired?(@sessions[session_id])
|
|
142
244
|
|
|
143
|
-
|
|
144
|
-
@sessions.delete(session_id)
|
|
245
|
+
cleanup_session_unsafe(session_id)
|
|
145
246
|
end
|
|
146
247
|
end
|
|
147
248
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# and will not attempt to close the same stream.
|
|
152
|
-
stream.close
|
|
153
|
-
rescue
|
|
154
|
-
nil
|
|
249
|
+
removed_sessions.each do |session|
|
|
250
|
+
close_stream_safely(session[:get_sse_stream])
|
|
251
|
+
close_post_request_streams(session)
|
|
155
252
|
end
|
|
156
253
|
end
|
|
157
254
|
|
|
@@ -170,21 +267,28 @@ module MCP
|
|
|
170
267
|
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
|
|
171
268
|
return accept_error if accept_error
|
|
172
269
|
|
|
270
|
+
content_type_error = validate_content_type(request)
|
|
271
|
+
return content_type_error if content_type_error
|
|
272
|
+
|
|
173
273
|
body_string = request.body.read
|
|
174
274
|
session_id = extract_session_id(request)
|
|
175
275
|
|
|
176
276
|
body = parse_request_body(body_string)
|
|
177
277
|
return body unless body.is_a?(Hash) # Error response
|
|
178
278
|
|
|
179
|
-
if body[
|
|
279
|
+
if body[:method] == "initialize"
|
|
180
280
|
handle_initialization(body_string, body)
|
|
181
281
|
else
|
|
182
282
|
return missing_session_id_response if !@stateless && !session_id
|
|
183
283
|
|
|
184
|
-
if notification?(body)
|
|
284
|
+
if notification?(body)
|
|
185
285
|
handle_accepted
|
|
286
|
+
elsif response?(body)
|
|
287
|
+
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
288
|
+
|
|
289
|
+
handle_response(body, session_id: session_id)
|
|
186
290
|
else
|
|
187
|
-
handle_regular_request(body_string, session_id)
|
|
291
|
+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
|
188
292
|
end
|
|
189
293
|
end
|
|
190
294
|
rescue StandardError => e
|
|
@@ -228,21 +332,51 @@ module MCP
|
|
|
228
332
|
end
|
|
229
333
|
|
|
230
334
|
def cleanup_session(session_id)
|
|
231
|
-
@mutex.synchronize do
|
|
335
|
+
session = @mutex.synchronize do
|
|
232
336
|
cleanup_session_unsafe(session_id)
|
|
233
337
|
end
|
|
338
|
+
|
|
339
|
+
if session
|
|
340
|
+
close_stream_safely(session[:get_sse_stream])
|
|
341
|
+
close_post_request_streams(session)
|
|
342
|
+
end
|
|
234
343
|
end
|
|
235
344
|
|
|
345
|
+
# Removes a session from `@sessions` and returns it. Does not close the stream.
|
|
346
|
+
# Callers must close the stream outside the mutex to avoid holding the lock during
|
|
347
|
+
# potentially blocking I/O.
|
|
236
348
|
def cleanup_session_unsafe(session_id)
|
|
237
|
-
session = @sessions
|
|
238
|
-
return unless session
|
|
349
|
+
session = @sessions.delete(session_id)
|
|
239
350
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
351
|
+
# Unblock threads waiting on pending responses for this session.
|
|
352
|
+
@pending_responses.each_value do |pending_response|
|
|
353
|
+
if pending_response[:session_id] == session_id
|
|
354
|
+
pending_response[:queue].push(:session_closed)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
session
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def cleanup_and_collect_stream(session_id, streams_to_close)
|
|
362
|
+
return unless (removed = cleanup_session_unsafe(session_id))
|
|
363
|
+
|
|
364
|
+
streams_to_close << removed[:get_sse_stream]
|
|
365
|
+
removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def close_stream_safely(stream)
|
|
369
|
+
stream&.close
|
|
370
|
+
rescue StandardError
|
|
371
|
+
# Ignore close-related errors from already closed/broken streams.
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def close_post_request_streams(session)
|
|
375
|
+
return unless (post_request_streams = session[:post_request_streams])
|
|
376
|
+
|
|
377
|
+
post_request_streams.each_value do |stream|
|
|
378
|
+
close_stream_safely(stream)
|
|
244
379
|
end
|
|
245
|
-
@sessions.delete(session_id)
|
|
246
380
|
end
|
|
247
381
|
|
|
248
382
|
def extract_session_id(request)
|
|
@@ -268,6 +402,18 @@ module MCP
|
|
|
268
402
|
end
|
|
269
403
|
end
|
|
270
404
|
|
|
405
|
+
def validate_content_type(request)
|
|
406
|
+
content_type = request.env["CONTENT_TYPE"]
|
|
407
|
+
media_type = content_type&.split(";")&.first&.strip&.downcase
|
|
408
|
+
return if media_type == "application/json"
|
|
409
|
+
|
|
410
|
+
[
|
|
411
|
+
415,
|
|
412
|
+
{ "Content-Type" => "application/json" },
|
|
413
|
+
[{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
|
|
414
|
+
]
|
|
415
|
+
end
|
|
416
|
+
|
|
271
417
|
def not_acceptable_response(required_types)
|
|
272
418
|
[
|
|
273
419
|
406,
|
|
@@ -277,17 +423,35 @@ module MCP
|
|
|
277
423
|
end
|
|
278
424
|
|
|
279
425
|
def parse_request_body(body_string)
|
|
280
|
-
JSON.parse(body_string)
|
|
426
|
+
JSON.parse(body_string, symbolize_names: true)
|
|
281
427
|
rescue JSON::ParserError, TypeError
|
|
282
428
|
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
|
283
429
|
end
|
|
284
430
|
|
|
285
431
|
def notification?(body)
|
|
286
|
-
!body[
|
|
432
|
+
!body[:id] && !!body[:method]
|
|
287
433
|
end
|
|
288
434
|
|
|
289
435
|
def response?(body)
|
|
290
|
-
!!body[
|
|
436
|
+
!!body[:id] && !body[:method]
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Verifies that the response came from the expected session to prevent
|
|
440
|
+
# cross-session response injection if request IDs are ever leaked.
|
|
441
|
+
def handle_response(body, session_id:)
|
|
442
|
+
request_id = body[:id]
|
|
443
|
+
@mutex.synchronize do
|
|
444
|
+
if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
|
|
445
|
+
if body.key?(:error)
|
|
446
|
+
error = body[:error]
|
|
447
|
+
pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
|
|
448
|
+
else
|
|
449
|
+
pending_response[:queue].push(body[:result])
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
handle_accepted
|
|
291
455
|
end
|
|
292
456
|
|
|
293
457
|
def handle_initialization(body_string, body)
|
|
@@ -300,7 +464,7 @@ module MCP
|
|
|
300
464
|
|
|
301
465
|
@mutex.synchronize do
|
|
302
466
|
@sessions[session_id] = {
|
|
303
|
-
|
|
467
|
+
get_sse_stream: nil,
|
|
304
468
|
server_session: server_session,
|
|
305
469
|
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
306
470
|
}
|
|
@@ -326,9 +490,8 @@ module MCP
|
|
|
326
490
|
[202, {}, []]
|
|
327
491
|
end
|
|
328
492
|
|
|
329
|
-
def handle_regular_request(body_string, session_id)
|
|
493
|
+
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
330
494
|
server_session = nil
|
|
331
|
-
stream = nil
|
|
332
495
|
|
|
333
496
|
unless @stateless
|
|
334
497
|
if session_id
|
|
@@ -338,55 +501,104 @@ module MCP
|
|
|
338
501
|
@mutex.synchronize do
|
|
339
502
|
session = @sessions[session_id]
|
|
340
503
|
server_session = session[:server_session] if session
|
|
341
|
-
stream = session[:stream] if session
|
|
342
504
|
end
|
|
343
505
|
end
|
|
344
506
|
end
|
|
345
507
|
|
|
346
|
-
|
|
347
|
-
|
|
508
|
+
if session_id && !@stateless
|
|
509
|
+
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
348
510
|
else
|
|
349
|
-
|
|
511
|
+
response = dispatch_handle_json(body_string, server_session)
|
|
512
|
+
[200, { "Content-Type" => "application/json" }, [response]]
|
|
350
513
|
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Returns the POST response as an SSE stream so the server can send
|
|
517
|
+
# JSON-RPC requests and notifications during request processing.
|
|
518
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
519
|
+
def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
|
|
520
|
+
body = proc do |stream|
|
|
521
|
+
@mutex.synchronize do
|
|
522
|
+
session = @sessions[session_id]
|
|
523
|
+
if session && related_request_id
|
|
524
|
+
session[:post_request_streams] ||= {}
|
|
525
|
+
session[:post_request_streams][related_request_id] = stream
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
begin
|
|
530
|
+
response = dispatch_handle_json(body_string, server_session)
|
|
351
531
|
|
|
352
|
-
|
|
353
|
-
|
|
532
|
+
send_to_stream(stream, response) if response
|
|
533
|
+
ensure
|
|
534
|
+
if related_request_id
|
|
535
|
+
@mutex.synchronize do
|
|
536
|
+
session = @sessions[session_id]
|
|
537
|
+
session[:post_request_streams]&.delete(related_request_id) if session
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
begin
|
|
542
|
+
stream.close
|
|
543
|
+
rescue StandardError
|
|
544
|
+
# Ignore close-related errors from already closed/broken streams.
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
[200, SSE_HEADERS, body]
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Returns the SSE stream available for server-to-client messages.
|
|
553
|
+
# When `related_request_id` is given, returns only the POST response
|
|
554
|
+
# stream for that request (no fallback to GET SSE). This prevents
|
|
555
|
+
# request-scoped messages from leaking to the wrong stream.
|
|
556
|
+
# When `related_request_id` is nil, returns the GET SSE stream.
|
|
557
|
+
def active_stream(session, related_request_id: nil)
|
|
558
|
+
if related_request_id
|
|
559
|
+
session.dig(:post_request_streams, related_request_id)
|
|
354
560
|
else
|
|
355
|
-
[
|
|
561
|
+
session[:get_sse_stream]
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def dispatch_handle_json(body_string, server_session)
|
|
566
|
+
if server_session
|
|
567
|
+
server_session.handle_json(body_string)
|
|
568
|
+
else
|
|
569
|
+
@server.handle_json(body_string)
|
|
356
570
|
end
|
|
357
571
|
end
|
|
358
572
|
|
|
359
573
|
def validate_and_touch_session(session_id)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
574
|
+
removed = nil
|
|
575
|
+
|
|
576
|
+
response = @mutex.synchronize do
|
|
577
|
+
next session_not_found_response unless (session = @sessions[session_id])
|
|
578
|
+
next unless @session_idle_timeout
|
|
363
579
|
|
|
364
580
|
if session_expired?(session)
|
|
365
|
-
cleanup_session_unsafe(session_id)
|
|
366
|
-
|
|
581
|
+
removed = cleanup_session_unsafe(session_id)
|
|
582
|
+
next session_not_found_response
|
|
367
583
|
end
|
|
368
584
|
|
|
369
585
|
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
586
|
+
nil
|
|
370
587
|
end
|
|
371
588
|
|
|
372
|
-
|
|
373
|
-
|
|
589
|
+
if removed
|
|
590
|
+
close_stream_safely(removed[:get_sse_stream])
|
|
374
591
|
|
|
375
|
-
|
|
376
|
-
|
|
592
|
+
removed[:post_request_streams]&.each_value do |stream|
|
|
593
|
+
close_stream_safely(stream)
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
response
|
|
377
598
|
end
|
|
378
599
|
|
|
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]]
|
|
600
|
+
def get_session_stream(session_id)
|
|
601
|
+
@mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
|
|
390
602
|
end
|
|
391
603
|
|
|
392
604
|
def session_exists?(session_id)
|
|
@@ -416,13 +628,7 @@ module MCP
|
|
|
416
628
|
def setup_sse_stream(session_id)
|
|
417
629
|
body = create_sse_body(session_id)
|
|
418
630
|
|
|
419
|
-
|
|
420
|
-
"Content-Type" => "text/event-stream",
|
|
421
|
-
"Cache-Control" => "no-cache",
|
|
422
|
-
"Connection" => "keep-alive",
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
[200, headers, body]
|
|
631
|
+
[200, SSE_HEADERS, body]
|
|
426
632
|
end
|
|
427
633
|
|
|
428
634
|
def create_sse_body(session_id)
|
|
@@ -435,8 +641,8 @@ module MCP
|
|
|
435
641
|
def store_stream_for_session(session_id, stream)
|
|
436
642
|
@mutex.synchronize do
|
|
437
643
|
session = @sessions[session_id]
|
|
438
|
-
if session && !session[:
|
|
439
|
-
session[:
|
|
644
|
+
if session && !session[:get_sse_stream]
|
|
645
|
+
session[:get_sse_stream] = stream
|
|
440
646
|
else
|
|
441
647
|
# Either session was removed, or another request already established a stream.
|
|
442
648
|
stream.close
|
|
@@ -461,13 +667,13 @@ module MCP
|
|
|
461
667
|
end
|
|
462
668
|
|
|
463
669
|
def session_active_with_stream?(session_id)
|
|
464
|
-
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:
|
|
670
|
+
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
|
|
465
671
|
end
|
|
466
672
|
|
|
467
673
|
def send_keepalive_ping(session_id)
|
|
468
674
|
@mutex.synchronize do
|
|
469
|
-
if @sessions[session_id] && @sessions[session_id][:
|
|
470
|
-
send_ping_to_stream(@sessions[session_id][:
|
|
675
|
+
if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
|
|
676
|
+
send_ping_to_stream(@sessions[session_id][:get_sse_stream])
|
|
471
677
|
end
|
|
472
678
|
end
|
|
473
679
|
rescue *STREAM_WRITE_ERRORS => e
|