mcp 1.2.0 → 1.3.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.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ class Client
5
+ module OAuth
6
+ # Bounds an OAuth response body while it arrives, rather than after it has been buffered.
7
+ # Discovery documents, registration responses, and token responses are all small by definition,
8
+ # so a body that keeps growing is never something worth holding in memory. Matches the 4 MiB cap of
9
+ # `MCP::Client::HTTP::MAX_MESSAGE_BYTES` and `MCP::Client::Stdio::MAX_LINE_BYTES`.
10
+ class BoundedBody
11
+ MAX_RESPONSE_BYTES = 4 * 1024 * 1024
12
+
13
+ # Raised while the body is read. Each caller translates it into its own error type,
14
+ # so this never reaches an embedder.
15
+ class TooLargeError < StandardError; end
16
+
17
+ # What the OAuth code reads from a response. The Faraday response itself is not passed on,
18
+ # so a later caller cannot reach the unbounded `response.body` by accident.
19
+ Response = Struct.new(:status, :body)
20
+
21
+ def initialize(max_bytes: MAX_RESPONSE_BYTES)
22
+ @max_bytes = max_bytes
23
+ @buffer = +""
24
+ end
25
+
26
+ # Faraday `on_data` streaming callback. The chunks arrive decompressed: the default `Net::HTTP` adapter negotiates
27
+ # `Accept-Encoding` itself and reads the body through `Net::HTTPResponse#inflater`, so a small compressed body
28
+ # that expands past the cap is refused partway through the expansion rather than after it. That holds only while
29
+ # the connection leaves `Accept-Encoding` to the adapter; see `Flow#default_http_client`.
30
+ def on_data
31
+ proc do |chunk, _received_bytes, _env|
32
+ @buffer << chunk
33
+
34
+ raise TooLargeError, too_large_message if @buffer.bytesize > @max_bytes
35
+ end
36
+ end
37
+
38
+ # The status paired with the bounded body. Adapters that ignore `on_data` leave the buffer empty and deliver
39
+ # the whole body in `response.body`, so that path is measured here instead; the bytes are already allocated by then,
40
+ # but refusing them still keeps an over-cap document out of `JSON.parse`.
41
+ def response_for(response)
42
+ Response.new(response.status, bounded_body(response))
43
+ end
44
+
45
+ private
46
+
47
+ def bounded_body(response)
48
+ return @buffer unless @buffer.empty?
49
+
50
+ body = response.body
51
+ body = body.is_a?(String) ? body : body.to_s
52
+ raise TooLargeError, too_large_message if body.bytesize > @max_bytes
53
+
54
+ body
55
+ end
56
+
57
+ def too_large_message
58
+ # Not "the authorization server": protected resource metadata comes from the MCP server's own origin,
59
+ # so this message covers endpoints on both sides of the flow.
60
+ "Response body from the OAuth endpoint exceeds #{@max_bytes} bytes"
61
+ end
62
+ end
63
+
64
+ private_constant :BoundedBody
65
+ end
66
+ end
67
+ end
@@ -1021,26 +1021,54 @@ module MCP
1021
1021
  end
1022
1022
 
1023
1023
  def http_get(url)
1024
- http_client.get(url)
1024
+ bounded_request do |on_data|
1025
+ http_client.get(url) do |req|
1026
+ req.options.on_data = on_data
1027
+ end
1028
+ end
1025
1029
  end
1026
1030
 
1027
1031
  def http_post_json(url, body)
1028
- http_client.post(url) do |req|
1029
- req.headers["Content-Type"] = "application/json"
1030
- req.headers["Accept"] = "application/json"
1031
- req.body = JSON.generate(body)
1032
+ bounded_request do |on_data|
1033
+ http_client.post(url) do |req|
1034
+ req.headers["Content-Type"] = "application/json"
1035
+ req.headers["Accept"] = "application/json"
1036
+ req.options.on_data = on_data
1037
+ req.body = JSON.generate(body)
1038
+ end
1032
1039
  end
1033
1040
  end
1034
1041
 
1035
1042
  def http_post_form(url, form, headers: {})
1036
- http_client.post(url) do |req|
1037
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
1038
- req.headers["Accept"] = "application/json"
1039
- headers.each { |key, value| req.headers[key] = value }
1040
- req.body = URI.encode_www_form(form)
1043
+ bounded_request do |on_data|
1044
+ http_client.post(url) do |req|
1045
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
1046
+ req.headers["Accept"] = "application/json"
1047
+
1048
+ headers.each do |key, value|
1049
+ req.headers[key] = value
1050
+ end
1051
+
1052
+ req.options.on_data = on_data
1053
+ req.body = URI.encode_www_form(form)
1054
+ end
1041
1055
  end
