ruby-mcp-client 0.6.2 → 0.7.1
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/README.md +316 -10
- data/lib/mcp_client/auth/oauth_provider.rb +514 -0
- data/lib/mcp_client/auth.rb +315 -0
- data/lib/mcp_client/client.rb +1 -1
- data/lib/mcp_client/config_parser.rb +73 -1
- data/lib/mcp_client/http_transport_base.rb +283 -0
- data/lib/mcp_client/json_rpc_common.rb +8 -10
- data/lib/mcp_client/oauth_client.rb +127 -0
- data/lib/mcp_client/server_factory.rb +42 -0
- data/lib/mcp_client/server_http/json_rpc_transport.rb +27 -0
- data/lib/mcp_client/server_http.rb +331 -0
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +5 -5
- data/lib/mcp_client/server_sse.rb +16 -8
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1 -1
- data/lib/mcp_client/server_stdio.rb +1 -1
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +76 -0
- data/lib/mcp_client/server_streamable_http.rb +332 -0
- data/lib/mcp_client/tool.rb +4 -3
- data/lib/mcp_client/version.rb +4 -1
- data/lib/mcp_client.rb +61 -2
- metadata +10 -2
@@ -0,0 +1,315 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'uri'
|
4
|
+
require 'json'
|
5
|
+
require 'base64'
|
6
|
+
require 'digest'
|
7
|
+
require 'securerandom'
|
8
|
+
require 'time'
|
9
|
+
|
10
|
+
module MCPClient
|
11
|
+
# OAuth 2.1 implementation for MCP client authentication
|
12
|
+
module Auth
|
13
|
+
# OAuth token model representing access/refresh tokens
|
14
|
+
class Token
|
15
|
+
attr_reader :access_token, :token_type, :expires_in, :scope, :refresh_token, :expires_at
|
16
|
+
|
17
|
+
# @param access_token [String] The access token
|
18
|
+
# @param token_type [String] Token type (default: "Bearer")
|
19
|
+
# @param expires_in [Integer, nil] Token lifetime in seconds
|
20
|
+
# @param scope [String, nil] Token scope
|
21
|
+
# @param refresh_token [String, nil] Refresh token for renewal
|
22
|
+
def initialize(access_token:, token_type: 'Bearer', expires_in: nil, scope: nil, refresh_token: nil)
|
23
|
+
@access_token = access_token
|
24
|
+
@token_type = token_type
|
25
|
+
@expires_in = expires_in
|
26
|
+
@scope = scope
|
27
|
+
@refresh_token = refresh_token
|
28
|
+
@expires_at = expires_in ? Time.now + expires_in : nil
|
29
|
+
end
|
30
|
+
|
31
|
+
# Check if the token is expired
|
32
|
+
# @return [Boolean] true if token is expired
|
33
|
+
def expired?
|
34
|
+
return false unless @expires_at
|
35
|
+
|
36
|
+
Time.now >= @expires_at
|
37
|
+
end
|
38
|
+
|
39
|
+
# Check if the token is close to expiring (within 5 minutes)
|
40
|
+
# @return [Boolean] true if token expires soon
|
41
|
+
def expires_soon?
|
42
|
+
return false unless @expires_at
|
43
|
+
|
44
|
+
Time.now >= (@expires_at - 300) # 5 minutes buffer
|
45
|
+
end
|
46
|
+
|
47
|
+
# Convert token to authorization header value
|
48
|
+
# @return [String] Authorization header value
|
49
|
+
def to_header
|
50
|
+
"#{@token_type} #{@access_token}"
|
51
|
+
end
|
52
|
+
|
53
|
+
# Convert to hash for serialization
|
54
|
+
# @return [Hash] Hash representation
|
55
|
+
def to_h
|
56
|
+
{
|
57
|
+
access_token: @access_token,
|
58
|
+
token_type: @token_type,
|
59
|
+
expires_in: @expires_in,
|
60
|
+
scope: @scope,
|
61
|
+
refresh_token: @refresh_token,
|
62
|
+
expires_at: @expires_at&.iso8601
|
63
|
+
}
|
64
|
+
end
|
65
|
+
|
66
|
+
# Create token from hash
|
67
|
+
# @param data [Hash] Token data
|
68
|
+
# @return [Token] New token instance
|
69
|
+
def self.from_h(data)
|
70
|
+
token = new(
|
71
|
+
access_token: data[:access_token] || data['access_token'],
|
72
|
+
token_type: data[:token_type] || data['token_type'] || 'Bearer',
|
73
|
+
expires_in: data[:expires_in] || data['expires_in'],
|
74
|
+
scope: data[:scope] || data['scope'],
|
75
|
+
refresh_token: data[:refresh_token] || data['refresh_token']
|
76
|
+
)
|
77
|
+
|
78
|
+
# Set expires_at if provided
|
79
|
+
if (expires_at_str = data[:expires_at] || data['expires_at'])
|
80
|
+
token.instance_variable_set(:@expires_at, Time.parse(expires_at_str))
|
81
|
+
end
|
82
|
+
|
83
|
+
token
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# OAuth client metadata for registration and authorization
|
88
|
+
class ClientMetadata
|
89
|
+
attr_reader :redirect_uris, :token_endpoint_auth_method, :grant_types, :response_types, :scope
|
90
|
+
|
91
|
+
# @param redirect_uris [Array<String>] List of valid redirect URIs
|
92
|
+
# @param token_endpoint_auth_method [String] Authentication method for token endpoint
|
93
|
+
# @param grant_types [Array<String>] Supported grant types
|
94
|
+
# @param response_types [Array<String>] Supported response types
|
95
|
+
# @param scope [String, nil] Requested scope
|
96
|
+
def initialize(redirect_uris:, token_endpoint_auth_method: 'none',
|
97
|
+
grant_types: %w[authorization_code refresh_token],
|
98
|
+
response_types: ['code'], scope: nil)
|
99
|
+
@redirect_uris = redirect_uris
|
100
|
+
@token_endpoint_auth_method = token_endpoint_auth_method
|
101
|
+
@grant_types = grant_types
|
102
|
+
@response_types = response_types
|
103
|
+
@scope = scope
|
104
|
+
end
|
105
|
+
|
106
|
+
# Convert to hash for HTTP requests
|
107
|
+
# @return [Hash] Hash representation
|
108
|
+
def to_h
|
109
|
+
{
|
110
|
+
redirect_uris: @redirect_uris,
|
111
|
+
token_endpoint_auth_method: @token_endpoint_auth_method,
|
112
|
+
grant_types: @grant_types,
|
113
|
+
response_types: @response_types,
|
114
|
+
scope: @scope
|
115
|
+
}.compact
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Registered OAuth client information
|
120
|
+
class ClientInfo
|
121
|
+
attr_reader :client_id, :client_secret, :client_id_issued_at, :client_secret_expires_at, :metadata
|
122
|
+
|
123
|
+
# @param client_id [String] OAuth client ID
|
124
|
+
# @param client_secret [String, nil] OAuth client secret (for confidential clients)
|
125
|
+
# @param client_id_issued_at [Integer, nil] Unix timestamp when client ID was issued
|
126
|
+
# @param client_secret_expires_at [Integer, nil] Unix timestamp when client secret expires
|
127
|
+
# @param metadata [ClientMetadata] Client metadata
|
128
|
+
def initialize(client_id:, metadata:, client_secret: nil, client_id_issued_at: nil,
|
129
|
+
client_secret_expires_at: nil)
|
130
|
+
@client_id = client_id
|
131
|
+
@client_secret = client_secret
|
132
|
+
@client_id_issued_at = client_id_issued_at
|
133
|
+
@client_secret_expires_at = client_secret_expires_at
|
134
|
+
@metadata = metadata
|
135
|
+
end
|
136
|
+
|
137
|
+
# Check if client secret is expired
|
138
|
+
# @return [Boolean] true if client secret is expired
|
139
|
+
def client_secret_expired?
|
140
|
+
return false unless @client_secret_expires_at
|
141
|
+
|
142
|
+
Time.now.to_i >= @client_secret_expires_at
|
143
|
+
end
|
144
|
+
|
145
|
+
# Convert to hash for serialization
|
146
|
+
# @return [Hash] Hash representation
|
147
|
+
def to_h
|
148
|
+
{
|
149
|
+
client_id: @client_id,
|
150
|
+
client_secret: @client_secret,
|
151
|
+
client_id_issued_at: @client_id_issued_at,
|
152
|
+
client_secret_expires_at: @client_secret_expires_at,
|
153
|
+
metadata: @metadata.to_h
|
154
|
+
}.compact
|
155
|
+
end
|
156
|
+
|
157
|
+
# Create client info from hash
|
158
|
+
# @param data [Hash] Client info data
|
159
|
+
# @return [ClientInfo] New client info instance
|
160
|
+
def self.from_h(data)
|
161
|
+
metadata_data = data[:metadata] || data['metadata'] || {}
|
162
|
+
metadata = build_metadata_from_hash(metadata_data)
|
163
|
+
|
164
|
+
new(
|
165
|
+
client_id: data[:client_id] || data['client_id'],
|
166
|
+
client_secret: data[:client_secret] || data['client_secret'],
|
167
|
+
client_id_issued_at: data[:client_id_issued_at] || data['client_id_issued_at'],
|
168
|
+
client_secret_expires_at: data[:client_secret_expires_at] || data['client_secret_expires_at'],
|
169
|
+
metadata: metadata
|
170
|
+
)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Build ClientMetadata from hash data
|
174
|
+
# @param metadata_data [Hash] Metadata hash
|
175
|
+
# @return [ClientMetadata] Client metadata instance
|
176
|
+
def self.build_metadata_from_hash(metadata_data)
|
177
|
+
ClientMetadata.new(
|
178
|
+
redirect_uris: metadata_data[:redirect_uris] || metadata_data['redirect_uris'] || [],
|
179
|
+
token_endpoint_auth_method: extract_auth_method(metadata_data),
|
180
|
+
grant_types: metadata_data[:grant_types] || metadata_data['grant_types'] ||
|
181
|
+
%w[authorization_code refresh_token],
|
182
|
+
response_types: metadata_data[:response_types] || metadata_data['response_types'] || ['code'],
|
183
|
+
scope: metadata_data[:scope] || metadata_data['scope']
|
184
|
+
)
|
185
|
+
end
|
186
|
+
|
187
|
+
# Extract token endpoint auth method from metadata
|
188
|
+
# @param metadata_data [Hash] Metadata hash
|
189
|
+
# @return [String] Authentication method
|
190
|
+
def self.extract_auth_method(metadata_data)
|
191
|
+
metadata_data[:token_endpoint_auth_method] ||
|
192
|
+
metadata_data['token_endpoint_auth_method'] || 'none'
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
# OAuth authorization server metadata
|
197
|
+
class ServerMetadata
|
198
|
+
attr_reader :issuer, :authorization_endpoint, :token_endpoint, :registration_endpoint,
|
199
|
+
:scopes_supported, :response_types_supported, :grant_types_supported
|
200
|
+
|
201
|
+
# @param issuer [String] Issuer identifier URL
|
202
|
+
# @param authorization_endpoint [String] Authorization endpoint URL
|
203
|
+
# @param token_endpoint [String] Token endpoint URL
|
204
|
+
# @param registration_endpoint [String, nil] Client registration endpoint URL
|
205
|
+
# @param scopes_supported [Array<String>, nil] Supported OAuth scopes
|
206
|
+
# @param response_types_supported [Array<String>, nil] Supported response types
|
207
|
+
# @param grant_types_supported [Array<String>, nil] Supported grant types
|
208
|
+
def initialize(issuer:, authorization_endpoint:, token_endpoint:, registration_endpoint: nil,
|
209
|
+
scopes_supported: nil, response_types_supported: nil, grant_types_supported: nil)
|
210
|
+
@issuer = issuer
|
211
|
+
@authorization_endpoint = authorization_endpoint
|
212
|
+
@token_endpoint = token_endpoint
|
213
|
+
@registration_endpoint = registration_endpoint
|
214
|
+
@scopes_supported = scopes_supported
|
215
|
+
@response_types_supported = response_types_supported
|
216
|
+
@grant_types_supported = grant_types_supported
|
217
|
+
end
|
218
|
+
|
219
|
+
# Check if dynamic client registration is supported
|
220
|
+
# @return [Boolean] true if registration endpoint is available
|
221
|
+
def supports_registration?
|
222
|
+
!@registration_endpoint.nil?
|
223
|
+
end
|
224
|
+
|
225
|
+
# Convert to hash
|
226
|
+
# @return [Hash] Hash representation
|
227
|
+
def to_h
|
228
|
+
{
|
229
|
+
issuer: @issuer,
|
230
|
+
authorization_endpoint: @authorization_endpoint,
|
231
|
+
token_endpoint: @token_endpoint,
|
232
|
+
registration_endpoint: @registration_endpoint,
|
233
|
+
scopes_supported: @scopes_supported,
|
234
|
+
response_types_supported: @response_types_supported,
|
235
|
+
grant_types_supported: @grant_types_supported
|
236
|
+
}.compact
|
237
|
+
end
|
238
|
+
|
239
|
+
# Create server metadata from hash
|
240
|
+
# @param data [Hash] Server metadata
|
241
|
+
# @return [ServerMetadata] New server metadata instance
|
242
|
+
def self.from_h(data)
|
243
|
+
new(
|
244
|
+
issuer: data[:issuer] || data['issuer'],
|
245
|
+
authorization_endpoint: data[:authorization_endpoint] || data['authorization_endpoint'],
|
246
|
+
token_endpoint: data[:token_endpoint] || data['token_endpoint'],
|
247
|
+
registration_endpoint: data[:registration_endpoint] || data['registration_endpoint'],
|
248
|
+
scopes_supported: data[:scopes_supported] || data['scopes_supported'],
|
249
|
+
response_types_supported: data[:response_types_supported] || data['response_types_supported'],
|
250
|
+
grant_types_supported: data[:grant_types_supported] || data['grant_types_supported']
|
251
|
+
)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
# Protected resource metadata for authorization server discovery
|
256
|
+
class ResourceMetadata
|
257
|
+
attr_reader :resource, :authorization_servers
|
258
|
+
|
259
|
+
# @param resource [String] Resource server identifier
|
260
|
+
# @param authorization_servers [Array<String>] List of authorization server URLs
|
261
|
+
def initialize(resource:, authorization_servers:)
|
262
|
+
@resource = resource
|
263
|
+
@authorization_servers = authorization_servers
|
264
|
+
end
|
265
|
+
|
266
|
+
# Convert to hash
|
267
|
+
# @return [Hash] Hash representation
|
268
|
+
def to_h
|
269
|
+
{
|
270
|
+
resource: @resource,
|
271
|
+
authorization_servers: @authorization_servers
|
272
|
+
}
|
273
|
+
end
|
274
|
+
|
275
|
+
# Create resource metadata from hash
|
276
|
+
# @param data [Hash] Resource metadata
|
277
|
+
# @return [ResourceMetadata] New resource metadata instance
|
278
|
+
def self.from_h(data)
|
279
|
+
new(
|
280
|
+
resource: data[:resource] || data['resource'],
|
281
|
+
authorization_servers: data[:authorization_servers] || data['authorization_servers']
|
282
|
+
)
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# PKCE (Proof Key for Code Exchange) helper
|
287
|
+
class PKCE
|
288
|
+
attr_reader :code_verifier, :code_challenge, :code_challenge_method
|
289
|
+
|
290
|
+
# Generate PKCE parameters
|
291
|
+
def initialize
|
292
|
+
@code_verifier = generate_code_verifier
|
293
|
+
@code_challenge = generate_code_challenge(@code_verifier)
|
294
|
+
@code_challenge_method = 'S256'
|
295
|
+
end
|
296
|
+
|
297
|
+
private
|
298
|
+
|
299
|
+
# Generate a cryptographically random code verifier
|
300
|
+
# @return [String] Base64url-encoded code verifier
|
301
|
+
def generate_code_verifier
|
302
|
+
# Generate 32 random bytes (256 bits) and base64url encode
|
303
|
+
Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
|
304
|
+
end
|
305
|
+
|
306
|
+
# Generate code challenge from verifier using SHA256
|
307
|
+
# @param verifier [String] Code verifier
|
308
|
+
# @return [String] Base64url-encoded SHA256 hash
|
309
|
+
def generate_code_challenge(verifier)
|
310
|
+
digest = Digest::SHA256.digest(verifier)
|
311
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
data/lib/mcp_client/client.rb
CHANGED
@@ -70,7 +70,7 @@ module MCPClient
|
|
70
70
|
if tools.empty? && !servers.empty?
|
71
71
|
raise connection_errors.first if connection_errors.any?
|
72
72
|
|
73
|
-
|
73
|
+
@logger.warn('No tools found from any server.')
|
74
74
|
end
|
75
75
|
|
76
76
|
tools
|
@@ -87,6 +87,10 @@ module MCPClient
|
|
87
87
|
parse_stdio_config(clean, config, server_name)
|
88
88
|
when 'sse'
|
89
89
|
return nil unless parse_sse_config(clean, config, server_name)
|
90
|
+
when 'streamable_http'
|
91
|
+
return nil unless parse_streamable_http_config(clean, config, server_name)
|
92
|
+
when 'http'
|
93
|
+
return nil unless parse_http_config(clean, config, server_name)
|
90
94
|
else
|
91
95
|
@logger.warn("Unrecognized type '#{type}' for server '#{server_name}'; skipping.")
|
92
96
|
return nil
|
@@ -106,7 +110,9 @@ module MCPClient
|
|
106
110
|
inferred_type = if config.key?('command') || config.key?('args') || config.key?('env')
|
107
111
|
'stdio'
|
108
112
|
elsif config.key?('url')
|
109
|
-
|
113
|
+
# Default to streamable_http unless URL contains "sse"
|
114
|
+
url = config['url'].to_s.downcase
|
115
|
+
url.include?('sse') ? 'sse' : 'streamable_http'
|
110
116
|
end
|
111
117
|
|
112
118
|
if inferred_type
|
@@ -181,6 +187,72 @@ module MCPClient
|
|
181
187
|
true
|
182
188
|
end
|
183
189
|
|
190
|
+
# Parse Streamable HTTP-specific configuration
|
191
|
+
# @param clean [Hash] clean configuration hash to update
|
192
|
+
# @param config [Hash] raw configuration from JSON
|
193
|
+
# @param server_name [String] name of the server for error reporting
|
194
|
+
# @return [Boolean] true if parsing succeeded, false if required elements are missing
|
195
|
+
def parse_streamable_http_config(clean, config, server_name)
|
196
|
+
# URL is required
|
197
|
+
source = config['url']
|
198
|
+
unless source
|
199
|
+
@logger.warn("Streamable HTTP server '#{server_name}' is missing required 'url' property; skipping.")
|
200
|
+
return false
|
201
|
+
end
|
202
|
+
|
203
|
+
unless source.is_a?(String)
|
204
|
+
@logger.warn("'url' for server '#{server_name}' is not a string; converting to string.")
|
205
|
+
source = source.to_s
|
206
|
+
end
|
207
|
+
|
208
|
+
# Headers are optional
|
209
|
+
headers = config['headers']
|
210
|
+
headers = headers.is_a?(Hash) ? headers.transform_keys(&:to_s) : {}
|
211
|
+
|
212
|
+
# Endpoint is optional (defaults to '/rpc' in the transport)
|
213
|
+
endpoint = config['endpoint']
|
214
|
+
endpoint = endpoint.to_s if endpoint && !endpoint.is_a?(String)
|
215
|
+
|
216
|
+
# Update clean config
|
217
|
+
clean[:url] = source
|
218
|
+
clean[:headers] = headers
|
219
|
+
clean[:endpoint] = endpoint if endpoint
|
220
|
+
true
|
221
|
+
end
|
222
|
+
|
223
|
+
# Parse HTTP-specific configuration
|
224
|
+
# @param clean [Hash] clean configuration hash to update
|
225
|
+
# @param config [Hash] raw configuration from JSON
|
226
|
+
# @param server_name [String] name of the server for error reporting
|
227
|
+
# @return [Boolean] true if parsing succeeded, false if required elements are missing
|
228
|
+
def parse_http_config(clean, config, server_name)
|
229
|
+
# URL is required
|
230
|
+
source = config['url']
|
231
|
+
unless source
|
232
|
+
@logger.warn("HTTP server '#{server_name}' is missing required 'url' property; skipping.")
|
233
|
+
return false
|
234
|
+
end
|
235
|
+
|
236
|
+
unless source.is_a?(String)
|
237
|
+
@logger.warn("'url' for server '#{server_name}' is not a string; converting to string.")
|
238
|
+
source = source.to_s
|
239
|
+
end
|
240
|
+
|
241
|
+
# Headers are optional
|
242
|
+
headers = config['headers']
|
243
|
+
headers = headers.is_a?(Hash) ? headers.transform_keys(&:to_s) : {}
|
244
|
+
|
245
|
+
# Endpoint is optional (defaults to '/rpc' in the transport)
|
246
|
+
endpoint = config['endpoint']
|
247
|
+
endpoint = endpoint.to_s if endpoint && !endpoint.is_a?(String)
|
248
|
+
|
249
|
+
# Update clean config
|
250
|
+
clean[:url] = source
|
251
|
+
clean[:headers] = headers
|
252
|
+
clean[:endpoint] = endpoint if endpoint
|
253
|
+
true
|
254
|
+
end
|
255
|
+
|
184
256
|
# Filter out reserved keys from configuration objects
|
185
257
|
# @param data [Hash] configuration data
|
186
258
|
# @return [Hash] filtered configuration data
|