mcp 0.10.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 +1384 -691
- data/lib/json_rpc_handler.rb +23 -10
- data/lib/mcp/cancellation.rb +72 -0
- data/lib/mcp/cancelled_error.rb +13 -0
- data/lib/mcp/client/http.rb +234 -15
- data/lib/mcp/client/paginated_result.rb +13 -0
- data/lib/mcp/client/stdio.rb +100 -49
- data/lib/mcp/client.rb +293 -66
- data/lib/mcp/configuration.rb +38 -2
- data/lib/mcp/content.rb +16 -12
- data/lib/mcp/instrumentation.rb +23 -2
- data/lib/mcp/methods.rb +4 -5
- data/lib/mcp/progress.rb +3 -1
- data/lib/mcp/prompt/result.rb +4 -3
- data/lib/mcp/resource/contents.rb +8 -7
- data/lib/mcp/resource.rb +4 -2
- data/lib/mcp/resource_template.rb +4 -2
- data/lib/mcp/server/pagination.rb +42 -0
- data/lib/mcp/server/transports/stdio_transport.rb +42 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +383 -88
- data/lib/mcp/server.rb +361 -50
- data/lib/mcp/server_context.rb +81 -2
- data/lib/mcp/server_session.rb +170 -12
- data/lib/mcp/tool/response.rb +4 -3
- data/lib/mcp/tool/schema.rb +1 -14
- data/lib/mcp/transport.rb +14 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +11 -6
- 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
|
|
@@ -24,15 +27,34 @@ module MCP
|
|
|
24
27
|
UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18 = [:description, :icons].freeze
|
|
25
28
|
UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26 = [:title, :websiteUrl].freeze
|
|
26
29
|
|
|
30
|
+
DEFAULT_COMPLETION_RESULT = { completion: { values: [], hasMore: false } }.freeze
|
|
31
|
+
|
|
32
|
+
# Servers return an array of completion values ranked by relevance, with maximum 100 items per response.
|
|
33
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#completion-results
|
|
34
|
+
MAX_COMPLETION_VALUES = 100
|
|
35
|
+
|
|
27
36
|
class RequestHandlerError < StandardError
|
|
28
|
-
attr_reader :error_type
|
|
29
|
-
attr_reader :original_error
|
|
37
|
+
attr_reader :error_type, :original_error, :error_code, :error_data
|
|
30
38
|
|
|
31
|
-
def initialize(message, request, error_type: :internal_error, original_error: nil)
|
|
39
|
+
def initialize(message, request, error_type: :internal_error, original_error: nil, error_code: nil, error_data: nil)
|
|
32
40
|
super(message)
|
|
33
41
|
@request = request
|
|
34
42
|
@error_type = error_type
|
|
35
43
|
@original_error = original_error
|
|
44
|
+
@error_code = error_code
|
|
45
|
+
@error_data = error_data
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class URLElicitationRequiredError < RequestHandlerError
|
|
50
|
+
def initialize(elicitations)
|
|
51
|
+
super(
|
|
52
|
+
"URL elicitation required",
|
|
53
|
+
nil,
|
|
54
|
+
error_type: :url_elicitation_required,
|
|
55
|
+
error_code: -32042,
|
|
56
|
+
error_data: { elicitations: elicitations },
|
|
57
|
+
)
|
|
36
58
|
end
|
|
37
59
|
end
|
|
38
60
|
|
|
@@ -46,8 +68,10 @@ module MCP
|
|
|
46
68
|
end
|
|
47
69
|
|
|
48
70
|
include Instrumentation
|
|
71
|
+
include Pagination
|
|
49
72
|
|
|
50
73
|
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
|
74
|
+
attr_reader :page_size, :client_capabilities
|
|
51
75
|
|
|
52
76
|
def initialize(
|
|
53
77
|
description: nil,
|
|
@@ -64,6 +88,7 @@ module MCP
|
|
|
64
88
|
server_context: nil,
|
|
65
89
|
configuration: nil,
|
|
66
90
|
capabilities: nil,
|
|
91
|
+
page_size: nil,
|
|
67
92
|
transport: nil
|
|
68
93
|
)
|
|
69
94
|
@description = description
|
|
@@ -80,18 +105,22 @@ module MCP
|
|
|
80
105
|
@resource_templates = resource_templates
|
|
81
106
|
@resource_index = index_resources_by_uri(resources)
|
|
82
107
|
@server_context = server_context
|
|
108
|
+
self.page_size = page_size
|
|
83
109
|
@configuration = MCP.configuration.merge(configuration)
|
|
84
110
|
@client = nil
|
|
85
111
|
|
|
86
112
|
validate!
|
|
87
113
|
|
|
88
114
|
@capabilities = capabilities || default_capabilities
|
|
115
|
+
@client_capabilities = nil
|
|
89
116
|
@logging_message_notification = nil
|
|
90
117
|
|
|
91
118
|
@handlers = {
|
|
92
119
|
Methods::RESOURCES_LIST => method(:list_resources),
|
|
93
120
|
Methods::RESOURCES_READ => method(:read_resource_no_content),
|
|
94
121
|
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
|
|
122
|
+
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
|
|
123
|
+
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
|
|
95
124
|
Methods::TOOLS_LIST => method(:list_tools),
|
|
96
125
|
Methods::TOOLS_CALL => method(:call_tool),
|
|
97
126
|
Methods::PROMPTS_LIST => method(:list_prompts),
|
|
@@ -100,13 +129,9 @@ module MCP
|
|
|
100
129
|
Methods::PING => ->(_) { {} },
|
|
101
130
|
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
|
|
102
131
|
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
|
|
132
|
+
Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
|
|
133
|
+
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
|
|
103
134
|
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
|
|
104
|
-
|
|
105
|
-
# No op handlers for currently unsupported methods
|
|
106
|
-
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
|
|
107
|
-
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
|
|
108
|
-
Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } },
|
|
109
|
-
Methods::ELICITATION_CREATE => ->(_) {},
|
|
110
135
|
}
|
|
111
136
|
@transport = transport
|
|
112
137
|
end
|
|
@@ -119,8 +144,8 @@ module MCP
|
|
|
119
144
|
# When `nil`, progress and logging notifications from tool handlers are silently skipped.
|
|
120
145
|
# @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
|
|
121
146
|
def handle(request, session: nil)
|
|
122
|
-
JsonRpcHandler.handle(request) do |method|
|
|
123
|
-
handle_request(request, method, session: session)
|
|
147
|
+
JsonRpcHandler.handle(request) do |method, request_id|
|
|
148
|
+
handle_request(request, method, session: session, related_request_id: request_id)
|
|
124
149
|
end
|
|
125
150
|
end
|
|
126
151
|
|
|
@@ -132,8 +157,8 @@ module MCP
|
|
|
132
157
|
# When `nil`, progress and logging notifications from tool handlers are silently skipped.
|
|
133
158
|
# @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
|
|
134
159
|
def handle_json(request, session: nil)
|
|
135
|
-
JsonRpcHandler.handle_json(request) do |method|
|
|
136
|
-
handle_request(request, method, session: session)
|
|
160
|
+
JsonRpcHandler.handle_json(request) do |method, request_id|
|
|
161
|
+
handle_request(request, method, session: session, related_request_id: request_id)
|
|
137
162
|
end
|
|
138
163
|
end
|
|
139
164
|
|
|
@@ -162,6 +187,14 @@ module MCP
|
|
|
162
187
|
@handlers[method_name] = block
|
|
163
188
|
end
|
|
164
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
|
+
|
|
165
198
|
def notify_tools_list_changed
|
|
166
199
|
return unless @transport
|
|
167
200
|
|
|
@@ -198,6 +231,14 @@ module MCP
|
|
|
198
231
|
report_exception(e, { notification: "log_message" })
|
|
199
232
|
end
|
|
200
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
|
+
|
|
201
242
|
# Sets a custom handler for `resources/read` requests.
|
|
202
243
|
# The block receives the parsed request params and should return resource
|
|
203
244
|
# contents. The return value is set as the `contents` field of the response.
|
|
@@ -208,6 +249,72 @@ module MCP
|
|
|
208
249
|
@handlers[Methods::RESOURCES_READ] = block
|
|
209
250
|
end
|
|
210
251
|
|
|
252
|
+
# Sets a custom handler for `completion/complete` requests.
|
|
253
|
+
# The block receives the parsed request params and should return completion values.
|
|
254
|
+
#
|
|
255
|
+
# @yield [params] The request params containing `:ref`, `:argument`, and optionally `:context`.
|
|
256
|
+
# @yieldreturn [Hash] A hash with `:completion` key containing `:values`, optional `:total`, and `:hasMore`.
|
|
257
|
+
def completion_handler(&block)
|
|
258
|
+
@handlers[Methods::COMPLETION_COMPLETE] = block
|
|
259
|
+
end
|
|
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
|
+
|
|
279
|
+
def build_sampling_params(
|
|
280
|
+
capabilities,
|
|
281
|
+
messages:,
|
|
282
|
+
max_tokens:,
|
|
283
|
+
system_prompt: nil,
|
|
284
|
+
model_preferences: nil,
|
|
285
|
+
include_context: nil,
|
|
286
|
+
temperature: nil,
|
|
287
|
+
stop_sequences: nil,
|
|
288
|
+
metadata: nil,
|
|
289
|
+
tools: nil,
|
|
290
|
+
tool_choice: nil
|
|
291
|
+
)
|
|
292
|
+
unless capabilities&.dig(:sampling)
|
|
293
|
+
raise "Client does not support sampling."
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
if tools && !capabilities.dig(:sampling, :tools)
|
|
297
|
+
raise "Client does not support sampling with tools."
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
if tool_choice && !capabilities.dig(:sampling, :tools)
|
|
301
|
+
raise "Client does not support sampling with tool_choice."
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
{
|
|
305
|
+
messages: messages,
|
|
306
|
+
maxTokens: max_tokens,
|
|
307
|
+
systemPrompt: system_prompt,
|
|
308
|
+
modelPreferences: model_preferences,
|
|
309
|
+
includeContext: include_context,
|
|
310
|
+
temperature: temperature,
|
|
311
|
+
stopSequences: stop_sequences,
|
|
312
|
+
metadata: metadata,
|
|
313
|
+
tools: tools,
|
|
314
|
+
toolChoice: tool_choice,
|
|
315
|
+
}.compact
|
|
316
|
+
end
|
|
317
|
+
|
|
211
318
|
private
|
|
212
319
|
|
|
213
320
|
def validate!
|
|
@@ -278,10 +385,17 @@ module MCP
|
|
|
278
385
|
end
|
|
279
386
|
end
|
|
280
387
|
|
|
281
|
-
def handle_request(request, method, session: nil)
|
|
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
|
+
|
|
282
396
|
handler = @handlers[method]
|
|
283
397
|
unless handler
|
|
284
|
-
instrument_call("unsupported_method") do
|
|
398
|
+
instrument_call("unsupported_method", server_context: { request: request }) do
|
|
285
399
|
client = session&.client || @client
|
|
286
400
|
add_instrumentation_data(client: client) if client
|
|
287
401
|
end
|
|
@@ -290,45 +404,78 @@ module MCP
|
|
|
290
404
|
|
|
291
405
|
Methods.ensure_capability!(method, capabilities)
|
|
292
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
|
+
|
|
293
413
|
->(params) {
|
|
294
|
-
|
|
414
|
+
reported_exception = nil
|
|
415
|
+
instrument_call(
|
|
416
|
+
method,
|
|
417
|
+
server_context: { request: request },
|
|
418
|
+
exception_already_reported: ->(e) { reported_exception.equal?(e) },
|
|
419
|
+
) do
|
|
295
420
|
result = case method
|
|
296
421
|
when Methods::INITIALIZE
|
|
297
422
|
init(params, session: session)
|
|
298
|
-
when Methods::TOOLS_LIST
|
|
299
|
-
{ tools: @handlers[Methods::TOOLS_LIST].call(params) }
|
|
300
|
-
when Methods::PROMPTS_LIST
|
|
301
|
-
{ prompts: @handlers[Methods::PROMPTS_LIST].call(params) }
|
|
302
|
-
when Methods::RESOURCES_LIST
|
|
303
|
-
{ resources: @handlers[Methods::RESOURCES_LIST].call(params) }
|
|
304
423
|
when Methods::RESOURCES_READ
|
|
305
|
-
{ contents:
|
|
306
|
-
when Methods::
|
|
307
|
-
|
|
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
|
+
{}
|
|
308
428
|
when Methods::TOOLS_CALL
|
|
309
|
-
call_tool(params, session: session)
|
|
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)
|
|
432
|
+
when Methods::COMPLETION_COMPLETE
|
|
433
|
+
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
310
434
|
when Methods::LOGGING_SET_LEVEL
|
|
311
435
|
configure_logging_level(params, session: session)
|
|
312
436
|
else
|
|
313
|
-
@handlers[method]
|
|
437
|
+
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
314
438
|
end
|
|
315
439
|
client = session&.client || @client
|
|
316
440
|
add_instrumentation_data(client: client) if client
|
|
317
441
|
|
|
442
|
+
if cancellation&.cancelled?
|
|
443
|
+
add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
|
|
444
|
+
next JsonRpcHandler::NO_RESPONSE
|
|
445
|
+
end
|
|
446
|
+
|
|
318
447
|
result
|
|
448
|
+
rescue CancelledError => e
|
|
449
|
+
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
|
450
|
+
next JsonRpcHandler::NO_RESPONSE
|
|
451
|
+
rescue RequestHandlerError => e
|
|
452
|
+
report_exception(e.original_error || e, { request: request })
|
|
453
|
+
add_instrumentation_data(error: e.error_type)
|
|
454
|
+
reported_exception = e
|
|
455
|
+
raise e
|
|
319
456
|
rescue => e
|
|
320
457
|
report_exception(e, { request: request })
|
|
321
|
-
if e.is_a?(RequestHandlerError)
|
|
322
|
-
add_instrumentation_data(error: e.error_type)
|
|
323
|
-
raise e
|
|
324
|
-
end
|
|
325
|
-
|
|
326
458
|
add_instrumentation_data(error: :internal_error)
|
|
327
|
-
|
|
459
|
+
wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
|
|
460
|
+
reported_exception = wrapped
|
|
461
|
+
raise wrapped
|
|
462
|
+
ensure
|
|
463
|
+
session&.unregister_in_flight(related_request_id) if related_request_id
|
|
328
464
|
end
|
|
329
465
|
}
|
|
330
466
|
end
|
|
331
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
|
+
|
|
332
479
|
def default_capabilities
|
|
333
480
|
{
|
|
334
481
|
tools: { listChanged: true },
|
|
@@ -355,10 +502,11 @@ module MCP
|
|
|
355
502
|
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
|
|
356
503
|
else
|
|
357
504
|
@client = params[:clientInfo]
|
|
505
|
+
@client_capabilities = params[:capabilities]
|
|
358
506
|
end
|
|
507
|
+
protocol_version = params[:protocolVersion]
|
|
359
508
|
end
|
|
360
509
|
|
|
361
|
-
protocol_version = params[:protocolVersion] if params
|
|
362
510
|
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
|
|
363
511
|
protocol_version
|
|
364
512
|
else
|
|
@@ -401,10 +549,12 @@ module MCP
|
|
|
401
549
|
end
|
|
402
550
|
|
|
403
551
|
def list_tools(request)
|
|
404
|
-
@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
|
|
405
555
|
end
|
|
406
556
|
|
|
407
|
-
def call_tool(request, session: nil)
|
|
557
|
+
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
408
558
|
tool_name = request[:name]
|
|
409
559
|
|
|
410
560
|
tool = tools[tool_name]
|
|
@@ -436,20 +586,29 @@ module MCP
|
|
|
436
586
|
|
|
437
587
|
progress_token = request.dig(:_meta, :progressToken)
|
|
438
588
|
|
|
439
|
-
call_tool_with_args(
|
|
440
|
-
|
|
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.
|
|
441
595
|
raise
|
|
442
596
|
rescue => e
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
597
|
+
raise RequestHandlerError.new(
|
|
598
|
+
"Internal error calling tool #{tool_name}: #{e.message}",
|
|
599
|
+
request,
|
|
600
|
+
error_type: :internal_error,
|
|
601
|
+
original_error: e,
|
|
602
|
+
)
|
|
446
603
|
end
|
|
447
604
|
|
|
448
605
|
def list_prompts(request)
|
|
449
|
-
@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
|
|
450
609
|
end
|
|
451
610
|
|
|
452
|
-
def get_prompt(request)
|
|
611
|
+
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
|
|
453
612
|
prompt_name = request[:name]
|
|
454
613
|
prompt = @prompts[prompt_name]
|
|
455
614
|
unless prompt
|
|
@@ -462,11 +621,20 @@ module MCP
|
|
|
462
621
|
prompt_args = request[:arguments]
|
|
463
622
|
prompt.validate_arguments!(prompt_args)
|
|
464
623
|
|
|
465
|
-
|
|
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)
|
|
466
632
|
end
|
|
467
633
|
|
|
468
634
|
def list_resources(request)
|
|
469
|
-
@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
|
|
470
638
|
end
|
|
471
639
|
|
|
472
640
|
# Server implementation should set `resources_read_handler` to override no-op default
|
|
@@ -476,7 +644,85 @@ module MCP
|
|
|
476
644
|
end
|
|
477
645
|
|
|
478
646
|
def list_resource_templates(request)
|
|
479
|
-
@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
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
|
|
653
|
+
validate_completion_params!(params)
|
|
654
|
+
|
|
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
|
+
)
|
|
662
|
+
|
|
663
|
+
normalize_completion_result(result)
|
|
664
|
+
end
|
|
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
|
+
)
|
|
480
726
|
end
|
|
481
727
|
|
|
482
728
|
def report_exception(exception, server_context = {})
|
|
@@ -499,18 +745,32 @@ module MCP
|
|
|
499
745
|
).to_h
|
|
500
746
|
end
|
|
501
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.
|
|
502
754
|
def accepts_server_context?(method_object)
|
|
503
755
|
parameters = method_object.parameters
|
|
504
756
|
|
|
505
|
-
parameters.any?
|
|
757
|
+
parameters.any? do |type, name|
|
|
758
|
+
type == :keyrest || (name == :server_context && (type == :key || type == :keyreq))
|
|
759
|
+
end
|
|
506
760
|
end
|
|
507
761
|
|
|
508
|
-
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
|
|
762
|
+
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
|
|
509
763
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
510
764
|
|
|
511
765
|
if accepts_server_context?(tool.method(:call))
|
|
512
|
-
progress = Progress.new(notification_target: session, progress_token: progress_token)
|
|
513
|
-
server_context = ServerContext.new(
|
|
766
|
+
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
|
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
|
+
)
|
|
514
774
|
tool.call(**args, server_context: server_context).to_h
|
|
515
775
|
else
|
|
516
776
|
tool.call(**args).to_h
|
|
@@ -537,5 +797,56 @@ module MCP
|
|
|
537
797
|
server_context
|
|
538
798
|
end
|
|
539
799
|
end
|
|
800
|
+
|
|
801
|
+
def validate_completion_params!(params)
|
|
802
|
+
unless params.is_a?(Hash)
|
|
803
|
+
raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
ref = params[:ref]
|
|
807
|
+
if ref.nil? || ref[:type].nil?
|
|
808
|
+
raise RequestHandlerError.new("Missing or invalid ref", params, error_type: :invalid_params)
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
argument = params[:argument]
|
|
812
|
+
if argument.nil? || argument[:name].nil? || !argument.key?(:value)
|
|
813
|
+
raise RequestHandlerError.new("Missing argument name or value", params, error_type: :invalid_params)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
case ref[:type]
|
|
817
|
+
when "ref/prompt"
|
|
818
|
+
unless @prompts[ref[:name]]
|
|
819
|
+
raise RequestHandlerError.new("Prompt not found: #{ref[:name]}", params, error_type: :invalid_params)
|
|
820
|
+
end
|
|
821
|
+
when "ref/resource"
|
|
822
|
+
uri = ref[:uri]
|
|
823
|
+
found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri }
|
|
824
|
+
unless found
|
|
825
|
+
raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params)
|
|
826
|
+
end
|
|
827
|
+
else
|
|
828
|
+
raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params)
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def normalize_completion_result(result)
|
|
833
|
+
return DEFAULT_COMPLETION_RESULT unless result.is_a?(Hash)
|
|
834
|
+
|
|
835
|
+
completion = result[:completion] || result["completion"]
|
|
836
|
+
return DEFAULT_COMPLETION_RESULT unless completion.is_a?(Hash)
|
|
837
|
+
|
|
838
|
+
values = completion[:values] || completion["values"] || []
|
|
839
|
+
total = completion[:total] || completion["total"]
|
|
840
|
+
has_more = completion[:hasMore] || completion["hasMore"] || false
|
|
841
|
+
|
|
842
|
+
count = values.length
|
|
843
|
+
if count > MAX_COMPLETION_VALUES
|
|
844
|
+
has_more = true
|
|
845
|
+
total ||= count
|
|
846
|
+
values = values.first(MAX_COMPLETION_VALUES)
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
{ completion: { values: values, total: total, hasMore: has_more }.compact }
|
|
850
|
+
end
|
|
540
851
|
end
|
|
541
852
|
end
|