mcp 1.6.1 → 1.8.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: '08c2de444c55244db435a10ce7b9f9026d24158d47cadd6f89b917f42e09b534'
4
- data.tar.gz: 6126a02c9adf92df07f22b323a0337f2abae9eb23c7bf014f1b7ef1ccbbf3a72
3
+ metadata.gz: 3585a92ae036b5018c7edf6b4c77a4a5c07903084314459a58180df16d3f0e28
4
+ data.tar.gz: d543ab701b3589e5728dba998e9b7d66b52baabfa4e9cf272834024bc57340a0
5
5
  SHA512:
6
- metadata.gz: 7318acb46d46e9c958115f95171f21b20c186ab32608770d443440e0202d29319b9384b11099ed69f136c3ba9146be39998e1a9a47a839fe506e07cddca0ad89
7
- data.tar.gz: 3008f7f6061e0a2cd514d377ade38a03be1ed88d863fb268e50c9820ece43f585b7fcc801bc6c0ffcf78fab3a80af3397c20e497722a6821c55d6128d391d4fc
6
+ metadata.gz: 2d8cdd94c440d86ed5c317cc4bd94b21279ea613be75956bc49de0ddb008a0b574f3ee15751053be78c249d420709a168f0810674dbeeea34d9cd359dd9ac2f0
7
+ data.tar.gz: 9f131703b9db0f7fdfbc0a57ffe7b4132c522720bf841aaa4d70f20487644fe6ef428f89f4f39d4c1044312045553356fe87c3684f1deadc95dc81b45e441d42
data/README.md CHANGED
@@ -6,7 +6,7 @@ Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io.
6
6
 
7
7
  ## Features
8
8
 
9
- - Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host
9
+ - Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host, with OAuth 2.1 resource-server protection
10
10
  - Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization
11
11
  - Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration
12
12
  - Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination
@@ -202,9 +202,10 @@ module MCP
202
202
 
203
203
  if parsed.key?("result") || parsed.key?("error")
204
204
  @response ||= parsed
205
- elsif parsed["method"] && parsed.key?("id")
206
- # A server-to-client request (e.g. `elicitation/create`) delivered
207
- # on the stream while the original request is still pending.
205
+ elsif parsed["method"] && parsed.key?("id") && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
206
+ # A server-to-client request (e.g. `elicitation/create`) delivered on the stream while
207
+ # the original request is still pending. One that is not JSON-RPC 2.0 is dropped unanswered,
208
+ # as the TypeScript client drops it at schema validation.
208
209
  @on_request&.call(parsed)
209
210
  end
210
211
  end
@@ -399,6 +400,7 @@ module MCP
399
400
  )
400
401
  end
401
402
 
403
+ request_id = request[:id] || request["id"]
402
404
  method = request[:method] || request["method"]
403
405
  params = request[:params] || request["params"]
404
406
  oauth_retried = false
@@ -417,14 +419,20 @@ module MCP
417
419
  yield if block_given?
418
420
 
419
421
  response = begin
420
- client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
421
- req.options.on_data = stream.on_data
422
+ redacting_authorization_header do
423
+ client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
424
+ req.options.on_data = stream.on_data
425
+ end
422
426
  end
423
427
  rescue StreamAbort
424
428
  nil
425
429
  end
426
430
 
427
- body = resolve_response_body(stream, response, method, params)
431
+ body = resolve_response_body(stream, response, request_id, method, params)
432
+
433
+ # Every way a response arrives (JSON body, SSE event, resumed stream) ends here. Checked before
434
+ # anything is learned from the body, and only for a request: a notification awaits no response.
435
+ reject_invalid_response!(body, method, params) if request_id
428
436
 
429
437
  capture_session_info(method, response, body) if response
430
438
  capture_mcp_param_declarations(method, params, body)
@@ -522,7 +530,7 @@ module MCP
522
530
  def send_notification(notification:)
523
531
  method = notification[:method] || notification["method"]
524
532
 
525
- client.post("", notification, session_headers)
533
+ redacting_authorization_header { client.post("", notification, session_headers) }
526
534
  nil
527
535
  rescue Faraday::Error => e
