mcp 0.24.0 → 0.25.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 +79 -0
- data/lib/json_rpc_handler.rb +1 -1
- data/lib/mcp/client/http.rb +59 -3
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +80 -0
- data/lib/mcp/client/oauth/discovery.rb +1 -1
- data/lib/mcp/client/oauth/flow.rb +34 -1
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +101 -0
- data/lib/mcp/client/oauth.rb +4 -1
- data/lib/mcp/client.rb +38 -0
- data/lib/mcp/configuration.rb +1 -1
- data/lib/mcp/icon.rb +1 -1
- data/lib/mcp/instrumentation.rb +0 -2
- data/lib/mcp/resource_template.rb +1 -1
- data/lib/mcp/server/transports/streamable_http_transport.rb +25 -9
- data/lib/mcp/server.rb +1 -3
- data/lib/mcp/version.rb +1 -1
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 829c2bc3c3644f2a7edcf5fd44b96d87887c37da3a4cf4766eed79a2676425ee
|
|
4
|
+
data.tar.gz: 1a168f15a16ad704db47e8c8c2aa7be3f4f869af2a5199fa54d44b4a05990aa0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e6ca51112faf60a4a2b9f5be2db20f9fe25b6581ca327708f483c03f021b9a68ce8df8c3342e5430132ac670103a26d4ac2f9617e3e0358e074745d6fa4c7743
|
|
7
|
+
data.tar.gz: a57eea28ea7e173422e855702789780381032e888bed42eb42900251e05f8a2806df4220c50c0727423d6c5a8e9124127cdc53e9e9bf34170e6a7c2afa058e7a
|
data/README.md
CHANGED
|
@@ -2318,6 +2318,10 @@ response = client.call_tool(
|
|
|
2318
2318
|
|
|
2319
2319
|
The server will send `notifications/progress` back to the client during execution.
|
|
2320
2320
|
|
|
2321
|
+
`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server -
|
|
2322
|
+
an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from
|
|
2323
|
+
a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses.
|
|
2324
|
+
|
|
2321
2325
|
#### Server-to-Client Requests (Elicitation)
|
|
2322
2326
|
|
|
2323
2327
|
Servers can send requests back to the client while one of the client's own requests is in flight - for example,
|
|
@@ -2342,6 +2346,44 @@ since servers deliver requests that are not tied to a client request on that str
|
|
|
2342
2346
|
a JSON-RPC `-32601` (method not found) error. To handle methods other than `elicitation/create`, register directly on the transport with
|
|
2343
2347
|
`http_transport.on_server_request("method/name") { |params| ... }`.
|
|
2344
2348
|
|
|
2349
|
+
#### Server-to-Client Requests (Sampling)
|
|
2350
|
+
|
|
2351
|
+
Servers can also request an LLM completion from the client with [`sampling/createMessage`](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling),
|
|
2352
|
+
letting a server leverage the client's model access without its own API keys.
|
|
2353
|
+
|
|
2354
|
+
> MCP Sampling is deprecated as of protocol version `2026-07-28` (SEP-2577), while remaining fully supported under `2025-11-25`.
|
|
2355
|
+
> Register this handler to interoperate with servers that still send sampling requests during the deprecation window;
|
|
2356
|
+
> new servers should call LLM provider APIs directly.
|
|
2357
|
+
|
|
2358
|
+
Register a handler and advertise the capability on `connect`:
|
|
2359
|
+
|
|
2360
|
+
```ruby
|
|
2361
|
+
client.connect(capabilities: { sampling: {} })
|
|
2362
|
+
|
|
2363
|
+
client.on_sampling do |params|
|
|
2364
|
+
completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
|
|
2365
|
+
{
|
|
2366
|
+
role: "assistant",
|
|
2367
|
+
content: { type: "text", text: completion.text },
|
|
2368
|
+
model: completion.model,
|
|
2369
|
+
stopReason: "endTurn",
|
|
2370
|
+
}
|
|
2371
|
+
end
|
|
2372
|
+
```
|
|
2373
|
+
|
|
2374
|
+
For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response.
|
|
2375
|
+
To reject a request, raise `MCP::Client::ServerRequestError` with the spec's user-rejection code `-1`:
|
|
2376
|
+
|
|
2377
|
+
```ruby
|
|
2378
|
+
client.on_sampling do |params|
|
|
2379
|
+
raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
|
|
2380
|
+
|
|
2381
|
+
generate_completion(params)
|
|
2382
|
+
end
|
|
2383
|
+
```
|
|
2384
|
+
|
|
2385
|
+
Use `capabilities: { sampling: { tools: {} } }` to receive tool-enabled sampling requests. Like elicitation, this uses the same standalone GET SSE listening stream.
|
|
2386
|
+
|
|
2345
2387
|
#### HTTP Authorization
|
|
2346
2388
|
|
|
2347
2389
|
By default, the HTTP transport layer provides no authentication to the server, but you can provide custom headers if you need authentication. For example, to use Bearer token authentication:
|
|
@@ -2505,6 +2547,43 @@ Keyword arguments:
|
|
|
2505
2547
|
- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
|
|
2506
2548
|
- `scope`, `storage`: Optional, same meaning as on `Provider`.
|
|
2507
2549
|
|
|
2550
|
+
##### Cross-App Access (JWT Bearer) Grant
|
|
2551
|
+
|
|
2552
|
+
For enterprise MCP deployments where an identity provider (IdP) governs authorization (SEP-990), use `MCP::Client::OAuth::CrossAppAccessProvider` instead of `Provider`.
|
|
2553
|
+
The client exchanges an IdP-issued ID token for an Identity Assertion Authorization Grant (ID-JAG) at the IdP via RFC 8693 token exchange, then presents the ID-JAG
|
|
2554
|
+
to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with `client_secret_basic`. There is no authorization request, PKCE, DCR, or `offline_access`.
|
|
2555
|
+
Mirrors `CrossAppAccessProvider` and `requestJwtAuthorizationGrant` in the TypeScript SDK.
|
|
2556
|
+
|
|
2557
|
+
`MCP::Client::OAuth::IDJAGTokenExchange.request` performs the RFC 8693 exchange at the IdP token endpoint. Wrap it in a callable so the same provider can plug into
|
|
2558
|
+
an enterprise secret store or a test double without changing the transport wiring.
|
|
2559
|
+
|
|
2560
|
+
```ruby
|
|
2561
|
+
provider = MCP::Client::OAuth::CrossAppAccessProvider.new(
|
|
2562
|
+
client_id: "my-mcp-client",
|
|
2563
|
+
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
|
2564
|
+
assertion_provider: ->(audience:, resource:) {
|
|
2565
|
+
MCP::Client::OAuth::IDJAGTokenExchange.request(
|
|
2566
|
+
token_endpoint: "https://idp.example.com/token",
|
|
2567
|
+
id_token: ENV.fetch("IDP_ID_TOKEN"),
|
|
2568
|
+
client_id: "my-idp-client",
|
|
2569
|
+
audience: audience,
|
|
2570
|
+
resource: resource,
|
|
2571
|
+
)
|
|
2572
|
+
},
|
|
2573
|
+
# scope: "mcp:read mcp:write" (optional; used when neither WWW-Authenticate nor PRM specify one)
|
|
2574
|
+
)
|
|
2575
|
+
|
|
2576
|
+
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
|
2577
|
+
```
|
|
2578
|
+
|
|
2579
|
+
Keyword arguments:
|
|
2580
|
+
|
|
2581
|
+
- `client_id`, `client_secret`: Required. The `jwt-bearer` grant authenticates with `client_secret_basic` at the MCP authorization server.
|
|
2582
|
+
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
|
|
2583
|
+
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
|
|
2584
|
+
Passing both through to `IDJAGTokenExchange.request` covers the common case.
|
|
2585
|
+
- `scope`, `storage`: Optional, same meaning as on `Provider`.
|
|
2586
|
+
|
|
2508
2587
|
##### Communication Security
|
|
2509
2588
|
|
|
2510
2589
|
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
|
data/lib/json_rpc_handler.rb
CHANGED
|
@@ -16,7 +16,7 @@ module JsonRpcHandler
|
|
|
16
16
|
PARSE_ERROR = -32700
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z
|
|
19
|
+
DEFAULT_ALLOWED_ID_CHARACTERS = /\A[a-zA-Z0-9_-]+\z/.freeze
|
|
20
20
|
|
|
21
21
|
# Sentinel return value from a handler. When a handler returns this,
|
|
22
22
|
# `process_request` emits no JSON-RPC response for the request,
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -30,6 +30,13 @@ module MCP
|
|
|
30
30
|
# (60 seconds for Net::HTTP) would recycle quiet streams too eagerly.
|
|
31
31
|
SSE_LISTENER_READ_TIMEOUT = 300
|
|
32
32
|
|
|
33
|
+
# Upper bound in bytes on a single JSON-RPC message from the server - an SSE event or
|
|
34
|
+
# a JSON response body - buffered in memory while reading a response. Without a bound,
|
|
35
|
+
# a server that never terminates an SSE event (or never ends a JSON body) grows
|
|
36
|
+
# the buffer indefinitely. Matches the 4 MiB default of `MCP::Client::Stdio::MAX_LINE_BYTES`
|
|
37
|
+
# and the server transports' request cap.
|
|
38
|
+
MAX_MESSAGE_BYTES = 4 * 1024 * 1024
|
|
39
|
+
|
|
33
40
|
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
|
|
34
41
|
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
|
35
42
|
# is trivially observed and stolen.
|
|
@@ -71,6 +78,12 @@ module MCP
|
|
|
71
78
|
class StreamAbort < StandardError; end
|
|
72
79
|
private_constant :StreamAbort
|
|
73
80
|
|
|
81
|
+
# Raised inside the streaming callback when the server sends more bytes than
|
|
82
|
+
# `max_message_bytes` without completing a message. Translated into
|
|
83
|
+
# a `RequestHandlerError` (or a listener stop) where the request context is known.
|
|
84
|
+
class MessageTooLargeError < StandardError; end
|
|
85
|
+
private_constant :MessageTooLargeError
|
|
86
|
+
|
|
74
87
|
# Per-exchange SSE state shared between the initial POST stream and any SEP-1699 reconnection GET streams:
|
|
75
88
|
# the incrementally parsed JSON-RPC response, the last received SSE event id (for `Last-Event-ID`),
|
|
76
89
|
# and the server's `retry:` reconnection delay. Non-SSE bodies accumulate in `buffer` for the JSON path.
|
|
@@ -78,8 +91,9 @@ module MCP
|
|
|
78
91
|
attr_reader :buffer, :response, :last_event_id, :retry_ms
|
|
79
92
|
attr_accessor :abortable
|
|
80
93
|
|
|
81
|
-
def initialize(abortable:, on_request: nil)
|
|
94
|
+
def initialize(abortable:, max_message_bytes: MAX_MESSAGE_BYTES, on_request: nil)
|
|
82
95
|
@abortable = abortable
|
|
96
|
+
@max_message_bytes = max_message_bytes
|
|
83
97
|
@on_request = on_request
|
|
84
98
|
@buffer = +""
|
|
85
99
|
@parser = nil
|
|
@@ -95,14 +109,24 @@ module MCP
|
|
|
95
109
|
end
|
|
96
110
|
|
|
97
111
|
# Faraday `on_data` streaming callback. SSE chunks are parsed incrementally;
|
|
98
|
-
# anything else (JSON bodies) accumulates in `buffer`.
|
|
112
|
+
# anything else (JSON bodies) accumulates in `buffer`. Both paths are bounded
|
|
113
|
+
# by `max_message_bytes`, checked after the awaited response had its chance to
|
|
114
|
+
# abort the stream so an over-limit tail cannot mask a response that already arrived.
|
|
99
115
|
def on_data
|
|
100
116
|
proc do |chunk, _received_bytes, env|
|
|
101
117
|
if event_stream?(env)
|
|
102
118
|
feed(chunk)
|
|
103
119
|
raise StreamAbort if @abortable && @response
|
|
120
|
+
|
|
121
|
+
if parser_buffered_bytes > @max_message_bytes
|
|
122
|
+
raise MessageTooLargeError, "Server SSE event exceeds #{@max_message_bytes} bytes without completing"
|
|
123
|
+
end
|
|
104
124
|
else
|
|
105
125
|
@buffer << chunk
|
|
126
|
+
|
|
127
|
+
if @buffer.bytesize > @max_message_bytes
|
|
128
|
+
raise MessageTooLargeError, "Server response body exceeds #{@max_message_bytes} bytes"
|
|
129
|
+
end
|
|
106
130
|
end
|
|
107
131
|
end
|
|
108
132
|
end
|
|
@@ -154,6 +178,20 @@ module MCP
|
|
|
154
178
|
end
|
|
155
179
|
end
|
|
156
180
|
|
|
181
|
+
# Bytes the parser has buffered for a not-yet-dispatched event: the partial line
|
|
182
|
+
# and the accumulated `data:` field values (plus the small event type and last event id buffers).
|
|
183
|
+
# External byte counting cannot tell consumed-and-discarded bytes (comments, field names, delimiters)
|
|
184
|
+
# from consumed-and-retained ones, so the parser's own String buffers are measured instead;
|
|
185
|
+
# summing every String instance variable keeps the measurement valid if the parser renames or adds buffers.
|
|
186
|
+
# A parser rewrite that buffered elsewhere would make this return 0 and quietly lift the cap -
|
|
187
|
+
# the regression tests feeding an over-limit event guard against that.
|
|
188
|
+
def parser_buffered_bytes
|
|
189
|
+
parser.instance_variables.sum do |name|
|
|
190
|
+
value = parser.instance_variable_get(name)
|
|
191
|
+
value.is_a?(String) ? value.bytesize : 0
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
157
195
|
def parser
|
|
158
196
|
@parser ||= begin
|
|
159
197
|
require "event_stream_parser"
|
|
@@ -169,7 +207,13 @@ module MCP
|
|
|
169
207
|
|
|
170
208
|
attr_reader :url, :session_id, :protocol_version, :server_info, :oauth
|
|
171
209
|
|
|
172
|
-
def initialize(url:, headers: {}, oauth: nil, &block)
|
|
210
|
+
def initialize(url:, headers: {}, oauth: nil, max_message_bytes: MAX_MESSAGE_BYTES, &block)
|
|
211
|
+
# `nil` or a non-positive value would make the buffering unbounded and silently
|
|
212
|
+
# disable the protection, so reject it up front.
|
|
213
|
+
unless max_message_bytes.is_a?(Integer) && max_message_bytes > 0
|
|
214
|
+
raise ArgumentError, "max_message_bytes must be a positive Integer"
|
|
215
|
+
end
|
|
216
|
+
|
|
173
217
|
if oauth && !MCP::Client::OAuth::Discovery.secure_url?(url)
|
|
174
218
|
# Mask credentials (userinfo) and query parameters before quoting the URL in the error message
|
|
175
219
|
# so they cannot leak into logs.
|
|
@@ -183,6 +227,7 @@ module MCP
|
|
|
183
227
|
@headers = headers
|
|
184
228
|
@faraday_customizer = block
|
|
185
229
|
@oauth = oauth
|
|
230
|
+
@max_message_bytes = max_message_bytes
|
|
186
231
|
# Snapshot the canonical URL at construction time. This single value
|
|
187
232
|
# serves two related roles, both of which need to see the query string:
|
|
188
233
|
#
|
|
@@ -336,6 +381,7 @@ module MCP
|
|
|
336
381
|
# so the response object (and its `Mcp-Session-Id` header) is always available for capture.
|
|
337
382
|
stream = SSEStream.new(
|
|
338
383
|
abortable: method.to_s != MCP::Methods::INITIALIZE,
|
|
384
|
+
max_message_bytes: @max_message_bytes,
|
|
339
385
|
on_request: ->(message) { dispatch_server_request(message) },
|
|
340
386
|
)
|
|
341
387
|
|
|
@@ -354,6 +400,12 @@ module MCP
|
|
|
354
400
|
capture_session_info(method, response, body) if response
|
|
355
401
|
|
|
356
402
|
body
|
|
403
|
+
rescue MessageTooLargeError => e
|
|
404
|
+
raise RequestHandlerError.new(
|
|
405
|
+
e.message,
|
|
406
|
+
{ method: method, params: params },
|
|
407
|
+
error_type: :internal_error,
|
|
408
|
+
)
|
|
357
409
|
rescue Faraday::BadRequestError => e
|
|
358
410
|
raise RequestHandlerError.new(
|
|
359
411
|
"The #{method} request is invalid",
|
|
@@ -699,6 +751,7 @@ module MCP
|
|
|
699
751
|
def listen_for_server_requests
|
|
700
752
|
stream = SSEStream.new(
|
|
701
753
|
abortable: false,
|
|
754
|
+
max_message_bytes: @max_message_bytes,
|
|
702
755
|
on_request: ->(message) { dispatch_server_request(message) },
|
|
703
756
|
)
|
|
704
757
|
consecutive_failures = 0
|
|
@@ -714,6 +767,9 @@ module MCP
|
|
|
714
767
|
end
|
|
715
768
|
|
|
716
769
|
consecutive_failures = 0
|
|
770
|
+
rescue MessageTooLargeError
|
|
771
|
+
# Reconnecting would let the server stream the same over-limit event again, so stop listening instead of retrying.
|
|
772
|
+
break
|
|
717
773
|
rescue Faraday::Error => e
|
|
718
774
|
break if e.response&.dig(:status) == 405
|
|
719
775
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
module OAuth
|
|
6
|
+
# OAuth client configuration for the MCP Enterprise Managed Authorization extension (SEP-990, "Cross-App Access"):
|
|
7
|
+
# the client obtains an Identity Assertion Authorization Grant (ID-JAG) from an enterprise identity provider and
|
|
8
|
+
# presents it to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with
|
|
9
|
+
# `client_secret_basic`. Handed to `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
|
|
10
|
+
#
|
|
11
|
+
# Mirrors `CrossAppAccessProvider` in the TypeScript SDK: the assertion is supplied by a callable so it can come
|
|
12
|
+
# from `IDJAGTokenExchange` (the common case), an enterprise secret store, or a test double.
|
|
13
|
+
#
|
|
14
|
+
# Required keyword arguments:
|
|
15
|
+
#
|
|
16
|
+
# - `client_id` - String identifying the pre-registered confidential client at the MCP authorization server.
|
|
17
|
+
# - `client_secret` - String shared secret for `client_secret_basic`.
|
|
18
|
+
# - `assertion_provider` - Callable invoked as `call(audience:, resource:)`, returning the ID-JAG assertion to
|
|
19
|
+
# present. `audience` is the authorization server's issuer identifier and `resource` is the canonical MCP server URL;
|
|
20
|
+
# pass both through to `IDJAGTokenExchange.request` when exchanging an IdP ID token.
|
|
21
|
+
#
|
|
22
|
+
# Optional keyword arguments:
|
|
23
|
+
#
|
|
24
|
+
# - `scope` - String of space-separated scopes to request when the server's `WWW-Authenticate` and
|
|
25
|
+
# the Protected Resource Metadata do not specify one.
|
|
26
|
+
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
|
|
27
|
+
# Defaults to an `InMemoryStorage`.
|
|
28
|
+
#
|
|
29
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
|
30
|
+
class CrossAppAccessProvider
|
|
31
|
+
include StorageBackedProvider
|
|
32
|
+
|
|
33
|
+
# Raised when the provider is constructed without the pieces the `jwt-bearer` grant needs.
|
|
34
|
+
class InvalidConfigurationError < ArgumentError; end
|
|
35
|
+
|
|
36
|
+
attr_reader :scope, :storage
|
|
37
|
+
|
|
38
|
+
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil)
|
|
39
|
+
if blank?(client_id)
|
|
40
|
+
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if blank?(client_secret)
|
|
44
|
+
raise InvalidConfigurationError, "client_secret is required: SEP-990 authenticates the jwt-bearer grant with client_secret_basic."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless assertion_provider.respond_to?(:call)
|
|
48
|
+
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@assertion_provider = assertion_provider
|
|
52
|
+
@scope = scope
|
|
53
|
+
@storage = storage || InMemoryStorage.new
|
|
54
|
+
@storage.save_client_information(
|
|
55
|
+
"client_id" => client_id,
|
|
56
|
+
"client_secret" => client_secret,
|
|
57
|
+
"token_endpoint_auth_method" => "client_secret_basic",
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# See `Provider#authorization_flow`.
|
|
62
|
+
def authorization_flow
|
|
63
|
+
:jwt_bearer
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the ID-JAG assertion to present at the MCP authorization server. Called by `Flow#run_jwt_bearer!` with the audience
|
|
67
|
+
# and resource resolved during discovery.
|
|
68
|
+
def jwt_bearer_assertion(audience:, resource:)
|
|
69
|
+
@assertion_provider.call(audience: audience, resource: resource)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def blank?(value)
|
|
75
|
+
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -37,7 +37,7 @@ module MCP
|
|
|
37
37
|
# Matches a single `key=value` pair inside an HTTP auth-scheme challenge.
|
|
38
38
|
# `value` is either a quoted string (which can contain commas and spaces)
|
|
39
39
|
# or a bare token, per RFC 7235.
|
|
40
|
-
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))
|
|
40
|
+
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
|
|
41
41
|
|
|
42
42
|
class << self
|
|
43
43
|
# Parses a `WWW-Authenticate` header and returns the parameters of
|
|
@@ -51,8 +51,11 @@ module MCP
|
|
|
51
51
|
|
|
52
52
|
as_metadata = authorization_server_metadata(authorization_server: authorization_server, legacy: prm.nil?)
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
case provider_authorization_flow
|
|
55
|
+
when :client_credentials
|
|
55
56
|
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
57
|
+
when :jwt_bearer
|
|
58
|
+
return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
ensure_pkce_supported!(as_metadata)
|
|
@@ -154,6 +157,36 @@ module MCP
|
|
|
154
157
|
"refusing to send them to the current authorization server (SEP-2352)."
|
|
155
158
|
end
|
|
156
159
|
|
|
160
|
+
# Runs the RFC 7523 `jwt-bearer` grant for the SEP-990 Enterprise Managed Authorization extension:
|
|
161
|
+
# the provider supplies an ID-JAG assertion (typically obtained from an enterprise IdP via `IDJAGTokenExchange`),
|
|
162
|
+
# which is presented at the token endpoint with `client_secret_basic` authentication. Shares the same discovery
|
|
163
|
+
# and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
|
|
164
|
+
# The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
|
|
165
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
|
166
|
+
def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
|
|
167
|
+
client_info = @provider.client_information
|
|
168
|
+
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
|
|
169
|
+
raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
|
|
173
|
+
if assertion.nil? || assertion.to_s.empty?
|
|
174
|
+
raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
form = {
|
|
178
|
+
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
179
|
+
"assertion" => assertion,
|
|
180
|
+
}
|
|
181
|
+
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
182
|
+
form["scope"] = effective_scope if effective_scope
|
|
183
|
+
form["resource"] = resource if resource
|
|
184
|
+
|
|
185
|
+
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
186
|
+
@provider.save_tokens(tokens)
|
|
187
|
+
:authorized
|
|
188
|
+
end
|
|
189
|
+
|
|
157
190
|
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
|
|
158
191
|
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
|
|
159
192
|
# checks before talking to it.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module MCP
|
|
7
|
+
class Client
|
|
8
|
+
module OAuth
|
|
9
|
+
# RFC 8693 token exchange against an enterprise identity provider, turning an IdP-issued ID token into
|
|
10
|
+
# an Identity Assertion Authorization Grant (ID-JAG) per the MCP Enterprise Managed Authorization extension (SEP-990).
|
|
11
|
+
# The returned ID-JAG is an opaque assertion the client then presents to the MCP authorization server with
|
|
12
|
+
# the RFC 7523 `jwt-bearer` grant (see `CrossAppAccessProvider`).
|
|
13
|
+
# Mirrors `requestJwtAuthorizationGrant` in the TypeScript SDK.
|
|
14
|
+
#
|
|
15
|
+
# - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
|
16
|
+
# - https://www.rfc-editor.org/rfc/rfc8693
|
|
17
|
+
module IDJAGTokenExchange
|
|
18
|
+
# Raised when the identity provider's token exchange fails or returns something other than an ID-JAG.
|
|
19
|
+
class ExchangeError < StandardError; end
|
|
20
|
+
|
|
21
|
+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
22
|
+
ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
|
|
23
|
+
ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Exchanges `id_token` for an ID-JAG at the IdP's token endpoint and returns the assertion string.
|
|
27
|
+
#
|
|
28
|
+
# @param token_endpoint [String] The identity provider's token endpoint.
|
|
29
|
+
# @param id_token [String] The IdP-issued ID token (the subject token).
|
|
30
|
+
# @param client_id [String] The client's identifier at the IdP.
|
|
31
|
+
# @param audience [String] The MCP authorization server's issuer identifier.
|
|
32
|
+
# @param resource [String] The canonical MCP server URL (RFC 8707).
|
|
33
|
+
# @param http_client [Object, nil] Faraday-compatible client; built lazily by default.
|
|
34
|
+
def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
|
|
35
|
+
http_client ||= default_http_client
|
|
36
|
+
|
|
37
|
+
response = begin
|
|
38
|
+
http_client.post(token_endpoint) do |req|
|
|
39
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
40
|
+
req.headers["Accept"] = "application/json"
|
|
41
|
+
req.body = URI.encode_www_form(
|
|
42
|
+
"grant_type" => GRANT_TYPE,
|
|
43
|
+
"subject_token" => id_token,
|
|
44
|
+
"subject_token_type" => ID_TOKEN_TYPE,
|
|
45
|
+
"requested_token_type" => ID_JAG_TOKEN_TYPE,
|
|
46
|
+
"audience" => audience,
|
|
47
|
+
"resource" => resource,
|
|
48
|
+
"client_id" => client_id,
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
rescue Faraday::Error => e
|
|
52
|
+
raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if response.status < 200 || response.status >= 300
|
|
56
|
+
raise ExchangeError, "Identity provider token exchange returned status #{response.status}."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
parse_id_jag(response)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def parse_id_jag(response)
|
|
65
|
+
body = response.body.is_a?(String) ? response.body : response.body.to_s
|
|
66
|
+
parsed = begin
|
|
67
|
+
JSON.parse(body)
|
|
68
|
+
rescue JSON::ParserError => e
|
|
69
|
+
raise ExchangeError, "Failed to parse token exchange response: #{e.message}."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless parsed.is_a?(Hash)
|
|
73
|
+
raise ExchangeError, "Token exchange response is not a JSON object (got #{parsed.class})."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
issued_token_type = parsed["issued_token_type"]
|
|
77
|
+
unless issued_token_type == ID_JAG_TOKEN_TYPE
|
|
78
|
+
raise ExchangeError,
|
|
79
|
+
"Token exchange did not issue an ID-JAG " \
|
|
80
|
+
"(expected issued_token_type #{ID_JAG_TOKEN_TYPE.inspect}, got #{issued_token_type.inspect})."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
assertion = parsed["access_token"]
|
|
84
|
+
if assertion.nil? || assertion.to_s.empty?
|
|
85
|
+
raise ExchangeError, "Token exchange response is missing `access_token`."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
assertion
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def default_http_client
|
|
92
|
+
require "faraday"
|
|
93
|
+
Faraday.new do |faraday|
|
|
94
|
+
faraday.headers["Accept"] = "application/json"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/mcp/client/oauth.rb
CHANGED
|
@@ -8,12 +8,15 @@ require_relative "oauth/storage_backed_provider"
|
|
|
8
8
|
require_relative "oauth/jwt_client_assertion"
|
|
9
9
|
require_relative "oauth/provider"
|
|
10
10
|
require_relative "oauth/client_credentials_provider"
|
|
11
|
+
require_relative "oauth/id_jag_token_exchange"
|
|
12
|
+
require_relative "oauth/cross_app_access_provider"
|
|
11
13
|
|
|
12
14
|
module MCP
|
|
13
15
|
class Client
|
|
14
16
|
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
|
15
17
|
# Authorization Server metadata discovery, Dynamic Client Registration,
|
|
16
|
-
# OAuth 2.1 Authorization Code + PKCE,
|
|
18
|
+
# OAuth 2.1 Authorization Code + PKCE, the client_credentials grant,
|
|
19
|
+
# and the SEP-990 Enterprise Managed Authorization jwt-bearer grant).
|
|
17
20
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
18
21
|
module OAuth
|
|
19
22
|
end
|
data/lib/mcp/client.rb
CHANGED
|
@@ -423,6 +423,44 @@ module MCP
|
|
|
423
423
|
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
|
|
424
424
|
end
|
|
425
425
|
|
|
426
|
+
# Registers a handler for `sampling/createMessage` requests the server sends while one of this client's requests is in flight.
|
|
427
|
+
# The handler receives the request `params` (`messages`, `maxTokens`, optionally `systemPrompt`, `modelPreferences`, `tools`,
|
|
428
|
+
# `toolChoice`, ...; string keys) and must return a `CreateMessageResult`-shaped Hash:
|
|
429
|
+
# `{ role: "assistant", content: { type: "text", text: "..." }, model: "...", stopReason: "..." }`.
|
|
430
|
+
#
|
|
431
|
+
# For trust and safety, the spec recommends a human in the loop able to review, edit, or reject the request and the generated response
|
|
432
|
+
# before it is returned to the server. To reject, raise `ServerRequestError` with the spec's user-rejection code `-1`.
|
|
433
|
+
#
|
|
434
|
+
# Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`); pass `capabilities: { sampling: {} }` to
|
|
435
|
+
# `connect` (or `{ sampling: { tools: {} } }` to receive tool-enabled sampling requests) so the server knows it may send them.
|
|
436
|
+
#
|
|
437
|
+
# @example Forward the request to an LLM and return its completion
|
|
438
|
+
#
|
|
439
|
+
# client.on_sampling do |params|
|
|
440
|
+
# raise MCP::Client::ServerRequestError.new("User rejected sampling request", code: -1) unless approved?(params)
|
|
441
|
+
#
|
|
442
|
+
# completion = my_llm.complete(params["messages"], max_tokens: params["maxTokens"])
|
|
443
|
+
# {
|
|
444
|
+
# role: "assistant",
|
|
445
|
+
# content: { type: "text", text: completion.text },
|
|
446
|
+
# model: completion.model,
|
|
447
|
+
# stopReason: "endTurn",
|
|
448
|
+
# }
|
|
449
|
+
# end
|
|
450
|
+
#
|
|
451
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
|
|
452
|
+
#
|
|
453
|
+
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
|
454
|
+
# Register this handler only to interoperate with servers that still send sampling requests during the deprecation window;
|
|
455
|
+
# new servers should call LLM provider APIs directly.
|
|
456
|
+
def on_sampling(&handler)
|
|
457
|
+
unless transport.respond_to?(:on_server_request)
|
|
458
|
+
raise ArgumentError, "The transport does not support server-to-client requests"
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
transport.on_server_request(Methods::SAMPLING_CREATE_MESSAGE, &handler)
|
|
462
|
+
end
|
|
463
|
+
|
|
426
464
|
# Sends a `ping` request to the server to verify the connection is alive.
|
|
427
465
|
# Per the MCP spec, the server responds with an empty result.
|
|
428
466
|
#
|
data/lib/mcp/configuration.rb
CHANGED
|
@@ -5,7 +5,7 @@ module MCP
|
|
|
5
5
|
LATEST_STABLE_PROTOCOL_VERSION = "2025-11-25"
|
|
6
6
|
SUPPORTED_STABLE_PROTOCOL_VERSIONS = [
|
|
7
7
|
LATEST_STABLE_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05",
|
|
8
|
-
]
|
|
8
|
+
].freeze
|
|
9
9
|
DEFAULT_NEGOTIATED_PROTOCOL_VERSION = "2025-03-26"
|
|
10
10
|
|
|
11
11
|
attr_writer :exception_reporter, :around_request
|
data/lib/mcp/icon.rb
CHANGED
data/lib/mcp/instrumentation.rb
CHANGED
|
@@ -14,9 +14,7 @@ module MCP
|
|
|
14
14
|
rescue => e
|
|
15
15
|
already_reported = begin
|
|
16
16
|
!!exception_already_reported&.call(e)
|
|
17
|
-
# rubocop:disable Lint/RescueException
|
|
18
17
|
rescue Exception
|
|
19
|
-
# rubocop:enable Lint/RescueException
|
|
20
18
|
# The predicate is expected to be side-effect-free and return a boolean.
|
|
21
19
|
# Any exception raised from it (including non-StandardError such as SystemExit)
|
|
22
20
|
# must not shadow the original exception.
|
|
@@ -8,7 +8,7 @@ module MCP
|
|
|
8
8
|
# Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`.
|
|
9
9
|
# Variable names are restricted to valid Regexp named-group names,
|
|
10
10
|
# so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match.
|
|
11
|
-
VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}
|
|
11
|
+
VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/.freeze
|
|
12
12
|
|
|
13
13
|
attr_reader :uri_template_value
|
|
14
14
|
attr_reader :title_value
|
|
@@ -19,9 +19,9 @@ module MCP
|
|
|
19
19
|
class InvalidJsonError < StandardError; end
|
|
20
20
|
|
|
21
21
|
SSE_HEADERS = {
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
22
|
+
"content-type" => "text/event-stream",
|
|
23
|
+
"cache-control" => "no-cache",
|
|
24
|
+
"connection" => "keep-alive",
|
|
25
25
|
}.freeze
|
|
26
26
|
|
|
27
27
|
# Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
|
|
@@ -517,6 +517,11 @@ module MCP
|
|
|
517
517
|
end
|
|
518
518
|
|
|
519
519
|
if notification?(body)
|
|
520
|
+
# Reject a notification carrying an unknown or expired session ID instead of
|
|
521
|
+
# dispatching it against the shared `Server`. Mirrors the response and regular-request
|
|
522
|
+
# branches; without it a custom notification handler could run without a live session.
|
|
523
|
+
return session_not_found_response if !@stateless && !session_active?(session_id)
|
|
524
|
+
|
|
520
525
|
dispatch_notification(body_string, session_id)
|
|
521
526
|
handle_accepted
|
|
522
527
|
elsif response?(body)
|
|
@@ -561,7 +566,7 @@ module MCP
|
|
|
561
566
|
end
|
|
562
567
|
|
|
563
568
|
def handle_delete(request)
|
|
564
|
-
success_response = [200, { "
|
|
569
|
+
success_response = [200, { "content-type" => "application/json" }, [{ success: true }.to_json]]
|
|
565
570
|
|
|
566
571
|
if @stateless
|
|
567
572
|
protocol_version_error = validate_protocol_version_header(request)
|
|
@@ -757,7 +762,7 @@ module MCP
|
|
|
757
762
|
|
|
758
763
|
def json_rpc_error_response(status:, code:, message:)
|
|
759
764
|
body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
|
|
760
|
-
[status, { "
|
|
765
|
+
[status, { "content-type" => "application/json" }, [body.to_json]]
|
|
761
766
|
end
|
|
762
767
|
|
|
763
768
|
def notification?(body)
|
|
@@ -844,6 +849,17 @@ module MCP
|
|
|
844
849
|
|
|
845
850
|
response = server_session.handle_json(body_string)
|
|
846
851
|
|
|
852
|
+
# `initialize_request?` matches on the method alone, so an `initialize` sent without
|
|
853
|
+
# an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
|
|
854
|
+
# but JSON-RPC emits no response for an id-less request, so `response` is nil.
|
|
855
|
+
# Returning `[200, ..., [nil]]` would place nil in the Rack body (which the web server cannot serialize),
|
|
856
|
+
# and the session would be retained since it is marked initialized. Discard the orphaned session
|
|
857
|
+
# and ack with 202, mirroring the nil-response handling in a regular request.
|
|
858
|
+
if response.nil?
|
|
859
|
+
cleanup_session(session_id) if session_id
|
|
860
|
+
return handle_accepted
|
|
861
|
+
end
|
|
862
|
+
|
|
847
863
|
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
|
848
864
|
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
|
849
865
|
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
|
|
@@ -854,10 +870,10 @@ module MCP
|
|
|
854
870
|
end
|
|
855
871
|
|
|
856
872
|
headers = {
|
|
857
|
-
"
|
|
873
|
+
"content-type" => "application/json",
|
|
858
874
|
}
|
|
859
875
|
|
|
860
|
-
headers["
|
|
876
|
+
headers["mcp-session-id"] = session_id if session_id
|
|
861
877
|
|
|
862
878
|
[200, headers, [response]]
|
|
863
879
|
end
|
|
@@ -899,7 +915,7 @@ module MCP
|
|
|
899
915
|
# which would produce an empty body the client cannot parse as JSON.
|
|
900
916
|
return handle_accepted if response.nil?
|
|
901
917
|
|
|
902
|
-
[200, { "
|
|
918
|
+
[200, { "content-type" => "application/json" }, [response]]
|
|
903
919
|
end
|
|
904
920
|
end
|
|
905
921
|
|
|
@@ -1132,7 +1148,7 @@ module MCP
|
|
|
1132
1148
|
message: message,
|
|
1133
1149
|
},
|
|
1134
1150
|
}
|
|
1135
|
-
[400, { "
|
|
1151
|
+
[400, { "content-type" => "application/json" }, [body.to_json]]
|
|
1136
1152
|
end
|
|
1137
1153
|
|
|
1138
1154
|
def session_already_connected_response
|
data/lib/mcp/server.rb
CHANGED
|
@@ -963,9 +963,7 @@ module MCP
|
|
|
963
963
|
end
|
|
964
964
|
|
|
965
965
|
def index_resources_by_uri(resources)
|
|
966
|
-
resources.
|
|
967
|
-
hash[resource.uri] = resource
|
|
968
|
-
end
|
|
966
|
+
resources.to_h { |resource| [resource.uri, resource] }
|
|
969
967
|
end
|
|
970
968
|
|
|
971
969
|
def error_tool_response(text)
|
data/lib/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.25.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -45,8 +45,10 @@ files:
|
|
|
45
45
|
- lib/mcp/client/http.rb
|
|
46
46
|
- lib/mcp/client/oauth.rb
|
|
47
47
|
- lib/mcp/client/oauth/client_credentials_provider.rb
|
|
48
|
+
- lib/mcp/client/oauth/cross_app_access_provider.rb
|
|
48
49
|
- lib/mcp/client/oauth/discovery.rb
|
|
49
50
|
- lib/mcp/client/oauth/flow.rb
|
|
51
|
+
- lib/mcp/client/oauth/id_jag_token_exchange.rb
|
|
50
52
|
- lib/mcp/client/oauth/in_memory_storage.rb
|
|
51
53
|
- lib/mcp/client/oauth/jwt_client_assertion.rb
|
|
52
54
|
- lib/mcp/client/oauth/pkce.rb
|
|
@@ -95,7 +97,7 @@ licenses:
|
|
|
95
97
|
- Apache-2.0
|
|
96
98
|
metadata:
|
|
97
99
|
allowed_push_host: https://rubygems.org
|
|
98
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.
|
|
100
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.25.0
|
|
99
101
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
100
102
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
101
103
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|
|
@@ -114,7 +116,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
114
116
|
- !ruby/object:Gem::Version
|
|
115
117
|
version: '0'
|
|
116
118
|
requirements: []
|
|
117
|
-
rubygems_version: 4.0.
|
|
119
|
+
rubygems_version: 4.0.16
|
|
118
120
|
specification_version: 4
|
|
119
121
|
summary: The official Ruby SDK for Model Context Protocol servers and clients
|
|
120
122
|
test_files: []
|