mcp 1.5.1 → 1.6.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 +2 -2
- data/lib/mcp/client/oauth/bounded_body.rb +1 -1
- data/lib/mcp/client/oauth/client_credentials_provider.rb +18 -6
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +20 -1
- data/lib/mcp/client/oauth/discovery.rb +75 -50
- data/lib/mcp/client/oauth/flow.rb +382 -101
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +1 -1
- data/lib/mcp/client/oauth/provider.rb +14 -1
- data/lib/mcp/client/oauth/storage_backed_provider.rb +42 -5
- data/lib/mcp/icon.rb +35 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +107 -53
- data/lib/mcp/tool.rb +1 -1
- data/lib/mcp/version.rb +1 -1
- metadata +3 -3
|
@@ -95,7 +95,7 @@ module MCP
|
|
|
95
95
|
assertion
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
-
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow
|
|
98
|
+
# `Accept-Encoding` is deliberately left unset, for the same reason as `Flow.build_http_client`:
|
|
99
99
|
# claiming that header turns Net::HTTP's `decode_content` off and would move `BoundedBody`'s cap
|
|
100
100
|
# onto compressed bytes.
|
|
101
101
|
def default_http_client
|
|
@@ -48,6 +48,13 @@ module MCP
|
|
|
48
48
|
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
|
|
49
49
|
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
|
50
50
|
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
51
|
+
# - `token_request_params` - Hash of String keys and values added to every token request this provider makes
|
|
52
|
+
# (the authorization code exchange and refresh), for parameters the authorization server requires beyond
|
|
53
|
+
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
|
|
54
|
+
# raises `Flow::InvalidTokenRequestParamsError`.
|
|
55
|
+
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
|
56
|
+
# - `http_client_customizer` - Callable invoked with the `Faraday::Connection` the flow builds for
|
|
57
|
+
# its own requests; see `StorageBackedProvider#http_client_customizer`.
|
|
51
58
|
class Provider
|
|
52
59
|
include StorageBackedProvider
|
|
53
60
|
|
|
@@ -87,7 +94,9 @@ module MCP
|
|
|
87
94
|
scope: nil,
|
|
88
95
|
storage: nil,
|
|
89
96
|
client_id_metadata_document_url: nil,
|
|
90
|
-
authorization_request_validator: nil
|
|
97
|
+
authorization_request_validator: nil,
|
|
98
|
+
token_request_params: nil,
|
|
99
|
+
http_client_customizer: nil
|
|
91
100
|
)
|
|
92
101
|
unless Discovery.secure_url?(redirect_uri)
|
|
93
102
|
raise InsecureRedirectURIError,
|
|
@@ -109,6 +118,8 @@ module MCP
|
|
|
109
118
|
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
|
110
119
|
end
|
|
111
120
|
|
|
121
|
+
http_client_customizer = validated_http_client_customizer(http_client_customizer)
|
|
122
|
+
|
|
112
123
|
@client_metadata = client_metadata
|
|
113
124
|
@redirect_uri = redirect_uri
|
|
114
125
|
@redirect_handler = redirect_handler
|
|
@@ -117,6 +128,8 @@ module MCP
|
|
|
117
128
|
@storage = storage || InMemoryStorage.new
|
|
118
129
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
119
130
|
@authorization_request_validator = authorization_request_validator
|
|
131
|
+
@token_request_params = frozen_token_request_params(token_request_params)
|
|
132
|
+
@http_client_customizer = http_client_customizer
|
|
120
133
|
end
|
|
121
134
|
|
|
122
135
|
# Identifies the OAuth flow this provider drives.
|
|
@@ -4,11 +4,11 @@ module MCP
|
|
|
4
4
|
class Client
|
|
5
5
|
module OAuth
|
|
6
6
|
# Shared token/credential persistence for the OAuth provider classes
|
|
7
|
-
# (`Provider` for the authorization-code flow
|
|
8
|
-
# for the client_credentials flow
|
|
9
|
-
# but
|
|
10
|
-
# the token response and the client information. This module supplies
|
|
11
|
-
# so the `Flow` orchestrator can treat any provider uniformly.
|
|
7
|
+
# (`Provider` for the authorization-code flow, `ClientCredentialsProvider`
|
|
8
|
+
# for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow).
|
|
9
|
+
# The grants differ in how they authenticate, but all read and write the same two pieces of state
|
|
10
|
+
# through a `storage` object: the token response and the client information. This module supplies
|
|
11
|
+
# that delegation so the `Flow` orchestrator can treat any provider uniformly.
|
|
12
12
|
#
|
|
13
13
|
# Including classes must set `@storage` to an object responding to `tokens`,
|
|
14
14
|
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
|
@@ -21,6 +21,20 @@ module MCP
|
|
|
21
21
|
# MCP SDK has today.
|
|
22
22
|
attr_reader :authorization_request_validator
|
|
23
23
|
|
|
24
|
+
# Optional Hash of String keys and values added to every token request the provider makes, for parameters
|
|
25
|
+
# the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them;
|
|
26
|
+
# Auth0's `audience` is the usual one). `nil` (the default) adds nothing.
|
|
27
|
+
# Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value
|
|
28
|
+
# with `Flow::InvalidTokenRequestParamsError`.
|
|
29
|
+
attr_reader :token_request_params
|
|
30
|
+
|
|
31
|
+
# Optional callable invoked with the `Faraday::Connection` the flow builds for its own requests,
|
|
32
|
+
# after the SDK's defaults and before its origin guard (see `Flow.build_http_client`), so an application can
|
|
33
|
+
# add middleware to discovery, registration, and token requests or swap their adapter. `nil` (the default)
|
|
34
|
+
# keeps the default connection. The transport's own connection and customizer block never serve these requests:
|
|
35
|
+
# they are bound to the MCP server URL and carry headers meant for that server.
|
|
36
|
+
attr_reader :http_client_customizer
|
|
37
|
+
|
|
24
38
|
def access_token
|
|
25
39
|
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
|
26
40
|
end
|
|
@@ -44,6 +58,29 @@ module MCP
|
|
|
44
58
|
def clear_tokens!
|
|
45
59
|
@storage.save_tokens(nil)
|
|
46
60
|
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Constructors call this before writing to `storage`, so a rejected provider leaves it untouched.
|
|
65
|
+
# The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key,
|
|
66
|
+
# or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly
|
|
67
|
+
# because `Hash` copies and freezes only keys whose class is exactly `String`.
|
|
68
|
+
def frozen_token_request_params(params)
|
|
69
|
+
return if params.nil?
|
|
70
|
+
|
|
71
|
+
problem = Flow.token_request_params_problem(params)
|
|
72
|
+
raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem
|
|
73
|
+
|
|
74
|
+
params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns `customizer` when it is `nil` or callable and raises otherwise, so a connection object passed
|
|
78
|
+
# in place of a callable fails at construction rather than at the first `401`.
|
|
79
|
+
def validated_http_client_customizer(customizer)
|
|
80
|
+
return customizer if customizer.nil? || customizer.respond_to?(:call)
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "http_client_customizer must respond to call (got #{customizer.class})."
|
|
83
|
+
end
|
|
47
84
|
end
|
|
48
85
|
end
|
|
49
86
|
end
|
data/lib/mcp/icon.rb
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
|
+
# An icon attached to a server, tool, prompt, or resource, per the specification's `Icon` type:
|
|
5
|
+
# https://modelcontextprotocol.io/specification/2026-07-28/basic#icons
|
|
6
|
+
#
|
|
7
|
+
# Each argument is checked against the schema's type, as the TypeScript and Python SDKs check theirs,
|
|
8
|
+
# so an icon that would serialize into something a client rejects fails here instead. What a value means
|
|
9
|
+
# is not judged: `src` may be any non-empty `String` (the schema types it as a URI, which an empty `String`
|
|
10
|
+
# is not, and the specification allows an HTTP/HTTPS URL or a `data:` URI), and a size may be any `String`
|
|
11
|
+
# (the specification expects `WxH` or `"any"`).
|
|
4
12
|
class Icon
|
|
5
13
|
SUPPORTED_THEMES = ["light", "dark"].freeze
|
|
6
14
|
|
|
7
15
|
attr_reader :mime_type, :sizes, :src, :theme
|
|
8
16
|
|
|
9
|
-
def initialize(mime_type: nil, sizes: nil, src
|
|
10
|
-
|
|
17
|
+
def initialize(mime_type: nil, sizes: nil, src:, theme: nil)
|
|
18
|
+
unless src.is_a?(String) && !src.empty?
|
|
19
|
+
raise ArgumentError, "The value of src must be a non-empty String (got #{src.class})."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless mime_type.nil? || mime_type.is_a?(String)
|
|
23
|
+
raise ArgumentError, "The value of mime_type must be a String (got #{mime_type.class})."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if (problem = sizes_problem(sizes))
|
|
27
|
+
raise ArgumentError, "The value of sizes must be an Array of Strings such as [\"48x48\"] or [\"any\"] (#{problem})."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
unless theme.nil? || SUPPORTED_THEMES.include?(theme)
|
|
31
|
+
raise ArgumentError, 'The value of theme must specify "light" or "dark".'
|
|
32
|
+
end
|
|
11
33
|
|
|
12
34
|
@mime_type = mime_type
|
|
13
35
|
@sizes = sizes
|
|
@@ -18,5 +40,16 @@ module MCP
|
|
|
18
40
|
def to_h
|
|
19
41
|
{ mimeType: mime_type, sizes: sizes, src: src, theme: theme }.compact
|
|
20
42
|
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def sizes_problem(sizes)
|
|
47
|
+
return if sizes.nil?
|
|
48
|
+
return "got #{sizes.class}" unless sizes.is_a?(Array)
|
|
49
|
+
|
|
50
|
+
index = sizes.index { |size| !size.is_a?(String) }
|
|
51
|
+
|
|
52
|
+
"got #{sizes[index].class} inside the Array" if index
|
|
53
|
+
end
|
|
21
54
|
end
|
|
22
55
|
end
|
|
@@ -169,8 +169,12 @@ module MCP
|
|
|
169
169
|
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
|
|
170
170
|
@pending_responses = {}
|
|
171
171
|
|
|
172
|
-
# Maps a `subscriptions/listen`
|
|
173
|
-
# `{ stream: stream_object, filter: honored_subscription_filter, active: boolean,
|
|
172
|
+
# Maps a key the transport mints for each `subscriptions/listen` stream to
|
|
173
|
+
# `{ request_id: listen_request_id, stream: stream_object, filter: honored_subscription_filter, active: boolean,
|
|
174
|
+
# write_mutex: Mutex, keepalive_wakeup: ConditionVariable }` (SEP-2575). The request id is the client's,
|
|
175
|
+
# unique only among that client's own in-flight requests, so it stamps `subscriptionId` but cannot serve as the key:
|
|
176
|
+
# two clients may pick the same one. Whoever removes an entry signals `keepalive_wakeup` under `@mutex`,
|
|
177
|
+
# so the stream's keepalive thread ends with its slot instead of sleeping out its interval.
|
|
174
178
|
# In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
|
|
175
179
|
# which is a follow-up.
|
|
176
180
|
@listen_subscriptions = {}
|
|
@@ -925,23 +929,33 @@ module MCP
|
|
|
925
929
|
# the legacy GET stream (`create_sse_body`).
|
|
926
930
|
#
|
|
927
931
|
# Registration and activation are split on purpose: the entry is inserted inactive
|
|
928
|
-
# (reserving the
|
|
932
|
+
# (reserving the cap slot atomically), the acknowledgement is written outside the registry lock,
|
|
929
933
|
# and only then does the entry become eligible for delivery. A concurrent notification between
|
|
930
934
|
# the insert and the acknowledgement write skips the inactive entry,
|
|
931
935
|
# enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
|
|
936
|
+
#
|
|
937
|
+
# The acknowledgement write holds the stream's write mutex, which `teardown_listen_subscriptions` also takes
|
|
938
|
+
# before it marks an entry closed. The two therefore cannot interleave: the acknowledgement either lands
|
|
939
|
+
# before the result, or, if the transport closed first, is not written at all and the stream just closes,
|
|
940
|
+
# so no stream ever carries a result ahead of its acknowledgement.
|
|
941
|
+
#
|
|
942
|
+
# The entry is keyed by an identifier minted here, not by the request id: that id is unique only among
|
|
943
|
+
# the requesting client's own in-flight requests, and two clients that pick the same one must each get
|
|
944
|
+
# their stream, stamped with the id they sent.
|
|
932
945
|
def listen_sse_body(request_id, honored)
|
|
933
946
|
ListenStreamBody.new do |stream|
|
|
934
|
-
|
|
947
|
+
subscription_key = SecureRandom.uuid
|
|
948
|
+
subscription = nil
|
|
935
949
|
@mutex.synchronize do
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
@listen_subscriptions[
|
|
950
|
+
unless @max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions
|
|
951
|
+
subscription = {
|
|
952
|
+
request_id: request_id, stream: stream, filter: honored, active: false, write_mutex: Mutex.new, keepalive_wakeup: ConditionVariable.new
|
|
953
|
+
}
|
|
954
|
+
@listen_subscriptions[subscription_key] = subscription
|
|
941
955
|
end
|
|
942
956
|
end
|
|
943
957
|
|
|
944
|
-
if
|
|
958
|
+
if subscription.nil?
|
|
945
959
|
close_stream_safely(stream)
|
|
946
960
|
else
|
|
947
961
|
acknowledgement = {
|
|
@@ -954,66 +968,92 @@ module MCP
|
|
|
954
968
|
}
|
|
955
969
|
|
|
956
970
|
begin
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
971
|
+
acknowledged = subscription[:write_mutex].synchronize do
|
|
972
|
+
next false if subscription[:closed]
|
|
973
|
+
|
|
974
|
+
send_to_stream(stream, acknowledgement)
|
|
975
|
+
|
|
976
|
+
# Set on the entry itself, not through the registry: a concurrent close may already have cleared the registry
|
|
977
|
+
# while its result write waits on this mutex, and that write must still find the stream acknowledged.
|
|
978
|
+
# Set under the registry lock as well, since that is the lock the delivery snapshot reads the flag under.
|
|
979
|
+
# This is the one place a write mutex is held while the registry lock is taken; it stays deadlock-free only
|
|
980
|
+
# as long as no path takes a write mutex inside `@mutex.synchronize`, so resolve entries under `@mutex`,
|
|
981
|
+
# release it, then write.
|
|
982
|
+
@mutex.synchronize { subscription[:active] = true }
|
|
983
|
+
|
|
984
|
+
true
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
if acknowledged
|
|
988
|
+
start_listen_keepalive_thread(subscription_key, request_id)
|
|
989
|
+
else
|
|
990
|
+
close_stream_safely(stream)
|
|
991
|
+
end
|
|
960
992
|
rescue *STREAM_WRITE_ERRORS
|
|
961
|
-
remove_listen_subscription(
|
|
993
|
+
remove_listen_subscription(subscription_key)
|
|
962
994
|
close_stream_safely(stream)
|
|
963
995
|
end
|
|
964
996
|
end
|
|
965
997
|
end
|
|
966
998
|
end
|
|
967
999
|
|
|
968
|
-
# Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
|
|
969
|
-
# The entry may already be gone when the transport closed concurrently.
|
|
970
|
-
def activate_listen_subscription(request_id)
|
|
971
|
-
@mutex.synchronize do
|
|
972
|
-
subscription = @listen_subscriptions[request_id]
|
|
973
|
-
subscription[:active] = true if subscription
|
|
974
|
-
end
|
|
975
|
-
end
|
|
976
|
-
|
|
977
1000
|
# Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
|
|
978
1001
|
# connection is detected and its slot freed, rather than held until the next fan-out write.
|
|
979
1002
|
# Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
|
|
980
1003
|
# cannot corrupt an interleaved notification's JSON.
|
|
981
|
-
|
|
1004
|
+
#
|
|
1005
|
+
# The wait between pings is a condition variable wait under `@mutex`, not a plain sleep:
|
|
1006
|
+
# the presence check and the wait happen under the same lock that removals signal from,
|
|
1007
|
+
# so a removal cannot slip in between them and a thread waiting out its interval wakes
|
|
1008
|
+
# at once when its entry goes, whichever path removed it. A thread already past the wait,
|
|
1009
|
+
# in a ping, finishes that write first and then finds its entry gone.
|
|
1010
|
+
def start_listen_keepalive_thread(subscription_key, request_id)
|
|
982
1011
|
return unless @listen_keepalive_interval
|
|
983
1012
|
|
|
984
1013
|
Thread.new do
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1014
|
+
loop do
|
|
1015
|
+
registered = @mutex.synchronize do
|
|
1016
|
+
subscription = @listen_subscriptions[subscription_key]
|
|
1017
|
+
next false unless subscription
|
|
1018
|
+
|
|
1019
|
+
subscription[:keepalive_wakeup].wait(@mutex, @listen_keepalive_interval)
|
|
1020
|
+
|
|
1021
|
+
@listen_subscriptions.key?(subscription_key)
|
|
1022
|
+
end
|
|
1023
|
+
break unless registered
|
|
1024
|
+
|
|
1025
|
+
send_listen_keepalive_ping(subscription_key)
|
|
988
1026
|
end
|
|
989
1027
|
rescue *STREAM_WRITE_ERRORS
|
|
990
1028
|
# The peer went away; the ensure frees the slot. A dropped listen stream is the normal
|
|
991
1029
|
# way this loop ends, so it is not reported.
|
|
992
1030
|
rescue StandardError => e
|
|
1031
|
+
# The request id is taken from the caller rather than the registry: a delivery failure may have
|
|
1032
|
+
# removed the entry already, and the report should still name the stream.
|
|
993
1033
|
MCP.configuration.exception_reporter.call(e, { subscription_id: request_id })
|
|
994
1034
|
ensure
|
|
995
1035
|
stream = @mutex.synchronize do
|
|
996
|
-
subscription = @listen_subscriptions.delete(
|
|
1036
|
+
subscription = @listen_subscriptions.delete(subscription_key)
|
|
997
1037
|
subscription && subscription[:stream]
|
|
998
1038
|
end
|
|
999
1039
|
close_stream_safely(stream) if stream
|
|
1000
1040
|
end
|
|
1001
1041
|
end
|
|
1002
1042
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1043
|
+
# Resolves the entry under the registry lock, then writes outside it so a stalled reader cannot
|
|
1044
|
+
# block every other subscription on `@mutex`. The write itself holds the stream's write mutex,
|
|
1045
|
+
# like notification delivery and the closing result: the comment frame then cannot land between
|
|
1046
|
+
# the bytes of a notification or after the closing result, and once teardown has marked
|
|
1047
|
+
# the entry closed the ping is skipped. A write error propagates to end the keepalive loop.
|
|
1048
|
+
def send_listen_keepalive_ping(subscription_key)
|
|
1049
|
+
subscription = @mutex.synchronize { @listen_subscriptions[subscription_key] }
|
|
1050
|
+
return unless subscription
|
|
1006
1051
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
def send_listen_keepalive_ping(request_id)
|
|
1010
|
-
stream = @mutex.synchronize do
|
|
1011
|
-
subscription = @listen_subscriptions[request_id]
|
|
1012
|
-
subscription && subscription[:stream]
|
|
1013
|
-
end
|
|
1014
|
-
return unless stream
|
|
1052
|
+
subscription[:write_mutex].synchronize do
|
|
1053
|
+
next if subscription[:closed]
|
|
1015
1054
|
|
|
1016
|
-
|
|
1055
|
+
send_ping_to_stream(subscription[:stream])
|
|
1056
|
+
end
|
|
1017
1057
|
end
|
|
1018
1058
|
|
|
1019
1059
|
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
|
|
@@ -1054,7 +1094,7 @@ module MCP
|
|
|
1054
1094
|
# The matching snapshot is taken under `@mutex`, but stream writes happen outside it:
|
|
1055
1095
|
# a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
|
|
1056
1096
|
matched = @mutex.synchronize do
|
|
1057
|
-
@listen_subscriptions.filter_map do |
|
|
1097
|
+
@listen_subscriptions.filter_map do |subscription_key, subscription|
|
|
1058
1098
|
# An inactive entry has not finished writing its acknowledgement yet;
|
|
1059
1099
|
# delivering to it would put a notification ahead of the acknowledgement.
|
|
1060
1100
|
next unless subscription[:active]
|
|
@@ -1067,12 +1107,12 @@ module MCP
|
|
|
1067
1107
|
uris.is_a?(Array) && uris.include?(uri)
|
|
1068
1108
|
end
|
|
1069
1109
|
|
|
1070
|
-
[
|
|
1110
|
+
[subscription_key, subscription] if hit
|
|
1071
1111
|
end
|
|
1072
1112
|
end
|
|
1073
1113
|
|
|
1074
|
-
matched.each do |
|
|
1075
|
-
meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
|
|
1114
|
+
matched.each do |subscription_key, subscription|
|
|
1115
|
+
meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] }
|
|
1076
1116
|
notification_params = (params || {}).merge(_meta: meta)
|
|
1077
1117
|
notification = { jsonrpc: "2.0", method: method, params: notification_params }
|
|
1078
1118
|
|
|
@@ -1089,16 +1129,21 @@ module MCP
|
|
|
1089
1129
|
rescue *STREAM_WRITE_ERRORS => e
|
|
1090
1130
|
MCP.configuration.exception_reporter.call(
|
|
1091
1131
|
e,
|
|
1092
|
-
{ subscription_id: request_id, error: "Failed to send notification" },
|
|
1132
|
+
{ subscription_id: subscription[:request_id], error: "Failed to send notification" },
|
|
1093
1133
|
)
|
|
1094
|
-
remove_listen_subscription(
|
|
1134
|
+
remove_listen_subscription(subscription_key)
|
|
1095
1135
|
close_stream_safely(subscription[:stream])
|
|
1096
1136
|
end
|
|
1097
1137
|
end
|
|
1098
1138
|
end
|
|
1099
1139
|
|
|
1100
|
-
def remove_listen_subscription(
|
|
1101
|
-
@mutex.synchronize
|
|
1140
|
+
def remove_listen_subscription(subscription_key)
|
|
1141
|
+
@mutex.synchronize do
|
|
1142
|
+
subscription = @listen_subscriptions.delete(subscription_key)
|
|
1143
|
+
subscription[:keepalive_wakeup].signal if subscription
|
|
1144
|
+
|
|
1145
|
+
subscription
|
|
1146
|
+
end
|
|
1102
1147
|
end
|
|
1103
1148
|
|
|
1104
1149
|
# Graceful teardown (SEP-2575): each open listen stream receives its `SubscriptionsListenResult` response
|
|
@@ -1107,26 +1152,35 @@ module MCP
|
|
|
1107
1152
|
removed = @mutex.synchronize do
|
|
1108
1153
|
subscriptions = @listen_subscriptions.dup
|
|
1109
1154
|
@listen_subscriptions.clear
|
|
1155
|
+
|
|
1156
|
+
subscriptions.each_value do |subscription|
|
|
1157
|
+
subscription[:keepalive_wakeup].signal
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1110
1160
|
subscriptions
|
|
1111
1161
|
end
|
|
1112
1162
|
|
|
1113
|
-
removed.
|
|
1163
|
+
removed.each_value do |subscription|
|
|
1114
1164
|
# Marking the entry closed and writing the result under the stream's write mutex orders
|
|
1115
|
-
# this against in-flight deliveries: each one either lands
|
|
1116
|
-
# `closed` and skips, keeping the graceful result the stream's final message.
|
|
1165
|
+
# this against in-flight deliveries and against the acknowledgement write: each one either lands
|
|
1166
|
+
# before the result or observes `closed` and skips, keeping the graceful result the stream's final message.
|
|
1117
1167
|
subscription[:write_mutex].synchronize do
|
|
1118
1168
|
subscription[:closed] = true
|
|
1119
1169
|
|
|
1170
|
+
# A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
|
|
1171
|
+
# the acknowledgement the first message, so the stream closes abruptly and the client re-sends.
|
|
1172
|
+
next unless subscription[:active]
|
|
1173
|
+
|
|
1120
1174
|
begin
|
|
1121
1175
|
send_to_stream(subscription[:stream], {
|
|
1122
1176
|
jsonrpc: "2.0",
|
|
1123
|
-
id: request_id,
|
|
1177
|
+
id: subscription[:request_id],
|
|
1124
1178
|
result: {
|
|
1125
1179
|
# `SubscriptionsListenResult` is served at the transport layer and never
|
|
1126
1180
|
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
|
|
1127
1181
|
# stamped at its construction site.
|
|
1128
1182
|
resultType: ResultType::COMPLETE,
|
|
1129
|
-
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
1183
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => subscription[:request_id] },
|
|
1130
1184
|
},
|
|
1131
1185
|
})
|
|
1132
1186
|
rescue *STREAM_WRITE_ERRORS
|
data/lib/mcp/tool.rb
CHANGED
|
@@ -149,7 +149,7 @@ module MCP
|
|
|
149
149
|
raise ArgumentError, "Tool names should be between 1 and 128 characters in length (inclusive)."
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
unless tool_name.match?(/\A[A-Za-z\d_
|
|
152
|
+
unless tool_name.match?(/\A[A-Za-z\d_\-.]+\z/)
|
|
153
153
|
raise ArgumentError, <<~MESSAGE
|
|
154
154
|
Tool names only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.).
|
|
155
155
|
MESSAGE
|
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.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -108,7 +108,7 @@ licenses:
|
|
|
108
108
|
- Apache-2.0
|
|
109
109
|
metadata:
|
|
110
110
|
allowed_push_host: https://rubygems.org
|
|
111
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.
|
|
111
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.6.1
|
|
112
112
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
113
113
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
114
114
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|
|
@@ -127,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
127
127
|
- !ruby/object:Gem::Version
|
|
128
128
|
version: '0'
|
|
129
129
|
requirements: []
|
|
130
|
-
rubygems_version: 4.0.
|
|
130
|
+
rubygems_version: 4.0.20
|
|
131
131
|
specification_version: 4
|
|
132
132
|
summary: The official Ruby SDK for Model Context Protocol servers and clients
|
|
133
133
|
test_files: []
|