mcp 1.9.0 → 1.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95dcc461ef4ba74c3128e82441b81587135488d8c9e4d17f5ea91ba45867f080
4
- data.tar.gz: 2d0afc689585aad6ffaf37c4ca5528497e30bf81e733514a05faf724cfb3e2f4
3
+ metadata.gz: 87d807c4e295950341aa7123459ac862a601c3bb840a881ac2a8c48ae2b8b2f0
4
+ data.tar.gz: b09a469593f943812b51f7d1f3db3eb1fb6d1c63c4d321c1cf6d6936158f842a
5
5
  SHA512:
6
- metadata.gz: 68e755e7b252d6680ca82ce073b3e541e0b4202ac18ce5b73a5175c326aa655e7cbdf4d0685cdb06d4007cc35696b0e7ad60c7a50820567b63b84e4cce5d7c7c
7
- data.tar.gz: 68a614ac492dfcfa34a1429efc57e031089dfa65b10db2f6b18b9d0bbffe6ebbcb03c13eacbac9cb3dea178fda603b279825b2cb67ccb81081523db809e27e98
6
+ metadata.gz: 124b4d117e0b4c0079a4af053d2de713bce66847f1c0d95404cd99d54a929ef0f250669b7207c8d6658ced4a70e63f1708376dcbe379a848f65cf8e4c5880cc0
7
+ data.tar.gz: 4e77573617cbafc37ac1b1fb59aee3fe6f96825c474fc912ca24f766c6690fd3d5df4b2e0c24831210b4d8c7700340f1daf4043ceaa3977b8dfaf589b5e75a01
data/README.md CHANGED
@@ -122,6 +122,9 @@ response = client.call_tool(
122
122
  stdio_transport.close
123
123
  ```
124
124
 
125
+ Stdio servers inherit only a small base environment by default, including `GEM_HOME` and `GEM_PATH`.
126
+ Pass extra variables through `env:`, or set `inherit_env: true` to restore full inheritance.
127
+
125
128
  The same client can connect to Streamable HTTP servers with `MCP::Client::HTTP`;
126
129
  see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transports/).
127
130
 
@@ -227,7 +227,7 @@ module JsonRpcHandler
227
227
  end
228
228
 
229
229
  def valid_method_name?(method)
230
- method.is_a?(String) && !method.start_with?("rpc.")
230
+ method.is_a?(String) && method.valid_encoding? && !method.start_with?("rpc.")
231
231
  end
232
232
 
233
233
  def valid_params?(params)
@@ -6,7 +6,7 @@ require_relative "../configuration"
6
6
  require_relative "../methods"
7
7
  require_relative "../protocol_deprecations"
8
8
  require_relative "../version"
9
- require_relative "mcp_param_headers"
9
+ require_relative "../mcp_param_headers"
10
10
  require_relative "modern_envelope"
11
11
 
12
12
  module MCP
@@ -545,8 +545,12 @@ module MCP
545
545
  def send_notification(notification:)
546
546
  method = notification[:method] || notification["method"]
547
547
 
548
- redacting_authorization_header { client.post("", notification, session_headers) }
548
+ redacting_authorization_header do
549
+ client.post("", notification, session_headers) { |req| req.options.on_data = bounded_on_data }
550
+ end
549
551
  nil
552
+ rescue MessageTooLargeError => e
553
+ raise RequestHandlerError.new(e.message, { method: method }, error_type: :internal_error)
550
554
  rescue Faraday::Error => e
551
555
  raise RequestHandlerError.new(
552
556
  "Failed to send #{method} notification",
@@ -573,7 +577,11 @@ module MCP
573
577
  end
574
578
 
575
579
  begin
576
- redacting_authorization_header { client.delete("", nil, session_headers) }
580
+ redacting_authorization_header do
581
+ client.delete("", nil, session_headers) { |req| req.options.on_data = bounded_on_data }
582
+ end
583
+ rescue MessageTooLargeError => e
584
+ raise RequestHandlerError.new(e.message, {}, error_type: :internal_error)
577
585
  rescue Faraday::ClientError => e
578
586
  raise unless [404, 405].include?(e.response&.dig(:status))
579
587
  ensure
@@ -1196,12 +1204,13 @@ module MCP
1196
1204
  error: { code: e.code, message: e.message },
1197
1205
  }
1198
1206
  rescue StandardError => e
1207
+ warn("MCP::Client::HTTP: error handling #{message["method"]} request: #{e.class}: #{e.message.dump}")
1199
1208
  {
1200
1209
  jsonrpc: JsonRpcHandler::Version::V2_0,
1201
1210
  id: message["id"],
1202
1211
  error: {
1203
1212
  code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
1204
- message: "Internal error handling #{message["method"]} request: #{e.message}",
1213
+ message: "Internal error",
1205
1214
  },
1206
1215
  }
1207
1216
  end
@@ -1233,7 +1242,22 @@ module MCP
1233
1242
  end
1234
1243
 
1235
1244
  def send_client_response(response)
1236
- redacting_authorization_header { client.post("", response, session_headers) }
1245
+ redacting_authorization_header do
1246
+ client.post("", response, session_headers) { |req| req.options.on_data = bounded_on_data }
1247
+ end
1248
+ end
1249
+
1250
+ # Counts discarded response bytes without parsing the body, whatever its content type.
1251
+ def bounded_on_data
1252
+ received_bytes = 0
1253
+
1254
+ proc do |chunk, _received_bytes, _env|
1255
+ received_bytes += chunk.bytesize
1256
+
1257
+ if received_bytes > @max_message_bytes
1258
+ raise MessageTooLargeError, "Server response body exceeds #{@max_message_bytes} bytes"
1259
+ end
1260
+ end
1237
1261
  end
1238
1262
 
1239
1263
  def parse_json_buffer(buffer, method, params)
@@ -35,6 +35,9 @@ module MCP
35
35
  # the server's `token_endpoint_auth_signing_alg_values_supported` fails
36
36
  # loudly at construction instead of as a 401 (the TypeScript and Python SDKs
37
37
  # also take the algorithm as an explicit option).
38
+ # - `issuer` - String issuer identifier of the authorization server the credentials belong to.
39
+ # A different authorization server raises `Flow::AuthorizationError`. Omitting this is deprecated
40
+ # and emits a warning; without it, credentials go to whichever authorization server the MCP server names.
38
41
  # - `scope` - String of space-separated scopes to request when the server's
39
42
  # `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
40
43
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
@@ -59,12 +62,14 @@ module MCP
59
62
 
60
63
  attr_reader :scope, :storage
61
64
 
65
+ # @note Omitting `issuer:` is deprecated. Pass the authorization server's issuer URL to limit the credentials to that server.
62
66
  def initialize(
63
67
  client_id:,
64
68
  client_secret: nil,
65
69
  token_endpoint_auth_method: "client_secret_basic",
66
70
  private_key: nil,
67
71
  signing_algorithm: nil,
72
+ issuer: nil,
68
73
  scope: nil,
69
74
  storage: nil,
70
75
  authorization_request_validator: nil,
@@ -82,7 +87,18 @@ module MCP
82
87
  "client_credentials request is not allowed."
83
88
  end
84
89
 
90
+ if issuer.nil?
91
+ warn(
92
+ "Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server " \
93
+ "the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.",
94
+ uplevel: 1,
95
+ )
96
+ elsif !issuer.is_a?(String) || blank?(issuer)
97
+ raise InvalidCredentialsError, "issuer must be a non-empty String: the issuer identifier of the authorization server the credentials belong to."
98
+ end
99
+
85
100
  client_information = { "client_id" => client_id, "token_endpoint_auth_method" => token_endpoint_auth_method }
101
+ client_information["issuer"] = issuer if issuer
86
102
 
87
103
  if token_endpoint_auth_method == "private_key_jwt"
88
104
  validate_private_key_jwt_arguments!(
@@ -21,6 +21,9 @@ module MCP
21
21
  #
22
22
  # Optional keyword arguments:
23
23
  #
24
+ # - `issuer` - String issuer identifier of the MCP authorization server the credentials belong to.
25
+ # A mismatch raises `Flow::AuthorizationError` before `assertion_provider` is called. Omitting this is
26
+ # deprecated and emits a warning; without it, credentials go to whichever authorization server the MCP server names.
24
27
  # - `scope` - String of space-separated scopes to request when the server's `WWW-Authenticate` and
25
28
  # the Protected Resource Metadata do not specify one.
26
29
  # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
@@ -41,10 +44,12 @@ module MCP
41
44
 
42
45
  attr_reader :scope, :storage
43
46
 
47
+ # @note Omitting `issuer:` is deprecated. Pass the authorization server's issuer URL to limit the credentials to that server.
44
48
  def initialize(
45
49
  client_id:,
46
50
  client_secret:,
47
51
  assertion_provider:,
52
+ issuer: nil,
48
53
  scope: nil,
49
54
  storage: nil,
50
55
  authorization_request_validator: nil,
@@ -63,6 +68,16 @@ module MCP
63
68
  raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
64
69
  end
65
70
 
71
+ if issuer.nil?
72
+ warn(
73
+ "Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server " \
74
+ "the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.",
75
+ uplevel: 1,
76
+ )
77
+ elsif !issuer.is_a?(String) || blank?(issuer)
78
+ raise InvalidConfigurationError, "issuer must be a non-empty String: the issuer identifier of the authorization server the credentials belong to."
79
+ end
80
+
66
81
  http_client_customizer = validated_http_client_customizer(http_client_customizer)
67
82
 
68
83
  @assertion_provider = assertion_provider
@@ -71,11 +86,13 @@ module MCP
71
86
  @authorization_request_validator = authorization_request_validator
72
87
  @token_request_params = frozen_token_request_params(token_request_params)
73
88
  @http_client_customizer = http_client_customizer
74
- @storage.save_client_information(
89
+ client_information = {
75
90
  "client_id" => client_id,
76
91
  "client_secret" => client_secret,
77
92
  "token_endpoint_auth_method" => "client_secret_basic",
78
- )
93
+ }
94
+ client_information["issuer"] = issuer if issuer
95
+ @storage.save_client_information(client_information)
79
96
  end
80
97
 
81
98
  # See `Provider#authorization_flow`.
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "json"
5
4
  require "openssl"
6
5
  require "securerandom"
@@ -380,7 +379,10 @@ module MCP
380
379
  def ensure_client_credentials_issuer!(client_info, as_metadata:)
381
380
  stored_issuer = client_info_required_value(client_info, "issuer")
382
381
  return if stored_issuer.nil?
383
- return if stored_issuer == as_metadata["issuer"]
382
+
383
+ current_issuer = as_metadata["issuer"]
384
+ return if stored_issuer == current_issuer
385
+ return if stored_issuer == "#{current_issuer}/" || "#{stored_issuer}/" == current_issuer
384
386
 
385
387
  raise AuthorizationError,
386
388
  "Stored client credentials are bound to a different authorization server " \
@@ -399,6 +401,7 @@ module MCP
399
401
  unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
400
402
  raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
401
403
  end
404
+ ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
402
405
 
403
406
  # Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant:
404
407
  # obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once
@@ -1106,6 +1109,11 @@ module MCP
1106
1109
  end
1107
1110
 
1108
1111
  def ensure_refreshable_client_information!(client_info, as_metadata:)
1112
+ case provider_authorization_flow
1113
+ when :client_credentials, :jwt_bearer
1114
+ return ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
1115
+ end
1116
+
1109
1117
  stored_issuer = client_info_required_value(client_info, "issuer")
1110
1118
  return if stored_issuer.nil?
1111
1119
  return if stored_issuer == as_metadata["issuer"]
@@ -1644,7 +1652,7 @@ module MCP
1644
1652
  def basic_auth_credentials(client_id, client_secret)
1645
1653
  encoded_id = URI.encode_www_form_component(client_id)
1646
1654
  encoded_secret = URI.encode_www_form_component(client_secret)
1647
- Base64.strict_encode64("#{encoded_id}:#{encoded_secret}")
1655
+ ["#{encoded_id}:#{encoded_secret}"].pack("m0")
1648
1656
  end
1649
1657
 
1650
1658
  def http_get(url)
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "json"
5
4
  require "openssl"
6
5
  require "securerandom"
@@ -118,8 +117,10 @@ module MCP
118
117
  end.join.then { |hex| [hex].pack("H*") }
119
118
  end
120
119
 
120
+ # base64url without padding (RFC 7515 Section 2), spelled with `pack` because `base64` is no longer
121
+ # a default gem as of Ruby 3.4 and this gem does not depend on it.
121
122
  def base64url(data)
122
- Base64.urlsafe_encode64(data, padding: false)
123
+ [data].pack("m0").tr("+/", "-_").delete("=")
123
124
  end
124
125
  end
125
126
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
3
  require "digest"
5
4
  require "securerandom"
6
5
 
@@ -27,7 +26,9 @@ module MCP
27
26
  # Generates a PKCE pair (code_verifier and S256 code_challenge).
28
27
  def generate
29
28
  verifier = SecureRandom.urlsafe_base64(64)
30
- challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
29
+ # base64url without padding (RFC 7636 Appendix A), spelled with `pack` because `base64` is no longer
30
+ # a default gem as of Ruby 3.4 and this gem does not depend on it.
31
+ challenge = [Digest::SHA256.digest(verifier)].pack("m0").tr("+/", "-_").delete("=")
31
32
 
32
33
  { code_verifier: verifier, code_challenge: challenge, code_challenge_method: "S256" }
33
34
  end
@@ -34,9 +34,20 @@ module MCP
34
34
  # Matches the C# SDK's `DiscoverProbeTimeout` default.
35
35
  DEFAULT_DISCOVER_PROBE_TIMEOUT = 5
36
36
 
37
+ # Inherited Windows environment, including RubyGems paths.
38
+ WINDOWS_INHERITED_ENV_VARS = %w[
39
+ APPDATA HOMEDRIVE HOMEPATH LOCALAPPDATA PATH PATHEXT PROCESSOR_ARCHITECTURE
40
+ SYSTEMDRIVE SYSTEMROOT TEMP USERNAME USERPROFILE GEM_HOME GEM_PATH
41
+ ].freeze
42
+
43
+ # Inherited Unix environment, including RubyGems paths.
44
+ UNIX_INHERITED_ENV_VARS = %w[HOME LOGNAME PATH SHELL TERM USER GEM_HOME GEM_PATH].freeze
45
+
46
+ DEFAULT_INHERITED_ENV_VARS = Gem.win_platform? ? WINDOWS_INHERITED_ENV_VARS : UNIX_INHERITED_ENV_VARS
47
+
37
48
  attr_reader :command, :args, :env, :server_info
38
49
 
39
- def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
50
+ def initialize(command:, args: [], env: nil, inherit_env: false, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
40
51
  # Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
41
52
  # limit read without an upper bound, which would silently disable the
42
53
  # protection this option exists to provide.
@@ -47,6 +58,7 @@ module MCP
47
58
  @command = command
48
59
  @args = args
49
60
  @env = env
61
+ @inherit_env = inherit_env
50
62
  @read_timeout = read_timeout
51
63
  @max_line_bytes = max_line_bytes
52
64
  @stdin = nil
@@ -166,8 +178,8 @@ module MCP
166
178
  def start
167
179
  raise "MCP::Client::Stdio already started" if @started
168
180
 
169
- spawn_env = @env || {}
170
- @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(spawn_env, @command, *@args)
181
+ spawn_env = DEFAULT_INHERITED_ENV_VARS.to_h { |key| [key, ENV[key]] }.compact.merge(@env || {})
182
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(spawn_env, @command, *@args, unsetenv_others: !@inherit_env)
171
183
  @stdout.set_encoding("UTF-8")
172
184
  @stdin.set_encoding("UTF-8")
173
185
 
data/lib/mcp/client.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "client/elicitation"
4
- require_relative "client/mcp_param_headers"
5
4
  require_relative "client/modern_envelope"
6
5
  require_relative "client/oauth"
7
6
  require_relative "client/stdio"
@@ -20,6 +19,11 @@ module MCP
20
19
  # every response would otherwise be followed indefinitely, growing the retained pages with it.
21
20
  MAX_PAGES = 1_000
22
21
 
22
+ # The custom-header codec lived under this name until the server transport came to share it;
23
+ # the name stays for code that referenced it.
24
+ # @api private
25
+ McpParamHeaders = MCP::McpParamHeaders
26
+
23
27
  class ServerError < StandardError
24
28
  attr_reader :code, :data
25
29
 
@@ -5,16 +5,17 @@ module MCP
5
5
  def instrument_call(method, server_context: {}, exception_already_reported: nil, &block)
6
6
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
7
7
  begin
8
- @instrumentation_data = {}
9
- add_instrumentation_data(method: method)
8
+ instrumentation_data = {}
9
+ @instrumentation_data = instrumentation_data
10
+ instrumentation_data.merge!(method: method)
10
11
  # `self.` is required: the `server_context:` keyword above shadows the
11
12
  # reader, and the value we want here is the user-defined hash passed to
12
13
  # `Server.new`, not the per-call reporter context.
13
14
  if configuration.instrument_server_context? && respond_to?(:server_context)
14
- add_instrumentation_data(server_context: self.server_context)
15
+ instrumentation_data.merge!(server_context: self.server_context)
15
16
  end
16
17
 
17
- result = configuration.around_request.call(@instrumentation_data, &block)
18
+ result = configuration.around_request.call(instrumentation_data) { block.call(instrumentation_data) }
18
19
 
19
20
  result
20
21
  rescue => e
@@ -28,22 +29,26 @@ module MCP
28
29
  end
29
30
 
30
31
  unless already_reported
31
- add_instrumentation_data(error: :internal_error) unless @instrumentation_data.key?(:error)
32
+ instrumentation_data.merge!(error: :internal_error) unless instrumentation_data.key?(:error)
32
33
  configuration.exception_reporter.call(e, server_context)
33
34
  end
34
35
 
35
36
  raise
36
37
  ensure
37
38
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
38
- add_instrumentation_data(duration: end_time - start_time)
39
+ instrumentation_data.merge!(duration: end_time - start_time)
39
40
 
40
41
  # Backward compatibility: `instrumentation_callback` is soft-deprecated
41
42
  # in favor of `around_request`, but existing callers still expect it
42
43
  # to fire after every request until it is removed in a future version.
43
- configuration.instrumentation_callback.call(@instrumentation_data)
44
+ configuration.instrumentation_callback.call(instrumentation_data)
44
45
  end
45
46
  end
46
47
 
48
+ # @deprecated Use the data hash passed to the {#instrument_call} block or
49
+ # `around_request` instead. `add_instrumentation_data` uses a shared record
50
+ # and is not safe for concurrent calls.
51
+ # @see #instrument_call
47
52
  def add_instrumentation_data(**kwargs)
48
53
  @instrumentation_data.merge!(kwargs)
49
54
  end