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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a1945319168ba9681dadea47a487f973d718e7f3504418a2b4b6e60bc0a1ba4
4
- data.tar.gz: 6523b4e444a94354adc9421c54c1091acbbb82e6d2789625fca5b47c8df60457
3
+ metadata.gz: '08c2de444c55244db435a10ce7b9f9026d24158d47cadd6f89b917f42e09b534'
4
+ data.tar.gz: 6126a02c9adf92df07f22b323a0337f2abae9eb23c7bf014f1b7ef1ccbbf3a72
5
5
  SHA512:
6
- metadata.gz: 9f67b440f824785842c513c350dcc6ffbe6753f6854924ae1655efce948dfde31fb29f5d431c763d314cef9863951722049aa45f1b2f3ccae667e6d621440007
7
- data.tar.gz: 8fc66cc8e4f90c59eaeb3033ff0797735a6c0a1d91f0a2663f23b5250840f5a70b6854f24d55096cfb7288c66cda4c8d82b2616573af85677a35702a07c9070c
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
- # Perform the MCP initialization handshake before sending any requests.
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/`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/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#default_http_client`.
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
- # `client_information`, and `save_client_information(info)`. Defaults to
42
- # an `InMemoryStorage`. The `client_id` / `client_secret` are written
43
- # into it so the token exchange reads them through the same path as
44
- # a pre-registered authorization-code client.
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(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
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
- # which marks the next challenge (e.g. `, DPoP algs="..."`).
65
- cursor = bearer.end(0)
66
- params = {}
67
- while cursor < header.length
68
- prefix = header[cursor..]
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
- match = prefix.match(WWW_AUTH_PARAM_PATTERN)
73
- break unless match
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[match[1].downcase] = match[2] ? unescape_quoted_pair(match[2]) : match[3]
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`. Walks the input
557
- # buffer one segment at a time, popping the previous output segment
558
- # whenever a `..` is encountered, so that `/api/../mcp` collapses to
559
- # `/mcp` and `/foo/./bar` collapses to `/foo/bar`.
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
- input = path.dup
565
- output = +""
566
- until input.empty?
567
- if input.start_with?("../")
568
- input = input[3..]
569
- elsif input.start_with?("./")
570
- input = input[2..]
571
- elsif input.start_with?("/./")
572
- input = "/#{input[3..]}"
573
- elsif input == "/."
574
- input = "/"
575
- elsif input.start_with?("/../")
576
- input = "/#{input[4..]}"
577
- output = remove_last_segment(output)
578
- elsif input == "/.."
579
- input = "/"
580
- output = remove_last_segment(output)
581
- elsif input == "." || input == ".."
582
- input = ""
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 = input.match(%r{\A/?[^/]*})[0]
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