mcp 0.10.0 → 0.12.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.
data/lib/mcp/server.rb CHANGED
@@ -24,6 +24,12 @@ module MCP
24
24
  UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18 = [:description, :icons].freeze
25
25
  UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26 = [:title, :websiteUrl].freeze
26
26
 
27
+ DEFAULT_COMPLETION_RESULT = { completion: { values: [], hasMore: false } }.freeze
28
+
29
+ # Servers return an array of completion values ranked by relevance, with maximum 100 items per response.
30
+ # https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#completion-results
31
+ MAX_COMPLETION_VALUES = 100
32
+
27
33
  class RequestHandlerError < StandardError
28
34
  attr_reader :error_type
29
35
  attr_reader :original_error
@@ -48,6 +54,7 @@ module MCP
48
54
  include Instrumentation
49
55
 
50
56
  attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
57
+ attr_reader :client_capabilities
51
58
 
52
59
  def initialize(
53
60
  description: nil,
@@ -86,6 +93,7 @@ module MCP
86
93
  validate!
87
94
 
88
95
  @capabilities = capabilities || default_capabilities
96
+ @client_capabilities = nil
89
97
  @logging_message_notification = nil
90
98
 
91
99
  @handlers = {
@@ -100,12 +108,12 @@ module MCP
100
108
  Methods::PING => ->(_) { {} },
101
109
  Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
102
110
  Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
111
+ Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
103
112
  Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
104
113
 
105
114
  # No op handlers for currently unsupported methods
106
115
  Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
107
116
  Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
108
- Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } },
109
117
  Methods::ELICITATION_CREATE => ->(_) {},
110
118
  }
111
119
  @transport = transport
@@ -119,8 +127,8 @@ module MCP
119
127
  # When `nil`, progress and logging notifications from tool handlers are silently skipped.
120
128
  # @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
121
129
  def handle(request, session: nil)
122
- JsonRpcHandler.handle(request) do |method|
123
- handle_request(request, method, session: session)
130
+ JsonRpcHandler.handle(request) do |method, request_id|
131
+ handle_request(request, method, session: session, related_request_id: request_id)
124
132
  end
125
133
  end
126
134
 
@@ -132,8 +140,8 @@ module MCP
132
140
  # When `nil`, progress and logging notifications from tool handlers are silently skipped.
133
141
  # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
134
142
  def handle_json(request, session: nil)
135
- JsonRpcHandler.handle_json(request) do |method|
136
- handle_request(request, method, session: session)
143
+ JsonRpcHandler.handle_json(request) do |method, request_id|
144
+ handle_request(request, method, session: session, related_request_id: request_id)
137
145
  end
138
146
  end
139
147
 
@@ -198,6 +206,44 @@ module MCP
198
206
  report_exception(e, { notification: "log_message" })
199
207
  end
200
208
 
209
+ # Sends a `sampling/createMessage` request to the client.
210
+ # For single-client transports (e.g., `StdioTransport`). For multi-client transports
211
+ # (e.g., `StreamableHTTPTransport`), use `ServerSession#create_sampling_message` instead
212
+ # to ensure the request is routed to the correct client.
213
+ def create_sampling_message(
214
+ messages:,
215
+ max_tokens:,
216
+ system_prompt: nil,
217
+ model_preferences: nil,
218
+ include_context: nil,
219
+ temperature: nil,
220
+ stop_sequences: nil,
221
+ metadata: nil,
222
+ tools: nil,
223
+ tool_choice: nil,
224
+ related_request_id: nil
225
+ )
226
+ unless @transport
227
+ raise "Cannot send sampling request without a transport."
228
+ end
229
+
230
+ params = build_sampling_params(
231
+ @client_capabilities,
232
+ messages: messages,
233
+ max_tokens: max_tokens,
234
+ system_prompt: system_prompt,
235
+ model_preferences: model_preferences,
236
+ include_context: include_context,
237
+ temperature: temperature,
238
+ stop_sequences: stop_sequences,
239
+ metadata: metadata,
240
+ tools: tools,
241
+ tool_choice: tool_choice,
242
+ )
243
+
244
+ @transport.send_request(Methods::SAMPLING_CREATE_MESSAGE, params)
245
+ end
246
+
201
247
  # Sets a custom handler for `resources/read` requests.
