ruby-mcp-client 0.8.1 → 0.9.1

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.
@@ -16,12 +16,17 @@ module MCPClient
16
16
  # @return [Hash<String, MCPClient::Resource>] cache of resources by composite key (server_id:uri)
17
17
  # @!attribute [r] logger
18
18
  # @return [Logger] logger for client operations
19
- attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger
19
+ # @!attribute [r] roots
20
+ # @return [Array<MCPClient::Root>] list of MCP roots (MCP 2025-06-18)
21
+ attr_reader :servers, :tool_cache, :prompt_cache, :resource_cache, :logger, :roots
20
22
 
21
23
  # Initialize a new MCPClient::Client
22
24
  # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
23
25
  # @param logger [Logger, nil] optional logger, defaults to STDOUT
24
- def initialize(mcp_server_configs: [], logger: nil)
26
+ # @param elicitation_handler [Proc, nil] optional handler for elicitation requests (MCP 2025-06-18)
27
+ # @param roots [Array<MCPClient::Root, Hash>, nil] optional list of roots (MCP 2025-06-18)
28
+ # @param sampling_handler [Proc, nil] optional handler for sampling requests (MCP 2025-06-18)
29
+ def initialize(mcp_server_configs: [], logger: nil, elicitation_handler: nil, roots: nil, sampling_handler: nil)
25
30
  @logger = logger || Logger.new($stdout, level: Logger::WARN)
26
31
  @logger.progname = self.class.name
27
32
  @logger.formatter = proc { |severity, _datetime, progname, msg| "#{severity} [#{progname}] #{msg}\n" }
@@ -34,6 +39,12 @@ module MCPClient
34
39
  @resource_cache = {}
35
40
  # JSON-RPC notification listeners
36
41
  @notification_listeners = []
42
+ # Elicitation handler (MCP 2025-06-18)
43
+ @elicitation_handler = elicitation_handler
44
+ # Sampling handler (MCP 2025-06-18)
45
+ @sampling_handler = sampling_handler
46
+ # Roots (MCP 2025-06-18)
47
+ @roots = normalize_roots(roots)
37
48
  # Register default and user-defined notification handlers on each server
38
49
  @servers.each do |server|
39
50
  server.on_notification do |method, params|
@@ -42,6 +53,14 @@ module MCPClient
42
53
  # Invoke user-defined listeners
43
54
  @notification_listeners.each { |cb| cb.call(server, method, params) }
44
55
  end
56
+ # Register elicitation handler on each server
57
+ if server.respond_to?(:on_elicitation_request)
58
+ server.on_elicitation_request(&method(:handle_elicitation_request))
59
+ end
60
+ # Register roots list handler on each server (MCP 2025-06-18)
61
+ server.on_roots_list_request(&method(:handle_roots_list_request)) if server.respond_to?(:on_roots_list_request)
62
+ # Register sampling handler on each server (MCP 2025-06-18)
63
+ server.on_sampling_request(&method(:handle_sampling_request)) if server.respond_to?(:on_sampling_request)
45
64
  end
46
65
  end
47
66
 
@@ -323,6 +342,16 @@ module MCPClient
323
342
  @notification_listeners << block
324
343
  end
325
344
 
345
+ # Set the roots for this client (MCP 2025-06-18)
346
+ # When roots are changed, a notification is sent to all connected servers
347
+ # @param new_roots [Array<MCPClient::Root, Hash>] the new roots to set
348
+ # @return [void]
349
+ def roots=(new_roots)
350
+ @roots = normalize_roots(new_roots)
351
+ # Notify servers that roots have changed
352
+ notify_roots_changed
353
+ end
354
+
326
355
  # Find a server by name
327
356
  # @param name [String] the name of the server to find
328
357
  # @return [MCPClient::ServerBase, nil] the server with the given name, or nil if not found
@@ -346,8 +375,10 @@ module MCPClient
346
375
  end
347
376
 
348
377
  # Call multiple tools in batch
