mcp 0.23.0 → 0.25.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.
@@ -19,9 +19,9 @@ module MCP
19
19
  class InvalidJsonError < StandardError; end
20
20
 
21
21
  SSE_HEADERS = {
22
- "Content-Type" => "text/event-stream",
23
- "Cache-Control" => "no-cache",
24
- "Connection" => "keep-alive",
22
+ "content-type" => "text/event-stream",
23
+ "cache-control" => "no-cache",
24
+ "connection" => "keep-alive",
25
25
  }.freeze
26
26
 
27
27
  # Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
@@ -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
260
 
261
- # Clean up failed sessions
262
- failed_sessions.each { |sid| cleanup_and_collect_stream(sid, streams_to_close) }
261
+ targets.count do |session_id, stream|
262
+ write_notification(stream, notification, session_id, nil)
263
+ end
264
+ end
263
265
 
264
- sent_count
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
276
+
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 = []
282
+
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)
@@ -490,6 +517,11 @@ module MCP
490
517
  end
491
518
 
492
519
  if notification?(body)
520
+ # Reject a notification carrying an unknown or expired session ID instead of
521
+ # dispatching it against the shared `Server`. Mirrors the response and regular-request
522
+ # branches; without it a custom notification handler could run without a live session.
523
+ return session_not_found_response if !@stateless && !session_active?(session_id)
524
+
493
525
  dispatch_notification(body_string, session_id)
494
526
  handle_accepted
495
527
  elsif response?(body)
@@ -534,7 +566,7 @@ module MCP
534
566
  end
535
567
 
536
568
  def handle_delete(request)
537
- success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
569
+ success_response = [200, { "content-type" => "application/json" }, [{ success: true }.to_json]]
538
570
 
539
571
  if @stateless
540
572
  protocol_version_error = validate_protocol_version_header(request)
@@ -712,6 +744,10 @@ module MCP
712
744
  body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
713
745
  end
714
746
 
747
+ def discover_request?(body)
748
+ body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
749
+ end
750
+
715
751
  def validate_protocol_version_header(request)
716
752
  header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
717
753
  return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
@@ -726,7 +762,7 @@ module MCP
726
762
 
727
763
  def json_rpc_error_response(status:, code:, message:)
728
764
  body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
729
- [status, { "Content-Type" => "application/json" }, [body.to_json]]
765
+ [status, { "content-type" => "application/json" }, [body.to_json]]
730
766
  end
731
767
 
732
768
  def notification?(body)
@@ -813,6 +849,17 @@ module MCP
813
849
 
814
850
  response = server_session.handle_json(body_string)
815
851
 
852
+ # `initialize_request?` matches on the method alone, so an `initialize` sent without
853
+ # an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
854
+ # but JSON-RPC emits no response for an id-less request, so `response` is nil.
855
+ # Returning `[200, ..., [nil]]` would place nil in the Rack body (which the web server cannot serialize),
856
+ # and the session would be retained since it is marked initialized. Discard the orphaned session
857
+ # and ack with 202, mirroring the nil-response handling in a regular request.
858
+ if response.nil?
859
+ cleanup_session(session_id) if session_id
860
+ return handle_accepted
861
+ end
862
+
816
863
  # If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
817
864
  # `mark_initialized!` was never called. Discard the orphaned session and omit
818
865
  # the `Mcp-Session-Id` header so the client retries from a clean state instead of
@@ -823,10 +870,10 @@ module MCP
823
870
  end
824
871
 
825
872
  headers = {
826
- "Content-Type" => "application/json",
873
+ "content-type" => "application/json",
827
874
  }
828
875
 
829
- headers["Mcp-Session-Id"] = session_id if session_id
876
+ headers["mcp-session-id"] = session_id if session_id
830
877
 
831
878
  [200, headers, [response]]
832
879
  end
@@ -868,7 +915,7 @@ module MCP
868
915
  # which would produce an empty body the client cannot parse as JSON.
869
916
  return handle_accepted if response.nil?
870
917
 
871
- [200, { "Content-Type" => "application/json" }, [response]]
918
+ [200, { "content-type" => "application/json" }, [response]]
872
919
  end
873
920
  end
874
921
 
@@ -1101,7 +1148,7 @@ module MCP
1101
1148
  message: message,
1102
1149
  },
1103
1150
  }
