mcp 1.9.0 → 1.10.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "set"
5
+ require "timeout"
5
6
  require_relative "../../result_type"
6
7
  require_relative "../../transport"
7
8
 
@@ -188,6 +189,13 @@ module MCP
188
189
  # @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
189
190
  # response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
190
191
  # Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
192
+ # @param stream_write_timeout [Numeric, nil] seconds a notification, a server-to-client request, a listen
193
+ # acknowledgement or a keepalive may take to be written to an SSE stream before the stream is dropped as stalled.
194
+ # These writes are made on behalf of every stream in turn, so a peer that stops reading would otherwise hold
195
+ # the writer, and the streams after it, for as long as its buffers stay full. The response of a request is not bounded:
196
+ # it is written on that request's own thread and may be large. The bound is per stream, so several stalled streams delay
197
+ # a notification by the sum of their waits, once each. Closing a stream is given the same number of seconds, once.
198
+ # Defaults to `DEFAULT_STREAM_WRITE_TIMEOUT` (10); pass `nil` to wait without limit.
191
199
  # @param token_verifier [#verify, nil] enables built-in OAuth 2.1 bearer enforcement: anything responding to
192
200
  # `verify(token) -> MCP::Server::OAuth::AccessToken | nil` (see `MCP::Server::OAuth::TokenVerifier` for
193
201
  # the contract and the built-in JWT/introspection implementations). Every POST, GET, and DELETE is verified
@@ -227,6 +235,7 @@ module MCP
227
235
  listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
228
236
  serve_subscriptions_listen: true,
229
237
  server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT,
238
+ stream_write_timeout: DEFAULT_STREAM_WRITE_TIMEOUT,
230
239
  token_verifier: nil,
231
240
  required_scopes: [],
232
241
  resource_metadata: nil,
@@ -325,6 +334,15 @@ module MCP
325
334
 
326
335
  @server_to_client_request_timeout = server_to_client_request_timeout
327
336
 
337
+ unless stream_write_timeout.nil? || (stream_write_timeout.is_a?(Numeric) && stream_write_timeout.positive?)
338
+ raise ArgumentError, "stream_write_timeout must be a positive number or nil"
339
+ end
340
+
341
+ @stream_write_timeout = stream_write_timeout
342
+
343
+ # Keyed by identity and weak, so a stream is not kept alive for having been recorded here.
344
+ @streams_with_abandoned_close = ObjectSpace::WeakMap.new
345
+
328
346
  if token_verifier
