mcp 0.23.0 → 0.24.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 +250 -6
- data/lib/json_rpc_handler.rb +17 -0
- data/lib/mcp/apps.rb +109 -0
- data/lib/mcp/client/elicitation.rb +51 -0
- data/lib/mcp/client/http.rb +326 -31
- data/lib/mcp/client/oauth/client_credentials_provider.rb +72 -14
- data/lib/mcp/client/oauth/flow.rb +148 -5
- data/lib/mcp/client/oauth/in_memory_storage.rb +3 -1
- data/lib/mcp/client/oauth/jwt_client_assertion.rb +128 -0
- data/lib/mcp/client/oauth/provider.rb +11 -3
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/client/paginated_result.rb +7 -5
- data/lib/mcp/client/stdio.rb +3 -1
- data/lib/mcp/client.rb +89 -1
- data/lib/mcp/error_codes.rb +21 -0
- data/lib/mcp/methods.rb +3 -1
- data/lib/mcp/resource.rb +135 -0
- data/lib/mcp/resource_template.rb +149 -0
- data/lib/mcp/result_type.rb +18 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +113 -78
- data/lib/mcp/server.rb +191 -20
- data/lib/mcp/server_context.rb +12 -0
- data/lib/mcp/server_session.rb +40 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +7 -2
data/lib/mcp/resource.rb
CHANGED
|
@@ -5,6 +5,141 @@ require_relative "resource/embedded"
|
|
|
5
5
|
|
|
6
6
|
module MCP
|
|
7
7
|
class Resource
|
|
8
|
+
class << self
|
|
9
|
+
NOT_SET = Object.new
|
|
10
|
+
|
|
11
|
+
attr_reader :uri_value
|
|
12
|
+
attr_reader :title_value
|
|
13
|
+
attr_reader :description_value
|
|
14
|
+
attr_reader :icons_value
|
|
15
|
+
attr_reader :mime_type_value
|
|
16
|
+
attr_reader :annotations_value
|
|
17
|
+
attr_reader :size_value
|
|
18
|
+
attr_reader :meta_value
|
|
19
|
+
|
|
20
|
+
def contents(server_context: nil)
|
|
21
|
+
raise NotImplementedError, "Subclasses must implement contents"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
uri: uri_value,
|
|
27
|
+
name: name_value,
|
|
28
|
+
title: title_value,
|
|
29
|
+
description: description_value,
|
|
30
|
+
icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
31
|
+
mimeType: mime_type_value,
|
|
32
|
+
annotations: annotations_value&.to_h,
|
|
33
|
+
size: size_value,
|
|
34
|
+
_meta: meta_value,
|
|
35
|
+
}.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inherited(subclass)
|
|
39
|
+
super
|
|
40
|
+
subclass.instance_variable_set(:@uri_value, nil)
|
|
41
|
+
subclass.instance_variable_set(:@name_value, nil)
|
|
42
|
+
subclass.instance_variable_set(:@title_value, nil)
|
|
43
|
+
subclass.instance_variable_set(:@description_value, nil)
|
|
44
|
+
subclass.instance_variable_set(:@icons_value, nil)
|
|
45
|
+
subclass.instance_variable_set(:@mime_type_value, nil)
|
|
46
|
+
subclass.instance_variable_set(:@annotations_value, nil)
|
|
47
|
+
subclass.instance_variable_set(:@size_value, nil)
|
|
48
|
+
subclass.instance_variable_set(:@meta_value, nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def uri(value = NOT_SET)
|
|
52
|
+
if value == NOT_SET
|
|
53
|
+
@uri_value
|
|
54
|
+
else
|
|
55
|
+
@uri_value = value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resource_name(value = NOT_SET)
|
|
60
|
+
if value == NOT_SET
|
|
61
|
+
name_value
|
|
62
|
+
else
|
|
63
|
+
@name_value = value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def name_value
|
|
68
|
+
@name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def title(value = NOT_SET)
|
|
72
|
+
if value == NOT_SET
|
|
73
|
+
@title_value
|
|
74
|
+
else
|
|
75
|
+
@title_value = value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def description(value = NOT_SET)
|
|
80
|
+
if value == NOT_SET
|
|
81
|
+
@description_value
|
|
82
|
+
else
|
|
83
|
+
@description_value = value
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def icons(value = NOT_SET)
|
|
88
|
+
if value == NOT_SET
|
|
89
|
+
@icons_value
|
|
90
|
+
else
|
|
91
|
+
@icons_value = value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def mime_type(value = NOT_SET)
|
|
96
|
+
if value == NOT_SET
|
|
97
|
+
@mime_type_value
|
|
98
|
+
else
|
|
99
|
+
@mime_type_value = value
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def annotations(value = NOT_SET)
|
|
104
|
+
if value == NOT_SET
|
|
105
|
+
@annotations_value
|
|
106
|
+
else
|
|
107
|
+
@annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def size(value = NOT_SET)
|
|
112
|
+
if value == NOT_SET
|
|
113
|
+
@size_value
|
|
114
|
+
else
|
|
115
|
+
@size_value = value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def meta(value = NOT_SET)
|
|
120
|
+
if value == NOT_SET
|
|
121
|
+
@meta_value
|
|
122
|
+
else
|
|
123
|
+
@meta_value = value
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def define(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block)
|
|
128
|
+
Class.new(self) do
|
|
129
|
+
uri uri
|
|
130
|
+
resource_name name
|
|
131
|
+
title title
|
|
132
|
+
description description
|
|
133
|
+
icons icons
|
|
134
|
+
mime_type mime_type
|
|
135
|
+
self.annotations(annotations) if annotations
|
|
136
|
+
size size
|
|
137
|
+
meta meta
|
|
138
|
+
define_singleton_method(:contents, &block) if block
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
8
143
|
attr_reader :uri, :name, :title, :description, :icons, :mime_type, :annotations, :size, :meta
|
|
9
144
|
|
|
10
145
|
def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil)
|
|
@@ -2,6 +2,155 @@
|
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
4
|
class ResourceTemplate
|
|
5
|
+
class << self
|
|
6
|
+
NOT_SET = Object.new
|
|
7
|
+
|
|
8
|
+
# Applied after `Regexp.escape`, which turns `{` and `}` into `\{` and `\}`.
|
|
9
|
+
# Variable names are restricted to valid Regexp named-group names,
|
|
10
|
+
# so RFC 6570 operator expressions (e.g. `{?query}`) stay literal and never match.
|
|
11
|
+
VARIABLE_PATTERN = /\\\{([A-Za-z_]\w*)\\\}/
|
|
12
|
+
|
|
13
|
+
attr_reader :uri_template_value
|
|
14
|
+
attr_reader :title_value
|
|
15
|
+
attr_reader :description_value
|
|
16
|
+
attr_reader :icons_value
|
|
17
|
+
attr_reader :mime_type_value
|
|
18
|
+
attr_reader :annotations_value
|
|
19
|
+
attr_reader :meta_value
|
|
20
|
+
|
|
21
|
+
def contents(server_context: nil, **params)
|
|
22
|
+
raise NotImplementedError, "Subclasses must implement contents"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
uriTemplate: uri_template_value,
|
|
28
|
+
name: name_value,
|
|
29
|
+
title: title_value,
|
|
30
|
+
description: description_value,
|
|
31
|
+
icons: icons_value&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
32
|
+
mimeType: mime_type_value,
|
|
33
|
+
annotations: annotations_value&.to_h,
|
|
34
|
+
_meta: meta_value,
|
|
35
|
+
}.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inherited(subclass)
|
|
39
|
+
super
|
|
40
|
+
subclass.instance_variable_set(:@uri_template_value, nil)
|
|
41
|
+
subclass.instance_variable_set(:@uri_template_pattern, nil)
|
|
42
|
+
subclass.instance_variable_set(:@name_value, nil)
|
|
43
|
+
subclass.instance_variable_set(:@title_value, nil)
|
|
44
|
+
subclass.instance_variable_set(:@description_value, nil)
|
|
45
|
+
subclass.instance_variable_set(:@icons_value, nil)
|
|
46
|
+
subclass.instance_variable_set(:@mime_type_value, nil)
|
|
47
|
+
subclass.instance_variable_set(:@annotations_value, nil)
|
|
48
|
+
subclass.instance_variable_set(:@meta_value, nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def uri_template(value = NOT_SET)
|
|
52
|
+
if value == NOT_SET
|
|
53
|
+
@uri_template_value
|
|
54
|
+
else
|
|
55
|
+
@uri_template_pattern = nil
|
|
56
|
+
@uri_template_value = value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resource_template_name(value = NOT_SET)
|
|
61
|
+
if value == NOT_SET
|
|
62
|
+
name_value
|
|
63
|
+
else
|
|
64
|
+
@name_value = value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def name_value
|
|
69
|
+
@name_value || (name.nil? ? nil : StringUtils.handle_from_class_name(name))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def title(value = NOT_SET)
|
|
73
|
+
if value == NOT_SET
|
|
74
|
+
@title_value
|
|
75
|
+
else
|
|
76
|
+
@title_value = value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def description(value = NOT_SET)
|
|
81
|
+
if value == NOT_SET
|
|
82
|
+
@description_value
|
|
83
|
+
else
|
|
84
|
+
@description_value = value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def icons(value = NOT_SET)
|
|
89
|
+
if value == NOT_SET
|
|
90
|
+
@icons_value
|
|
91
|
+
else
|
|
92
|
+
@icons_value = value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def mime_type(value = NOT_SET)
|
|
97
|
+
if value == NOT_SET
|
|
98
|
+
@mime_type_value
|
|
99
|
+
else
|
|
100
|
+
@mime_type_value = value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def annotations(value = NOT_SET)
|
|
105
|
+
if value == NOT_SET
|
|
106
|
+
@annotations_value
|
|
107
|
+
else
|
|
108
|
+
@annotations_value = value.is_a?(Annotations) ? value : Annotations.new(**value)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def meta(value = NOT_SET)
|
|
113
|
+
if value == NOT_SET
|
|
114
|
+
@meta_value
|
|
115
|
+
else
|
|
116
|
+
@meta_value = value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Matches a concrete URI against the template's simple RFC 6570 level-1 `{var}` expressions.
|
|
121
|
+
# Returns a symbol-keyed Hash of variable values, or `nil` if the URI does not match.
|
|
122
|
+
# Variables match one or more characters excluding `/`, and values are not percent-decoded.
|
|
123
|
+
def match_uri(uri)
|
|
124
|
+
match = uri_template_pattern&.match(uri)
|
|
125
|
+
match&.named_captures&.transform_keys(&:to_sym)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def define(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block)
|
|
129
|
+
Class.new(self) do
|
|
130
|
+
uri_template uri_template
|
|
131
|
+
resource_template_name name
|
|
132
|
+
title title
|
|
133
|
+
description description
|
|
134
|
+
icons icons
|
|
135
|
+
mime_type mime_type
|
|
136
|
+
self.annotations(annotations) if annotations
|
|
137
|
+
meta meta
|
|
138
|
+
define_singleton_method(:contents, &block) if block
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def uri_template_pattern
|
|
145
|
+
return if uri_template.nil?
|
|
146
|
+
|
|
147
|
+
@uri_template_pattern ||= begin
|
|
148
|
+
pattern = Regexp.escape(uri_template).gsub(VARIABLE_PATTERN) { "(?<#{Regexp.last_match(1)}>[^/]+)" }
|
|
149
|
+
Regexp.new("\\A#{pattern}\\z")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
5
154
|
attr_reader :uri_template, :name, :title, :description, :icons, :mime_type, :annotations, :meta
|
|
6
155
|
|
|
7
156
|
def initialize(uri_template:, name:, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
# Values of the `resultType` result field introduced by SEP-2322 (Multi Round-Trip Requests)
|
|
5
|
+
# for the MCP 2026-07-28 draft.
|
|
6
|
+
#
|
|
7
|
+
# A result with `resultType: "input_required"` is not a final answer: it carries an `inputRequests` map
|
|
8
|
+
# of server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create` shapes) plus
|
|
9
|
+
# an opaque `requestState` string, and the client is expected to fulfill the requests and re-issue
|
|
10
|
+
# the original request with `inputResponses` and the echoed `requestState`. A missing `resultType`
|
|
11
|
+
# or `"complete"` is a final result.
|
|
12
|
+
#
|
|
13
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
|
14
|
+
module ResultType
|
|
15
|
+
COMPLETE = "complete"
|
|
16
|
+
INPUT_REQUIRED = "input_required"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -196,80 +196,107 @@ module MCP
|
|
|
196
196
|
}
|
|
197
197
|
notification[:params] = params if params
|
|
198
198
|
|
|
199
|
+
if session_id
|
|
200
|
+
deliver_targeted_notification(notification, session_id, related_request_id)
|
|
201
|
+
else
|
|
202
|
+
deliver_broadcast_notification(notification)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def deliver_targeted_notification(notification, session_id, related_request_id)
|
|
207
|
+
# JSON response mode returns a single JSON object as the POST response,
|
|
208
|
+
# so request-scoped notifications (e.g. progress, log) cannot be delivered
|
|
209
|
+
# alongside it. Session-scoped standalone notifications
|
|
210
|
+
# (e.g. `resources/updated`, `elicitation/complete`) still flow via GET SSE.
|
|
211
|
+
return false if @enable_json_response && related_request_id
|
|
212
|
+
|
|
199
213
|
streams_to_close = []
|
|
200
214
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
215
|
+
# Resolve the target stream under the lock, then write outside it: a stalled SSE reader
|
|
216
|
+
# must not block every other session that needs `@mutex`.
|
|
217
|
+
stream = @mutex.synchronize do
|
|
218
|
+
next unless (session = @sessions[session_id])
|
|
219
|
+
|
|
220
|
+
if session_expired?(session)
|
|
221
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
222
|
+
next
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
active_stream(session, related_request_id: related_request_id)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
close_streams(streams_to_close)
|
|
229
|
+
return false unless stream
|
|
230
|
+
|
|
231
|
+
write_notification(stream, notification, session_id, related_request_id)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def deliver_broadcast_notification(notification)
|
|
235
|
+
streams_to_close = []
|
|
236
|
+
|
|
237
|
+
# Snapshot the connected streams under the lock, then write to each outside it.
|
|
238
|
+
targets = @mutex.synchronize do
|
|
239
|
+
expired_session_ids = []
|
|
240
|
+
|
|
241
|
+
collected = @sessions.filter_map do |session_id, session|
|
|
242
|
+
next unless (stream = session[:get_sse_stream])
|
|
214
243
|
|
|
215
244
|
if session_expired?(session)
|
|
216
|
-
|
|
217
|
-
next
|
|
245
|
+
expired_session_ids << session_id
|
|
246
|
+
next
|
|
218
247
|
end
|
|
219
248
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
true
|
|
223
|
-
rescue *STREAM_WRITE_ERRORS => e
|
|
224
|
-
MCP.configuration.exception_reporter.call(
|
|
225
|
-
e,
|
|
226
|
-
{ session_id: session_id, error: "Failed to send notification" },
|
|
227
|
-
)
|
|
228
|
-
if related_request_id && session[:post_request_streams]&.key?(related_request_id)
|
|
229
|
-
session[:post_request_streams].delete(related_request_id)
|
|
230
|
-
streams_to_close << stream
|
|
231
|
-
else
|
|
232
|
-
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
233
|
-
end
|
|
234
|
-
false
|
|
235
|
-
end
|
|
236
|
-
else
|
|
237
|
-
# Broadcast to all connected SSE sessions
|
|
238
|
-
sent_count = 0
|
|
239
|
-
failed_sessions = []
|
|
249
|
+
[session_id, stream]
|
|
250
|
+
end
|
|
240
251
|
|
|
241
|
-
|
|
242
|
-
|
|
252
|
+
expired_session_ids.each do |session_id|
|
|
253
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
254
|
+
end
|
|
243
255
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
next
|
|
247
|
-
end
|
|
256
|
+
collected
|
|
257
|
+
end
|
|
248
258
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
259
|
+
close_streams(streams_to_close)
|
|
260
|
+
|
|
261
|
+
targets.count do |session_id, stream|
|
|
262
|
+
write_notification(stream, notification, session_id, nil)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Writes a notification to an SSE stream without holding `@mutex`. On a write error,
|
|
267
|
+
# drops the broken stream and returns false; on success returns true.
|
|
268
|
+
def write_notification(stream, notification, session_id, related_request_id)
|
|
269
|
+
send_to_stream(stream, notification)
|
|
270
|
+
true
|
|
271
|
+
rescue *STREAM_WRITE_ERRORS => e
|
|
272
|
+
MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" })
|
|
273
|
+
drop_broken_stream(session_id, stream, related_request_id)
|
|
274
|
+
false
|
|
275
|
+
end
|
|
260
276
|
|
|
261
|
-
|
|
262
|
-
|
|
277
|
+
# Removes a stream that failed to accept a write. A request-scoped stream is dropped on its own;
|
|
278
|
+
# a session-scoped (GET SSE) failure tears down the whole session. The `@sessions` mutation runs
|
|
279
|
+
# under `@mutex`, and the affected streams are closed outside it.
|
|
280
|
+
def drop_broken_stream(session_id, stream, related_request_id)
|
|
281
|
+
streams_to_close = []
|
|
263
282
|
|
|
264
|
-
|
|
283
|
+
@mutex.synchronize do
|
|
284
|
+
session = @sessions[session_id]
|
|
285
|
+
if related_request_id && session&.dig(:post_request_streams, related_request_id)
|
|
286
|
+
session[:post_request_streams].delete(related_request_id)
|
|
287
|
+
streams_to_close << stream
|
|
288
|
+
else
|
|
289
|
+
cleanup_and_collect_stream(session_id, streams_to_close)
|
|
265
290
|
end
|
|
266
291
|
end
|
|
267
292
|
|
|
268
|
-
streams_to_close
|
|
293
|
+
close_streams(streams_to_close)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def close_streams(streams)
|
|
297
|
+
streams.each do |stream|
|
|
269
298
|
close_stream_safely(stream)
|
|
270
299
|
end
|
|
271
|
-
|
|
272
|
-
result
|
|
273
300
|
end
|
|
274
301
|
|
|
275
302
|
# Sends a server-to-client JSON-RPC request (e.g., `sampling/createMessage`) and
|
|
@@ -299,27 +326,25 @@ module MCP
|
|
|
299
326
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
300
327
|
request[:params] = params if params
|
|
301
328
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
@mutex.synchronize do
|
|
329
|
+
# Register the pending response and resolve the stream under the lock, but perform
|
|
330
|
+
# the write outside it so a stalled reader cannot block every other session on `@mutex`.
|
|
331
|
+
stream = @mutex.synchronize do
|
|
305
332
|
unless (session = @sessions[session_id])
|
|
306
333
|
raise "Session not found: #{session_id}."
|
|
307
334
|
end
|
|
308
335
|
|
|
309
336
|
@pending_responses[request_id] = { queue: queue, session_id: session_id }
|
|
310
337
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
end
|
|
322
|
-
end
|
|
338
|
+
active_stream(session, related_request_id: related_request_id)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
sent = false
|
|
342
|
+
if stream
|
|
343
|
+
begin
|
|
344
|
+
send_to_stream(stream, request)
|
|
345
|
+
sent = true
|
|
346
|
+
rescue *STREAM_WRITE_ERRORS
|
|
347
|
+
drop_broken_stream(session_id, stream, related_request_id)
|
|
323
348
|
end
|
|
324
349
|
end
|
|
325
350
|
|
|
@@ -461,7 +486,9 @@ module MCP
|
|
|
461
486
|
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
|
|
462
487
|
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
|
|
463
488
|
# This matches the TypeScript and Python SDKs.
|
|
464
|
-
|
|
489
|
+
# `server/discover` (SEP-2575) is likewise exempt: it is sessionless capability discovery that
|
|
490
|
+
# happens before (or instead of) negotiation.
|
|
491
|
+
unless initialize_request?(body) || discover_request?(body)
|
|
465
492
|
return missing_session_id_response if !@stateless && !session_id
|
|
466
493
|
|
|
467
494
|
protocol_version_error = validate_protocol_version_header(request)
|
|
@@ -712,6 +739,10 @@ module MCP
|
|
|
712
739
|
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
|
|
713
740
|
end
|
|
714
741
|
|
|
742
|
+
def discover_request?(body)
|
|
743
|
+
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
|
|
744
|
+
end
|
|
745
|
+
|
|
715
746
|
def validate_protocol_version_header(request)
|
|
716
747
|
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
|
|
717
748
|
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
|
@@ -1158,11 +1189,15 @@ module MCP
|
|
|
1158
1189
|
end
|
|
1159
1190
|
|
|
1160
1191
|
def send_keepalive_ping(session_id)
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1192
|
+
# Resolve the stream under the lock, then write outside it so a stalled reader
|
|
1193
|
+
# cannot block every other session on `@mutex`.
|
|
1194
|
+
stream = @mutex.synchronize do
|
|
1195
|
+
session = @sessions[session_id]
|
|
1196
|
+
session && session[:get_sse_stream]
|
|
1165
1197
|
end
|
|
1198
|
+
return unless stream
|
|
1199
|
+
|
|
1200
|
+
send_ping_to_stream(stream)
|
|
1166
1201
|
rescue *STREAM_WRITE_ERRORS => e
|
|
1167
1202
|
MCP.configuration.exception_reporter.call(
|
|
1168
1203
|
e,
|