1104
- [400, { "Content-Type" => "application/json" }, [body.to_json]]
1151
+ [400, { "content-type" => "application/json" }, [body.to_json]]
1105
1152
  end
1106
1153
 
1107
1154
  def session_already_connected_response
@@ -1158,11 +1205,15 @@ module MCP
1158
1205
  end
1159
1206
 
1160
1207
  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
1208
+ # Resolve the stream under the lock, then write outside it so a stalled reader
1209
+ # cannot block every other session on `@mutex`.
1210
+ stream = @mutex.synchronize do
1211
+ session = @sessions[session_id]
1212
+ session && session[:get_sse_stream]
1165
1213
  end
1214
+ return unless stream
1215
+
1216
+ send_ping_to_stream(stream)
1166
1217
  rescue *STREAM_WRITE_ERRORS => e
1167
1218
  MCP.configuration.exception_reporter.call(
1168
1219
  e,
data/lib/mcp/server.rb CHANGED
@@ -102,8 +102,11 @@ module MCP
102
102
  include Instrumentation
103
103
  include Pagination
104
104
 
105
- attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
106
- attr_reader :page_size, :client_capabilities
105
+ # Allowed values for the SEP-2549 `cacheScope` cache hint.
106
+ CACHE_SCOPES = ["public", "private"].freeze
107
+
108
+ attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
109
+ attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope
107
110
 
108
111
  def initialize(
109
112
  description: nil,
@@ -121,6 +124,8 @@ module MCP
121
124
  configuration: nil,
122
125
  capabilities: nil,
123
126
  page_size: nil,
127
+ ttl_ms: nil,
128
+ cache_scope: nil,
124
129
  transport: nil
125
130
  )
126
131
  @description = description
@@ -138,6 +143,8 @@ module MCP
138
143
  @resource_index = index_resources_by_uri(resources)
139
144
  @server_context = server_context
140
145
  self.page_size = page_size
146
+ self.ttl_ms = ttl_ms
147
+ self.cache_scope = cache_scope
141
148
  @configuration = MCP.configuration.merge(configuration)
142
149
  @client = nil
143
150
 
@@ -154,7 +161,7 @@ module MCP
154
161
 
155
162
  @handlers = {
156
163
  Methods::RESOURCES_LIST => method(:list_resources),
157
- Methods::RESOURCES_READ => method(:read_resource_no_content),
164
+ Methods::RESOURCES_READ => method(:read_resource),
158
165
  Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
159
166
  Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
160
167
  Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
@@ -163,6 +170,7 @@ module MCP
163
170
  Methods::PROMPTS_LIST => method(:list_prompts),
164
171
  Methods::PROMPTS_GET => method(:get_prompt),
165
172
  Methods::INITIALIZE => method(:init),
173
+ Methods::SERVER_DISCOVER => method(:discover),
166
174
  Methods::PING => ->(_) { {} },
167
175
  Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
168
176
  Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
@@ -216,6 +224,34 @@ module MCP
216
224
  validate!
217
225
  end
218
226
 
227
+ def define_resource(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block)
228
+ resource = Resource.define(
229
+ uri: uri, name: name, title: title, description: description, icons: icons,
230
+ mime_type: mime_type, annotations: annotations, size: size, meta: meta,
231
+ &block
232
+ )
233
+ @resources << resource
234
+ @resource_index[resource.uri] = resource
235
+
236
+ validate!
237
+ end
238
+
239
+ def define_resource_template(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block)
240
+ resource_template = ResourceTemplate.define(
241
+ uri_template: uri_template, name: name, title: title, description: description, icons: icons,
242
+ mime_type: mime_type, annotations: annotations, meta: meta,
243
+ &block
244
+ )
245
+ @resource_templates << resource_template
246
+
247
+ validate!
248
+ end
249
+
250
+ def resources=(resources)
251
+ @resources = resources
252
+ @resource_index = index_resources_by_uri(resources)
253
+ end
254
+
219
255
  def define_custom_method(method_name:, &block)
220
256
  if @handlers.key?(method_name)
221
257
  raise MethodAlreadyDefinedError, method_name
@@ -232,6 +268,27 @@ module MCP
232
268
  @page_size = page_size
233
269
  end
234
270
 
271
+ # SEP-2549 cache hint: freshness lifetime in milliseconds for list and read results
272
+ # (max-age semantics; 0 means do not cache). Emission is opt-in: when both `ttl_ms`
273
+ # and `cache_scope` are nil, results are serialized exactly as before.
274
+ def ttl_ms=(ttl_ms)
275
+ unless ttl_ms.nil? || (ttl_ms.is_a?(Integer) && ttl_ms >= 0)
276
+ raise ArgumentError, "ttl_ms must be nil or a non-negative integer"
277
+ end
278
+
279
+ @ttl_ms = ttl_ms
280
+ end
281
+
282
+ # SEP-2549 cache hint: whether shared intermediaries may cache the result ("public")
283
+ # or only the requesting client ("private").
284
+ def cache_scope=(cache_scope)
285
+ unless cache_scope.nil? || CACHE_SCOPES.include?(cache_scope)
286
+ raise ArgumentError, "cache_scope must be nil, \"public\", or \"private\""
287
+ end
288
+
289
+ @cache_scope = cache_scope
290
+ end
291
+
235
292
  def notify_tools_list_changed
236
293
  return unless @transport
237
294
 
@@ -472,8 +529,9 @@ module MCP
472
529
  when Methods::INITIALIZE
473
530
  init(params, session: session)
474
531
  when Methods::RESOURCES_READ
475
- { contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) }
532
+ build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation))
476
533
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
534
+ validate_resource_subscription_params!(params)
477
535
  dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