329
347
  @oauth_authenticator = OAuth::Authenticator.new(
330
348
  token_verifier: token_verifier,
@@ -358,6 +376,14 @@ module MCP
358
376
  REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
359
377
  REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
360
378
  STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
379
+
380
+ # Raised from a write the transport pushes onto an SSE stream when it has not finished within `stream_write_timeout:` seconds.
381
+ # An `IOError`, so every path that drops a stream on a failed write drops this one the same way.
382
+ class StreamWriteTimeoutError < IOError; end
383
+
384
+ # A write blocks only once the peer's socket buffers are full, which takes a reader already hundreds of kilobytes behind;
385
+ # one that still has not taken the frame after this long is dropped whether it stopped reading or is only that slow.
386
+ DEFAULT_STREAM_WRITE_TIMEOUT = 10
361
387
  SESSION_REAP_INTERVAL = 60
362
388
  # Seconds `close` waits for a reap in progress to finish closing its streams before it kills the reaper.
363
389
  REAPER_JOIN_TIMEOUT = 5
@@ -509,6 +535,20 @@ module MCP
509
535
  end
510
536
  end
511
537
 
538
+ # Sessions a server-wide log broadcast is offered to, each applying its own level or the server default.
539
+ # Public only so that `Server#notify_log_message` can call it. Custom transports may implement this hook;
540
+ # returning `nil` keeps the server-wide filter.
541
+ #
542
+ # @api private
543
+ # @return [Array<ServerSession>]
544
+ def logging_sessions
545
+ @mutex.synchronize do
546
+ @sessions.values.filter_map do |session|
547
+ session[:server_session] if session[:get_sse_stream]
548
+ end
549
+ end
550
+ end
551
+
512
552
  def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
513
553
  # `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below:
514
554
  # a resource updated by one session's tool call changed globally, so modern subscribers hear about it too.
@@ -619,7 +659,7 @@ module MCP
619
659
  # Writes a notification to an SSE stream without holding `@mutex`. On a write error,
620
660
  # drops the broken stream and returns false; on success returns true.
621
661
  def write_notification(stream, notification, session_id, related_request_id)
622
- send_to_stream(stream, notification)
662
+ push_to_stream(stream, notification)
623
663
  true
624
664
  rescue *STREAM_WRITE_ERRORS => e
625
665
  MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" })
@@ -722,7 +762,7 @@ module MCP
722
762
  sent = false
723
763
  if stream
724
764
  begin
725
- send_to_stream(stream, request)
765
+ push_to_stream(stream, request)
726
766
  sent = true
727
767
  rescue *STREAM_WRITE_ERRORS
728
768
  drop_broken_stream(session_id, stream, related_request_id)
@@ -866,6 +906,41 @@ module MCP
866
906
  stream.flush
867
907
  end
868
908
 
909
+ # `send_to_stream` and `send_ping_to_stream` for the frames the transport pushes onto a stream on its own: a notification,
910
+ # a server-to-client request, a listen acknowledgement, a keepalive. One thread writes them to stream after stream,
911
+ # and a keepalive write holds its stream's write mutex, so a peer that stopped reading would stall the notifications of
912
+ # every other client, and each thread that sends one, for as long as its buffers stay full.
913
+ # The write is given `stream_write_timeout:` seconds; past that it fails as a write to a broken stream does, and the caller drops the stream.
914
+ #
915
+ # The deadline may cut a frame short, so the stream is closed here, before the error leaves: a listen stream is written under its write mutex,
916
+ # which the error releases on its way out, ahead of the caller's removal of the entry, and a writer waiting on that mutex would otherwise put
917
+ # its frame after the cut one. Closed first, every later write fails as on any closed stream. The entry of a listen stream, passed as `subscription`,
918
+ # is marked closed as well, still under its write mutex, so those writers skip it even when the close itself had to be abandoned.
919
+ #
920
+ #
921
+ # The deadline interrupts the write with the exception `Timeout` raises on its own, which no `rescue` of an `IOError` or a `StandardError` inside
922
+ # a stream implementation catches, and becomes `StreamWriteTimeoutError` only here, where it leaves the write.
923
+ def push_to_stream(stream, data, subscription: nil)
924
+ within_stream_write_timeout(stream, subscription) { send_to_stream(stream, data) }
925
+ end
926
+
927
+ def push_ping_to_stream(stream, subscription: nil)
928
+ within_stream_write_timeout(stream, subscription) { send_ping_to_stream(stream) }
929
+ end
930
+
931
+ def within_stream_write_timeout(stream, subscription, &block)
932
+ return yield unless @stream_write_timeout
933
+
934
+ begin
935
+ Timeout.timeout(@stream_write_timeout, &block)
936
+ rescue Timeout::Error
937
+ subscription[:closed] = true if subscription
938
+ close_stream_safely(stream)
939
+
940
+ raise StreamWriteTimeoutError, "SSE stream write did not finish within #{@stream_write_timeout} seconds"
941
+ end
942
+ end
943
+
869
944
  # Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
870
945
  # a single POST/JSON exchange with no session. The modern path never consults
871
946
  # `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
@@ -905,9 +980,9 @@ module MCP
905
980
  message: "Unsupported protocol version",
906
981
  data: {
907
982
  supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
908
- requested: header_version,
983
+ requested: printable_value(header_version),
909
984
  },
910
- id: body[:id],
985
+ id: echoable_id(body[:id]),
911
986
  )
912
987
  end
913
988
 
@@ -916,13 +991,20 @@ module MCP
916
991
  status: 400,
917
992
  code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
918
993
  message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
919
- id: body[:id],
994
+ id: echoable_id(body[:id]),
920
995
  )
921
996
  end
922
997
 
923
998
  mismatch_error = validate_modern_headers(request, body, header_version)