202
248
  # The block receives the parsed request params and should return resource
203
249
  # contents. The return value is set as the `contents` field of the response.
@@ -208,6 +254,54 @@ module MCP
208
254
  @handlers[Methods::RESOURCES_READ] = block
209
255
  end
210
256
 
257
+ # Sets a custom handler for `completion/complete` requests.
258
+ # The block receives the parsed request params and should return completion values.
259
+ #
260
+ # @yield [params] The request params containing `:ref`, `:argument`, and optionally `:context`.
261
+ # @yieldreturn [Hash] A hash with `:completion` key containing `:values`, optional `:total`, and `:hasMore`.
262
+ def completion_handler(&block)
263
+ @handlers[Methods::COMPLETION_COMPLETE] = block
264
+ end
265
+
266
+ def build_sampling_params(
267
+ capabilities,
268
+ messages:,
269
+ max_tokens:,
270
+ system_prompt: nil,
271
+ model_preferences: nil,
272
+ include_context: nil,
273
+ temperature: nil,
274
+ stop_sequences: nil,
275
+ metadata: nil,
276
+ tools: nil,
277
+ tool_choice: nil
278
+ )
279
+ unless capabilities&.dig(:sampling)
280
+ raise "Client does not support sampling."
281
+ end
282
+
283
+ if tools && !capabilities.dig(:sampling, :tools)
284
+ raise "Client does not support sampling with tools."
285
+ end
286
+
287
+ if tool_choice && !capabilities.dig(:sampling, :tools)
288
+ raise "Client does not support sampling with tool_choice."
289
+ end
290
+
291
+ {
292
+ messages: messages,
293
+ maxTokens: max_tokens,
294
+ systemPrompt: system_prompt,
295
+ modelPreferences: model_preferences,
296
+ includeContext: include_context,
297
+ temperature: temperature,
298
+ stopSequences: stop_sequences,
299
+ metadata: metadata,
300
+ tools: tools,
301
+ toolChoice: tool_choice,
302
+ }.compact
303
+ end
304
+
211
305
  private
212
306
 
213
307
  def validate!
@@ -278,7 +372,7 @@ module MCP
278
372
  end
279
373
  end
280
374
 
281
- def handle_request(request, method, session: nil)
375
+ def handle_request(request, method, session: nil, related_request_id: nil)
282
376
  handler = @handlers[method]
283
377
  unless handler
284
378
  instrument_call("unsupported_method") do
@@ -306,7 +400,9 @@ module MCP
306
400
  when Methods::RESOURCES_TEMPLATES_LIST
307
401
  { resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
308
402
  when Methods::TOOLS_CALL
309
- call_tool(params, session: session)
403
+ call_tool(params, session: session, related_request_id: related_request_id)
404
+ when Methods::COMPLETION_COMPLETE
405
+ complete(params)
310
406
  when Methods::LOGGING_SET_LEVEL
311
407
  configure_logging_level(params, session: session)
312
408
  else
@@ -316,13 +412,12 @@ module MCP
316
412
  add_instrumentation_data(client: client) if client
317
413
 
318
414
  result
415
+ rescue RequestHandlerError => e
416
+ report_exception(e.original_error || e, { request: request })
417
+ add_instrumentation_data(error: e.error_type)
418
+ raise e
319
419
  rescue => e
320
420
  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
421
  add_instrumentation_data(error: :internal_error)
327
422
  raise RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
328
423
  end
@@ -355,10 +450,11 @@ module MCP
355
450
  session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
356
451
  else
357
452
  @client = params[:clientInfo]
453
+ @client_capabilities = params[:capabilities]
358
454
  end
455
+ protocol_version = params[:protocolVersion]
359
456
  end
360
457
 
361
- protocol_version = params[:protocolVersion] if params
362
458
  negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
363
459
  protocol_version
364
460
  else
@@ -404,7 +500,7 @@ module MCP
404
500
  @tools.values.map(&:to_h)
405
501
  end
406
502
 
407
- def call_tool(request, session: nil)
503
+ def call_tool(request, session: nil, related_request_id: nil)
408
504
  tool_name = request[:name]
409
505
 
410
506
  tool = tools[tool_name]
@@ -421,7 +517,7 @@ module MCP
421
517
  add_instrumentation_data(error: :missing_required_arguments)
422
518
 
423
519
  missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
424
- return error_tool_response("Missing required arguments: #{missing}")
520
+ raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params)
425
521
  end