1042
1056
  end
1043
1057
 
1058
+ # Issues a request with the response body bounded as it arrives, and returns the status paired with
1059
+ # that body. An over-cap response is refused rather than truncated: a partial discovery or token document
1060
+ # cannot be validated, and `fetch_metadata_json` must not fall through to the next candidate URL either,
1061
+ # since the same server would serve the same body.
1062
+ def bounded_request
1063
+ bounded = BoundedBody.new
1064
+
1065
+ response = yield(bounded.on_data)
1066
+
1067
+ bounded.response_for(response)
1068
+ rescue BoundedBody::TooLargeError => e
1069
+ raise AuthorizationError, "#{e.message}."
1070
+ end
1071
+
1044
1072
  def http_client
1045
1073
  @http_client ||= @http_client_factory.call
1046
1074
  end
@@ -1050,8 +1078,14 @@ module MCP
1050
1078
  # that transparently followed a `3xx` would let a server reach a host the checks just refused.
1051
1079
  # A caller passing `http_client_factory:` takes on that responsibility: add redirect following here
1052
1080
  # and the guards above only cover the first hop.
1081
+ #
1082
+ # `Accept-Encoding` is deliberately left unset. `Net::HTTP::GenericRequest` negotiates it and decodes
1083
+ # the response only while the caller has not claimed that header; assigning it turns `decode_content` off,
1084
+ # which would silently move `BoundedBody`'s cap onto compressed bytes and let a small body expand past it
1085
+ # after the check.
1053
1086
  def default_http_client
1054
1087
  require "faraday"
1088
+
1055
1089
  Faraday.new do |faraday|
1056
1090
  faraday.headers["Accept"] = "application/json"
1057
1091
  end
@@ -34,10 +34,13 @@ module MCP
34
34
  def request(token_endpoint:, id_token:, client_id:, audience:, resource:, http_client: nil)
35
35
  http_client ||= default_http_client
36
36
 
37
+ bounded = BoundedBody.new
38
+
37
39
  response = begin
38
- http_client.post(token_endpoint) do |req|
40
+ raw_response = http_client.post(token_endpoint) do |req|
39
41
  req.headers["Content-Type"] = "application/x-www-form-urlencoded"
40
42
  req.headers["Accept"] = "application/json"
43
+ req.options.on_data = bounded.on_data
41
44
  req.body = URI.encode_www_form(
42
45
  "grant_type" => GRANT_TYPE,
43
46
  "subject_token" => id_token,
@@ -48,6 +51,10 @@ module MCP
48
51
  "client_id" => client_id,
49
52
  )
50
53
  end
54
+
55
+ bounded.response_for(raw_response)
56
+ rescue BoundedBody::TooLargeError => e
57
+ raise ExchangeError, "#{e.message}."
51
58
  rescue Faraday::Error => e
52
59
  raise ExchangeError, "Token exchange request to #{token_endpoint} failed: #{e.class}: #{e.message}."
53
60
  end
@@ -88,8 +95,12 @@ module MCP
88
95
  assertion
89
96
  end
90
97
 
98
+ # `Accept-Encoding` is deliberately left unset, for the same reason as `Flow#default_http_client`:
99
+ # claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
100
+ # onto compressed bytes.
91
101
  def default_http_client
92
102
  require "faraday"
103
+
93
104
  Faraday.new do |faraday|
94
105
  faraday.headers["Accept"] = "application/json"
95
106
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "oauth/bounded_body"
3
4
  require_relative "oauth/discovery"
4
5
  require_relative "oauth/flow"
5
6
  require_relative "oauth/in_memory_storage"
@@ -450,8 +450,13 @@ module MCP
450
450
 
451
451
  @mutex.synchronize do
452
452
  session = @sessions[session_id]
453
- if related_request_id && session&.dig(:post_request_streams, related_request_id)
454
- session[:post_request_streams].delete(related_request_id)
453
+ if related_request_id
454
+ # Unregister only our own stream: removing on the id alone would drop whichever stream currently holds it,
455
+ # which is not necessarily the one that failed. The failed stream is closed either way, and a request-scoped
456
+ # failure never reaches the session teardown below.
457
+ registered = session&.dig(:post_request_streams, related_request_id)
458
+ session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
459
+
455
460
  streams_to_close << stream
456
461
  else
457
462
  cleanup_and_collect_stream(session_id, streams_to_close)
@@ -1595,6 +1600,14 @@ module MCP
1595
1600
  end