349
- # @param calls [Array<Hash>] array of calls in the form:
350
- # { name: tool_name, parameters: {...}, server: optional_server_name }
378
+ # @param calls [Array<Hash>] array of call hashes with keys:
379
+ # - name: tool name (required)
380
+ # - parameters: tool parameters (optional, default empty hash)
381
+ # - server: server name for routing (optional)
351
382
  # @return [Array<Object>] array of results for each tool invocation
352
383
  def call_tools(calls)
353
384
  calls.map do |call|
@@ -452,6 +483,28 @@ module MCPClient
452
483
  srv.rpc_notify(method, params)
453
484
  end
454
485
 
486
+ # Request completion suggestions from a server (MCP 2025-06-18)
487
+ # @param ref [Hash] reference object (e.g., { 'type' => 'ref/prompt', 'name' => 'prompt_name' })
488
+ # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
489
+ # @param server [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
490
+ # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
491
+ # @raise [MCPClient::Errors::ServerNotFound] if no server is available
492
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
493
+ def complete(ref:, argument:, server: nil)
494
+ srv = select_server(server)
495
+ srv.complete(ref: ref, argument: argument)
496
+ end
497
+
498
+ # Set the logging level on all connected servers (MCP 2025-06-18)
499
+ # To set on a specific server, use: client.find_server('name').log_level = 'debug'
500
+ # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
501
+ # 'critical', 'alert', 'emergency')
502
+ # @return [Array<Hash>] results from servers
503
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
504
+ def log_level=(level)
505
+ @servers.map { |srv| srv.log_level = level }
506
+ end
507
+
455
508
  private
456
509
 
457
510
  # Process incoming JSON-RPC notifications with default handlers
@@ -473,12 +526,43 @@ module MCPClient
473
526
  when 'notifications/resources/list_changed'
474
527
  logger.warn("[#{server_id}] Resource list has changed, clearing resource cache")
475
528
  @resource_cache.clear
529
+ when 'notifications/message'
530
+ # MCP 2025-06-18: Handle logging messages from server
531
+ handle_log_message(server_id, params)
476
532
  else
477
533
  # Log unknown notification types for debugging purposes
478
534
  logger.debug("[#{server_id}] Received unknown notification: #{method} - #{params}")
479
535
  end
480
536
  end
481
537
 
538
+ # Handle logging message notification from server (MCP 2025-06-18)
539
+ # @param server_id [String] server identifier for log prefix
540
+ # @param params [Hash] log message params (level, logger, data)
541
+ # @return [void]
542
+ def handle_log_message(server_id, params)
543
+ level = params['level'] || 'info'
544
+ logger_name = params['logger']
545
+ data = params['data']
546
+
547
+ # Format the message
548
+ prefix = logger_name ? "[#{server_id}:#{logger_name}]" : "[#{server_id}]"
549
+ message = data.is_a?(String) ? data : data.inspect
550
+
551
+ # Map MCP log levels to Ruby Logger levels
552
+ case level.to_s.downcase
553
+ when 'debug'
554
+ logger.debug("#{prefix} #{message}")
555
+ when 'info', 'notice'
556
+ logger.info("#{prefix} #{message}")
557
+ when 'warning'
558
+ logger.warn("#{prefix} #{message}")
559
+ when 'error', 'critical', 'alert', 'emergency'
560
+ logger.error("#{prefix} #{message}")
561
+ else
562
+ logger.info("#{prefix} [#{level}] #{message}")
563
+ end
564
+ end
565
+
482
566
  # Select a server based on index, name, type, or instance
483
567
  # @param server_arg [Integer, String, Symbol, MCPClient::ServerBase, nil] server selector
484
568
  # @return [MCPClient::ServerBase]
@@ -598,5 +682,177 @@ module MCPClient
598
682
  "Error reading resource '#{uri}': #{e.message} (Server: #{server_id})"
599
683
  end
600
684
  end