426
522
 
427
523
  if configuration.validate_tool_call_arguments && tool.input_schema
@@ -430,19 +526,22 @@ module MCP
430
526
  rescue Tool::InputSchema::ValidationError => e
431
527
  add_instrumentation_data(error: :invalid_schema)
432
528
 
433
- return error_tool_response(e.message)
529
+ raise RequestHandlerError.new(e.message, request, error_type: :invalid_params)
434
530
  end
435
531
  end
436
532
 
437
533
  progress_token = request.dig(:_meta, :progressToken)
438
534
 
439
- call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session)
535
+ call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id)
440
536
  rescue RequestHandlerError
441
537
  raise
442
538
  rescue => e
443
- report_exception(e, request: request)
444
-
445
- error_tool_response("Internal error calling tool #{tool_name}: #{e.message}")
539
+ raise RequestHandlerError.new(
540
+ "Internal error calling tool #{tool_name}: #{e.message}",
541
+ request,
542
+ error_type: :internal_error,
543
+ original_error: e,
544
+ )
446
545
  end
447
546
 
448
547
  def list_prompts(request)
@@ -479,6 +578,14 @@ module MCP
479
578
  @resource_templates.map(&:to_h)
480
579
  end
481
580
 
581
+ def complete(params)
582
+ validate_completion_params!(params)
583
+
584
+ result = @handlers[Methods::COMPLETION_COMPLETE].call(params)
585
+
586
+ normalize_completion_result(result)
587
+ end
588
+
482
589
  def report_exception(exception, server_context = {})
483
590
  configuration.exception_reporter.call(exception, server_context)
484
591
  end
@@ -505,12 +612,12 @@ module MCP
505
612
  parameters.any? { |type, name| type == :keyrest || name == :server_context }
506
613
  end
507
614
 
508
- def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil)
615
+ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
509
616
  args = arguments&.transform_keys(&:to_sym) || {}
510
617
 
511
618
  if accepts_server_context?(tool.method(:call))
512
- progress = Progress.new(notification_target: session, progress_token: progress_token)
513
- server_context = ServerContext.new(context, progress: progress, notification_target: session)
619
+ progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
620
+ server_context = ServerContext.new(context, progress: progress, notification_target: session, related_request_id: related_request_id)
514
621
  tool.call(**args, server_context: server_context).to_h
515
622
  else
516
623
  tool.call(**args).to_h
@@ -537,5 +644,56 @@ module MCP
537
644
  server_context
538
645
  end
539
646
  end
647
+
648
+ def validate_completion_params!(params)
649
+ unless params.is_a?(Hash)
650
+ raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
651
+ end
652
+
653
+ ref = params[:ref]
654
+ if ref.nil? || ref[:type].nil?
655
+ raise RequestHandlerError.new("Missing or invalid ref", params, error_type: :invalid_params)
656
+ end
657
+
658
+ argument = params[:argument]
659
+ if argument.nil? || argument[:name].nil? || !argument.key?(:value)
660
+ raise RequestHandlerError.new("Missing argument name or value", params, error_type: :invalid_params)
661
+ end
662
+
663
+ case ref[:type]
664
+ when "ref/prompt"
665
+ unless @prompts[ref[:name]]
666
+ raise RequestHandlerError.new("Prompt not found: #{ref[:name]}", params, error_type: :invalid_params)
667
+ end
668
+ when "ref/resource"
669
+ uri = ref[:uri]
670
+ found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri }
671
+ unless found
672
+ raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params)
673
+ end
674
+ else
675
+ raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params)
676
+ end
677
+ end
678
+
679
+ def normalize_completion_result(result)
680
+ return DEFAULT_COMPLETION_RESULT unless result.is_a?(Hash)
681
+
682
+ completion = result[:completion] || result["completion"]
683
+ return DEFAULT_COMPLETION_RESULT unless completion.is_a?(Hash)
684
+
685
+ values = completion[:values] || completion["values"] || []
686
+ total = completion[:total] || completion["total"]
687
+ has_more = completion[:hasMore] || completion["hasMore"] || false
688
+
689
+ count = values.length
690
+ if count > MAX_COMPLETION_VALUES
691
+ has_more = true
692
+ total ||= count
693
+ values = values.first(MAX_COMPLETION_VALUES)
694
+ end
695
+
696
+ { completion: { values: values, total: total, hasMore: has_more }.compact }
697
+ end
540
698
  end
