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.
- checksums.yaml +4 -4
- data/README.md +50 -2967
- data/lib/mcp/client/oauth/bounded_body.rb +67 -0
- data/lib/mcp/client/oauth/flow.rb +44 -10
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +12 -1
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +35 -4
- data/lib/mcp/server.rb +83 -12
- data/lib/mcp/server_session.rb +27 -5
- data/lib/mcp/version.rb +1 -1
- metadata +3 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
data/lib/mcp/client/oauth.rb
CHANGED
|
@@ -450,8 +450,13 @@ module MCP
|
|
|
450
450
|
|
|
451
451
|
@mutex.synchronize do
|
|
452
452
|
session = @sessions[session_id]
|
|
453
|
-
if related_request_id
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
450
|
-
#
|
|
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
|
|
459
|
-
#
|
|
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 =
|
|
623
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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))
|
data/lib/mcp/server_session.rb
CHANGED
|
@@ -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
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
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.
|
|
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.
|
|
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
|