mcpable 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,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Rails
5
+ class ToolsController < ActionController::API
6
+ def create
7
+ transport = Mcpable::Transports::OfficialMcp.new(
8
+ registry: Mcpable.registry,
9
+ runtime: Mcpable.runtime,
10
+ profile: (params[:profile] || :default).to_sym
11
+ )
12
+ context = Mcpable.config.context_builder.call(request.env)
13
+ response_body = transport.handle(request.body.read, context: context)
14
+
15
+ if response_body.nil?
16
+ head :accepted
17
+ else
18
+ render json: response_body
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module ActiveRecord
5
+ module ColumnInference
6
+ TYPE_MAP = {
7
+ integer: :integer,
8
+ bigint: :integer,
9
+ float: :number,
10
+ decimal: :number,
11
+ boolean: :boolean,
12
+ date: :date,
13
+ datetime: :datetime,
14
+ timestamp: :datetime
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def infer(model, name)
20
+ return nil unless model.respond_to?(:columns_hash)
21
+
22
+ enum = enum_values(model, name)
23
+ return { type: :string, enum: enum } if enum
24
+
25
+ column = model.columns_hash[name.to_s]
26
+ return nil if column.nil?
27
+
28
+ { type: TYPE_MAP.fetch(column.type, :string), enum: nil }
29
+ end
30
+
31
+ def enum_values(model, name)
32
+ return nil unless model.respond_to?(:defined_enums)
33
+
34
+ values = model.defined_enums[name.to_s]
35
+ values && values.keys
36
+ end
37
+
38
+ def to_proc
39
+ method(:infer).to_proc
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module ActiveRecord
5
+ class Source < Ports::Source
6
+ DEFAULT_PER_PAGE = 25
7
+
8
+ attr_reader :order_whitelist
9
+
10
+ def self.supports?(model)
11
+ model.respond_to?(:all) && model.respond_to?(:arel_table) && model.respond_to?(:columns_hash)
12
+ end
13
+
14
+ def initialize(base, order_whitelist: [])
15
+ super(base)
16
+ @order_whitelist = order_whitelist.map(&:to_s)
17
+ end
18
+
19
+ def fetch(filters:, scope: nil, page: nil, per_page: nil, order: nil)
20
+ relation = apply_scope(resolve_base, scope)
21
+ relation = apply_filters(relation, filters)
22
+ relation = apply_order(relation, order)
23
+
24
+ total = relation.count
25
+ page = normalize_page(page)
26
+ per_page = normalize_per_page(per_page)
27
+
28
+ Page.new(
29
+ records: relation.offset((page - 1) * per_page).limit(per_page).to_a,
30
+ total: total,
31
+ page: page,
32
+ per_page: per_page
33
+ )
34
+ end
35
+
36
+ def find(id, scope: nil)
37
+ apply_scope(resolve_base, scope).find_by(id: id)
38
+ end
39
+
40
+ private
41
+
42
+ def resolve_base
43
+ resolved = base.respond_to?(:call) ? base.call : base
44
+ resolved.respond_to?(:all) ? resolved.all : resolved
45
+ end
46
+
47
+ def apply_scope(relation, scope)
48
+ scope.nil? ? relation : relation.merge(scope)
49
+ end
50
+
51
+ def apply_filters(relation, filters)
52
+ (filters || {}).reduce(relation) do |acc, (argument, value)|
53
+ apply_filter(acc, argument, value)
54
+ end
55
+ end
56
+
57
+ def apply_filter(relation, argument, value)
58
+ return relation if value.nil?
59
+
60
+ target = (argument.filter_target || argument.name).to_s
61
+
62
+ case argument.filter_kind
63
+ when :eq then relation.where(target => value)
64
+ when :match then apply_match(relation, target, value)
65
+ when :range_from then relation.where(arel_column(relation, target).gteq(value))
66
+ when :range_to then relation.where(arel_column(relation, target).lteq(value))
67
+ when :scope then value ? relation.public_send(target) : relation
68
+ else relation
69
+ end
70
+ end
71
+
72
+ def apply_match(relation, target, value)
73
+ pattern = "%#{escape_like(relation, value.to_s)}%"
74
+ relation.where(arel_column(relation, target).matches(pattern, nil, false))
75
+ end
76
+
77
+ def arel_column(relation, target)
78
+ relation.klass.arel_table[target]
79
+ end
80
+
81
+ def escape_like(relation, value)
82
+ model = relation.klass
83
+ model.respond_to?(:sanitize_sql_like) ? model.sanitize_sql_like(value) : value
84
+ end
85
+
86
+ def apply_order(relation, order)
87
+ return relation if order.nil? || order.to_s.empty?
88
+
89
+ raw = order.to_s
90
+ desc = raw.start_with?("-")
91
+ column = desc ? raw[1..] : raw
92
+ return relation unless order_whitelist.include?(column)
93
+
94
+ relation.order(column => desc ? :desc : :asc)
95
+ end
96
+
97
+ def normalize_page(page)
98
+ value = page.to_i
99
+ value < 1 ? 1 : value
100
+ end
101
+
102
+ def normalize_per_page(per_page)
103
+ value = per_page.to_i
104
+ value < 1 ? DEFAULT_PER_PAGE : value
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcpable"
4
+ require "mcpable/active_record/column_inference"
5
+ require "mcpable/active_record/source"
6
+
7
+ Mcpable::Dsl::ResourceBuilder.type_inferrer = lambda do |model, name|
8
+ Mcpable::ActiveRecord::ColumnInference.infer(model, name)
9
+ end
10
+
11
+ Mcpable::Dsl::ResourceBuilder.source_factory = lambda do |model, order_whitelist:|
12
+ next nil unless Mcpable::ActiveRecord::Source.supports?(model)
13
+
14
+ Mcpable::ActiveRecord::Source.new(model, order_whitelist: order_whitelist)
15
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ Argument = Data.define(
5
+ :name,
6
+ :type,
7
+ :required,
8
+ :description,
9
+ :enum,
10
+ :filter
11
+ ) do
12
+ def self.build(name, type:, required: false, description: nil, enum: nil, filter: nil)
13
+ new(
14
+ name: name.to_sym,
15
+ type: type.to_sym,
16
+ required: required,
17
+ description: description,
18
+ enum: enum,
19
+ filter: filter
20
+ )
21
+ end
22
+
23
+ def filter? = !filter.nil?
24
+
25
+ def filter_kind = filter && filter[:kind]
26
+
27
+ def filter_target = filter && filter[:target]
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ Definition = Data.define(
5
+ :name,
6
+ :description,
7
+ :arguments,
8
+ :handler,
9
+ :annotations,
10
+ :profiles,
11
+ :metadata
12
+ ) do
13
+ def self.build(name:, description: nil, arguments: [], handler: nil, annotations: {},
14
+ profiles: [:default], metadata: {})
15
+ new(
16
+ name: name.to_s,
17
+ description: description,
18
+ arguments: arguments,
19
+ handler: handler,
20
+ annotations: annotations,
21
+ profiles: profiles,
22
+ metadata: metadata
23
+ )
24
+ end
25
+
26
+ def read_only? = annotations.fetch(:read_only, true)
27
+
28
+ def destructive? = annotations.fetch(:destructive, false)
29
+
30
+ def open_world? = annotations.fetch(:open_world, false)
31
+
32
+ def profile?(profile) = profiles.include?(profile)
33
+
34
+ def argument(name) = arguments.find { |a| a.name == name.to_sym }
35
+ end
36
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Dsl
5
+ class ResourceBuilder
6
+ class << self
7
+ attr_accessor :type_inferrer, :source_factory
8
+ end
9
+
10
+ DEFAULT_PER_PAGE = 25
11
+ SUPPORTED_ACTIONS = %i[list show].freeze
12
+
13
+ def initialize(target)
14
+ @target = target
15
+ @base_name = Naming.underscore(target.name.to_s.split("::").last)
16
+ @description = nil
17
+ @attributes = []
18
+ @conditional_attributes = []
19
+ @filters = []
20
+ @source = nil
21
+ @policy = nil
22
+ @actions = %i[list show]
23
+ @order_whitelist = nil
24
+ @per_page = DEFAULT_PER_PAGE
25
+ @profiles = [:default]
26
+ @annotations = { read_only: true }
27
+ end
28
+
29
+ def name(value) = @base_name = Naming.underscore(value.to_s)
30
+
31
+ def description(value) = @description = value
32
+
33
+ def attributes(*values) = @attributes = values.flatten.map(&:to_sym)
34
+
35
+ def attribute(name, **options)
36
+ condition = options[:if]
37
+ return @attributes |= [name.to_sym] if condition.nil?
38
+
39
+ @conditional_attributes << { name: name.to_sym, condition: condition }
40
+ end
41
+
42
+ def filter(name, type: nil, match: nil, range: false, required: false, description: nil,
43
+ enum: nil, scope: false)
44
+ @filters << {
45
+ name: name.to_sym,
46
+ type: type,
47
+ match: match,
48
+ range: range,
49
+ required: required,
50
+ description: description,
51
+ enum: enum,
52
+ scope: scope
53
+ }
54
+ end
55
+
56
+ def source(value) = @source = value
57
+
58
+ def order_whitelist(*values) = @order_whitelist = values.flatten.map(&:to_sym)
59
+
60
+ def policy(value) = @policy = value
61
+
62
+ def actions(*values) = @actions = values.flatten.map(&:to_sym)
63
+
64
+ def default_page_size(value) = @per_page = Integer(value)
65
+
66
+ def profiles(*values) = @profiles = values.flatten.map(&:to_sym)
67
+
68
+ def annotations(**values) = @annotations = @annotations.merge(values)
69
+
70
+ def compile
71
+ validate_actions!
72
+ validate_conditional_attributes!
73
+
74
+ @source ||= build_default_source
75
+ raise ArgumentError, "#{@target} needs a source" if @source.nil?
76
+
77
+ definitions = []
78
+ definitions << compile_list if @actions.include?(:list)
79
+ definitions << compile_show if @actions.include?(:show)
80
+ definitions
81
+ end
82
+
83
+ private
84
+
85
+ def validate_conditional_attributes!
86
+ hidden = @conditional_attributes.map { |a| a[:name] }
87
+ return if hidden.empty?
88
+
89
+ leaked = @filters.map { |f| f[:name] } & hidden
90
+ return if leaked.empty?
91
+
92
+ raise ArgumentError,
93
+ "#{@target} filters on conditionally visible attributes: #{leaked.join(', ')} " \
94
+ "(a filter would expose them to callers that cannot read them)"
95
+ end
96
+
97
+ def validate_actions!
98
+ raise ArgumentError, "#{@target} declares no actions" if @actions.empty?
99
+
100
+ unsupported = @actions - SUPPORTED_ACTIONS
101
+ return if unsupported.empty?
102
+
103
+ raise ArgumentError,
104
+ "#{@target} declares unsupported actions: #{unsupported.join(', ')} " \
105
+ "(supported: #{SUPPORTED_ACTIONS.join(', ')}; write actions are not implemented yet)"
106
+ end
107
+
108
+ def build_default_source
109
+ factory = self.class.source_factory
110
+ factory&.call(@target, order_whitelist: @order_whitelist || @attributes)
111
+ end
112
+
113
+ def list_name = "#{Naming.pluralize(@base_name)}_list"
114
+
115
+ def show_name = "#{Naming.pluralize(@base_name)}_show"
116
+
117
+ def compile_list
118
+ arguments = list_arguments
119
+ attributes = @attributes
120
+ conditional = @conditional_attributes
121
+ source_ref = @source
122
+ per_page = @per_page
123
+
124
+ handler = lambda do |ctx|
125
+ visible = ResourceBuilder.visible_attributes(attributes, conditional, ctx)
126
+ source = ResourceBuilder.resolve_source(source_ref)
127
+ filters = arguments.select(&:filter?).to_h { |a| [a, ctx.args[a.name]] }
128
+ page = source.fetch(
129
+ filters: filters,
130
+ scope: ctx.scope,
131
+ page: ctx.args[:page] || 1,
132
+ per_page: ctx.args[:per_page] || per_page,
133
+ order: ctx.args[:order]
134
+ )
135
+ Result.ok(
136
+ records: page.records.map { |r| ResourceBuilder.serialize(r, visible) },
137
+ total: page.total,
138
+ page: page.page,
139
+ per_page: page.per_page
140
+ )
141
+ end
142
+
143
+ Definition.build(
144
+ name: list_name,
145
+ description: @description,
146
+ arguments: arguments,
147
+ handler: handler,
148
+ annotations: @annotations,
149
+ profiles: @profiles,
150
+ metadata: {
151
+ model: @target,
152
+ action: :list,
153
+ policy: @policy,
154
+ per_page: @per_page,
155
+ paginated: true
156
+ }
157
+ )
158
+ end
159
+
160
+ def compile_show
161
+ attributes = @attributes
162
+ conditional = @conditional_attributes
163
+ source_ref = @source
164
+
165
+ handler = lambda do |ctx|
166
+ source = ResourceBuilder.resolve_source(source_ref)
167
+ record = source.find(ctx.args[:id], scope: ctx.scope)
168
+ next Result.fail("not found") if record.nil?
169
+
170
+ visible = ResourceBuilder.visible_attributes(attributes, conditional, ctx)
171
+ Result.ok(ResourceBuilder.serialize(record, visible))
172
+ end
173
+
174
+ Definition.build(
175
+ name: show_name,
176
+ description: @description,
177
+ arguments: [Argument.build(:id, type: :integer, required: true, description: "Record id.")],
178
+ handler: handler,
179
+ annotations: @annotations,
180
+ profiles: @profiles,
181
+ metadata: {
182
+ model: @target,
183
+ action: :show,
184
+ policy: @policy
185
+ }
186
+ )
187
+ end
188
+
189
+ def list_arguments
190
+ @filters.flat_map { |spec| expand_filter(spec) }
191
+ end
192
+
193
+ def expand_filter(spec)
194
+ type, enum = resolve_type(spec)
195
+
196
+ if spec[:range]
197
+ [
198
+ build_argument("#{spec[:name]}_from", spec, type, enum, :range_from),
199
+ build_argument("#{spec[:name]}_to", spec, type, enum, :range_to)
200
+ ]
201
+ elsif spec[:scope]
202
+ [build_argument(spec[:name], spec, type, enum, :scope)]
203
+ elsif spec[:match] == :partial
204
+ [build_argument(spec[:name], spec, type, enum, :match)]
205
+ else
206
+ [build_argument(spec[:name], spec, type, enum, :eq)]
207
+ end
208
+ end
209
+
210
+ def build_argument(name, spec, type, enum, kind)
211
+ Argument.build(
212
+ name,
213
+ type: type,
214
+ required: kind == :eq ? spec[:required] : false,
215
+ description: spec[:description],
216
+ enum: enum,
217
+ filter: { kind: kind, target: spec[:name] }
218
+ )
219
+ end
220
+
221
+ def resolve_type(spec)
222
+ return [spec[:type].to_sym, spec[:enum]] if spec[:type]
223
+
224
+ inferrer = self.class.type_inferrer
225
+ inferred = inferrer&.call(@target, spec[:name])
226
+ raise ArgumentError, "filter :#{spec[:name]} needs type:" if inferred.nil?
227
+
228
+ [inferred[:type], spec[:enum] || inferred[:enum]]
229
+ end
230
+
231
+ def self.resolve_source(source_ref)
232
+ return source_ref if source_ref.respond_to?(:fetch)
233
+
234
+ source_ref.respond_to?(:call) ? source_ref.call : source_ref
235
+ end
236
+
237
+ def self.visible_attributes(base, conditional, ctx)
238
+ return base if conditional.empty?
239
+
240
+ base + conditional.select { |a| visible?(a[:condition], ctx) }.map { |a| a[:name] }
241
+ end
242
+
243
+ def self.visible?(condition, ctx)
244
+ case condition
245
+ when Symbol then ctx.user.respond_to?(condition) && !!ctx.user.public_send(condition)
246
+ else condition.arity.zero? ? !!condition.call : !!condition.call(ctx)
247
+ end
248
+ end
249
+
250
+ def self.serialize(record, attributes)
251
+ attributes.each_with_object({}) do |attribute, out|
252
+ out[attribute] = read(record, attribute)
253
+ end
254
+ end
255
+
256
+ def self.read(record, attribute)
257
+ return record.public_send(attribute) if record.respond_to?(attribute)
258
+ return record[attribute] if record.respond_to?(:[])
259
+
260
+ nil
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Dsl
5
+ class ToolBuilder
6
+ def initialize(target)
7
+ @target = target
8
+ @name = default_name(target)
9
+ @description = nil
10
+ @arguments = []
11
+ @annotations = {}
12
+ @profiles = [:default]
13
+ @metadata = {}
14
+ end
15
+
16
+ def name(value) = @name = value.to_s
17
+
18
+ def description(value) = @description = value
19
+
20
+ def argument(name, type, required: false, description: nil, enum: nil)
21
+ @arguments << Argument.build(
22
+ name,
23
+ type: type,
24
+ required: required,
25
+ description: description,
26
+ enum: enum
27
+ )
28
+ end
29
+
30
+ def annotations(**values) = @annotations = @annotations.merge(values)
31
+
32
+ def profiles(*values) = @profiles = values.flatten.map(&:to_sym)
33
+
34
+ def metadata(**values) = @metadata = @metadata.merge(values)
35
+
36
+ def compile
37
+ target = @target
38
+ argument_names = @arguments.map(&:name)
39
+
40
+ handler = lambda do |ctx|
41
+ call_args = ctx.args.slice(*argument_names)
42
+ instance = target.new
43
+ instance.mcp_call = ctx if instance.respond_to?(:mcp_call=)
44
+ value = instance.call(**call_args)
45
+ value.is_a?(Result) ? value : Result.ok(value)
46
+ end
47
+
48
+ Definition.build(
49
+ name: @name,
50
+ description: @description,
51
+ arguments: @arguments,
52
+ handler: handler,
53
+ annotations: @annotations,
54
+ profiles: @profiles,
55
+ metadata: @metadata
56
+ )
57
+ end
58
+
59
+ private
60
+
61
+ def default_name(target)
62
+ Naming.underscore(target.name.to_s.gsub("::", "_"))
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Dsl
5
+ module_function
6
+
7
+ def evaluate(builder, &block)
8
+ return builder if block.nil?
9
+
10
+ if block.arity == 1
11
+ block.call(builder)
12
+ else
13
+ builder.instance_eval(&block)
14
+ end
15
+ builder
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Naming
5
+ module_function
6
+
7
+ def underscore(value)
8
+ value
9
+ .to_s
10
+ .gsub("::", "_")
11
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
12
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
13
+ .tr("-", "_")
14
+ .downcase
15
+ end
16
+
17
+ def pluralize(value)
18
+ word = value.to_s
19
+ case word
20
+ when /(?:s|x|z|ch|sh)\z/ then "#{word}es"
21
+ when /[^aeiou]y\z/ then "#{word[0..-2]}ies"
22
+ when /f\z/ then "#{word[0..-2]}ves"
23
+ when /fe\z/ then "#{word[0..-3]}ves"
24
+ else "#{word}s"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ class Pipeline
5
+ HANDLER = lambda do |ctx|
6
+ handler = ctx.definition.handler
7
+ raise Error, "definition #{ctx.definition.name} has no handler" if handler.nil?
8
+
9
+ handler.call(ctx)
10
+ end
11
+
12
+ attr_reader :middlewares
13
+
14
+ def initialize(config: nil)
15
+ @middlewares = []
16
+ @config = config
17
+ end
18
+
19
+ def use(mw, *args)
20
+ @middlewares << [mw, args]
21
+ self
22
+ end
23
+
24
+ def clear
25
+ @middlewares = []
26
+ self
27
+ end
28
+
29
+ def call(ctx)
30
+ result = build_stack.call(ctx)
31
+ result.is_a?(Result) ? result : Result.fail("invalid result")
32
+ rescue StandardError => e
33
+ mapped = error_mapper.call(e)
34
+ mapped.is_a?(Result) ? mapped : Result.fail("invalid result")
35
+ end
36
+
37
+ private
38
+
39
+ def error_mapper = (@config || Mcpable.config).error_mapper
40
+
41
+ def build_stack
42
+ @middlewares.reverse.reduce(HANDLER) do |app, (mw, args)|
43
+ mw.new(app, *args)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpable
4
+ module Ports
5
+ class Middleware
6
+ attr_reader :app
7
+
8
+ def initialize(app, *args)
9
+ @app = app
10
+ @args = args
11
+ end
12
+
13
+ def call(ctx)
14
+ @app.call(ctx)
15
+ end
16
+ end
17
+ end
18
+ end