mcp 0.23.0 → 0.24.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.
data/lib/mcp/resource.rb CHANGED
@@ -5,6 +5,141 @@ require_relative "resource/embedded"
5
5
 
6
6
  module MCP
7
7
  class Resource
8
+ class << self
9
+ NOT_SET = Object.new
10
+
11
+ attr_reader :uri_value
12
+ attr_reader :title_value
13
+ attr_reader :description_value
14
+ attr_reader :icons_value
15
+ attr_reader :mime_type_value
16
+ attr_reader :annotations_value
17
+ attr_reader :size_value
18
+ attr_reader :meta_value
19
+
20
+ def contents(server_context: nil)
21
+ raise NotImplementedError, "Subclasses must implement contents"
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ uri: uri_value,
27
+ name: name_value,
28
+ title: title_value,
29
+ description: description_value,
30
+ icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
31
+ mimeType: mime_type_value,
32
+ annotations: annotations_value&.to_h,
33
+ size: size_value,
34
+ _meta: meta_value,
35
+ }.compact
36
+ end
37
+
38
+ def inherited(subclass)
39
+ super
40
+ subclass.instance_variable_set(:@uri_value, nil)
41
+ subclass.instance_variable_set(:@name_value, nil)
42
+ subclass.instance_variable_set(:@title_value, nil)
43
+ subclass.instance_variable_set(:@description_value, nil)
44
+ subclass.instance_variable_set(:@icons_value, nil)
45
+ subclass.instance_variable_set(:@mime_type_value, nil)
46
+ subclass.instance_variable_set(:@annotations_value, nil)
47
+ subclass.instance_variable_set(:@size_value, nil)
48
+ subclass.instance_variable_set(:@meta_value, nil)
49
+ end
50
+
51
+ def uri(value = NOT_SET)
52
+ if value == NOT_SET
53
+ @uri_value
54
+ else
55
+ @uri_value = value
56
+ end
57
+ end
58
+
59
+ def resource_name(value = NOT_SET)
60
+ if value == NOT_SET
61
+ name_value
62
+ else
63
+ @name_value = value
64
+ end
65
+ end
66
+
67
+ def name_value
68
+ @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name))
69
+ end
70
+
71
+ def title(value = NOT_SET)
72
+ if value == NOT_SET
73
+ @title_value
74
+ else
75
+ @title_value = value
76
+ end
77
+ end
78
+
79
+ def description(value = NOT_SET)
80
+ if value == NOT_SET
81
+ @description_value
82
+ else
83
+ @description_value = value
84
+ end
85
+ end
86
+
87
+ def icons(value = NOT_SET)
88
+ if value == NOT_SET
89
+ @icons_value
90
+ else
91
+ @icons_value = value
92
+ end
93
+ end
94
+
95
+ def mime_type(value = NOT_SET)
96
+ if value == NOT_SET
97
+ @mime_type_value
98
+ else
99
+ @mime_type_value = value
100
+ end
101
+ end
102
+
103
+ def annotations(value = NOT_SET)
104
+ if value == NOT_SET
105
+ @annotations_value
106
+ else
107
+ @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value)
108
+ end
109
+ end
110
+
111
+ def size(value = NOT_SET)
112
+ if value == NOT_SET
113
+ @size_value
114
+ else
115
+ @size_value = value
116
+ end
117
+ end
118
+
119
+ def meta(value = NOT_SET)
120
+ if value == NOT_SET
121
+ @meta_value
122
+ else
123
+ @meta_value = value
124
+ end
125
+ end
126
+
127
+ def define(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block)
128
+ Class.new(self) do
129
+ uri uri
130
+ resource_name name
131
+ title title
132
+ description description
133
+ icons icons
134
+ mime_type mime_type
135
+ self.annotations(annotations) if annotations
136
+ size size
137
+ meta meta
138
+ define_singleton_method(:contents, &block) if block
139
+ end
140
+ end
141
+ end
142
+
8
143
  attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta
9
144
 
10
145
  def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil)
@@ -2,6 +2,155 @@
2
2
 
3
3
  module MCP
4
4
  class ResourceTemplate
