ruby_llm_swarm-mcp 0.8.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
- data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/chat.rb +34 -0
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +401 -0
- data/lib/ruby_llm/mcp/completion.rb +16 -0
- data/lib/ruby_llm/mcp/configuration.rb +310 -0
- data/lib/ruby_llm/mcp/content.rb +28 -0
- data/lib/ruby_llm/mcp/elicitation.rb +48 -0
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +91 -0
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
- data/lib/ruby_llm/mcp/native/client.rb +387 -0
- data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
- data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
- data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
- data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
- data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
- data/lib/ruby_llm/mcp/native/messages.rb +36 -0
- data/lib/ruby_llm/mcp/native/notification.rb +16 -0
- data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
- data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
- data/lib/ruby_llm/mcp/native/transport.rb +88 -0
- data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
- data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
- data/lib/ruby_llm/mcp/native.rb +12 -0
- data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
- data/lib/ruby_llm/mcp/progress.rb +35 -0
- data/lib/ruby_llm/mcp/prompt.rb +132 -0
- data/lib/ruby_llm/mcp/railtie.rb +14 -0
- data/lib/ruby_llm/mcp/resource.rb +112 -0
- data/lib/ruby_llm/mcp/resource_template.rb +85 -0
- data/lib/ruby_llm/mcp/result.rb +108 -0
- data/lib/ruby_llm/mcp/roots.rb +45 -0
- data/lib/ruby_llm/mcp/sample.rb +152 -0
- data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
- data/lib/ruby_llm/mcp/tool.rb +228 -0
- data/lib/ruby_llm/mcp/version.rb +7 -0
- data/lib/ruby_llm/mcp.rb +125 -0
- data/lib/tasks/release.rake +23 -0
- metadata +184 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Content < RubyLLM::Content
|
|
6
|
+
attr_reader :text, :attachments, :content
|
|
7
|
+
|
|
8
|
+
def initialize(text: nil, attachments: nil) # rubocop:disable Lint/MissingSuper
|
|
9
|
+
@text = text
|
|
10
|
+
@attachments = []
|
|
11
|
+
|
|
12
|
+
# Handle MCP::Attachment objects directly without processing
|
|
13
|
+
if attachments.is_a?(Array) && attachments.all? { |a| a.is_a?(MCP::Attachment) }
|
|
14
|
+
@attachments = attachments
|
|
15
|
+
elsif attachments
|
|
16
|
+
# Let parent class process other types of attachments
|
|
17
|
+
process_attachments(attachments)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# This is a workaround to allow the content object to be passed as the tool call
|
|
22
|
+
# to return audio or image attachments.
|
|
23
|
+
def to_s
|
|
24
|
+
text.to_s
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Elicitation
|
|
6
|
+
ACCEPT_ACTION = "accept"
|
|
7
|
+
CANCEL_ACTION = "cancel"
|
|
8
|
+
REJECT_ACTION = "reject"
|
|
9
|
+
|
|
10
|
+
attr_writer :structured_response
|
|
11
|
+
|
|
12
|
+
def initialize(coordinator, result)
|
|
13
|
+
@coordinator = coordinator
|
|
14
|
+
@result = result
|
|
15
|
+
@id = result.id
|
|
16
|
+
|
|
17
|
+
@message = @result.params["message"]
|
|
18
|
+
@requested_schema = @result.params["requestedSchema"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def execute
|
|
22
|
+
success = @coordinator.elicitation_callback&.call(self)
|
|
23
|
+
|
|
24
|
+
if success
|
|
25
|
+
valid = validate_response
|
|
26
|
+
if valid
|
|
27
|
+
@coordinator.elicitation_response(id: @id,
|
|
28
|
+
elicitation: {
|
|
29
|
+
action: ACCEPT_ACTION, content: @structured_response
|
|
30
|
+
})
|
|
31
|
+
else
|
|
32
|
+
@coordinator.elicitation_response(id: @id, elicitation: { action: CANCEL_ACTION, content: nil })
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
@coordinator.elicitation_response(id: @id, elicitation: { action: REJECT_ACTION, content: nil })
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def message
|
|
40
|
+
@result.params["message"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_response
|
|
44
|
+
JSON::Validator.validate(@requested_schema, @structured_response)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Error
|
|
6
|
+
def initialize(error_data)
|
|
7
|
+
@code = error_data["code"]
|
|
8
|
+
@message = error_data["message"]
|
|
9
|
+
@data = error_data["data"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def type
|
|
13
|
+
case @code
|
|
14
|
+
when -32_700
|
|
15
|
+
:parse_error
|
|
16
|
+
when -32_600
|
|
17
|
+
:invalid_request
|
|
18
|
+
when -32_601
|
|
19
|
+
:method_not_found
|
|
20
|
+
when -32_602
|
|
21
|
+
:invalid_params
|
|
22
|
+
when -32_603
|
|
23
|
+
:internal_error
|
|
24
|
+
else
|
|
25
|
+
:custom_error
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_s
|
|
30
|
+
"Error: code: #{@code} (#{type}), message: #{@message}, data: #{@data}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Errors
|
|
6
|
+
class BaseError < StandardError
|
|
7
|
+
attr_reader :message
|
|
8
|
+
|
|
9
|
+
def initialize(message:)
|
|
10
|
+
@message = message
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module Capabilities
|
|
16
|
+
class CompletionNotAvailable < BaseError; end
|
|
17
|
+
class ResourceSubscribeNotAvailable < BaseError; end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class InvalidFormatError < BaseError; end
|
|
21
|
+
|
|
22
|
+
class InvalidProtocolVersionError < BaseError; end
|
|
23
|
+
|
|
24
|
+
class InvalidTransportType < BaseError; end
|
|
25
|
+
|
|
26
|
+
class ProgressHandlerNotAvailable < BaseError; end
|
|
27
|
+
|
|
28
|
+
class PromptArgumentError < BaseError; end
|
|
29
|
+
|
|
30
|
+
class ResponseError < BaseError
|
|
31
|
+
attr_reader :error
|
|
32
|
+
|
|
33
|
+
def initialize(message:, error:)
|
|
34
|
+
@error = error
|
|
35
|
+
super(message: message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class AuthenticationRequiredError < BaseError
|
|
40
|
+
attr_reader :code
|
|
41
|
+
|
|
42
|
+
def initialize(message: "Authentication required", code: 401)
|
|
43
|
+
@code = code
|
|
44
|
+
super(message: message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class ConfigurationError < BaseError; end
|
|
49
|
+
|
|
50
|
+
class SessionExpiredError < BaseError; end
|
|
51
|
+
|
|
52
|
+
class TimeoutError < BaseError
|
|
53
|
+
attr_reader :request_id
|
|
54
|
+
|
|
55
|
+
def initialize(message:, request_id: nil)
|
|
56
|
+
@request_id = request_id
|
|
57
|
+
super(message: message)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class TransportError < BaseError
|
|
62
|
+
attr_reader :code, :error
|
|
63
|
+
|
|
64
|
+
def initialize(message:, code: nil, error: nil)
|
|
65
|
+
@code = code
|
|
66
|
+
@error = error
|
|
67
|
+
super(message: message)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class UnknownRequest < BaseError; end
|
|
72
|
+
|
|
73
|
+
class UnsupportedProtocolVersion < BaseError; end
|
|
74
|
+
|
|
75
|
+
class UnsupportedFeature < BaseError; end
|
|
76
|
+
|
|
77
|
+
class UnsupportedTransport < BaseError; end
|
|
78
|
+
|
|
79
|
+
class AdapterConfigurationError < BaseError; end
|
|
80
|
+
|
|
81
|
+
class RequestCancelled < BaseError
|
|
82
|
+
attr_reader :request_id
|
|
83
|
+
|
|
84
|
+
def initialize(message:, request_id:)
|
|
85
|
+
@request_id = request_id
|
|
86
|
+
super(message: message)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Logging
|
|
6
|
+
DEBUG = "debug"
|
|
7
|
+
INFO = "info"
|
|
8
|
+
NOTICE = "notice"
|
|
9
|
+
WARNING = "warning"
|
|
10
|
+
ERROR = "error"
|
|
11
|
+
CRITICAL = "critical"
|
|
12
|
+
ALERT = "alert"
|
|
13
|
+
EMERGENCY = "emergency"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Native
|
|
6
|
+
# Wraps server-initiated requests to support cancellation
|
|
7
|
+
# Executes the request in a separate thread that can be terminated on cancellation
|
|
8
|
+
class CancellableOperation
|
|
9
|
+
attr_reader :request_id, :thread
|
|
10
|
+
|
|
11
|
+
def initialize(request_id)
|
|
12
|
+
@request_id = request_id
|
|
13
|
+
@cancelled = false
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@thread = nil
|
|
16
|
+
@result = nil
|
|
17
|
+
@error = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def cancelled?
|
|
21
|
+
@mutex.synchronize { @cancelled }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def cancel
|
|
25
|
+
@mutex.synchronize { @cancelled = true }
|
|
26
|
+
if @thread&.alive?
|
|
27
|
+
@thread.raise(Errors::RequestCancelled.new(
|
|
28
|
+
message: "Request #{@request_id} was cancelled",
|
|
29
|
+
request_id: @request_id
|
|
30
|
+
))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Execute a block in a separate thread
|
|
35
|
+
# This allows the thread to be terminated if cancellation is requested
|
|
36
|
+
# Returns the result of the block or re-raises any error that occurred
|
|
37
|
+
def execute(&)
|
|
38
|
+
@thread = Thread.new do
|
|
39
|
+
Thread.current.abort_on_exception = false
|
|
40
|
+
begin
|
|
41
|
+
@result = yield
|
|
42
|
+
rescue Errors::RequestCancelled, StandardError => e
|
|
43
|
+
@error = e
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@thread.join
|
|
48
|
+
raise @error if @error && !@error.is_a?(Errors::RequestCancelled)
|
|
49
|
+
|
|
50
|
+
@result
|
|
51
|
+
ensure
|
|
52
|
+
@thread = nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
module Native
|
|
6
|
+
# Native MCP protocol client implementation
|
|
7
|
+
# This is the core protocol implementation that handles all MCP operations
|
|
8
|
+
# It's self-contained and could potentially be extracted as a separate gem
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :name, :transport_type, :config, :capabilities, :protocol_version, :elicitation_callback,
|
|
11
|
+
:sampling_callback
|
|
12
|
+
|
|
13
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
14
|
+
name:,
|
|
15
|
+
transport_type:,
|
|
16
|
+
transport_config: {},
|
|
17
|
+
human_in_the_loop_callback: nil,
|
|
18
|
+
roots_callback: nil,
|
|
19
|
+
logging_enabled: false,
|
|
20
|
+
logging_level: nil,
|
|
21
|
+
elicitation_enabled: false,
|
|
22
|
+
elicitation_callback: nil,
|
|
23
|
+
progress_tracking_enabled: false,
|
|
24
|
+
sampling_callback: nil,
|
|
25
|
+
notification_callback: nil,
|
|
26
|
+
protocol_version: nil,
|
|
27
|
+
request_timeout: nil
|
|
28
|
+
)
|
|
29
|
+
@name = name
|
|
30
|
+
@transport_type = transport_type
|
|
31
|
+
@config = transport_config.merge(request_timeout: request_timeout || MCP.config.request_timeout)
|
|
32
|
+
@protocol_version = protocol_version || MCP.config.protocol_version || Native::Protocol.default_negotiated_version
|
|
33
|
+
|
|
34
|
+
# Callbacks
|
|
35
|
+
@human_in_the_loop_callback = human_in_the_loop_callback
|
|
36
|
+
@roots_callback = roots_callback
|
|
37
|
+
@logging_enabled = logging_enabled
|
|
38
|
+
@logging_level = logging_level
|
|
39
|
+
@elicitation_enabled = elicitation_enabled
|
|
40
|
+
@elicitation_callback = elicitation_callback
|
|
41
|
+
@progress_tracking_enabled = progress_tracking_enabled
|
|
42
|
+
@sampling_callback = sampling_callback
|
|
43
|
+
@notification_callback = notification_callback
|
|
44
|
+
|
|
45
|
+
@transport = nil
|
|
46
|
+
@capabilities = nil
|
|
47
|
+
|
|
48
|
+
# Track in-flight server-initiated requests for cancellation
|
|
49
|
+
@in_flight_requests = {}
|
|
50
|
+
@in_flight_mutex = Mutex.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def request(body, **options)
|
|
54
|
+
transport.request(body, **options)
|
|
55
|
+
rescue RubyLLM::MCP::Errors::TimeoutError => e
|
|
56
|
+
if transport&.alive? && !e.request_id.nil?
|
|
57
|
+
cancelled_notification(reason: "Request timed out", request_id: e.request_id)
|
|
58
|
+
end
|
|
59
|
+
raise e
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def process_result(result)
|
|
63
|
+
if result.notification?
|
|
64
|
+
process_notification(result)
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if result.request?
|
|
69
|
+
process_request(result) if alive?
|
|
70
|
+
return nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if result.response?
|
|
74
|
+
return result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def start
|
|
81
|
+
return unless capabilities.nil?
|
|
82
|
+
|
|
83
|
+
transport.start
|
|
84
|
+
|
|
85
|
+
initialize_response = initialize_request
|
|
86
|
+
initialize_response.raise_error! if initialize_response.error?
|
|
87
|
+
|
|
88
|
+
# Extract and store the negotiated protocol version
|
|
89
|
+
negotiated_version = initialize_response.value["protocolVersion"]
|
|
90
|
+
|
|
91
|
+
if negotiated_version && !Native::Protocol.supported_version?(negotiated_version)
|
|
92
|
+
raise Errors::UnsupportedProtocolVersion.new(
|
|
93
|
+
message: <<~MESSAGE
|
|
94
|
+
Unsupported protocol version, and could not negotiate a supported version: #{negotiated_version}.
|
|
95
|
+
Supported versions: #{Native::Protocol.supported_versions.join(', ')}
|
|
96
|
+
MESSAGE
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@protocol_version = negotiated_version if negotiated_version
|
|
101
|
+
|
|
102
|
+
# Set the protocol version on the transport for subsequent requests
|
|
103
|
+
if @transport.respond_to?(:set_protocol_version)
|
|
104
|
+
@transport.set_protocol_version(@protocol_version)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@capabilities = RubyLLM::MCP::ServerCapabilities.new(initialize_response.value["capabilities"])
|
|
108
|
+
initialize_notification
|
|
109
|
+
|
|
110
|
+
if @logging_enabled && @logging_level
|
|
111
|
+
set_logging(level: @logging_level)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def stop
|
|
116
|
+
@transport&.close
|
|
117
|
+
@capabilities = nil
|
|
118
|
+
@transport = nil
|
|
119
|
+
@protocol_version = Native::Protocol.default_negotiated_version
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def restart!
|
|
123
|
+
@initialize_response = nil
|
|
124
|
+
stop
|
|
125
|
+
start
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def alive?
|
|
129
|
+
!!@transport&.alive?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def ping
|
|
133
|
+
body = Native::Messages::Requests.ping(tracking_progress: tracking_progress?)
|
|
134
|
+
if alive?
|
|
135
|
+
result = request(body)
|
|
136
|
+
else
|
|
137
|
+
transport.start
|
|
138
|
+
|
|
139
|
+
result = request(body)
|
|
140
|
+
@transport = nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
result.value == {}
|
|
144
|
+
rescue RubyLLM::MCP::Errors::TimeoutError, RubyLLM::MCP::Errors::TransportError
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def process_notification(result)
|
|
149
|
+
notification = result.notification
|
|
150
|
+
@notification_callback&.call(notification)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def process_request(result)
|
|
154
|
+
Native::ResponseHandler.new(self).execute(result)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def initialize_request
|
|
158
|
+
body = Native::Messages::Requests.initialize(
|
|
159
|
+
protocol_version: protocol_version,
|
|
160
|
+
capabilities: client_capabilities
|
|
161
|
+
)
|
|
162
|
+
request(body)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def tool_list(cursor: nil)
|
|
166
|
+
body = Native::Messages::Requests.tool_list(cursor: cursor, tracking_progress: tracking_progress?)
|
|
167
|
+
result = request(body)
|
|
168
|
+
result.raise_error! if result.error?
|
|
169
|
+
|
|
170
|
+
if result.next_cursor?
|
|
171
|
+
result.value["tools"] + tool_list(cursor: result.next_cursor)
|
|
172
|
+
else
|
|
173
|
+
result.value["tools"]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def execute_tool(name:, parameters:)
|
|
178
|
+
if @human_in_the_loop_callback && !@human_in_the_loop_callback.call(name, parameters)
|
|
179
|
+
result = Result.new(
|
|
180
|
+
{
|
|
181
|
+
"result" => {
|
|
182
|
+
"isError" => true,
|
|
183
|
+
"content" => [{ "type" => "text", "text" => "Tool call was cancelled by the client" }]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return result
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
body = Native::Messages::Requests.tool_call(name: name, parameters: parameters,
|
|
191
|
+
tracking_progress: tracking_progress?)
|
|
192
|
+
request(body)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def resource_list(cursor: nil)
|
|
196
|
+
body = Native::Messages::Requests.resource_list(cursor: cursor, tracking_progress: tracking_progress?)
|
|
197
|
+
result = request(body)
|
|
198
|
+
result.raise_error! if result.error?
|
|
199
|
+
|
|
200
|
+
if result.next_cursor?
|
|
201
|
+
result.value["resources"] + resource_list(cursor: result.next_cursor)
|
|
202
|
+
else
|
|
203
|
+
result.value["resources"]
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def resource_read(uri:)
|
|
208
|
+
body = Native::Messages::Requests.resource_read(uri: uri, tracking_progress: tracking_progress?)
|
|
209
|
+
request(body)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def resource_template_list(cursor: nil)
|
|
213
|
+
body = Native::Messages::Requests.resource_template_list(cursor: cursor,
|
|
214
|
+
tracking_progress: tracking_progress?)
|
|
215
|
+
result = request(body)
|
|
216
|
+
result.raise_error! if result.error?
|
|
217
|
+
|
|
218
|
+
if result.next_cursor?
|
|
219
|
+
result.value["resourceTemplates"] + resource_template_list(cursor: result.next_cursor)
|
|
220
|
+
else
|
|
221
|
+
result.value["resourceTemplates"]
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def resources_subscribe(uri:)
|
|
226
|
+
body = Native::Messages::Requests.resources_subscribe(uri: uri, tracking_progress: tracking_progress?)
|
|
227
|
+
request(body, wait_for_response: false)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def prompt_list(cursor: nil)
|
|
231
|
+
body = Native::Messages::Requests.prompt_list(cursor: cursor, tracking_progress: tracking_progress?)
|
|
232
|
+
result = request(body)
|
|
233
|
+
result.raise_error! if result.error?
|
|
234
|
+
|
|
235
|
+
if result.next_cursor?
|
|
236
|
+
result.value["prompts"] + prompt_list(cursor: result.next_cursor)
|
|
237
|
+
else
|
|
238
|
+
result.value["prompts"]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def execute_prompt(name:, arguments:)
|
|
243
|
+
body = Native::Messages::Requests.prompt_call(name: name, arguments: arguments,
|
|
244
|
+
tracking_progress: tracking_progress?)
|
|
245
|
+
request(body)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def completion_resource(uri:, argument:, value:, context: nil)
|
|
249
|
+
body = Native::Messages::Requests.completion_resource(uri: uri, argument: argument, value: value,
|
|
250
|
+
context: context, tracking_progress: tracking_progress?)
|
|
251
|
+
request(body)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def completion_prompt(name:, argument:, value:, context: nil)
|
|
255
|
+
body = Native::Messages::Requests.completion_prompt(name: name, argument: argument, value: value,
|
|
256
|
+
context: context, tracking_progress: tracking_progress?)
|
|
257
|
+
request(body)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def set_logging(level:)
|
|
261
|
+
body = Native::Messages::Requests.logging_set_level(level: level, tracking_progress: tracking_progress?)
|
|
262
|
+
request(body)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def set_progress_tracking(enabled:)
|
|
266
|
+
@progress_tracking_enabled = enabled
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
## Notifications
|
|
270
|
+
#
|
|
271
|
+
def initialize_notification
|
|
272
|
+
body = Native::Messages::Notifications.initialized
|
|
273
|
+
request(body, wait_for_response: false)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def cancelled_notification(reason:, request_id:)
|
|
277
|
+
body = Native::Messages::Notifications.cancelled(request_id: request_id, reason: reason)
|
|
278
|
+
request(body, wait_for_response: false)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def roots_list_change_notification
|
|
282
|
+
body = Native::Messages::Notifications.roots_list_changed
|
|
283
|
+
request(body, wait_for_response: false)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
## Responses
|
|
287
|
+
#
|
|
288
|
+
def ping_response(id:)
|
|
289
|
+
body = Native::Messages::Responses.ping(id: id)
|
|
290
|
+
request(body, wait_for_response: false)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def roots_list_response(id:)
|
|
294
|
+
body = Native::Messages::Responses.roots_list(id: id, roots_paths: roots_paths)
|
|
295
|
+
request(body, wait_for_response: false)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def sampling_create_message_response(id:, model:, message:, **_options)
|
|
299
|
+
body = Native::Messages::Responses.sampling_create_message(id: id, model: model, message: message)
|
|
300
|
+
request(body, wait_for_response: false)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def error_response(id:, message:, code: Native::JsonRpc::ErrorCodes::SERVER_ERROR, data: nil)
|
|
304
|
+
body = Native::Messages::Responses.error(id: id, message: message, code: code, data: data)
|
|
305
|
+
request(body, wait_for_response: false)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def elicitation_response(id:, elicitation:)
|
|
309
|
+
body = Native::Messages::Responses.elicitation(id: id, action: elicitation[:action],
|
|
310
|
+
content: elicitation[:content])
|
|
311
|
+
request(body, wait_for_response: false)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def client_capabilities
|
|
315
|
+
capabilities_hash = {}
|
|
316
|
+
|
|
317
|
+
if @roots_callback&.call&.any?
|
|
318
|
+
capabilities_hash[:roots] = {
|
|
319
|
+
listChanged: true
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
if MCP.config.sampling.enabled?
|
|
324
|
+
capabilities_hash[:sampling] = {}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
if @elicitation_enabled
|
|
328
|
+
capabilities_hash[:elicitation] = {}
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
capabilities_hash
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def roots_paths
|
|
335
|
+
@roots_callback&.call || []
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def tracking_progress?
|
|
339
|
+
@progress_tracking_enabled
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def sampling_callback_enabled?
|
|
343
|
+
!@sampling_callback.nil?
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def transport
|
|
347
|
+
@transport ||= Native::Transport.new(@transport_type, self, config: @config)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Register a server-initiated request that can be cancelled
|
|
351
|
+
# @param request_id [String] The ID of the request
|
|
352
|
+
# @param cancellable_operation [CancellableOperation, nil] The operation that can be cancelled
|
|
353
|
+
def register_in_flight_request(request_id, cancellable_operation = nil)
|
|
354
|
+
@in_flight_mutex.synchronize do
|
|
355
|
+
@in_flight_requests[request_id.to_s] = cancellable_operation
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Unregister a completed or cancelled request
|
|
360
|
+
# @param request_id [String] The ID of the request
|
|
361
|
+
def unregister_in_flight_request(request_id)
|
|
362
|
+
@in_flight_mutex.synchronize do
|
|
363
|
+
@in_flight_requests.delete(request_id.to_s)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Cancel an in-flight server-initiated request
|
|
368
|
+
# @param request_id [String] The ID of the request to cancel
|
|
369
|
+
# @return [Boolean] true if the request was found and cancelled, false otherwise
|
|
370
|
+
def cancel_in_flight_request(request_id) # rubocop:disable Naming/PredicateMethod
|
|
371
|
+
operation = nil
|
|
372
|
+
@in_flight_mutex.synchronize do
|
|
373
|
+
operation = @in_flight_requests[request_id.to_s]
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if operation.respond_to?(:cancel)
|
|
377
|
+
operation.cancel
|
|
378
|
+
true
|
|
379
|
+
else
|
|
380
|
+
RubyLLM::MCP.logger.warn("Request #{request_id} cannot be cancelled or was already completed")
|
|
381
|
+
false
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|