685
+
686
+ # Handle elicitation request from server (MCP 2025-06-18)
687
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused at client layer)
688
+ # @param params [Hash] the elicitation parameters
689
+ # @return [Hash] the elicitation response
690
+ def handle_elicitation_request(_request_id, params)
691
+ # If no handler is configured, decline the request
692
+ unless @elicitation_handler
693
+ @logger.warn('Received elicitation request but no handler configured, declining')
694
+ return { 'action' => 'decline' }
695
+ end
696
+
697
+ message = params['message']
698
+ schema = params['schema'] || params['requestedSchema']
699
+ metadata = params['metadata']
700
+
701
+ begin
702
+ # Call the user-defined handler
703
+ result = case @elicitation_handler.arity
704
+ when 0
705
+ @elicitation_handler.call
706
+ when 1
707
+ @elicitation_handler.call(message)
708
+ when 2, -1
709
+ @elicitation_handler.call(message, schema)
710
+ else
711
+ @elicitation_handler.call(message, schema, metadata)
712
+ end
713
+
714
+ # Validate and format response
715
+ case result
716
+ when Hash
717
+ if result['action']
718
+ normalised_action_response(result)
719
+ elsif result[:action]
720
+ # Convert symbol keys to strings
721
+ {
722
+ 'action' => result[:action].to_s,
723
+ 'content' => result[:content]
724
+ }.compact.then { |payload| normalised_action_response(payload) }
725
+ else
726
+ # Assume it's content for an accept action
727
+ { 'action' => 'accept', 'content' => result }
728
+ end
729
+ when nil
730
+ { 'action' => 'cancel' }
731
+ else
732
+ { 'action' => 'accept', 'content' => result }
733
+ end
734
+ rescue StandardError => e
735
+ @logger.error("Elicitation handler error: #{e.message}")
736
+ @logger.debug(e.backtrace.join("\n"))
737
+ { 'action' => 'decline' }
738
+ end
739
+ end
740
+
741
+ # Ensure the action value conforms to MCP spec (accept, decline, cancel)
742
+ # Falls back to accept for unknown action values.
743
+ def normalised_action_response(result)
744
+ action = result['action']
745
+ return result if %w[accept decline cancel].include?(action)
746
+
747
+ @logger.warn("Unknown elicitation action '#{action}', defaulting to accept")
748
+ result.merge('action' => 'accept')
749
+ end
750
+
751
+ # Normalize roots array - convert Hashes to Root objects (MCP 2025-06-18)
752
+ # @param roots [Array<MCPClient::Root, Hash>, nil] the roots to normalize
753
+ # @return [Array<MCPClient::Root>] normalized roots array
754
+ def normalize_roots(roots)
755
+ return [] if roots.nil?
756
+
757
+ roots.map do |root|
758
+ case root
759
+ when MCPClient::Root
760
+ root
761
+ when Hash
762
+ MCPClient::Root.from_json(root)
763
+ else
764
+ raise ArgumentError, "Invalid root type: #{root.class}. Expected MCPClient::Root or Hash."
765
+ end
766
+ end
767
+ end
768
+
769
+ # Handle roots/list request from server (MCP 2025-06-18)
770
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
771
+ # @param _params [Hash] the request parameters (unused)
772
+ # @return [Hash] the roots list response
773
+ def handle_roots_list_request(_request_id, _params)
774
+ { 'roots' => @roots.map(&:to_h) }
775
+ end
776
+
777
+ # Send notification to all servers that roots have changed (MCP 2025-06-18)
778
+ # @return [void]
779
+ def notify_roots_changed
780
+ @servers.each do |server|
781
+ server.rpc_notify('notifications/roots/list_changed', {})
782
+ rescue StandardError => e
783
+ server_id = server.name ? "#{server.class}[#{server.name}]" : server.class
784
+ @logger.warn("[#{server_id}] Failed to send roots/list_changed notification: #{e.message}")
785
+ end
786
+ end
787
+
788
+ # Handle sampling/createMessage request from server (MCP 2025-06-18)
789
+ # @param _request_id [String, Integer] the JSON-RPC request ID (unused, kept for callback signature)
790
+ # @param params [Hash] the sampling parameters (messages, modelPreferences, systemPrompt, maxTokens)
791
+ # @return [Hash] the sampling response (role, content, model, stopReason)
792
+ def handle_sampling_request(_request_id, params)
793
+ # If no handler is configured, return an error
794
+ unless @sampling_handler
795
+ @logger.warn('Received sampling request but no handler configured')
796
+ return { 'error' => { 'code' => -1, 'message' => 'Sampling not supported' } }
797
+ end
798
+
799
+ messages = params['messages'] || []
800
+ model_preferences = params['modelPreferences']
801
+ system_prompt = params['systemPrompt']
802
+ max_tokens = params['maxTokens']
803
+
804
+ begin
805
+ # Call the user-defined handler
806
+ result = case @sampling_handler.arity
807
+ when 0
808
+ @sampling_handler.call
809
+ when 1
810
+ @sampling_handler.call(messages)
811
+ when 2
812
+ @sampling_handler.call(messages, model_preferences)
813
+ when 3
814
+ @sampling_handler.call(messages, model_preferences, system_prompt)
815
+ else
816
+ @sampling_handler.call(messages, model_preferences, system_prompt, max_tokens)
817
+ end
818
+
819
+ # Validate and format response
820
+ validate_sampling_response(result)
821
+ rescue StandardError => e
822
+ @logger.error("Sampling handler error: #{e.message}")
823
+ @logger.debug(e.backtrace.join("\n"))
824
+ { 'error' => { 'code' => -1, 'message' => "Sampling error: #{e.message}" } }
825
+ end
826
+ end
827
+
828
+ # Validate sampling response from handler (MCP 2025-06-18)
829
+ # @param result [Hash] the result from the sampling handler
830
+ # @return [Hash] validated sampling response
831
+ def validate_sampling_response(result)
832
+ return { 'error' => { 'code' => -1, 'message' => 'Sampling rejected' } } if result.nil?
833
+
834
+ # Convert symbol keys to string keys
835
+ result = result.transform_keys(&:to_s) if result.is_a?(Hash) && result.keys.first.is_a?(Symbol)
836
+
837
+ # Ensure required fields are present
838
+ unless result.is_a?(Hash) && result['content']
839
+ return {
840
+ 'role' => 'assistant',
841
+ 'content' => { 'type' => 'text', 'text' => result.to_s },
842
+ 'model' => 'unknown',
843
+ 'stopReason' => 'endTurn'
844
+ }
845
+ end
846
+
847
+ # Set defaults for missing fields
848
+ result['role'] ||= 'assistant'
849
+ result['model'] ||= 'unknown'
850
+ result['stopReason'] ||= 'endTurn'
851
+
852
+ # Normalize content if it's a string
853
+ result['content'] = { 'type' => 'text', 'text' => result['content'] } if result['content'].is_a?(String)
854
+
855
+ result
856
+ end
601
857
  end