5
+ class << self
6
+ NOT_SET = Object.new
7
+
8
+ # Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`.
9
+ # Variable names are restricted to valid Regexp named-group names,
10
+ # so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match.
11
+ VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/
12
+
13
+ attr_reader :uri_template_value
14
+ attr_reader :title_value
15
+ attr_reader :description_value
16
+ attr_reader :icons_value
17
+ attr_reader :mime_type_value
18
+ attr_reader :annotations_value
19
+ attr_reader :meta_value
20
+
21
+ def contents(server_context: nil, **params)
22
+ raise NotImplementedError, "Subclasses must implement contents"
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ uriTemplate: uri_template_value,
28
+ name: name_value,
29
+ title: title_value,
30
+ description: description_value,
31
+ icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
32
+ mimeType: mime_type_value,
33
+ annotations: annotations_value&.to_h,
34
+ _meta: meta_value,
35
+ }.compact
36
+ end
37
+
38
+ def inherited(subclass)
39
+ super
40
+ subclass.instance_variable_set(:@uri_template_value, nil)
41
+ subclass.instance_variable_set(:@uri_template_pattern, nil)
42
+ subclass.instance_variable_set(:@name_value, nil)
43
+ subclass.instance_variable_set(:@title_value, nil)
44
+ subclass.instance_variable_set(:@description_value, nil)
45
+ subclass.instance_variable_set(:@icons_value, nil)
46
+ subclass.instance_variable_set(:@mime_type_value, nil)
47
+ subclass.instance_variable_set(:@annotations_value, nil)
48
+ subclass.instance_variable_set(:@meta_value, nil)
49
+ end
50
+
51
+ def uri_template(value = NOT_SET)
52
+ if value == NOT_SET
53
+ @uri_template_value
54
+ else
55
+ @uri_template_pattern = nil
56
+ @uri_template_value = value
57
+ end
58
+ end
59
+
60
+ def resource_template_name(value = NOT_SET)
61
+ if value == NOT_SET
62
+ name_value
63
+ else
64
+ @name_value = value
65
+ end
66
+ end
67
+
68
+ def name_value
69
+ @name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name))
70
+ end
71
+
72
+ def title(value = NOT_SET)
73
+ if value == NOT_SET
74
+ @title_value
75
+ else
76
+ @title_value = value
77
+ end
78
+ end
79
+
80
+ def description(value = NOT_SET)
81
+ if value == NOT_SET
82
+ @description_value
83
+ else
84
+ @description_value = value
85
+ end
86
+ end
87
+
88
+ def icons(value = NOT_SET)
89
+ if value == NOT_SET
90
+ @icons_value
91
+ else
92
+ @icons_value = value
93
+ end
94
+ end
95
+
96
+ def mime_type(value = NOT_SET)
97
+ if value == NOT_SET
98
+ @mime_type_value
99
+ else
100
+ @mime_type_value = value
101
+ end
102
+ end
103
+
104
+ def annotations(value = NOT_SET)
105
+ if value == NOT_SET
106
+ @annotations_value
107
+ else
108
+ @annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value)
109
+ end
110
+ end
111
+
112
+ def meta(value = NOT_SET)
113
+ if value == NOT_SET
114
+ @meta_value
115
+ else
116
+ @meta_value = value
117
+ end
118
+ end
119
+
120
+ # Matches a concrete URI against the template's simple RFC 6570 level-1 `{var}` expressions.
121
+ # Returns a symbol-keyed Hash of variable values, or `nil` if the URI does not match.
122
+ # Variables match one or more characters excluding `/`, and values are not percent-decoded.
123
+ def match_uri(uri)
124
+ match = uri_template_pattern&.match(uri)
125
+ match&.named_captures&.transform_keys(&:to_sym)
126
+ end
127
+
128
+ def define(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block)
129
+ Class.new(self) do
130
+ uri_template uri_template
131
+ resource_template_name name
132
+ title title
133
+ description description
134
+ icons icons
135
+ mime_type mime_type
136
+ self.annotations(annotations) if annotations
137
+ meta meta
138
+ define_singleton_method(:contents, &block) if block
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def uri_template_pattern
145
+ return if uri_template.nil?
146
+
147
+ @uri_template_pattern ||= begin
148
+ pattern = Regexp.escape(uri_template).gsub(VARIABLE_PATTERN) { "(?<#{Regexp.last_match(1)}>[^/]+)" }
149
+ Regexp.new("\\A#{pattern}\\z")
150
+ end
151
+ end
152
+ end
153
+
5
154
  attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta
6
155
 
7
156
  def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ # Values of the `resultType` result field introduced by SEP-2322 (Multi Round-Trip Requests)
5
+ # for the MCP 2026-07-28 draft.
6
+ #
7
+ # A result with `resultType: "input_required"` is not a final answer: it carries an `inputRequests` map
8
+ # of server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create` shapes) plus
9
+ # an opaque `requestState` string, and the client is expected to fulfill the requests and re-issue
10
+ # the original request with `inputResponses` and the echoed `requestState`. A missing `resultType`
11
+ # or `"complete"` is a final result.
12
+ #
13
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
14
+ module ResultType
15
+ COMPLETE = "complete"
16
+ INPUT_REQUIRED = "input_required"
17
+ end
18
+ end
@@ -196,80 +196,107 @@ module MCP
196
196
  }
