mcp 0.23.0 → 0.25.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 +329 -6
- data/lib/json_rpc_handler.rb +18 -1
- data/lib/mcp/apps.rb +109 -0
- data/lib/mcp/client/elicitation.rb +51 -0
- data/lib/mcp/client/http.rb +383 -32
- data/lib/mcp/client/oauth/client_credentials_provider.rb +72 -14
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +80 -0
- data/lib/mcp/client/oauth/discovery.rb +1 -1
- data/lib/mcp/client/oauth/flow.rb +182 -6
- data/lib/mcp/client/oauth/id_jag_token_exchange.rb +101 -0
- 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 +5 -1
- data/lib/mcp/client/paginated_result.rb +7 -5
- data/lib/mcp/client/stdio.rb +3 -1
- data/lib/mcp/client.rb +127 -1
- data/lib/mcp/configuration.rb +1 -1
- data/lib/mcp/error_codes.rb +21 -0
- data/lib/mcp/icon.rb +1 -1
- data/lib/mcp/instrumentation.rb +0 -2
- 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 +138 -87
- data/lib/mcp/server.rb +192 -23
- 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 +10 -3
|
@@ -19,9 +19,9 @@ module MCP
|
|
|
19
19
|
class InvalidJsonError < StandardError; end
|
|
20
20
|
|
|
21
21
|
SSE_HEADERS = {
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
22
|
+
"content-type" => "text/event-stream",
|
|
23
|
+
"cache-control" => "no-cache",
|
|
24
|
+
"connection" => "keep-alive",
|
|
25
25
|
}.freeze
|
|
26
26
|
|
|
27
27
|
# Secure defaults for stateful mode. Without a finite idle timeout, sessions live until an explicit client DELETE,
|
|
@@ -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
|
-
send_to_stream(stream, notification)
|
|
251
|
-
sent_count += 1
|
|
252
|
-
rescue *STREAM_WRITE_ERRORS => e
|
|
253
|
-
MCP.configuration.exception_reporter.call(
|
|
254
|
-
e,
|
|
255
|
-
{ session_id: sid, error: "Failed to send notification" },
|
|
256
|
-
)
|
|
257
|
-
failed_sessions << sid
|
|
258
|
-
end
|
|
259
|
-
end
|
|
259
|
+
close_streams(streams_to_close)
|
|
260
260
|
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
targets.count do |session_id, stream|
|
|
262
|
+
write_notification(stream, notification, session_id, nil)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
263
265
|
|
|
264
|
-
|
|
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
|
|
276
|
+
|
|
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 = []
|
|
282
|
+
|
|
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)
|
|
@@ -490,6 +517,11 @@ module MCP
|
|
|
490
517
|
end
|
|
491
518
|
|
|
492
519
|
if notification?(body)
|
|
520
|
+
# Reject a notification carrying an unknown or expired session ID instead of
|
|
521
|
+
# dispatching it against the shared `Server`. Mirrors the response and regular-request
|
|
522
|
+
# branches; without it a custom notification handler could run without a live session.
|
|
523
|
+
return session_not_found_response if !@stateless && !session_active?(session_id)
|
|
524
|
+
|
|
493
525
|
dispatch_notification(body_string, session_id)
|
|
494
526
|
handle_accepted
|
|
495
527
|
elsif response?(body)
|
|
@@ -534,7 +566,7 @@ module MCP
|
|
|
534
566
|
end
|
|
535
567
|
|
|
536
568
|
def handle_delete(request)
|
|
537
|
-
success_response = [200, { "
|
|
569
|
+
success_response = [200, { "content-type" => "application/json" }, [{ success: true }.to_json]]
|
|
538
570
|
|
|
539
571
|
if @stateless
|
|
540
572
|
protocol_version_error = validate_protocol_version_header(request)
|
|
@@ -712,6 +744,10 @@ module MCP
|
|
|
712
744
|
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
|
|
713
745
|
end
|
|
714
746
|
|
|
747
|
+
def discover_request?(body)
|
|
748
|
+
body.is_a?(Hash) && body[:method] == Methods::SERVER_DISCOVER
|
|
749
|
+
end
|
|
750
|
+
|
|
715
751
|
def validate_protocol_version_header(request)
|
|
716
752
|
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"] || MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION
|
|
717
753
|
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
|
@@ -726,7 +762,7 @@ module MCP
|
|
|
726
762
|
|
|
727
763
|
def json_rpc_error_response(status:, code:, message:)
|
|
728
764
|
body = { jsonrpc: "2.0", id: nil, error: { code: code, message: message } }
|
|
729
|
-
[status, { "
|
|
765
|
+
[status, { "content-type" => "application/json" }, [body.to_json]]
|
|
730
766
|
end
|
|
731
767
|
|
|
732
768
|
def notification?(body)
|
|
@@ -813,6 +849,17 @@ module MCP
|
|
|
813
849
|
|
|
814
850
|
response = server_session.handle_json(body_string)
|
|
815
851
|
|
|
852
|
+
# `initialize_request?` matches on the method alone, so an `initialize` sent without
|
|
853
|
+
# an id (framed as a notification) reaches here. `Server#init` marks the session initialized,
|
|
854
|
+
# but JSON-RPC emits no response for an id-less request, so `response` is nil.
|
|
855
|
+
# Returning `[200, ..., [nil]]` would place nil in the Rack body (which the web server cannot serialize),
|
|
856
|
+
# and the session would be retained since it is marked initialized. Discard the orphaned session
|
|
857
|
+
# and ack with 202, mirroring the nil-response handling in a regular request.
|
|
858
|
+
if response.nil?
|
|
859
|
+
cleanup_session(session_id) if session_id
|
|
860
|
+
return handle_accepted
|
|
861
|
+
end
|
|
862
|
+
|
|
816
863
|
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
|
817
864
|
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
|
818
865
|
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
|
|
@@ -823,10 +870,10 @@ module MCP
|
|
|
823
870
|
end
|
|
824
871
|
|
|
825
872
|
headers = {
|
|
826
|
-
"
|
|
873
|
+
"content-type" => "application/json",
|
|
827
874
|
}
|
|
828
875
|
|
|
829
|
-
headers["
|
|
876
|
+
headers["mcp-session-id"] = session_id if session_id
|
|
830
877
|
|
|
831
878
|
[200, headers, [response]]
|
|
832
879
|
end
|
|
@@ -868,7 +915,7 @@ module MCP
|
|
|
868
915
|
# which would produce an empty body the client cannot parse as JSON.
|
|
869
916
|
return handle_accepted if response.nil?
|
|
870
917
|
|
|
871
|
-
[200, { "
|
|
918
|
+
[200, { "content-type" => "application/json" }, [response]]
|
|
872
919
|
end
|
|
873
920
|
end
|
|
874
921
|
|
|
@@ -1101,7 +1148,7 @@ module MCP
|
|
|
1101
1148
|
message: message,
|
|
1102
1149
|
},
|
|
1103
1150
|
}
|
|
1104
|
-
[400, { "
|
|
1151
|
+
[400, { "content-type" => "application/json" }, [body.to_json]]
|
|
1105
1152
|
end
|
|
1106
1153
|
|
|
1107
1154
|
def session_already_connected_response
|
|
@@ -1158,11 +1205,15 @@ module MCP
|
|
|
1158
1205
|
end
|
|
1159
1206
|
|
|
1160
1207
|
def send_keepalive_ping(session_id)
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1208
|
+
# Resolve the stream under the lock, then write outside it so a stalled reader
|
|
1209
|
+
# cannot block every other session on `@mutex`.
|
|
1210
|
+
stream = @mutex.synchronize do
|
|
1211
|
+
session = @sessions[session_id]
|
|
1212
|
+
session && session[:get_sse_stream]
|
|
1165
1213
|
end
|
|
1214
|
+
return unless stream
|
|
1215
|
+
|
|
1216
|
+
send_ping_to_stream(stream)
|
|
1166
1217
|
rescue *STREAM_WRITE_ERRORS => e
|
|
1167
1218
|
MCP.configuration.exception_reporter.call(
|
|
1168
1219
|
e,
|
data/lib/mcp/server.rb
CHANGED
|
@@ -102,8 +102,11 @@ module MCP
|
|
|
102
102
|
include Instrumentation
|
|
103
103
|
include Pagination
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
# Allowed values for the SEP-2549 `cacheScope` cache hint.
|
|
106
|
+
CACHE_SCOPES = ["public", "private"].freeze
|
|
107
|
+
|
|
108
|
+
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
|
109
|
+
attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope
|
|
107
110
|
|
|
108
111
|
def initialize(
|
|
109
112
|
description: nil,
|
|
@@ -121,6 +124,8 @@ module MCP
|
|
|
121
124
|
configuration: nil,
|
|
122
125
|
capabilities: nil,
|
|
123
126
|
page_size: nil,
|
|
127
|
+
ttl_ms: nil,
|
|
128
|
+
cache_scope: nil,
|
|
124
129
|
transport: nil
|
|
125
130
|
)
|
|
126
131
|
@description = description
|
|
@@ -138,6 +143,8 @@ module MCP
|
|
|
138
143
|
@resource_index = index_resources_by_uri(resources)
|
|
139
144
|
@server_context = server_context
|
|
140
145
|
self.page_size = page_size
|
|
146
|
+
self.ttl_ms = ttl_ms
|
|
147
|
+
self.cache_scope = cache_scope
|
|
141
148
|
@configuration = MCP.configuration.merge(configuration)
|
|
142
149
|
@client = nil
|
|
143
150
|
|
|
@@ -154,7 +161,7 @@ module MCP
|
|
|
154
161
|
|
|
155
162
|
@handlers = {
|
|
156
163
|
Methods::RESOURCES_LIST => method(:list_resources),
|
|
157
|
-
Methods::RESOURCES_READ => method(:
|
|
164
|
+
Methods::RESOURCES_READ => method(:read_resource),
|
|
158
165
|
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
|
|
159
166
|
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
|
|
160
167
|
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
|
|
@@ -163,6 +170,7 @@ module MCP
|
|
|
163
170
|
Methods::PROMPTS_LIST => method(:list_prompts),
|
|
164
171
|
Methods::PROMPTS_GET => method(:get_prompt),
|
|
165
172
|
Methods::INITIALIZE => method(:init),
|
|
173
|
+
Methods::SERVER_DISCOVER => method(:discover),
|
|
166
174
|
Methods::PING => ->(_) { {} },
|
|
167
175
|
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
|
|
168
176
|
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
|
|
@@ -216,6 +224,34 @@ module MCP
|
|
|
216
224
|
validate!
|
|
217
225
|
end
|
|
218
226
|
|
|
227
|
+
def define_resource(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block)
|
|
228
|
+
resource = Resource.define(
|
|
229
|
+
uri: uri, name: name, title: title, description: description, icons: icons,
|
|
230
|
+
mime_type: mime_type, annotations: annotations, size: size, meta: meta,
|
|
231
|
+
&block
|
|
232
|
+
)
|
|
233
|
+
@resources << resource
|
|
234
|
+
@resource_index[resource.uri] = resource
|
|
235
|
+
|
|
236
|
+
validate!
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def define_resource_template(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block)
|
|
240
|
+
resource_template = ResourceTemplate.define(
|
|
241
|
+
uri_template: uri_template, name: name, title: title, description: description, icons: icons,
|
|
242
|
+
mime_type: mime_type, annotations: annotations, meta: meta,
|
|
243
|
+
&block
|
|
244
|
+
)
|
|
245
|
+
@resource_templates << resource_template
|
|
246
|
+
|
|
247
|
+
validate!
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def resources=(resources)
|
|
251
|
+
@resources = resources
|
|
252
|
+
@resource_index = index_resources_by_uri(resources)
|
|
253
|
+
end
|
|
254
|
+
|
|
219
255
|
def define_custom_method(method_name:, &block)
|
|
220
256
|
if @handlers.key?(method_name)
|
|
221
257
|
raise MethodAlreadyDefinedError, method_name
|
|
@@ -232,6 +268,27 @@ module MCP
|
|
|
232
268
|
@page_size = page_size
|
|
233
269
|
end
|
|
234
270
|
|
|
271
|
+
# SEP-2549 cache hint: freshness lifetime in milliseconds for list and read results
|
|
272
|
+
# (max-age semantics; 0 means do not cache). Emission is opt-in: when both `ttl_ms`
|
|
273
|
+
# and `cache_scope` are nil, results are serialized exactly as before.
|
|
274
|
+
def ttl_ms=(ttl_ms)
|
|
275
|
+
unless ttl_ms.nil? || (ttl_ms.is_a?(Integer) && ttl_ms >= 0)
|
|
276
|
+
raise ArgumentError, "ttl_ms must be nil or a non-negative integer"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
@ttl_ms = ttl_ms
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# SEP-2549 cache hint: whether shared intermediaries may cache the result ("public")
|
|
283
|
+
# or only the requesting client ("private").
|
|
284
|
+
def cache_scope=(cache_scope)
|
|
285
|
+
unless cache_scope.nil? || CACHE_SCOPES.include?(cache_scope)
|
|
286
|
+
raise ArgumentError, "cache_scope must be nil, \"public\", or \"private\""
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
@cache_scope = cache_scope
|
|
290
|
+
end
|
|
291
|
+
|
|
235
292
|
def notify_tools_list_changed
|
|
236
293
|
return unless @transport
|
|
237
294
|
|
|
@@ -472,8 +529,9 @@ module MCP
|
|
|
472
529
|
when Methods::INITIALIZE
|
|
473
530
|
init(params, session: session)
|
|
474
531
|
when Methods::RESOURCES_READ
|
|
475
|
-
|
|
532
|
+
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation))
|
|
476
533
|
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
|
534
|
+
validate_resource_subscription_params!(params)
|
|
477
535
|
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
478
536
|
{}
|
|
479
537
|
when Methods::TOOLS_CALL
|
|
@@ -548,6 +606,8 @@ module MCP
|
|
|
548
606
|
end
|
|
549
607
|
|
|
550
608
|
def init(params, session: nil)
|
|
609
|
+
validate_initialize_params!(params)
|
|
610
|
+
|
|
551
611
|
# MCP spec: the initialization phase MUST be the first interaction between client and server.
|
|
552
612
|
# Reject duplicate `initialize` on an already-initialized session so the negotiated
|
|
553
613
|
# client identity and capabilities cannot be silently overwritten.
|
|
@@ -555,15 +615,13 @@ module MCP
|
|
|
555
615
|
raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request)
|
|
556
616
|
end
|
|
557
617
|
|
|
558
|
-
if
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
@client_capabilities = params[:capabilities]
|
|
564
|
-
end
|
|
565
|
-
protocol_version = params[:protocolVersion]
|
|
618
|
+
if session
|
|
619
|
+
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
|
|
620
|
+
else
|
|
621
|
+
@client = params[:clientInfo]
|
|
622
|
+
@client_capabilities = params[:capabilities]
|
|
566
623
|
end
|
|
624
|
+
protocol_version = params[:protocolVersion]
|
|
567
625
|
|
|
568
626
|
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
|
|
569
627
|
protocol_version
|
|
@@ -592,6 +650,47 @@ module MCP
|
|
|
592
650
|
}.compact
|
|
593
651
|
end
|
|
594
652
|
|
|
653
|
+
def validate_resource_subscription_params!(params)
|
|
654
|
+
unless params.is_a?(Hash) && params[:uri].is_a?(String)
|
|
655
|
+
raise RequestHandlerError.new("Missing or invalid uri", params, error_type: :invalid_params)
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def validate_initialize_params!(params)
|
|
660
|
+
unless params.is_a?(Hash)
|
|
661
|
+
raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
unless params[:protocolVersion].is_a?(String)
|
|
665
|
+
raise RequestHandlerError.new("Missing or invalid protocolVersion", params, error_type: :invalid_params)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
unless params[:capabilities].is_a?(Hash)
|
|
669
|
+
raise RequestHandlerError.new("Missing or invalid capabilities", params, error_type: :invalid_params)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
client_info = params[:clientInfo]
|
|
673
|
+
if !client_info.is_a?(Hash) || client_info[:name].nil? || client_info[:version].nil?
|
|
674
|
+
raise RequestHandlerError.new("Missing or invalid clientInfo", params, error_type: :invalid_params)
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Handles `server/discover` (MCP 2026-07-28 draft, SEP-2575): sessionless capability discovery.
|
|
679
|
+
# Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
|
|
680
|
+
# the session initialized, and responds regardless of capability declarations or initialization state,
|
|
681
|
+
# so clients can probe a server before (or instead of) `initialize`. `serverInfo` is returned unfiltered
|
|
682
|
+
# because discovery happens before version negotiation. The draft's `ttlMs`/`cacheScope` cache hints
|
|
683
|
+
# are not included here yet.
|
|
684
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
685
|
+
def discover(_request)
|
|
686
|
+
{
|
|
687
|
+
supportedVersions: Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS,
|
|
688
|
+
capabilities: capabilities,
|
|
689
|
+
serverInfo: server_info,
|
|
690
|
+
instructions: instructions,
|
|
691
|
+
}.compact
|
|
692
|
+
end
|
|
693
|
+
|
|
595
694
|
def configure_logging_level(request, session: nil)
|
|
596
695
|
if capabilities[:logging].nil?
|
|
597
696
|
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
|
|
@@ -611,7 +710,7 @@ module MCP
|
|
|
611
710
|
def list_tools(request)
|
|
612
711
|
page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
613
712
|
|
|
614
|
-
{ tools: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
713
|
+
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
615
714
|
end
|
|
616
715
|
|
|
617
716
|
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
@@ -667,7 +766,7 @@ module MCP
|
|
|
667
766
|
def list_prompts(request)
|
|
668
767
|
page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
669
768
|
|
|
670
|
-
{ prompts: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
769
|
+
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
671
770
|
end
|
|
672
771
|
|
|
673
772
|
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
@@ -696,19 +795,91 @@ module MCP
|
|
|
696
795
|
def list_resources(request)
|
|
697
796
|
page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
698
797
|
|
|
699
|
-
{ resources: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
798
|
+
apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
700
799
|
end
|
|
701
800
|
|
|
702
|
-
#
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
801
|
+
# Default `resources/read` handler: routes to class-based resources and resource templates.
|
|
802
|
+
# Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
|
|
803
|
+
# unknown URIs keep the historical no-op `[]` response instead of raising.
|
|
804
|
+
def read_resource(request, server_context: nil)
|
|
805
|
+
uri = request[:uri]
|
|
806
|
+
add_instrumentation_data(resource_uri: uri)
|
|
807
|
+
|
|
808
|
+
resource = @resource_index[uri]
|
|
809
|
+
if resource.is_a?(Class)
|
|
810
|
+
return normalize_resource_contents(call_resource_contents(resource, server_context))
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
@resource_templates.each do |template|
|
|
814
|
+
next unless template.is_a?(Class)
|
|
815
|
+
|
|
816
|
+
params = template.match_uri(uri)
|
|
817
|
+
next unless params
|
|
818
|
+
|
|
819
|
+
add_instrumentation_data(resource_template: template.uri_template)
|
|
820
|
+
return normalize_resource_contents(call_template_contents(template, params, server_context))
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
return [] unless class_based_resources_registered?
|
|
824
|
+
|
|
825
|
+
raise ResourceNotFoundError.new(uri, request)
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def call_resource_contents(resource, server_context)
|
|
829
|
+
if accepts_server_context?(resource.method(:contents))
|
|
830
|
+
resource.contents(server_context: server_context)
|
|
831
|
+
else
|
|
832
|
+
resource.contents
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def call_template_contents(template, params, server_context)
|
|
837
|
+
if accepts_server_context?(template.method(:contents))
|
|
838
|
+
template.contents(**params, server_context: server_context)
|
|
839
|
+
else
|
|
840
|
+
template.contents(**params)
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def normalize_resource_contents(result)
|
|
845
|
+
contents = result.is_a?(Array) ? result : [result]
|
|
846
|
+
contents.map(&:to_h)
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
def class_based_resources_registered?
|
|
850
|
+
@resources.any? { |resource| resource.is_a?(Class) } ||
|
|
851
|
+
@resource_templates.any? { |template| template.is_a?(Class) }
|
|
706
852
|
end
|
|
707
853
|
|
|
708
854
|
def list_resource_templates(request)
|
|
709
855
|
page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
710
856
|
|
|
711
|
-
{ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
857
|
+
apply_cache_metadata({ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
# Builds the `resources/read` result. The documented handler contract is "return value becomes `contents`";
|
|
861
|
+
# a Hash with a `:contents` key is also accepted as a full result so a handler can override the server-level
|
|
862
|
+
# `ttlMs`/`cacheScope` cache hints per result (SEP-2549).
|
|
863
|
+
def build_read_resource_result(handler_result)
|
|
864
|
+
result = if handler_result.is_a?(Hash) && handler_result.key?(:contents)
|
|
865
|
+
handler_result
|
|
866
|
+
else
|
|
867
|
+
{ contents: handler_result }
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
apply_cache_metadata(result)
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
# Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added
|
|
874
|
+
# unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in
|
|
875
|
+
# which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`).
|
|
876
|
+
# Values already in the result win, enabling per-result overrides.
|
|
877
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
|
|
878
|
+
def apply_cache_metadata(result)
|
|
879
|
+
explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
|
|
880
|
+
return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
|
|
881
|
+
|
|
882
|
+
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
|
|
712
883
|
end
|
|
713
884
|
|
|
714
885
|
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
|
|
@@ -792,9 +963,7 @@ module MCP
|
|
|
792
963
|
end
|
|
793
964
|
|
|
794
965
|
def index_resources_by_uri(resources)
|
|
795
|
-
resources.
|
|
796
|
-
hash[resource.uri] = resource
|
|
797
|
-
end
|
|
966
|
+
resources.to_h { |resource| [resource.uri, resource] }
|
|
798
967
|
end
|
|
799
968
|
|
|
800
969
|
def error_tool_response(text)
|