478
536
  {}
479
537
  when Methods::TOOLS_CALL
@@ -548,6 +606,8 @@ module MCP
548
606
  end
549
607
 
550
608
  def init(params, session: nil)
609
+ validate_initialize_params!(params)
610
+
551
611
  # MCP spec: the initialization phase MUST be the first interaction between client and server.
552
612
  # Reject duplicate `initialize` on an already-initialized session so the negotiated
553
613
  # client identity and capabilities cannot be silently overwritten.
@@ -555,15 +615,13 @@ module MCP
555
615
  raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request)
556
616
  end
557
617
 
558
- if params
559
- if session
560
- session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
561
- else
562
- @client = params[:clientInfo]
563
- @client_capabilities = params[:capabilities]
564
- end
565
- protocol_version = params[:protocolVersion]
618
+ if session
619
+ session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
620
+ else
621
+ @client = params[:clientInfo]
622
+ @client_capabilities = params[:capabilities]
566
623
  end
624
+ protocol_version = params[:protocolVersion]
567
625
 
568
626
  negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
569
627
  protocol_version
@@ -592,6 +650,47 @@ module MCP
592
650
  }.compact
593
651
  end
594
652
 
653
+ def validate_resource_subscription_params!(params)
654
+ unless params.is_a?(Hash) && params[:uri].is_a?(String)
655
+ raise RequestHandlerError.new("Missing or invalid uri", params, error_type: :invalid_params)
656
+ end
657
+ end
658
+
659
+ def validate_initialize_params!(params)
660
+ unless params.is_a?(Hash)
661
+ raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
662
+ end
663
+
664
+ unless params[:protocolVersion].is_a?(String)
665
+ raise RequestHandlerError.new("Missing or invalid protocolVersion", params, error_type: :invalid_params)
666
+ end
667
+
668
+ unless params[:capabilities].is_a?(Hash)
669
+ raise RequestHandlerError.new("Missing or invalid capabilities", params, error_type: :invalid_params)
670
+ end
671
+
672
+ client_info = params[:clientInfo]
673
+ if !client_info.is_a?(Hash) || client_info[:name].nil? || client_info[:version].nil?
674
+ raise RequestHandlerError.new("Missing or invalid clientInfo", params, error_type: :invalid_params)
675
+ end
676
+ end
677
+
678
+ # Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery.
679
+ # Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
680
+ # the session initialized, and responds regardless of capability declarations or initialization state,
681
+ # so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered
682
+ # because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints
683
+ # are not included here yet.
684
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
685
+ def discover(_request)
686
+ {
687
+ supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS,
688
+ capabilities: capabilities,
689
+ serverInfo: server_info,
690
+ instructions: instructions,
691
+ }.compact
692
+ end
693
+
595
694
  def configure_logging_level(request, session: nil)
596
695
  if capabilities[:logging].nil?
597
696
  raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
@@ -611,7 +710,7 @@ module MCP
611
710
  def list_tools(request)