197
197
  notification[:params] = params if params
198
198
 
199
+ if session_id
200
+ deliver_targeted_notification(notification, session_id, related_request_id)
201
+ else
202
+ deliver_broadcast_notification(notification)
203
+ end
204
+ end
205
+
206
+ def deliver_targeted_notification(notification, session_id, related_request_id)
207
+ # JSON response mode returns a single JSON object as the POST response,
208
+ # so request-scoped notifications (e.g. progress, log) cannot be delivered
209
+ # alongside it. Session-scoped standalone notifications
210
+ # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
211
+ return false if @enable_json_response && related_request_id
212
+
199
213
  streams_to_close = []
200
214
 
201
- result = @mutex.synchronize do
202
- if session_id
203
- # JSON response mode returns a single JSON object as the POST response,
204
- # so request-scoped notifications (e.g. progress, log) cannot be delivered
205
- # alongside it. Session-scoped standalone notifications
206
- # (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
207
- next false if @enable_json_response && related_request_id
208
-
209
- # Send to specific session
210
- if (session = @sessions[session_id])
211
- stream = active_stream(session, related_request_id: related_request_id)
212
- end
213
- next false unless stream
215
+ # Resolve the target stream under the lock, then write outside it: a stalled SSE reader
216
+ # must not block every other session that needs `@mutex`.
217
+ stream = @mutex.synchronize do
218
+ next unless (session = @sessions[session_id])
219
+
220
+ if session_expired?(session)
221
+ cleanup_and_collect_stream(session_id, streams_to_close)
222
+ next
223
+ end
224
+
225
+ active_stream(session, related_request_id: related_request_id)
226
+ end
227
+
228
+ close_streams(streams_to_close)
229
+ return false unless stream
230
+
231
+ write_notification(stream, notification, session_id, related_request_id)
232
+ end
233
+
234
+ def deliver_broadcast_notification(notification)
235
+ streams_to_close = []
236
+
237
+ # Snapshot the connected streams under the lock, then write to each outside it.
238
+ targets = @mutex.synchronize do
239
+ expired_session_ids = []
240
+
241
+ collected = @sessions.filter_map do |session_id, session|
242
+ next unless (stream = session[:get_sse_stream])
214
243
 
215
244
  if session_expired?(session)
216
- cleanup_and_collect_stream(session_id, streams_to_close)
217
- next false
245
+ expired_session_ids << session_id
246
+ next
218
247
  end
219
248
 
220
- begin
221
- send_to_stream(stream, notification)
222
- true
223
- rescue *STREAM_WRITE_ERRORS => e
224
- MCP.configuration.exception_reporter.call(
225
- e,
226
- { session_id: session_id, error: "Failed to send notification" },
227
- )
228
- if related_request_id && session[:post_request_streams]&.key?(related_request_id)
229
- session[:post_request_streams].delete(related_request_id)
230
- streams_to_close << stream
231
- else
232
- cleanup_and_collect_stream(session_id, streams_to_close)
233
- end
234
- false
235
- end
236
- else
237
- # Broadcast to all connected SSE sessions
238
- sent_count = 0
239
- failed_sessions = []
249
+ [session_id, stream]
250
+ end
240
251
 
241
- @sessions.each do |sid, session|
242
- next unless (stream = session[:get_sse_stream])
252
+ expired_session_ids.each do |session_id|
253
+ cleanup_and_collect_stream(session_id, streams_to_close)
254
+ end
243
255
 
244
- if session_expired?(session)
245
- failed_sessions << sid
246
- next
247
- end
256
+ collected
257
+ end
248
258
 
249
- begin
250
- send_to_stream(stream, notification)
251
- sent_count += 1
252
- rescue *STREAM_WRITE_ERRORS => e
253
- MCP.configuration.exception_reporter.call(
254
- e,
255
- { session_id: sid, error: "Failed to send notification" },
256
- )
257
- failed_sessions << sid
258
- end
259
- end
259
+ close_streams(streams_to_close)
260
+
261
+ targets.count do |session_id, stream|
262
+ write_notification(stream, notification, session_id, nil)
263
+ end
264
+ end
265
+
266
+ # Writes a notification to an SSE stream without holding `@mutex`. On a write error,
267
+ # drops the broken stream and returns false; on success returns true.
268
+ def write_notification(stream, notification, session_id, related_request_id)
269
+ send_to_stream(stream, notification)
270
+ true
271
+ rescue *STREAM_WRITE_ERRORS => e
272
+ MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" })
273
+ drop_broken_stream(session_id, stream, related_request_id)
274
+ false
275
+ end
260
276
 
