mcp 0.13.0 → 0.15.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 +373 -6
- 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 +230 -14
- data/lib/mcp/client/paginated_result.rb +13 -0
- data/lib/mcp/client/stdio.rb +100 -49
- data/lib/mcp/client.rb +235 -22
- data/lib/mcp/methods.rb +2 -4
- data/lib/mcp/server/pagination.rb +42 -0
- data/lib/mcp/server/transports/stdio_transport.rb +7 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +82 -7
- data/lib/mcp/server.rb +204 -33
- data/lib/mcp/server_context.rb +30 -1
- data/lib/mcp/server_session.rb +121 -20
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +8 -5
- data/lib/mcp/transports/stdio.rb +0 -15
data/lib/mcp/server.rb
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
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"
|
|
7
9
|
require_relative "progress"
|
|
8
10
|
require_relative "server_context"
|
|
11
|
+
require_relative "server/pagination"
|
|
9
12
|
require_relative "server/transports"
|
|
10
13
|
|
|
11
14
|
module MCP
|
|
@@ -65,9 +68,10 @@ module MCP
|
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
include Instrumentation
|
|
71
|
+
include Pagination
|
|
68
72
|
|
|
69
73
|
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
|
70
|
-
attr_reader :client_capabilities
|
|
74
|
+
attr_reader :page_size, :client_capabilities
|
|
71
75
|
|
|
72
76
|
def initialize(
|
|
73
77
|
description: nil,
|
|
@@ -84,6 +88,7 @@ module MCP
|
|
|
84
88
|
server_context: nil,
|
|
85
89
|
configuration: nil,
|
|
86
90
|
capabilities: nil,
|
|
91
|
+
page_size: nil,
|
|
87
92
|
transport: nil
|
|
88
93
|
)
|
|
89
94
|
@description = description
|
|
@@ -100,6 +105,7 @@ module MCP
|
|
|
100
105
|
@resource_templates = resource_templates
|
|
101
106
|
@resource_index = index_resources_by_uri(resources)
|
|
102
107
|
@server_context = server_context
|
|
108
|
+
self.page_size = page_size
|
|
103
109
|
@configuration = MCP.configuration.merge(configuration)
|
|
104
110
|
@client = nil
|
|
105
111
|
|
|
@@ -113,6 +119,8 @@ module MCP
|
|
|
113
119
|
Methods::RESOURCES_LIST => method(:list_resources),
|
|
114
120
|
Methods::RESOURCES_READ => method(:read_resource_no_content),
|
|
115
121
|
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
|
|
122
|
+
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
|
|
123
|
+
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
|
|
116
124
|
Methods::TOOLS_LIST => method(:list_tools),
|
|
117
125
|
Methods::TOOLS_CALL => method(:call_tool),
|
|
118
126
|
Methods::PROMPTS_LIST => method(:list_prompts),
|
|
@@ -121,12 +129,9 @@ module MCP
|
|
|
121
129
|
Methods::PING => ->(_) { {} },
|
|
122
130
|
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
|
|
123
131
|
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
|
|
132
|
+
Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
|
|
124
133
|
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
|
|
125
134
|
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
|
|
126
|
-
|
|
127
|
-
# No op handlers for currently unsupported methods
|
|
128
|
-
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
|
|
129
|
-
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
|
|
130
135
|
}
|
|
131
136
|
@transport = transport
|
|
132
137
|
end
|
|
@@ -182,6 +187,14 @@ module MCP
|
|
|
182
187
|
@handlers[method_name] = block
|
|
183
188
|
end
|
|
184
189
|
|
|
190
|
+
def page_size=(page_size)
|
|
191
|
+
unless page_size.nil? || (page_size.is_a?(Integer) && page_size > 0)
|
|
192
|
+
raise ArgumentError, "page_size must be nil or a positive integer"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@page_size = page_size
|
|
196
|
+
end
|
|
197
|
+
|
|
185
198
|
def notify_tools_list_changed
|
|
186
199
|
return unless @transport
|
|
187
200
|
|
|
@@ -218,6 +231,14 @@ module MCP
|
|
|
218
231
|
report_exception(e, { notification: "log_message" })
|
|
219
232
|
end
|
|
220
233
|
|
|
234
|
+
# Sets a handler for `notifications/roots/list_changed` notifications.
|
|
235
|
+
# Called when a client notifies the server that its filesystem roots have changed.
|
|
236
|
+
#
|
|
237
|
+
# @yield [params] The notification params (typically `nil`).
|
|
238
|
+
def roots_list_changed_handler(&block)
|
|
239
|
+
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
|
|
240
|
+
end
|
|
241
|
+
|
|
221
242
|
# Sets a custom handler for `resources/read` requests.
|
|
222
243
|
# The block receives the parsed request params and should return resource
|
|
223
244
|
# contents. The return value is set as the `contents` field of the response.
|
|
@@ -237,6 +258,24 @@ module MCP
|
|
|
237
258
|
@handlers[Methods::COMPLETION_COMPLETE] = block
|
|
238
259
|
end
|
|
239
260
|
|
|
261
|
+
# Sets a custom handler for `resources/subscribe` requests.
|
|
262
|
+
# The block receives the parsed request params. The return value is
|
|
263
|
+
# ignored; the response is always an empty result `{}` per the MCP specification.
|
|
264
|
+
#
|
|
265
|
+
# @yield [params] The request params containing `:uri`.
|
|
266
|
+
def resources_subscribe_handler(&block)
|
|
267
|
+
@handlers[Methods::RESOURCES_SUBSCRIBE] = block
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Sets a custom handler for `resources/unsubscribe` requests.
|
|
271
|
+
# The block receives the parsed request params. The return value is
|
|
272
|
+
# ignored; the response is always an empty result `{}` per the MCP specification.
|
|
273
|
+
#
|
|
274
|
+
# @yield [params] The request params containing `:uri`.
|
|
275
|
+
def resources_unsubscribe_handler(&block)
|
|
276
|
+
@handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
|
|
277
|
+
end
|
|
278
|
+
|
|
240
279
|
def build_sampling_params(
|
|
241
280
|
capabilities,
|
|
242
281
|
messages:,
|
|
@@ -347,6 +386,13 @@ module MCP
|
|
|
347
386
|
end
|
|
348
387
|
|
|
349
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
|
+
|
|
350
396
|
handler = @handlers[method]
|
|
351
397
|
unless handler
|
|
352
398
|
instrument_call("unsupported_method", server_context: { request: request }) do
|
|
@@ -358,6 +404,12 @@ module MCP
|
|
|
358
404
|
|
|
359
405
|
Methods.ensure_capability!(method, capabilities)
|
|
360
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
|
+
|
|
361
413
|
->(params) {
|
|
362
414
|
reported_exception = nil
|
|
363
415
|
instrument_call(
|
|
@@ -368,29 +420,34 @@ module MCP
|
|
|
368
420
|
result = case method
|
|
369
421
|
when Methods::INITIALIZE
|
|
370
422
|
init(params, session: session)
|
|
371
|
-
when Methods::TOOLS_LIST
|
|
372
|
-
{ tools: @handlers[Methods::TOOLS_LIST].call(params) }
|
|
373
|
-
when Methods::PROMPTS_LIST
|
|
374
|
-
{ prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
|
|
375
|
-
when Methods::RESOURCES_LIST
|
|
376
|
-
{ resources: @handlers[Methods::RESOURCES_LIST].call(params) }
|
|
377
423
|
when Methods::RESOURCES_READ
|
|
378
|
-
{ contents:
|
|
379
|
-
when Methods::
|
|
380
|
-
|
|
424
|
+
{ contents: read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation) }
|
|
425
|
+
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
|
426
|
+
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
427
|
+
{}
|
|
381
428
|
when Methods::TOOLS_CALL
|
|
382
|
-
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)
|
|
383
432
|
when Methods::COMPLETION_COMPLETE
|
|
384
|
-
complete(params)
|
|
433
|
+
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
385
434
|
when Methods::LOGGING_SET_LEVEL
|
|
386
435
|
configure_logging_level(params, session: session)
|
|
387
436
|
else
|
|
388
|
-
@handlers[method]
|
|
437
|
+
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
389
438
|
end
|
|
390
439
|
client = session&.client || @client
|
|
391
440
|
add_instrumentation_data(client: client) if client
|
|
392
441
|
|
|
442
|
+
if cancellation&.cancelled?
|
|
443
|
+
add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
|
|
444
|
+
next JsonRpcHandler::NO_RESPONSE
|
|
445
|
+
end
|
|
446
|
+
|
|
393
447
|
result
|
|
448
|
+
rescue CancelledError => e
|
|
449
|
+
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
|
450
|
+
next JsonRpcHandler::NO_RESPONSE
|
|
394
451
|
rescue RequestHandlerError => e
|
|
395
452
|
report_exception(e.original_error || e, { request: request })
|
|
396
453
|
add_instrumentation_data(error: e.error_type)
|
|
@@ -402,10 +459,23 @@ module MCP
|
|
|
402
459
|
wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
|
|
403
460
|
reported_exception = wrapped
|
|
404
461
|
raise wrapped
|
|
462
|
+
ensure
|
|
463
|
+
session&.unregister_in_flight(related_request_id) if related_request_id
|
|
405
464
|
end
|
|
406
465
|
}
|
|
407
466
|
end
|
|
408
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
|
+
|
|
409
479
|
def default_capabilities
|
|
410
480
|
{
|
|
411
481
|
tools: { listChanged: true },
|
|
@@ -479,10 +549,12 @@ module MCP
|
|
|
479
549
|
end
|
|
480
550
|
|
|
481
551
|
def list_tools(request)
|
|
482
|
-
@tools.values
|
|
552
|
+
page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
553
|
+
|
|
554
|
+
{ tools: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
483
555
|
end
|
|
484
556
|
|
|
485
|
-
def call_tool(request, session: nil, related_request_id: nil)
|
|
557
|
+
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
486
558
|
tool_name = request[:name]
|
|
487
559
|
|
|
488
560
|
tool = tools[tool_name]
|
|
@@ -499,7 +571,7 @@ module MCP
|
|
|
499
571
|
add_instrumentation_data(error: :missing_required_arguments)
|
|
500
572
|
|
|
501
573
|
missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
|
|
502
|
-
|
|
574
|
+
return error_tool_response("Missing required arguments: #{missing}")
|
|
503
575
|
end
|
|
504
576
|
|
|
505
577
|
if configuration.validate_tool_call_arguments && tool.input_schema
|
|
@@ -508,14 +580,18 @@ module MCP
|
|
|
508
580
|
rescue Tool::InputSchema::ValidationError => e
|
|
509
581
|
add_instrumentation_data(error: :invalid_schema)
|
|
510
582
|
|
|
511
|
-
|
|
583
|
+
return error_tool_response(e.message)
|
|
512
584
|
end
|
|
513
585
|
end
|
|
514
586
|
|
|
515
587
|
progress_token = request.dig(:_meta, :progressToken)
|
|
516
588
|
|
|
517
|
-
call_tool_with_args(
|
|
518
|
-
|
|
589
|
+
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
|
+
rescue RequestHandlerError, CancelledError
|
|
593
|
+
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
|
|
594
|
+
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
|
|
519
595
|
raise
|
|
520
596
|
rescue => e
|
|
521
597
|
raise RequestHandlerError.new(
|
|
@@ -527,10 +603,12 @@ module MCP
|
|
|
527
603
|
end
|
|
528
604
|
|
|
529
605
|
def list_prompts(request)
|
|
530
|
-
@prompts.values
|
|
606
|
+
page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
607
|
+
|
|
608
|
+
{ prompts: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
531
609
|
end
|
|
532
610
|
|
|
533
|
-
def get_prompt(request)
|
|
611
|
+
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
534
612
|
prompt_name = request[:name]
|
|
535
613
|
prompt = @prompts[prompt_name]
|
|
536
614
|
unless prompt
|
|
@@ -543,11 +621,20 @@ module MCP
|
|
|
543
621
|
prompt_args = request[:arguments]
|
|
544
622
|
prompt.validate_arguments!(prompt_args)
|
|
545
623
|
|
|
546
|
-
|
|
624
|
+
server_context = build_server_context(
|
|
625
|
+
request: request,
|
|
626
|
+
session: session,
|
|
627
|
+
related_request_id: related_request_id,
|
|
628
|
+
cancellation: cancellation,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
call_prompt_template_with_args(prompt, prompt_args, server_context)
|
|
547
632
|
end
|
|
548
633
|
|
|
549
634
|
def list_resources(request)
|
|
550
|
-
@resources
|
|
635
|
+
page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
636
|
+
|
|
637
|
+
{ resources: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
551
638
|
end
|
|
552
639
|
|
|
553
640
|
# Server implementation should set `resources_read_handler` to override no-op default
|
|
@@ -557,17 +644,87 @@ module MCP
|
|
|
557
644
|
end
|
|
558
645
|
|
|
559
646
|
def list_resource_templates(request)
|
|
560
|
-
@resource_templates
|
|
647
|
+
page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
648
|
+
|
|
649
|
+
{ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
561
650
|
end
|
|
562
651
|
|
|
563
|
-
def complete(params)
|
|
652
|
+
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
|
|
564
653
|
validate_completion_params!(params)
|
|
565
654
|
|
|
566
|
-
result =
|
|
655
|
+
result = dispatch_optional_context_handler(
|
|
656
|
+
@handlers[Methods::COMPLETION_COMPLETE],
|
|
657
|
+
params,
|
|
658
|
+
session: session,
|
|
659
|
+
related_request_id: related_request_id,
|
|
660
|
+
cancellation: cancellation,
|
|
661
|
+
)
|
|
567
662
|
|
|
568
663
|
normalize_completion_result(result)
|
|
569
664
|
end
|
|
570
665
|
|
|
666
|
+
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
|
|
667
|
+
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
|
|
668
|
+
# `server_context.raise_if_cancelled!`.
|
|
669
|
+
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
670
|
+
dispatch_optional_context_handler(
|
|
671
|
+
@handlers[Methods::RESOURCES_READ],
|
|
672
|
+
request,
|
|
673
|
+
session: session,
|
|
674
|
+
related_request_id: related_request_id,
|
|
675
|
+
cancellation: cancellation,
|
|
676
|
+
)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`,
|
|
680
|
+
# `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
|
|
681
|
+
# Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
|
|
682
|
+
# keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
|
|
683
|
+
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
|
|
684
|
+
return handler.call(params) unless handler_declares_server_context?(handler)
|
|
685
|
+
|
|
686
|
+
server_context = build_server_context(
|
|
687
|
+
request: params,
|
|
688
|
+
session: session,
|
|
689
|
+
related_request_id: related_request_id,
|
|
690
|
+
cancellation: cancellation,
|
|
691
|
+
)
|
|
692
|
+
handler.call(params, server_context: server_context)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Stricter than `accepts_server_context?`: requires `server_context` to appear as a named keyword parameter
|
|
696
|
+
# (`:key` optional, `:keyreq` required). Positional parameters named `server_context` (`:req` / `:opt`) are NOT
|
|
697
|
+
# treated as opt-in - otherwise `handler.call(params, server_context: ctx)` would pass the `{server_context: ctx}`
|
|
698
|
+
# Hash as the handler's second positional argument, which is never what the user meant.
|
|
699
|
+
#
|
|
700
|
+
# `**kwargs`-only signatures (`:keyrest` without a named `server_context`) are also not opt-in here,
|
|
701
|
+
# because the dispatch site passes a positional `params`, and a `**kwargs`-only block cannot accept
|
|
702
|
+
# that positional argument (lambdas/methods raise `ArgumentError`; non-lambda procs silently drop `params`).
|
|
703
|
+
# Tool handlers intentionally allow `**kwargs` opt-in via `accepts_server_context?` because they are invoked
|
|
704
|
+
# via `tool.call(**args, server_context: …)` without a positional argument.
|
|
705
|
+
def handler_declares_server_context?(handler)
|
|
706
|
+
return false unless handler.respond_to?(:parameters)
|
|
707
|
+
|
|
708
|
+
handler.parameters.any? do |type, name|
|
|
709
|
+
name == :server_context && (type == :key || type == :keyreq)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
|
|
714
|
+
# (progress, cancellation, nested server-to-client requests).
|
|
715
|
+
def build_server_context(request:, session:, related_request_id:, cancellation:)
|
|
716
|
+
meta_source = request.is_a?(Hash) ? request : {}
|
|
717
|
+
progress_token = meta_source.dig(:_meta, :progressToken)
|
|
718
|
+
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
|
719
|
+
ServerContext.new(
|
|
720
|
+
server_context_with_meta(meta_source),
|
|
721
|
+
progress: progress,
|
|
722
|
+
notification_target: session,
|
|
723
|
+
related_request_id: related_request_id,
|
|
724
|
+
cancellation: cancellation,
|
|
725
|
+
)
|
|
726
|
+
end
|
|
727
|
+
|
|
571
728
|
def report_exception(exception, server_context = {})
|
|
572
729
|
configuration.exception_reporter.call(exception, server_context)
|
|
573
730
|
end
|
|
@@ -588,18 +745,32 @@ module MCP
|
|
|
588
745
|
).to_h
|
|
589
746
|
end
|
|
590
747
|
|
|
748
|
+
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
|
|
749
|
+
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
|
|
750
|
+
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
|
|
751
|
+
# Named keyword `server_context` must be `:key` or `:keyreq` - positional parameters (`:req` / `:opt`) that
|
|
752
|
+
# happen to be named `server_context` are excluded because the call site passes `server_context:` as a keyword,
|
|
753
|
+
# and a positional slot would receive the `{server_context: ctx}` Hash instead.
|
|
591
754
|
def accepts_server_context?(method_object)
|
|
592
755
|
parameters = method_object.parameters
|
|
593
756
|
|
|
594
|
-
parameters.any?
|
|
757
|
+
parameters.any? do |type, name|
|
|
758
|
+
type == :keyrest || (name == :server_context && (type == :key || type == :keyreq))
|
|
759
|
+
end
|
|
595
760
|
end
|
|
596
761
|
|
|
597
|
-
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
|
|
762
|
+
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
|
|
598
763
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
599
764
|
|
|
600
765
|
if accepts_server_context?(tool.method(:call))
|
|
601
766
|
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
|
602
|
-
server_context = ServerContext.new(
|
|
767
|
+
server_context = ServerContext.new(
|
|
768
|
+
context,
|
|
769
|
+
progress: progress,
|
|
770
|
+
notification_target: session,
|
|
771
|
+
related_request_id: related_request_id,
|
|
772
|
+
cancellation: cancellation,
|
|
773
|
+
)
|
|
603
774
|
tool.call(**args, server_context: server_context).to_h
|
|
604
775
|
else
|
|
605
776
|
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.
|
|
@@ -30,6 +41,24 @@ module MCP
|
|
|
30
41
|
@notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
|
|
31
42
|
end
|
|
32
43
|
|
|
44
|
+
# Sends a resource updated notification scoped to the originating session.
|
|
45
|
+
#
|
|
46
|
+
# @param uri [String] The URI of the updated resource.
|
|
47
|
+
def notify_resources_updated(uri:)
|
|
48
|
+
return unless @notification_target
|
|
49
|
+
|
|
50
|
+
@notification_target.notify_resources_updated(uri: uri)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Delegates to the session so the request is scoped to the originating client.
|
|
54
|
+
def list_roots
|
|
55
|
+
if @notification_target.respond_to?(:list_roots)
|
|
56
|
+
@notification_target.list_roots(related_request_id: @related_request_id)
|
|
57
|
+
else
|
|
58
|
+
raise NoMethodError, "undefined method 'list_roots' for #{self}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
33
62
|
# Delegates to the session so the request is scoped to the originating client.
|
|
34
63
|
# Falls back to `@context` (via `method_missing`) when `@notification_target`
|
|
35
64
|
# does not support sampling.
|
data/lib/mcp/server_session.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "cancellation"
|
|
3
4
|
require_relative "methods"
|
|
4
5
|
|
|
5
6
|
module MCP
|
|
@@ -15,6 +16,48 @@ module MCP
|
|
|
15
16
|
@client = nil
|
|
16
17
|
@client_capabilities = nil
|
|
17
18
|
@logging_message_notification = nil
|
|
19
|
+
@in_flight = {}
|
|
20
|
+
@in_flight_mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Registers a `Cancellation` token for an in-flight request.
|
|
24
|
+
def register_in_flight(request_id)
|
|
25
|
+
return if request_id.nil?
|
|
26
|
+
|
|
27
|
+
cancellation = Cancellation.new(request_id: request_id)
|
|
28
|
+
@in_flight_mutex.synchronize { @in_flight[request_id] = cancellation }
|
|
29
|
+
cancellation
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def unregister_in_flight(request_id)
|
|
33
|
+
return if request_id.nil?
|
|
34
|
+
|
|
35
|
+
@in_flight_mutex.synchronize { @in_flight.delete(request_id) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def lookup_in_flight(request_id)
|
|
39
|
+
@in_flight_mutex.synchronize { @in_flight[request_id] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Flips the `Cancellation` for a matching in-flight request received from the peer.
|
|
43
|
+
# Silently ignores unknown IDs per MCP spec (cancellation utilities, item 5).
|
|
44
|
+
def cancel_incoming(request_id:, reason: nil)
|
|
45
|
+
cancellation = lookup_in_flight(request_id)
|
|
46
|
+
cancellation&.cancel(reason: reason)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Sends `notifications/cancelled` to the peer for a previously-issued request.
|
|
50
|
+
# Also unblocks any transport-level `send_request` waiting on a response for `request_id`.
|
|
51
|
+
def cancel_request(request_id:, reason: nil)
|
|
52
|
+
params = { requestId: request_id }
|
|
53
|
+
params[:reason] = reason if reason
|
|
54
|
+
send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params)
|
|
55
|
+
|
|
56
|
+
if @transport.respond_to?(:cancel_pending_request)
|
|
57
|
+
@transport.cancel_pending_request(request_id, reason: reason)
|
|
58
|
+
end
|
|
59
|
+
rescue => e
|
|
60
|
+
MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: request_id })
|
|
18
61
|
end
|
|
19
62
|
|
|
20
63
|
def handle(request)
|
|
@@ -41,6 +84,15 @@ module MCP
|
|
|
41
84
|
@client_capabilities || @server.client_capabilities
|
|
42
85
|
end
|
|
43
86
|
|
|
87
|
+
# Sends a `roots/list` request scoped to this session.
|
|
88
|
+
def list_roots(related_request_id: nil)
|
|
89
|
+
unless client_capabilities&.dig(:roots)
|
|
90
|
+
raise "Client does not support roots."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
|
|
94
|
+
end
|
|
95
|
+
|
|
44
96
|
# Sends a `sampling/createMessage` request scoped to this session.
|
|
45
97
|
def create_sampling_message(related_request_id: nil, **kwargs)
|
|
46
98
|
params = @server.build_sampling_params(client_capabilities, **kwargs)
|
|
@@ -69,6 +121,23 @@ module MCP
|
|
|
69
121
|
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
|
|
70
122
|
end
|
|
71
123
|
|
|
124
|
+
# Sends `notifications/cancelled` to the peer for a nested server-to-client request
|
|
125
|
+
# that was started inside a now-cancelled parent request. `related_request_id`
|
|
126
|
+
# is the parent request id so the notification is routed to the same stream
|
|
127
|
+
# (e.g. the parent's POST response stream on `StreamableHTTPTransport`) rather than
|
|
128
|
+
# the GET SSE stream.
|
|
129
|
+
def send_peer_cancellation(nested_request_id:, related_request_id: nil, reason: nil)
|
|
130
|
+
params = { requestId: nested_request_id }
|
|
131
|
+
params[:reason] = reason if reason
|
|
132
|
+
send_to_transport(Methods::NOTIFICATIONS_CANCELLED, params, related_request_id: related_request_id)
|
|
133
|
+
|
|
134
|
+
if @transport.respond_to?(:cancel_pending_request)
|
|
135
|
+
@transport.cancel_pending_request(nested_request_id, reason: reason)
|
|
136
|
+
end
|
|
137
|
+
rescue => e
|
|
138
|
+
MCP.configuration.exception_reporter.call(e, { notification: "cancelled", request_id: nested_request_id })
|
|
139
|
+
end
|
|
140
|
+
|
|
72
141
|
# Sends an elicitation complete notification scoped to this session.
|
|
73
142
|
def notify_elicitation_complete(elicitation_id:)
|
|
74
143
|
send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
|
|
@@ -76,6 +145,13 @@ module MCP
|
|
|
76
145
|
@server.report_exception(e, notification: "elicitation_complete")
|
|
77
146
|
end
|
|
78
147
|
|
|
148
|
+
# Sends a resource updated notification to this session only.
|
|
149
|
+
def notify_resources_updated(uri:)
|
|
150
|
+
send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
|
|
151
|
+
rescue => e
|
|
152
|
+
@server.report_exception(e, notification: "resources_updated")
|
|
153
|
+
end
|
|
154
|
+
|
|
79
155
|
# Sends a progress notification to this session only.
|
|
80
156
|
def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
|
|
81
157
|
params = {
|
|
@@ -105,32 +181,57 @@ module MCP
|
|
|
105
181
|
|
|
106
182
|
private
|
|
107
183
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
# `@transport.send_notification(method, params, session_id: @session_id)` and
|
|
113
|
-
# add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
|
|
184
|
+
# Forwards `send_notification` to the transport with only the kwargs the transport's method signature
|
|
185
|
+
# actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
|
|
186
|
+
# contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`
|
|
187
|
+
# receive the session-scoped routing information.
|
|
114
188
|
def send_to_transport(method, params, related_request_id: nil)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
189
|
+
kwargs = {
|
|
190
|
+
session_id: @session_id,
|
|
191
|
+
related_request_id: related_request_id,
|
|
192
|
+
}.compact
|
|
193
|
+
|
|
194
|
+
forward_to_transport(@transport.method(:send_notification), method, params, kwargs)
|
|
120
195
|
end
|
|
121
196
|
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
197
|
+
# Forwards `send_request` to the transport with only the kwargs the transport's method signature
|
|
198
|
+
# actually accepts. Custom transports that implement the abstract `send_request(method, params = nil)`
|
|
199
|
+
# contract continue to work; bundled transports that declare `session_id:` / `related_request_id:` /
|
|
200
|
+
# `parent_cancellation:` / `server_session:` receive the nested-cancellation plumbing.
|
|
201
|
+
# When `related_request_id` names an in-flight request, its `Cancellation` token is looked up
|
|
202
|
+
# so that cancelling the parent also cancels this nested server-to-client request.
|
|
128
203
|
def send_to_transport_request(method, params, related_request_id: nil)
|
|
129
|
-
|
|
130
|
-
|
|
204
|
+
parent_cancellation = related_request_id ? lookup_in_flight(related_request_id) : nil
|
|
205
|
+
|
|
206
|
+
kwargs = {
|
|
207
|
+
session_id: @session_id,
|
|
208
|
+
related_request_id: related_request_id,
|
|
209
|
+
parent_cancellation: parent_cancellation,
|
|
210
|
+
server_session: self,
|
|
211
|
+
}.compact
|
|
212
|
+
|
|
213
|
+
forward_to_transport(@transport.method(:send_request), method, params, kwargs)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Calls `transport_method(method, params, **supported)` where `supported` contains only the keys
|
|
217
|
+
# the transport's method signature accepts. This keeps bundled transports (which declare the new kwargs)
|
|
218
|
+
# working while preserving compatibility with custom transports that implement only the abstract
|
|
219
|
+
# `(method, params = nil)` contract.
|
|
220
|
+
def forward_to_transport(transport_method, method, params, kwargs)
|
|
221
|
+
parameters = transport_method.parameters
|
|
222
|
+
accepts_keyrest = parameters.any? { |type, _| type == :keyrest }
|
|
223
|
+
supported = if accepts_keyrest
|
|
224
|
+
kwargs
|
|
131
225
|
else
|
|
132
|
-
|
|
226
|
+
allowed = parameters.filter_map { |type, name| name if type == :key || type == :keyreq }
|
|
227
|
+
kwargs.slice(*allowed)
|
|
133
228
|
end
|
|
229
|
+
|
|
230
|
+
# Always splat `**supported` even when empty: on Ruby 2.7 the bare `transport_method.call(method, params)`
|
|
231
|
+
# form would let the trailing `params` Hash be auto-promoted to keyword arguments when the receiver
|
|
232
|
+
# accepts `**kwargs`, breaking handlers that rely on `params` arriving as a positional Hash.
|
|
233
|
+
# The explicit splat suppresses that conversion and is a no-op when `supported` is empty.
|
|
234
|
+
transport_method.call(method, params, **supported)
|
|
134
235
|
end
|
|
135
236
|
end
|
|
136
237
|
end
|
data/lib/mcp/version.rb
CHANGED
data/lib/mcp.rb
CHANGED
|
@@ -8,6 +8,8 @@ require_relative "mcp/version"
|
|
|
8
8
|
|
|
9
9
|
module MCP
|
|
10
10
|
autoload :Annotations, "mcp/annotations"
|
|
11
|
+
autoload :Cancellation, "mcp/cancellation"
|
|
12
|
+
autoload :CancelledError, "mcp/cancelled_error"
|
|
11
13
|
autoload :Client, "mcp/client"
|
|
12
14
|
autoload :Content, "mcp/content"
|
|
13
15
|
autoload :Icon, "mcp/icon"
|