mcp 1.6.0 → 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 +1 -1
- data/lib/mcp/client/oauth/discovery.rb +52 -50
- data/lib/mcp/client/oauth/flow.rb +24 -10
- data/lib/mcp/server/transports/streamable_http_transport.rb +107 -53
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08c2de444c55244db435a10ce7b9f9026d24158d47cadd6f89b917f42e09b534'
|
|
4
|
+
data.tar.gz: 6126a02c9adf92df07f22b323a0337f2abae9eb23c7bf014f1b7ef1ccbbf3a72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7318acb46d46e9c958115f95171f21b20c186ab32608770d443440e0202d29319b9384b11099ed69f136c3ba9146be39998e1a9a47a839fe506e07cddca0ad89
|
|
7
|
+
data.tar.gz: 3008f7f6061e0a2cd514d377ade38a03be1ed88d863fb268e50c9820ece43f585b7fcc801bc6c0ffcf78fab3a80af3397c20e497722a6821c55d6128d391d4fc
|
data/README.md
CHANGED
|
@@ -125,7 +125,7 @@ see [Client Transports](https://ruby.sdk.modelcontextprotocol.io/client/transpor
|
|
|
125
125
|
|
|
126
126
|
## Examples
|
|
127
127
|
|
|
128
|
-
Runnable examples are available in [`examples
|
|
128
|
+
Runnable examples are available in [`examples`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples),
|
|
129
129
|
including a complete Rails application in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails).
|
|
130
130
|
|
|
131
131
|
## Documentation
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ipaddr"
|
|
4
|
+
require "strscan"
|
|
4
5
|
require "uri"
|
|
5
6
|
|
|
6
7
|
module MCP
|
|
@@ -43,6 +44,12 @@ module MCP
|
|
|
43
44
|
# or a bare token, per RFC 7235.
|
|
44
45
|
WWW_AUTH_PARAM_PATTERN = /\A([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/.freeze
|
|
45
46
|
|
|
47
|
+
# The whitespace and optional comma between two `key=value` pairs, or before the first one.
|
|
48
|
+
WWW_AUTH_PARAM_SEPARATOR_PATTERN = /\s*,?\s*/.freeze
|
|
49
|
+
|
|
50
|
+
# The `Bearer` challenge: at the start of the header or after a comma.
|
|
51
|
+
WWW_AUTH_BEARER_PATTERN = /(?:\A|,)\s*Bearer(?:\s+|\z)/i.freeze
|
|
52
|
+
|
|
46
53
|
class << self
|
|
47
54
|
# Parses a `WWW-Authenticate` header and returns the parameters of
|
|
48
55
|
# the `Bearer` challenge as a hash with lower-cased keys (e.g. `resource_metadata`,
|
|
@@ -55,25 +62,21 @@ module MCP
|
|
|
55
62
|
def parse_www_authenticate(header)
|
|
56
63
|
return {} unless header
|
|
57
64
|
|
|
58
|
-
# Locate the Bearer challenge: at the start of the header or after a comma.
|
|
59
|
-
bearer = header.match(/(?:\A|,)\s*Bearer(?:\s+|\z)/i)
|
|
60
|
-
return {} unless bearer
|
|
61
|
-
|
|
62
65
|
# Walk key=value pairs starting where Bearer's parameters begin.
|
|
63
|
-
# The loop stops at the first token that is not a key=value pair,
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
prefix = prefix.sub(/\A\s*,?\s*/, "")
|
|
70
|
-
break if prefix.empty?
|
|
66
|
+
# The loop stops at the first token that is not a key=value pair, which marks the next challenge (e.g. `, DPoP algs="..."`).
|
|
67
|
+
# The scanner keeps the walk linear in the header's length: slicing off the consumed prefix instead copies the remainder
|
|
68
|
+
# for every pair, and the server chooses how many pairs it sends. The header is also the server's to fill: a byte sequence
|
|
69
|
+
# that is not valid in the string's encoding would make the patterns raise `ArgumentError`, so such bytes are replaced first.
|
|
70
|
+
scanner = StringScanner.new(header.scrub)
|
|
71
|
+
return {} unless scanner.skip_until(WWW_AUTH_BEARER_PATTERN)
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
params = {}
|
|
74
|
+
until scanner.eos?
|
|
75
|
+
scanner.skip(WWW_AUTH_PARAM_SEPARATOR_PATTERN)
|
|
76
|
+
break if scanner.eos?
|
|
77
|
+
break unless scanner.scan(WWW_AUTH_PARAM_PATTERN)
|
|
74
78
|
|
|
75
|
-
params[
|
|
76
|
-
cursor = header.length - prefix.length + match.end(0)
|
|
79
|
+
params[scanner[1].downcase] = scanner[2] ? unescape_quoted_pair(scanner[2]) : scanner[3]
|
|
77
80
|
end
|
|
78
81
|
params
|
|
79
82
|
end
|
|
@@ -576,47 +579,46 @@ module MCP
|
|
|
576
579
|
end.join("&")
|
|
577
580
|
end
|
|
578
581
|
|
|
579
|
-
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments
|
|
580
|
-
#
|
|
581
|
-
#
|
|
582
|
-
#
|
|
582
|
+
# Implements RFC 3986 Section 5.2.4 `remove_dot_segments` over the path's segments, so that `/api/../mcp` collapses to
|
|
583
|
+
# `/mcp` and `/foo/./bar` collapses to `/foo/bar`. Each segment is visited once: the RFC's buffer rewriting,
|
|
584
|
+
# applied literally, copies the remaining input for every dot segment, and the path is the server's to choose.
|
|
585
|
+
#
|
|
586
|
+
# The output matches the RFC's algorithm for a relative path as well, including its quirk that a `..` popping
|
|
587
|
+
# the first segment leaves the result absolute (`a/../b` becomes `/b`), although `URI#path` never hands over a relative path.
|
|
588
|
+
# The rule letters below are the RFC's own: steps A through E of its loop.
|
|
583
589
|
# https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4
|
|
584
590
|
def remove_dot_segments(path)
|
|
585
591
|
return path if path.nil? || path.empty?
|
|
586
592
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
593
|
+
absolute = path.start_with?("/")
|
|
594
|
+
segments = path.split("/", -1)
|
|
595
|
+
segments.shift if absolute
|
|
596
|
+
|
|
597
|
+
output = []
|
|
598
|
+
|
|
599
|
+
# True until a segment other than `.` or `..` is kept: a relative path's leading `./` and `../` are dropped together with
|
|
600
|
+
# the slash after them (Rule A), so the segment that follows is still the slash-less first one.
|
|
601
|
+
leading = !absolute
|
|
602
|
+
segments.each_with_index do |segment, index|
|
|
603
|
+
last = index == segments.length - 1
|
|
604
|
+
|
|
605
|
+
# Rule A, and Rule D for a path that is nothing but `.` or `..`.
|
|
606
|
+
next if leading && (segment == "." || segment == "..")
|
|
607
|
+
|
|
608
|
+
if segment == "."
|
|
609
|
+
# Rule B: `/./` disappears; a final `/.` leaves its slash behind.
|
|
610
|
+
output << "/" if last
|
|
611
|
+
elsif segment == ".."
|
|
612
|
+
# Rule C: `/../` removes the previous segment; a final `/..` leaves its slash behind.
|
|
613
|
+
output.pop
|
|
614
|
+
output << "/" if last
|
|
606
615
|
else
|
|
607
|
-
segment
|
|
608
|
-
output << segment
|
|
609
|
-
input = input[segment.length..]
|
|
616
|
+
# Rule E: the first segment of a relative path carries no slash.
|
|
617
|
+
output << (leading ? segment : "/#{segment}")
|
|
610
618
|
end
|
|
619
|
+
leading = false
|
|
611
620
|
end
|
|
612
|
-
output
|
|
613
|
-
end
|
|
614
|
-
|
|
615
|
-
def remove_last_segment(output)
|
|
616
|
-
idx = output.rindex("/")
|
|
617
|
-
return +"" if idx.nil?
|
|
618
|
-
|
|
619
|
-
output[0...idx]
|
|
621
|
+
output.join
|
|
620
622
|
end
|
|
621
623
|
end
|
|
622
624
|
end
|
|
@@ -1206,16 +1206,30 @@ module MCP
|
|
|
1206
1206
|
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
|
|
1207
1207
|
end
|
|
1208
1208
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1209
|
+
# A parameter the flow sets replaces any of the same name the endpoint URL already carries.
|
|
1210
|
+
# RFC 6749 Section 3.1 forbids sending a parameter twice, and which of two values a server would honor is
|
|
1211
|
+
# its own choice; on the legacy path the endpoint URL is served by the MCP server, whose query must not speak
|
|
1212
|
+
# for the client's `client_id`, `redirect_uri`, `code_challenge`, or `resource`.
|
|
1213
|
+
# Other parameters in the URL are kept, as the TypeScript SDK's `searchParams.set` keeps them; that includes
|
|
1214
|
+
# a `scope` when the flow has none, since an authorization server may set a default scope there.
|
|
1215
|
+
# RFC 9101 `request` and `request_uri` are dropped as well, though the flow sets neither: a server takes
|
|
1216
|
+
# the whole authorization request from the object they carry, over every parameter in the query, and both are
|
|
1217
|
+
# the client's to send, never an endpoint URL's to supply.
|
|
1218
|
+
own_params = [
|
|
1219
|
+
["response_type", "code"],
|
|
1220
|
+
["client_id", client_id],
|
|
1221
|
+
["redirect_uri", @provider.redirect_uri],
|
|
1222
|
+
["code_challenge", code_challenge],
|
|
1223
|
+
["code_challenge_method", "S256"],
|
|
1224
|
+
["state", state],
|
|
1225
|
+
]
|
|
1226
|
+
own_params << ["scope", scope] if scope
|
|
1227
|
+
own_params << ["resource", resource] if resource
|
|
1228
|
+
dropped_names = own_params.map(&:first) + ["request", "request_uri"]
|
|
1229
|
+
|
|
1230
|
+
params = URI.decode_www_form(uri.query.to_s).reject { |name, _value| dropped_names.include?(name) }
|
|
1231
|
+
uri.query = URI.encode_www_form(params + own_params)
|
|
1232
|
+
|
|
1219
1233
|
uri
|
|
1220
1234
|
end
|
|
1221
1235
|
|
|
@@ -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/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.6.
|
|
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.6.
|
|
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
|