mcp 0.10.0 → 0.15.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 +1384 -691
- data/lib/json_rpc_handler.rb +23 -10
- data/lib/mcp/cancellation.rb +72 -0
- data/lib/mcp/cancelled_error.rb +13 -0
- data/lib/mcp/client/http.rb +234 -15
- data/lib/mcp/client/paginated_result.rb +13 -0
- data/lib/mcp/client/stdio.rb +100 -49
- data/lib/mcp/client.rb +293 -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 +42 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +383 -88
- data/lib/mcp/server.rb +361 -50
- data/lib/mcp/server_context.rb +81 -2
- data/lib/mcp/server_session.rb +170 -12
- 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
- data/lib/mcp.rb +2 -0
- metadata +11 -6
- data/lib/mcp/transports/stdio.rb +0 -15
|
@@ -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,136 @@ 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, parent_cancellation: nil, server_session: 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
|
+
cancel_hook = nil
|
|
194
|
+
|
|
195
|
+
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
196
|
+
request[:params] = params if params
|
|
197
|
+
|
|
198
|
+
sent = false
|
|
199
|
+
|
|
200
|
+
@mutex.synchronize do
|
|
201
|
+
unless (session = @sessions[session_id])
|
|
202
|
+
raise "Session not found: #{session_id}."
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
@pending_responses[request_id] = { queue: queue, session_id: session_id }
|
|
206
|
+
|
|
207
|
+
if (stream = active_stream(session, related_request_id: related_request_id))
|
|
208
|
+
begin
|
|
209
|
+
send_to_stream(stream, request)
|
|
210
|
+
sent = true
|
|
211
|
+
rescue *STREAM_WRITE_ERRORS
|
|
212
|
+
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
|
|
213
|
+
session[:post_request_streams].delete(related_request_id)
|
|
214
|
+
close_stream_safely(stream)
|
|
215
|
+
else
|
|
216
|
+
cleanup_session_unsafe(session_id)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# TODO: Replace with event store + replay when resumability is implemented.
|
|
223
|
+
# Resumability is a separate MCP specification feature (SSE event IDs, Last-Event-ID replay,
|
|
224
|
+
# event store management) independent of sampling.
|
|
225
|
+
# See: https://modelcontextprotocol.io/specification/latest/basic/transports#resumability-and-redelivery
|
|
226
|
+
#
|
|
227
|
+
# The TypeScript and Python SDKs buffer messages and replay on reconnect.
|
|
228
|
+
# Until then, raise to prevent queue.pop from blocking indefinitely.
|
|
229
|
+
unless sent
|
|
230
|
+
raise "No active stream for #{method} request."
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if parent_cancellation && server_session
|
|
234
|
+
cancel_hook = parent_cancellation.on_cancel do |reason|
|
|
235
|
+
server_session.send_peer_cancellation(
|
|
236
|
+
nested_request_id: request_id,
|
|
237
|
+
related_request_id: related_request_id,
|
|
238
|
+
reason: reason,
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
response = queue.pop
|
|
244
|
+
|
|
245
|
+
if response.is_a?(Hash) && response.key?(:error)
|
|
246
|
+
raise StandardError, "Client returned an error for #{method} request (code: #{response[:error][:code]}): #{response[:error][:message]}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if response == :session_closed
|
|
250
|
+
raise "SSE session closed while waiting for #{method} response."
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
if response == :cancelled
|
|
254
|
+
reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) }
|
|
255
|
+
raise MCP::CancelledError.new(
|
|
256
|
+
"#{method} request was cancelled",
|
|
257
|
+
request_id: request_id,
|
|
258
|
+
reason: reason,
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
response
|
|
263
|
+
ensure
|
|
264
|
+
parent_cancellation.off_cancel(cancel_hook) if cancel_hook
|
|
265
|
+
if request_id
|
|
266
|
+
@mutex.synchronize do
|
|
267
|
+
@pending_responses.delete(request_id)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Unblocks a `send_request` awaiting a response when the peer is being cancelled.
|
|
273
|
+
# The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`.
|
|
274
|
+
#
|
|
275
|
+
# Race note: this is first-writer-wins on the pending-response queue. If a real response
|
|
276
|
+
# has already been pushed (client responded before the cancel hook fired), that response
|
|
277
|
+
# wins and `:cancelled` is enqueued behind it but never read - `send_request` returns
|
|
278
|
+
# the real response and deletes the pending entry in its `ensure` block. Conversely,
|
|
279
|
+
# if `:cancelled` arrives first, any later client response is silently dropped in `handle_response`
|
|
280
|
+
# because the pending entry has been removed.
|
|
281
|
+
def cancel_pending_request(request_id, reason: nil)
|
|
282
|
+
@mutex.synchronize do
|
|
283
|
+
if (pending = @pending_responses[request_id])
|
|
284
|
+
pending[:cancel_reason] = reason
|
|
285
|
+
pending[:queue].push(:cancelled)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
121
288
|
end
|
|
122
289
|
|
|
123
290
|
private
|
|
@@ -136,55 +303,59 @@ module MCP
|
|
|
136
303
|
def reap_expired_sessions
|
|
137
304
|
return unless @session_idle_timeout
|
|
138
305
|
|
|
139
|
-
|
|
140
|
-
@sessions.
|
|
141
|
-
next unless session_expired?(
|
|
306
|
+
removed_sessions = @mutex.synchronize do
|
|
307
|
+
@sessions.each_key.filter_map do |session_id|
|
|
308
|
+
next unless session_expired?(@sessions[session_id])
|
|
142
309
|
|
|
143
|
-
|
|
144
|
-
@sessions.delete(session_id)
|
|
310
|
+
cleanup_session_unsafe(session_id)
|
|
145
311
|
end
|
|
146
312
|
end
|
|
147
313
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
# and will not attempt to close the same stream.
|
|
152
|
-
stream.close
|
|
153
|
-
rescue
|
|
154
|
-
nil
|
|
314
|
+
removed_sessions.each do |session|
|
|
315
|
+
close_stream_safely(session[:get_sse_stream])
|
|
316
|
+
close_post_request_streams(session)
|
|
155
317
|
end
|
|
156
318
|
end
|
|
157
319
|
|
|
158
320
|
def send_to_stream(stream, data)
|
|
159
321
|
message = data.is_a?(String) ? data : data.to_json
|
|
160
322
|
stream.write("data: #{message}\n\n")
|
|
161
|
-
stream.flush
|
|
323
|
+
stream.flush
|
|
162
324
|
end
|
|
163
325
|
|
|
164
326
|
def send_ping_to_stream(stream)
|
|
165
327
|
stream.write(": ping #{Time.now.iso8601}\n\n")
|
|
166
|
-
stream.flush
|
|
328
|
+
stream.flush
|
|
167
329
|
end
|
|
168
330
|
|
|
169
331
|
def handle_post(request)
|
|
170
|
-
|
|
332
|
+
required_types = @enable_json_response ? REQUIRED_POST_ACCEPT_TYPES_JSON : REQUIRED_POST_ACCEPT_TYPES_SSE
|
|
333
|
+
accept_error = validate_accept_header(request, required_types)
|
|
171
334
|
return accept_error if accept_error
|
|
172
335
|
|
|
336
|
+
content_type_error = validate_content_type(request)
|
|
337
|
+
return content_type_error if content_type_error
|
|
338
|
+
|
|
173
339
|
body_string = request.body.read
|
|
174
340
|
session_id = extract_session_id(request)
|
|
175
341
|
|
|
176
342
|
body = parse_request_body(body_string)
|
|
177
343
|
return body unless body.is_a?(Hash) # Error response
|
|
178
344
|
|
|
179
|
-
if body[
|
|
345
|
+
if body[:method] == "initialize"
|
|
180
346
|
handle_initialization(body_string, body)
|
|
181
347
|
else
|
|
182
348
|
return missing_session_id_response if !@stateless && !session_id
|
|
183
349
|
|
|
184
|
-
if notification?(body)
|
|
350
|
+
if notification?(body)
|
|
351
|
+
dispatch_notification(body_string, session_id)
|
|
185
352
|
handle_accepted
|
|
353
|
+
elsif response?(body)
|
|
354
|
+
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
355
|
+
|
|
356
|
+
handle_response(body, session_id: session_id)
|
|
186
357
|
else
|
|
187
|
-
handle_regular_request(body_string, session_id)
|
|
358
|
+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
|
188
359
|
end
|
|
189
360
|
end
|
|
190
361
|
rescue StandardError => e
|
|
@@ -228,21 +399,51 @@ module MCP
|
|
|
228
399
|
end
|
|
229
400
|
|
|
230
401
|
def cleanup_session(session_id)
|
|
231
|
-
@mutex.synchronize do
|
|
402
|
+
session = @mutex.synchronize do
|
|
232
403
|
cleanup_session_unsafe(session_id)
|
|
233
404
|
end
|
|
405
|
+
|
|
406
|
+
if session
|
|
407
|
+
close_stream_safely(session[:get_sse_stream])
|
|
408
|
+
close_post_request_streams(session)
|
|
409
|
+
end
|
|
234
410
|
end
|
|
235
411
|
|
|
412
|
+
# Removes a session from `@sessions` and returns it. Does not close the stream.
|
|
413
|
+
# Callers must close the stream outside the mutex to avoid holding the lock during
|
|
414
|
+
# potentially blocking I/O.
|
|
236
415
|
def cleanup_session_unsafe(session_id)
|
|
237
|
-
session = @sessions
|
|
238
|
-
return unless session
|
|
416
|
+
session = @sessions.delete(session_id)
|
|
239
417
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
418
|
+
# Unblock threads waiting on pending responses for this session.
|
|
419
|
+
@pending_responses.each_value do |pending_response|
|
|
420
|
+
if pending_response[:session_id] == session_id
|
|
421
|
+
pending_response[:queue].push(:session_closed)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
session
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def cleanup_and_collect_stream(session_id, streams_to_close)
|
|
429
|
+
return unless (removed = cleanup_session_unsafe(session_id))
|
|
430
|
+
|
|
431
|
+
streams_to_close << removed[:get_sse_stream]
|
|
432
|
+
removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def close_stream_safely(stream)
|
|
436
|
+
stream&.close
|
|
437
|
+
rescue StandardError
|
|
438
|
+
# Ignore close-related errors from already closed/broken streams.
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def close_post_request_streams(session)
|
|
442
|
+
return unless (post_request_streams = session[:post_request_streams])
|
|
443
|
+
|
|
444
|
+
post_request_streams.each_value do |stream|
|
|
445
|
+
close_stream_safely(stream)
|
|
244
446
|
end
|
|
245
|
-
@sessions.delete(session_id)
|
|
246
447
|
end
|
|
247
448
|
|
|
248
449
|
def extract_session_id(request)
|
|
@@ -268,6 +469,18 @@ module MCP
|
|
|
268
469
|
end
|
|
269
470
|
end
|
|
270
471
|
|
|
472
|
+
def validate_content_type(request)
|
|
473
|
+
content_type = request.env["CONTENT_TYPE"]
|
|
474
|
+
media_type = content_type&.split(";")&.first&.strip&.downcase
|
|
475
|
+
return if media_type == "application/json"
|
|
476
|
+
|
|
477
|
+
[
|
|
478
|
+
415,
|
|
479
|
+
{ "Content-Type" => "application/json" },
|
|
480
|
+
[{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json],
|
|
481
|
+
]
|
|
482
|
+
end
|
|
483
|
+
|
|
271
484
|
def not_acceptable_response(required_types)
|
|
272
485
|
[
|
|
273
486
|
406,
|
|
@@ -277,17 +490,51 @@ module MCP
|
|
|
277
490
|
end
|
|
278
491
|
|
|
279
492
|
def parse_request_body(body_string)
|
|
280
|
-
JSON.parse(body_string)
|
|
493
|
+
JSON.parse(body_string, symbolize_names: true)
|
|
281
494
|
rescue JSON::ParserError, TypeError
|
|
282
495
|
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
|
283
496
|
end
|
|
284
497
|
|
|
285
498
|
def notification?(body)
|
|
286
|
-
!body[
|
|
499
|
+
!body[:id] && !!body[:method]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Dispatches a client-originated notification (e.g. `notifications/cancelled`,
|
|
503
|
+
# `notifications/initialized`) through the server so it can update session state.
|
|
504
|
+
def dispatch_notification(body_string, session_id)
|
|
505
|
+
server_session = nil
|
|
506
|
+
if session_id && !@stateless
|
|
507
|
+
@mutex.synchronize do
|
|
508
|
+
session = @sessions[session_id]
|
|
509
|
+
server_session = session[:server_session] if session
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
dispatch_handle_json(body_string, server_session)
|
|
514
|
+
rescue => e
|
|
515
|
+
MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
|
|
287
516
|
end
|
|
288
517
|
|
|
289
518
|
def response?(body)
|
|
290
|
-
!!body[
|
|
519
|
+
!!body[:id] && !body[:method]
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Verifies that the response came from the expected session to prevent
|
|
523
|
+
# cross-session response injection if request IDs are ever leaked.
|
|
524
|
+
def handle_response(body, session_id:)
|
|
525
|
+
request_id = body[:id]
|
|
526
|
+
@mutex.synchronize do
|
|
527
|
+
if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
|
|
528
|
+
if body.key?(:error)
|
|
529
|
+
error = body[:error]
|
|
530
|
+
pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
|
|
531
|
+
else
|
|
532
|
+
pending_response[:queue].push(body[:result])
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
handle_accepted
|
|
291
538
|
end
|
|
292
539
|
|
|
293
540
|
def handle_initialization(body_string, body)
|
|
@@ -300,7 +547,7 @@ module MCP
|
|
|
300
547
|
|
|
301
548
|
@mutex.synchronize do
|
|
302
549
|
@sessions[session_id] = {
|
|
303
|
-
|
|
550
|
+
get_sse_stream: nil,
|
|
304
551
|
server_session: server_session,
|
|
305
552
|
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
306
553
|
}
|
|
@@ -326,9 +573,8 @@ module MCP
|
|
|
326
573
|
[202, {}, []]
|
|
327
574
|
end
|
|
328
575
|
|
|
329
|
-
def handle_regular_request(body_string, session_id)
|
|
576
|
+
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
330
577
|
server_session = nil
|
|
331
|
-
stream = nil
|
|
332
578
|
|
|
333
579
|
unless @stateless
|
|
334
580
|
if session_id
|
|
@@ -338,55 +584,110 @@ module MCP
|
|
|
338
584
|
@mutex.synchronize do
|
|
339
585
|
session = @sessions[session_id]
|
|
340
586
|
server_session = session[:server_session] if session
|
|
341
|
-
stream = session[:stream] if session
|
|
342
587
|
end
|
|
343
588
|
end
|
|
344
589
|
end
|
|
345
590
|
|
|
346
|
-
|
|
347
|
-
|
|
591
|
+
if session_id && !@stateless && !@enable_json_response
|
|
592
|
+
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
348
593
|
else
|
|
349
|
-
|
|
594
|
+
response = dispatch_handle_json(body_string, server_session)
|
|
595
|
+
|
|
596
|
+
# `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
|
|
597
|
+
# Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
|
|
598
|
+
# which would produce an empty body the client cannot parse as JSON.
|
|
599
|
+
return handle_accepted if response.nil?
|
|
600
|
+
|
|
601
|
+
[200, { "Content-Type" => "application/json" }, [response]]
|
|
350
602
|
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Returns the POST response as an SSE stream so the server can send
|
|
606
|
+
# JSON-RPC requests and notifications during request processing.
|
|
607
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
608
|
+
def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
|
|
609
|
+
body = proc do |stream|
|
|
610
|
+
@mutex.synchronize do
|
|
611
|
+
session = @sessions[session_id]
|
|
612
|
+
if session && related_request_id
|
|
613
|
+
session[:post_request_streams] ||= {}
|
|
614
|
+
session[:post_request_streams][related_request_id] = stream
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
begin
|
|
619
|
+
response = dispatch_handle_json(body_string, server_session)
|
|
351
620
|
|
|
352
|
-
|
|
353
|
-
|
|
621
|
+
send_to_stream(stream, response) if response
|
|
622
|
+
ensure
|
|
623
|
+
if related_request_id
|
|
624
|
+
@mutex.synchronize do
|
|
625
|
+
session = @sessions[session_id]
|
|
626
|
+
session[:post_request_streams]&.delete(related_request_id) if session
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
begin
|
|
631
|
+
stream.close
|
|
632
|
+
rescue StandardError
|
|
633
|
+
# Ignore close-related errors from already closed/broken streams.
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
[200, SSE_HEADERS.dup, body]
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# Returns the SSE stream available for server-to-client messages.
|
|
642
|
+
# When `related_request_id` is given, returns only the POST response
|
|
643
|
+
# stream for that request (no fallback to GET SSE). This prevents
|
|
644
|
+
# request-scoped messages from leaking to the wrong stream.
|
|
645
|
+
# When `related_request_id` is nil, returns the GET SSE stream.
|
|
646
|
+
def active_stream(session, related_request_id: nil)
|
|
647
|
+
if related_request_id
|
|
648
|
+
session.dig(:post_request_streams, related_request_id)
|
|
354
649
|
else
|
|
355
|
-
[
|
|
650
|
+
session[:get_sse_stream]
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def dispatch_handle_json(body_string, server_session)
|
|
655
|
+
if server_session
|
|
656
|
+
server_session.handle_json(body_string)
|
|
657
|
+
else
|
|
658
|
+
@server.handle_json(body_string)
|
|
356
659
|
end
|
|
357
660
|
end
|
|
358
661
|
|
|
359
662
|
def validate_and_touch_session(session_id)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
663
|
+
removed = nil
|
|
664
|
+
|
|
665
|
+
response = @mutex.synchronize do
|
|
666
|
+
next session_not_found_response unless (session = @sessions[session_id])
|
|
667
|
+
next unless @session_idle_timeout
|
|
363
668
|
|
|
364
669
|
if session_expired?(session)
|
|
365
|
-
cleanup_session_unsafe(session_id)
|
|
366
|
-
|
|
670
|
+
removed = cleanup_session_unsafe(session_id)
|
|
671
|
+
next session_not_found_response
|
|
367
672
|
end
|
|
368
673
|
|
|
369
674
|
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
675
|
+
nil
|
|
370
676
|
end
|
|
371
677
|
|
|
372
|
-
|
|
373
|
-
|
|
678
|
+
if removed
|
|
679
|
+
close_stream_safely(removed[:get_sse_stream])
|
|
374
680
|
|
|
375
|
-
|
|
376
|
-
|
|
681
|
+
removed[:post_request_streams]&.each_value do |stream|
|
|
682
|
+
close_stream_safely(stream)
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
response
|
|
377
687
|
end
|
|
378
688
|
|
|
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]]
|
|
689
|
+
def get_session_stream(session_id)
|
|
690
|
+
@mutex.synchronize { @sessions[session_id]&.fetch(:get_sse_stream, nil) }
|
|
390
691
|
end
|
|
391
692
|
|
|
392
693
|
def session_exists?(session_id)
|
|
@@ -416,13 +717,7 @@ module MCP
|
|
|
416
717
|
def setup_sse_stream(session_id)
|
|
417
718
|
body = create_sse_body(session_id)
|
|
418
719
|
|
|
419
|
-
|
|
420
|
-
"Content-Type" => "text/event-stream",
|
|
421
|
-
"Cache-Control" => "no-cache",
|
|
422
|
-
"Connection" => "keep-alive",
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
[200, headers, body]
|
|
720
|
+
[200, SSE_HEADERS.dup, body]
|
|
426
721
|
end
|
|
427
722
|
|
|
428
723
|
def create_sse_body(session_id)
|
|
@@ -435,8 +730,8 @@ module MCP
|
|
|
435
730
|
def store_stream_for_session(session_id, stream)
|
|
436
731
|
@mutex.synchronize do
|
|
437
732
|
session = @sessions[session_id]
|
|
438
|
-
if session && !session[:
|
|
439
|
-
session[:
|
|
733
|
+
if session && !session[:get_sse_stream]
|
|
734
|
+
session[:get_sse_stream] = stream
|
|
440
735
|
else
|
|
441
736
|
# Either session was removed, or another request already established a stream.
|
|
442
737
|
stream.close
|
|
@@ -461,13 +756,13 @@ module MCP
|
|
|
461
756
|
end
|
|
462
757
|
|
|
463
758
|
def session_active_with_stream?(session_id)
|
|
464
|
-
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:
|
|
759
|
+
@mutex.synchronize { @sessions.key?(session_id) && @sessions[session_id][:get_sse_stream] }
|
|
465
760
|
end
|
|
466
761
|
|
|
467
762
|
def send_keepalive_ping(session_id)
|
|
468
763
|
@mutex.synchronize do
|
|
469
|
-
if @sessions[session_id] && @sessions[session_id][:
|
|
470
|
-
send_ping_to_stream(@sessions[session_id][:
|
|
764
|
+
if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
|
|
765
|
+
send_ping_to_stream(@sessions[session_id][:get_sse_stream])
|
|
471
766
|
end
|
|
472
767
|
end
|
|
473
768
|
rescue *STREAM_WRITE_ERRORS => e
|