rails_mcp_engine 0.1.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.
@@ -0,0 +1,49 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require_relative 'fast_mcp_builder'
6
+
7
+ module ToolSchema
8
+ class FastMcpFactory
9
+ extend T::Sig
10
+
11
+ sig do
12
+ params(
13
+ service_class: T.class_of(Object),
14
+ schema: T::Hash[Symbol, T.untyped],
15
+ before_call: T.nilable(Proc),
16
+ after_call: T.nilable(Proc)
17
+ ).returns(T.class_of(Object))
18
+ end
19
+ def self.build(service_class, schema, before_call: nil, after_call: nil)
20
+ tool_constant = tool_class_name(service_class)
21
+ parent = Mcp
22
+ parent.send(:remove_const, tool_constant) if parent.const_defined?(tool_constant, false)
23
+
24
+ klass = Class.new(ApplicationTool) do
25
+ description(schema[:description])
26
+ tool_name(schema[:name])
27
+ arguments(&FastMcpBuilder.arguments_block(schema[:params]))
28
+
29
+ define_method(:call) do |**kwargs|
30
+ before_call&.call(kwargs)
31
+ result = service_class.new.public_send(schema[:entrypoint], **kwargs)
32
+ after_call&.call(result)
33
+ result
34
+ end
35
+ end
36
+
37
+ parent.const_set(tool_constant, klass)
38
+ end
39
+
40
+ sig { params(service_class: T.class_of(Object)).returns(String) }
41
+ def self.tool_class_name(service_class)
42
+ base = service_class.name.split('::').last
43
+ base.gsub(/Service$/, '')
44
+ end
45
+ end
46
+ end
47
+
48
+ module Mcp
49
+ end
@@ -0,0 +1,80 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ToolSchema
7
+ module RubyLlmBuilder
8
+ extend T::Sig
9
+
10
+ sig { params(params_ast: T::Array[T::Hash[Symbol, T.untyped]]).returns(Proc) }
11
+ def self.params_block(params_ast)
12
+ proc do
13
+ params_ast.each do |param|
14
+ RubyLlmBuilder.build_param(self, param)
15
+ end
16
+ end
17
+ end
18
+
19
+ sig { params(ctx: BasicObject, param: T::Hash[Symbol, T.untyped]).void }
20
+ def self.build_param(ctx, param)
21
+ name = param[:name]
22
+ case param[:type]
23
+ when :object
24
+ ctx.object(name) do
25
+ (param[:children] || []).each do |child|
26
+ RubyLlmBuilder.build_param(self, child)
27
+ end
28
+ end
29
+ when :array
30
+ item = param[:item_type]
31
+ if item && scalar_type?(item[:type])
32
+ ctx.array(name, of: scalar_symbol(item[:type]))
33
+ elsif item&.dig(:type) == :object
34
+ ctx.array(name) do
35
+ object :item do
36
+ (item[:children] || []).each do |child|
37
+ RubyLlmBuilder.build_param(self, child)
38
+ end
39
+ end
40
+ end
41
+ else
42
+ ctx.array(name)
43
+ end
44
+ when :any
45
+ ctx.object(name, description: param[:description]) do
46
+ additional_properties true
47
+ end
48
+ else
49
+ method = scalar_method(param[:type])
50
+ ctx.public_send(method, name)
51
+ end
52
+ end
53
+
54
+ sig { params(type: T.untyped).returns(T::Boolean) }
55
+ def self.scalar_type?(type)
56
+ %i[string integer float boolean].include?(type)
57
+ end
58
+
59
+ sig { params(type: T.untyped).returns(Symbol) }
60
+ def self.scalar_symbol(type)
61
+ case type
62
+ when :string then :string
63
+ when :integer then :integer
64
+ when :float then :float
65
+ when :boolean then :boolean
66
+ else :any
67
+ end
68
+ end
69
+
70
+ sig { params(type: T.untyped).returns(Symbol) }
71
+ def self.scalar_method(type)
72
+ case type
73
+ when :integer then :integer
74
+ when :float then :float
75
+ when :boolean then :boolean
76
+ else :string
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,48 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require_relative 'ruby_llm_builder'
6
+
7
+ module ToolSchema
8
+ class RubyLlmFactory
9
+ extend T::Sig
10
+
11
+ sig do
12
+ params(
13
+ service_class: T.class_of(Object),
14
+ schema: T::Hash[Symbol, T.untyped],
15
+ before_call: T.nilable(Proc),
16
+ after_call: T.nilable(Proc)
17
+ ).returns(T.class_of(Object))
18
+ end
19
+ def self.build(service_class, schema, before_call: nil, after_call: nil)
20
+ tool_constant = tool_class_name(service_class)
21
+ parent = Tools
22
+ parent.send(:remove_const, tool_constant) if parent.const_defined?(tool_constant, false)
23
+
24
+ klass = Class.new(RubyLLM::Tool) do
25
+ description(schema[:description])
26
+ params(&RubyLlmBuilder.params_block(schema[:params]))
27
+
28
+ define_method(:execute) do |**kwargs|
29
+ before_call&.call(kwargs)
30
+ result = service_class.new.public_send(schema[:entrypoint], **kwargs)
31
+ after_call&.call(result)
32
+ result
33
+ end
34
+ end
35
+
36
+ parent.const_set(tool_constant, klass)
37
+ end
38
+
39
+ sig { params(service_class: T.class_of(Object)).returns(String) }
40
+ def self.tool_class_name(service_class)
41
+ base = service_class.name.split('::').last
42
+ base.gsub(/Service$/, '')
43
+ end
44
+ end
45
+ end
46
+
47
+ module Tools
48
+ end
@@ -0,0 +1,137 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ToolSchema
7
+ module SorbetTypeMapper
8
+ extend T::Sig
9
+
10
+ TypeAst = T.type_alias do
11
+ T::Hash[Symbol, T.untyped]
12
+ end
13
+
14
+ sig { params(method: UnboundMethod).returns(T::Hash[Symbol, T.untyped]) }
15
+ def self.map_signature(method)
16
+ signature = T::Private::Methods.signature_for_method(method)
17
+ raise ToolMeta::MissingSignatureError, "Missing Sorbet signature for #{method.name}" if signature.nil?
18
+
19
+ params_ast = signature.arg_types.map do |name, type|
20
+ map_param(name, type)
21
+ end
22
+
23
+ signature.kwarg_types.each do |name, type|
24
+ params_ast << map_param(name, type)
25
+ end
26
+
27
+ {
28
+ params: params_ast,
29
+ return_type: map_type(signature.return_type)
30
+ }
31
+ end
32
+
33
+ sig { params(name: T.any(String, Symbol), type: T.untyped).returns(TypeAst) }
34
+ def self.map_param(name, type)
35
+ details = map_type(type)
36
+ details.merge(name: name.to_sym)
37
+ end
38
+
39
+ sig { params(type: T.untyped).returns(TypeAst) }
40
+ def self.map_type(type)
41
+ nilable, inner_type = unwrap_nilable(type)
42
+ mapped = case inner_type
43
+ when T::Types::FixedHash
44
+ map_fixed_hash(inner_type)
45
+ when T::Types::TypedArray
46
+ map_array(inner_type)
47
+ when T::Types::TypedHash
48
+ map_hash(inner_type)
49
+ when T::Types::Simple
50
+ map_simple(inner_type)
51
+ when T::Types::Union
52
+ map_union(inner_type)
53
+ else
54
+ { type: :any }
55
+ end
56
+
57
+ mapped.merge(required: !nilable)
58
+ end
59
+
60
+ sig { params(type: T::Types::Union).returns(TypeAst) }
61
+ def self.map_union(type)
62
+ non_nil_types = type.types.reject { |t| t.is_a?(T::Types::Simple) && t.raw_type == NilClass }
63
+
64
+ # Check for Boolean (TrueClass | FalseClass)
65
+ is_boolean = non_nil_types.length == 2 && non_nil_types.all? do |t|
66
+ t.is_a?(T::Types::Simple) && [TrueClass, FalseClass].include?(t.raw_type)
67
+ end
68
+ return { type: :boolean } if is_boolean
69
+
70
+ return { type: :any } if non_nil_types.length != 1
71
+
72
+ map_type(non_nil_types.first).merge(required: true)
73
+ end
74
+
75
+ sig { params(type: T::Types::TypedArray).returns(TypeAst) }
76
+ def self.map_array(type)
77
+ {
78
+ type: :array,
79
+ item_type: map_type(type.type)
80
+ }
81
+ end
82
+
83
+ sig { params(type: T::Types::TypedHash).returns(TypeAst) }
84
+ def self.map_hash(type)
85
+ {
86
+ type: :object,
87
+ children: [],
88
+ key_type: map_type(type.keys),
89
+ value_type: map_type(type.values)
90
+ }
91
+ end
92
+
93
+ sig { params(type: T::Types::FixedHash).returns(TypeAst) }
94
+ def self.map_fixed_hash(type)
95
+ children = type.keys.map do |key, value|
96
+ mapped = map_type(value.type)
97
+ mapped.merge(name: key.to_sym, required: value.required?)
98
+ end
99
+
100
+ {
101
+ type: :object,
102
+ children: children
103
+ }
104
+ end
105
+
106
+ sig { params(type: T::Types::Simple).returns(TypeAst) }
107
+ def self.map_simple(type)
108
+ raw = type.raw_type
109
+ case raw.name
110
+ when 'String'
111
+ { type: :string }
112
+ when 'Integer'
113
+ { type: :integer }
114
+ when 'Float'
115
+ { type: :float }
116
+ when 'TrueClass', 'FalseClass'
117
+ { type: :boolean }
118
+ else
119
+ if raw == T::Boolean
120
+ { type: :boolean }
121
+ else
122
+ { type: :any }
123
+ end
124
+ end
125
+ end
126
+
127
+ sig { params(type: T.untyped).returns([T::Boolean, T.untyped]) }
128
+ def self.unwrap_nilable(type)
129
+ return [false, type] unless type.is_a?(T::Types::Union)
130
+
131
+ nilable = type.types.any? { |t| t.is_a?(T::Types::Simple) && t.raw_type == NilClass }
132
+ non_nil = type.types.reject { |t| t.is_a?(T::Types::Simple) && t.raw_type == NilClass }
133
+ target = non_nil.length == 1 ? non_nil.first : type
134
+ [nilable, target]
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,165 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+ require 'tool_meta'
6
+ require 'tool_schema/builder'
7
+ require 'tool_schema/ruby_llm_factory'
8
+ require 'tool_schema/fast_mcp_factory'
9
+
10
+ module Tools
11
+ class MetaToolService
12
+ extend T::Sig
13
+ extend ToolMeta
14
+
15
+ tool_name 'meta_tool'
16
+ tool_description 'Inspect and run registered tools via a single meta interface.'
17
+ tool_param :action, description: 'Operation to perform', enum: %w[search list list_summary get run]
18
+ tool_param :tool_name, description: 'Tool name to target (for get/run)', required: false
19
+ tool_param :query, description: 'Search string to match against tool names/descriptions', required: false
20
+ tool_param :arguments, description: 'Arguments to pass when running a tool', required: false
21
+
22
+ sig do
23
+ params(
24
+ action: String,
25
+ tool_name: T.nilable(String),
26
+ query: T.nilable(String),
27
+ arguments: T.nilable(T::Hash[T.untyped, T.untyped])
28
+ ).returns(T::Hash[Symbol, T.untyped])
29
+ end
30
+ def call(action:, tool_name: nil, query: nil, arguments: nil)
31
+ case action
32
+ when 'search'
33
+ search_tools(query)
34
+ when 'list'
35
+ { tools: list_tools }
36
+ when 'list_summary'
37
+ { tools: list_summaries }
38
+ when 'get'
39
+ get_tool(tool_name)
40
+ when 'run'
41
+ run_tool(tool_name, arguments || {})
42
+ else
43
+ { error: "Unknown action: #{action}" }
44
+ end
45
+ end
46
+
47
+ sig { params(class_name: T.nilable(String), before_call: T.nilable(Proc), after_call: T.nilable(Proc)).returns(T::Hash[Symbol, T.untyped]) }
48
+ def register_tool(class_name, before_call: nil, after_call: nil)
49
+ return { error: 'class_name is required for register' } if class_name.nil? || class_name.empty?
50
+
51
+ service_class = constantize(class_name)
52
+ return { error: "Could not find #{class_name}" } if service_class.nil?
53
+ return { error: "#{class_name} must extend ToolMeta" } unless service_class.respond_to?(:tool_metadata)
54
+
55
+ ToolMeta.registry << service_class unless ToolMeta.registry.include?(service_class)
56
+
57
+ schema = ToolSchema::Builder.build(service_class)
58
+ ToolSchema::RubyLlmFactory.build(service_class, schema, before_call: before_call, after_call: after_call)
59
+ ToolSchema::FastMcpFactory.build(service_class, schema, before_call: before_call, after_call: after_call)
60
+
61
+ { status: 'registered', tool: summary_payload(schema) }
62
+ rescue ToolMeta::MissingSignatureError => e
63
+ { error: e.message }
64
+ rescue NameError => e
65
+ { error: "Could not find #{class_name}: #{e.message}" }
66
+ end
67
+
68
+ private
69
+
70
+ sig { params(query: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
71
+ def search_tools(query)
72
+ return { tools: list_tools } if query.nil? || query.empty?
73
+
74
+ normalized = query.downcase
75
+ tools = list_tools.select do |tool|
76
+ tool[:name].downcase.include?(normalized) || tool[:description].downcase.include?(normalized)
77
+ end
78
+
79
+ { tools: tools }
80
+ end
81
+
82
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
83
+ def list_tools
84
+ schemas.map { |schema| detailed_payload(schema) }
85
+ end
86
+
87
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
88
+ def list_summaries
89
+ schemas.map { |schema| summary_payload(schema) }
90
+ end
91
+
92
+ sig { params(tool_name: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
93
+ def get_tool(tool_name)
94
+ schema = find_schema(tool_name)
95
+ return { error: "Tool not found: #{tool_name}" } unless schema
96
+
97
+ { tool: detailed_payload(schema) }
98
+ end
99
+
100
+ sig { params(tool_name: T.nilable(String), arguments: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
101
+ def run_tool(tool_name, arguments)
102
+ schema = find_schema(tool_name)
103
+ return { error: "Tool not found: #{tool_name}" } unless schema
104
+
105
+ service_class = schema[:service_class]
106
+ result = service_class.new.public_send(schema[:entrypoint], **deep_symbolize(arguments))
107
+
108
+ { tool: summary_payload(schema), result: result }
109
+ rescue ArgumentError => e
110
+ { error: e.message }
111
+ end
112
+
113
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
114
+ def schemas
115
+ ToolMeta.registry.map { |service_class| ToolSchema::Builder.build(service_class) }
116
+ end
117
+
118
+ sig { params(tool_name: T.nilable(String)).returns(T.nilable(T::Hash[Symbol, T.untyped])) }
119
+ def find_schema(tool_name)
120
+ return nil if tool_name.nil? || tool_name.empty?
121
+
122
+ schemas.find { |schema| schema[:name] == tool_name }
123
+ end
124
+
125
+ sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
126
+ def summary_payload(schema)
127
+ {
128
+ name: schema[:name],
129
+ description: schema[:description],
130
+ usage: usage_string(schema)
131
+ }
132
+ end
133
+
134
+ sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
135
+ def detailed_payload(schema)
136
+ summary_payload(schema).merge(params: schema[:params], return_type: schema[:return_type])
137
+ end
138
+
139
+ sig { params(schema: T::Hash[Symbol, T.untyped]).returns(String) }
140
+ def usage_string(schema)
141
+ param_list = schema[:params].map { |param| param[:name].to_s }.join(', ')
142
+ "#{schema[:name]}(#{param_list})"
143
+ end
144
+
145
+ sig { params(name: String).returns(T.class_of(Object)) }
146
+ def constantize(name)
147
+ Object.const_get(name)
148
+ end
149
+
150
+ sig { params(value: T.untyped).returns(T.untyped) }
151
+ def deep_symbolize(value)
152
+ case value
153
+ when Hash
154
+ value.each_with_object({}) do |(key, nested), result|
155
+ symbol_key = key.is_a?(String) ? key.to_sym : key
156
+ result[symbol_key] = deep_symbolize(nested)
157
+ end
158
+ when Array
159
+ value.map { |element| deep_symbolize(element) }
160
+ else
161
+ value
162
+ end
163
+ end
164
+ end
165
+ end