602
858
  end
@@ -47,5 +47,8 @@ module MCPClient
47
47
 
48
48
  # Raised when multiple resources with the same URI exist across different servers
49
49
  class AmbiguousResourceURI < MCPError; end
50
+
51
+ # Raised when transport type cannot be determined from target URL/command
52
+ class TransportDetectionError < MCPError; end
50
53
  end
51
54
  end
@@ -256,14 +256,20 @@ module MCPClient
256
256
  end
257
257
 
258
258
  # Create a Faraday connection for HTTP requests
259
+ # Applies default configuration first, then allows user customization via @faraday_config block
259
260
  # @return [Faraday::Connection] the configured connection
260
261
  def create_http_connection
261
- Faraday.new(url: @base_url) do |f|
262
+ conn = Faraday.new(url: @base_url) do |f|
262
263
  f.request :retry, max: @max_retries, interval: @retry_backoff, backoff_factor: 2
263
264
  f.options.open_timeout = @read_timeout
264
265
  f.options.timeout = @read_timeout
265
266
  f.adapter Faraday.default_adapter
266
267
  end
268
+
269
+ # Apply user's Faraday customizations after defaults
270
+ @faraday_config&.call(conn)
271
+
272
+ conn
267
273
  end
268
274
 
269
275
  # Log HTTP response (to be overridden by specific transports)
@@ -62,9 +62,15 @@ module MCPClient
62
62
  # Generate initialization parameters for MCP protocol
63
63
  # @return [Hash] the initialization parameters
64
64
  def initialization_params
65
+ capabilities = {
66
+ 'elicitation' => {}, # MCP 2025-06-18: Support for server-initiated user interactions
67
+ 'roots' => { 'listChanged' => true }, # MCP 2025-06-18: Support for roots
68
+ 'sampling' => {} # MCP 2025-06-18: Support for server-initiated LLM sampling
69
+ }
70
+
65
71
  {
66
72
  'protocolVersion' => MCPClient::PROTOCOL_VERSION,
67
- 'capabilities' => {},
73
+ 'capabilities' => capabilities,
68
74
  'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
69
75
  }
70
76
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Represents an MCP Root - a URI that defines a boundary where servers can operate
5
+ # Roots are declared by clients to inform servers about relevant resources and their locations
6
+ class Root
7
+ attr_reader :uri, :name
8
+
9
+ # Create a new Root
10
+ # @param uri [String] The URI for the root (typically file:// URI)
11
+ # @param name [String, nil] Optional human-readable name for display purposes
12
+ def initialize(uri:, name: nil)
13
+ @uri = uri
14
+ @name = name
15
+ end
16
+
17
+ # Create a Root from a JSON hash
18
+ # @param json [Hash] The JSON hash with 'uri' and optional 'name' keys
19
+ # @return [Root]
20
+ def self.from_json(json)
21
+ new(
22
+ uri: json['uri'] || json[:uri],
23
+ name: json['name'] || json[:name]
24
+ )
25
+ end
26
+
27
+ # Convert to JSON-serializable hash
28
+ # @return [Hash]
29
+ def to_h
30
+ result = { 'uri' => @uri }
31
+ result['name'] = @name if @name
32
+ result
33
+ end
34
+
35
+ # Convert to JSON string
36
+ # @return [String]
37
+ def to_json(*)
38
+ to_h.to_json(*)
39
+ end
40
+
41
+ # Check equality
42
+ def ==(other)
43
+ return false unless other.is_a?(Root)
44
+
45
+ uri == other.uri && name == other.name
46
+ end
47
+
48
+ alias eql? ==
49
+
50
+ def hash
51
+ [uri, name].hash
52
+ end
53
+
54
+ # String representation
55
+ def to_s
56
+ name ? "#{name} (#{uri})" : uri
57
+ end
58
+
59
+ def inspect
60
+ "#<MCPClient::Root uri=#{uri.inspect} name=#{name.inspect}>"
61
+ end
62
+ end
63
+ end
@@ -76,7 +76,9 @@ module MCPClient
76
76
  retries: config[:retries] || 3,
77
77
  retry_backoff: config[:retry_backoff] || 1,
78
78
  name: config[:name],
79
- logger: logger
79
+ logger: logger,
80
+ oauth_provider: config[:oauth_provider],
81
+ faraday_config: config[:faraday_config]
80
82
  )
81
83
  end
82
84
 
@@ -95,7 +97,9 @@ module MCPClient
95
97
  retries: config[:retries] || 3,
96
98
  retry_backoff: config[:retry_backoff] || 1,
97
99
  name: config[:name],
98
- logger: logger
100
+ logger: logger,
101
+ oauth_provider: config[:oauth_provider],
102
+ faraday_config: config[:faraday_config]
99
103
  )
100
104
  end
101
105
 
@@ -12,6 +12,14 @@ module MCPClient
12
12
  # Implementation of MCP server that communicates via HTTP requests/responses
13
13
  # Useful for communicating with MCP servers that support HTTP-based transport
14
14
  # without Server-Sent Events streaming
15
+ #
16
+ # @note Elicitation Support (MCP 2025-06-18)
17
+ # This transport does NOT support server-initiated elicitation requests.
18
+ # The HTTP transport uses a pure request-response architecture where only the client
19
+ # can initiate requests. For elicitation support, use one of these transports instead:
20
+ # - ServerStdio: Full bidirectional JSON-RPC over stdin/stdout
21
+ # - ServerSSE: Server requests via SSE stream, client responses via HTTP POST
22
+ # - ServerStreamableHTTP: Server requests via SSE-formatted responses, client responses via HTTP POST
15
23
  class ServerHTTP < ServerBase
16
24
  require_relative 'server_http/json_rpc_transport'
17
25
 
@@ -47,6 +55,7 @@ module MCPClient
47
55
  # @option options [String, nil] :name Optional name for this server
48
56
  # @option options [Logger, nil] :logger Optional logger
49
57
  # @option options [MCPClient::Auth::OAuthProvider, nil] :oauth_provider Optional OAuth provider
58
+ # @option options [Proc, nil] :faraday_config Optional block to customize the Faraday connection
50
59
  def initialize(base_url:, **options)
51
60
  opts = default_options.merge(options)
52
61
  super(name: opts[:name])
@@ -91,6 +100,7 @@ module MCPClient
91
100
  })
92
101
 
93
102
  @read_timeout = opts[:read_timeout]
103
+ @faraday_config = opts[:faraday_config]
94
104
  @tools = nil
95
105
  @tools_data = nil
96
106
  @request_id = 0
@@ -312,6 +322,33 @@ module MCPClient
312
322
  raise MCPClient::Errors::ResourceReadError, "Error reading resource '#{uri}': #{e.message}"
313
323
  end
314
324
 
325
+ # Request completion suggestions from the server (MCP 2025-06-18)
326
+ # @param ref [Hash] reference to complete (prompt or resource)
327
+ # @param argument [Hash] the argument being completed (e.g., { 'name' => 'arg_name', 'value' => 'partial' })
328
+ # @return [Hash] completion result with 'values', optional 'total', and 'hasMore' fields
329
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
330
+ def complete(ref:, argument:)
331
+ result = rpc_request('completion/complete', { ref: ref, argument: argument })
332
+ result['completion'] || { 'values' => [] }
333
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
334
+ raise
335
+ rescue StandardError => e
336
+ raise MCPClient::Errors::ServerError, "Error requesting completion: #{e.message}"
337
+ end
338
+
339
+ # Set the logging level on the server (MCP 2025-06-18)
340
+ # @param level [String] the log level ('debug', 'info', 'notice', 'warning', 'error',
341
+ # 'critical', 'alert', 'emergency')
342
+ # @return [Hash] empty result on success
343
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
344
+ def log_level=(level)
345
+ rpc_request('logging/setLevel', { level: level })
346
+ rescue MCPClient::Errors::ConnectionError, MCPClient::Errors::TransportError
347
+ raise
348
+ rescue StandardError => e
349
+ raise MCPClient::Errors::ServerError, "Error setting log level: #{e.message}"
350
+ end
351
+
315
352
  # List all resource templates available from the MCP server
316
353
  # @param cursor [String, nil] optional cursor for pagination
317
354
  # @return [Hash] result containing resourceTemplates array and optional nextCursor
@@ -412,7 +449,8 @@ module MCPClient
412
449
  retry_backoff: 1,
413
450
  name: nil,
414
451
  logger: nil,
415
- oauth_provider: nil
452
+ oauth_provider: nil,
453
+ faraday_config: nil
416
454
  }
417
455
  end
418
456
 
@@ -31,6 +31,7 @@ module MCPClient
31
31
  data = JSON.parse(event[:data])
32
32
 
33
33
  return if process_error_in_message(data)
34
+ return if process_server_request?(data)
34
35
  return if process_notification?(data)
35
36
 
36
37
  process_response?(data)
@@ -58,6 +59,16 @@ module MCPClient
58
59
  true
59
60
  end
60
61
 
62
+ # Process a JSON-RPC request from server (has both id AND method)
63
+ # @param data [Hash] the parsed JSON payload
64
+ # @return [Boolean] true if we saw & handled a server request
65
+ def process_server_request?(data)
66
+ return false unless data['method'] && data.key?('id')
67
+
68
+ handle_server_request(data)
69
+ true
70
+ end
71
+
61
72
  # Process a JSON-RPC notification (no id => notification)
62
73
  # @param data [Hash] the parsed JSON payload
63
74
  # @return [Boolean] true if we saw & handled a notification