528
536
  raise RequestHandlerError.new(
@@ -550,7 +558,7 @@ module MCP
550
558
  end
551
559
 
552
560
  begin
553
- client.delete("", nil, session_headers)
561
+ redacting_authorization_header { client.delete("", nil, session_headers) }
554
562
  rescue Faraday::ClientError => e
555
563
  raise unless [404, 405].include?(e.response&.dig(:status))
556
564
  ensure
@@ -902,15 +910,25 @@ module MCP
902
910
  MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
903
911
  end
904
912
 
913
+ # A provider without a `callback_handler` finishes the authorization in the request that receives the redirect,
914
+ # not here, so there is nothing to retry with yet: the pending authorization surfaces as
915
+ # `Flow::AuthorizationPendingError`, and requests made after `Flow#finish!` pick up the stored tokens.
905
916
  def run_full_authorization_flow!(flow:, params:)
906
917
  # Use the URL snapshotted at `initialize` time so a post-construction
907
918
  # mutation of `@url` cannot redirect PRM/AS discovery and the authorize
908
919
  # URL to an attacker-controlled host.
909
- flow.run!(
920
+ result = flow.run!(
910
921
  server_url: @oauth_server_url,
911
922
  resource_metadata_url: params["resource_metadata"],
912
923
  scope: params["scope"],
913
924
  )
925
+ return unless result == :redirect
926
+
927
+ raise MCP::Client::OAuth::Flow::AuthorizationPendingError.new(
928
+ "Authorization is pending: the user was sent to the authorization server, and the request can be retried " \
929
+ "once `MCP::Client::OAuth::Flow#finish!` completes the authorization with the redirect's query.",
930
+ authorization_url: flow.authorization_url,
931
+ )
914
932
  end
915
933
 
916
934
  # Tries to swap a saved `refresh_token` for a fresh access token. Returns truthy
@@ -1035,6 +1053,51 @@ module MCP
1035
1053
  [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
1036
1054
  end
1037
1055
 
1056
+ # Runs a request on the transport's connection and, when Faraday raises, replaces the value of the `Authorization` header
1057
+ # in the request headers the exception retains before it leaves the transport.
1058
+ # Faraday's `raise_error` middleware keeps a reference to `env.request_headers` in `Faraday::Error#response`
1059
+ # under `:request`, so the exception that becomes `RequestHandlerError#original_error` (and its `cause`) would otherwise print
1060
+ # the bearer token through `inspect`, which is what error reporters and log lines do with it. The request is complete by
1061
+ # the time the exception surfaces, and every request builds its own headers from the connection's defaults,
1062
+ # so the rewrite reaches neither those defaults nor a later request.
1063
+ def redacting_authorization_header
1064
+ yield
1065
+ rescue Faraday::Error => e
1066
+ redact_authorization_header!(e)
1067
+ raise
1068
+ end
1069
+
1070
+ # `raise_error` retains the request as a Hash under `:request`; a JSON middleware the customizer added raises
1071
+ # `Faraday::ParsingError` with the `Faraday::Response` itself, whose `env` holds the request headers.
1072
+ def redact_authorization_header!(error)
1073
+ response = error.response
1074
+
1075
+ if response.is_a?(Hash)
1076
+ request = response[:request]
1077
+ redact_authorization_header_in!(request, :headers) if request.is_a?(Hash)
1078
+ elsif response.respond_to?(:env) && response.env.respond_to?(:request_headers)
1079
+ redact_authorization_header_in!(response.env, :request_headers)
1080
+ end
1081
+ end
1082
+
1083
+ # The headers are a `Faraday::Utils::Headers`, which spells the name `Authorization` whatever the caller wrote,
1084
+ # unless a customizer middleware replaced them with a plain Hash or froze them: the name is matched regardless
1085
+ # of case so such a Hash is covered too, and a frozen object is swapped for a copy, so the redaction never
1086
+ # raises in place of the error it is redacting.
1087
+ def redact_authorization_header_in!(holder, key)
1088
+ headers = holder[key]
1089
+ return unless headers.is_a?(Hash)
1090
+
1091
+ names = headers.each_key.select { |name| name.to_s.casecmp?("authorization") }
1092
+ return if names.empty?
1093
+
1094
+ headers = holder[key] = headers.dup if headers.frozen?
1095
+
1096
+ names.each do |name|
1097
+ headers[name] = "[redacted]"
1098
+ end
1099
+ end
1100
+
1038
1101
  def require_faraday!
1039
1102
  require "faraday"
1040
1103
  rescue LoadError
@@ -1044,8 +1107,11 @@ module MCP
1044
1107
  end
1045
1108
 
1046
1109
  # Determines the logical JSON-RPC body of the exchange after the POST stream finished
1047
- # (or was aborted because the response already arrived).
1048
- def resolve_response_body(stream, response, method, params)
1110
+ # (or was aborted because the response already arrived). A notification (no `request_id`) awaits
1111
+ # no response, so any success status acknowledges it, as it does for the TypeScript and Python clients,
1112
+ # which check the content type of a response only for a request; the spec has the server answer
1113
+ # a notification with 202, but a server answering with another 2xx is accepted as well.
1114
+ def resolve_response_body(stream, response, request_id, method, params)
1049
1115
  return stream.response if stream.response
1050
1116
 
1051
1117
  # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
@@ -1063,6 +1129,9 @@ module MCP
1063
1129
  return stream.response if stream.response
1064
1130
  end
1065
1131
 
1132
+ # A stream that ends without a response has acknowledged a notification; there is nothing to resume for.
1133
+ return if request_id.nil? && response.success?
1134
+
1066
1135
  # SEP-1699: a graceful close after a priming event (an event with an id) but before
1067
1136
  # the response means "reconnect via GET to resume".
1068
1137
  return await_response_after_disconnect(stream, method, params) if stream.primed?
@@ -1079,6 +1148,8 @@ module MCP
1079
1148
  # added by the Faraday customizer may have parsed it already.
1080
1149
  response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
1081
1150
  else
1151
+ return if request_id.nil? && response.success?
1152
+
1082
1153
  raise RequestHandlerError.new(
1083
1154
  "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
1084
1155
  { method: method, params: params },
@@ -1147,7 +1218,7 @@ module MCP
1147
1218
  end
1148
1219
 
1149
1220
  def send_client_response(response)
1150
- client.post("", response, session_headers)
1221
+ redacting_authorization_header { client.post("", response, session_headers) }
1151
1222
  end
1152
1223
 
1153
1224
  def parse_json_buffer(buffer, method, params)
@@ -1162,6 +1233,20 @@ module MCP
1162
1233
  )
1163
1234
  end
1164
1235
 
1236
+ # A response is a JSON object whose `jsonrpc` is exactly "2.0". No body at all (202, or an empty 200)
1237
+ # is not a message and is left to the caller as before; a JSON `null` body parses to the same `nil`.
1238
+ # The received value is left out of the message because it can be any JSON value.
1239
+ def reject_invalid_response!(body, method, params)
1240
+ return if body.nil?
1241
+ return if body.is_a?(Hash) && JsonRpcHandler.valid_version?(body["jsonrpc"])
1242
+
1243
+ raise RequestHandlerError.new(
1244
+ 'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
1245
+ { method: method, params: params },
1246
+ error_type: :parse_error,
1247
+ )
1248
+ end
1249
+
1165
1250
  # SEP-1699 resumability: the server closed the SSE stream after a priming event
1166
1251
  # without delivering the response. Treat the graceful close like a network failure:
1167
1252
  # wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
@@ -1196,12 +1281,14 @@ module MCP
1196
1281
  read_timeout = remaining_reconnection_budget(deadline)
1197
1282
 
1198
1283
  reconnect_response = begin
1199
- client.get("") do |req|
1200
- req.headers.update(session_headers)
1201
- req.headers["Accept"] = SSE_ACCEPT_HEADER
1202
- req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
1203
- req.options.read_timeout = read_timeout
1204
- req.options.on_data = stream.on_data
1284
+ redacting_authorization_header do
1285
+ client.get("") do |req|
1286
+ req.headers.update(session_headers)
1287
+ req.headers["Accept"] = SSE_ACCEPT_HEADER
1288
+ req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
1289
+ req.options.read_timeout = read_timeout
1290
+ req.options.on_data = stream.on_data
1291
+ end
1205
1292
  end
1206
1293
  rescue StreamAbort
1207
1294
  # The awaited response arrived on the reconnected stream.