mcp 0.14.0 → 0.16.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 +149 -1
- data/lib/json_rpc_handler.rb +6 -0
- data/lib/mcp/cancellation.rb +72 -0
- data/lib/mcp/cancelled_error.rb +13 -0
- data/lib/mcp/client/http.rb +99 -2
- data/lib/mcp/client/stdio.rb +100 -49
- data/lib/mcp/client.rb +41 -0
- data/lib/mcp/configuration.rb +22 -1
- data/lib/mcp/server/transports/stdio_transport.rb +7 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +63 -1
- data/lib/mcp/server.rb +160 -19
- data/lib/mcp/server_context.rb +12 -1
- data/lib/mcp/server_session.rb +105 -20
- data/lib/mcp/tool/schema.rb +22 -4
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +5 -4
- data/lib/mcp/transports/stdio.rb +0 -15
data/lib/mcp/configuration.rb
CHANGED
|
@@ -16,7 +16,7 @@ module MCP
|
|
|
16
16
|
attr_writer :instrumentation_callback
|
|
17
17
|
|
|
18
18
|
def initialize(exception_reporter: nil, around_request: nil, instrumentation_callback: nil, protocol_version: nil,
|
|
19
|
-
validate_tool_call_arguments: true)
|
|
19
|
+
validate_tool_call_arguments: true, validate_tool_call_results: false)
|
|
20
20
|
@exception_reporter = exception_reporter
|
|
21
21
|
@around_request = around_request
|
|
22
22
|
@instrumentation_callback = instrumentation_callback
|
|
@@ -25,8 +25,10 @@ module MCP
|
|
|
25
25
|
validate_protocol_version!(protocol_version)
|
|
26
26
|
end
|
|
27
27
|
validate_value_of_validate_tool_call_arguments!(validate_tool_call_arguments)
|
|
28
|
+
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
|
|
28
29
|
|
|
29
30
|
@validate_tool_call_arguments = validate_tool_call_arguments
|
|
31
|
+
@validate_tool_call_results = validate_tool_call_results
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
def protocol_version=(protocol_version)
|
|
@@ -41,6 +43,12 @@ module MCP
|
|
|
41
43
|
@validate_tool_call_arguments = validate_tool_call_arguments
|
|
42
44
|
end
|
|
43
45
|
|
|
46
|
+
def validate_tool_call_results=(validate_tool_call_results)
|
|
47
|
+
validate_value_of_validate_tool_call_results!(validate_tool_call_results)
|
|
48
|
+
|
|
49
|
+
@validate_tool_call_results = validate_tool_call_results
|
|
50
|
+
end
|
|
51
|
+
|
|
44
52
|
def protocol_version
|
|
45
53
|
@protocol_version || LATEST_STABLE_PROTOCOL_VERSION
|
|
46
54
|
end
|
|
@@ -80,11 +88,16 @@ module MCP
|
|
|
80
88
|
end
|
|
81
89
|
|
|
82
90
|
attr_reader :validate_tool_call_arguments
|
|
91
|
+
attr_reader :validate_tool_call_results
|
|
83
92
|
|
|
84
93
|
def validate_tool_call_arguments?
|
|
85
94
|
!!@validate_tool_call_arguments
|
|
86
95
|
end
|
|
87
96
|
|
|
97
|
+
def validate_tool_call_results?
|
|
98
|
+
!!@validate_tool_call_results
|
|
99
|
+
end
|
|
100
|
+
|
|
88
101
|
def merge(other)
|
|
89
102
|
return self if other.nil?
|
|
90
103
|
|
|
@@ -113,6 +126,7 @@ module MCP
|
|
|
113
126
|
end
|
|
114
127
|
|
|
115
128
|
validate_tool_call_arguments = other.validate_tool_call_arguments
|
|
129
|
+
validate_tool_call_results = other.validate_tool_call_results
|
|
116
130
|
|
|
117
131
|
Configuration.new(
|
|
118
132
|
exception_reporter: exception_reporter,
|
|
@@ -120,6 +134,7 @@ module MCP
|
|
|
120
134
|
instrumentation_callback: instrumentation_callback,
|
|
121
135
|
protocol_version: protocol_version,
|
|
122
136
|
validate_tool_call_arguments: validate_tool_call_arguments,
|
|
137
|
+
validate_tool_call_results: validate_tool_call_results,
|
|
123
138
|
)
|
|
124
139
|
end
|
|
125
140
|
|
|
@@ -138,6 +153,12 @@ module MCP
|
|
|
138
153
|
end
|
|
139
154
|
end
|
|
140
155
|
|
|
156
|
+
def validate_value_of_validate_tool_call_results!(validate_tool_call_results)
|
|
157
|
+
unless validate_tool_call_results.is_a?(TrueClass) || validate_tool_call_results.is_a?(FalseClass)
|
|
158
|
+
raise ArgumentError, "validate_tool_call_results must be a boolean"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
141
162
|
def default_exception_reporter
|
|
142
163
|
@default_exception_reporter ||= ->(exception, server_context) {}
|
|
143
164
|
end
|
|
@@ -54,6 +54,13 @@ module MCP
|
|
|
54
54
|
false
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# NOTE: This signature deliberately matches the abstract `Transport#send_request` contract
|
|
58
|
+
# (`method, params = nil`) without the cancellation kwargs that `StreamableHTTPTransport#send_request` accepts.
|
|
59
|
+
# On Ruby 2.7 the project's supported minimum a method that mixes a positional `params` Hash with
|
|
60
|
+
# explicit keyword arguments cannot be called as `send_request(method, { ... })` - the trailing Hash would be
|
|
61
|
+
# auto-promoted to keyword arguments. Stdio is single-threaded and blocks on `$stdin.gets`, so nested-request
|
|
62
|
+
# cancellation has very limited value here regardless; servers that need cancellation propagation for nested
|
|
63
|
+
# server-to-client requests should use `StreamableHTTPTransport`.
|
|
57
64
|
def send_request(method, params = nil)
|
|
58
65
|
request_id = generate_request_id
|
|
59
66
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
@@ -175,7 +175,7 @@ module MCP
|
|
|
175
175
|
# sends the request via SSE stream, then blocks on `queue.pop`.
|
|
176
176
|
# When the client POSTs a response, `handle_response` matches it by `request_id`
|
|
177
177
|
# and pushes the result onto the queue, unblocking this thread.
|
|
178
|
-
def send_request(method, params = nil, session_id: nil, related_request_id: nil)
|
|
178
|
+
def send_request(method, params = nil, session_id: nil, related_request_id: nil, parent_cancellation: nil, server_session: nil)
|
|
179
179
|
if @stateless
|
|
180
180
|
raise "Stateless mode does not support server-to-client requests."
|
|
181
181
|
end
|
|
@@ -190,6 +190,7 @@ module MCP
|
|
|
190
190
|
|
|
191
191
|
request_id = generate_request_id
|
|
192
192
|
queue = Queue.new
|
|
193
|
+
cancel_hook = nil
|
|
193
194
|
|
|
194
195
|
request = { jsonrpc: "2.0", id: request_id, method: method }
|
|
195
196
|
request[:params] = params if params
|
|
@@ -229,6 +230,16 @@ module MCP
|
|
|
229
230
|
raise "No active stream for #{method} request."
|
|
230
231
|
end
|
|
231
232
|
|
|
233
|
+
if parent_cancellation && server_session
|
|
234
|
+
cancel_hook = parent_cancellation.on_cancel do |reason|
|
|
235
|
+
server_session.send_peer_cancellation(
|
|
236
|
+
nested_request_id: request_id,
|
|
237
|
+
related_request_id: related_request_id,
|
|
238
|
+
reason: reason,
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
232
243
|
response = queue.pop
|
|
233
244
|
|
|
234
245
|
if response.is_a?(Hash) && response.key?(:error)
|
|
@@ -239,8 +250,18 @@ module MCP
|
|
|
239
250
|
raise "SSE session closed while waiting for #{method} response."
|
|
240
251
|
end
|
|
241
252
|
|
|
253
|
+
if response == :cancelled
|
|
254
|
+
reason = @mutex.synchronize { @pending_responses.dig(request_id, :cancel_reason) }
|
|
255
|
+
raise MCP::CancelledError.new(
|
|
256
|
+
"#{method} request was cancelled",
|
|
257
|
+
request_id: request_id,
|
|
258
|
+
reason: reason,
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
242
262
|
response
|
|
243
263
|
ensure
|
|
264
|
+
parent_cancellation.off_cancel(cancel_hook) if cancel_hook
|
|
244
265
|
if request_id
|
|
245
266
|
@mutex.synchronize do
|
|
246
267
|
@pending_responses.delete(request_id)
|
|
@@ -248,6 +269,24 @@ module MCP
|
|
|
248
269
|
end
|
|
249
270
|
end
|
|
250
271
|
|
|
272
|
+
# Unblocks a `send_request` awaiting a response when the peer is being cancelled.
|
|
273
|
+
# The waiting thread will see `:cancelled` on its queue and raise `MCP::CancelledError`.
|
|
274
|
+
#
|
|
275
|
+
# Race note: this is first-writer-wins on the pending-response queue. If a real response
|
|
276
|
+
# has already been pushed (client responded before the cancel hook fired), that response
|
|
277
|
+
# wins and `:cancelled` is enqueued behind it but never read - `send_request` returns
|
|
278
|
+
# the real response and deletes the pending entry in its `ensure` block. Conversely,
|
|
279
|
+
# if `:cancelled` arrives first, any later client response is silently dropped in `handle_response`
|
|
280
|
+
# because the pending entry has been removed.
|
|
281
|
+
def cancel_pending_request(request_id, reason: nil)
|
|
282
|
+
@mutex.synchronize do
|
|
283
|
+
if (pending = @pending_responses[request_id])
|
|
284
|
+
pending[:cancel_reason] = reason
|
|
285
|
+
pending[:queue].push(:cancelled)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
251
290
|
private
|
|
252
291
|
|
|
253
292
|
def start_reaper_thread
|
|
@@ -309,6 +348,7 @@ module MCP
|
|
|
309
348
|
return missing_session_id_response if !@stateless && !session_id
|
|
310
349
|
|
|
311
350
|
if notification?(body)
|
|
351
|
+
dispatch_notification(body_string, session_id)
|
|
312
352
|
handle_accepted
|
|
313
353
|
elsif response?(body)
|
|
314
354
|
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
@@ -459,6 +499,22 @@ module MCP
|
|
|
459
499
|
!body[:id] && !!body[:method]
|
|
460
500
|
end
|
|
461
501
|
|
|
502
|
+
# Dispatches a client-originated notification (e.g. `notifications/cancelled`,
|
|
503
|
+
# `notifications/initialized`) through the server so it can update session state.
|
|
504
|
+
def dispatch_notification(body_string, session_id)
|
|
505
|
+
server_session = nil
|
|
506
|
+
if session_id && !@stateless
|
|
507
|
+
@mutex.synchronize do
|
|
508
|
+
session = @sessions[session_id]
|
|
509
|
+
server_session = session[:server_session] if session
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
dispatch_handle_json(body_string, server_session)
|
|
514
|
+
rescue => e
|
|
515
|
+
MCP.configuration.exception_reporter.call(e, { error: "Failed to dispatch notification" })
|
|
516
|
+
end
|
|
517
|
+
|
|
462
518
|
def response?(body)
|
|
463
519
|
!!body[:id] && !body[:method]
|
|
464
520
|
end
|
|
@@ -536,6 +592,12 @@ module MCP
|
|
|
536
592
|
handle_request_with_sse_response(body_string, session_id, server_session, related_request_id: related_request_id)
|
|
537
593
|
else
|
|
538
594
|
response = dispatch_handle_json(body_string, server_session)
|
|
595
|
+
|
|
596
|
+
# `Server#handle_json` returns `nil` when cancellation has suppressed the JSON-RPC response per spec.
|
|
597
|
+
# Mirror the notification path and ack with 202 instead of returning a 200 with a `nil` Rack body,
|
|
598
|
+
# which would produce an empty body the client cannot parse as JSON.
|
|
599
|
+
return handle_accepted if response.nil?
|
|
600
|
+
|
|
539
601
|
[200, { "Content-Type" => "application/json" }, [response]]
|
|
540
602
|
end
|
|
541
603
|
end
|
data/lib/mcp/server.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../json_rpc_handler"
|
|
4
|
+
require_relative "cancellation"
|
|
5
|
+
require_relative "cancelled_error"
|
|
4
6
|
require_relative "instrumentation"
|
|
5
7
|
require_relative "methods"
|
|
6
8
|
require_relative "logging_message_notification"
|
|
@@ -160,8 +162,8 @@ module MCP
|
|
|
160
162
|
end
|
|
161
163
|
end
|
|
162
164
|
|
|
163
|
-
def define_tool(name: nil, title: nil, description: nil, input_schema: nil, annotations: nil, meta: nil, &block)
|
|
164
|
-
tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, annotations: annotations, meta: meta, &block)
|
|
165
|
+
def define_tool(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, annotations: nil, meta: nil, &block)
|
|
166
|
+
tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, output_schema: output_schema, annotations: annotations, meta: meta, &block)
|
|
165
167
|
tool_name = tool.name_value
|
|
166
168
|
|
|
167
169
|
@tool_names << tool_name
|
|
@@ -384,6 +386,13 @@ module MCP
|
|
|
384
386
|
end
|
|
385
387
|
|
|
386
388
|
def handle_request(request, method, session: nil, related_request_id: nil)
|
|
389
|
+
# `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
|
|
390
|
+
# and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
|
|
391
|
+
# and rescue blocks below.
|
|
392
|
+
if method == Methods::NOTIFICATIONS_CANCELLED
|
|
393
|
+
return ->(params) { handle_cancelled_notification(params, session: session) }
|
|
394
|
+
end
|
|
395
|
+
|
|
387
396
|
handler = @handlers[method]
|
|
388
397
|
unless handler
|
|
389
398
|
instrument_call("unsupported_method", server_context: { request: request }) do
|
|
@@ -395,6 +404,12 @@ module MCP
|
|
|
395
404
|
|
|
396
405
|
Methods.ensure_capability!(method, capabilities)
|
|
397
406
|
|
|
407
|
+
# `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
|
|
408
|
+
# so do not track it in the in-flight registry.
|
|
409
|
+
cancellation = if related_request_id && method != Methods::INITIALIZE
|
|
410
|
+
session&.register_in_flight(related_request_id)
|
|
411
|
+
end
|
|
412
|
+
|
|
398
413
|
->(params) {
|
|
399
414
|
reported_exception = nil
|
|
400
415
|
instrument_call(
|
|
@@ -406,23 +421,33 @@ module MCP
|
|
|
406
421
|
when Methods::INITIALIZE
|
|
407
422
|
init(params, session: session)
|
|
408
423
|
when Methods::RESOURCES_READ
|
|
409
|
-
{ contents:
|
|
424
|
+
{ contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) }
|
|
410
425
|
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
|
411
|
-
@handlers[method]
|
|
426
|
+
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
412
427
|
{}
|
|
413
428
|
when Methods::TOOLS_CALL
|
|
414
|
-
call_tool(params, session: session, related_request_id: related_request_id)
|
|
429
|
+
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
430
|
+
when Methods::PROMPTS_GET
|
|
431
|
+
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
415
432
|
when Methods::COMPLETION_COMPLETE
|
|
416
|
-
complete(params)
|
|
433
|
+
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
417
434
|
when Methods::LOGGING_SET_LEVEL
|
|
418
435
|
configure_logging_level(params, session: session)
|
|
419
436
|
else
|
|
420
|
-
@handlers[method]
|
|
437
|
+
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
421
438
|
end
|
|
422
439
|
client = session&.client || @client
|
|
423
440
|
add_instrumentation_data(client: client) if client
|
|
424
441
|
|
|
442
|
+
if cancellation&.cancelled?
|
|
443
|
+
add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
|
|
444
|
+
next JsonRpcHandler::NO_RESPONSE
|
|
445
|
+
end
|
|
446
|
+
|
|
425
447
|
result
|
|
448
|
+
rescue CancelledError => e
|
|
449
|
+
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
|
450
|
+
next JsonRpcHandler::NO_RESPONSE
|
|
426
451
|
rescue RequestHandlerError => e
|
|
427
452
|
report_exception(e.original_error || e, { request: request })
|
|
428
453
|
add_instrumentation_data(error: e.error_type)
|
|
@@ -434,10 +459,23 @@ module MCP
|
|
|
434
459
|
wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
|
|
435
460
|
reported_exception = wrapped
|
|
436
461
|
raise wrapped
|
|
462
|
+
ensure
|
|
463
|
+
session&.unregister_in_flight(related_request_id) if related_request_id
|
|
437
464
|
end
|
|
438
465
|
}
|
|
439
466
|
end
|
|
440
467
|
|
|
468
|
+
def handle_cancelled_notification(params, session: nil)
|
|
469
|
+
return unless session
|
|
470
|
+
return unless params.is_a?(Hash)
|
|
471
|
+
|
|
472
|
+
request_id = params[:requestId] || params["requestId"]
|
|
473
|
+
return if request_id.nil?
|
|
474
|
+
|
|
475
|
+
reason = params[:reason] || params["reason"]
|
|
476
|
+
session.cancel_incoming(request_id: request_id, reason: reason)
|
|
477
|
+
end
|
|
478
|
+
|
|
441
479
|
def default_capabilities
|
|
442
480
|
{
|
|
443
481
|
tools: { listChanged: true },
|
|
@@ -516,7 +554,7 @@ module MCP
|
|
|
516
554
|
{ tools: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
517
555
|
end
|
|
518
556
|
|
|
519
|
-
def call_tool(request, session: nil, related_request_id: nil)
|
|
557
|
+
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
520
558
|
tool_name = request[:name]
|
|
521
559
|
|
|
522
560
|
tool = tools[tool_name]
|
|
@@ -533,7 +571,7 @@ module MCP
|
|
|
533
571
|
add_instrumentation_data(error: :missing_required_arguments)
|
|
534
572
|
|
|
535
573
|
missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
|
|
536
|
-
|
|
574
|
+
return error_tool_response("Missing required arguments: #{missing}")
|
|
537
575
|
end
|
|
538
576
|
|
|
539
577
|
if configuration.validate_tool_call_arguments && tool.input_schema
|
|
@@ -542,14 +580,20 @@ module MCP
|
|
|
542
580
|
rescue Tool::InputSchema::ValidationError => e
|
|
543
581
|
add_instrumentation_data(error: :invalid_schema)
|
|
544
582
|
|
|
545
|
-
|
|
583
|
+
return error_tool_response(e.message)
|
|
546
584
|
end
|
|
547
585
|
end
|
|
548
586
|
|
|
549
587
|
progress_token = request.dig(:_meta, :progressToken)
|
|
550
588
|
|
|
551
|
-
|
|
552
|
-
|
|
589
|
+
result = call_tool_with_args(
|
|
590
|
+
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
|
|
591
|
+
)
|
|
592
|
+
validate_tool_call_result!(tool, result)
|
|
593
|
+
result
|
|
594
|
+
rescue RequestHandlerError, CancelledError
|
|
595
|
+
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
|
|
596
|
+
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
|
|
553
597
|
raise
|
|
554
598
|
rescue => e
|
|
555
599
|
raise RequestHandlerError.new(
|
|
@@ -566,7 +610,7 @@ module MCP
|
|
|
566
610
|
{ prompts: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
567
611
|
end
|
|
568
612
|
|
|
569
|
-
def get_prompt(request)
|
|
613
|
+
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
570
614
|
prompt_name = request[:name]
|
|
571
615
|
prompt = @prompts[prompt_name]
|
|
572
616
|
unless prompt
|
|
@@ -579,7 +623,14 @@ module MCP
|
|
|
579
623
|
prompt_args = request[:arguments]
|
|
580
624
|
prompt.validate_arguments!(prompt_args)
|
|
581
625
|
|
|
582
|
-
|
|
626
|
+
server_context = build_server_context(
|
|
627
|
+
request: request,
|
|
628
|
+
session: session,
|
|
629
|
+
related_request_id: related_request_id,
|
|
630
|
+
cancellation: cancellation,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
call_prompt_template_with_args(prompt, prompt_args, server_context)
|
|
583
634
|
end
|
|
584
635
|
|
|
585
636
|
def list_resources(request)
|
|
@@ -600,14 +651,82 @@ module MCP
|
|
|
600
651
|
{ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
601
652
|
end
|
|
602
653
|
|
|
603
|
-
def complete(params)
|
|
654
|
+
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
|
|
604
655
|
validate_completion_params!(params)
|
|
605
656
|
|
|
606
|
-
result =
|
|
657
|
+
result = dispatch_optional_context_handler(
|
|
658
|
+
@handlers[Methods::COMPLETION_COMPLETE],
|
|
659
|
+
params,
|
|
660
|
+
session: session,
|
|
661
|
+
related_request_id: related_request_id,
|
|
662
|
+
cancellation: cancellation,
|
|
663
|
+
)
|
|
607
664
|
|
|
608
665
|
normalize_completion_result(result)
|
|
609
666
|
end
|
|
610
667
|
|
|
668
|
+
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
|
|
669
|
+
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
|
|
670
|
+
# `server_context.raise_if_cancelled!`.
|
|
671
|
+
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
672
|
+
dispatch_optional_context_handler(
|
|
673
|
+
@handlers[Methods::RESOURCES_READ],
|
|
674
|
+
request,
|
|
675
|
+
session: session,
|
|
676
|
+
related_request_id: related_request_id,
|
|
677
|
+
cancellation: cancellation,
|
|
678
|
+
)
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`,
|
|
682
|
+
# `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
|
|
683
|
+
# Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
|
|
684
|
+
# keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
|
|
685
|
+
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
|
|
686
|
+
return handler.call(params) unless handler_declares_server_context?(handler)
|
|
687
|
+
|
|
688
|
+
server_context = build_server_context(
|
|
689
|
+
request: params,
|
|
690
|
+
session: session,
|
|
691
|
+
related_request_id: related_request_id,
|
|
692
|
+
cancellation: cancellation,
|
|
693
|
+
)
|
|
694
|
+
handler.call(params, server_context: server_context)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Stricter than `accepts_server_context?`: requires `server_context` to appear as a named keyword parameter
|
|
698
|
+
# (`:key` optional, `:keyreq` required). Positional parameters named `server_context` (`:req` / `:opt`) are NOT
|
|
699
|
+
# treated as opt-in - otherwise `handler.call(params, server_context: ctx)` would pass the `{server_context: ctx}`
|
|
700
|
+
# Hash as the handler's second positional argument, which is never what the user meant.
|
|
701
|
+
#
|
|
702
|
+
# `**kwargs`-only signatures (`:keyrest` without a named `server_context`) are also not opt-in here,
|
|
703
|
+
# because the dispatch site passes a positional `params`, and a `**kwargs`-only block cannot accept
|
|
704
|
+
# that positional argument (lambdas/methods raise `ArgumentError`; non-lambda procs silently drop `params`).
|
|
705
|
+
# Tool handlers intentionally allow `**kwargs` opt-in via `accepts_server_context?` because they are invoked
|
|
706
|
+
# via `tool.call(**args, server_context: …)` without a positional argument.
|
|
707
|
+
def handler_declares_server_context?(handler)
|
|
708
|
+
return false unless handler.respond_to?(:parameters)
|
|
709
|
+
|
|
710
|
+
handler.parameters.any? do |type, name|
|
|
711
|
+
name == :server_context && (type == :key || type == :keyreq)
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
|
|
716
|
+
# (progress, cancellation, nested server-to-client requests).
|
|
717
|
+
def build_server_context(request:, session:, related_request_id:, cancellation:)
|
|
718
|
+
meta_source = request.is_a?(Hash) ? request : {}
|
|
719
|
+
progress_token = meta_source.dig(:_meta, :progressToken)
|
|
720
|
+
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
|
721
|
+
ServerContext.new(
|
|
722
|
+
server_context_with_meta(meta_source),
|
|
723
|
+
progress: progress,
|
|
724
|
+
notification_target: session,
|
|
725
|
+
related_request_id: related_request_id,
|
|
726
|
+
cancellation: cancellation,
|
|
727
|
+
)
|
|
728
|
+
end
|
|
729
|
+
|
|
611
730
|
def report_exception(exception, server_context = {})
|
|
612
731
|
configuration.exception_reporter.call(exception, server_context)
|
|
613
732
|
end
|
|
@@ -628,18 +747,40 @@ module MCP
|
|
|
628
747
|
).to_h
|
|
629
748
|
end
|
|
630
749
|
|
|
750
|
+
def validate_tool_call_result!(tool, result)
|
|
751
|
+
return unless configuration.validate_tool_call_results
|
|
752
|
+
return unless tool.output_schema
|
|
753
|
+
return if result[:isError]
|
|
754
|
+
|
|
755
|
+
tool.output_schema.validate_result(result[:structuredContent])
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
|
|
759
|
+
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
|
|
760
|
+
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
|
|
761
|
+
# Named keyword `server_context` must be `:key` or `:keyreq` - positional parameters (`:req` / `:opt`) that
|
|
762
|
+
# happen to be named `server_context` are excluded because the call site passes `server_context:` as a keyword,
|
|
763
|
+
# and a positional slot would receive the `{server_context: ctx}` Hash instead.
|
|
631
764
|
def accepts_server_context?(method_object)
|
|
632
765
|
parameters = method_object.parameters
|
|
633
766
|
|
|
634
|
-
parameters.any?
|
|
767
|
+
parameters.any? do |type, name|
|
|
768
|
+
type == :keyrest || (name == :server_context && (type == :key || type == :keyreq))
|
|
769
|
+
end
|
|
635
770
|
end
|
|
636
771
|
|
|
637
|
-
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
|
|
772
|
+
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
|
|
638
773
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
639
774
|
|
|
640
775
|
if accepts_server_context?(tool.method(:call))
|
|
641
776
|
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
|
642
|
-
server_context = ServerContext.new(
|
|
777
|
+
server_context = ServerContext.new(
|
|
778
|
+
context,
|
|
779
|
+
progress: progress,
|
|
780
|
+
notification_target: session,
|
|
781
|
+
related_request_id: related_request_id,
|
|
782
|
+
cancellation: cancellation,
|
|
783
|
+
)
|
|
643
784
|
tool.call(**args, server_context: server_context).to_h
|
|
644
785
|
else
|
|
645
786
|
tool.call(**args).to_h
|
data/lib/mcp/server_context.rb
CHANGED
|
@@ -2,11 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module MCP
|
|
4
4
|
class ServerContext
|
|
5
|
-
|
|
5
|
+
attr_reader :cancellation
|
|
6
|
+
|
|
7
|
+
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
|
|
6
8
|
@context = context
|
|
7
9
|
@progress = progress
|
|
8
10
|
@notification_target = notification_target
|
|
9
11
|
@related_request_id = related_request_id
|
|
12
|
+
@cancellation = cancellation
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cancelled?
|
|
16
|
+
!!@cancellation&.cancelled?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def raise_if_cancelled!
|
|
20
|
+
@cancellation&.raise_if_cancelled!
|
|
10
21
|
end
|
|
11
22
|
|
|
12
23
|
# Reports progress for the current tool operation.
|