1596
1601
  end
1597
1602
 
1603
+ # `Server` refuses a duplicate id as well, but only once the request reaches it. The SSE branch below
1604
+ # registers this request's stream under that id first, so without this check the colliding request
1605
+ # would take over the routing entry for the moment it takes to be rejected, and its `ensure` would then
1606
+ # clear the entry the original request still needs.
1607
+ if related_request_id && server_session&.in_flight?(related_request_id)
1608
+ return request_id_conflict_response
1609
+ end
1610
+
1598
1611
  if session_id && !@stateless && !@enable_json_response
1599
1612
  handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
1600
1613
  else
@@ -1618,7 +1631,11 @@ module MCP
1618
1631
  session = @sessions[session_id]
1619
1632
  if session && related_request_id
1620
1633
  session[:post_request_streams] ||= {}
1621
- session[:post_request_streams][related_request_id] = stream
1634
+
1635
+ # Claim the id only while it is free. `handle_regular_request` already refused the colliding request,
1636
+ # so reaching an occupied slot means a race got past that check; leaving the first stream in place keeps
1637
+ # its messages going where they belong.
1638
+ session[:post_request_streams][related_request_id] ||= stream
1622
1639
  end
1623
1640
  end
1624
1641
 
@@ -1630,7 +1647,11 @@ module MCP
1630
1647
  if related_request_id
1631
1648
  @mutex.synchronize do
1632
1649
  session = @sessions[session_id]
1633
- session[:post_request_streams]&.delete(related_request_id) if session
1650
+ # Only retire our own registration: a request that never claimed the id, or one whose claim has
1651
+ # already been replaced, must not unregister the stream that owns it.
1652
+ registered = session&.dig(:post_request_streams, related_request_id)
1653
+
1654
+ session[:post_request_streams].delete(related_request_id) if registered.equal?(stream)
1634
1655
  end
1635
1656
  end
1636
1657
 
@@ -1849,6 +1870,16 @@ module MCP
1849
1870
  )
1850
1871
  end
1851
1872
 
1873
+ # The POST counterpart of the GET conflict above. A request id already in flight cannot be given
1874
+ # a stream of its own, because the id is what routes request-scoped messages back.
1875
+ def request_id_conflict_response
1876
+ json_rpc_error_response(
1877
+ status: 409,
1878
+ code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
1879
+ message: "Conflict: Request id is already in flight for this session",
1880
+ )
1881
+ end
1882
+
1852
1883
  def setup_sse_stream(session_id)
1853
1884
  body = create_sse_body(session_id)
1854
1885
 
data/lib/mcp/server.rb CHANGED
@@ -219,6 +219,7 @@ module MCP
219
219
  @resources = resources
220
220
  @resource_templates = resource_templates
221
221
  @resource_index = index_resources_by_uri(resources)
222
+ @resources_list_handler = nil
222
223
  @server_context = server_context
223
224
  self.page_size = page_size
224
225
  self.ttl_ms = ttl_ms
@@ -426,6 +427,22 @@ module MCP
426
427
  @handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
427
428
  end
428
429
 
430
+ # Sets a custom handler for `resources/list` requests, letting the visible list depend on request context such as
431
+ # the authenticated principal or granted scope. The block returns the resource collection to serve;
432
+ # the framework paginates it and stamps SEP-2549 cache hints exactly as it does for the constructor-provided resources,
433
+ # so the block returns only the array, not the paginated result.
434
+ # A block that declares a `server_context:` keyword receives an `MCP::ServerContext`. When no handler is set,
435
+ # the constructor-provided `resources` array is served unchanged.
436
+ #
437
+ # The block is invoked once per page, so it must return a stable ordering across the pages of one logical query;
438
+ # the cursor is a positional offset into the returned collection.
439
+ #
440
+ # @yield [params, server_context:] The request params, and an `MCP::ServerContext` when declared.
441
+ # @yieldreturn [Array<MCP::Resource>] The resources to paginate.
442
+ def resources_list_handler(&block)
443
+ @resources_list_handler = block
444
+ end
445
+
429
446
  # Sets a custom handler for `resources/read` requests.
430
447
  # The block receives the parsed request params and should return resource
431
448
  # contents. The return value is set as the `contents` field of the response.
@@ -446,19 +463,25 @@ module MCP
446
463
  end
447
464
 
448
465
  # Sets a custom handler for `resources/subscribe` requests.