612
711
  page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
613
712
 
614
- { tools: page[:items], nextCursor: page[:next_cursor] }.compact
713
+ apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
615
714
  end
616
715
 
617
716
  def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
@@ -667,7 +766,7 @@ module MCP
667
766
  def list_prompts(request)
668
767
  page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
669
768
 
670
- { prompts: page[:items], nextCursor: page[:next_cursor] }.compact
769
+ apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
671
770
  end
672
771
 
673
772
  def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
@@ -696,19 +795,91 @@ module MCP
696
795
  def list_resources(request)
697
796
  page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
698
797
 
699
- { resources: page[:items], nextCursor: page[:next_cursor] }.compact
798
+ apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
700
799
  end
701
800
 
702
- # Server implementation should set `resources_read_handler` to override no-op default
703
- def read_resource_no_content(request)
704
- add_instrumentation_data(resource_uri: request[:uri])
705
- []
801
+ # Default `resources/read` handler: routes to class-based resources and resource templates.
802
+ # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
803
+ # unknown URIs keep the historical no-op `[]` response instead of raising.
804
+ def read_resource(request, server_context: nil)
805
+ uri = request[:uri]
806
+ add_instrumentation_data(resource_uri: uri)
807
+
808
+ resource = @resource_index[uri]
809
+ if resource.is_a?(Class)
810
+ return normalize_resource_contents(call_resource_contents(resource, server_context))
811
+ end
812
+
813
+ @resource_templates.each do |template|
814
+ next unless template.is_a?(Class)
815
+
816
+ params = template.match_uri(uri)
817
+ next unless params
818
+
819
+ add_instrumentation_data(resource_template: template.uri_template)
820
+ return normalize_resource_contents(call_template_contents(template, params, server_context))
821
+ end
822
+
823
+ return [] unless class_based_resources_registered?
824
+
825
+ raise ResourceNotFoundError.new(uri, request)
826
+ end
827
+
828
+ def call_resource_contents(resource, server_context)
829
+ if accepts_server_context?(resource.method(:contents))
830
+ resource.contents(server_context: server_context)
831
+ else
832
+ resource.contents
833
+ end
834
+ end
835
+
836
+ def call_template_contents(template, params, server_context)
837
+ if accepts_server_context?(template.method(:contents))
838
+ template.contents(**params, server_context: server_context)
839
+ else
840
+ template.contents(**params)
841
+ end
842
+ end
843
+
844
+ def normalize_resource_contents(result)
845
+ contents = result.is_a?(Array) ? result : [result]
846
+ contents.map(&:to_h)
847
+ end
848
+
849
+ def class_based_resources_registered?
850
+ @resources.any? { |resource| resource.is_a?(Class) } ||
851
+ @resource_templates.any? { |template| template.is_a?(Class) }
706
852
  end
707
853
 
708
854
  def list_resource_templates(request)
709
855
  page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
710
856
 
711
- { resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
857
+ apply_cache_metadata({ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact)
858
+ end
859
+
860
+ # Builds the `resources/read` result. The documented handler contract is "return value becomes `contents`";
861
+ # a Hash with a `:contents` key is also accepted as a full result so a handler can override the server-level
862
+ # `ttlMs`/`cacheScope` cache hints per result (SEP-2549).
863
+ def build_read_resource_result(handler_result)
864
+ result = if handler_result.is_a?(Hash) && handler_result.key?(:contents)
865
+ handler_result
866
+ else
867
+ { contents: handler_result }
868
+ end
869
+
870
+ apply_cache_metadata(result)
871
+ end
872
+
873
+ # Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added
874
+ # unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in
875
+ # which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`).
876
+ # Values already in the result win, enabling per-result overrides.
877
+ # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
878
+ def apply_cache_metadata(result)
879
+ explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
880
+ return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
881
+
882
+ { ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
712
883
  end
713
884
 
714
885
  def complete(params, session: nil, related_request_id: nil, cancellation: nil)
@@ -792,9 +963,7 @@ module MCP
792
963
  end
793
964
 
794
965
  def index_resources_by_uri(resources)
795
- resources.each_with_object({}) do |resource, hash|
796
- hash[resource.uri] = resource
797
- end
966
+ resources.to_h { |resource| [resource.uri, resource] }
798
967
  end
799
968
 
800
969
  def error_tool_response(text)