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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- 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
|