924
999
  return mismatch_error if mismatch_error
925
1000
 
1001
+ # SEP-2243 custom headers: the `Mcp-Param-*` headers of a `tools/call` are checked against the body here,
1002
+ # before dispatch, so a disagreement is refused without the tool ever running.
1003
+ if body[:method] == Methods::TOOLS_CALL
1004
+ param_error = validate_mcp_param_headers(request, body)
1005
+ return param_error if param_error
1006
+ end
1007
+
926
1008
  # `subscriptions/listen` is a long-lived notification stream served at the transport layer;
927
1009
  # it never dispatches through `Server#handle`. A transport constructed with
928
1010
  # `serve_subscriptions_listen: false` skips the interception, so the method falls through
@@ -983,7 +1065,7 @@ module MCP
983
1065
  meta_version = params.is_a?(Hash) && params[:_meta].is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
984
1066
  if meta_version && meta_version != header_version
985
1067
  return header_mismatch_response(
986
- "MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
1068
+ "MCP-Protocol-Version header value '#{printable_value(header_version)}' does not match body value '#{printable_value(meta_version)}'",
987
1069
  body[:id],
988
1070
  )
989
1071
  end
@@ -1002,7 +1084,7 @@ module MCP
1002
1084
  end
1003
1085
  if method_header != body[:method]
1004
1086
  return header_mismatch_response(
1005
- "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
1087
+ "Mcp-Method header value '#{printable_value(method_header)}' does not match body value '#{printable_value(body[:method])}'",
1006
1088
  body[:id],
1007
1089
  )
1008
1090
  end
@@ -1013,7 +1095,7 @@ module MCP
1013
1095
  if body_name
1014
1096
  if name_header.to_s.empty?
1015
1097
  return header_mismatch_response(
1016
- "Mcp-Name header is required for `#{body[:method]}`",
1098
+ "Mcp-Name header is required for `#{printable_value(body[:method])}`",
1017
1099
  body[:id],
1018
1100
  )
1019
1101
  end
@@ -1021,7 +1103,7 @@ module MCP
1021
1103
  decoded_name = decode_header_value(name_header)
1022
1104
  if decoded_name != body_name
1023
1105
  return header_mismatch_response(
1024
- "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
1106
+ "Mcp-Name header value '#{printable_value(decoded_name)}' does not match body value '#{printable_value(body_name)}'",
1025
1107
  body[:id],
1026
1108
  )
1027
1109
  end
@@ -1031,6 +1113,39 @@ module MCP
1031
1113
  nil
1032
1114
  end
1033
1115
 
1116
+ # Enforces the SEP-2243 server behavior for custom headers (`-32020`, HTTP 400) on a modern `tools/call`: each
1117
+ # `x-mcp-header` parameter of the called tool must have its `Mcp-Param-{Name}` header present exactly when the body
1118
+ # carries a value for it, agreeing with that value once a `=?base64?...?=` sentinel is decoded, and a malformed
1119
+ # sentinel is refused outright. The comparison runs before the tool does, on the schema the server itself serves
1120
+ # in `tools/list`, so the body a handler acts on is the one an intermediary routed on. A tool the server does not
1121
+ # know, or one whose declarations are invalid (a conforming client drops such a tool and sends no headers for it),
1122
+ # is left to the dispatcher, and headers for parameters no declaration names are ignored, as the spec asks of any
1123
+ # server that does not recognize them. The refusal names the header and the argument, not their values.
1124
+ #
1125
+ # Rack folds header names into `HTTP_` keys with `-` as `_`, so `Mcp-Param-Region-A` and `Mcp-Param-Region_A` land
1126
+ # on one key; a token that differs only in that way is the schema author's choice, and the value still has to
1127
+ # agree with the body.
1128
+ def validate_mcp_param_headers(request, body)
1129
+ params = body[:params]
1130
+ return unless params.is_a?(Hash)
1131
+
1132
+ tool = @server.tools[params[:name]]
1133
+ return unless tool
1134
+
1135
+ scan = McpParamHeaders.scan(tool.input_schema.to_h)
1136
+ return unless scan[:valid] && !scan[:declarations].empty?
1137
+
1138
+ arguments = params[:arguments] || {}
1139
+ return unless arguments.is_a?(Hash)
1140
+
1141
+ message = McpParamHeaders.mismatch(scan[:declarations], arguments) do |header_name|
1142
+ header_bytes(request, "HTTP_#{header_name.upcase.tr("-", "_")}")
1143
+ end
1144
+ return unless message
1145
+
1146
+ header_mismatch_response(message, body[:id])
1147
+ end
1148
+
1034
1149
  # Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is
