sc-ruby_llm-mcp 0.3.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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +446 -0
  4. data/lib/ruby_llm/chat.rb +33 -0
  5. data/lib/ruby_llm/mcp/attachment.rb +18 -0
  6. data/lib/ruby_llm/mcp/capabilities.rb +29 -0
  7. data/lib/ruby_llm/mcp/client.rb +104 -0
  8. data/lib/ruby_llm/mcp/completion.rb +15 -0
  9. data/lib/ruby_llm/mcp/content.rb +20 -0
  10. data/lib/ruby_llm/mcp/coordinator.rb +112 -0
  11. data/lib/ruby_llm/mcp/errors.rb +28 -0
  12. data/lib/ruby_llm/mcp/parameter.rb +19 -0
  13. data/lib/ruby_llm/mcp/prompt.rb +106 -0
  14. data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +65 -0
  15. data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +61 -0
  16. data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +52 -0
  17. data/lib/ruby_llm/mcp/requests/base.rb +31 -0
  18. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +40 -0
  19. data/lib/ruby_llm/mcp/requests/completion_resource.rb +40 -0
  20. data/lib/ruby_llm/mcp/requests/initialization.rb +24 -0
  21. data/lib/ruby_llm/mcp/requests/initialize_notification.rb +14 -0
  22. data/lib/ruby_llm/mcp/requests/prompt_call.rb +32 -0
  23. data/lib/ruby_llm/mcp/requests/prompt_list.rb +23 -0
  24. data/lib/ruby_llm/mcp/requests/resource_list.rb +21 -0
  25. data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
  26. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +21 -0
  27. data/lib/ruby_llm/mcp/requests/tool_call.rb +32 -0
  28. data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -0
  29. data/lib/ruby_llm/mcp/resource.rb +77 -0
  30. data/lib/ruby_llm/mcp/resource_template.rb +79 -0
  31. data/lib/ruby_llm/mcp/tool.rb +115 -0
  32. data/lib/ruby_llm/mcp/transport/sse.rb +244 -0
  33. data/lib/ruby_llm/mcp/transport/stdio.rb +210 -0
  34. data/lib/ruby_llm/mcp/transport/streamable.rb +299 -0
  35. data/lib/ruby_llm/mcp/version.rb +7 -0
  36. data/lib/ruby_llm/mcp.rb +27 -0
  37. metadata +175 -0
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Coordinator
6
+ PROTOCOL_VERSION = "2025-03-26"
7
+ PV_2024_11_05 = "2024-11-05"
8
+
9
+ attr_reader :client, :transport_type, :config, :request_timeout, :headers, :transport, :initialize_response,
10
+ :capabilities, :protocol_version
11
+
12
+ def initialize(client, transport_type:, config: {})
13
+ @client = client
14
+ @transport_type = transport_type
15
+ @config = config
16
+
17
+ @protocol_version = PROTOCOL_VERSION
18
+ @headers = config[:headers] || {}
19
+
20
+ @transport = nil
21
+ @capabilities = nil
22
+ end
23
+
24
+ def request(body, **options)
25
+ @transport.request(body, **options)
26
+ end
27
+
28
+ def start_transport
29
+ case @transport_type
30
+ when :sse
31
+ @transport = RubyLLM::MCP::Transport::SSE.new(@config[:url],
32
+ request_timeout: @config[:request_timeout],
33
+ headers: @headers)
34
+ when :stdio
35
+ @transport = RubyLLM::MCP::Transport::Stdio.new(@config[:command],
36
+ request_timeout: @config[:request_timeout],
37
+ args: @config[:args],
38
+ env: @config[:env])
39
+ when :streamable
40
+ @transport = RubyLLM::MCP::Transport::Streamable.new(@config[:url],
41
+ request_timeout: @config[:request_timeout],
42
+ headers: @headers)
43
+ else
44
+ message = "Invalid transport type: :#{transport_type}. Supported types are :sse, :stdio, :streamable"
45
+ raise Errors::InvalidTransportType.new(message: message)
46
+ end
47
+
48
+ @initialize_response = initialize_request
49
+ @capabilities = RubyLLM::MCP::Capabilities.new(@initialize_response["result"]["capabilities"])
50
+ initialize_notification
51
+ end
52
+
53
+ def stop_transport
54
+ @transport&.close
55
+ @transport = nil
56
+ end
57
+
58
+ def restart_transport
59
+ stop_transport
60
+ start_transport
61
+ end
62
+
63
+ def alive?
64
+ !!@transport&.alive?
65
+ end
66
+
67
+ def execute_tool(**args)
68
+ RubyLLM::MCP::Requests::ToolCall.new(self, **args).call
69
+ end
70
+
71
+ def resource_read(**args)
72
+ RubyLLM::MCP::Requests::ResourceRead.new(self, **args).call
73
+ end
74
+
75
+ def completion_resource(**args)
76
+ RubyLLM::MCP::Requests::CompletionResource.new(self, **args).call
77
+ end
78
+
79
+ def completion_prompt(**args)
80
+ RubyLLM::MCP::Requests::CompletionPrompt.new(self, **args).call
81
+ end
82
+
83
+ def execute_prompt(**args)
84
+ RubyLLM::MCP::Requests::PromptCall.new(self, **args).call
85
+ end
86
+
87
+ def initialize_request
88
+ RubyLLM::MCP::Requests::Initialization.new(self).call
89
+ end
90
+
91
+ def initialize_notification
92
+ RubyLLM::MCP::Requests::InitializeNotification.new(self).call
93
+ end
94
+
95
+ def tool_list
96
+ RubyLLM::MCP::Requests::ToolList.new(self).call
97
+ end
98
+
99
+ def resource_list
100
+ RubyLLM::MCP::Requests::ResourceList.new(self).call
101
+ end
102
+
103
+ def resource_template_list
104
+ RubyLLM::MCP::Requests::ResourceTemplateList.new(self).call
105
+ end
106
+
107
+ def prompt_list
108
+ RubyLLM::MCP::Requests::PromptList.new(self).call
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,28 @@
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
+ class CompletionNotAvailable < BaseError; end
16
+
17
+ class PromptArgumentError < BaseError; end
18
+
19
+ class InvalidProtocolVersionError < BaseError; end
20
+
21
+ class SessionExpiredError < BaseError; end
22
+
23
+ class TimeoutError < BaseError; end
24
+
25
+ class InvalidTransportType < BaseError; end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Parameter < RubyLLM::Parameter
6
+ attr_accessor :items, :properties, :enum, :union_type
7
+
8
+ def initialize(name, type: "string", desc: nil, required: true, union_type: nil)
9
+ super(name, type: type.to_sym, desc: desc, required: required)
10
+ @properties = {}
11
+ @union_type = union_type
12
+ end
13
+
14
+ def item_type
15
+ @items["type"].to_sym
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Prompt
6
+ class Argument
7
+ attr_reader :name, :description, :required
8
+
9
+ def initialize(name:, description:, required:)
10
+ @name = name
11
+ @description = description
12
+ @required = required
13
+ end
14
+ end
15
+
16
+ attr_reader :name, :description, :arguments, :coordinator
17
+
18
+ def initialize(coordinator, prompt)
19
+ @coordinator = coordinator
20
+ @name = prompt["name"]
21
+ @description = prompt["description"]
22
+ @arguments = parse_arguments(prompt["arguments"])
23
+ end
24
+
25
+ def fetch(arguments = {})
26
+ fetch_prompt_messages(arguments)
27
+ end
28
+
29
+ def include(chat, arguments: {})
30
+ validate_arguments!(arguments)
31
+ messages = fetch_prompt_messages(arguments)
32
+
33
+ messages.each { |message| chat.add_message(message) }
34
+ chat
35
+ end
36
+
37
+ def ask(chat, arguments: {}, &)
38
+ include(chat, arguments: arguments)
39
+
40
+ chat.complete(&)
41
+ end
42
+
43
+ alias say ask
44
+
45
+ def complete(argument, value)
46
+ if @coordinator.capabilities.completion?
47
+ response = @coordinator.completion_prompt(name: @name, argument: argument, value: value)
48
+ response = response.dig("result", "completion")
49
+
50
+ Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
51
+ else
52
+ raise Errors::CompletionNotAvailable.new(message: "Completion is not available for this MCP server")
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def fetch_prompt_messages(arguments)
59
+ response = @coordinator.execute_prompt(
60
+ name: @name,
61
+ arguments: arguments
62
+ )
63
+
64
+ response["result"]["messages"].map do |message|
65
+ content = create_content_for_message(message["content"])
66
+
67
+ RubyLLM::Message.new(
68
+ role: message["role"],
69
+ content: content
70
+ )
71
+ end
72
+ end
73
+
74
+ def validate_arguments!(incoming_arguments)
75
+ @arguments.each do |arg|
76
+ if arg.required && incoming_arguments.key?(arg.name)
77
+ raise Errors::PromptArgumentError, "Argument #{arg.name} is required"
78
+ end
79
+ end
80
+ end
81
+
82
+ def create_content_for_message(content)
83
+ case content["type"]
84
+ when "text"
85
+ MCP::Content.new(text: content["text"])
86
+ when "image", "audio"
87
+ attachment = MCP::Attachment.new(content["content"], content["mime_type"])
88
+ MCP::Content.new(text: nil, attachments: [attachment])
89
+ when "resource"
90
+ resource = Resource.new(coordinator, content["resource"])
91
+ resource.to_content
92
+ end
93
+ end
94
+
95
+ def parse_arguments(arguments)
96
+ if arguments.nil?
97
+ []
98
+ else
99
+ arguments.map do |arg|
100
+ Argument.new(name: arg["name"], description: arg["description"], required: arg["required"])
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Providers
6
+ module Anthropic
7
+ module ComplexParameterSupport
8
+ module_function
9
+
10
+ def clean_parameters(parameters)
11
+ parameters.transform_values do |param|
12
+ build_properties(param).compact
13
+ end
14
+ end
15
+
16
+ def required_parameters(parameters)
17
+ parameters.select { |_, param| param.required }.keys
18
+ end
19
+
20
+ def build_properties(param)
21
+ case param.type
22
+ when :array
23
+ if param.item_type == :object
24
+ {
25
+ type: param.type,
26
+ items: { type: param.item_type, properties: clean_parameters(param.properties) }
27
+ }
28
+ else
29
+ {
30
+ type: param.type,
31
+ items: { type: param.item_type, enum: param.enum }.compact
32
+ }
33
+ end
34
+ when :object
35
+ {
36
+ type: param.type,
37
+ properties: clean_parameters(param.properties),
38
+ required: required_parameters(param.properties)
39
+ }
40
+ when :union
41
+ {
42
+ param.union_type => param.properties.map { |property| build_properties(property) }
43
+ }
44
+ else
45
+ {
46
+ type: param.type,
47
+ description: param.description
48
+ }.compact
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ module RubyLLM::Providers::Anthropic::Tools
58
+ def self.clean_parameters(parameters)
59
+ RubyLLM::MCP::Providers::Anthropic::ComplexParameterSupport.clean_parameters(parameters)
60
+ end
61
+
62
+ def self.required_parameters(parameters)
63
+ RubyLLM::MCP::Providers::Anthropic::ComplexParameterSupport.required_parameters(parameters)
64
+ end
65
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Providers
6
+ module Gemini
7
+ module ComplexParameterSupport
8
+ module_function
9
+
10
+ # Format tool parameters for Gemini API
11
+ def format_parameters(parameters)
12
+ {
13
+ type: "OBJECT",
14
+ properties: parameters.transform_values { |param| build_properties(param) },
15
+ required: parameters.select { |_, p| p.required }.keys.map(&:to_s)
16
+ }
17
+ end
18
+
19
+ def build_properties(param)
20
+ properties = case param.type
21
+ when :array
22
+ if param.item_type == :object
23
+ {
24
+ type: param_type_for_gemini(param.type),
25
+ items: {
26
+ type: param_type_for_gemini(param.item_type),
27
+ properties: param.properties.transform_values { |value| build_properties(value) }
28
+ }
29
+ }
30
+ else
31
+ {
32
+ type: param_type_for_gemini(param.type),
33
+ items: { type: param_type_for_gemini(param.item_type), enum: param.enum }.compact
34
+ }
35
+ end
36
+ when :object
37
+ {
38
+ type: param_type_for_gemini(param.type),
39
+ properties: param.properties.transform_values { |value| build_properties(value) },
40
+ required: param.properties.select { |_, p| p.required }.keys
41
+ }
42
+ when :union
43
+ {
44
+ param.union_type => param.properties.map { |properties| build_properties(properties) }
45
+ }
46
+ else
47
+ {
48
+ type: param_type_for_gemini(param.type),
49
+ description: param.description
50
+ }
51
+ end
52
+
53
+ properties.compact
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ RubyLLM::Providers::Gemini.extend(RubyLLM::MCP::Providers::Gemini::ComplexParameterSupport)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Providers
6
+ module OpenAI
7
+ module ComplexParameterSupport
8
+ module_function
9
+
10
+ def param_schema(param)
11
+ properties = case param.type
12
+ when :array
13
+ if param.item_type == :object
14
+ {
15
+ type: param.type,
16
+ items: {
17
+ type: param.item_type,
18
+ properties: param.properties.transform_values { |value| param_schema(value) }
19
+ }
20
+ }
21
+ else
22
+ {
23
+ type: param.type,
24
+ items: { type: param.item_type, enum: param.enum }.compact
25
+ }
26
+ end
27
+ when :object
28
+ {
29
+ type: param.type,
30
+ properties: param.properties.transform_values { |value| param_schema(value) },
31
+ required: param.properties.select { |_, p| p.required }.keys
32
+ }
33
+ when :union
34
+ {
35
+ param.union_type => param.properties.map { |property| param_schema(property) }
36
+ }
37
+ else
38
+ {
39
+ type: param.type,
40
+ description: param.description
41
+ }.compact
42
+ end
43
+
44
+ properties.compact
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ RubyLLM::Providers::OpenAI.extend(RubyLLM::MCP::Providers::OpenAI::ComplexParameterSupport)
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module Requests
8
+ class Base
9
+ attr_reader :client
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ def call
16
+ raise "Not implemented"
17
+ end
18
+
19
+ private
20
+
21
+ def validate_response!(response, body)
22
+ # TODO: Implement response validation
23
+ end
24
+
25
+ def raise_error(error)
26
+ raise "MCP Error: code: #{error['code']} message: #{error['message']} data: #{error['data']}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class CompletionPrompt
7
+ def initialize(client, name:, argument:, value:)
8
+ @client = client
9
+ @name = name
10
+ @argument = argument
11
+ @value = value
12
+ end
13
+
14
+ def call
15
+ @client.request(request_body)
16
+ end
17
+
18
+ private
19
+
20
+ def request_body
21
+ {
22
+ jsonrpc: "2.0",
23
+ id: 1,
24
+ method: "completion/complete",
25
+ params: {
26
+ ref: {
27
+ type: "ref/prompt",
28
+ name: @name
29
+ },
30
+ argument: {
31
+ name: @argument,
32
+ value: @value
33
+ }
34
+ }
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class CompletionResource
7
+ def initialize(client, uri:, argument:, value:)
8
+ @client = client
9
+ @uri = uri
10
+ @argument = argument
11
+ @value = value
12
+ end
13
+
14
+ def call
15
+ @client.request(request_body)
16
+ end
17
+
18
+ private
19
+
20
+ def request_body
21
+ {
22
+ jsonrpc: "2.0",
23
+ id: 1,
24
+ method: "completion/complete",
25
+ params: {
26
+ ref: {
27
+ type: "ref/resource",
28
+ uri: @uri
29
+ },
30
+ argument: {
31
+ name: @argument,
32
+ value: @value
33
+ }
34
+ }
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RubyLLM::MCP::Requests::Initialization < RubyLLM::MCP::Requests::Base
4
+ def call
5
+ client.request(initialize_body)
6
+ end
7
+
8
+ private
9
+
10
+ def initialize_body
11
+ {
12
+ jsonrpc: "2.0",
13
+ method: "initialize",
14
+ params: {
15
+ protocolVersion: @client.protocol_version,
16
+ capabilities: {},
17
+ clientInfo: {
18
+ name: "RubyLLM-MCP Client",
19
+ version: RubyLLM::MCP::VERSION
20
+ }
21
+ }
22
+ }
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RubyLLM::MCP::Requests::InitializeNotification < RubyLLM::MCP::Requests::Base
4
+ def call
5
+ client.request(notification_body, add_id: false, wait_for_response: false)
6
+ end
7
+
8
+ def notification_body
9
+ {
10
+ jsonrpc: "2.0",
11
+ method: "notifications/initialized"
12
+ }
13
+ end
14
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class PromptCall
7
+ def initialize(client, name:, arguments: {})
8
+ @client = client
9
+ @name = name
10
+ @arguments = arguments
11
+ end
12
+
13
+ def call
14
+ @client.request(request_body)
15
+ end
16
+
17
+ private
18
+
19
+ def request_body
20
+ {
21
+ jsonrpc: "2.0",
22
+ method: "prompts/get",
23
+ params: {
24
+ name: @name,
25
+ arguments: @arguments
26
+ }
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class PromptList < Base
7
+ def call
8
+ client.request(request_body)
9
+ end
10
+
11
+ private
12
+
13
+ def request_body
14
+ {
15
+ jsonrpc: "2.0",
16
+ method: "prompts/list",
17
+ params: {}
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class ResourceList < Base
7
+ def call
8
+ client.request(resource_list_body)
9
+ end
10
+
11
+ def resource_list_body
12
+ {
13
+ jsonrpc: "2.0",
14
+ method: "resources/list",
15
+ params: {}
16
+ }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end