mcp 1.7.0 → 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 +94 -17
- data/lib/mcp/client/oauth/flow.rb +68 -21
- data/lib/mcp/client/oauth/provider.rb +11 -0
- data/lib/mcp/client/stdio.rb +23 -4
- 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 +488 -120
- data/lib/mcp/server.rb +55 -28
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- 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
|
|
@@ -1045,6 +1053,51 @@ module MCP
|
|
|
1045
1053
|
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
|
|
1046
1054
|
end
|
|
1047
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
|
+
|
|
1048
1101
|
def require_faraday!
|
|
1049
1102
|
require "faraday"
|
|
1050
1103
|
rescue LoadError
|
|
@@ -1054,8 +1107,11 @@ module MCP
|
|
|
1054
1107
|
end
|
|
1055
1108
|
|
|
1056
1109
|
# Determines the logical JSON-RPC body of the exchange after the POST stream finished
|
|
1057
|
-
# (or was aborted because the response already arrived).
|
|
1058
|
-
|
|
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)
|
|
1059
1115
|
return stream.response if stream.response
|
|
1060
1116
|
|
|
1061
1117
|
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
|
|
@@ -1073,6 +1129,9 @@ module MCP
|
|
|
1073
1129
|
return stream.response if stream.response
|
|
1074
1130
|
end
|
|
1075
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
|
+
|
|
1076
1135
|
# SEP-1699: a graceful close after a priming event (an event with an id) but before
|
|
1077
1136
|
# the response means "reconnect via GET to resume".
|
|
1078
1137
|
return await_response_after_disconnect(stream, method, params) if stream.primed?
|
|
@@ -1089,6 +1148,8 @@ module MCP
|
|
|
1089
1148
|
# added by the Faraday customizer may have parsed it already.
|
|
1090
1149
|
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
|
|
1091
1150
|
else
|
|
1151
|
+
return if request_id.nil? && response.success?
|
|
1152
|
+
|
|
1092
1153
|
raise RequestHandlerError.new(
|
|
1093
1154
|
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
|
|
1094
1155
|
{ method: method, params: params },
|
|
@@ -1157,7 +1218,7 @@ module MCP
|
|
|
1157
1218
|
end
|
|
1158
1219
|
|
|
1159
1220
|
def send_client_response(response)
|
|
1160
|
-
client.post("", response, session_headers)
|
|
1221
|
+
redacting_authorization_header { client.post("", response, session_headers) }
|
|
1161
1222
|
end
|
|
1162
1223
|
|
|
1163
1224
|
def parse_json_buffer(buffer, method, params)
|
|
@@ -1172,6 +1233,20 @@ module MCP
|
|
|
1172
1233
|
)
|
|
1173
1234
|
end
|
|
1174
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
|
+
|
|
1175
1250
|
# SEP-1699 resumability: the server closed the SSE stream after a priming event
|
|
1176
1251
|
# without delivering the response. Treat the graceful close like a network failure:
|
|
1177
1252
|
# wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
|
|
@@ -1206,12 +1281,14 @@ module MCP
|
|
|
1206
1281
|
read_timeout = remaining_reconnection_budget(deadline)
|
|
1207
1282
|
|
|
1208
1283
|
reconnect_response = begin
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
|
1215
1292
|
end
|
|
1216
1293
|
rescue StreamAbort
|
|
1217
1294
|
# The awaited response arrived on the reconnected stream.
|
|
@@ -19,6 +19,9 @@ module MCP
|
|
|
19
19
|
METADATA_DIAGNOSTIC_MAX_LENGTH = 128
|
|
20
20
|
METADATA_URL_MAX_LENGTH = 2048
|
|
21
21
|
|
|
22
|
+
# RFC 6749 scope-token: visible ASCII except space, double quote, and backslash.
|
|
23
|
+
SCOPE_TOKEN_FORMAT = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/.freeze
|
|
24
|
+
|
|
22
25
|
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
|
|
23
26
|
# so a provider naming one of these is refused rather than left believing its value was sent.
|
|
24
27
|
RESERVED_TOKEN_REQUEST_PARAMS = [
|
|
@@ -264,12 +267,14 @@ module MCP
|
|
|
264
267
|
|
|
265
268
|
ensure_pkce_supported!(as_metadata)
|
|
266
269
|
|
|
267
|
-
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
270
|
+
effective_scope = resolve_scope(scope: scope, prm: prm, select_prm_scope: true)
|
|
268
271
|
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
272
|
+
endpoint_uri, endpoint_params = authorization_endpoint_parameters(as_metadata: as_metadata)
|
|
273
|
+
request_scope = authorization_request_scope(scope: effective_scope, endpoint_params: endpoint_params)
|
|
269
274
|
|
|
270
275
|
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
|
|
271
|
-
# the embedding application has just rejected.
|
|
272
|
-
authorize_request!(as_metadata: as_metadata, scope:
|
|
276
|
+
# the embedding application has just rejected. Use the scopes that will actually reach the browser URL.
|
|
277
|
+
authorize_request!(as_metadata: as_metadata, scope: request_scope, server_url: server_url, resource: resource)
|
|
273
278
|
|
|
274
279
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
275
280
|
|
|
@@ -277,7 +282,8 @@ module MCP
|
|
|
277
282
|
state = SecureRandom.urlsafe_base64(32)
|
|
278
283
|
|
|
279
284
|
authorization_url = build_authorization_url(
|
|
280
|
-
|
|
285
|
+
endpoint_uri: endpoint_uri,
|
|
286
|
+
endpoint_params: endpoint_params,
|
|
281
287
|
client_id: client_info_required_value(client_info, "client_id"),
|
|
282
288
|
scope: effective_scope,
|
|
283
289
|
state: state,
|
|
@@ -850,16 +856,16 @@ module MCP
|
|
|
850
856
|
# Hands the embedding application the authorization server and the scopes that are about to be requested,
|
|
851
857
|
# and abandons the flow when it refuses them.
|
|
852
858
|
#
|
|
853
|
-
#
|
|
854
|
-
#
|
|
859
|
+
# The MCP server names its authorization server in Protected Resource Metadata and states scopes
|
|
860
|
+
# in `scopes_supported` or the `WWW-Authenticate` challenge. Authorization-code policy also sees
|
|
861
|
+
# any prefilled endpoint scope that will survive URL assembly.
|
|
855
862
|
# Neither the specification nor any MCP SDK binds that choice to the server's own identity,
|
|
856
863
|
# and validating that a token was issued for the intended audience is a responsibility the specification
|
|
857
864
|
# places on MCP servers rather than on clients.
|
|
858
865
|
# A host that knows which providers its user deals with can apply that knowledge here.
|
|
859
866
|
#
|
|
860
|
-
#
|
|
861
|
-
#
|
|
862
|
-
# to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
|
|
867
|
+
# Challenged scopes are authoritative for the operation and bypass the PRM scope selector.
|
|
868
|
+
# The validator can accept or refuse them, not quietly request fewer scopes.
|
|
863
869
|
#
|
|
864
870
|
# Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
|
|
865
871
|
# this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
|
|
@@ -1302,17 +1308,18 @@ module MCP
|
|
|
1302
1308
|
AuthorizationError.new(message, error: error, error_description: description)
|
|
1303
1309
|
end
|
|
1304
1310
|
|
|
1305
|
-
#
|
|
1306
|
-
#
|
|
1307
|
-
|
|
1308
|
-
# scope only if both are absent. The provider-supplied scope must not pre-empt
|
|
1309
|
-
# a server-advertised one.
|
|
1310
|
-
def resolve_scope(scope:, prm:)
|
|
1311
|
+
# MCP scope selection prefers the challenge, then PRM `scopes_supported`, then the provider's fallback.
|
|
1312
|
+
# Authorization-code clients may narrow only the PRM default, before `offline_access` augmentation.
|
|
1313
|
+
def resolve_scope(scope:, prm:, select_prm_scope: false)
|
|
1311
1314
|
return scope if scope && !scope.empty?
|
|
1312
1315
|
|
|
1313
1316
|
# `prm` is nil on the legacy path, where nothing advertises scopes.
|
|
1314
1317
|
supported = prm && prm["scopes_supported"]
|
|
1315
|
-
|
|
1318
|
+
if supported.is_a?(Array) && !supported.empty?
|
|
1319
|
+
return select_prm_scopes(supported) if select_prm_scope
|
|
1320
|
+
|
|
1321
|
+
return supported.join(" ")
|
|
1322
|
+
end
|
|
1316
1323
|
|
|
1317
1324
|
return @provider.scope if @provider.scope && !@provider.scope.empty?
|
|
1318
1325
|
|
|
@@ -1334,7 +1341,8 @@ module MCP
|
|
|
1334
1341
|
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
|
|
1335
1342
|
# own request consistent with the AS's advertisement.
|
|
1336
1343
|
#
|
|
1337
|
-
# Returns `nil` when
|
|
1344
|
+
# Returns `nil` when empty so the URL builder adds no flow-owned scope parameter; a prefilled scope
|
|
1345
|
+
# can still survive and is checked by the authorization-request validator.
|
|
1338
1346
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
|
|
1339
1347
|
def normalize_offline_access_scope(scope, as_metadata:)
|
|
1340
1348
|
scopes = scope.to_s.split
|
|
@@ -1354,6 +1362,28 @@ module MCP
|
|
|
1354
1362
|
supported.is_a?(Array) && supported.include?("offline_access")
|
|
1355
1363
|
end
|
|
1356
1364
|
|
|
1365
|
+
# Selects a subset of the PRM default without changing challenges, provider fallback, or refresh policy.
|
|
1366
|
+
def select_prm_scopes(scopes)
|
|
1367
|
+
selector = @provider.scope_selector if @provider.respond_to?(:scope_selector)
|
|
1368
|
+
return scopes.join(" ") unless selector
|
|
1369
|
+
|
|
1370
|
+
unless scopes.all? { |token| token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) }
|
|
1371
|
+
raise AuthorizationError, "Protected Resource Metadata `scopes_supported` contains invalid OAuth scope tokens."
|
|
1372
|
+
end
|
|
1373
|
+
|
|
1374
|
+
candidates = scopes.map { |token| token.dup.freeze }.freeze
|
|
1375
|
+
candidate_index = candidates.to_h { |token| [token, true] }
|
|
1376
|
+
selected = selector.call(candidates)
|
|
1377
|
+
valid = selected.is_a?(Array) && selected.all? do |token|
|
|
1378
|
+
token.is_a?(String) && SCOPE_TOKEN_FORMAT.match?(token) && candidate_index.key?(token)
|
|
1379
|
+
end
|
|
1380
|
+
unless valid
|
|
1381
|
+
raise ArgumentError, "scope_selector must return an Array containing only scopes from PRM scopes_supported."
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
selected.empty? ? nil : selected.join(" ")
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1357
1387
|
def wants_refresh_token?
|
|
1358
1388
|
metadata = @provider.client_metadata
|
|
1359
1389
|
grant_types = metadata[:grant_types] || metadata["grant_types"]
|
|
@@ -1398,7 +1428,7 @@ module MCP
|
|
|
1398
1428
|
@provider.client_id_metadata_document_url
|
|
1399
1429
|
end
|
|
1400
1430
|
|
|
1401
|
-
def
|
|
1431
|
+
def authorization_endpoint_parameters(as_metadata:)
|
|
1402
1432
|
authorization_endpoint = as_metadata["authorization_endpoint"]
|
|
1403
1433
|
unless authorization_endpoint
|
|
1404
1434
|
raise AuthorizationError,
|
|
@@ -1412,6 +1442,23 @@ module MCP
|
|
|
1412
1442
|
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
|
|
1413
1443
|
end
|
|
1414
1444
|
|
|
1445
|
+
[uri, URI.decode_www_form(uri.query.to_s)]
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
# A flow scope replaces every endpoint scope; otherwise a single prefilled value survives.
|
|
1449
|
+
# Decode once before policy approval and reuse those parameters when building the URL.
|
|
1450
|
+
def authorization_request_scope(scope:, endpoint_params:)
|
|
1451
|
+
return scope if scope
|
|
1452
|
+
|
|
1453
|
+
endpoint_scopes = endpoint_params.filter_map { |name, value| value if name == "scope" }
|
|
1454
|
+
if endpoint_scopes.length > 1
|
|
1455
|
+
raise AuthorizationError, "Authorization endpoint contains repeated `scope` parameters."
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
endpoint_scopes.first
|
|
1459
|
+
end
|
|
1460
|
+
|
|
1461
|
+
def build_authorization_url(endpoint_uri:, endpoint_params:, client_id:, scope:, state:, code_challenge:, resource:)
|
|
1415
1462
|
# A parameter the flow sets replaces any of the same name the endpoint URL already carries.
|
|
1416
1463
|
# RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is
|
|
1417
1464
|
# its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak
|
|
@@ -1433,10 +1480,10 @@ module MCP
|
|
|
1433
1480
|
own_params << ["resource", resource] if resource
|
|
1434
1481
|
dropped_names = own_params.map(&:first) + ["request", "request_uri"]
|
|
1435
1482
|
|
|
1436
|
-
params =
|
|
1437
|
-
|
|
1483
|
+
params = endpoint_params.reject { |name, _value| dropped_names.include?(name) }
|
|
1484
|
+
endpoint_uri.query = URI.encode_www_form(params + own_params)
|
|
1438
1485
|
|
|
1439
|
-
|
|
1486
|
+
endpoint_uri
|
|
1440
1487
|
end
|
|
1441
1488
|
|
|
1442
1489
|
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:, redirect_uri: @provider.redirect_uri)
|
|
@@ -37,6 +37,10 @@ module MCP
|
|
|
37
37
|
# `run!` saves it, when `callback_handler` is omitted. Defaults to `DEFAULT_PENDING_AUTHORIZATION_MAX_AGE`.
|
|
38
38
|
# - `scope` - String of space-separated scopes to request when the server's
|
|
39
39
|
# `WWW-Authenticate` does not specify one.
|
|
40
|
+
# - `scope_selector` - Callable receiving a read-only Array of PRM `scopes_supported` tokens when
|
|
41
|
+
# those defaults are selected without a challenged scope. Return an Array containing a subset;
|
|
42
|
+
# `[]` requests none of the PRM defaults. Challenged scopes and provider fallback bypass it.
|
|
43
|
+
# The existing `offline_access` policy runs afterward; endpoint query parameters are unchanged.
|
|
40
44
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
41
45
|
# `client_information`, and `save_client_information(info)`. Defaults to
|
|
42
46
|
# an `InMemoryStorage`. Persisted `client_information` is stamped with
|
|
@@ -108,6 +112,7 @@ module MCP
|
|
|
108
112
|
attr_reader :client_metadata,
|
|
109
113
|
:redirect_uri,
|
|
110
114
|
:scope,
|
|
115
|
+
:scope_selector,
|
|
111
116
|
:storage,
|
|
112
117
|
:redirect_handler,
|
|
113
118
|
:callback_handler,
|
|
@@ -120,6 +125,7 @@ module MCP
|
|
|
120
125
|
redirect_handler:,
|
|
121
126
|
callback_handler: nil,
|
|
122
127
|
scope: nil,
|
|
128
|
+
scope_selector: nil,
|
|
123
129
|
storage: nil,
|
|
124
130
|
client_id_metadata_document_url: nil,
|
|
125
131
|
authorization_request_validator: nil,
|
|
@@ -147,6 +153,10 @@ module MCP
|
|
|
147
153
|
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
|
148
154
|
end
|
|
149
155
|
|
|
156
|
+
unless scope_selector.nil? || scope_selector.respond_to?(:call)
|
|
157
|
+
raise ArgumentError, "scope_selector must respond to call (got #{scope_selector.class})."
|
|
158
|
+
end
|
|
159
|
+
|
|
150
160
|
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
151
161
|
|
|
152
162
|
unless pending_authorization_max_age.is_a?(Integer) && pending_authorization_max_age.positive?
|
|
@@ -170,6 +180,7 @@ module MCP
|
|
|
170
180
|
@redirect_handler = redirect_handler
|
|
171
181
|
@callback_handler = callback_handler
|
|
172
182
|
@scope = scope
|
|
183
|
+
@scope_selector = scope_selector
|
|
173
184
|
@storage = storage
|
|
174
185
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
175
186
|
@authorization_request_validator = authorization_request_validator
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -442,17 +442,22 @@ module MCP
|
|
|
442
442
|
# other server-to-client requests over stdio stay unsupported as documented,
|
|
443
443
|
# and notifications carry no id.
|
|
444
444
|
if parsed.is_a?(Hash) && parsed.key?("method")
|
|
445
|
-
# A JSON-RPC id is a String or a Number; the reference SDKs reject
|
|
446
|
-
# schema validation, so a ping carrying one is skipped rather than echoed back.
|
|
447
|
-
answer_ping(parsed) if
|
|
445
|
+
# A JSON-RPC id is a String or a Number, and `jsonrpc` is "2.0"; the reference SDKs reject
|
|
446
|
+
# other shapes at schema validation, so a ping carrying one is skipped rather than echoed back.
|
|
447
|
+
answer_ping(parsed) if answerable_ping?(parsed)
|
|
448
448
|
next
|
|
449
449
|
end
|
|
450
450
|
|
|
451
451
|
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
|
|
452
452
|
# the same way as a frame without an id.
|
|
453
453
|
next unless parsed.is_a?(Hash) && parsed.key?("id")
|
|
454
|
+
next unless parsed["id"] == request_id
|
|
454
455
|
|
|
455
|
-
|
|
456
|
+
# Nothing else will answer the awaited request, so a response that is not JSON-RPC 2.0 fails it
|
|
457
|
+
# instead of being skipped: without a `read_timeout`, skipping would wait forever.
|
|
458
|
+
raise_invalid_response!(method, params) unless JsonRpcHandler.valid_version?(parsed["jsonrpc"])
|
|
459
|
+
|
|
460
|
+
return parsed
|
|
456
461
|
end
|
|
457
462
|
rescue JSON::ParserError => e
|
|
458
463
|
raise RequestHandlerError.new(
|
|
@@ -475,6 +480,10 @@ module MCP
|
|
|
475
480
|
# surface as an error of the unrelated request whose response the loop is reading.
|
|
476
481
|
end
|
|
477
482
|
|
|
483
|
+
def answerable_ping?(parsed)
|
|
484
|
+
parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"]) && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
|
|
485
|
+
end
|
|
486
|
+
|
|
478
487
|
def json_rpc_id?(id)
|
|
479
488
|
id.is_a?(String) || id.is_a?(Numeric)
|
|
480
489
|
end
|
|
@@ -532,6 +541,16 @@ module MCP
|
|
|
532
541
|
error_type: :internal_error,
|
|
533
542
|
)
|
|
534
543
|
end
|
|
544
|
+
|
|
545
|
+
# The line framing is intact, so the transport stays usable; the received value is left out of
|
|
546
|
+
# the message because it can be any JSON value.
|
|
547
|
+
def raise_invalid_response!(method, params)
|
|
548
|
+
raise RequestHandlerError.new(
|
|
549
|
+
'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
|
|
550
|
+
{ method: method, params: params },
|
|
551
|
+
error_type: :internal_error,
|
|
552
|
+
)
|
|
553
|
+
end
|
|
535
554
|
end
|
|
536
555
|
end
|
|
537
556
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Server
|
|
5
|
+
module OAuth
|
|
6
|
+
# The result of a successful token verification: the resource-server-side view of an access token.
|
|
7
|
+
# Instances are produced by a `TokenVerifier` and reach tool/prompt/resource handlers as `server_context.auth_info`.
|
|
8
|
+
#
|
|
9
|
+
# The field set is the union of what the official SDKs converged on (Python `AccessToken` and TypeScript `AuthInfo`):
|
|
10
|
+
#
|
|
11
|
+
# - `token` - the raw bearer token as presented by the client
|
|
12
|
+
# - `client_id` - the OAuth client the token was issued to
|
|
13
|
+
# - `scopes` - granted scopes as an array of strings
|
|
14
|
+
# - `expires_at` - expiry as an Integer unix timestamp, or nil when the token does not expire
|
|
15
|
+
# - `subject` - the end user (`sub` claim), when known
|
|
16
|
+
# - `issuer` - the authorization server that issued the token (`iss` claim)
|
|
17
|
+
# - `audience` - the raw `aud` value (String or Array), when known
|
|
18
|
+
# - `resource` - the canonical RFC 8707 resource identifier the verifier matched the token against,
|
|
19
|
+
# useful for tenancy checks inside handlers
|
|
20
|
+
# - `claims` - the full claim/introspection-response Hash for anything not covered by the named fields
|
|
21
|
+
class AccessToken
|
|
22
|
+
attr_reader :token, :client_id, :scopes, :expires_at, :subject, :issuer, :audience, :resource, :claims
|
|
23
|
+
|
|
24
|
+
def initialize(token:, client_id: nil, scopes: [], expires_at: nil, subject: nil, issuer: nil, audience: nil, resource: nil, claims: {})
|
|
25
|
+
@token = token
|
|
26
|
+
@client_id = client_id
|
|
27
|
+
@scopes = scopes
|
|
28
|
+
@expires_at = expires_at
|
|
29
|
+
@subject = subject
|
|
30
|
+
@issuer = issuer
|
|
31
|
+
@audience = audience
|
|
32
|
+
@resource = resource
|
|
33
|
+
@claims = claims
|
|
34
|
+
@scope_matcher = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Per RFC 7519 Section 4.1.4, a token must not be accepted on or after its expiry time.
|
|
38
|
+
# Tokens without an expiry never count as expired here; verifiers that require an expiry must enforce that themselves
|
|
39
|
+
# (`JWTVerifier` does, the TypeScript SDK rejects such tokens outright, and Python accepts them like this class —
|
|
40
|
+
# the divergence is deliberate: opaque-token verifiers may have no expiry to report even though the authorization server enforces one).
|
|
41
|
+
def expired?(now: Time.now.to_i)
|
|
42
|
+
return false if expires_at.nil?
|
|
43
|
+
|
|
44
|
+
expires_at <= now
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the token grants `scope`: exact membership by default. When the authenticator attached its `scope_matcher:`
|
|
48
|
+
# (see `with_scope_matcher`), that callable decides instead, so a hierarchical scope scheme is honored the same way
|
|
49
|
+
# at the endpoint gate and inside handlers via `require_scopes!`.
|
|
50
|
+
def scope?(scope)
|
|
51
|
+
return scopes.include?(scope.to_s) if @scope_matcher.nil?
|
|
52
|
+
|
|
53
|
+
!!@scope_matcher.call(scope.to_s, scopes)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A copy of this token whose `scope?` consults `matcher`, a `(required_scope, granted_scopes) -> Boolean` callable.
|
|
57
|
+
# The receiver is left untouched; a nil matcher returns the receiver itself.
|
|
58
|
+
def with_scope_matcher(matcher)
|
|
59
|
+
return self if matcher.nil?
|
|
60
|
+
|
|
61
|
+
dup.tap { |copy| copy.scope_matcher = matcher }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Mirrors `to_h`: the default `inspect` would print `@token`, leaking the credential into exception reports and debug output.
|
|
65
|
+
def inspect
|
|
66
|
+
"#<#{self.class.name} #{to_h.inspect}>"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Omits `token` so the result is safe to log without leaking the credential.
|
|
70
|
+
def to_h
|
|
71
|
+
{
|
|
72
|
+
client_id: client_id,
|
|
73
|
+
scopes: scopes,
|
|
74
|
+
expires_at: expires_at,
|
|
75
|
+
subject: subject,
|
|
76
|
+
issuer: issuer,
|
|
77
|
+
audience: audience,
|
|
78
|
+
resource: resource,
|
|
79
|
+
claims: claims,
|
|
80
|
+
}.compact
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
protected
|
|
84
|
+
|
|
85
|
+
attr_writer :scope_matcher
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|