mcp 0.4.0 → 0.11.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/LICENSE +216 -0
- data/README.md +550 -63
- data/lib/json_rpc_handler.rb +171 -0
- data/lib/mcp/annotations.rb +21 -0
- data/lib/mcp/client/http.rb +23 -7
- data/lib/mcp/client/stdio.rb +222 -0
- data/lib/mcp/client.rb +109 -34
- data/lib/mcp/configuration.rb +11 -9
- data/lib/mcp/content.rb +29 -2
- data/lib/mcp/icon.rb +22 -0
- data/lib/mcp/instrumentation.rb +1 -1
- data/lib/mcp/logging_message_notification.rb +30 -0
- data/lib/mcp/methods.rb +3 -0
- data/lib/mcp/progress.rb +24 -0
- data/lib/mcp/prompt/message.rb +1 -1
- data/lib/mcp/prompt/result.rb +1 -1
- data/lib/mcp/prompt.rb +22 -5
- data/lib/mcp/resource/contents.rb +2 -2
- data/lib/mcp/resource/embedded.rb +2 -1
- data/lib/mcp/resource.rb +7 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/transports/stdio_transport.rb +41 -4
- data/lib/mcp/server/transports/streamable_http_transport.rb +456 -85
- data/lib/mcp/server/transports.rb +10 -0
- data/lib/mcp/server.rb +403 -67
- data/lib/mcp/server_context.rb +58 -0
- data/lib/mcp/server_session.rb +107 -0
- data/lib/mcp/string_utils.rb +3 -3
- data/lib/mcp/tool/annotations.rb +1 -1
- data/lib/mcp/tool/input_schema.rb +6 -55
- data/lib/mcp/tool/output_schema.rb +3 -54
- data/lib/mcp/tool/response.rb +1 -1
- data/lib/mcp/tool/schema.rb +48 -0
- data/lib/mcp/tool.rb +39 -5
- data/lib/mcp/transport.rb +15 -2
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +12 -31
- metadata +21 -42
- data/.gitattributes +0 -4
- data/.github/dependabot.yml +0 -6
- data/.github/workflows/ci.yml +0 -33
- data/.github/workflows/release.yml +0 -25
- data/.gitignore +0 -10
- data/.rubocop.yml +0 -12
- data/AGENTS.md +0 -119
- data/CHANGELOG.md +0 -87
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -27
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -17
- data/bin/console +0 -15
- data/bin/rake +0 -31
- data/bin/setup +0 -8
- data/dev.yml +0 -31
- data/examples/README.md +0 -197
- data/examples/http_client.rb +0 -184
- data/examples/http_server.rb +0 -170
- data/examples/stdio_server.rb +0 -94
- data/examples/streamable_http_client.rb +0 -203
- data/examples/streamable_http_server.rb +0 -172
- data/mcp.gemspec +0 -32
|
@@ -1,20 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../../transport"
|
|
4
3
|
require "json"
|
|
5
|
-
|
|
4
|
+
require_relative "../../transport"
|
|
6
5
|
|
|
7
6
|
module MCP
|
|
8
7
|
class Server
|
|
9
8
|
module Transports
|
|
10
9
|
class StreamableHTTPTransport < Transport
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
SSE_HEADERS = {
|
|
11
|
+
"Content-Type" => "text/event-stream",
|
|
12
|
+
"Cache-Control" => "no-cache",
|
|
13
|
+
"Connection" => "keep-alive",
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(server, stateless: false, session_idle_timeout: nil)
|
|
17
|
+
super(server)
|
|
18
|
+
# Maps `session_id` to `{ stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
|
|
14
19
|
@sessions = {}
|
|
15
20
|
@mutex = Mutex.new
|
|
21
|
+
|
|
22
|
+
@stateless = stateless
|
|
23
|
+
@session_idle_timeout = session_idle_timeout
|
|
24
|
+
@pending_responses = {}
|
|
25
|
+
|
|
26
|
+
if @session_idle_timeout
|
|
27
|
+
if @stateless
|
|
28
|
+
raise ArgumentError, "session_idle_timeout is not supported in stateless mode."
|
|
29
|
+
elsif @session_idle_timeout <= 0
|
|
30
|
+
raise ArgumentError, "session_idle_timeout must be a positive number."
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
start_reaper_thread if @session_idle_timeout
|
|
16
35
|
end
|
|
17
36
|
|
|
37
|
+
REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
|
|
38
|
+
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
|
|
39
|
+
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
40
|
+
SESSION_REAP_INTERVAL = 60
|
|
41
|
+
|
|
18
42
|
def handle_request(request)
|
|
19
43
|
case request.env["REQUEST_METHOD"]
|
|
20
44
|
when "POST"
|
|
@@ -24,38 +48,63 @@ module MCP
|
|
|
24
48
|
when "DELETE"
|
|
25
49
|
handle_delete(request)
|
|
26
50
|
else
|
|
27
|
-
|
|
51
|
+
method_not_allowed_response
|
|
28
52
|
end
|
|
29
53
|
end
|
|
30
54
|
|
|
31
55
|
def close
|
|
32
|
-
@
|
|
33
|
-
|
|
56
|
+
@reaper_thread&.kill
|
|
57
|
+
@reaper_thread = nil
|
|
58
|
+
|
|
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[:stream])
|
|
65
|
+
close_post_request_streams(session)
|
|
34
66
|
end
|
|
35
67
|
end
|
|
36
68
|
|
|
37
|
-
def send_notification(method, params = nil, session_id: nil)
|
|
69
|
+
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
70
|
+
# Stateless mode doesn't support notifications
|
|
71
|
+
raise "Stateless mode does not support notifications" if @stateless
|
|
72
|
+
|
|
38
73
|
notification = {
|
|
39
74
|
jsonrpc: "2.0",
|
|
40
|
-
method
|
|
75
|
+
method: method,
|
|
41
76
|
}
|
|
42
77
|
notification[:params] = params if params
|
|
43
78
|
|
|
44
|
-
|
|
79
|
+
streams_to_close = []
|
|
80
|
+
|
|
81
|
+
result = @mutex.synchronize do
|
|
45
82
|
if session_id
|
|
46
83
|
# Send to specific session
|
|
47
|
-
session = @sessions[session_id]
|
|
48
|
-
|
|
84
|
+
if (session = @sessions[session_id])
|
|
85
|
+
stream = active_stream(session, related_request_id: related_request_id)
|
|
86
|
+
end
|
|
87
|
+
next false unless stream
|
|
88
|
+
|
|
89
|
+
if session_expired?(session)
|
|
90
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
91
|
+
next false
|
|
92
|
+
end
|
|
49
93
|
|
|
50
94
|
begin
|
|
51
|
-
send_to_stream(
|
|
95
|
+
send_to_stream(stream, notification)
|
|
52
96
|
true
|
|
53
|
-
rescue
|
|
97
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
54
98
|
MCP.configuration.exception_reporter.call(
|
|
55
99
|
e,
|
|
56
100
|
{ session_id: session_id, error: "Failed to send notification" },
|
|
57
101
|
)
|
|
58
|
-
|
|
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
|
|
59
108
|
false
|
|
60
109
|
end
|
|
61
110
|
else
|
|
@@ -64,12 +113,17 @@ module MCP
|
|
|
64
113
|
failed_sessions = []
|
|
65
114
|
|
|
66
115
|
@sessions.each do |sid, session|
|
|
67
|
-
next unless session[:stream]
|
|
116
|
+
next unless (stream = session[:stream])
|
|
117
|
+
|
|
118
|
+
if session_expired?(session)
|
|
119
|
+
failed_sessions << sid
|
|
120
|
+
next
|
|
121
|
+
end
|
|
68
122
|
|
|
69
123
|
begin
|
|
70
|
-
send_to_stream(
|
|
124
|
+
send_to_stream(stream, notification)
|
|
71
125
|
sent_count += 1
|
|
72
|
-
rescue
|
|
126
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
73
127
|
MCP.configuration.exception_reporter.call(
|
|
74
128
|
e,
|
|
75
129
|
{ session_id: sid, error: "Failed to send notification" },
|
|
@@ -79,15 +133,125 @@ module MCP
|
|
|
79
133
|
end
|
|
80
134
|
|
|
81
135
|
# Clean up failed sessions
|
|
82
|
-
failed_sessions.each { |sid|
|
|
136
|
+
failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
|
|
83
137
|
|
|
84
138
|
sent_count
|
|
85
139
|
end
|
|
86
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
|
|
87
223
|
end
|
|
88
224
|
|
|
89
225
|
private
|
|
90
226
|
|
|
227
|
+
def start_reaper_thread
|
|
228
|
+
@reaper_thread = Thread.new do
|
|
229
|
+
loop do
|
|
230
|
+
sleep(SESSION_REAP_INTERVAL)
|
|
231
|
+
reap_expired_sessions
|
|
232
|
+
rescue StandardError => e
|
|
233
|
+
MCP.configuration.exception_reporter.call(e, error: "Session reaper error")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def reap_expired_sessions
|
|
239
|
+
return unless @session_idle_timeout
|
|
240
|
+
|
|
241
|
+
removed_sessions = @mutex.synchronize do
|
|
242
|
+
@sessions.each_key.filter_map do |session_id|
|
|
243
|
+
next unless session_expired?(@sessions[session_id])
|
|
244
|
+
|
|
245
|
+
cleanup_session_unsafe(session_id)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
removed_sessions.each do |session|
|
|
250
|
+
close_stream_safely(session[:stream])
|
|
251
|
+
close_post_request_streams(session)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
91
255
|
def send_to_stream(stream, data)
|
|
92
256
|
message = data.is_a?(String) ? data : data.to_json
|
|
93
257
|
stream.write("data: #{message}\n\n")
|
|
@@ -100,18 +264,29 @@ module MCP
|
|
|
100
264
|
end
|
|
101
265
|
|
|
102
266
|
def handle_post(request)
|
|
267
|
+
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
|
|
268
|
+
return accept_error if accept_error
|
|
269
|
+
|
|
103
270
|
body_string = request.body.read
|
|
104
271
|
session_id = extract_session_id(request)
|
|
105
272
|
|
|
106
273
|
body = parse_request_body(body_string)
|
|
107
274
|
return body unless body.is_a?(Hash) # Error response
|
|
108
275
|
|
|
109
|
-
if body[
|
|
276
|
+
if body[:method] == "initialize"
|
|
110
277
|
handle_initialization(body_string, body)
|
|
111
|
-
elsif notification?(body) || response?(body)
|
|
112
|
-
handle_accepted
|
|
113
278
|
else
|
|
114
|
-
|
|
279
|
+
return missing_session_id_response if !@stateless && !session_id
|
|
280
|
+
|
|
281
|
+
if notification?(body)
|
|
282
|
+
handle_accepted
|
|
283
|
+
elsif response?(body)
|
|
284
|
+
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
285
|
+
|
|
286
|
+
handle_response(body, session_id: session_id)
|
|
287
|
+
else
|
|
288
|
+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
|
289
|
+
end
|
|
115
290
|
end
|
|
116
291
|
rescue StandardError => e
|
|
117
292
|
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
@@ -119,79 +294,180 @@ module MCP
|
|
|
119
294
|
end
|
|
120
295
|
|
|
121
296
|
def handle_get(request)
|
|
297
|
+
if @stateless
|
|
298
|
+
return method_not_allowed_response
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
accept_error = validate_accept_header(request, REQUIRED_GET_ACCEPT_TYPES)
|
|
302
|
+
return accept_error if accept_error
|
|
303
|
+
|
|
122
304
|
session_id = extract_session_id(request)
|
|
123
305
|
|
|
124
306
|
return missing_session_id_response unless session_id
|
|
125
|
-
|
|
307
|
+
|
|
308
|
+
error_response = validate_and_touch_session(session_id)
|
|
309
|
+
return error_response if error_response
|
|
310
|
+
return session_already_connected_response if get_session_stream(session_id)
|
|
126
311
|
|
|
127
312
|
setup_sse_stream(session_id)
|
|
128
313
|
end
|
|
129
314
|
|
|
130
315
|
def handle_delete(request)
|
|
131
|
-
|
|
316
|
+
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
|
|
132
317
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
318
|
+
if @stateless
|
|
319
|
+
# Stateless mode doesn't support sessions, so we can just return a success response
|
|
320
|
+
return success_response
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
|
|
324
|
+
return session_not_found_response unless session_exists?(session_id)
|
|
138
325
|
|
|
139
326
|
cleanup_session(session_id)
|
|
140
|
-
|
|
327
|
+
|
|
328
|
+
success_response
|
|
141
329
|
end
|
|
142
330
|
|
|
143
331
|
def cleanup_session(session_id)
|
|
144
|
-
@mutex.synchronize do
|
|
332
|
+
session = @mutex.synchronize do
|
|
145
333
|
cleanup_session_unsafe(session_id)
|
|
146
334
|
end
|
|
335
|
+
|
|
336
|
+
if session
|
|
337
|
+
close_stream_safely(session[:stream])
|
|
338
|
+
close_post_request_streams(session)
|
|
339
|
+
end
|
|
147
340
|
end
|
|
148
341
|
|
|
342
|
+
# Removes a session from `@sessions` and returns it. Does not close the stream.
|
|
343
|
+
# Callers must close the stream outside the mutex to avoid holding the lock during
|
|
344
|
+
# potentially blocking I/O.
|
|
149
345
|
def cleanup_session_unsafe(session_id)
|
|
150
|
-
session = @sessions
|
|
151
|
-
return unless session
|
|
346
|
+
session = @sessions.delete(session_id)
|
|
152
347
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
348
|
+
# Unblock threads waiting on pending responses for this session.
|
|
349
|
+
@pending_responses.each_value do |pending_response|
|
|
350
|
+
if pending_response[:session_id] == session_id
|
|
351
|
+
pending_response[:queue].push(:session_closed)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
session
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def cleanup_and_collect_stream(session_id, streams_to_close)
|
|
359
|
+
return unless (removed = cleanup_session_unsafe(session_id))
|
|
360
|
+
|
|
361
|
+
streams_to_close << removed[:stream]
|
|
362
|
+
removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def close_stream_safely(stream)
|
|
366
|
+
stream&.close
|
|
367
|
+
rescue StandardError
|
|
368
|
+
# Ignore close-related errors from already closed/broken streams.
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def close_post_request_streams(session)
|
|
372
|
+
return unless (post_request_streams = session[:post_request_streams])
|
|
373
|
+
|
|
374
|
+
post_request_streams.each_value do |stream|
|
|
375
|
+
close_stream_safely(stream)
|
|
157
376
|
end
|
|
158
|
-
@sessions.delete(session_id)
|
|
159
377
|
end
|
|
160
378
|
|
|
161
379
|
def extract_session_id(request)
|
|
162
380
|
request.env["HTTP_MCP_SESSION_ID"]
|
|
163
381
|
end
|
|
164
382
|
|
|
383
|
+
def validate_accept_header(request, required_types)
|
|
384
|
+
accept_header = request.env["HTTP_ACCEPT"]
|
|
385
|
+
return not_acceptable_response(required_types) unless accept_header
|
|
386
|
+
|
|
387
|
+
accepted_types = parse_accept_header(accept_header)
|
|
388
|
+
return if accepted_types.include?("*/*")
|
|
389
|
+
|
|
390
|
+
missing_types = required_types - accepted_types
|
|
391
|
+
return not_acceptable_response(required_types) unless missing_types.empty?
|
|
392
|
+
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def parse_accept_header(header)
|
|
397
|
+
header.split(",").map do |part|
|
|
398
|
+
part.split(";").first.strip
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def not_acceptable_response(required_types)
|
|
403
|
+
[
|
|
404
|
+
406,
|
|
405
|
+
{ "Content-Type" => "application/json" },
|
|
406
|
+
[{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
|
|
407
|
+
]
|
|
408
|
+
end
|
|
409
|
+
|
|
165
410
|
def parse_request_body(body_string)
|
|
166
|
-
JSON.parse(body_string)
|
|
411
|
+
JSON.parse(body_string, symbolize_names: true)
|
|
167
412
|
rescue JSON::ParserError, TypeError
|
|
168
413
|
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
|
169
414
|
end
|
|
170
415
|
|
|
171
416
|
def notification?(body)
|
|
172
|
-
!body[
|
|
417
|
+
!body[:id] && !!body[:method]
|
|
173
418
|
end
|
|
174
419
|
|
|
175
420
|
def response?(body)
|
|
176
|
-
!!body[
|
|
421
|
+
!!body[:id] && !body[:method]
|
|
177
422
|
end
|
|
178
423
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
424
|
+
# Verifies that the response came from the expected session to prevent
|
|
425
|
+
# cross-session response injection if request IDs are ever leaked.
|
|
426
|
+
def handle_response(body, session_id:)
|
|
427
|
+
request_id = body[:id]
|
|
182
428
|
@mutex.synchronize do
|
|
183
|
-
@
|
|
184
|
-
|
|
185
|
-
|
|
429
|
+
if (pending_response = @pending_responses[request_id]) && pending_response[:session_id] == session_id
|
|
430
|
+
if body.key?(:error)
|
|
431
|
+
error = body[:error]
|
|
432
|
+
pending_response[:queue].push(error: { code: error[:code], message: error[:message] })
|
|
433
|
+
else
|
|
434
|
+
pending_response[:queue].push(body[:result])
|
|
435
|
+
end
|
|
436
|
+
end
|
|
186
437
|
end
|
|
187
438
|
|
|
188
|
-
|
|
439
|
+
handle_accepted
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def handle_initialization(body_string, body)
|
|
443
|
+
session_id = nil
|
|
444
|
+
server_session = nil
|
|
445
|
+
|
|
446
|
+
unless @stateless
|
|
447
|
+
session_id = SecureRandom.uuid
|
|
448
|
+
server_session = ServerSession.new(server: @server, transport: self, session_id: session_id)
|
|
449
|
+
|
|
450
|
+
@mutex.synchronize do
|
|
451
|
+
@sessions[session_id] = {
|
|
452
|
+
stream: nil,
|
|
453
|
+
server_session: server_session,
|
|
454
|
+
last_active_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
455
|
+
}
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
response = if server_session
|
|
460
|
+
server_session.handle_json(body_string)
|
|
461
|
+
else
|
|
462
|
+
@server.handle_json(body_string)
|
|
463
|
+
end
|
|
189
464
|
|
|
190
465
|
headers = {
|
|
191
466
|
"Content-Type" => "application/json",
|
|
192
|
-
"Mcp-Session-Id" => session_id,
|
|
193
467
|
}
|
|
194
468
|
|
|
469
|
+
headers["Mcp-Session-Id"] = session_id if session_id
|
|
470
|
+
|
|
195
471
|
[200, headers, [response]]
|
|
196
472
|
end
|
|
197
473
|
|
|
@@ -199,43 +475,125 @@ module MCP
|
|
|
199
475
|
[202, {}, []]
|
|
200
476
|
end
|
|
201
477
|
|
|
202
|
-
def handle_regular_request(body_string, session_id)
|
|
203
|
-
|
|
204
|
-
if session_id && !@sessions.key?(session_id)
|
|
205
|
-
return [400, { "Content-Type" => "application/json" }, [{ error: "Invalid session ID" }.to_json]]
|
|
206
|
-
end
|
|
478
|
+
def handle_regular_request(body_string, session_id, related_request_id: nil)
|
|
479
|
+
server_session = nil
|
|
207
480
|
|
|
208
|
-
|
|
209
|
-
|
|
481
|
+
unless @stateless
|
|
482
|
+
if session_id
|
|
483
|
+
error_response = validate_and_touch_session(session_id)
|
|
484
|
+
return error_response if error_response
|
|
485
|
+
|
|
486
|
+
@mutex.synchronize do
|
|
487
|
+
session = @sessions[session_id]
|
|
488
|
+
server_session = session[:server_session] if session
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
210
492
|
|
|
211
|
-
if
|
|
212
|
-
|
|
493
|
+
if session_id && !@stateless
|
|
494
|
+
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
213
495
|
else
|
|
496
|
+
response = dispatch_handle_json(body_string, server_session)
|
|
214
497
|
[200, { "Content-Type" => "application/json" }, [response]]
|
|
215
498
|
end
|
|
216
499
|
end
|
|
217
500
|
|
|
218
|
-
|
|
219
|
-
|
|
501
|
+
# Returns the POST response as an SSE stream so the server can send
|
|
502
|
+
# JSON-RPC requests and notifications during request processing.
|
|
503
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
|
|
504
|
+
def handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: nil)
|
|
505
|
+
body = proc do |stream|
|
|
506
|
+
@mutex.synchronize do
|
|
507
|
+
session = @sessions[session_id]
|
|
508
|
+
if session && related_request_id
|
|
509
|
+
session[:post_request_streams] ||= {}
|
|
510
|
+
session[:post_request_streams][related_request_id] = stream
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
begin
|
|
515
|
+
response = dispatch_handle_json(body_string, server_session)
|
|
516
|
+
|
|
517
|
+
send_to_stream(stream, response) if response
|
|
518
|
+
ensure
|
|
519
|
+
if related_request_id
|
|
520
|
+
@mutex.synchronize do
|
|
521
|
+
session = @sessions[session_id]
|
|
522
|
+
session[:post_request_streams]&.delete(related_request_id) if session
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
begin
|
|
527
|
+
stream.close
|
|
528
|
+
rescue StandardError
|
|
529
|
+
# Ignore close-related errors from already closed/broken streams.
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
[200, SSE_HEADERS, body]
|
|
220
535
|
end
|
|
221
536
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
537
|
+
# Returns the SSE stream available for server-to-client messages.
|
|
538
|
+
# When `related_request_id` is given, returns only the POST response
|
|
539
|
+
# stream for that request (no fallback to GET SSE). This prevents
|
|
540
|
+
# request-scoped messages from leaking to the wrong stream.
|
|
541
|
+
# When `related_request_id` is nil, returns the GET SSE stream.
|
|
542
|
+
def active_stream(session, related_request_id: nil)
|
|
543
|
+
if related_request_id
|
|
544
|
+
session.dig(:post_request_streams, related_request_id)
|
|
545
|
+
else
|
|
546
|
+
session[:stream]
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def dispatch_handle_json(body_string, server_session)
|
|
551
|
+
if server_session
|
|
552
|
+
server_session.handle_json(body_string)
|
|
553
|
+
else
|
|
554
|
+
@server.handle_json(body_string)
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def validate_and_touch_session(session_id)
|
|
559
|
+
removed = nil
|
|
560
|
+
|
|
561
|
+
response = @mutex.synchronize do
|
|
562
|
+
next session_not_found_response unless (session = @sessions[session_id])
|
|
563
|
+
next unless @session_idle_timeout
|
|
564
|
+
|
|
565
|
+
if session_expired?(session)
|
|
566
|
+
removed = cleanup_session_unsafe(session_id)
|
|
567
|
+
next session_not_found_response
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
session[:last_active_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
571
|
+
nil
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
if removed
|
|
575
|
+
close_stream_safely(removed[:stream])
|
|
576
|
+
|
|
577
|
+
removed[:post_request_streams]&.each_value do |stream|
|
|
578
|
+
close_stream_safely(stream)
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
response
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def get_session_stream(session_id)
|
|
586
|
+
@mutex.synchronize { @sessions[session_id]&.fetch(:stream, nil) }
|
|
233
587
|
end
|
|
234
588
|
|
|
235
589
|
def session_exists?(session_id)
|
|
236
590
|
@mutex.synchronize { @sessions.key?(session_id) }
|
|
237
591
|
end
|
|
238
592
|
|
|
593
|
+
def method_not_allowed_response
|
|
594
|
+
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
|
|
595
|
+
end
|
|
596
|
+
|
|
239
597
|
def missing_session_id_response
|
|
240
598
|
[400, { "Content-Type" => "application/json" }, [{ error: "Missing session ID" }.to_json]]
|
|
241
599
|
end
|
|
@@ -244,31 +602,38 @@ module MCP
|
|
|
244
602
|
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
|
245
603
|
end
|
|
246
604
|
|
|
605
|
+
def session_already_connected_response
|
|
606
|
+
[
|
|
607
|
+
409,
|
|
608
|
+
{ "Content-Type" => "application/json" },
|
|
609
|
+
[{ error: "Conflict: Only one SSE stream is allowed per session" }.to_json],
|
|
610
|
+
]
|
|
611
|
+
end
|
|
612
|
+
|
|
247
613
|
def setup_sse_stream(session_id)
|
|
248
614
|
body = create_sse_body(session_id)
|
|
249
615
|
|
|
250
|
-
|
|
251
|
-
"Content-Type" => "text/event-stream",
|
|
252
|
-
"Cache-Control" => "no-cache",
|
|
253
|
-
"Connection" => "keep-alive",
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
[200, headers, body]
|
|
616
|
+
[200, SSE_HEADERS, body]
|
|
257
617
|
end
|
|
258
618
|
|
|
259
619
|
def create_sse_body(session_id)
|
|
260
620
|
proc do |stream|
|
|
261
|
-
store_stream_for_session(session_id, stream)
|
|
262
|
-
start_keepalive_thread(session_id)
|
|
621
|
+
stored = store_stream_for_session(session_id, stream)
|
|
622
|
+
start_keepalive_thread(session_id) if stored
|
|
263
623
|
end
|
|
264
624
|
end
|
|
265
625
|
|
|
266
626
|
def store_stream_for_session(session_id, stream)
|
|
267
627
|
@mutex.synchronize do
|
|
268
|
-
|
|
269
|
-
|
|
628
|
+
session = @sessions[session_id]
|
|
629
|
+
if session && !session[:stream]
|
|
630
|
+
session[:stream] = stream
|
|
270
631
|
else
|
|
632
|
+
# Either session was removed, or another request already established a stream.
|
|
271
633
|
stream.close
|
|
634
|
+
# `stream.close` may return a truthy value depending on the stream class.
|
|
635
|
+
# Explicitly return nil to guarantee a falsy return for callers.
|
|
636
|
+
nil
|
|
272
637
|
end
|
|
273
638
|
end
|
|
274
639
|
end
|
|
@@ -296,13 +661,19 @@ module MCP
|
|
|
296
661
|
send_ping_to_stream(@sessions[session_id][:stream])
|
|
297
662
|
end
|
|
298
663
|
end
|
|
299
|
-
rescue
|
|
664
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
300
665
|
MCP.configuration.exception_reporter.call(
|
|
301
666
|
e,
|
|
302
667
|
{ session_id: session_id, error: "Stream closed" },
|
|
303
668
|
)
|
|
304
669
|
raise # Re-raise to exit the keepalive loop
|
|
305
670
|
end
|
|
671
|
+
|
|
672
|
+
def session_expired?(session)
|
|
673
|
+
return false unless @session_idle_timeout
|
|
674
|
+
|
|
675
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - session[:last_active_at] > @session_idle_timeout
|
|
676
|
+
end
|
|
306
677
|
end
|
|
307
678
|
end
|
|
308
679
|
end
|