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 +4 -4
- data/README.md +1 -1
- data/lib/mcp/client/http.rb +105 -18
- data/lib/mcp/client/oauth/flow.rb +276 -23
- data/lib/mcp/client/oauth/in_memory_storage.rb +74 -1
- data/lib/mcp/client/oauth/provider.rb +77 -6
- data/lib/mcp/client/stdio.rb +23 -4
- data/lib/mcp/icon.rb +61 -0
- data/lib/mcp/prompt.rb +1 -1
- data/lib/mcp/resource.rb +2 -2
- data/lib/mcp/resource_template.rb +2 -2
- data/lib/mcp/server/oauth/access_token.rb +89 -0
- data/lib/mcp/server/oauth/authenticator.rb +150 -0
- data/lib/mcp/server/oauth/challenge.rb +114 -0
- data/lib/mcp/server/oauth/errors.rb +58 -0
- data/lib/mcp/server/oauth/introspection_verifier.rb +151 -0
- data/lib/mcp/server/oauth/jwt_verifier.rb +330 -0
- data/lib/mcp/server/oauth/middleware.rb +60 -0
- data/lib/mcp/server/oauth/protected_resource_metadata.rb +138 -0
- data/lib/mcp/server/oauth/protected_resource_metadata_middleware.rb +86 -0
- data/lib/mcp/server/oauth/token_verifier.rb +135 -0
- data/lib/mcp/server/oauth.rb +29 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +712 -160
- data/lib/mcp/server.rb +63 -31
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3585a92ae036b5018c7edf6b4c77a4a5c07903084314459a58180df16d3f0e28
|
|
4
|
+
data.tar.gz: d543ab701b3589e5728dba998e9b7d66b52baabfa4e9cf272834024bc57340a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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.
|