mcp 1.7.0 → 1.8.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 +4 -4
- data/README.md +1 -1
- data/lib/json_rpc_handler.rb +37 -18
- data/lib/mcp/client/http.rb +117 -25
- 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/stdio_transport.rb +45 -8
- data/lib/mcp/server/transports/streamable_http_transport.rb +517 -139
- data/lib/mcp/server.rb +92 -35
- data/lib/mcp/server_context.rb +26 -2
- data/lib/mcp/server_session.rb +14 -4
- data/lib/mcp/transport.rb +8 -0
- 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: 255e316335616783063d8a7374f385ff10b99c9ddef24571734e19b6ec6f5f93
|
|
4
|
+
data.tar.gz: 07021e227f5b1be05fd2c1e4dc88e0d563bc963b6f1190f998aa632ed6871031
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d496817c30ae59b7f99704fead73cc9dfe5d926a87455a5c51fcc766bdf299d943ce86c0d43ca859d870a488df191cc0a4bdeba302b95d8ebcf084730743a68
|
|
7
|
+
data.tar.gz: 6291785257c054baf3570fb9baef4ecb1db006c920608ad69239460e2494610fa9133341bdf903a4c127f7ab40146f2bf9eecb34266621cba172a08ef52812f0
|
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/json_rpc_handler.rb
CHANGED
|
@@ -55,14 +55,19 @@ module JsonRpcHandler
|
|
|
55
55
|
|
|
56
56
|
def handle_json(request_json, id_validation_pattern: DEFAULT_ALLOWED_ID_CHARACTERS, &method_finder)
|
|
57
57
|
begin
|
|
58
|
-
|
|
58
|
+
begin
|
|
59
|
+
request = JSON.parse(request_json, symbolize_names: true)
|
|
60
|
+
rescue JSON::ParserError, EncodingError
|
|
61
|
+
# `symbolize_names` raises `EncodingError` for a key that is not valid UTF-8, a parse error as well.
|
|
62
|
+
# Rescued around the parse only, so an error raised while handling keeps its own classification.
|
|
63
|
+
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
64
|
+
code: ErrorCode::PARSE_ERROR,
|
|
65
|
+
message: "Parse error",
|
|
66
|
+
data: "Invalid JSON",
|
|
67
|
+
}).to_json
|
|
68
|
+
end
|
|
69
|
+
|
|
59
70
|
response = handle(request, id_validation_pattern: id_validation_pattern, &method_finder)
|
|
60
|
-
rescue JSON::ParserError
|
|
61
|
-
response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
|
|
62
|
-
code: ErrorCode::PARSE_ERROR,
|
|
63
|
-
message: "Parse error",
|
|
64
|
-
data: "Invalid JSON",
|
|
65
|
-
})
|
|
66
71
|
rescue StandardError
|
|
67
72
|
# Last-resort guard so an unexpected handling error returns a JSON-RPC error
|
|
68
73
|
# instead of escaping to the transport loop.
|
|
@@ -90,17 +95,25 @@ module JsonRpcHandler
|
|
|
90
95
|
|
|
91
96
|
error = if !valid_version?(request[:jsonrpc])
|
|
92
97
|
"JSON-RPC version must be 2.0"
|
|
98
|
+
elsif request.key?(:id) && id.nil?
|
|
99
|
+
# JSON-RPC 2.0 allows a null id; MCP rules it out: a request carries a string or an integer id,
|
|
100
|
+
# and a notification carries no id member at all. Refused rather than run as a notification,
|
|
101
|
+
# since a sender that wrote an id meant to be answered.
|
|
102
|
+
"Request ID must not be null"
|
|
93
103
|
elsif !valid_id?(id, id_validation_pattern)
|
|
94
|
-
|
|
104
|
+
if id_validation_pattern
|
|
105
|
+
"Request ID must be a string matching the validation pattern, or an integer"
|
|
106
|
+
else
|
|
107
|
+
"Request ID must be a string or an integer"
|
|
108
|
+
end
|
|
95
109
|
elsif !valid_method_name?(request[:method])
|
|
96
110
|
'Method name must be a string and not start with "rpc."'
|
|
97
111
|
end
|
|
98
112
|
|
|
99
113
|
# Per JSON-RPC 2.0 (Response object, `id`), the error response must carry
|
|
100
|
-
# the same id as the request when the id could be detected
|
|
101
|
-
# for
|
|
102
|
-
#
|
|
103
|
-
# (with a null id) being emitted when the request carried no id at all.
|
|
114
|
+
# the same id as the request when the id could be detected. `error_response`
|
|
115
|
+
# leaves the id out for ids that fail validation, and the `:unknown_id` sentinel
|
|
116
|
+
# keeps a response (without an id) being emitted when the request carried no id at all.
|
|
104
117
|
return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
|
|
105
118
|
code: ErrorCode::INVALID_REQUEST,
|
|
106
119
|
message: "Invalid Request",
|
|
@@ -176,7 +189,9 @@ module JsonRpcHandler
|
|
|
176
189
|
|
|
177
190
|
def valid_id?(id, pattern = nil)
|
|
178
191
|
return true if id.nil? || id.is_a?(Integer)
|
|
179
|
-
|
|
192
|
+
# A string that is not valid in its encoding cannot be written into the response,
|
|
193
|
+
# and matching a pattern against it raises, so it is refused before the method runs.
|
|
194
|
+
return false unless id.is_a?(String) && id.valid_encoding?
|
|
180
195
|
|
|
181
196
|
pattern ? id.match?(pattern) : true
|
|
182
197
|
end
|
|
@@ -197,11 +212,15 @@ module JsonRpcHandler
|
|
|
197
212
|
} unless id.nil?
|
|
198
213
|
end
|
|
199
214
|
|
|
215
|
+
# An error response for a request whose id could not be read carries no `id` member: JSON-RPC 2.0 puts
|
|
216
|
+
# a null there, but the MCP schema allows only a string or an integer, and the Streamable HTTP transport
|
|
217
|
+
# specification describes such a body as an error response that has no id.
|
|
200
218
|
def error_response(id:, id_validation_pattern:, error:)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
return if id.nil?
|
|
220
|
+
|
|
221
|
+
response = { jsonrpc: Version::V2_0 }
|
|
222
|
+
response[:id] = id if valid_id?(id, id_validation_pattern)
|
|
223
|
+
response[:error] = error.compact
|
|
224
|
+
response
|
|
206
225
|
end
|
|
207
226
|
end
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -120,7 +120,7 @@ module MCP
|
|
|
120
120
|
# the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
|
|
121
121
|
# and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
|
|
122
122
|
class SSEStream
|
|
123
|
-
attr_reader :buffer, :response, :last_event_id, :retry_ms
|
|
123
|
+
attr_reader :buffer, :response, :response_headers, :last_event_id, :retry_ms
|
|
124
124
|
attr_accessor :abortable
|
|
125
125
|
|
|
126
126
|
def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil)
|
|
@@ -132,6 +132,7 @@ module MCP
|
|
|
132
132
|
@response = nil
|
|
133
133
|
@last_event_id = nil
|
|
134
134
|
@retry_ms = nil
|
|
135
|
+
@response_headers = nil
|
|
135
136
|
end
|
|
136
137
|
|
|
137
138
|
# Whether the server sent a SEP-1699 priming event (any event carrying an id),
|
|
@@ -146,9 +147,10 @@ module MCP
|
|
|
146
147
|
# abort the stream so an over-limit tail cannot mask a response that already arrived.
|
|
147
148
|
def on_data
|
|
148
149
|
proc do |chunk, _received_bytes, env|
|
|
150
|
+
@response_headers ||= env.response_headers if env
|
|
149
151
|
if event_stream?(env)
|
|
150
152
|
feed(chunk)
|
|
151
|
-
raise StreamAbort if @abortable && @response
|
|
153
|
+
raise StreamAbort if @abortable && @response && !error_status?(env)
|
|
152
154
|
|
|
153
155
|
if parser_buffered_bytes > @max_message_bytes
|
|
154
156
|
raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing"
|
|
@@ -163,6 +165,16 @@ module MCP
|
|
|
163
165
|
end
|
|
164
166
|
end
|
|
165
167
|
|
|
168
|
+
# An error status must reach Faraday's `raise_error` middleware (which drives the
|
|
169
|
+
# OAuth and step-up retries in `send_request`), so the stream is read to EOF instead
|
|
170
|
+
# of aborting when the server answered with one. This is the behavior every
|
|
171
|
+
# method had before the abort shortcut. It is the only case that still needs
|
|
172
|
+
# the finished response object.
|
|
173
|
+
def error_status?(env)
|
|
174
|
+
status = env.status if env.respond_to?(:status)
|
|
175
|
+
status.is_a?(Integer) && status >= 400
|
|
176
|
+
end
|
|
177
|
+
|
|
166
178
|
# A fresh parser for a new HTTP connection (reconnection GET),
|
|
167
179
|
# so a partial line from the previous stream cannot corrupt the next one.
|
|
168
180
|
def reset_parser!
|
|
@@ -202,9 +214,10 @@ module MCP
|
|
|
202
214
|
|
|
203
215
|
if parsed.key?("result") || parsed.key?("error")
|
|
204
216
|
@response ||= parsed
|
|
205
|
-
elsif parsed["method"] && parsed.key?("id")
|
|
206
|
-
# A server-to-client request (e.g. `elicitation/create`) delivered
|
|
207
|
-
#
|
|
217
|
+
elsif parsed["method"] && parsed.key?("id") && JsonRpcHandler.valid_version?(parsed["jsonrpc"])
|
|
218
|
+
# A server-to-client request (e.g. `elicitation/create`) delivered on the stream while
|
|
219
|
+
# the original request is still pending. One that is not JSON-RPC 2.0 is dropped unanswered,
|
|
220
|
+
# as the TypeScript client drops it at schema validation.
|
|
208
221
|
@on_request&.call(parsed)
|
|
209
222
|
end
|
|
210
223
|
end
|
|
@@ -399,6 +412,7 @@ module MCP
|
|
|
399
412
|
)
|
|
400
413
|
end
|
|
401
414
|
|
|
415
|
+
request_id = request[:id] || request["id"]
|
|
402
416
|
method = request[:method] || request["method"]
|
|
403
417
|
params = request[:params] || request["params"]
|
|
404
418
|
oauth_retried = false
|
|
@@ -406,10 +420,13 @@ module MCP
|
|
|
406
420
|
|
|
407
421
|
begin
|
|
408
422
|
# The response is consumed incrementally so that an SSE stream the server holds open
|
|
409
|
-
# (or closes early per SEP-1699) can be handled
|
|
410
|
-
#
|
|
423
|
+
# (or closes early per SEP-1699) can be handled. Every request, `initialize` included,
|
|
424
|
+
# aborts once its response event arrives: `SSEStream` has already captured the response
|
|
425
|
+
# headers from the streaming `env` by then, so `Mcp-Session-Id` survives the abort.
|
|
426
|
+
# When `env` is unavailable (Faraday < 2.5 or an adapter without `on_data` support)
|
|
427
|
+
# nothing aborts and the stream is still read to EOF, as before.
|
|
411
428
|
stream = SSEStream.new(
|
|
412
|
-
abortable:
|
|
429
|
+
abortable: true,
|
|
413
430
|
max_message_bytes: @max_message_bytes,
|
|
414
431
|
on_request: ->(message) { dispatch_server_request(message) },
|
|
415
432
|
)
|
|
@@ -417,16 +434,22 @@ module MCP
|
|
|
417
434
|
yield if block_given?
|
|
418
435
|
|
|
419
436
|
response = begin
|
|
420
|
-
|
|
421
|
-
|
|
437
|
+
redacting_authorization_header do
|
|
438
|
+
client.post("", request, session_headers.merge(request_metadata_headers(method, params))) do |req|
|
|
439
|
+
req.options.on_data = stream.on_data
|
|
440
|
+
end
|
|
422
441
|
end
|
|
423
442
|
rescue StreamAbort
|
|
424
443
|
nil
|
|
425
444
|
end
|
|
426
445
|
|
|
427
|
-
body = resolve_response_body(stream, response, method, params)
|
|
446
|
+
body = resolve_response_body(stream, response, request_id, method, params)
|
|
447
|
+
|
|
448
|
+
# Every way a response arrives (JSON body, SSE event, resumed stream) ends here. Checked before
|
|
449
|
+
# anything is learned from the body, and only for a request: a notification awaits no response.
|
|
450
|
+
reject_invalid_response!(body, method, params) if request_id
|
|
428
451
|
|
|
429
|
-
capture_session_info(method, response, body)
|
|
452
|
+
capture_session_info(method, response ? response.headers : stream.response_headers, body)
|
|
430
453
|
capture_mcp_param_declarations(method, params, body)
|
|
431
454
|
|
|
432
455
|
body
|
|
@@ -522,7 +545,7 @@ module MCP
|
|
|
522
545
|
def send_notification(notification:)
|
|
523
546
|
method = notification[:method] || notification["method"]
|
|
524
547
|
|
|
525
|
-
client.post("", notification, session_headers)
|
|
548
|
+
redacting_authorization_header { client.post("", notification, session_headers) }
|
|
526
549
|
nil
|
|
527
550
|
rescue Faraday::Error => e
|
|
528
551
|
raise RequestHandlerError.new(
|
|
@@ -550,7 +573,7 @@ module MCP
|
|
|
550
573
|
end
|
|
551
574
|
|
|
552
575
|
begin
|
|
553
|
-
client.delete("", nil, session_headers)
|
|
576
|
+
redacting_authorization_header { client.delete("", nil, session_headers) }
|
|
554
577
|
rescue Faraday::ClientError => e
|
|
555
578
|
raise unless [404, 405].include?(e.response&.dig(:status))
|
|
556
579
|
ensure
|
|
@@ -955,11 +978,11 @@ module MCP
|
|
|
955
978
|
!(tokens["refresh_token"] || tokens[:refresh_token]).to_s.empty?
|
|
956
979
|
end
|
|
957
980
|
|
|
958
|
-
def capture_session_info(method,
|
|
981
|
+
def capture_session_info(method, headers, body)
|
|
959
982
|
return unless method.to_s == Methods::INITIALIZE
|
|
960
983
|
|
|
961
984
|
# Faraday normalizes header names to lowercase.
|
|
962
|
-
session_id =
|
|
985
|
+
session_id = headers[SESSION_ID_HEADER.downcase]
|
|
963
986
|
@session_id ||= session_id unless session_id.to_s.empty?
|
|
964
987
|
@protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil
|
|
965
988
|
end
|
|
@@ -1045,6 +1068,51 @@ module MCP
|
|
|
1045
1068
|
[deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0.001].max
|
|
1046
1069
|
end
|
|
1047
1070
|
|
|
1071
|
+
# Runs a request on the transport's connection and, when Faraday raises, replaces the value of the `Authorization` header
|
|
1072
|
+
# in the request headers the exception retains before it leaves the transport.
|
|
1073
|
+
# Faraday's `raise_error` middleware keeps a reference to `env.request_headers` in `Faraday::Error#response`
|
|
1074
|
+
# under `:request`, so the exception that becomes `RequestHandlerError#original_error` (and its `cause`) would otherwise print
|
|
1075
|
+
# the bearer token through `inspect`, which is what error reporters and log lines do with it. The request is complete by
|
|
1076
|
+
# the time the exception surfaces, and every request builds its own headers from the connection's defaults,
|
|
1077
|
+
# so the rewrite reaches neither those defaults nor a later request.
|
|
1078
|
+
def redacting_authorization_header
|
|
1079
|
+
yield
|
|
1080
|
+
rescue Faraday::Error => e
|
|
1081
|
+
redact_authorization_header!(e)
|
|
1082
|
+
raise
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# `raise_error` retains the request as a Hash under `:request`; a JSON middleware the customizer added raises
|
|
1086
|
+
# `Faraday::ParsingError` with the `Faraday::Response` itself, whose `env` holds the request headers.
|
|
1087
|
+
def redact_authorization_header!(error)
|
|
1088
|
+
response = error.response
|
|
1089
|
+
|
|
1090
|
+
if response.is_a?(Hash)
|
|
1091
|
+
request = response[:request]
|
|
1092
|
+
redact_authorization_header_in!(request, :headers) if request.is_a?(Hash)
|
|
1093
|
+
elsif response.respond_to?(:env) && response.env.respond_to?(:request_headers)
|
|
1094
|
+
redact_authorization_header_in!(response.env, :request_headers)
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# The headers are a `Faraday::Utils::Headers`, which spells the name `Authorization` whatever the caller wrote,
|
|
1099
|
+
# unless a customizer middleware replaced them with a plain Hash or froze them: the name is matched regardless
|
|
1100
|
+
# of case so such a Hash is covered too, and a frozen object is swapped for a copy, so the redaction never
|
|
1101
|
+
# raises in place of the error it is redacting.
|
|
1102
|
+
def redact_authorization_header_in!(holder, key)
|
|
1103
|
+
headers = holder[key]
|
|
1104
|
+
return unless headers.is_a?(Hash)
|
|
1105
|
+
|
|
1106
|
+
names = headers.each_key.select { |name| name.to_s.casecmp?("authorization") }
|
|
1107
|
+
return if names.empty?
|
|
1108
|
+
|
|
1109
|
+
headers = holder[key] = headers.dup if headers.frozen?
|
|
1110
|
+
|
|
1111
|
+
names.each do |name|
|
|
1112
|
+
headers[name] = "[redacted]"
|
|
1113
|
+
end
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1048
1116
|
def require_faraday!
|
|
1049
1117
|
require "faraday"
|
|
1050
1118
|
rescue LoadError
|
|
@@ -1054,8 +1122,11 @@ module MCP
|
|
|
1054
1122
|
end
|
|
1055
1123
|
|
|
1056
1124
|
# Determines the logical JSON-RPC body of the exchange after the POST stream finished
|
|
1057
|
-
# (or was aborted because the response already arrived).
|
|
1058
|
-
|
|
1125
|
+
# (or was aborted because the response already arrived). A notification (no `request_id`) awaits
|
|
1126
|
+
# no response, so any success status acknowledges it, as it does for the TypeScript and Python clients,
|
|
1127
|
+
# which check the content type of a response only for a request; the spec has the server answer
|
|
1128
|
+
# a notification with 202, but a server answering with another 2xx is accepted as well.
|
|
1129
|
+
def resolve_response_body(stream, response, request_id, method, params)
|
|
1059
1130
|
return stream.response if stream.response
|
|
1060
1131
|
|
|
1061
1132
|
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
|
|
@@ -1073,6 +1144,9 @@ module MCP
|
|
|
1073
1144
|
return stream.response if stream.response
|
|
1074
1145
|
end
|
|
1075
1146
|
|
|
1147
|
+
# A stream that ends without a response has acknowledged a notification; there is nothing to resume for.
|
|
1148
|
+
return if request_id.nil? && response.success?
|
|
1149
|
+
|
|
1076
1150
|
# SEP-1699: a graceful close after a priming event (an event with an id) but before
|
|
1077
1151
|
# the response means "reconnect via GET to resume".
|
|
1078
1152
|
return await_response_after_disconnect(stream, method, params) if stream.primed?
|
|
@@ -1089,6 +1163,8 @@ module MCP
|
|
|
1089
1163
|
# added by the Faraday customizer may have parsed it already.
|
|
1090
1164
|
response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body
|
|
1091
1165
|
else
|
|
1166
|
+
return if request_id.nil? && response.success?
|
|
1167
|
+
|
|
1092
1168
|
raise RequestHandlerError.new(
|
|
1093
1169
|
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
|
|
1094
1170
|
{ method: method, params: params },
|
|
@@ -1157,7 +1233,7 @@ module MCP
|
|
|
1157
1233
|
end
|
|
1158
1234
|
|
|
1159
1235
|
def send_client_response(response)
|
|
1160
|
-
client.post("", response, session_headers)
|
|
1236
|
+
redacting_authorization_header { client.post("", response, session_headers) }
|
|
1161
1237
|
end
|
|
1162
1238
|
|
|
1163
1239
|
def parse_json_buffer(buffer, method, params)
|
|
@@ -1172,6 +1248,20 @@ module MCP
|
|
|
1172
1248
|
)
|
|
1173
1249
|
end
|
|
1174
1250
|
|
|
1251
|
+
# A response is a JSON object whose `jsonrpc` is exactly "2.0". No body at all (202, or an empty 200)
|
|
1252
|
+
# is not a message and is left to the caller as before; a JSON `null` body parses to the same `nil`.
|
|
1253
|
+
# The received value is left out of the message because it can be any JSON value.
|
|
1254
|
+
def reject_invalid_response!(body, method, params)
|
|
1255
|
+
return if body.nil?
|
|
1256
|
+
return if body.is_a?(Hash) && JsonRpcHandler.valid_version?(body["jsonrpc"])
|
|
1257
|
+
|
|
1258
|
+
raise RequestHandlerError.new(
|
|
1259
|
+
'Server response is not a valid JSON-RPC 2.0 message: "jsonrpc" must be "2.0"',
|
|
1260
|
+
{ method: method, params: params },
|
|
1261
|
+
error_type: :parse_error,
|
|
1262
|
+
)
|
|
1263
|
+
end
|
|
1264
|
+
|
|
1175
1265
|
# SEP-1699 resumability: the server closed the SSE stream after a priming event
|
|
1176
1266
|
# without delivering the response. Treat the graceful close like a network failure:
|
|
1177
1267
|
# wait the `retry:` interval the server asked for (default 1000ms), then reconnect with
|
|
@@ -1206,12 +1296,14 @@ module MCP
|
|
|
1206
1296
|
read_timeout = remaining_reconnection_budget(deadline)
|
|
1207
1297
|
|
|
1208
1298
|
reconnect_response = begin
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1299
|
+
redacting_authorization_header do
|
|
1300
|
+
client.get("") do |req|
|
|
1301
|
+
req.headers.update(session_headers)
|
|
1302
|
+
req.headers["Accept"] = SSE_ACCEPT_HEADER
|
|
1303
|
+
req.headers[LAST_EVENT_ID_HEADER] = stream.last_event_id if stream.last_event_id
|
|
1304
|
+
req.options.read_timeout = read_timeout
|
|
1305
|
+
req.options.on_data = stream.on_data
|
|
1306
|
+
end
|
|
1215
1307
|
end
|
|
1216
1308
|
rescue StreamAbort
|
|
1217
1309
|
# 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
|