mcp 1.5.1 → 1.6.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 +2 -2
- data/lib/mcp/client/oauth/bounded_body.rb +1 -1
- data/lib/mcp/client/oauth/client_credentials_provider.rb +18 -6
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +20 -1
- data/lib/mcp/client/oauth/discovery.rb +75 -50
- data/lib/mcp/client/oauth/flow.rb +382 -101
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +1 -1
- data/lib/mcp/client/oauth/provider.rb +14 -1
- data/lib/mcp/client/oauth/storage_backed_provider.rb +42 -5
- data/lib/mcp/icon.rb +35 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +107 -53
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08c2de444c55244db435a10ce7b9f9026d24158d47cadd6f89b917f42e09b534'
|
|
4
|
+
data.tar.gz: 6126a02c9adf92df07f22b323a0337f2abae9eb23c7bf014f1b7ef1ccbbf3a72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7318acb46d46e9c958115f95171f21b20c186ab32608770d443440e0202d29319b9384b11099ed69f136c3ba9146be39998e1a9a47a839fe506e07cddca0ad89
|
|
7
|
+
data.tar.gz: 3008f7f6061e0a2cd514d377ade38a03be1ed88d863fb268e50c9820ece43f585b7fcc801bc6c0ffcf78fab3a80af3397c20e497722a6821c55d6128d391d4fc
|
data/README.md
CHANGED
|
@@ -101,7 +101,7 @@ stdio_transport = MCP::Client::Stdio.new(
|
|
|
101
101
|
)
|
|
102
102
|
client = MCP::Client.new(transport: stdio_transport)
|
|
103
103
|
|
|
104
|
-
#
|
|
104
|
+
# Negotiate the protocol lifecycle before sending any requests.
|
|
105
105
|
client.connect
|
|
106
106
|
|
|
107
107
|
# List available tools.
|
|
@@ -125,7 +125,7 @@ see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transpor
|
|
|
125
125
|
|
|
126
126
|
## Examples
|
|
127
127
|
|
|
128
|
-
Runnable examples are available in [`examples
|
|
128
|
+
Runnable examples are available in [`examples`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples),
|
|
129
129
|
including a complete Rails application in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails).
|
|
130
130
|
|
|
131
131
|
## Documentation
|
|
@@ -26,7 +26,7 @@ module MCP
|
|
|
26
26
|
# Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
|
|
27
27
|
# `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
|
|
28
28
|
# that expands past the cap is refused partway through the expansion rather than after it. That holds only while
|
|
29
|
-
# the connection leaves `Accept-Encoding` to the adapter; see `Flow
|
|
29
|
+
# the connection leaves `Accept-Encoding` to the adapter; see `Flow.build_http_client`.
|
|
30
30
|
def on_data
|
|
31
31
|
proc do |chunk, _received_bytes, _env|
|
|
32
32
|
@buffer << chunk
|
|
@@ -37,11 +37,17 @@ module MCP
|
|
|
37
37
|
# also take the algorithm as an explicit option).
|
|
38
38
|
# - `scope` - String of space-separated scopes to request when the server's
|
|
39
39
|
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
|
|
40
|
-
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
40
|
+
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
|
|
41
|
+
# and `save_client_information(info)`. Defaults to an `InMemoryStorage`.
|
|
42
|
+
# The `client_id` / `client_secret` are written into it so the token exchange reads
|
|
43
|
+
# them through the same path as a pre-registered authorization-code client.
|
|
44
|
+
# - `token_request_params` - Hash of String keys and values added to every
|
|
45
|
+
# token request this provider makes, for parameters the authorization
|
|
46
|
+
# server requires beyond the grant itself (Auth0's `audience`, for example).
|
|
47
|
+
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
|
|
48
|
+
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
|
49
|
+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
|
|
50
|
+
# its own requests; see `StorageBackedProvider#http_client_customizer`.
|
|
45
51
|
class ClientCredentialsProvider
|
|
46
52
|
include StorageBackedProvider
|
|
47
53
|
|
|
@@ -61,7 +67,9 @@ module MCP
|
|
|
61
67
|
signing_algorithm: nil,
|
|
62
68
|
scope: nil,
|
|
63
69
|
storage: nil,
|
|
64
|
-
authorization_request_validator: nil
|
|
70
|
+
authorization_request_validator: nil,
|
|
71
|
+
token_request_params: nil,
|
|
72
|
+
http_client_customizer: nil
|
|
65
73
|
)
|
|
66
74
|
if blank?(client_id)
|
|
67
75
|
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
|
@@ -99,12 +107,16 @@ module MCP
|
|
|
99
107
|
client_information["client_secret"] = client_secret
|
|
100
108
|
end
|
|
101
109
|
|
|
110
|
+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
111
|
+
|
|
102
112
|
@client_id = client_id
|
|
103
113
|
@private_key = private_key
|
|
104
114
|
@signing_algorithm = signing_algorithm
|
|
105
115
|
@scope = scope
|
|
106
116
|
@storage = storage || InMemoryStorage.new
|
|
107
117
|
@authorization_request_validator = authorization_request_validator
|
|
118
|
+
@token_request_params = frozen_token_request_params(token_request_params)
|
|
119
|
+
@http_client_customizer = http_client_customizer
|
|
108
120
|
@storage.save_client_information(client_information)
|
|
109
121
|
end
|
|
110
122
|
|
|
@@ -25,6 +25,12 @@ module MCP
|
|
|
25
25
|
# the Protected Resource Metadata do not specify one.
|
|
26
26
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
|
|
27
27
|
# Defaults to an `InMemoryStorage`.
|
|
28
|
+
# - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters
|
|
29
|
+
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
|
|
30
|
+
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
|
|
31
|
+
# See `StorageBackedProvider#token_request_params`.
|
|
32
|
+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for its own requests;
|
|
33
|
+
# see `StorageBackedProvider#http_client_customizer`.
|
|
28
34
|
#
|
|
29
35
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
|
30
36
|
class CrossAppAccessProvider
|
|
@@ -35,7 +41,16 @@ module MCP
|
|
|
35
41
|
|
|
36
42
|
attr_reader :scope, :storage
|
|
37
43
|
|
|
38
|
-
def initialize(
|
|
44
|
+
def initialize(
|
|
45
|
+
client_id:,
|
|
46
|
+
client_secret:,
|
|
47
|
+
assertion_provider:,
|
|
48
|
+
scope: nil,
|
|
49
|
+
storage: nil,
|
|
50
|
+
authorization_request_validator: nil,
|
|
51
|
+
token_request_params: nil,
|
|
52
|
+
http_client_customizer: nil
|
|
53
|
+
)
|
|
39
54
|
if blank?(client_id)
|
|
40
55
|
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
|
41
56
|
end
|
|
@@ -48,10 +63,14 @@ module MCP
|
|
|
48
63
|
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
|
|
49
64
|
end
|
|
50
65
|
|
|
66
|
+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
67
|
+
|
|
51
68
|
@assertion_provider = assertion_provider
|
|
52
69
|
@scope = scope
|
|
53
70
|
@storage = storage || InMemoryStorage.new
|
|
54
71
|
@authorization_request_validator = authorization_request_validator
|
|
72
|
+
@token_request_params = frozen_token_request_params(token_request_params)
|
|
73
|
+
@http_client_customizer = http_client_customizer
|
|
55
74
|
@storage.save_client_information(
|
|
56
75
|
"client_id" => client_id,
|
|
57
76
|
"client_secret" => client_secret,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ipaddr"
|
|
4
|
+
require "strscan"
|
|
4
5
|
require "uri"
|
|
5
6
|
|
|
6
7
|
module MCP
|
|
@@ -43,6 +44,12 @@ module MCP
|
|
|
43
44
|
# or a bare token, per RFC 7235.
|
|
44
45
|
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
|
|
45
46
|
|
|
47
|
+
# The whitespace and optional comma between two `key=value` pairs, or before the first one.
|
|
48
|
+
WWW_AUTH_PARAM_SEPARATOR_PATTERN = /\s*,?\s*/.freeze
|
|
49
|
+
|
|
50
|
+
# The `Bearer` challenge: at the start of the header or after a comma.
|
|
51
|
+
WWW_AUTH_BEARER_PATTERN = /(?:\A|,)\s*Bearer(?:\s+|\z)/i.freeze
|
|
52
|
+
|
|
46
53
|
class << self
|
|
47
54
|
# Parses a `WWW-Authenticate` header and returns the parameters of
|
|
48
55
|
# the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`,
|
|
@@ -55,25 +62,21 @@ module MCP
|
|
|
55
62
|
def parse_www_authenticate(header)
|
|
56
63
|
return {} unless header
|
|
57
64
|
|
|
58
|
-
# Locate the Bearer challenge: at the start of the header or after a comma.
|
|
59
|
-
bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
|
|
60
|
-
return {} unless bearer
|
|
61
|
-
|
|
62
65
|
# Walk key=value pairs starting where Bearer's parameters begin.
|
|
63
|
-
# The loop stops at the first token that is not a key=value pair,
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
prefix = prefix.sub(/\A\s*,?\s*/, "")
|
|
70
|
-
break if prefix.empty?
|
|
66
|
+
# The loop stops at the first token that is not a key=value pair, which marks the next challenge (e.g. `, DPoP algs="..."`).
|
|
67
|
+
# The scanner keeps the walk linear in the header's length: slicing off the consumed prefix instead copies the remainder
|
|
68
|
+
# for every pair, and the server chooses how many pairs it sends. The header is also the server's to fill: a byte sequence
|
|
69
|
+
# that is not valid in the string's encoding would make the patterns raise `ArgumentError`, so such bytes are replaced first.
|
|
70
|
+
scanner = StringScanner.new(header.scrub)
|
|
71
|
+
return {} unless scanner.skip_until(WWW_AUTH_BEARER_PATTERN)
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
params = {}
|
|
74
|
+
until scanner.eos?
|
|
75
|
+
scanner.skip(WWW_AUTH_PARAM_SEPARATOR_PATTERN)
|
|
76
|
+
break if scanner.eos?
|
|
77
|
+
break unless scanner.scan(WWW_AUTH_PARAM_PATTERN)
|
|
74
78
|
|
|
75
|
-
params[
|
|
76
|
-
cursor = header.length - prefix.length + match.end(0)
|
|
79
|
+
params[scanner[1].downcase] = scanner[2] ? unescape_quoted_pair(scanner[2]) : scanner[3]
|
|
77
80
|
end
|
|
78
81
|
params
|
|
79
82
|
end
|
|
@@ -336,6 +339,29 @@ module MCP
|
|
|
336
339
|
uri.to_s
|
|
337
340
|
end
|
|
338
341
|
|
|
342
|
+
# Drops userinfo, query and fragment from `url` and leaves the host and path spelled as given, for reporting
|
|
343
|
+
# a URL next to the request that failed, where it must still match the server's access log; `URI#to_s`
|
|
344
|
+
# still lowercases the scheme and drops an explicit default port. Unlike `canonicalize_origin_and_path`
|
|
345
|
+
# it neither normalizes nor resolves dot segments, so its cost stays linear in the URL length, which matters
|
|
346
|
+
# for a URL the server chose. A URL that does not parse is not echoed, since the raw value could carry
|
|
347
|
+
# the very credentials being dropped.
|
|
348
|
+
def redact_url(url)
|
|
349
|
+
uri = URI.parse(url.to_s)
|
|
350
|
+
|
|
351
|
+
uri.fragment = nil
|
|
352
|
+
uri.query = nil
|
|
353
|
+
# `URI::Generic#userinfo=` is a no-op on Ruby 2.7 (the project's minimum supported version),
|
|
354
|
+
# so clear the components individually.
|
|
355
|
+
if uri.respond_to?(:user) && (uri.user || uri.password)
|
|
356
|
+
uri.user = nil
|
|
357
|
+
uri.password = nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
uri.to_s
|
|
361
|
+
rescue URI::Error
|
|
362
|
+
"[unparseable URL]"
|
|
363
|
+
end
|
|
364
|
+
|
|
339
365
|
# Returns true when `prm` (a PRM `resource` URL) covers `server` (the MCP endpoint URL):
|
|
340
366
|
# same scheme/host/port, with PRM's path being a prefix of the server's path. When PRM
|
|
341
367
|
# also advertises a query string, the server's query MUST be identical to it (otherwise
|
|
@@ -553,47 +579,46 @@ module MCP
|
|
|
553
579
|
end.join("&")
|
|
554
580
|
end
|
|
555
581
|
|
|
556
|
-
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments
|
|
557
|
-
#
|
|
558
|
-
#
|
|
559
|
-
#
|
|
582
|
+
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments` over the path's segments, so that `/api/../mcp` collapses to
|
|
583
|
+
# `/mcp` and `/foo/./bar` collapses to `/foo/bar`. Each segment is visited once: the RFC's buffer rewriting,
|
|
584
|
+
# applied literally, copies the remaining input for every dot segment, and the path is the server's to choose.
|
|
585
|
+
#
|
|
586
|
+
# The output matches the RFC's algorithm for a relative path as well, including its quirk that a `..` popping
|
|
587
|
+
# the first segment leaves the result absolute (`a/../b` becomes `/b`), although `URI#path` never hands over a relative path.
|
|
588
|
+
# The rule letters below are the RFC's own: steps A through E of its loop.
|
|
560
589
|
# https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
|
|
561
590
|
def remove_dot_segments(path)
|
|
562
591
|
return path if path.nil? || path.empty?
|
|
563
592
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
593
|
+
absolute = path.start_with?("/")
|
|
594
|
+
segments = path.split("/", -1)
|
|
595
|
+
segments.shift if absolute
|
|
596
|
+
|
|
597
|
+
output = []
|
|
598
|
+
|
|
599
|
+
# True until a segment other than `.` or `..` is kept: a relative path's leading `./` and `../` are dropped together with
|
|
600
|
+
# the slash after them (Rule A), so the segment that follows is still the slash-less first one.
|
|
601
|
+
leading = !absolute
|
|
602
|
+
segments.each_with_index do |segment, index|
|
|
603
|
+
last = index == segments.length - 1
|
|
604
|
+
|
|
605
|
+
# Rule A, and Rule D for a path that is nothing but `.` or `..`.
|
|
606
|
+
next if leading && (segment == "." || segment == "..")
|
|
607
|
+
|
|
608
|
+
if segment == "."
|
|
609
|
+
# Rule B: `/./` disappears; a final `/.` leaves its slash behind.
|
|
610
|
+
output << "/" if last
|
|
611
|
+
elsif segment == ".."
|
|
612
|
+
# Rule C: `/../` removes the previous segment; a final `/..` leaves its slash behind.
|
|
613
|
+
output.pop
|
|
614
|
+
output << "/" if last
|
|
583
615
|
else
|
|
584
|
-
segment
|
|
585
|
-
output << segment
|
|
586
|
-
input = input[segment.length..]
|
|
616
|
+
# Rule E: the first segment of a relative path carries no slash.
|
|
617
|
+
output << (leading ? segment : "/#{segment}")
|
|
587
618
|
end
|
|
619
|
+
leading = false
|
|
588
620
|
end
|
|
589
|
-
output
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
def remove_last_segment(output)
|
|
593
|
-
idx = output.rindex("/")
|
|
594
|
-
return +"" if idx.nil?
|
|
595
|
-
|
|
596
|
-
output[0...idx]
|
|
621
|
+
output.join
|
|
597
622
|
end
|
|
598
623
|
end
|
|
599
624
|
end
|