1035
1150
  # `notifications/subscriptions/acknowledged` with the subset of requested notification types
1036
1151
  # the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId`
@@ -1135,6 +1250,14 @@ module MCP
1135
1250
  false
1136
1251
  end
1137
1252
 
1253
+ # The id a refusal built here can echo: an Integer, or a String that is valid UTF-8. A String holding other
1254
+ # bytes would make the body's `to_json` raise, so the refusal carries no id, as for an id it could not read.
1255
+ def echoable_id(id)
1256
+ return id if id.is_a?(Integer) || (id.is_a?(String) && id.valid_encoding?)
1257
+
1258
+ nil
1259
+ end
1260
+
1138
1261
  def listen_subscriptions_full?
1139
1262
  return false unless @max_listen_subscriptions
1140
1263
 
@@ -1222,7 +1345,7 @@ module MCP
1222
1345
  acknowledged = subscription[:write_mutex].synchronize do
1223
1346
  next false if subscription[:closed]
1224
1347
 
1225
- send_to_stream(stream, acknowledgement)
1348
+ push_to_stream(stream, acknowledgement, subscription: subscription)
1226
1349
 
1227
1350
  # Set on the entry itself, not through the registry: a concurrent close may already have cleared the registry
1228
1351
  # while its result write waits on this mutex, and that write must still find the stream acknowledged.
@@ -1303,7 +1426,7 @@ module MCP
1303
1426
  subscription[:write_mutex].synchronize do
1304
1427
  next if subscription[:closed]
1305
1428
 
1306
- send_ping_to_stream(subscription[:stream])
1429
+ push_ping_to_stream(subscription[:stream], subscription: subscription)
1307
1430
  end
1308
1431
  end
1309
1432
 
@@ -1445,7 +1568,7 @@ module MCP
1445
1568
  subscription[:write_mutex].synchronize do
1446
1569
  next if subscription[:closed]
1447
1570
 
1448
- send_to_stream(subscription[:stream], notification)
1571
+ push_to_stream(subscription[:stream], notification, subscription: subscription)
1449
1572
  end
1450
1573
  rescue *STREAM_WRITE_ERRORS => e