541
699
  end
@@ -2,10 +2,11 @@
2
2
 
3
3
  module MCP
4
4
  class ServerContext
5
- def initialize(context, progress:, notification_target:)
5
+ def initialize(context, progress:, notification_target:, related_request_id: nil)
6
6
  @context = context
7
7
  @progress = progress
8
8
  @notification_target = notification_target
9
+ @related_request_id = related_request_id
9
10
  end
10
11
 
11
12
  # Reports progress for the current tool operation.
@@ -26,7 +27,20 @@ module MCP
26
27
  def notify_log_message(data:, level:, logger: nil)
27
28
  return unless @notification_target
28
29
 
29
- @notification_target.notify_log_message(data: data, level: level, logger: logger)
30
+ @notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
31
+ end
32
+
33
+ # Delegates to the session so the request is scoped to the originating client.
34
+ # Falls back to `@context` (via `method_missing`) when `@notification_target`
35
+ # does not support sampling.
36
+ def create_sampling_message(**kwargs)
37
+ if @notification_target.respond_to?(:create_sampling_message)
38
+ @notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id)
39
+ elsif @context.respond_to?(:create_sampling_message)
40
+ @context.create_sampling_message(**kwargs, related_request_id: @related_request_id)
41
+ else
42
+ raise NoMethodError, "undefined method 'create_sampling_message' for #{self}"
43
+ end
30
44
  end
31
45
 
32
46
  def method_missing(name, ...)
@@ -13,7 +13,7 @@ module MCP
13
13
  @transport = transport
14
14
  @session_id = session_id
15
15
  @client = nil
16
- @client_capabilities = nil # TODO: Use for per-session capability validation.
16
+ @client_capabilities = nil
17
17
  @logging_message_notification = nil
18
18
  end
19
19
 
@@ -36,8 +36,19 @@ module MCP
36
36
  @logging_message_notification = logging_message_notification
37
37
  end
38
38
 
39
+ # Returns per-session client capabilities, falling back to global.
40
+ def client_capabilities
41
+ @client_capabilities || @server.client_capabilities
42
+ end
43
+
44
+ # Sends a `sampling/createMessage` request scoped to this session.
45
+ def create_sampling_message(related_request_id: nil, **kwargs)
46
+ params = @server.build_sampling_params(client_capabilities, **kwargs)
47
+ send_to_transport_request(Methods::SAMPLING_CREATE_MESSAGE, params, related_request_id: related_request_id)
48
+ end
49
+
39
50
  # Sends a progress notification to this session only.
40
- def notify_progress(progress_token:, progress:, total: nil, message: nil)
51
+ def notify_progress(progress_token:, progress:, total: nil, message: nil, related_request_id: nil)
41
52
  params = {
42
53
  "progressToken" => progress_token,
43
54
  "progress" => progress,
@@ -45,35 +56,52 @@ module MCP
45
56
  "message" => message,
46
57
  }.compact
47
58
 
48
- send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params)
59
+ send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
49
60
  rescue => e
50
61
  @server.report_exception(e, notification: "progress")
51
62
  end
52
63
 
53
64
  # Sends a log message notification to this session only.
54
- def notify_log_message(data:, level:, logger: nil)
65
+ def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
55
66
  effective_logging = @logging_message_notification || @server.logging_message_notification
56
67
  return unless effective_logging&.should_notify?(level)
57
68
 
58
69
  params = { "data" => data, "level" => level }
59
70
  params["logger"] = logger if logger
60
71
 
61
- send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params)
72
+ send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
62
73
  rescue => e
63
74
  @server.report_exception(e, { notification: "log_message" })
