ruby-mcp-client 2.1.0 → 3.0.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Deep copies for the objects a transport hands out of its caches: a
5
+ # caller may change what it received without changing what is cached.
6
+ # Copies every instance variable but the transport reference (@server).
7
+ module DeepCopy
8
+ # @param value [Object] JSON-like data (hashes, arrays, strings, scalars)
9
+ # @return [Object] a copy sharing no mutable structure with the input
10
+ def self.copy(value)
11
+ # Iterative: a peer-supplied document may be nested deeper than the
12
+ # Ruby stack allows, and a copy must never be what overflows it.
13
+ root = shallow_copy(value)
14
+ pending = [[value, root]]
15
+ until pending.empty?
16
+ source, target = pending.pop
17
+ case source
18
+ when Hash then source.each { |k, v| pending << [v, target[shallow_copy(k)] = shallow_copy(v)] }
19
+ when Array then source.each_with_index { |v, i| pending << [v, target[i] = shallow_copy(v)] }
20
+ end
21
+ end
22
+ root
23
+ end
24
+
25
+ # @param value [Object]
26
+ # @return [Object] an empty container for a hash or array, else the
27
+ # leaf copy {.copy} would have made
28
+ def self.shallow_copy(value)
29
+ case value
30
+ when Hash then {}
31
+ when Array then Array.new(value.size)
32
+ when String then value.frozen? ? value : value.dup
33
+ when DeepCopy then value.dup
34
+ else value
35
+ end
36
+ end
37
+
38
+ # @param source [Object] the object being copied
39
+ # @return [void]
40
+ def initialize_copy(source)
41
+ super
42
+ source.instance_variables.each do |ivar|
43
+ next if ivar == :@server
44
+
45
+ instance_variable_set(ivar, DeepCopy.copy(source.instance_variable_get(ivar)))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'deprecations'
4
+
5
+ module MCPClient
6
+ # The 2026-07-28 deprecation notices raised from the TRANSPORT, so a host
7
+ # that drives a `ServerStdio`, `ServerSSE`, `ServerHTTP` or
8
+ # `ServerStreamableHTTP` object directly still sees them. Constructing a
9
+ # {MCPClient::Client} is not the only way to negotiate and serve Roots,
10
+ # Sampling or Logging: `on_roots_list_request`, `on_sampling_request` and
11
+ # `on_notification` are public transport APIs, and a notice tied to the
12
+ # Client constructor never fires for a caller that uses them.
13
+ #
14
+ # Mixed into {MCPClient::JsonRpcCommon}, so every transport has it.
15
+ module DeprecationNotices
16
+ # Serving a roots/list request. The Roots capability counts as USED only
17
+ # when the answer actually carries a root: a transport's roots handler is
18
+ # registered independently of whether the host ever configured a root —
19
+ # {MCPClient::Client} registers one on every server so a later `roots=`
20
+ # is served, and answers with an empty list until a root is set — so a
21
+ # registered handler is no evidence the host adopted the feature, while a
22
+ # non-empty answer is. A host that never opted in must not be told it is
23
+ # using a deprecated feature.
24
+ # @param result [Hash, nil] the roots/list result about to be served
25
+ # @return [void]
26
+ def warn_roots_deprecated(result)
27
+ roots = result.is_a?(Hash) ? (result['roots'] || result[:roots]) : nil
28
+ return unless roots.is_a?(Array) && !roots.empty?
29
+
30
+ MCPClient::Deprecations.warn(:roots, @logger)
31
+ end
32
+
33
+ # Serving a sampling/createMessage request. SEP-2596 also deprecated the
34
+ # includeContext values "thisServer" and "allServers", which arrive on
35
+ # the very same request, so both notices belong here.
36
+ # @param params [Hash, nil] the sampling/createMessage params
37
+ # @return [void]
38
+ def warn_sampling_deprecated(params = nil)
39
+ MCPClient::Deprecations.warn(:sampling, @logger)
40
+ value = params.is_a?(Hash) ? params['includeContext'] : nil
41
+ return unless %w[thisServer allServers].include?(value)
42
+
43
+ MCPClient::Deprecations.warn(:include_context, @logger, detail: "includeContext #{value}")
44
+ end
45
+
46
+ # Receiving or setting a log level over MCP: the whole Logging page is
47
+ # Deprecated (SEP-2577).
48
+ # @return [void]
49
+ def warn_logging_deprecated
50
+ MCPClient::Deprecations.warn(:logging, @logger)
51
+ end
52
+
53
+ # A request whose effective `_meta` carries the log level. 2026-07-28
54
+ # moved the level off `logging/setLevel` and onto every request, so
55
+ # `log_level=` and an incoming `notifications/message` are not the only
56
+ # ways in: a host that puts `io.modelcontextprotocol/logLevel` in
57
+ # `request_meta` or in a per-call `_meta` adopts the same deprecated
58
+ # utility, and {MCPClient::JsonRpcCommon#with_request_meta} deliberately
59
+ # forwards it on the wire. That is a first use like any other.
60
+ # @param meta [Hash, nil] the effective outgoing request metadata
61
+ # @return [void]
62
+ def warn_request_log_level_deprecated(meta)
63
+ return unless meta.is_a?(Hash)
64
+
65
+ key = MCPClient::JsonRpcCommon::META_LOG_LEVEL
66
+ return unless meta.key?(key) || meta.key?(key.to_sym)
67
+
68
+ warn_logging_deprecated
69
+ end
70
+
71
+ # The notice for an input request fulfilled through the multi round-trip
72
+ # pattern, whose handler is the same callback the legacy
73
+ # server-initiated request would have reached. Asking for a sample IS the
74
+ # use of Sampling, so its notice is raised before the handler runs; Roots
75
+ # is only used once the answer carries a root, so its notice waits for
76
+ # {#warn_input_request_answer_deprecated}.
77
+ # @param method [String] the input request's JSON-RPC method
78
+ # @param params [Hash, nil] the input request's params
79
+ # @return [void]
80
+ def warn_input_request_deprecated(method, params)
81
+ warn_sampling_deprecated(params) if method == 'sampling/createMessage'
82
+ end
83
+
84
+ # The half of the input-request notice that needs the handler's answer:
85
+ # Roots is deprecated, but answering roots/list with no roots is not use
86
+ # of it (see {#warn_roots_deprecated}).
87
+ # @param method [String] the input request's JSON-RPC method
88
+ # @param result [Hash, nil] the handler's result
89
+ # @return [void]
90
+ def warn_input_request_answer_deprecated(method, result)
91
+ warn_roots_deprecated(result) if method == 'roots/list'
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module MCPClient
6
+ # Deprecation notices for the features listed as Deprecated by the MCP
7
+ # 2026-07-28 deprecated features registry (feature lifecycle policy,
8
+ # SEP-2596): they keep working during their deprecation window, but new
9
+ # integrations should not adopt them. `earliest_removal` carries the
10
+ # registry's own "Earliest removal" wording
11
+ # (https://modelcontextprotocol.io/specification/2026-07-28/deprecated)
12
+ # rather than a paraphrase of the policy floor: what the features
13
+ # 2026-07-28 deprecates wait for is the first revision RELEASED on or after
14
+ # 2027-07-28, which may fall well after that date, so a host must not plan
15
+ # around 2027-07-28 as a removal date. The includeContext values follow
16
+ # Sampling, and only the HTTP+SSE transport has a clock of its own. The
17
+ # earliest removal marks when a feature becomes eligible for removal; the
18
+ # actual removal is a Core Maintainer decision. The client logs one notice
19
+ # per feature per process, on the first use, and names both the earliest
20
+ # removal and the suggested migration.
21
+ #
22
+ # Notices can be silenced with `MCPClient::Deprecations.enabled = false`.
23
+ module Deprecations
24
+ # The "Earliest removal" the registry gives Roots, Sampling, Logging and
25
+ # Dynamic Client Registration. It names a revision, not a date: the
26
+ # release on or after 2027-07-28 may itself be later than 2027-07-28.
27
+ REVISION_AFTER_2027_07_28 = 'the first revision released on or after 2027-07-28'
28
+
29
+ # Every feature the 2026-07-28 deprecated features registry lists, keyed
30
+ # by the identifier passed to {.warn}. `since` is the protocol revision
31
+ # in which the feature entered the Deprecated state; `earliest_removal`
32
+ # is the registry's "Earliest removal" cell verbatim, so features that
33
+ # share a window carry the identical string.
34
+ REGISTRY = {
35
+ roots: {
36
+ feature: 'Roots',
37
+ since: '2026-07-28',
38
+ reference: 'SEP-2577',
39
+ earliest_removal: REVISION_AFTER_2027_07_28,
40
+ migration: 'pass directories or files through tool parameters, resource URIs or server configuration'
41
+ },
42
+ sampling: {
43
+ feature: 'Sampling',
44
+ since: '2026-07-28',
45
+ reference: 'SEP-2577',
46
+ earliest_removal: REVISION_AFTER_2027_07_28,
47
+ migration: 'integrate directly with the LLM provider API instead of serving sampling/createMessage'
48
+ },
49
+ logging: {
50
+ feature: 'Logging',
51
+ since: '2026-07-28',
52
+ reference: 'SEP-2577',
53
+ earliest_removal: REVISION_AFTER_2027_07_28,
54
+ migration: 'have the server log to stderr (stdio) or use OpenTelemetry instead of notifications/message'
55
+ },
56
+ http_sse_transport: {
57
+ feature: 'The HTTP+SSE transport',
58
+ since: '2025-03-26',
59
+ reference: 'reclassified by SEP-2596 in 2026-07-28',
60
+ earliest_removal: 'three months after SEP-2596 reaches Final',
61
+ migration: 'migrate the server to Streamable HTTP (MCPClient::ServerStreamableHTTP)'
62
+ },
63
+ include_context: {
64
+ feature: 'The includeContext values "thisServer" and "allServers"',
65
+ since: '2025-11-25',
66
+ reference: 'reclassified by SEP-2596 in 2026-07-28',
67
+ earliest_removal: 'follows Sampling (SEP-2577)',
68
+ migration: 'servers should omit includeContext or send "none"; the values are removed no later than Sampling'
69
+ },
70
+ dynamic_client_registration: {
71
+ feature: 'OAuth 2.0 Dynamic Client Registration (RFC 7591)',
72
+ since: '2026-07-28',
73
+ reference: 'MCP PR #2858',
74
+ earliest_removal: REVISION_AFTER_2027_07_28,
75
+ migration: 'prefer a Client ID Metadata Document (client_id_metadata_url) or pre-registered credentials'
76
+ }
77
+ }.freeze
78
+
79
+ # Longest peer-supplied detail quoted in a notice.
80
+ MAX_DETAIL_LENGTH = 200
81
+
82
+ # How many wrappers deep the logger the host passed is looked through
83
+ # (see {.underlying_logger}). One or two is what a host actually builds;
84
+ # the bound is only there so a delegator that holds itself cannot spin.
85
+ MAX_UNWRAP_DEPTH = 8
86
+
87
+ # Marks a thread that is inside a notice: from the moment it first asks
88
+ # the logger anything until it comes back out of the write. It covers
89
+ # the level probe as well as the write, because both are host code and
90
+ # either can reach a deprecated feature and come straight back in. A
91
+ # thread-level variable, not a fiber-local one: what it guards is a
92
+ # claim this thread holds, which every fiber of the thread holds with it.
93
+ EMITTING_KEY = :mcp_client_deprecation_emitting
94
+
95
+ # A notice claimed by a caller that is inside the logger right now, as
96
+ # opposed to {EMITTED} for one the logger took.
97
+ WRITING = :writing
98
+
99
+ # A notice that went out.
100
+ EMITTED = :emitted
101
+
102
+ @enabled = true
103
+ @notices = {}
104
+ @mutex = Mutex.new
105
+ @owner_pid = Process.pid
106
+
107
+ class << self
108
+ # @return [Boolean] whether notices are logged (default true)
109
+ attr_writer :enabled
110
+
111
+ # @return [Boolean] whether notices are logged
112
+ def enabled?
113
+ @enabled
114
+ end
115
+
116
+ # Log the notice for a deprecated feature once per process. The notice
117
+ # counts as emitted only once the logger accepted it: a logger that
118
+ # drops warnings (its own level above WARN, or that of a logger it
119
+ # wraps), writes nowhere (`Logger.new(nil)`, a wrapper around one,
120
+ # or a device that was closed), fails to report its level or raises
121
+ # leaves it for a later use, and so do a nested attempt from inside
122
+ # another notice's logger — its `level` accessor as much as its `warn`
123
+ # — and a caller that finds the notice already in flight (see
124
+ # {.emit_once}). Never raises for a logger failure: the deprecated
125
+ # feature keeps working whatever the log does (feature lifecycle
126
+ # policy).
127
+ #
128
+ # A notice costs its caller what one `logger.warn` costs it, and no
129
+ # more. That is not a promise that it never waits: every other
130
+ # `logger.warn` in this library blocks its caller the same way, so a
131
+ # logger that blocks forever blocks the library everywhere, not only
132
+ # here, and this path claims no exemption the rest of the code cannot
133
+ # claim. What it does promise is that the waiting is the logger's: no
134
+ # caller ever waits for a lock of THIS module, which is never held
135
+ # while calling out, so a notice in flight never delays another
136
+ # feature's notice, another caller, {.emitted?} or {.reset!}.
137
+ # @param feature [Symbol] a {REGISTRY} key
138
+ # @param logger [Logger, nil] where the notice goes (a nil logger emits nothing)
139
+ # @param detail [String, nil] peer-supplied context quoted in the notice
140
+ # (control characters are escaped and the text is bounded)
141
+ # @return [Boolean] true when a notice was written, false when it was
142
+ # already emitted or in flight, notices are disabled, the logger drops
143
+ # warnings or failed
144
+ # @raise [ArgumentError] for an unknown feature
145
+ def warn(feature, logger, detail: nil)
146
+ entry = REGISTRY[feature] or raise ArgumentError, "unknown deprecated feature: #{feature.inspect}"
147
+ return false unless enabled? && logger
148
+ # Asking the logger anything is already calling out to the host, so
149
+ # the reentrancy guard goes up here rather than around the write
150
+ # alone: `level` is host code too, and a host whose accessor reaches
151
+ # a deprecated feature (a formatter, a log subscriber, an audit hook
152
+ # reading the client's configuration) comes straight back into this
153
+ # method. Guarding only `logger.warn` leaves that probe recursing
154
+ # until the stack ends, and SystemStackError is not a StandardError,
155
+ # so it escapes the rescues that exist to keep the deprecated
156
+ # operation working and takes the host's `roots=` down with it.
157
+ return false if emitting?
158
+
159
+ mark_emitting(true)
160
+ begin
161
+ accepts_warnings?(logger) && emit_once(feature, logger) { logger.warn(message(entry, detail)) }
162
+ ensure
163
+ mark_emitting(false)
164
+ end
165
+ end
166
+
167
+ # @param feature [Symbol] a {REGISTRY} key
168
+ # @return [Boolean] whether a notice for the feature actually went out.
169
+ # A caller currently inside `logger.warn` has not emitted one: it may
170
+ # yet fail, which leaves the notice owed.
171
+ def emitted?(feature)
172
+ @mutex.synchronize { notice_states[feature] == EMITTED }
173
+ end
174
+
175
+ # Forget which notices were emitted (each feature warns again on its
176
+ # next use). Intended for tests.
177
+ # @return [void]
178
+ def reset!
179
+ @mutex.synchronize do
180
+ @owner_pid = Process.pid
181
+ @notices.clear
182
+ end
183
+ end
184
+
185
+ private
186
+
187
+ # Run the emission at most once per feature per process, and count it
188
+ # only once it came back without raising.
189
+ #
190
+ # Emitting is what spends the notice, not attempting to: marking the
191
+ # feature spent before the logger had said anything could lose the
192
+ # notice outright, since a first use that fails inside a broken logger
193
+ # would leave every later use looking at a spent slot. So an attempt
194
+ # is a CLAIM (see {.claim}), released again when the logger did not
195
+ # take the notice, and the feature is marked emitted only afterwards.
196
+ #
197
+ # Nothing is ever waited for. A caller that finds the notice in flight
198
+ # stands down at once and leaves it to a later use, exactly as a
199
+ # dropped one is left — it does not queue behind the emission, and it
200
+ # does not take it over. Queueing is what buys a lock-order inversion,
201
+ # and no rule about who may queue can avoid it, because the waiter
202
+ # cannot know what it is holding: `logger.warn` is host code that
203
+ # serializes its writes (as ::Logger does behind its device lock) and
204
+ # that may reach a deprecated feature from a formatter, a log
205
+ # subscriber or an audit hook. A thread writing an ORDINARY log line
206
+ # holds that device lock and is not inside a notice at all; let it
207
+ # queue for a notice held by a thread that is waiting for the same
208
+ # device lock and both stop, taking the sampling request, the log
209
+ # level or the SSE `connect` behind the notice with them. A claim is
210
+ # a mark under @mutex instead, taken and released without ever calling
211
+ # out of this module, so a lock of ours is never held across host code
212
+ # and never acquired behind one of theirs.
213
+ #
214
+ # Standing down loses nothing that was there to lose: whoever holds
215
+ # the claim is writing that notice, and if their logger fails the
216
+ # claim is released, so the next use of the feature attempts it again.
217
+ # A thread already inside a notice stands down too — {.warn} turns it
218
+ # away before it reaches here — which keeps a logger callback from
219
+ # re-entering the host's logger under this module's own name.
220
+ #
221
+ # The logger is asked one more time whether it keeps warnings, next to
222
+ # the write rather than at the top of {.warn}: `Logger#warn` returns
223
+ # true whether it wrote or filtered, so a level that went up in
224
+ # between would otherwise spend the process's one notice on a warning
225
+ # nobody can read. A level that changes DURING the write is beyond
226
+ # reach — that race is the host's own, and the same one two of its
227
+ # threads have with each other.
228
+ #
229
+ # The caller ({.warn}) has already marked this thread as emitting, so
230
+ # both that second probe and the write itself run guarded.
231
+ # @param feature [Symbol] a {REGISTRY} key
232
+ # @param logger [Logger, #warn] the logger the emission writes to
233
+ # @yield the emission, called with no lock of this module held
234
+ # @return [Boolean] whether this call emitted the notice
235
+ def emit_once(feature, logger)
236
+ return false unless claim(feature)
237
+
238
+ written = false
239
+ begin
240
+ if accepts_warnings?(logger)
241
+ yield
242
+ written = true
243
+ end
244
+ rescue StandardError
245
+ written = false
246
+ ensure
247
+ settle(feature, written)
248
+ end
249
+ written
250
+ end
251
+
252
+ # Reserve this process's notice for the caller. The claim is a mark in
253
+ # the same map that records emitted notices, so a feature is claimable
254
+ # only while it is neither emitted nor being written right now, and
255
+ # taking it costs @mutex for the length of a hash lookup.
256
+ # @param feature [Symbol] a {REGISTRY} key
257
+ # @return [Boolean] whether the caller may write the notice
258
+ def claim(feature)
259
+ @mutex.synchronize do
260
+ states = notice_states
261
+ return false if states.key?(feature)
262
+
263
+ states[feature] = WRITING
264
+ true
265
+ end
266
+ end
267
+
268
+ # Close a claim: spend the notice, or hand it back to a later use.
269
+ # @param feature [Symbol] a {REGISTRY} key
270
+ # @param written [Boolean] whether the logger took the notice
271
+ # @return [void]
272
+ def settle(feature, written)
273
+ @mutex.synchronize do
274
+ states = notice_states
275
+ written ? states[feature] = EMITTED : states.delete(feature)
276
+ end
277
+ end
278
+
279
+ # @return [Boolean] whether this thread is already inside a notice
280
+ def emitting?
281
+ Thread.current.thread_variable_get(EMITTING_KEY) ? true : false
282
+ end
283
+
284
+ # @param value [Boolean] whether this thread is inside a notice
285
+ # @return [void]
286
+ def mark_emitting(value)
287
+ Thread.current.thread_variable_set(EMITTING_KEY, value)
288
+ end
289
+
290
+ # What this process knows about each feature's notice: {WRITING} while
291
+ # a caller is inside the logger, {EMITTED} once one came back having
292
+ # written it, absent otherwise. A prefork server (Puma, Unicorn) that
293
+ # warned while preloading would hand every worker an already-spent map
294
+ # and silence the worker's own first use, so an inherited map is
295
+ # dropped the first time the owning PID no longer matches — including
296
+ # any claim held by a thread that did not survive the fork, which no
297
+ # one in the child will ever settle. Callers hold @mutex.
298
+ # @return [Hash{Symbol => Symbol}]
299
+ def notice_states
300
+ if @owner_pid != Process.pid
301
+ @owner_pid = Process.pid
302
+ @notices = {}
303
+ end
304
+ @notices
305
+ end
306
+
307
+ # Whether the logger would keep a warning. Asking is itself protected:
308
+ # a Logger subclass whose `level` accessor raises must not abort the
309
+ # deprecated operation, so the failure is treated as "would not keep
310
+ # it" and the notice slot stays free for a later, working logger.
311
+ # @param logger [Logger, #warn] the candidate logger
312
+ # @return [Boolean] false when the logger drops warnings or asking failed
313
+ def accepts_warnings?(logger)
314
+ return false if drops_warnings?(logger)
315
+
316
+ !no_output_device?(logger)
317
+ rescue StandardError
318
+ false
319
+ end
320
+
321
+ # Whether the logger says it would drop a WARN record.
322
+ #
323
+ # A host's logger is rarely a bare ::Logger: Rails hands out a tagged
324
+ # or a broadcast logger, and an application that routes its own
325
+ # deprecation output wraps one itself. Every such wrapper answers
326
+ # `warn` without writing when the logger underneath is above WARN, so
327
+ # reading `level` off a ::Logger and nothing else spends the process's
328
+ # one notice on a line nobody can read — and silences the host's own
329
+ # working logger for good, since the slot is then marked emitted.
330
+ # `warn?` is the same question in the form a wrapper forwards, and it
331
+ # is asked only of an object that offers it: a minimal host logger
332
+ # implementing `warn` and nothing else is still taken at its word.
333
+ #
334
+ # A wrapper that filters on something a level cannot express — a tag,
335
+ # a source allow-list — cannot be asked at all, and does spend the
336
+ # notice. That is the boundary of what this can promise; what it may
337
+ # not do is fail the deprecated operation trying to establish more
338
+ # (see {.accepts_warnings?}).
339
+ # @param logger [Logger, #warn] the candidate logger
340
+ # @return [Boolean] whether a warning written now would be dropped
341
+ def drops_warnings?(logger)
342
+ return !logger.warn? if logger.respond_to?(:warn?)
343
+
344
+ logger.is_a?(::Logger) && logger.level > ::Logger::WARN
345
+ end
346
+
347
+ # `Logger.new(nil)` is the documented no-output logger: it keeps every
348
+ # level, so the level check passes, and `warn` returns successfully
349
+ # having written nothing. Counting that as the notice would spend it on
350
+ # a reader that does not exist and silence every later use — including
351
+ # one holding a logger that does write. A logger has no device only
352
+ # when it was built without one: `logger` 1.7 also folds
353
+ # `Logger.new(File::NULL)` into that (it opens no file), earlier
354
+ # versions give it a real device, and either reading is safe here —
355
+ # the notice is written or it stays owed.
356
+ #
357
+ # A CLOSED device reads the same way, and needs asking for separately:
358
+ # `Logger::LogDevice#write` rescues the failure of a write to a closed
359
+ # IO and reports it through `Kernel#warn`, so `Logger#warn` returns
360
+ # exactly as it does after a successful write and the caller cannot
361
+ # tell from its answer that the line went nowhere. Only the absence of
362
+ # a device is visible without asking, so a closed one would otherwise
363
+ # spend the process's notice on a stream nobody can read.
364
+ # @param logger [Logger, #warn] the candidate logger
365
+ # @return [Boolean] whether the logger provably writes nowhere
366
+ def no_output_device?(logger)
367
+ logger = underlying_logger(logger)
368
+ return false unless logger.is_a?(::Logger) && logger.instance_variable_defined?(:@logdev)
369
+
370
+ logdev = logger.instance_variable_get(:@logdev)
371
+ return true if logdev.nil?
372
+
373
+ device = logdev.respond_to?(:dev) ? logdev.dev : nil
374
+ return true if device.nil?
375
+
376
+ # A device that cannot say whether it is closed is taken as open: a
377
+ # notice written to a working stream is the point, and treating an
378
+ # unknown device as dead would suppress every notice a host with a
379
+ # custom log device should see. An OPEN device that fails to write is
380
+ # the one failure this cannot see: ::Logger's device rescues it and
381
+ # reports on $stderr only, returning as if it had written, so such a
382
+ # notice is spent (the documented boundary of the retry guarantee).
383
+ device.respond_to?(:closed?) && device.closed?
384
+ end
385
+
386
+ # The logger under whatever the host wrapped it in. A Delegator
387
+ # forwards `warn` and `warn?` to the logger it holds, but not the
388
+ # question above: `instance_variable_defined?` is Object's own method
389
+ # and answers for the WRAPPER, so a wrapped `Logger.new(nil)` would
390
+ # read as some unknown logger that writes somewhere and spend the
391
+ # notice on a device that does not exist.
392
+ # @param logger [Logger, #warn] the logger the host passed
393
+ # @return [Object] the logger that would take the write
394
+ def underlying_logger(logger)
395
+ MAX_UNWRAP_DEPTH.times do
396
+ break unless defined?(::Delegator) && logger.is_a?(::Delegator)
397
+
398
+ logger = logger.__getobj__
399
+ end
400
+ logger
401
+ end
402
+
403
+ # @return [String] the notice text
404
+ def message(entry, detail)
405
+ text = "#{entry[:feature]} is deprecated since MCP #{entry[:since]} (#{entry[:reference]}); " \
406
+ 'it keeps working during its deprecation window. Earliest removal: ' \
407
+ "#{entry[:earliest_removal]}. Migration: #{entry[:migration]}."
408
+ detail ? "#{text} Received: #{sanitize(detail)}" : text
409
+ end
410
+
411
+ # @return [String] the detail with control characters (and the Unicode
412
+ # line and paragraph separators) escaped and its length bounded
413
+ def sanitize(detail)
414
+ escaped = detail.to_s.gsub(/[[:cntrl:]\u2028\u2029]/) { |c| format('\\u%04X', c.ord) }
415
+ escaped.length <= MAX_DETAIL_LENGTH ? escaped : "#{escaped[0, MAX_DETAIL_LENGTH]}..."
416
+ end
417
+ end
418
+ end
419
+ end