1451
1574
  MCP.configuration.exception_reporter.call(
@@ -1535,6 +1658,10 @@ module MCP
1535
1658
  # The stream is left open for the caller to close.
1536
1659
  def write_listen_closing_result(subscription)
1537
1660
  subscription[:write_mutex].synchronize do
1661
+ # Already marked by a write that ran into its deadline: the last frame on the stream may be cut short,
1662
+ # and the stream may still be open because its close was abandoned, so the result must not follow it.
1663
+ next if subscription[:closed]
1664
+
1538
1665
  subscription[:closed] = true
1539
1666
 
1540
1667
  # A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
@@ -1571,12 +1698,21 @@ module MCP
1571
1698
  end
1572
1699
  end
1573
1700
 
1701
+ # A client-supplied value for an error message or `data` member. A header value reaches the transport as
1702
+ # ASCII-8BIT from a Rack server, or tagged UTF-8 from a test request or a framework, and a body value comes
1703
+ # out of the JSON parser; a value holding bytes that are not valid UTF-8 would make the response's `to_json`
1704
+ # raise and turn the intended 400 into a 500 with an exception report. Such bytes are replaced with U+FFFD;
1705
+ # a valid value comes through unchanged.
1706
+ def printable_value(value)
1707
+ value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
1708
+ end
1709
+
1574
1710
  def header_mismatch_response(message, id)
1575
1711
  json_rpc_error_response(
1576
1712
  status: 400,
1577
1713
  code: ErrorCodes::HEADER_MISMATCH,
1578
1714
  message: "Header mismatch: #{message}",
1579
- id: id,
1715
+ id: echoable_id(id),
1580
1716
  )
1581
1717
  end
1582
1718
 
@@ -1653,7 +1789,7 @@ module MCP
1653
1789
  if header_version && MCP::Configuration.modern_protocol_version?(header_version)
1654
1790
  return invalid_request_response(
1655
1791
  "Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
1656
- request_id: body[:id],
1792
+ request_id: echoable_id(body[:id]),
1657
1793
  )
1658
1794
  end
1659
1795
 
@@ -1699,7 +1835,7 @@ module MCP
1699
1835
  session = @mutex.synchronize { @sessions[session_id] }
1700
1836
  return session_not_found_response unless session && session_principal_matches?(session, request)
1701
1837
 
1702
- return already_initialized_response(body[:id])
1838
+ return already_initialized_response(echoable_id(body[:id]))
1703
1839
  end
1704
1840
 
1705
1841
  return session_not_found_response
@@ -1834,8 +1970,21 @@ module MCP
1834
1970
  removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
1835
1971
  end
1836
1972
 
1973
+ # A close can block on the same full buffers a write does (a TLS stream writes its closing alert, a buffered one flushes),
1974
+ # and it runs on the threads that deliver to every stream, in places under a stream's write mutex,
1975
+ # so it gets `stream_write_timeout:` seconds as well. One that has not finished by then is abandoned,
1976
+ # and not tried again: the path that drops a stream after its write timed out closes it a second time,
1977
+ # which would wait out the same block once more.
1837
1978
  def close_stream_safely(stream)
1838
- stream&.close
1979
+ return unless stream
1980
+ return stream.close unless @stream_write_timeout
1981
+ return if @streams_with_abandoned_close.key?(stream)
1982
+
1983
+ begin
1984
+ Timeout.timeout(@stream_write_timeout) { stream.close }
1985
+ rescue Timeout::Error
1986
+ @streams_with_abandoned_close[stream] = true
1987
+ end
1839
1988
  rescue StandardError
1840
1989
  # Ignore close-related errors from already closed/broken streams.
1841
1990
  end
@@ -1966,7 +2115,7 @@ module MCP
1966
2115
  status: 413,
1967
2116
  code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1968
2117
  message: "Payload too large: initialize request #{reason}",
1969
- id: request_id,
2118
+ id: echoable_id(request_id),
1970
2119
  )
1971
2120
  end
1972
2121
 
@@ -2055,7 +2204,7 @@ module MCP
2055
2204
  json_rpc_error_response(
2056
2205
  status: 400,
2057
2206
  code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
2058
- message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
2207
+ message: "Bad Request: Unsupported protocol version: #{printable_value(header_value)}. Supported versions: #{supported}",
2059
2208
  )
2060
2209
  end
2061
2210
 
@@ -2657,7 +2806,7 @@ module MCP
2657
2806
  def send_keepalive_ping(session_id, stream)
2658
2807
  return unless session_holds_stream?(session_id, stream)
2659
2808
 
2660
- send_ping_to_stream(stream)
2809
+ push_ping_to_stream(stream)
2661
2810
  rescue *STREAM_WRITE_ERRORS => e
2662
2811
  MCP.configuration.exception_reporter.call(
2663
2812
  e,
data/lib/mcp/server.rb CHANGED
@@ -447,6 +447,13 @@ module MCP
447
447
  # Use stderr or OpenTelemetry instead.
448
448
  def notify_log_message(data:, level:, logger: nil)
449
449
  return unless @transport
450
+
451
+ if @transport.respond_to?(:logging_sessions) && (sessions = @transport.logging_sessions)
452
+ sessions.each do |session|
453
+ session.notify_log_message(data: data, level: level, logger: logger)
454
+ end
455
+ return
456
+ end
450
457
  return unless logging_message_notification&.should_notify?(level)
451
458
 
452
459
  params = { "data" => data, "level" => level }
@@ -733,9 +740,9 @@ module MCP
733
740
 
734
741
  handler = @handlers[method]
735
742
  unless handler
736
- instrument_call("unsupported_method", server_context: { request: request }) do
743
+ instrument_call("unsupported_method", server_context: { request: request }) do |instrumentation_data|
737
744
  client = session&.client || @client
738
- add_instrumentation_data(client: client) if client
745
+ instrumentation_data.merge!(client: client) if client
739
746
  end
740
747
  return
741
748
  end
@@ -798,7 +805,7 @@ module MCP
798
805
  method,
799
806
  server_context: { request: request },
800
807
  exception_already_reported: ->(e) { reported_exception.equal?(e) },
801
- ) do
808
+ ) do |instrumentation_data|
802
809
  refuse_non_object_meta!(params)
803
810
  envelope = lift_request_envelope(params, method: method, session: session)
804
811
 
@@ -817,7 +824,7 @@ module MCP
817
824
  when Methods::INITIALIZE
818
825
  init(params, session: session)
819
826
  when Methods::RESOURCES_READ
820
- contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
827
+ contents = read_resource_contents(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
821
828
 
822
829
  # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
823
830
  contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
@@ -827,9 +834,9 @@ module MCP
827
834
 
828
835
  subscription_result(handler_result)
829
836
  when Methods::TOOLS_CALL
830
- call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
837
+ call_tool(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
831
838
  when Methods::PROMPTS_GET
832
- get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
839
+ get_prompt(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
833
840
  when Methods::COMPLETION_COMPLETE
834
841
  complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
835
842
  when Methods::LOGGING_SET_LEVEL
@@ -838,10 +845,10 @@ module MCP
838
845
  dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
839
846
  end
840
847
  client = session&.client || @client
841
- add_instrumentation_data(client: client) if client
848
+ instrumentation_data.merge!(client: client) if client
842
849
 
843
850
  if cancellation&.cancelled?
844
- add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
851
+ instrumentation_data.merge!(cancelled: true, cancellation_reason: cancellation.reason)
845
852
  next JsonRpcHandler::NO_RESPONSE
846
853
  end
847
854
 
@@ -851,6 +858,7 @@ module MCP
851
858
  result = if envelope.nil? && @input_required_legacy_shim && session
852
859
  run_legacy_input_required_shim(
853
860
  result,
861
+ instrumentation_data: instrumentation_data,
854
862
  method: method,
855
863
  params: params,
856
864
  session: session,
@@ -858,7 +866,7 @@ module MCP
858
866
  cancellation: cancellation,
859
867
  )
860
868
  else
861
- serialize_input_required_result(result, envelope: envelope, request: params, method: method)
869
+ serialize_input_required_result(result, instrumentation_data: instrumentation_data, envelope: envelope, request: params, method: method)
862
870
  end
863
871
  end
864
872
 
@@ -884,25 +892,25 @@ module MCP
884
892
 
885
893
  result
886
894
  rescue CancelledError => e
887
- add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
895
+ instrumentation_data.merge!(cancelled: true, cancellation_reason: e.reason)
888
896
  next JsonRpcHandler::NO_RESPONSE
889
897
  rescue OAuth::InsufficientScopeError => e
890
898
  # A handler's `require_scopes!` failure is a client authorization error, not a server fault:
891
899
  # surface it as an invalid-request JSON-RPC error whose message names the missing scopes.
892
900
  # HTTP-level 403 challenges remain the transport `required_scopes` gate's job.
893
901
  report_exception(e, { request: request })
894
- add_instrumentation_data(error: :insufficient_scope)
902
+ instrumentation_data.merge!(error: :insufficient_scope)
895
903
  converted = RequestHandlerError.new(e.message, request, error_type: :invalid_request, original_error: e)
896
904
  reported_exception = converted
897
905
  raise converted
898
906
  rescue RequestHandlerError => e
899
907
  report_exception(e.original_error || e, { request: request })
900
- add_instrumentation_data(error: e.error_type)
908
+ instrumentation_data.merge!(error: e.error_type)
901
909
  reported_exception = e
902
910
  raise e
903
911
  rescue => e
904
912
  report_exception(e, { request: request })
905
- add_instrumentation_data(error: :internal_error)
913
+ instrumentation_data.merge!(error: :internal_error)
906
914
  wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
907
915
  reported_exception = wrapped
908
916
  raise wrapped
@@ -998,7 +1006,7 @@ module MCP
998
1006
  # a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on
999
1007
  # (or embed requests for) capabilities the client did not declare, and reports every missing capability at
1000
1008
  # once so the client sees the full set.
1001
- def serialize_input_required_result(result, envelope:, request:, method:)
1009
+ def serialize_input_required_result(result, instrumentation_data:, envelope:, request:, method:)
1002
1010
  if envelope.nil?
1003
1011
  raise RequestHandlerError.new(
1004
1012
  "input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)",
@@ -1010,7 +1018,7 @@ module MCP
1010
1018
  missing = result.missing_client_capabilities(envelope.client_capabilities)
1011
1019
  raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty?
1012
1020
 
1013
- add_instrumentation_data(input_required: true)
1021
+ instrumentation_data.merge!(input_required: true)
1014
1022
  serialized = result.to_h
1015
1023
 
1016
1024
  if @request_state_security && serialized[:requestState]
@@ -1039,7 +1047,7 @@ module MCP
1039
1047
  # re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay
1040
1048
  # contract the modern client driver follows. The `requestState` round-trips in-process as the raw value
1041
1049
  # the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply.
1042
- def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:)
1050
+ def run_legacy_input_required_shim(result, instrumentation_data:, method:, params:, session:, related_request_id:, cancellation:)
1043
1051
  rounds = 0
1044
1052
 
1045
1053
  loop do
@@ -1071,6 +1079,7 @@ module MCP
1071
1079
  result = redispatch_mrtr_method(
1072
1080
  method,
1073
1081
  retry_params,
1082
+ instrumentation_data: instrumentation_data,
1074
1083
  session: session,
1075
1084
  related_request_id: related_request_id,
1076
1085
  cancellation: cancellation,
@@ -1104,14 +1113,14 @@ module MCP
1104
1113
 
1105
1114
  # Re-runs the handler of one of the three MRTR-capable methods for
1106
1115
  # the legacy shim. Legacy wire, so no envelope is threaded.
1107
- def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:)
1116
+ def redispatch_mrtr_method(method, params, instrumentation_data:, session:, related_request_id:, cancellation:)
1108
1117
  case method
1109
1118
  when Methods::TOOLS_CALL
1110
- call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
1119
+ call_tool(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
1111
1120
  when Methods::PROMPTS_GET
1112
- get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
1121
+ get_prompt(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
1113
1122
  when Methods::RESOURCES_READ
1114
- contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
1123
+ contents = read_resource_contents(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
1115
1124
  contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
1116
1125
  else
1117
1126
  raise RequestHandlerError.new(
@@ -1382,8 +1391,11 @@ module MCP
1382
1391
  raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
1383
1392
  end
1384
1393
 
1385
- session&.configure_logging(logging_message_notification)
1386
- @logging_message_notification = logging_message_notification
1394
+ if session
1395
+ session.configure_logging(logging_message_notification)
1396
+ else
1397
+ @logging_message_notification = logging_message_notification
1398
+ end
1387
1399
 
1388
1400
  {}
1389
1401
  end
@@ -1394,21 +1406,21 @@ module MCP
1394
1406
  apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
1395
1407
  end
1396
1408
 
1397
- def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1409
+ def call_tool(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1398
1410
  tool_name = request[:name]
1399
1411
 
1400
1412
  tool = tools[tool_name]
1401
1413
  unless tool
1402
- add_instrumentation_data(tool_name: tool_name, error: :tool_not_found)
1414
+ instrumentation_data.merge!(tool_name: tool_name, error: :tool_not_found)
1403
1415
 
1404
1416
  raise RequestHandlerError.new("Tool not found: #{tool_name}", request, error_type: :invalid_params)
1405
1417
  end
1406
1418
 
1407
1419
  arguments = request[:arguments] || {}
1408
- add_instrumentation_data(tool_name: tool_name, tool_arguments: arguments)
1420
+ instrumentation_data.merge!(tool_name: tool_name, tool_arguments: arguments)
1409
1421
 
1410
1422
  if tool.input_schema&.missing_required_arguments?(arguments)
1411
- add_instrumentation_data(error: :missing_required_arguments)
1423
+ instrumentation_data.merge!(error: :missing_required_arguments)
1412
1424
 
1413
1425
  missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
1414
1426
  return error_tool_response("Missing required arguments: #{missing}")
@@ -1418,7 +1430,7 @@ module MCP
1418
1430
  begin
1419
1431
  tool.input_schema.validate_arguments(arguments)
1420
1432
  rescue Tool::InputSchema::ValidationError => e
1421
- add_instrumentation_data(error: :invalid_schema)
1433
+ instrumentation_data.merge!(error: :invalid_schema)
1422
1434
 
1423
1435
  return error_tool_response(e.message)
1424
1436
  end
@@ -1473,11 +1485,11 @@ module MCP
1473
1485
  apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
1474
1486
  end
1475
1487
 
1476
- def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1488
+ def get_prompt(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1477
1489
  prompt_name = request[:name]
1478
1490
  prompt = @prompts[prompt_name]
1479
1491
  unless prompt
1480
- add_instrumentation_data(error: :prompt_not_found)
1492
+ instrumentation_data.merge!(error: :prompt_not_found)
1481
1493
  # The explicit `error_code` maps an unknown prompt to Invalid Params (-32602) rather than
1482
1494
  # the default Internal Error (-32603), matching the `tools/call`, `resources/read`, and
1483
1495
  # `completion/complete` siblings for the same not-found condition, while `error_type:
@@ -1490,7 +1502,7 @@ module MCP
1490
1502
  )
1491
1503
  end
1492
1504
 
1493
- add_instrumentation_data(prompt_name: prompt_name)
1505
+ instrumentation_data.merge!(prompt_name: prompt_name)
1494
1506
 
1495
1507
  prompt_args = request[:arguments]
1496
1508
  prompt.validate_arguments!(prompt_args)
@@ -1766,9 +1778,9 @@ module MCP
1766
1778
  # Default `resources/read` handler: routes to class-based resources and resource templates.
1767
1779
  # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
1768
1780
  # unknown URIs keep the historical no-op `[]` response instead of raising.
1769
- def read_resource(request, server_context: nil)
1781
+ def read_resource(request, instrumentation_data:, server_context: nil)
1770
1782
  uri = request[:uri]
1771
- add_instrumentation_data(resource_uri: uri)
1783
+ instrumentation_data.merge!(resource_uri: uri)
1772
1784
 
1773
1785
  # Files of directory-backed skills are looked up by exact string equality in a map built at construction,
1774
1786
  # so request data never reaches the filesystem; a miss falls through to the routing below.
@@ -1798,7 +1810,7 @@ module MCP
1798
1810
  params = template.match_uri(uri)
1799
1811
  next unless params
1800
1812
 
1801
- add_instrumentation_data(resource_template: template.uri_template)
1813
+ instrumentation_data.merge!(resource_template: template.uri_template)
1802
1814
  return normalize_resource_contents(call_template_contents(template, params, server_context))
1803
1815
  end
1804
1816
 
@@ -1884,9 +1896,22 @@ module MCP
1884
1896
  # Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
1885
1897
  # pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
1886
1898
  # `server_context.raise_if_cancelled!`.
1887
- def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1899
+ def read_resource_contents(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
1900
+ handler = @handlers[Methods::RESOURCES_READ]
1901
+ if handler == method(:read_resource)
1902
+ server_context = build_server_context(
1903
+ request: request,
1904
+ session: session,
1905
+ related_request_id: related_request_id,
1906
+ cancellation: cancellation,
1907
+ envelope: envelope,
1908
+ auth_info: auth_info,
1909
+ )
1910
+ return read_resource(request, instrumentation_data: instrumentation_data, server_context: server_context)
1911
+ end
1912
+
1888
1913
  dispatch_optional_context_handler(
1889
- @handlers[Methods::RESOURCES_READ],
1914
+ handler,
1890
1915
  request,
1891
1916
  session: session,
1892
1917
  related_request_id: related_request_id,