449
- # The block receives the parsed request params. The return value is
450
- # ignored; the response is always an empty result `{}` per the MCP specification.
466
+ # The block receives the parsed request params. The response is an empty result, except that
467
+ # a `_meta` hash the block returns is passed through - the spec defines no other member for this result,
468
+ # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data
469
+ # under `_meta`.
451
470
  #
452
471
  # @yield [params] The request params containing `:uri`.
472
+ # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
453
473
  def resources_subscribe_handler(&block)
454
474
  @handlers[Methods::RESOURCES_SUBSCRIBE] = block
455
475
  end
456
476
 
457
477
  # Sets a custom handler for `resources/unsubscribe` requests.
458
- # The block receives the parsed request params. The return value is
459
- # ignored; the response is always an empty result `{}` per the MCP specification.
478
+ # The block receives the parsed request params. The response is an empty result, except that
479
+ # a `_meta` hash the block returns is passed through - the spec defines no other member for this result,
480
+ # so any other field the block returns is dropped. Nest a subscription identifier or other advisory data
481
+ # under `_meta`.
460
482
  #
461
483
  # @yield [params] The request params containing `:uri`.
484
+ # @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
462
485
  def resources_unsubscribe_handler(&block)
463
486
  @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
464
487
  end
@@ -619,8 +642,24 @@ module MCP
619
642
 
620
643
  # `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
621
644
  # so do not track it in the in-flight registry.
622
- cancellation = if related_request_id && method != Methods::INITIALIZE
623
- session&.register_in_flight(related_request_id)
645
+ cancellation = nil
646
+ if related_request_id && method != Methods::INITIALIZE && session
647
+ cancellation = session.register_in_flight(related_request_id)
648
+
649
+ # The spec puts the uniqueness obligation on the sender - "The request ID MUST NOT have been previously used by
650
+ # the requestor within the same session" - and says nothing about what a receiver does with a duplicate.
651
+ # Answering one is the only option that stays correct: the id routes request-scoped messages back to
652
+ # the request that caused them, and the transport's rule is that those messages "SHOULD relate to
653
+ # the originating client request", which a second live request under the same id makes impossible to honor
654
+ # for either of them. Refused the same way a duplicate `initialize` is, and for the same reason:
655
+ # so that a repeated id cannot silently displace state negotiated by the first one.
656
+ if cancellation.nil?
657
+ raise RequestHandlerError.new(
658
+ "Invalid Request: request id #{related_request_id.inspect} is already in flight",
659
+ request,
660
+ error_type: :invalid_request,
661
+ )
662
+ end
624
663
  end
625
664
 
626
665
  ->(params) {
@@ -653,8 +692,9 @@ module MCP
653
692
  contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
654
693
  when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
655
694
  validate_resource_subscription_params!(params)
656
- dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
657
- {}
695
+ handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
696
+
697
+ subscription_result(handler_result)
658
698
  when Methods::TOOLS_CALL
659
699
  call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
660
700
  when Methods::PROMPTS_GET
@@ -727,7 +767,10 @@ module MCP
727
767
  reported_exception = wrapped
728
768
  raise wrapped
729
769
  ensure
730
- session&.unregister_in_flight(related_request_id) if related_request_id
770
+ # `cancellation` is non-nil exactly when this request claimed the id above, so this also keeps `initialize`
771
+ # (which never registers) from evicting an in-flight registration under a reused id when the duplicate-`initialize`
772
+ # refusal raises out of the handler.
773
+ session&.unregister_in_flight(related_request_id, cancellation: cancellation) if related_request_id && cancellation
731
774
  end
732
775
  }
733
776
  end
@@ -1080,6 +1123,18 @@ module MCP
1080
1123
  end
1081
1124
  end
1082
1125
 
1126
+ # The `resources/subscribe` and `resources/unsubscribe` result is an empty object except for the optional `_meta`
1127
+ # every result may carry: the TypeScript SDK validates it against `EmptyResultSchema.strict()`,
1128
+ # which rejects any other member, so only `_meta` is passed through from the handler. A handler that returns
1129
+ # anything else keeps the empty `{}` result it had before, so returning a subscription identifier or
1130
+ # other advisory data means nesting it under `_meta`.
1131
+ def subscription_result(handler_result)
1132
+ return {} unless handler_result.is_a?(Hash)
1133
+
1134
+ meta = handler_result[:_meta] || handler_result["_meta"]
1135
+ meta.is_a?(Hash) ? { _meta: meta } : {}
1136
+ end
1137
+
1083
1138
  def validate_initialize_params!(params)
1084
1139
  unless params.is_a?(Hash)
1085
1140
  raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