261
- # Clean up failed sessions
262
- failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
277
+ # Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
278
+ # a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
279
+ # under `@mutex`, and the affected streams are closed outside it.
280
+ def drop_broken_stream(session_id, stream, related_request_id)
281
+ streams_to_close = []
263
282
 
264
- sent_count
283
+ @mutex.synchronize do
284
+ session = @sessions[session_id]
285
+ if related_request_id && session&.dig(:post_request_streams, related_request_id)
286
+ session[:post_request_streams].delete(related_request_id)
287
+ streams_to_close << stream
288
+ else
289
+ cleanup_and_collect_stream(session_id, streams_to_close)
265
290
  end
266
291
  end
267
292
 
268
- streams_to_close.each do |stream|
293
+ close_streams(streams_to_close)
294
+ end
295
+
296
+ def close_streams(streams)
297
+ streams.each do |stream|
269
298
  close_stream_safely(stream)
270
299
  end
271
-
272
- result
273
300
  end
274
301
 
275
302
  # Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
@@ -299,27 +326,25 @@ module MCP
299
326
  request = { jsonrpc: "2.0", id: request_id, method: method }
300
327
  request[:params] = params if params
301
328
 
302
- sent = false
303
-
304
- @mutex.synchronize do
329
+ # Register the pending response and resolve the stream under the lock, but perform
330
+ # the write outside it so a stalled reader cannot block every other session on `@mutex`.
331
+ stream = @mutex.synchronize do
305
332
  unless (session = @sessions[session_id])
306
333
  raise "Session not found: #{session_id}."
307
334
  end
308
335
 
309
336
  @pending_responses[request_id] = { queue: queue, session_id: session_id }
310
337
 
311
- if (stream = active_stream(session, related_request_id: related_request_id))
312
- begin
313
- send_to_stream(stream, request)
314
- sent = true
315
- rescue *STREAM_WRITE_ERRORS
316
- if related_request_id && session[:post_request_streams]&.key?(related_request_id)
317
- session[:post_request_streams].delete(related_request_id)
318
- close_stream_safely(stream)
319
- else
320
- cleanup_session_unsafe(session_id)
321
- end
322
- end
338
+ active_stream(session, related_request_id: related_request_id)
339
+ end
340
+
341
+ sent = false
342
+ if stream
343
+ begin
344
+ send_to_stream(stream, request)
345
+ sent = true
346
+ rescue *STREAM_WRITE_ERRORS
347
+ drop_broken_stream(session_id, stream, related_request_id)
323
348
  end
324
349
  end
325
350
 
@@ -461,7 +486,9 @@ module MCP
461
486
  # The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
462
487
  # the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
463
488
  # This matches the TypeScript and Python SDKs.
464
- unless initialize_request?(body)
489
+ # `server/discover` (SEP-2575) is likewise exempt: it is sessionless capability discovery that
490
+ # happens before (or instead of) negotiation.
491
+ unless initialize_request?(body) || discover_request?(body)
465
492
  return missing_session_id_response if !@stateless && !session_id
466
493
 
467
494
  protocol_version_error = validate_protocol_version_header(request)
@@ -712,6 +739,10 @@ module MCP
712
739
  body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
713
740
  end
714
741
 
742
+ def discover_request?(body)
743
+ body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
744
+ end
745
+
715
746
  def validate_protocol_version_header(request)
716
747
  header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
717
748
  return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
@@ -1158,11 +1189,15 @@ module MCP
1158
1189
  end
1159
1190
 
1160
1191
  def send_keepalive_ping(session_id)
1161
- @mutex.synchronize do
1162
- if @sessions[session_id] && @sessions[session_id][:get_sse_stream]
1163
- send_ping_to_stream(@sessions[session_id][:get_sse_stream])
1164
- end
1192
+ # Resolve the stream under the lock, then write outside it so a stalled reader
1193
+ # cannot block every other session on `@mutex`.
1194
+ stream = @mutex.synchronize do
1195
+ session = @sessions[session_id]
1196
+ session && session[:get_sse_stream]
1165
1197
  end
1198
+ return unless stream
1199
+
1200
+ send_ping_to_stream(stream)
1166
1201
  rescue *STREAM_WRITE_ERRORS => e
1167
1202
  MCP.configuration.exception_reporter.call(
1168
1203
  e,