64
75
  end
65
76
 
66
77
  private
67
78
 
79
+ # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
80
+ # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
81
+ #
68
82
  # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
69
83
  # `@transport.send_notification(method, params, session_id: @session_id)` and
70
84
  # add `**` to `Transport#send_notification` and `StdioTransport#send_notification`.
71
- def send_to_transport(method, params)
85
+ def send_to_transport(method, params, related_request_id: nil)
72
86
  if @session_id
73
- @transport.send_notification(method, params, session_id: @session_id)
87
+ @transport.send_notification(method, params, session_id: @session_id, related_request_id: related_request_id)
74
88
  else
75
89
  @transport.send_notification(method, params)
76
90
  end
77
91
  end
92
+
93
+ # Branches on `@session_id` because `StdioTransport` creates a `ServerSession` without
94
+ # a `session_id` (`session_id: nil`), while `StreamableHTTPTransport` always provides one.
95
+ #
96
+ # TODO: When Ruby 2.7 support is dropped, replace with a direct call:
97
+ # `@transport.send_request(method, params, session_id: @session_id)` and
98
+ # add `**` to `Transport#send_request` and `StdioTransport#send_request`.
99
+ def send_to_transport_request(method, params, related_request_id: nil)
100
+ if @session_id
101
+ @transport.send_request(method, params, session_id: @session_id, related_request_id: related_request_id)
102
+ else
103
+ @transport.send_request(method, params)
104
+ end
105
+ end
78
106
  end
79
107
  end
@@ -8,7 +8,7 @@ module MCP
8
8
  attr_reader :schema
9
9
 
10
10
  def initialize(schema = {})
11
- @schema = deep_transform_keys(JSON.parse(JSON.dump(schema)), &:to_sym)
11
+ @schema = JSON.parse(JSON.dump(schema), symbolize_names: true)
12
12
  @schema[:type] ||= "object"
13
13
  validate_schema!
14
14
  end
@@ -27,19 +27,6 @@ module MCP
27
27
  JSON::Validator.fully_validate(to_h, data)
28
28
  end
29
29
 
30
- def deep_transform_keys(schema, &block)
31
- case schema
32
- when Hash
33
- schema.each_with_object({}) do |(key, value), result|
34
- result[yield(key)] = deep_transform_keys(value, &block)
35
- end
36
- when Array
37
- schema.map { |e| deep_transform_keys(e, &block) }
38
- else
39
- schema
40
- end
41
- end
42
-
43
30
  def validate_schema!
44
31
  schema = to_h
45
32
  gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
data/lib/mcp/transport.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module MCP
4
6
  class Transport
5
7
  # Initialize the transport with the server instance
6
8
  def initialize(server)
7
9
  @server = server
10
+ server.transport = self
8
11
  end
9
12
 
10
13
  # Send a response to the client
@@ -41,5 +44,16 @@ module MCP
41
44
  def send_notification(method, params = nil)
42
45
  raise NotImplementedError, "Subclasses must implement send_notification"
43
46
  end
47
+
48
+ # Send a JSON-RPC request to the client and wait for a response.
49
+ def send_request(method, params = nil)
50
+ raise NotImplementedError, "Subclasses must implement send_request"
51
+ end
52
+
53
+ private
54
+
55
+ def generate_request_id
56
+ SecureRandom.uuid
57
+ end
44
58
  end
45
59
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MCP
4
- VERSION = "0.10.0"
4
+ VERSION = "0.12.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Model Context Protocol
@@ -28,7 +28,9 @@ email:
28
28
  - mcp-support@anthropic.com
29
29
  executables: []
30
30
  extensions: []
31
- extra_rdoc_files: []
31
+ extra_rdoc_files:
32
+ - LICENSE
33
+ - README.md
32
34
  files:
33
35
  - LICENSE
34
36
  - README.md
@@ -76,7 +78,7 @@ licenses:
76
78
  - Apache-2.0
77
79
  metadata:
78
80
  allowed_push_host: https://rubygems.org
79
- changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.10.0
81
+ changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.12.0
80
82
  homepage_uri: https://github.com/modelcontextprotocol/ruby-sdk
81
83
  source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
82
84
  bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues