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,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCPClient
|
|
4
|
+
module Auth
|
|
5
|
+
# Every string an authorization server, a resource server or a browser
|
|
6
|
+
# callback supplies passes through here before it reaches a log line or an
|
|
7
|
+
# exception message.
|
|
8
|
+
#
|
|
9
|
+
# Those messages are not private: {MCPClient::Auth::BrowserOAuth} renders
|
|
10
|
+
# the message of a failed flow on the page it serves to the browser, and
|
|
11
|
+
# logs are read, forwarded and pasted. Peer-supplied bytes quoted verbatim
|
|
12
|
+
# carry CR/LF into a log record (splitting it into attacker-chosen lines),
|
|
13
|
+
# terminal escape sequences into a console, and unbounded response bodies
|
|
14
|
+
# into both. And the message of a JSON::ParserError quotes the token it
|
|
15
|
+
# choked on — "expected object key, got 'SECRET' at line 1 column 2" —
|
|
16
|
+
# which puts a fragment of the very body that failed to parse into the
|
|
17
|
+
# text.
|
|
18
|
+
#
|
|
19
|
+
# So: printable, bounded text for peer strings, and position without
|
|
20
|
+
# content for a parse failure. The OAuth classes define these themselves
|
|
21
|
+
# rather than reaching for {MCPClient::JsonRpcCommon}, which they do not
|
|
22
|
+
# include: a rescue path that calls a helper its object does not have
|
|
23
|
+
# raises NoMethodError out of the very request the rescue existed to keep
|
|
24
|
+
# working.
|
|
25
|
+
#
|
|
26
|
+
# Every helper here is TOTAL: no input can raise out of it. Nothing about
|
|
27
|
+
# a peer's bytes guarantees they are valid UTF-8 — a response body arrives
|
|
28
|
+
# as whatever the socket carried, a callback parameter as whatever
|
|
29
|
+
# `CGI.unescape` made of `%FF`, and a `JSON::ParserError` message quotes
|
|
30
|
+
# the undecodable bytes it choked on — and `String#gsub`, `String#strip`
|
|
31
|
+
# and `Regexp#match` all raise `ArgumentError: invalid byte sequence in
|
|
32
|
+
# UTF-8` on them. A sanitizer that raises on the input it exists to
|
|
33
|
+
# sanitize is worse than none: it turns a peer's `400` body, or an
|
|
34
|
+
# `error_description=%FF`, into an exception out of the rescue path that
|
|
35
|
+
# was meant to report it. Undecodable bytes are therefore replaced before
|
|
36
|
+
# anything else looks at them.
|
|
37
|
+
module PeerText
|
|
38
|
+
# How much peer-supplied text a message may carry.
|
|
39
|
+
PEER_TEXT_LIMIT = 200
|
|
40
|
+
|
|
41
|
+
# What an undecodable byte becomes. ASCII, so the result is safe to
|
|
42
|
+
# write to a log device of any encoding.
|
|
43
|
+
UNDECODABLE_BYTE = '?'
|
|
44
|
+
|
|
45
|
+
# What a helper reports when even the replacement could not be made
|
|
46
|
+
# (an object whose #to_s raises, a string no encoding handler accepts).
|
|
47
|
+
# A helper here never raises, so there is always something to say.
|
|
48
|
+
UNREADABLE_TEXT = '(unreadable)'
|
|
49
|
+
|
|
50
|
+
# Peer bytes as valid UTF-8, and nothing else changed: no control
|
|
51
|
+
# characters removed, no truncation. This is the form peer text has to
|
|
52
|
+
# be in BEFORE it is parsed rather than printed — a WWW-Authenticate
|
|
53
|
+
# header matched for its Bearer segment, an OAuth error description
|
|
54
|
+
# matched for a redirect_uri mismatch, a callback parameter that is
|
|
55
|
+
# about to be compared, logged or rendered. `String#gsub`,
|
|
56
|
+
# `String#match`, `Regexp#match?`, `String#split` and `String#strip` all
|
|
57
|
+
# raise `ArgumentError` on bytes that are not valid UTF-8, so a peer
|
|
58
|
+
# that sends `%FF` turns the code that reads its error into the error.
|
|
59
|
+
# Making the bytes decodable is not enough on its own — the printable,
|
|
60
|
+
# bounded form is still what reaches a message — but it is what has to
|
|
61
|
+
# happen first, and it must happen at the point the bytes stop being
|
|
62
|
+
# bytes and start being text.
|
|
63
|
+
#
|
|
64
|
+
# A module function, not only a private instance method, because the
|
|
65
|
+
# transports read the same WWW-Authenticate header and do not (and must
|
|
66
|
+
# not) take on the rest of this module: {MCPClient::JsonRpcCommon}
|
|
67
|
+
# already defines helpers of the same names.
|
|
68
|
+
# @param text [Object] peer-supplied bytes
|
|
69
|
+
# @return [String] the same text as valid UTF-8
|
|
70
|
+
def self.decodable(text)
|
|
71
|
+
return UNREADABLE_TEXT unless text.is_a?(String)
|
|
72
|
+
|
|
73
|
+
utf8 = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
|
|
74
|
+
utf8.valid_encoding? ? utf8 : utf8.scrub(UNDECODABLE_BYTE)
|
|
75
|
+
rescue StandardError
|
|
76
|
+
UNREADABLE_TEXT
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Whether a peer's bytes are already text: a String that reads as valid
|
|
80
|
+
# UTF-8 as it stands. What {.decodable} returns for anything else is a
|
|
81
|
+
# rewriting of what the peer sent — fine for a message, wrong for a
|
|
82
|
+
# value that is about to be used as a URL.
|
|
83
|
+
# @param text [Object] peer-supplied bytes
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def self.decodable?(text)
|
|
86
|
+
return false unless text.is_a?(String)
|
|
87
|
+
|
|
88
|
+
(text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)).valid_encoding?
|
|
89
|
+
rescue StandardError
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# @param value [Object] peer-supplied text
|
|
96
|
+
# @return [String, nil] printable, bounded text; nil unless a String was given
|
|
97
|
+
def safe_error_text(value)
|
|
98
|
+
return nil unless value.is_a?(String)
|
|
99
|
+
|
|
100
|
+
printable_peer_text(value)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The same, for a value that is not necessarily a String (a response
|
|
104
|
+
# body, which Faraday may hand over as nil).
|
|
105
|
+
# @param value [Object] a peer-supplied response body
|
|
106
|
+
# @return [String] printable, bounded text, never nil
|
|
107
|
+
def safe_body_text(value)
|
|
108
|
+
return '' if value.nil?
|
|
109
|
+
|
|
110
|
+
printable_peer_text(value.is_a?(String) ? value : value.to_s)
|
|
111
|
+
rescue StandardError
|
|
112
|
+
UNREADABLE_TEXT
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Peer bytes as text that can be logged, matched, sliced and rendered:
|
|
116
|
+
# valid UTF-8 (undecodable bytes replaced), free of control characters,
|
|
117
|
+
# and bounded.
|
|
118
|
+
# @param text [String] peer-supplied bytes
|
|
119
|
+
# @return [String]
|
|
120
|
+
def printable_peer_text(text)
|
|
121
|
+
decodable_text(text).gsub(/[[:cntrl:]]/, ' ')[0, PEER_TEXT_LIMIT].to_s
|
|
122
|
+
rescue StandardError
|
|
123
|
+
UNREADABLE_TEXT
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Peer text a Regexp, #split or #strip can be run over: valid UTF-8 and
|
|
127
|
+
# otherwise unchanged, so a parser reads the value the peer sent rather
|
|
128
|
+
# than a truncated, control-stripped rendering of it.
|
|
129
|
+
# @param value [Object] peer-supplied text
|
|
130
|
+
# @return [String, nil] valid UTF-8; nil unless a String was given
|
|
131
|
+
def matchable_peer_text(value)
|
|
132
|
+
return nil unless value.is_a?(String)
|
|
133
|
+
|
|
134
|
+
PeerText.decodable(value)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The same bytes as valid UTF-8. A string in another encoding (a binary
|
|
138
|
+
# response body, a UTF-16 message) is read as UTF-8 and scrubbed rather
|
|
139
|
+
# than transcoded: the point is text that cannot raise, not a faithful
|
|
140
|
+
# rendering of a peer's mojibake.
|
|
141
|
+
# @param text [String]
|
|
142
|
+
# @return [String] valid UTF-8
|
|
143
|
+
def decodable_text(text)
|
|
144
|
+
PeerText.decodable(text)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A log-safe description of a JSON parse failure: where it failed and
|
|
148
|
+
# how much there was, never what it said.
|
|
149
|
+
# @param error [JSON::ParserError] the parse failure
|
|
150
|
+
# @param payload [Object, nil] the body that failed to parse
|
|
151
|
+
# @return [String]
|
|
152
|
+
def describe_parse_error(error, payload = nil)
|
|
153
|
+
parts = ['malformed JSON']
|
|
154
|
+
# The parser's own message quotes the bytes it choked on, so it is no
|
|
155
|
+
# more decodable than the body was: it is made matchable first.
|
|
156
|
+
location = decodable_text(error.message.to_s)[/at line \d+ column \d+/]
|
|
157
|
+
parts << location if location
|
|
158
|
+
parts << describe_body_size(payload) unless payload.nil?
|
|
159
|
+
parts.join(', ')
|
|
160
|
+
rescue StandardError
|
|
161
|
+
'malformed JSON'
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @param body [Object, nil] a response body
|
|
165
|
+
# @return [String] its size, never its content
|
|
166
|
+
def describe_body_size(body)
|
|
167
|
+
text = body.to_s
|
|
168
|
+
text.empty? ? 'empty body' : "#{text.bytesize} byte body"
|
|
169
|
+
rescue StandardError
|
|
170
|
+
'body of unknown size'
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|