@@ -1273,12 +1328,28 @@ module MCP
1273
1328
  call_prompt_template_with_args(prompt, prompt_args, server_context)
1274
1329
  end
1275
1330
 
1276
- def list_resources(request)
1277
- page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
1331
+ def list_resources(request, server_context: nil)
1332
+ resources = if @resources_list_handler
1333
+ invoke_resources_list_handler(request, server_context)
1334
+ else
1335
+ @resources
1336
+ end
1337
+
1338
+ page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
1278
1339
 
1279
1340
  apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
1280
1341
  end
1281
1342
 
1343
+ # Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in
1344
+ # by declaring the keyword (the same rule `dispatch_optional_context_handler` applies).
1345
+ def invoke_resources_list_handler(request, server_context)
1346
+ if handler_declares_server_context?(@resources_list_handler)
1347
+ @resources_list_handler.call(request, server_context: server_context)
1348
+ else
1349
+ @resources_list_handler.call(request)
1350
+ end
1351
+ end
1352
+
1282
1353
  # Default `resources/read` handler: routes to class-based resources and resource templates.
1283
1354
  # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
1284
1355
  # unknown URIs keep the historical no-op `[]` response instead of raising.
@@ -1507,7 +1578,7 @@ module MCP
1507
1578
  # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
1508
1579
  # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
1509
1580
  # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
1510
- # See docs/building-servers.md ("Tool argument keys").
1581
+ # See docs/server/tools.md ("Tool argument keys").
1511
1582
  args = arguments&.transform_keys(&:to_sym) || {}
1512
1583
 
1513
1584
  if accepts_server_context?(tool.method(:call))
@@ -58,19 +58,41 @@ module MCP
58
58
  @era = era
59
59
  end
60
60
 
61
- # Registers a `Cancellation` token for an in-flight request.
61
+ # Registers a `Cancellation` token for an in-flight request, or returns `nil` when `request_id` is already in flight.
62
+ # The request id is the only key that routes request-scoped notifications, server-to-client requests,
63
+ # and `notifications/cancelled` back to the request that caused them, so a second live request under the same id
64
+ # has no destination of its own. Rather than let the newcomer displace the registration, report the collision
65
+ # and leave the first request intact; the caller turns that into an Invalid Request.
62
66
  def register_in_flight(request_id)
63
67
  return if request_id.nil?
64
68
 
65
69
  cancellation = Cancellation.new(request_id: request_id)
66
- @in_flight_mutex.synchronize { @in_flight[request_id] = cancellation }
67
- cancellation
70
+ registered = @in_flight_mutex.synchronize do
71
+ next false if @in_flight.key?(request_id)
72
+
73
+ @in_flight[request_id] = cancellation
74
+ true
75
+ end
76
+
77
+ registered ? cancellation : nil
68
78
  end
69
79
 
70
- def unregister_in_flight(request_id)
80
+ # Removes an in-flight registration. Passing the `Cancellation` that `register_in_flight` returned removes
81
+ # the entry only while it is still that one, so a request can never evict a registration it does not own.
82
+ def unregister_in_flight(request_id, cancellation: nil)
71
83
  return if request_id.nil?
72
84
 
73
- @in_flight_mutex.synchronize { @in_flight.delete(request_id) }
85
+ @in_flight_mutex.synchronize do
86
+ next if cancellation && !@in_flight[request_id].equal?(cancellation)
87
+
88
+ @in_flight.delete(request_id)
89
+ end
90
+ end
91
+
92
+ # Whether `request_id` is currently in flight, so a transport can refuse a colliding request
93
+ # before registering any state of its own for it.
94
+ def in_flight?(request_id)
95
+ !lookup_in_flight(request_id).nil?
74
96
  end
75
97
 
76
98
  def lookup_in_flight(request_id)
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
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: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -46,6 +46,7 @@ files:
46
46
  - lib/mcp/client/mcp_param_headers.rb
47
47
  - lib/mcp/client/modern_envelope.rb
48
48
  - lib/mcp/client/oauth.rb
49
+ - lib/mcp/client/oauth/bounded_body.rb
49
50
  - lib/mcp/client/oauth/client_credentials_provider.rb
50
51
  - lib/mcp/client/oauth/cross_app_access_provider.rb
51
52
  - lib/mcp/client/oauth/discovery.rb
@@ -106,7 +107,7 @@ licenses:
106
107
  - Apache-2.0
107
108
  metadata:
108
109
  allowed_push_host: https://rubygems.org
109
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.2.0
110
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.3.0
110
111
  homepage_uri: https://ruby.sdk.modelcontextprotocol.io
111
112
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
112
113
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues