ruby_llm-mcp 0.3.1 → 0.4.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +121 -2
  3. data/lib/ruby_llm/mcp/capabilities.rb +22 -2
  4. data/lib/ruby_llm/mcp/client.rb +106 -18
  5. data/lib/ruby_llm/mcp/configuration.rb +66 -0
  6. data/lib/ruby_llm/mcp/coordinator.rb +197 -33
  7. data/lib/ruby_llm/mcp/error.rb +34 -0
  8. data/lib/ruby_llm/mcp/errors.rb +37 -4
  9. data/lib/ruby_llm/mcp/logging.rb +16 -0
  10. data/lib/ruby_llm/mcp/parameter.rb +2 -0
  11. data/lib/ruby_llm/mcp/progress.rb +33 -0
  12. data/lib/ruby_llm/mcp/prompt.rb +12 -5
  13. data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +5 -2
  14. data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +6 -3
  15. data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +6 -3
  16. data/lib/ruby_llm/mcp/requests/base.rb +3 -3
  17. data/lib/ruby_llm/mcp/requests/cancelled_notification.rb +32 -0
  18. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +3 -3
  19. data/lib/ruby_llm/mcp/requests/completion_resource.rb +3 -3
  20. data/lib/ruby_llm/mcp/requests/initialization.rb +24 -18
  21. data/lib/ruby_llm/mcp/requests/initialize_notification.rb +15 -9
  22. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +28 -0
  23. data/lib/ruby_llm/mcp/requests/meta.rb +30 -0
  24. data/lib/ruby_llm/mcp/requests/ping.rb +20 -0
  25. data/lib/ruby_llm/mcp/requests/ping_response.rb +28 -0
  26. data/lib/ruby_llm/mcp/requests/prompt_call.rb +3 -3
  27. data/lib/ruby_llm/mcp/requests/prompt_list.rb +1 -1
  28. data/lib/ruby_llm/mcp/requests/resource_list.rb +1 -1
  29. data/lib/ruby_llm/mcp/requests/resource_read.rb +4 -4
  30. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +1 -1
  31. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +30 -0
  32. data/lib/ruby_llm/mcp/requests/tool_call.rb +6 -3
  33. data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -11
  34. data/lib/ruby_llm/mcp/resource.rb +26 -5
  35. data/lib/ruby_llm/mcp/resource_template.rb +11 -6
  36. data/lib/ruby_llm/mcp/result.rb +90 -0
  37. data/lib/ruby_llm/mcp/tool.rb +28 -3
  38. data/lib/ruby_llm/mcp/transport/sse.rb +81 -75
  39. data/lib/ruby_llm/mcp/transport/stdio.rb +33 -17
  40. data/lib/ruby_llm/mcp/transport/streamable_http.rb +647 -0
  41. data/lib/ruby_llm/mcp/version.rb +1 -1
  42. data/lib/ruby_llm/mcp.rb +18 -0
  43. data/lib/tasks/release.rake +23 -0
  44. metadata +20 -50
  45. data/lib/ruby_llm/mcp/transport/streamable.rb +0 -299
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53435b09aee8d32aaede3da3463b8fb782a6d00f6abcabc3b9925f1301cd60c9
4
- data.tar.gz: 366cf4d89bef7f79904cd897a7db006ee21a8766c8ff1675e51225576e8c87f4
3
+ metadata.gz: 2dd0be50f01aa4fd126828c9c4f81d2e5a7b3cb35f4b32a4ed8e3e4deb731202
4
+ data.tar.gz: d6efbfbb3345544da2e201968ff26ff1e238a72efc95e7eb5cc2cb77adaaaf94
5
5
  SHA512:
6
- metadata.gz: 94754a9b6d8799252bebfb19ccc3dedfc6767fdbe07468960ed3eb56978954d734425f79b366fb92610f4c059970a0607c249b3c6c35bca943fbab7e5a721e77
7
- data.tar.gz: d0e757cdc7d064ffc4334b0b1c2a1ebc1feff3dc684d35d28f0b51ce5761a3c80ad427654767fae97da8c8ddacbc182d3d896ef4dedc45ce4d6d8e6297b81fe3
6
+ metadata.gz: 769aa222a4f5afd5dc124f1fa1bb62771fd2d2dd5d60211fa3f86e664aa030811600f7ee448ec7607a7d244d2d6f961a9ded088f3db7eaa611fff8041528b6c1
7
+ data.tar.gz: d3a494430dcce6c0ee9246f6378d682771458c0009779c3e7f34aba43309b2ba6b72722ddbbc9e8d2b84061358cc8375f5fedd19c76a187795fce5adfd49338b
data/README.md CHANGED
@@ -103,13 +103,39 @@ response = chat.ask("Can you help me search for recent files in my project?")
103
103
  puts response
104
104
  ```
105
105
 
106
+ ### Human in the Loop
107
+
108
+ You can use the `on_human_in_the_loop` callback to allow the human to intervene in the tool call. This is useful for tools that require human input or programic input to verify if the tool should be executed.
109
+
110
+ For tool calls that have access to do important operations, there SHOULD always be a human in the loop with the ability to deny tool invocations.
111
+
112
+ ```ruby
113
+ client.on_human_in_the_loop do |name, params|
114
+ name == "add" && params[:a] == 1 && params[:b] == 2
115
+ end
116
+
117
+ tool = client.tool("add")
118
+ result = tool.execute(a: 1, b: 2)
119
+ puts result # 3
120
+
121
+ # If the human in the loop returns false, the tool call will be cancelled
122
+ result = tool.execute(a: 2, b: 2)
123
+ puts result # Tool execution error: Tool call was cancelled by the client
124
+ ```
125
+
126
+ tool = client.tool("add")
127
+ result = tool.execute(a: 1, b: 2)
128
+ puts result
129
+
130
+ ````
131
+
106
132
  ### Support Complex Parameters
107
133
 
108
134
  If you want to support complex parameters, like an array of objects it currently requires a patch to RubyLLM itself. This is planned to be temporary until the RubyLLM is updated.
109
135
 
110
136
  ```ruby
111
137
  RubyLLM::MCP.support_complex_parameters!
112
- ```
138
+ ````
113
139
 
114
140
  ### Streaming Responses with Tool Calls
115
141
 
@@ -341,6 +367,14 @@ client.restart!
341
367
  client.stop
342
368
  ```
343
369
 
370
+ ### Ping
371
+
372
+ You can ping the MCP server to check if it is alive:
373
+
374
+ ```ruby
375
+ client.ping # => true or false
376
+ ```
377
+
344
378
  ## Refreshing Cached Data
345
379
 
346
380
  The client caches tools, resources, prompts, and resource templates list calls are cached to reduce round trips back to the MCP server. You can refresh this cache:
@@ -363,6 +397,71 @@ prompt = client.prompt("daily_greeting", refresh: true)
363
397
  template = client.resource_template("user_logs", refresh: true)
364
398
  ```
365
399
 
400
+ ## Notifications
401
+
402
+ MCPs can produce notifications that happen in an async nature outside normal calls to the MCP server.
403
+
404
+ ### Subscribing to a Resource Update
405
+
406
+ By default, the client will look for any resource cha to resource updates and refresh the resource content when it changes.
407
+
408
+ ### Logging Notifications
409
+
410
+ MCPs can produce logging notifications for long-running tool operations. Logging notifications allow tools to send real-time updates about their execution status.
411
+
412
+ ```ruby
413
+ client.on_logging do |logging|
414
+ puts "Logging: #{logging.level} - #{logging.message}"
415
+ end
416
+
417
+ # Execute a tool that supports logging notifications
418
+ tool = client.tool("long_running_operation")
419
+ result = tool.execute(operation: "data_processing")
420
+
421
+ # Logging: info - Processing data...
422
+ # Logging: info - Processing data...
423
+ # Logging: warning - Something went wrong but not major...
424
+ ```
425
+
426
+ Different levels of logging are supported:
427
+
428
+ ```ruby
429
+ client.on_logging(RubyLLM::MCP::Logging::WARNING) do |logging|
430
+ puts "Logging: #{logging.level} - #{logging.message}"
431
+ end
432
+
433
+ # Execute a tool that supports logging notifications
434
+ tool = client.tool("long_running_operation")
435
+ result = tool.execute(operation: "data_processing")
436
+
437
+ # Logging: warning - Something went wrong but not major...
438
+ ```
439
+
440
+ ### Progress Notifications
441
+
442
+ MCPs can produce progress notifications for long-running tool operations. Progress notifications allow tools to send real-time updates about their execution status.
443
+
444
+ **Note:** that we only support progress notifications for tool calls today.
445
+
446
+ ```ruby
447
+ # Set up progress tracking
448
+ client.on_progress do |progress|
449
+ puts "Progress: #{progress.progress}% - #{progress.message}"
450
+ end
451
+
452
+ # Execute a tool that supports progress notifications
453
+ tool = client.tool("long_running_operation")
454
+ result = tool.execute(operation: "data_processing")
455
+
456
+ # Progress 25% - Processing data...
457
+ # Progress 50% - Processing data...
458
+ # Progress 75% - Processing data...
459
+ # Progress 100% - Processing data...
460
+ puts result
461
+
462
+ # Result: { status: "success", data: "Processed data" }
463
+ ```
464
+
366
465
  ## Transport Types
367
466
 
368
467
  ### SSE (Server-Sent Events)
@@ -411,7 +510,27 @@ client = RubyLLM::MCP.client(
411
510
  )
412
511
  ```
413
512
 
414
- ## Configuration Options
513
+ ## RubyLLM::MCP and Client Configuration Options
514
+
515
+ MCP comes with some common configuration options that can be set on the client.
516
+
517
+ ```ruby
518
+ RubyLLM::MCP.configure do |config|
519
+ # Set the progress handler
520
+ config.support_complex_parameters!
521
+
522
+ # Set parameters on the built in logger
523
+ config.log_file = $stdout
524
+ config.log_level = Logger::ERROR
525
+
526
+ # Or add a custom logger
527
+ config.logger = Logger.new(STDOUT)
528
+ end
529
+ ```
530
+
531
+ ### MCP Client Options
532
+
533
+ MCP client options are set on the client itself.
415
534
 
416
535
  - `name`: A unique identifier for your MCP client
417
536
  - `transport_type`: Either `:sse`, `:streamable`, or `:stdio`
@@ -9,7 +9,11 @@ module RubyLLM
9
9
  @capabilities = capabilities
10
10
  end
11
11
 
12
- def resources_list_changed?
12
+ def resources_list?
13
+ !@capabilities["resources"].nil?
14
+ end
15
+
16
+ def resources_list_changes?
13
17
  @capabilities.dig("resources", "listChanged") || false
14
18
  end
15
19
 
@@ -17,13 +21,29 @@ module RubyLLM
17
21
  @capabilities.dig("resources", "subscribe") || false
18
22
  end
19
23
 
20
- def tools_list_changed?
24
+ def tools_list?
25
+ !@capabilities["tools"].nil?
26
+ end
27
+
28
+ def tools_list_changes?
21
29
  @capabilities.dig("tools", "listChanged") || false
22
30
  end
23
31
 
32
+ def prompt_list?
33
+ !@capabilities["prompts"].nil?
34
+ end
35
+
36
+ def prompt_list_changes?
37
+ @capabilities.dig("prompts", "listChanged") || false
38
+ end
39
+
24
40
  def completion?
25
41
  !@capabilities["completions"].nil?
26
42
  end
43
+
44
+ def logging?
45
+ !@capabilities["logging"].nil?
46
+ end
27
47
  end
28
48
  end
29
49
  end
@@ -7,29 +7,47 @@ module RubyLLM
7
7
  class Client
8
8
  extend Forwardable
9
9
 
10
- attr_reader :name, :config, :transport_type, :request_timeout
10
+ attr_reader :name, :config, :transport_type, :request_timeout, :log_level, :on
11
11
 
12
- def initialize(name:, transport_type:, start: true, request_timeout: 8000, config: {})
12
+ def initialize(name:, transport_type:, start: true, request_timeout: MCP.config.request_timeout, config: {})
13
13
  @name = name
14
14
  @config = config.merge(request_timeout: request_timeout)
15
15
  @transport_type = transport_type.to_sym
16
16
  @request_timeout = request_timeout
17
17
 
18
- @coordinator = Coordinator.new(self, transport_type: @transport_type, config: @config)
18
+ @coordinator = setup_coordinator
19
19
 
20
- start_transport if start
20
+ @on = {}
21
+ @tools = {}
22
+ @resources = {}
23
+ @resource_templates = {}
24
+ @prompts = {}
25
+
26
+ @log_level = nil
27
+
28
+ @coordinator.start_transport if start
21
29
  end
22
30
 
23
- def_delegators :@coordinator, :start_transport, :stop_transport, :restart_transport, :alive?, :capabilities
31
+ def_delegators :@coordinator, :alive?, :capabilities, :ping
24
32
 
25
- alias start start_transport
26
- alias stop stop_transport
27
- alias restart! restart_transport
33
+ def start
34
+ @coordinator.start_transport
35
+ end
36
+
37
+ def stop
38
+ @coordinator.stop_transport
39
+ end
40
+
41
+ def restart!
42
+ @coordinator.restart_transport
43
+ end
28
44
 
29
45
  def tools(refresh: false)
46
+ return [] unless capabilities.tools_list?
47
+
30
48
  fetch(:tools, refresh) do
31
- tools_data = @coordinator.tool_list.dig("result", "tools")
32
- build_map(tools_data, MCP::Tool)
49
+ tools = @coordinator.tool_list
50
+ build_map(tools, MCP::Tool)
33
51
  end
34
52
 
35
53
  @tools.values
@@ -41,10 +59,16 @@ module RubyLLM
41
59
  @tools[name]
42
60
  end
43
61
 
62
+ def reset_tools!
63
+ @tools = {}
64
+ end
65
+
44
66
  def resources(refresh: false)
67
+ return [] unless capabilities.resources_list?
68
+
45
69
  fetch(:resources, refresh) do
46
- resources_data = @coordinator.resource_list.dig("result", "resources")
47
- build_map(resources_data, MCP::Resource)
70
+ resources = @coordinator.resource_list
71
+ build_map(resources, MCP::Resource)
48
72
  end
49
73
 
50
74
  @resources.values
@@ -56,10 +80,16 @@ module RubyLLM
56
80
  @resources[name]
57
81
  end
58
82
 
83
+ def reset_resources!
84
+ @resources = {}
85
+ end
86
+
59
87
  def resource_templates(refresh: false)
88
+ return [] unless capabilities.resources_list?
89
+
60
90
  fetch(:resource_templates, refresh) do
61
- templates_data = @coordinator.resource_template_list.dig("result", "resourceTemplates")
62
- build_map(templates_data, MCP::ResourceTemplate)
91
+ resource_templates = @coordinator.resource_template_list
92
+ build_map(resource_templates, MCP::ResourceTemplate)
63
93
  end
64
94
 
65
95
  @resource_templates.values
@@ -71,10 +101,16 @@ module RubyLLM
71
101
  @resource_templates[name]
72
102
  end
73
103
 
104
+ def reset_resource_templates!
105
+ @resource_templates = {}
106
+ end
107
+
74
108
  def prompts(refresh: false)
109
+ return [] unless capabilities.prompt_list?
110
+
75
111
  fetch(:prompts, refresh) do
76
- prompts_data = @coordinator.prompt_list.dig("result", "prompts")
77
- build_map(prompts_data, MCP::Prompt)
112
+ prompts = @coordinator.prompt_list
113
+ build_map(prompts, MCP::Prompt)
78
114
  end
79
115
 
80
116
  @prompts.values
@@ -86,11 +122,63 @@ module RubyLLM
86
122
  @prompts[name]
87
123
  end
88
124
 
125
+ def reset_prompts!
126
+ @prompts = {}
127
+ end
128
+
129
+ def tracking_progress?
130
+ @on.key?(:progress) && !@on[:progress].nil?
131
+ end
132
+
133
+ def on_progress(&block)
134
+ @on[:progress] = block
135
+ self
136
+ end
137
+
138
+ def human_in_the_loop?
139
+ @on.key?(:human_in_the_loop) && !@on[:human_in_the_loop].nil?
140
+ end
141
+
142
+ def on_human_in_the_loop(&block)
143
+ @on[:human_in_the_loop] = block
144
+ self
145
+ end
146
+
147
+ def logging_handler_enabled?
148
+ @on.key?(:logging) && !@on[:logging].nil?
149
+ end
150
+
151
+ def logging_enabled?
152
+ !@log_level.nil?
153
+ end
154
+
155
+ def on_logging(level: Logging::WARNING, logger: nil, &block)
156
+ @coordinator.set_logging(level: level)
157
+
158
+ @on[:logging] = if block_given?
159
+ block
160
+ else
161
+ lambda do |notification|
162
+ @coordinator.default_process_logging_message(notification, logger: logger)
163
+ end
164
+ end
165
+ self
166
+ end
167
+
89
168
  private
90
169
 
170
+ def setup_coordinator
171
+ Coordinator.new(self,
172
+ transport_type: @transport_type,
173
+ config: @config)
174
+ end
175
+
91
176
  def fetch(cache_key, refresh)
92
- instance_variable_set("@#{cache_key}", nil) if refresh
93
- instance_variable_get("@#{cache_key}") || instance_variable_set("@#{cache_key}", yield)
177
+ instance_variable_set("@#{cache_key}", {}) if refresh
178
+ if instance_variable_get("@#{cache_key}").empty?
179
+ instance_variable_set("@#{cache_key}", yield)
180
+ end
181
+ instance_variable_get("@#{cache_key}")
94
182
  end
95
183
 
96
184
  def build_map(raw_data, klass)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Configuration
6
+ attr_accessor :request_timeout, :log_file, :log_level, :has_support_complex_parameters
7
+ attr_writer :logger
8
+
9
+ REQUEST_TIMEOUT_DEFAULT = 8000
10
+
11
+ def initialize
12
+ set_defaults
13
+ end
14
+
15
+ def reset!
16
+ set_defaults
17
+ end
18
+
19
+ def support_complex_parameters!
20
+ return if @has_support_complex_parameters
21
+
22
+ @has_support_complex_parameters = true
23
+ RubyLLM::MCP.support_complex_parameters!
24
+ end
25
+
26
+ def logger
27
+ @logger ||= Logger.new(
28
+ log_file,
29
+ progname: "RubyLLM::MCP",
30
+ level: log_level
31
+ )
32
+ end
33
+
34
+ def inspect
35
+ redacted = lambda do |name, value|
36
+ if name.match?(/_id|_key|_secret|_token$/)
37
+ value.nil? ? "nil" : "[FILTERED]"
38
+ else
39
+ value
40
+ end
41
+ end
42
+
43
+ inspection = instance_variables.map do |ivar|
44
+ name = ivar.to_s.delete_prefix("@")
45
+ value = redacted[name, instance_variable_get(ivar)]
46
+ "#{name}: #{value}"
47
+ end.join(", ")
48
+
49
+ "#<#{self.class}:0x#{object_id.to_s(16)} #{inspection}>"
50
+ end
51
+
52
+ private
53
+
54
+ def set_defaults
55
+ # Connection configuration
56
+ @request_timeout = REQUEST_TIMEOUT_DEFAULT
57
+
58
+ # Logging configuration
59
+ @log_file = $stdout
60
+ @log_level = ENV["RUBYLLM_MCP_DEBUG"] ? Logger::DEBUG : Logger::INFO
61
+ @has_support_complex_parameters = false
62
+ @logger = nil
63
+ end
64
+ end
65
+ end
66
+ end